Private message support

This commit is contained in:
Vitor Pamplona
2023-01-14 17:56:18 -05:00
parent f580fdd216
commit aa11bf212a
23 changed files with 799 additions and 107 deletions

View File

@@ -5,6 +5,8 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Persona
import nostr.postr.Utils
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -78,6 +80,48 @@ class Account(val loggedIn: Persona) {
}
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey,
publishedRecipientPubKey = user.pubkey,
msg = message,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun decryptContent(note: Note): String? {
val event = note.event
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
if (note.author == userProfile())
pubkeyToUse = event.recipientPubKey!!
val sharedSecret = Utils.getSharedSecret(loggedIn.privKey!!, pubkeyToUse)
return try {
val retVal = Utils.decrypt(event.content, sharedSecret)
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
retVal.substring(16)
} else {
retVal
}
} catch (e: Exception) {
e.printStackTrace()
null
}
} else {
event?.content
}
}
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)

View File

@@ -115,9 +115,9 @@ object LocalCache {
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
//Log.d("CL", "${user.toBestDisplayName()} ${event.follows}")
if (event.createdAt > user.updatedFollowsAt) {
//Log.d("CL", "${user.toBestDisplayName()} ${event.follows.size}")
user.updateFollows(
event.follows.map {
try {
@@ -138,7 +138,25 @@ object LocalCache {
}
fun consume(event: PrivateDmEvent) {
//Log.d("PM", event.toJson())
val note = getOrCreateNote(event.id.toHex())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val recipient = event.recipientPubKey?.let { getOrCreateUser(it) }
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }.toMutableList()
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateUser(decodePublicKey(it)) }
note.loadEvent(event, author, mentions, repliesTo)
if (recipient != null) {
author.addMessage(recipient, note)
recipient.addMessage(author, note)
}
}
fun consume(event: DeletionEvent) {

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class User(val pubkey: ByteArray) {
val pubkeyHex = pubkey.toHexKey()
@@ -20,6 +21,8 @@ class User(val pubkey: ByteArray) {
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
}
@@ -46,6 +49,20 @@ class User(val pubkey: ByteArray) {
user.followers.remove(this)
}
@Synchronized
fun getOrCreateChannel(user: User): MutableSet<Note> {
return messages[user] ?: run {
val channel = mutableSetOf<Note>()
messages[user] = channel
channel
}
}
fun addMessage(user: User, msg: Note) {
getOrCreateChannel(user).add(msg)
live.refresh()
}
fun updateFollows(newFollows: List<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows

View File

@@ -0,0 +1,45 @@
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.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatRoomDataSource: NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = LocalCache.users[userId]
}
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = withUser?.let { listOf(it.pubkeyHex) },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = withUser?.let { mapOf("p" to listOf(it.pubkeyHex)) }
)
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser]
return messages?.sortedBy { it.event!!.createdAt } ?: emptyList()
}
override fun updateChannelFilters() {
incomingChannel.filter = createMessagesToMeFilter()
outgoingChannel.filter = createMessagesFromMeFilter()
}
}

View File

@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
lateinit var account: Account
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
val messagingWith = messages.keys().toList()
return messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.last { it.event != null }
}.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
incomingChannel.filter = createMessagesToMeFilter()
outgoingChannel.filter = createMessagesFromMeFilter()
}
}

View File

@@ -8,7 +8,7 @@ object NostrNotificationDataSource: NostrDataSource("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter(
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 2 days
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 7 days
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex).filterNotNull())
)

View File

@@ -62,7 +62,6 @@ class Relay(
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
else -> listeners.forEach {
println("else: " + text)
it.onError(
this@Relay,
channel,

View File

@@ -8,12 +8,12 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.android.exoplayer2.util.Util
import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
@@ -51,6 +51,7 @@ class MainActivity : ComponentActivity() {
override fun onPause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChatroomListDataSource.stop()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()

View File

@@ -107,7 +107,8 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
onPost = {
postViewModel.sendPost()
onClose()
}
},
postViewModel.message.isNotBlank()
)
}
@@ -207,15 +208,17 @@ fun CloseButton(onCancel: () -> Unit) {
}
@Composable
fun PostButton(onPost: () -> Unit = {}) {
fun PostButton(onPost: () -> Unit = {}, isActive: Boolean) {
Button(
onClick = {
onPost()
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Post", color = Color.White)

View File

@@ -10,8 +10,9 @@ import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.MessageScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
@@ -28,7 +29,7 @@ sealed class Route(
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 -> { _ -> MessageScreen(acc) }})
object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, nav -> { _ -> ChatroomListScreen(acc, nav) }})
object Profile : Route("Profile", R.drawable.ic_profile, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
object Lists : Route("Lists", R.drawable.ic_lists, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
object Topics : Route("Topics", R.drawable.ic_topics, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
@@ -39,6 +40,11 @@ sealed class Route(
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
buildScreen = { acc, 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) }}
)
}
val Routes = listOf(
@@ -56,7 +62,8 @@ val Routes = listOf(
Route.Moments,
//inner
Route.Note
Route.Note,
Route.Room
)
@Composable

View File

@@ -0,0 +1,98 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountUser = accountUserState?.user
if (note?.event == null) {
BlankNote(Modifier)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
val replyAuthorBase = note.mentions?.first()
var userToComposeOn = author
if ( replyAuthorBase != null ) {
val replyAuthorState by replyAuthorBase.live.observeAsState()
val replyAuthor = replyAuthorState?.user
if (author == accountUser) {
userToComposeOn = replyAuthor
}
}
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("Room/${userToComposeOn?.pubkeyHex}") }
)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
AsyncImage(
model = userToComposeOn?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (userToComposeOn != null)
UserDisplay(userToComposeOn)
Text(
timeAgo(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
RichTextViewer(eventContent.take(100), note.event?.tags, note, accountViewModel, navController)
else
RichTextViewer("Referenced event not found", note.event?.tags, note, accountViewModel, navController)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
}

View File

@@ -0,0 +1,122 @@
package com.vitorpamplona.amethyst.ui.note
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
val ChatBubbleShapeMe = RoundedCornerShape(20.dp, 20.dp, 3.dp, 20.dp)
val ChatBubbleShapeThem = RoundedCornerShape(20.dp, 20.dp, 20.dp, 3.dp)
@Composable
fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountUser = accountUserState?.user
if (note?.event == null) {
BlankNote(Modifier)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("User/${note.idHex}") }
)
) {
var backgroundBubbleColor: Color
var alignment: Arrangement.Horizontal
var shape: Shape
if (author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
shape = ChatBubbleShapeMe
} else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
alignment = Arrangement.Start
shape = ChatBubbleShapeThem
}
Row(
horizontalArrangement = alignment,
modifier = Modifier.fillMaxWidth()
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Surface(
color = backgroundBubbleColor,
shape = shape
) {
Column(
modifier = Modifier.padding(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
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(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment
) {
Text(
timeAgoLong(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
}
}
}
}

View File

@@ -1,10 +1,8 @@
package com.vitorpamplona.amethyst.ui.note
import android.text.format.DateUtils.getRelativeTimeSpanString
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -29,10 +27,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote

View File

@@ -18,6 +18,7 @@ fun timeAgo(mills: Long?): String {
return "" + humanReadable
.replace(" hr. ago", "h")
.replace(" min. ago", "m")
.replace(" days. ago", "d")
}
fun timeAgoLong(mills: Long?): String {

View File

@@ -9,14 +9,21 @@ import com.vitorpamplona.amethyst.model.User
@Composable
fun UserDisplay(user: User) {
if (user.bestUsername() != null || user.bestDisplayName() != null) {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
if (user.bestDisplayName().isNullOrBlank()) {
Text(
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
)
} else {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
} else {
Text(
user.pubkeyDisplayHex,

View File

@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
@@ -59,6 +60,7 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
NostrAccountDataSource.account = loggedIn
NostrHomeDataSource.account = loggedIn
NostrNotificationDataSource.account = loggedIn
NostrChatroomListDataSource.account = loggedIn
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
@@ -67,6 +69,7 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
NostrSingleEventDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
}
fun newKey() {

View File

@@ -0,0 +1,98 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun ChatroomFeedView(userId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
isRefreshing = false
}
}
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
isRefreshing = true
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
isRefreshing = true
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
isRefreshing = true
}
}
is FeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
var previousDate: String = ""
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
ChatroomMessageCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
LaunchedEffect(Unit) {
listState.animateScrollToItem(state.feed.size-1, 0)
}
}
FeedState.Loading -> {
LoadingFeed()
}
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
val listState = rememberLazyListState()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
isRefreshing = false
}
}
SwipeRefresh(
state = swipeRefreshState,
onRefresh = {
isRefreshing = true
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
isRefreshing = true
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
isRefreshing = true
}
}
is FeedState.Loaded -> {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
ChatroomCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
}
FeedState.Loading -> {
LoadingFeed()
}
}
}
}
}
}

View File

@@ -133,57 +133,4 @@ fun FeedEmpty(onRefresh: () -> Unit) {
Text(text = "Refresh")
}
}
}
// Bosted code to be deleted:
/*
Boosted By: removed because it was ugly
if (item.event is RepostEvent) {
Row(
modifier = Modifier.padding(
start = 12.dp,
end = 12.dp,
bottom = 8.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
tint = Color.Gray
)
Text(
text = "Boosted by ${item.author.toBestDisplayName()}",
modifier = Modifier.padding(start = 10.dp),
fontWeight = FontWeight.Bold,
color = Color.Gray,
)
}
val refNote = item.replyTo.firstOrNull()
if (refNote != null) {
NoteCompose(index, refNote)
} else {
Row(
modifier = Modifier.padding(
start = 40.dp,
end = 40.dp,
bottom = 25.dp,
top = 15.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Could not find referenced event",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
}
}
} else {
NoteCompose(index, item)
}*/
}

View File

@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.events.PrivateDmEvent
class AccountViewModel(private val account: Account): ViewModel() {
val accountLiveData: LiveData<AccountState> = Transformations.map(account.live) { it }
@@ -24,4 +25,8 @@ class AccountViewModel(private val account: Account): ViewModel() {
fun broadcast(note: Note) {
account.broadcast(note)
}
fun decrypt(note: Note): String? {
return account.decryptContent(note)
}
}

View File

@@ -0,0 +1,47 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
if (account != null) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatroomListDataSource ) }
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
}
}
}
}

View File

@@ -0,0 +1,145 @@
package com.vitorpamplona.amethyst.ui.screen
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.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
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.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
if (account != null && userId != null) {
val newPost = remember { mutableStateOf(TextFieldValue("")) }
NostrChatRoomDataSource.loadMessagesBetween(account, userId)
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatRoomDataSource ) }
Column(Modifier.fillMaxHeight()) {
NostrChatRoomDataSource.withUser?.let {
ChatroomHeader(
it,
accountViewModel = accountViewModel,
navController = navController
)
}
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
) {
ChatroomFeedView(userId, feedViewModel, accountViewModel, navController)
}
//LAST ROW
Row(modifier = Modifier.padding(10.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = newPost.value,
onValueChange = { newPost.value = it },
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.weight(1f, true).padding(end = 10.dp),
placeholder = {
Text(
text = "reply here.. ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
PostButton(
onPost = {
account.sendPrivateMeesage(newPost.value.text, userId)
newPost.value = TextFieldValue("")
},
newPost.value.text.isNotBlank()
)
}
}
}
}
@Composable
fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
val authorState by baseUser.live.observeAsState()
val author = authorState?.user
Column(modifier =
Modifier
.padding(12.dp)
//.clickable(
//onClick = { navController.navigate("User/${author?.pubkeyHex}") }
//)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UserDisplay(author)
}
}
}
Divider(
modifier = Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
}

View File

@@ -1,29 +0,0 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Text
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun MessageScreen(accountViewModel: AccountViewModel) {
val state = rememberScaffoldState()
val scope = rememberCoroutineScope()
Column(
Modifier
.fillMaxHeight()
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Message Screen")
}
}