Private message support

pull/3/head
Vitor Pamplona 2023-01-14 17:56:18 -05:00
rodzic f580fdd216
commit aa11bf212a
23 zmienionych plików z 799 dodań i 107 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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())
)

Wyświetl plik

@ -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,

Wyświetl plik

@ -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()

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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
)
}
}
}

Wyświetl plik

@ -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)
)
}
}
}
}
}
}
}
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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,

Wyświetl plik

@ -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() {

Wyświetl plik

@ -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()
}
}
}
}
}
}

Wyświetl plik

@ -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()
}
}
}
}
}
}

Wyświetl plik

@ -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)
}*/
}

Wyświetl plik

@ -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)
}
}

Wyświetl plik

@ -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)
}
}
}
}

Wyświetl plik

@ -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
)
}
}

Wyświetl plik

@ -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")
}
}