Compare commits

..
14 Commits
43 changed files with 1114 additions and 227 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 9
versionName "0.8.1"
versionCode 11
versionName "0.9"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -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()) {
@@ -149,6 +154,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)
@@ -24,6 +24,7 @@ import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import nostr.postr.toNpub
object LocalCache {
@@ -244,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}")
@@ -258,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) {
@@ -277,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)
@@ -297,8 +317,6 @@ object LocalCache {
it.addReply(note)
}
UrlCachedPreviewer.preloadPreviewsFor(note)
refreshObservers()
}
@@ -311,7 +329,27 @@ object LocalCache {
}
fun findUsersStartingWith(username: String): List<User> {
return users.values.filter { it.info.anyNameStartsWith(username) }
return users.values.filter {
it.info.anyNameStartsWith(username)
|| it.pubkeyHex.startsWith(username, true)
|| it.pubkey.toNpub().startsWith(username, true)
}
}
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.
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -11,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
@@ -40,7 +43,7 @@ class Note(val idHex: String) {
this.mentions = mentions
this.replyTo = replyTo
refreshObservers()
invalidateData()
}
fun formattedDateTime(timestamp: Long): String {
@@ -72,17 +75,17 @@ class Note(val idHex: String) {
fun addReply(note: Note) {
if (replies.add(note))
refreshObservers()
invalidateData()
}
fun addBoost(note: Note) {
if (boosts.add(note))
refreshObservers()
invalidateData()
}
fun addReaction(note: Note) {
if (reactions.add(note))
refreshObservers()
invalidateData()
}
fun isReactedBy(user: User): Boolean {
@@ -96,8 +99,19 @@ class Note(val idHex: String) {
// Observers line up here.
val live: NoteLiveData = NoteLiveData(this)
private fun refreshObservers() {
live.refresh()
// Refreshes observers in batches.
val scope = CoroutineScope(Job() + Dispatchers.Main)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
}
@@ -108,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)
}
}
@@ -8,6 +8,10 @@ import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.videoExtension
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object UrlCachedPreviewer {
val cache = ConcurrentHashMap<String, UrlInfoItem>()
@@ -18,16 +22,19 @@ object UrlCachedPreviewer {
return
}
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache.put(url, urlInfo)
callback?.onComplete(urlInfo)
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache.put(url, urlInfo)
callback?.onComplete(urlInfo)
}
override fun onFailed(throwable: Throwable) {
callback?.onFailed(throwable)
}
}).fetchUrlPreview()
override fun onFailed(throwable: Throwable) {
callback?.onFailed(throwable)
}
}).fetchUrlPreview()
}
}
fun findUrlsInMessage(message: String): List<String> {
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
@@ -9,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
@@ -28,7 +31,6 @@ class User(val pubkey: ByteArray) {
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
fun toBestDisplayName(): String {
@@ -51,10 +53,16 @@ class User(val pubkey: ByteArray) {
fun follow(user: User) {
follows.add(user)
user.followers.add(this)
invalidateData()
user.invalidateData()
}
fun unfollow(user: User) {
follows.remove(user)
user.followers.remove(this)
invalidateData()
user.invalidateData()
}
@Synchronized
@@ -86,15 +94,13 @@ class User(val pubkey: ByteArray) {
}
updatedFollowsAt = updateAt
live.refresh()
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
info = newUserInfo
updatedMetadataAt = updateAt
live.refresh()
invalidateData()
}
fun isFollowing(user: User): Boolean {
@@ -106,8 +112,19 @@ class User(val pubkey: ByteArray) {
// Observers line up here.
val live: UserLiveData = UserLiveData(this)
private fun refreshObservers() {
live.refresh()
// Refreshes observers in batches.
val scope = CoroutineScope(Job() + Dispatchers.Main)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
}
@@ -143,18 +160,12 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
override fun onActive() {
super.onActive()
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
NostrSingleUserDataSource.add(user.pubkeyHex)
}
NostrSingleUserDataSource.add(user.pubkeyHex)
}
override fun onInactive() {
super.onInactive()
val scope = CoroutineScope(Job() + Dispatchers.Main)
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
)
}
@@ -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 }
}
}
@@ -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 }
}
}
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.service
import android.os.Handler
import android.os.Looper
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
@@ -18,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
@@ -112,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)
}
@@ -125,7 +127,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
return returningList
}
suspend fun loadPreviews(list: List<T>) {
fun loadPreviews(list: List<T>) {
list.forEach {
if (it is Note) {
UrlCachedPreviewer.preloadPreviewsFor(it)
@@ -146,6 +148,20 @@ abstract class NostrDataSource<T>(val debugName: String) {
channelIds.remove(channel.id)
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateFilters() {
if (handlerWaiting) return
handlerWaiting = true
scope.launch {
delay(200)
resetFilters()
handlerWaiting = false
}
}
fun resetFilters() {
// saves the channels that are currently active
val activeChannels = channels.filter { it.filter != null }
@@ -8,7 +8,7 @@ import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
limit = 200
limit = 50
)
val globalFeedChannel = requestNewChannel()
@@ -70,11 +70,11 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
fun add(eventId: String) {
eventsToWatch = eventsToWatch.plus(eventId)
resetFilters()
invalidateFilters()
}
fun remove(eventId: String) {
eventsToWatch = eventsToWatch.minus(eventId)
resetFilters()
invalidateFilters()
}
}
@@ -16,7 +16,7 @@ object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.substring(0, 8)),
limit = 1
limit = 10
)
}
}
@@ -35,11 +35,11 @@ object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
fun add(userId: String) {
usersToWatch = usersToWatch.plus(userId)
resetFilters()
invalidateFilters()
}
fun remove(userId: String) {
usersToWatch = usersToWatch.minus(userId)
resetFilters()
invalidateFilters()
}
}
@@ -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)
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.JsonElement
import java.util.Collections
import nostr.postr.events.Event
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -16,6 +16,7 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
@@ -71,6 +72,7 @@ class MainActivity : ComponentActivity() {
override fun onPause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
@@ -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()
}
}
@@ -1,24 +1,21 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
@@ -37,16 +34,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
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 coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.UrlPreview
@@ -57,7 +52,7 @@ import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.videoExtension
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import com.vitorpamplona.amethyst.ui.screen.UserLine
import kotlinx.coroutines.delay
import nostr.postr.events.TextNoteEvent
@@ -169,41 +164,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
)
) {
itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { index, item ->
Column(modifier = Modifier.fillMaxWidth().clickable(onClick = {
UserLine(item) {
postViewModel.autocompleteWithUser(item)
})) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
AsyncImage(
model = item.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp).height(55.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UserDisplay(item)
}
Text(
item.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
}
@@ -278,3 +240,46 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
Text(text = "Post", color = Color.White)
}
}
@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(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Icon(
painter = painterResource(R.drawable.ic_search),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}
@@ -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))
}
}
}
@@ -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)
)
}
@@ -106,12 +113,15 @@ fun DrawerContent(navController: NavHostController,
fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
val coroutineScope = rememberCoroutineScope()
println("AAA " + accountUser?.profilePicture())
Column(modifier = modifier) {
AsyncImage(
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 +215,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,10 +23,9 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.events.TextNoteEvent
@Composable
fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
@@ -45,6 +44,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(),
@@ -55,7 +61,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}") })
}
@@ -79,7 +85,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
userToComposeOn?.let {
ChannelName(
channelPicture = it.profilePicture(),
channelTitle = { UserDisplay(it) },
channelTitle = { UsernameDisplay(it) },
channelLastTime = note.event?.createdAt,
channelLastContent = accountViewModel.decrypt(note),
onClick = { navController.navigate("Room/${it.pubkeyHex}") })
@@ -89,7 +95,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
}
@Composable
private fun ChannelName(
fun ChannelName(
channelPicture: String,
channelTitle: @Composable () -> Unit,
channelLastTime: Long?,
@@ -118,10 +124,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,9 @@ 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.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -134,26 +136,41 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
}
Row(verticalAlignment = Alignment.CenterVertically) {
val eventContent = accountViewModel.decrypt(note)
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
)
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,
horizontalArrangement = Arrangement.End,
@@ -110,7 +110,7 @@ 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)
UserDisplay(author)
UsernameDisplay(author)
if (note.event !is RepostEvent) {
Text(
@@ -178,7 +178,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.event?.content ?: "")); onDismiss() }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
Text("Copy Text")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkey?.toNpub() ?: "")); onDismiss() }) {
@@ -54,7 +54,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UserDisplay(user)
UsernameDisplay(user)
}
Text(
@@ -7,7 +7,7 @@ import androidx.compose.ui.text.font.FontWeight
import com.vitorpamplona.amethyst.model.User
@Composable
fun UserDisplay(user: User) {
fun UsernameDisplay(user: User) {
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
@@ -6,6 +6,7 @@ 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.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
@@ -8,7 +10,11 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
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
@@ -21,13 +27,13 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private var lastNotes: List<Note>? = null
fun refresh() {
// For some reason, view Model Scope doesn't call
viewModelScope.launch {
refreshSuspend()
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
refreshSuspended()
}
}
fun refreshSuspend() {
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val lastNotesCopy = lastNotes
@@ -75,22 +81,32 @@ 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 scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
private val cacheListener: (LocalCacheState) -> Unit = {
refresh()
invalidateData()
}
init {
@@ -1,30 +1,55 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.ViewModel
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
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() {
// For some reason, view Model Scope doesn't call
viewModelScope.launch {
refreshSuspend()
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
refreshSuspended()
}
}
fun refreshSuspend() {
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
@@ -38,10 +63,13 @@ class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
fun updateFeed(notes: List<Note>) {
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
} else {
_feedContent.update { FeedState.Loaded(notes) }
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
} else {
_feedContent.update { FeedState.Loaded(notes) }
}
}
}
@@ -52,8 +80,22 @@ class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
private val cacheListener: (LocalCacheState) -> Unit = {
refresh()
invalidateData()
}
init {
@@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ReactionsRowState
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -185,7 +185,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UserDisplay(author)
UsernameDisplay(author)
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
@@ -8,7 +10,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
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
@@ -27,13 +33,13 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
val feedContent = _feedContent.asStateFlow()
fun refresh() {
// For some reason, view Model Scope doesn't call
viewModelScope.launch {
refreshSuspend()
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
refreshSuspended()
}
}
fun refreshSuspend() {
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
@@ -47,10 +53,13 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
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) }
}
}
}
@@ -61,8 +70,22 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
private val cacheListener: (LocalCacheState) -> Unit = {
refresh()
invalidateData()
}
init {
@@ -1,6 +1,5 @@
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
@@ -10,20 +9,28 @@ 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.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
@@ -33,16 +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.User
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
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.note.UserDisplay
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
@@ -54,23 +64,32 @@ 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()
}
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)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
@@ -18,7 +19,11 @@ 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()
}
Column(Modifier.fillMaxHeight()) {
Column(
@@ -13,10 +13,10 @@ 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.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -33,7 +33,7 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.User
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.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -46,7 +46,11 @@ 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()
}
Column(Modifier.fillMaxHeight()) {
NostrChatRoomDataSource.withUser?.let {
@@ -121,7 +125,7 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UserDisplay(author)
UsernameDisplay(author)
}
}
}
@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
@@ -12,6 +14,9 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.lang.System.currentTimeMillis
@@ -20,7 +25,11 @@ 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()
}
Column(Modifier.fillMaxHeight()) {
Column(
@@ -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)
}
}
}
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
@@ -18,8 +19,11 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon
val account by accountViewModel.accountLiveData.observeAsState()
if (account != null) {
val feedViewModel: CardFeedViewModel =
viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
val feedViewModel: CardFeedViewModel = viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
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
@@ -73,8 +75,6 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val accountUserState by accountViewModel.userLiveData.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())) },
@@ -1,25 +1,235 @@
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
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.size
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
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.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
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()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(accountViewModel, navController)
FeedView(feedViewModel, accountViewModel, navController)
}
}
}
@Composable
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 {
searchValue.value.text.isNotBlank()
}
}
//LAST ROW
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = searchValue.value,
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
),
leadingIcon = {
Icon(
painter = painterResource(R.drawable.ic_search),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
},
modifier = Modifier
.weight(1f, true)
.defaultMinSize(minHeight = 20.dp),
placeholder = {
Text(
text = "npub, hex, username ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
if (isTrailingIconVisible) {
IconButton(
onClick = {
searchValue.value = TextFieldValue("")
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
}
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Clear"
)
}
}
}
)
}
if (searchValue.value.text.isNotBlank()) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
) {
itemsIndexed(searchResults.value, key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item) {
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)
}
}
}
}
@Composable
fun UserLine(
baseUser: User,
onClick: () -> Unit
) {
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
Column(modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
) {
AsyncImage(
model = user.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(user)
}
Text(
user.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
@@ -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(