Compare commits

..
29 Commits
Author SHA1 Message Date
Vitor Pamplona 144b0a7512 v0.9.2:
- Fixes bug when tagging new users on reply
- Fixes bug on likes not being shown.
- Fixes alignment of the time in the home feed.
2023-01-19 21:48:15 -05:00
Vitor Pamplona 359eb0b000 Downloading just the last metadata for users being displayed in the screen. 2023-01-19 21:40:24 -05:00
Vitor Pamplona e47476129f BugFix for the position of time in the feed. 2023-01-19 21:39:43 -05:00
Vitor Pamplona 0017845de2 Fixing the bug that wasn't showing likes 2023-01-19 21:35:57 -05:00
Vitor Pamplona ab0bab0c16 Solving User tagging bug 2023-01-19 21:35:36 -05:00
Vitor Pamplona b9b1da1f04 v0.9.1 2023-01-19 18:26:12 -05:00
Vitor Pamplona 0e22a25d3d Removing double call refresh 2023-01-19 18:14:56 -05:00
Vitor Pamplona 8b61dc09d0 Showing likes for Public Chat events. 2023-01-19 18:14:42 -05:00
Vitor Pamplona e1fa46290e Correctly shows Chat notifications in the Notifications Tab. 2023-01-19 17:58:35 -05:00
Vitor Pamplona ec5f510264 Shows replies in chat 2023-01-19 17:58:01 -05:00
Vitor Pamplona 2d46b17493 Block like breaks in the User Information block of the layout. 2023-01-19 17:57:43 -05:00
Vitor Pamplona 99f478d891 Fixing large usernames breaking the layout 2023-01-19 17:56:44 -05:00
Vitor Pamplona 451137e8fa handling co-routines with viewModel Scope 2023-01-19 17:56:22 -05:00
Vitor Pamplona fc88c2867d Adding replying information for notes in a channel 2023-01-19 17:55:57 -05:00
Vitor Pamplona 98704bc43d remove transformations 2023-01-19 17:55:37 -05:00
Vitor Pamplona c5c8ffc70f Avoid using trasnformations. They won't work in this application (base object never changes) 2023-01-19 17:55:25 -05:00
Vitor Pamplona 94a228d78a Simplifying the name of the "Add Image from Gallery" button 2023-01-19 17:54:30 -05:00
Vitor Pamplona 625bbaf35c Marking relays with failure as not connected 2023-01-19 17:54:07 -05:00
Vitor Pamplona dd35e01f8a Fixing null pointer exceptions 2023-01-19 17:53:51 -05:00
Vitor Pamplona c1d46dcc2f Using short-term co-routines 2023-01-19 17:52:32 -05:00
Vitor Pamplona 85c66279b2 Removing unnecessary code 2023-01-19 17:51:35 -05:00
Vitor Pamplona a18c5b975f Solving some of the Out of Memory errors. 2023-01-19 17:50:29 -05:00
Vitor Pamplona 4224ebe429 Version 0.9 2023-01-19 08:44:41 -05:00
Vitor Pamplona 57636607d2 Version 0.9 2023-01-19 08:42:36 -05:00
Vitor Pamplona 8771584deb Moving away from handlers and into coroutines. 2023-01-19 08:41:48 -05:00
Vitor Pamplona 9d52180550 Join/Leave Channels, Channel search. 2023-01-19 08:03:01 -05:00
Vitor Pamplona 75219f0f2d Add Channel 2023-01-18 22:00:32 -05:00
Vitor Pamplona 0e3b007730 Fixing Public chat's event order. 2023-01-18 18:59:18 -05:00
Vitor Pamplona ce4e3e6589 Fixing #1, unable to see bio in the Profile Screen 2023-01-18 17:22:01 -05:00
51 changed files with 959 additions and 246 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 10
versionName "0.8.2"
versionCode 13
versionName "0.9.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
+1
View File
@@ -14,6 +14,7 @@
android:enableOnBackInvokedCallback="true"
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
android:largeHeap="true"
tools:targetApi="31">
<activity
android:name=".ui.MainActivity"
@@ -2,10 +2,14 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
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.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.Contact
import nostr.postr.Persona
import nostr.postr.Utils
@@ -15,7 +19,8 @@ import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
val DefaultChannels = setOf(
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb" // -> Anigma's Nostr
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
)
class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet()) {
@@ -90,36 +95,20 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
}
}
fun sendPost(message: String, originalNote: Note?, modifiedMentions: List<User>?) {
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) {
if (!isWriteable()) return
val replyToEvent = originalNote?.event
if (replyToEvent is TextNoteEvent) {
val modifiedMentionsHex = modifiedMentions?.map { it.pubkeyHex }?.toSet() ?: emptySet()
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex())
val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex()).plus(modifiedMentionsHex).filter {
it in modifiedMentionsHex
}
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesTo,
mentions = mentions,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
} else {
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = null,
mentions = modifiedMentions?.map { it.pubkeyHex } ?: emptyList(),
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null) {
@@ -149,6 +138,53 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
LocalCache.consume(signedEvent)
}
fun sendCreateNewChannel(name: String, about: String, picture: String, accountStateViewModel: AccountStateViewModel) {
if (!isWriteable()) return
val metadata = ChannelCreateEvent.ChannelData(
name, about, picture
)
val event = ChannelCreateEvent.create(
channelInfo = metadata,
privateKey = loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id.toHex(), accountStateViewModel)
}
fun joinChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
followingChannels.add(idHex)
accountStateViewModel.saveToEncryptedStorage(this)
}
fun leaveChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
followingChannels.remove(idHex)
accountStateViewModel.saveToEncryptedStorage(this)
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
if (!isWriteable()) return
val metadata = ChannelCreateEvent.ChannelData(
name, about, picture
)
val event = ChannelMetadataEvent.create(
newChannelInfo = metadata,
originalChannelIdHex = channel.idHex,
privateKey = loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
followingChannels.add(event.id.toHex())
}
fun decryptContent(note: Note): String? {
val event = note.event
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
@@ -13,6 +13,7 @@ class Channel(val id: ByteArray) {
val idHex = id.toHexKey()
val idDisplayHex = id.toShortenHex()
var creator: User? = null
var info = ChannelCreateEvent.ChannelData(null, null, null)
var updatedMetadataAt: Long = 0;
@@ -28,9 +29,10 @@ class Channel(val id: ByteArray) {
}
}
fun updateChannelInfo(channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
info = channelInfo
updatedMetadataAt = updatedAt
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.refresh()
}
@@ -40,6 +42,11 @@ class Channel(val id: ByteArray) {
return info.picture ?: "https://robohash.org/${idHex}.png"
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
}
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
@@ -245,13 +245,23 @@ object LocalCache {
}
fun consume(event: ChannelCreateEvent) {
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event
val oldChannel = getOrCreateChannel(event.id.toHex())
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
oldChannel.updateChannelInfo(event.channelInfo, event.createdAt)
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
val note = oldChannel.getOrCreateNote(event.id.toHex())
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
}
} else {
// older data, does nothing
}
refreshObservers()
}
fun consume(event: ChannelMetadataEvent) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
@@ -259,11 +269,20 @@ object LocalCache {
// new event
val oldChannel = getOrCreateChannel(event.channel)
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
oldChannel.updateChannelInfo(event.channelInfo, event.createdAt)
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
val note = oldChannel.getOrCreateNote(event.id.toHex())
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
}
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
refreshObservers()
}
fun consume(event: ChannelMessageEvent) {
@@ -278,7 +297,7 @@ object LocalCache {
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())
val replyTo = Collections.synchronizedList(event.replyTos.map { channel.getOrCreateNote(it) }.toMutableList())
note.channel = channel
note.loadEvent(event, author, mentions, replyTo)
@@ -317,6 +336,22 @@ object LocalCache {
}
}
fun findNotesStartingWith(text: String): List<Note> {
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content?.contains(text) ?: false)
|| it.idHex.startsWith(text, true)
|| it.id.toNote().startsWith(text, true)
}
}
fun findChannelsStartingWith(text: String): List<Channel> {
return channels.values.filter {
it.anyNameStartsWith(text)
|| it.idHex.startsWith(text, true)
|| it.id.toNote().startsWith(text, true)
}
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
@@ -13,6 +13,7 @@ import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.events.Event
@@ -99,17 +100,18 @@ class Note(val idHex: String) {
val live: NoteLiveData = NoteLiveData(this)
// Refreshes observers in batches.
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}, 100)
}
}
}
@@ -120,18 +122,12 @@ class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
override fun onActive() {
super.onActive()
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
NostrSingleEventDataSource.add(note.idHex)
}
NostrSingleEventDataSource.add(note.idHex)
}
override fun onInactive() {
super.onInactive()
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
NostrSingleEventDataSource.remove(note.idHex)
}
NostrSingleEventDataSource.remove(note.idHex)
}
}
@@ -22,7 +22,7 @@ object UrlCachedPreviewer {
return
}
val scope = CoroutineScope(Job() + Dispatchers.Main)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
@@ -11,6 +11,7 @@ import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
@@ -112,17 +113,18 @@ class User(val pubkey: ByteArray) {
val live: UserLiveData = UserLiveData(this)
// Refreshes observers in batches.
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}, 100)
}
}
}
@@ -152,24 +154,18 @@ class UserMetadata {
}
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
fun refresh() {
postValue(UserState(user))
}
override fun onActive() {
super.onActive()
scope.launch {
NostrSingleUserDataSource.add(user.pubkeyHex)
}
NostrSingleUserDataSource.add(user.pubkeyHex)
}
override fun onInactive() {
super.onInactive()
scope.launch {
NostrSingleUserDataSource.remove(user.pubkeyHex)
}
NostrSingleUserDataSource.remove(user.pubkeyHex)
}
}
@@ -41,7 +41,7 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
limit = 3
)
}
@@ -59,7 +59,7 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event!!.createdAt }
.sortedBy { it.event?.createdAt }
.reversed()
}
@@ -10,22 +10,28 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
fun loadMessagesBetween(channelId: String) {
channel = LocalCache.channels[channelId]
resetFilters()
}
fun createMessagesToChannelFilter() = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(channel?.idHex).filterNotNull()),
limit = 100
)
fun createMessagesToChannelFilter(): JsonFilter? {
if (channel != null) {
return JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
)
}
return null
}
val messagesChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.sortedBy { it.event!!.createdAt } ?: emptyList()
return channel?.notes?.values?.sortedBy { it.event?.createdAt } ?: emptyList()
}
override fun updateChannelFilters() {
messagesChannel.filter = listOf(createMessagesToChannelFilter()).ifEmpty { null }
messagesChannel.filter = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
}
}
@@ -35,7 +35,7 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser]
return messages?.sortedBy { it.event!!.createdAt } ?: emptyList()
return messages?.sortedBy { it.event?.createdAt } ?: emptyList()
}
override fun updateChannelFilters() {
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -21,6 +22,11 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
authors = listOf(account.userProfile().pubkeyHex)
)
fun createChannelsCreatedbyMeFilter() = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
fun createMyChannelsFilter() = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
@@ -53,6 +59,8 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
val myChannelsInfoChannel = requestNewChannel()
val myChannelsMessagesChannel = requestNewChannel()
val myChannelsCreatedbyMeChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
@@ -66,6 +74,12 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
val channelsCreatedByMe = LocalCache.channels.values.filter {
it.creator == account.userProfile()
}.map {
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
}
@@ -75,5 +89,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
myChannelsChannel.filter = listOf(createMyChannelsFilter()).ifEmpty { null }
myChannelsInfoChannel.filter = createLastChannelInfoFilter().ifEmpty { null }
myChannelsMessagesChannel.filter = createLastMessageOfEachChannelFilter().ifEmpty { null }
//myChannelsCreatedbyMeChannel.filter = listOf(createChannelsCreatedbyMeFilter()).ifEmpty { null }
}
}
@@ -20,6 +20,7 @@ import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
@@ -114,12 +115,11 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
}
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
val returningList = feed().take(100)
// prepare previews
val scope = CoroutineScope(Job() + Dispatchers.Main)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
loadPreviews(returningList)
}
@@ -148,20 +148,28 @@ abstract class NostrDataSource<T>(val debugName: String) {
channelIds.remove(channel.id)
}
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateFilters() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
resetFilters()
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(200)
resetFiltersSuspend()
handlerWaiting = false
}, 200)
}
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
resetFiltersSuspend()
}
}
fun resetFiltersSuspend() {
// saves the channels that are currently active
val activeChannels = channels.filter { it.filter != null }
// saves the current content to only update if it changes
@@ -17,7 +17,7 @@ object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
.filter {
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
}
.sortedBy { it.event!!.createdAt }
.sortedBy { it.event?.createdAt }
.reversed()
override fun updateChannelFilters() {
@@ -76,7 +76,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event!!.createdAt }
.sortedBy { it.event?.createdAt }
.reversed()
}
@@ -20,7 +20,7 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
set.filter { it.event != null }
}
return filtered.sortedBy { it.event!!.createdAt }.reversed()
return filtered.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
@@ -12,7 +12,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var eventsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
val reactionsToWatch = eventsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
return null
@@ -38,7 +38,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
.map { it.idHex.substring(0, 8) }
.map { it.idHex }
if (interestedEvents.isEmpty()) {
return null
@@ -12,7 +12,7 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
val reactionsToWatch = eventsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
return null
@@ -30,14 +30,14 @@ class ChannelMetadataEvent (
companion object {
const val kind = 41
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannel: ChannelCreateEvent, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, 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 tags = listOf( listOf("e", originalChannelIdHex, "", "root") )
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
@@ -30,6 +30,7 @@ class Relay(
}
fun requestAndWatch() {
println("Connecting with ${url}")
val request = Request.Builder().url(url).build()
val listener = object : WebSocketListener() {
@@ -87,6 +88,9 @@ class Relay(
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
// Failures disconnect the relay.
socket = null
//println("Relay onFailure ${url}, ${response?.message}")
t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
@@ -99,6 +103,7 @@ class Relay(
fun disconnect() {
//httpClient.dispatcher.executorService.shutdown()
socket?.close(1000, "Normal close")
socket = null
}
fun sendFilter(requestId: String) {
@@ -3,6 +3,10 @@ package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.Event
/**
@@ -108,7 +112,10 @@ object RelayPool: Relay.Listener {
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
live.refresh()
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
live.refresh()
}
}
}
@@ -0,0 +1,123 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel: AccountStateViewModel, channel: Channel? = null) {
val postViewModel: NewChannelViewModel = viewModel()
postViewModel.load(account, channel, accountStateViewModel)
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false
)
) {
Surface(
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
onClose()
})
PostButton(
onPost = {
postViewModel.create()
onClose()
},
postViewModel.channelName.value.text.isNotBlank()
)
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Channel Name") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelName.value,
onValueChange = { postViewModel.channelName.value = it },
placeholder = {
Text(
text = "My Awesome Group",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Picture Url") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelPicture.value,
onValueChange = { postViewModel.channelPicture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Description") },
modifier = Modifier.fillMaxWidth().height(100.dp),
value = postViewModel.channelDescription.value,
onValueChange = { postViewModel.channelDescription.value = it },
placeholder = {
Text(
text = "About us.. ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
maxLines = 10
)
}
}
}
}
@@ -0,0 +1,56 @@
package com.vitorpamplona.amethyst.ui.actions
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.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
class NewChannelViewModel: ViewModel() {
private var account: Account? = null
private var originalChannel: Channel? = null
private var accountStateViewModel: AccountStateViewModel? = null
val channelName = mutableStateOf(TextFieldValue())
val channelPicture = mutableStateOf(TextFieldValue())
val channelDescription = mutableStateOf(TextFieldValue())
fun load(account: Account, channel: Channel?, accountStateViewModel: AccountStateViewModel) {
this.accountStateViewModel = accountStateViewModel
this.account = account
if (channel != null) {
originalChannel = channel
channelName.value = TextFieldValue(channel.info.name ?: "")
channelPicture.value = TextFieldValue(channel.info.picture ?: "")
channelDescription.value = TextFieldValue(channel.info.about ?: "")
}
}
fun create() {
if (originalChannel == null)
this.account?.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
accountStateViewModel!!
)
else
this.account?.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
clear()
}
fun clear() {
channelName.value = TextFieldValue()
channelPicture.value = TextFieldValue()
channelDescription.value = TextFieldValue()
}
}
@@ -241,6 +241,25 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
}
}
@Composable
fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Create", color = Color.White)
}
}
@Composable
fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
@@ -38,7 +38,13 @@ class NewPostViewModel: ViewModel() {
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: mutableListOf()).plus(replyNote).toMutableList()
replyNote.author?.let { replyUser ->
this.mentions = (replyNote.mentions ?: emptyList()).plus(replyUser)
val currentMentions = replyNote.mentions ?: emptyList()
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
this.mentions = currentMentions.plus(replyUser)
}
}
}
this.account = account
@@ -89,7 +95,7 @@ class NewPostViewModel: ViewModel() {
}.joinToString(" ")
}.joinToString("\n")
account?.sendPost(newMessage, originalNote, mentions)
account?.sendPost(newMessage, replyTos, mentions)
message = TextFieldValue("")
urlPreview = null
}
@@ -48,14 +48,14 @@ fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
showGallerySelect = true
}
) {
Text("Add Image from Gallery")
Text("Upload Image")
}
}
}
} else {
Column {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Add Image from Gallery")
Text("Upload Image")
}
}
}
@@ -0,0 +1,53 @@
package com.vitorpamplona.amethyst.buttons
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelButton(account: Account, accountStateViewModel: AccountStateViewModel) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost)
NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel)
OutlinedButton(
onClick = { wantsToPost = true },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = "New Channel",
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}
@@ -4,16 +4,18 @@ import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel
) {
NavHost(navController, startDestination = Route.Home.route) {
Routes.forEach {
composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, navController))
composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, accountStateViewModel, navController))
}
}
}
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@@ -59,7 +60,10 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
@@ -101,6 +105,8 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
NostrUserProfileDataSource.printCounter()
NostrUserProfileFollowersDataSource.printCounter()
NostrUserProfileFollowsDataSource.printCounter()
println("AAA: " + RelayPool.connectedRelays())
}
) {
Icon(
@@ -49,7 +49,10 @@ fun DrawerContent(navController: NavHostController,
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel) {
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
Surface(
@@ -64,14 +67,18 @@ fun DrawerContent(navController: NavHostController,
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(150.dp)
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(150.dp)
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
}
@@ -111,7 +118,8 @@ fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldSt
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
modifier = Modifier
.width(100.dp).height(100.dp)
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
@@ -205,7 +213,8 @@ fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState
})
) {
Row(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 15.dp, horizontal = 25.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -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.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
@@ -25,31 +26,31 @@ sealed class Route(
val route: String,
val icon: Int,
val arguments: List<NamedNavArgument> = emptyList(),
val buildScreen: (AccountViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
) {
object Home : Route("Home", R.drawable.ic_home, buildScreen = { acc, nav -> { _ -> HomeScreen(acc, nav) } })
object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, nav -> { _ -> SearchScreen(acc, nav) }})
object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, nav -> { _ -> NotificationScreen(acc, nav) }})
object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, nav -> { _ -> ChatroomListScreen(acc, nav) }})
object Home : Route("Home", R.drawable.ic_home, buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } })
object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }})
object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }})
object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }})
object Profile : Route("User/{id}", R.drawable.ic_profile,
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
buildScreen = { acc, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) }}
buildScreen = { acc, accSt, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) }}
)
object Note : Route("Note/{id}", R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
buildScreen = { acc, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) }}
buildScreen = { acc, accSt, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) }}
)
object Room : Route("Room/{id}", R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
buildScreen = { acc, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) }}
buildScreen = { acc, accSt, 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) }}
buildScreen = { acc, accSt, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, accSt, nav) }}
)
}
@@ -23,6 +23,8 @@ 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.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -30,7 +32,10 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
if (note?.event == null) {
@@ -42,6 +47,13 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val channelState by note.channel!!.live.observeAsState()
val channel = channelState?.channel
val description = if (note.event is ChannelCreateEvent) {
"Channel created"
} else if (note.event is ChannelMetadataEvent) {
"Channel Information changed to "
} else {
note.event?.content
}
channel?.let {
ChannelName(
channelPicture = it.profilePicture(),
@@ -52,7 +64,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
)
},
channelLastTime = note.event?.createdAt,
channelLastContent = "${author?.toBestDisplayName()}: " + note.event?.content,
channelLastContent = "${author?.toBestDisplayName()}: " + description,
onClick = { navController.navigate("Channel/${it.idHex}") })
}
@@ -86,7 +98,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
}
@Composable
private fun ChannelName(
fun ChannelName(
channelPicture: String,
channelTitle: @Composable () -> Unit,
channelLastTime: Long?,
@@ -115,10 +127,13 @@ private fun ChannelName(
) {
channelTitle()
Text(
timeAgo(channelLastTime),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f)
)
channelLastTime?.let {
Text(
timeAgo(channelLastTime),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f)
)
}
}
if (channelLastContent != null)
@@ -35,7 +35,11 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
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.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -44,11 +48,14 @@ val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
fun ChatroomMessageCompose(baseNote: Note, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
var popupExpanded by remember { mutableStateOf(false) }
@@ -85,7 +92,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
) {
Row(
horizontalArrangement = alignment,
modifier = Modifier.fillMaxWidth(0.85f)
modifier = Modifier.fillMaxWidth(if (innerQuote) 1f else 0.85f)
) {
Surface(
@@ -101,7 +108,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
) {
if (author != accountUser && note.event is ChannelMessageEvent) {
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment,
@@ -133,25 +140,53 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
val eventContent = accountViewModel.decrypt(note)
val replyTo = note.replyTo
if (replyTo != null && replyTo.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.mapIndexed { index, note ->
ChatroomMessageCompose(
note,
innerQuote = true,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
if (eventContent != null)
RichTextViewer(
eventContent,
note.event?.tags,
note,
accountViewModel,
navController
)
else
RichTextViewer(
"Could Not decrypt the message",
note.event?.tags,
note,
accountViewModel,
navController
)
Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event
if (event is ChannelCreateEvent) {
Text(text = "${note.author?.toBestDisplayName()} created " +
"${event.channelInfo.name ?: ""} with " +
"description of '${event.channelInfo.about ?: ""}', " +
"and picture '${event.channelInfo.picture ?: ""}'")
} else if (event is ChannelMetadataEvent) {
Text(text = "${note.author?.toBestDisplayName()} changed " +
"chat name to '${event.channelInfo.name ?: ""}', " +
"description to '${event.channelInfo.about ?: ""}', " +
"and picture to '${event.channelInfo.picture ?: ""}'")
} else {
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
RichTextViewer(
eventContent,
note.event?.tags,
note,
accountViewModel,
navController
)
else
RichTextViewer(
"Could Not decrypt the message",
note.event?.tags,
note,
accountViewModel,
navController
)
}
}
Row(
@@ -31,7 +31,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
val noteState by likeSetCard.note.live.observeAsState()
val note = noteState?.note
if (note?.event == null) {
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
Column(modifier = modifier) {
@@ -34,6 +34,7 @@ import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
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.ui.components.RichTextViewer
@@ -50,14 +51,25 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
var popupExpanded by remember { mutableStateOf(false) }
if (note?.event == null) {
BlankNote(modifier, isInnerNote)
BlankNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
Column(modifier =
modifier.combinedClickable(
onClick = { navController.navigate("Note/${note.idHex}") },
onClick = {
if (note.event !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}")
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true },
)
) {
@@ -110,12 +122,13 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author)
UsernameDisplay(author, Modifier.weight(1f))
if (note.event !is RepostEvent) {
Text(
timeAgo(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
} else {
Text(
@@ -128,6 +141,10 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
if (note.event is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
ReplyInformation(note.replyTo, note.mentions, navController)
} else if (note.event is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
note.channel?.let {
ReplyInformationChannel(note.replyTo, note.mentions, it, navController)
}
}
if (note.event is ReactionEvent || note.event is RepostEvent) {
@@ -155,7 +172,9 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
ReactionsRowState(note, accountViewModel)
if (note.event !is ChannelMessageEvent) {
ReactionsRowState(note, accountViewModel)
}
Divider(
modifier = Modifier.padding(top = 10.dp),
@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -40,6 +41,80 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
mentions.toSet().forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
ClickableText(
AnnotatedString("${prefix}@${myUser.toBestDisplayName()}"),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
)
if (idx < mentions.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < mentions.size - 1) {
Text(
" and ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
}
}
}
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
ReplyInformationChannel(replyTo, mentions, channel,
onUserTagClick = {
navController.navigate("User/${it.pubkeyHex}")
},
onChannelTagClick = {
navController.navigate("Channel/${it.idHex}")
}
)
}
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?,
mentions: List<User>?,
channel: Channel,
prefix: String = "",
onUserTagClick: (User) -> Unit,
onChannelTagClick: (Channel) -> Unit
) {
FlowRow() {
if (mentions != null && mentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
Text(
"in channel ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
ClickableText(
AnnotatedString("${channel.info.name} "),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onChannelTagClick(channel) }
)
Text(
"replying to ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
mentions.toSet().forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUser = innerUserState?.user
@@ -3,16 +3,21 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.User
@Composable
fun UsernameDisplay(user: User) {
fun UsernameDisplay(user: User, weight: Modifier = Modifier) {
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else {
Text(
@@ -22,12 +27,18 @@ fun UsernameDisplay(user: User) {
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
} else {
Text(
user.pubkeyDisplayHex,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
}
@@ -71,7 +71,6 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
NostrNotificationDataSource.account = account
NostrChatroomListDataSource.account = account
NostrChannelDataSource.start()
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
NostrHomeDataSource.start()
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -26,24 +27,28 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private var lastNotes: List<Note>? = null
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Main)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
val notes = dataSource.loadTop()
refreshSuspended()
}
}
val lastNotesCopy = lastNotes
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
val newCards = convertToCard(notes.minus(lastNotesCopy))
if (newCards.isNotEmpty()) {
lastNotes = notes
updateFeed((oldNotesState.feed + newCards).sortedBy { it.createdAt() }.reversed())
}
} else {
val cards = convertToCard(notes)
val lastNotesCopy = lastNotes
val oldNotesState = feedContent.value
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
val newCards = convertToCard(notes.minus(lastNotesCopy))
if (newCards.isNotEmpty()) {
lastNotes = notes
updateFeed(cards)
updateFeed((oldNotesState.feed + newCards).sortedBy { it.createdAt() }.reversed())
}
} else {
val cards = convertToCard(notes)
lastNotes = notes
updateFeed(cards)
}
}
@@ -76,31 +81,28 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
fun updateFeed(notes: List<Card>) {
if (notes.isEmpty()) {
_feedContent.update { CardFeedState.Empty }
} else {
_feedContent.update { CardFeedState.Loaded(notes) }
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
if (notes.isEmpty()) {
_feedContent.update { CardFeedState.Empty }
} else {
_feedContent.update { CardFeedState.Loaded(notes) }
}
}
}
fun refreshCurrentList() {
val state = feedContent.value
if (state is CardFeedState.Loaded) {
_feedContent.update { CardFeedState.Loaded(state.feed) }
}
}
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}, 100)
}
}
private val cacheListener: (LocalCacheState) -> Unit = {
@@ -7,35 +7,57 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
class NostrChatroomListFeedViewModel: FeedViewModel(NostrChatroomListDataSource)
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource)
class NostrGlobalFeedViewModel: FeedViewModel(NostrGlobalDataSource)
class NostrThreadFeedViewModel: FeedViewModel(NostrThreadDataSource)
class NostrUserProfileFeedViewModel: FeedViewModel(NostrUserProfileDataSource)
class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
viewModelScope.launch(Dispatchers.IO) {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
if (notes != oldNotesState.feed) {
updateFeed(notes)
withContext(Dispatchers.Main) {
updateFeed(notes)
}
}
} else {
updateFeed(notes)
withContext(Dispatchers.Main) {
updateFeed(notes)
}
}
}
}
@@ -48,24 +70,18 @@ class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
}
fun refreshCurrentList() {
val state = feedContent.value
if (state is FeedState.Loaded) {
_feedContent.update { FeedState.Loaded(state.feed) }
}
}
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}, 100)
}
}
private val cacheListener: (LocalCacheState) -> Unit = {
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -32,47 +33,48 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
val feedContent = _feedContent.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Main)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
val notes = dataSource.loadTop()
refreshSuspended()
}
}
val oldNotesState = feedContent.value
if (oldNotesState is UserFeedState.Loaded) {
if (notes != oldNotesState.feed) {
updateFeed(notes)
}
} else {
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
if (oldNotesState is UserFeedState.Loaded) {
if (notes != oldNotesState.feed) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
}
fun updateFeed(notes: List<User>) {
if (notes.isEmpty()) {
_feedContent.update { UserFeedState.Empty }
} else {
_feedContent.update { UserFeedState.Loaded(notes) }
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
if (notes.isEmpty()) {
_feedContent.update { UserFeedState.Empty }
} else {
_feedContent.update { UserFeedState.Loaded(notes) }
}
}
}
fun refreshCurrentList() {
val state = feedContent.value
if (state is UserFeedState.Loaded) {
_feedContent.update { UserFeedState.Loaded(state.feed) }
}
}
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}, 100)
}
}
private val cacheListener: (LocalCacheState) -> Unit = {
@@ -10,7 +10,6 @@ import com.vitorpamplona.amethyst.model.UserState
class AccountViewModel(private val account: Account): ViewModel() {
val accountLiveData: LiveData<AccountState> = Transformations.map(account.live) { it }
val userLiveData: LiveData<UserState> = Transformations.map(account.userProfile().live) { it }
fun reactTo(note: Note) {
account.reactTo(note)
@@ -9,7 +9,10 @@ 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -20,9 +23,14 @@ 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.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
@@ -32,13 +40,19 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navController: NavController) {
fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
@@ -50,7 +64,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navCon
val channelState by NostrChannelDataSource.channel!!.live.observeAsState()
val channel = channelState?.channel ?: return
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChannelDataSource ) }
val feedViewModel: NostrChannelFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
@@ -58,19 +72,24 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navCon
Column(Modifier.fillMaxHeight()) {
ChannelHeader(
channel,
accountViewModel = accountViewModel,
channel, account,
accountStateViewModel = accountStateViewModel,
navController = navController
)
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, navController)
}
//LAST ROW
Row(modifier = Modifier.padding(10.dp).fillMaxWidth(),
Row(modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -80,7 +99,9 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navCon
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.weight(1f, true).padding(end = 10.dp),
modifier = Modifier
.weight(1f, true)
.padding(end = 10.dp),
placeholder = {
Text(
text = "reply here.. ",
@@ -104,7 +125,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navCon
}
@Composable
fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, navController: NavController) {
fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel: AccountStateViewModel, navController: NavController) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
@@ -116,11 +137,14 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, navC
model = channel?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
.width(35.dp)
.height(35.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Column(modifier = Modifier
.padding(start = 10.dp)
.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel?.info?.name}",
@@ -132,12 +156,26 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, navC
Text(
"${channel?.info?.about}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
channel?.let { NoteCopyButton(it) }
channel?.let {
if (channel.creator == account.userProfile()) {
EditButton(account, it, accountStateViewModel)
} else {
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account,channel,accountStateViewModel, navController)
} else {
JoinButton(account,channel,accountStateViewModel, navController)
}
}
}
}
}
@@ -146,4 +184,82 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, navC
thickness = 0.25.dp
)
}
}
@Composable
private fun NoteCopyButton(
note: Channel
) {
val clipboardManager = LocalClipboardManager.current
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())) },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
),
) {
Text(text = "note", color = Color.White)
}
}
@Composable
private fun EditButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost)
NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel, channel)
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = { wantsToPost = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Edit", color = Color.White)
}
}
@Composable
private fun JoinButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
account.joinChannel(channel.idHex, accountStateViewModel)
navController.navigate(Route.Message.route)
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Join", color = Color.White)
}
}
@Composable
private fun LeaveButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
account.leaveChannel(channel.idHex, accountStateViewModel)
navController.navigate(Route.Message.route)
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Leave", color = Color.White)
}
}
@@ -19,7 +19,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
val account by accountViewModel.accountLiveData.observeAsState()
if (account != null) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatroomListDataSource ) }
val feedViewModel: NostrChatroomListFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
@@ -46,7 +46,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
NostrChatRoomDataSource.loadMessagesBetween(account, userId)
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatRoomDataSource ) }
val feedViewModel: NostrChatRoomFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
@@ -25,7 +25,7 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
val accountState by accountViewModel.accountLiveData.observeAsState()
if (accountState != null) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrHomeDataSource ) }
val feedViewModel: NostrHomeFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
@@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.buttons.NewChannelButton
import com.vitorpamplona.amethyst.buttons.NewNoteButton
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
@@ -49,7 +50,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
scaffoldState = scaffoldState
) {
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
AppNavigation(navController, accountViewModel)
AppNavigation(navController, accountViewModel, accountStateViewModel)
}
}
}
@@ -73,4 +74,20 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
}
}
}
if (currentRoute(navController) == Route.Message.route) {
Crossfade(targetState = accountState) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
}
is AccountState.LoggedOff -> {
// Does nothing.
}
is AccountState.LoggedIn -> {
NewChannelButton(state.account, accountViewModel)
}
}
}
}
}
@@ -19,12 +19,7 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon
val account by accountViewModel.accountLiveData.observeAsState()
if (account != null) {
val feedViewModel: CardFeedViewModel =
viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
val feedViewModel: CardFeedViewModel = viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
Column(Modifier.fillMaxHeight()) {
Column(
@@ -51,7 +51,9 @@ import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
@@ -68,13 +70,11 @@ data class TabRowItem(
@Composable
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val account = accountState?.account ?: return
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
val clipboardManager = LocalClipboardManager.current
if (userId != null && account != null && accountUser != null) {
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
@@ -138,7 +138,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
MessageButton(user, navController)
NPubCopyButton(clipboardManager, user)
NPubCopyButton(user)
if (accountUser == user) {
EditButton()
@@ -160,7 +160,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
Text(" @${user.bestUsername()}", color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f))
Text(
"${user.info.about}",
color = Color.White,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
@@ -222,7 +222,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
if (accountState != null) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrUserProfileDataSource ) }
val feedViewModel: NostrUserProfileFeedViewModel = viewModel()
Column(Modifier.fillMaxHeight()) {
Column(
@@ -262,9 +262,10 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController:
@Composable
private fun NPubCopyButton(
clipboardManager: ClipboardManager,
user: User
) {
val clipboardManager = LocalClipboardManager.current
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())) },
@@ -15,12 +15,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
@@ -38,7 +36,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
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
@@ -46,17 +44,18 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SearchButton
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrGlobalDataSource ) }
val feedViewModel: NostrGlobalFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
@@ -66,16 +65,18 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(navController)
SearchBar(accountViewModel, navController)
FeedView(feedViewModel, accountViewModel, navController)
}
}
}
@Composable
private fun SearchBar(navController: NavController) {
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
val searchValue = remember { mutableStateOf(TextFieldValue("")) }
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val isTrailingIconVisible by remember {
derivedStateOf {
@@ -96,6 +97,8 @@ private fun SearchBar(navController: NavController) {
onValueChange = {
searchValue.value = it
searchResults.value = LocalCache.findUsersStartingWith(it.text)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it.text)
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it.text)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
@@ -123,6 +126,8 @@ private fun SearchBar(navController: NavController) {
onClick = {
searchValue.value = TextFieldValue("")
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
}
) {
Icon(
@@ -148,6 +153,24 @@ private fun SearchBar(navController: NavController) {
navController.navigate("User/${item.pubkeyHex}")
}
}
itemsIndexed(searchResultsChannels.value, key = { _, item -> item.idHex }) { index, item ->
ChannelName(
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
onClick = { navController.navigate("Channel/${item.idHex}") })
}
itemsIndexed(searchResultsNotes.value, key = { _, item -> item.idHex }) { index, item ->
NoteCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
}
}
@@ -27,7 +27,7 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navControl
if (account != null && noteId != null) {
NostrThreadDataSource.loadThread(noteId)
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrThreadDataSource ) }
val feedViewModel: NostrThreadFeedViewModel = viewModel()
Column(Modifier.fillMaxHeight()) {
Column(