diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index abcfae6b8..bc9005f44 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 2d6cd4cfd..eaa024dab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index abd286cd9..b5fbb52b1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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()) + val messages = ConcurrentHashMap>() + 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 { + return messages[user] ?: run { + val channel = mutableSetOf() + messages[user] = channel + channel + } + } + + fun addMessage(user: User, msg: Note) { + getOrCreateChannel(user).add(msg) + live.refresh() + } + fun updateFollows(newFollows: List, updateAt: Long) { val toBeAdded = newFollows - follows val toBeRemoved = follows - newFollows diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt new file mode 100644 index 000000000..ffd4a0691 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt @@ -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 { + val messages = account.userProfile().messages[withUser] + + return messages?.sortedBy { it.event!!.createdAt } ?: emptyList() + } + + override fun updateChannelFilters() { + incomingChannel.filter = createMessagesToMeFilter() + outgoingChannel.filter = createMessagesFromMeFilter() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt new file mode 100644 index 000000000..62f4b238d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -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 { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt index b8066eca7..af4a89384 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt @@ -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()) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 16668028d..def33d7fc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 8c8385bae..a71193122 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 291e57819..c7a7c1694 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 23d7ff735..c7e4a8170 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -10,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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt new file mode 100644 index 000000000..b831485c7 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt new file mode 100644 index 000000000..c6f9530d9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -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) + ) + } + } + } + } + } + } + } +} + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt similarity index 97% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index e3febe5dc..8a6994456 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 4fab1e123..34762911b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserDisplay.kt index 6cfc378b4..13bf498ff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserDisplay.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index f7ed8a302..f8a2f2800 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -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() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt new file mode 100644 index 000000000..9f21329f7 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -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() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt new file mode 100644 index 000000000..c3876787e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -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() + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index e00c17d6b..48ad855d8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -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) - }*/ \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index af7dc28ab..fbcc8fa4a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 = 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) + } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt new file mode 100644 index 000000000..0da005dfa --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -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) + } + } + } +} + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt new file mode 100644 index 000000000..f40db580a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MessageScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MessageScreen.kt deleted file mode 100644 index 5b2dc24c6..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MessageScreen.kt +++ /dev/null @@ -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") - } -} \ No newline at end of file