From 4543a68615744dd8549d103f89b3990627e6904d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Jan 2023 21:52:59 -0500 Subject: [PATCH] Support for User Profiles --- app/build.gradle | 4 + .../vitorpamplona/amethyst/model/Account.kt | 38 +- .../amethyst/model/LocalCache.kt | 4 +- .../com/vitorpamplona/amethyst/model/User.kt | 3 + .../service/NostrAccountDataSource.kt | 2 +- .../service/NostrChatRoomDataSource.kt | 2 +- .../service/NostrChatroomListDataSource.kt | 2 +- .../amethyst/service/NostrDataSource.kt | 7 +- .../amethyst/service/NostrGlobalDataSource.kt | 3 +- .../amethyst/service/NostrHomeDataSource.kt | 2 +- .../service/NostrNotificationDataSource.kt | 2 +- .../service/NostrSingleEventDataSource.kt | 2 +- .../service/NostrSingleUserDataSource.kt | 2 +- .../amethyst/service/NostrThreadDataSource.kt | 2 +- .../service/NostrUserProfileDataSource.kt | 45 +++ .../NostrUserProfileFollowersDataSource.kt | 31 ++ .../NostrUserProfileFollowsDataSource.kt | 33 ++ .../vitorpamplona/amethyst/ui/MainActivity.kt | 6 + .../amethyst/ui/components/RichTextViewer.kt | 1 - .../amethyst/ui/navigation/AppTopBar.kt | 9 +- .../amethyst/ui/navigation/DrawerContent.kt | 118 +++--- .../amethyst/ui/navigation/Routes.kt | 16 +- .../amethyst/ui/note/BoostSetCompose.kt | 6 + .../amethyst/ui/note/ChatroomCompose.kt | 2 +- .../amethyst/ui/note/LikeSetCompose.kt | 6 + .../amethyst/ui/note/NoteCompose.kt | 19 +- .../amethyst/ui/note/UserCompose.kt | 80 ++++ .../amethyst/ui/screen/CardFeedViewModel.kt | 2 +- .../amethyst/ui/screen/FeedViewModel.kt | 2 +- .../amethyst/ui/screen/ThreadFeedView.kt | 10 +- .../amethyst/ui/screen/UserFeedState.kt | 10 + .../amethyst/ui/screen/UserFeedView.kt | 78 ++++ .../amethyst/ui/screen/UserFeedViewModel.kt | 79 ++++ .../ui/screen/loggedIn/ChatroomScreen.kt | 7 +- .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 4 +- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 6 +- .../ui/screen/loggedIn/ProfileScreen.kt | 343 +++++++++++++++++- .../ui/screen/loggedIn/ThreadScreen.kt | 7 + .../ui/screen/loggedOff/LoginScreen.kt | 1 - build.gradle | 1 + 40 files changed, 891 insertions(+), 106 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowersDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowsDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index 2f4f2ec39..b1e220b2f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -99,6 +99,10 @@ dependencies { // view videos implementation 'com.google.android.exoplayer:exoplayer:2.18.2' + // tabs for user profiles + implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager + implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version" // Pager Indicators + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 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 bc9005f44..f7706cbef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1,11 +1,14 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData +import com.vitorpamplona.amethyst.service.Constants import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.relays.Client +import nostr.postr.Contact import nostr.postr.Persona import nostr.postr.Utils +import nostr.postr.events.ContactListEvent import nostr.postr.events.PrivateDmEvent import nostr.postr.events.TextNoteEvent import nostr.postr.toHex @@ -52,6 +55,32 @@ class Account(val loggedIn: Persona) { } } + fun follow(user: User) { + if (!isWriteable()) return + + val lastestContactList = userProfile().lastestContactList + val event = if (lastestContactList != null) { + ContactListEvent.create(lastestContactList.follows.plus(Contact(user.pubkeyHex, null)), lastestContactList.relayUse, loggedIn.privKey!!) + } else { + val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } + ContactListEvent.create(listOf(Contact(user.pubkeyHex, null)), relays, loggedIn.privKey!!) + } + + Client.send(event) + LocalCache.consume(event) + } + + fun unfollow(user: User) { + if (!isWriteable()) return + + val lastestContactList = userProfile().lastestContactList + if (lastestContactList != null) { + val event = ContactListEvent.create(lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex }, lastestContactList.relayUse, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + fun sendPost(message: String, replyingTo: Note?) { if (!isWriteable()) return @@ -99,12 +128,15 @@ class Account(val loggedIn: Persona) { 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) + val recepientPK = event.recipientPubKey + + if (note.author == userProfile() && recepientPK != null) + pubkeyToUse = recepientPK return try { + val sharedSecret = Utils.getSharedSecret(loggedIn.privKey!!, pubkeyToUse) + val retVal = Utils.decrypt(event.content, sharedSecret) if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) { 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 32f336fee..ab758399f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -116,7 +116,7 @@ object LocalCache { val user = getOrCreateUser(event.pubKey) if (event.createdAt > user.updatedFollowsAt) { - //Log.d("CL", "${user.toBestDisplayName()} ${event.follows.size}") + Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}") user.updateFollows( event.follows.map { try { @@ -131,6 +131,8 @@ object LocalCache { }.filterNotNull(), event.createdAt ) + + user.lastestContactList = event } refreshObservers() 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 35b3d6051..991164abc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.ui.note.toDisplayHex import java.util.Collections import java.util.concurrent.ConcurrentHashMap +import nostr.postr.events.ContactListEvent class User(val pubkey: ByteArray) { val pubkeyHex = pubkey.toHexKey() @@ -15,6 +16,8 @@ class User(val pubkey: ByteArray) { var updatedMetadataAt: Long = 0; var updatedFollowsAt: Long = 0; + var lastestContactList: ContactListEvent? = null + val notes = Collections.synchronizedSet(mutableSetOf()) val follows = Collections.synchronizedSet(mutableSetOf()) val taggedPosts = Collections.synchronizedSet(mutableSetOf()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 78e160c3c..2be70a467 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -10,7 +10,7 @@ import nostr.postr.events.ContactListEvent import nostr.postr.events.MetadataEvent import nostr.postr.events.TextNoteEvent -object NostrAccountDataSource: NostrDataSource("AccountData") { +object NostrAccountDataSource: NostrDataSource("AccountData") { lateinit var account: Account private val cacheListener: (UserState) -> Unit = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt index ffd4a0691..39fda3850 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt @@ -7,7 +7,7 @@ import com.vitorpamplona.amethyst.model.User import nostr.postr.JsonFilter import nostr.postr.events.PrivateDmEvent -object NostrChatRoomDataSource: NostrDataSource("ChatroomFeed") { +object NostrChatRoomDataSource: NostrDataSource("ChatroomFeed") { lateinit var account: Account var withUser: User? = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index 62f4b238d..ae9186ecf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Note import nostr.postr.JsonFilter import nostr.postr.events.PrivateDmEvent -object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") { +object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") { lateinit var account: Account fun createMessagesToMeFilter() = JsonFilter( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 1805fd09f..251bcabe9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -1,7 +1,6 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.relays.Client @@ -15,7 +14,7 @@ import nostr.postr.events.PrivateDmEvent import nostr.postr.events.RecommendRelayEvent import nostr.postr.events.TextNoteEvent -abstract class NostrDataSource(val debugName: String) { +abstract class NostrDataSource(val debugName: String) { private val channels = Collections.synchronizedSet(mutableSetOf()) private val channelIds = Collections.synchronizedSet(mutableSetOf()) @@ -78,7 +77,7 @@ abstract class NostrDataSource(val debugName: String) { } } - fun loadTop(): List { + fun loadTop(): List { return feed().take(100) } @@ -135,5 +134,5 @@ abstract class NostrDataSource(val debugName: String) { } abstract fun updateChannelFilters() - abstract fun feed(): List + abstract fun feed(): List } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt index d9aa365ee..4b7339a18 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -1,10 +1,11 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import nostr.postr.JsonFilter import nostr.postr.events.TextNoteEvent -object NostrGlobalDataSource: NostrDataSource("GlobalFeed") { +object NostrGlobalDataSource: NostrDataSource("GlobalFeed") { val fifteenMinutes = (60*15) // 15 mins fun createGlobalFilter() = JsonFilter( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 46796c685..20ebe5c8b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -9,7 +9,7 @@ import nostr.postr.JsonFilter import nostr.postr.events.TextNoteEvent import nostr.postr.toHex -object NostrHomeDataSource: NostrDataSource("HomeFeed") { +object NostrHomeDataSource: NostrDataSource("HomeFeed") { lateinit var account: Account private val cacheListener: (UserState) -> Unit = { 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 af4a89384..85df90629 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt @@ -4,7 +4,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import nostr.postr.JsonFilter -object NostrNotificationDataSource: NostrDataSource("GlobalFeed") { +object NostrNotificationDataSource: NostrDataSource("GlobalFeed") { lateinit var account: Account fun createGlobalFilter() = JsonFilter( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 94dec5b7e..3da3fbeb3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -8,7 +8,7 @@ import java.util.Collections import nostr.postr.JsonFilter import nostr.postr.events.TextNoteEvent -object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { +object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { val eventsToWatch = Collections.synchronizedList(mutableListOf()) fun createRepliesAndReactionsFilter(): JsonFilter? { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index 8cef84032..b533e0925 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -6,7 +6,7 @@ import java.util.Collections import nostr.postr.JsonFilter import nostr.postr.events.MetadataEvent -object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") { +object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") { val usersToWatch = Collections.synchronizedList(mutableListOf()) fun createUserFilter(): JsonFilter? { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index 3172d2d1d..0ad387b65 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Note import java.util.Collections import nostr.postr.JsonFilter -object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") { +object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") { val eventsToWatch = Collections.synchronizedList(mutableListOf()) fun createRepliesAndReactionsFilter(): JsonFilter? { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt new file mode 100644 index 000000000..a2d14fdcd --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -0,0 +1,45 @@ +package com.vitorpamplona.amethyst.service + +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.MetadataEvent +import nostr.postr.events.TextNoteEvent + +object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") { + var user: User? = null + + fun loadUserProfile(userId: String) { + user = LocalCache.users[userId] + resetFilters() + } + + fun createUserInfoFilter(): JsonFilter { + return JsonFilter( + kinds = listOf(MetadataEvent.kind), + authors = listOf(user!!.pubkeyHex), + since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7) + ) + } + + fun createUserPostsFilter(): JsonFilter { + return JsonFilter( + kinds = listOf(TextNoteEvent.kind), + authors = listOf(user!!.pubkeyHex), + since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 4) + ) + } + + val userInfoChannel = requestNewChannel() + val notesChannel = requestNewChannel() + + override fun feed(): List { + return user?.notes?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList() + } + + override fun updateChannelFilters() { + userInfoChannel.filter = createUserInfoFilter() + notesChannel.filter = createUserPostsFilter() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowersDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowersDataSource.kt new file mode 100644 index 000000000..9c7943393 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowersDataSource.kt @@ -0,0 +1,31 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import nostr.postr.JsonFilter +import nostr.postr.events.ContactListEvent + +object NostrUserProfileFollowersDataSource: NostrDataSource("UserProfileFollowerFeed") { + var user: User? = null + + fun loadUserProfile(userId: String) { + user = LocalCache.users[userId] + resetFilters() + } + + fun createFollowersFilter() = JsonFilter( + kinds = listOf(ContactListEvent.kind), + since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 7 days + tags = mapOf("p" to listOf(user!!.pubkeyHex).filterNotNull()) + ) + + val followerChannel = requestNewChannel() + + override fun feed(): List { + return user?.followers?.toList() ?: emptyList() + } + + override fun updateChannelFilters() { + followerChannel.filter = createFollowersFilter() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowsDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowsDataSource.kt new file mode 100644 index 000000000..771cbc15e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowsDataSource.kt @@ -0,0 +1,33 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import nostr.postr.JsonFilter +import nostr.postr.events.ContactListEvent + +object NostrUserProfileFollowsDataSource: NostrDataSource("UserProfileFollowsFeed") { + var user: User? = null + + fun loadUserProfile(userId: String) { + user = LocalCache.users[userId] + resetFilters() + } + + fun createFollowFilter(): JsonFilter { + return JsonFilter( + kinds = listOf(ContactListEvent.kind), + authors = listOf(user!!.pubkeyHex), + since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 4 days + ) + } + + val followChannel = requestNewChannel() + + override fun feed(): List { + return user?.follows?.toList() ?: emptyList() + } + + override fun updateChannelFilters() { + followChannel.filter = createFollowFilter() + } +} \ No newline at end of file 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 c30c94a33..fd4554428 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -17,6 +17,9 @@ import com.vitorpamplona.amethyst.service.NostrNotificationDataSource import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource +import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource +import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource +import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel @@ -52,6 +55,9 @@ class MainActivity : ComponentActivity() { NostrAccountDataSource.stop() NostrHomeDataSource.stop() NostrChatroomListDataSource.stop() + NostrUserProfileDataSource.stop() + NostrUserProfileFollowersDataSource.stop() + NostrUserProfileFollowsDataSource.stop() NostrGlobalDataSource.stop() NostrNotificationDataSource.stop() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 43c39e305..bff30ed5c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -10,7 +10,6 @@ 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index a98cc3d21..278e116b6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -38,14 +38,13 @@ import kotlinx.coroutines.launch @Composable fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { + + when (currentRoute(navController)) { - Route.Profile.route, - Route.Lists.route, - Route.Topics.route, - Route.Bookmarks.route, - Route.Moments.route -> TopBarWithBackButton(navController) + //Route.Profile.route -> TopBarWithBackButton(navController) else -> MainTopBar(scaffoldState, accountViewModel) } + } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 0d1c4ed1c..0c319dd73 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Divider import androidx.compose.material.Icon @@ -35,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight.Companion.W500 import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavController import androidx.navigation.NavHostController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R @@ -43,14 +43,6 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.launch -val bottomNavigations = listOf( - Route.Profile, - Route.Lists, - //Route.Topics, - Route.Bookmarks, - //Route.Moments -) - @Composable fun DrawerContent(navController: NavHostController, scaffoldState: ScaffoldState, @@ -66,19 +58,31 @@ fun DrawerContent(navController: NavHostController, ) { Column() { Box { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = "Profile Banner", - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) + val banner = accountUser?.info?.banner + if (banner != null && banner.isNotBlank()) { + AsyncImage( + model = banner, + contentDescription = "Profile Image", + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().height(150.dp) + ) + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = "Profile Banner", + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().height(150.dp) + ) + } ProfileContent( accountUser, modifier = Modifier .fillMaxWidth() .padding(horizontal = 25.dp) - .padding(top = 125.dp) + .padding(top = 100.dp), + scaffoldState, + navController ) } Divider( @@ -86,6 +90,7 @@ fun DrawerContent(navController: NavHostController, modifier = Modifier.padding(top = 20.dp) ) ListContent( + accountUser, navController, scaffoldState, modifier = Modifier @@ -98,7 +103,9 @@ fun DrawerContent(navController: NavHostController, } @Composable -fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier) { +fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) { + val coroutineScope = rememberCoroutineScope() + Column(modifier = modifier) { AsyncImage( model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png", @@ -108,6 +115,14 @@ fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier) { .clip(shape = CircleShape) .border(3.dp, MaterialTheme.colors.background, CircleShape) .background(MaterialTheme.colors.background) + .clickable(onClick = { + accountUser?.let { + navController.navigate("User/${it.pubkeyHex}") + } + coroutineScope.launch { + scaffoldState.drawerState.close() + } + }) ) Text( accountUser?.bestDisplayName() ?: "", @@ -131,21 +146,25 @@ fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier) { @Composable fun ListContent( + accountUser: User?, navController: NavHostController, scaffoldState: ScaffoldState, modifier: Modifier, accountViewModel: AccountStateViewModel ) { - Column( - modifier = modifier - ) { + Column(modifier = modifier) { LazyColumn() { - items(items = bottomNavigations) { - NavigationRow(navController, scaffoldState, it) - } item { + if (accountUser != null) + NavigationRow(navController, + scaffoldState, + "User/${accountUser.pubkeyHex}", + Route.Profile.icon, + "Profile" + ) + Divider( - modifier = Modifier.padding(vertical = 15.dp), + modifier = Modifier.padding(bottom = 15.dp), thickness = 0.25.dp ) Column(modifier = modifier.padding(horizontal = 25.dp)) { @@ -171,31 +190,36 @@ fun ListContent( } @Composable -fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState, route: Route) { +fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState, route: String, icon: Int, title: String) { val coroutineScope = rememberCoroutineScope() val currentRoute = currentRoute(navController) - Row( - modifier = Modifier - .padding(vertical = 15.dp, horizontal = 25.dp) - .clickable(onClick = { - if (currentRoute != route.route) { - navController.navigate(route.route) - } - coroutineScope.launch { - scaffoldState.drawerState.close() - } - }), - verticalAlignment = Alignment.CenterVertically + Row(modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { + if (currentRoute != route) { + navController.navigate(route) + } + coroutineScope.launch { + scaffoldState.drawerState.close() + } + }) ) { - Icon( - painter = painterResource(route.icon), null, - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colors.primary - ) - Text( - modifier = Modifier.padding(start = 16.dp), - text = route.route, - fontSize = 18.sp, - ) + Row( + modifier = Modifier.fillMaxWidth() + .padding(vertical = 15.dp, horizontal = 25.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(icon), null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colors.primary + ) + Text( + modifier = Modifier.padding(start = 16.dp), + text = title, + fontSize = 18.sp, + ) + } } + } \ No newline at end of file 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 e911f30da..625f7160e 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 @@ -30,11 +30,11 @@ sealed class Route( object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, nav -> { _ -> SearchScreen(acc, nav) }}) object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, nav -> { _ -> NotificationScreen(acc, nav) }}) object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, nav -> { _ -> ChatroomListScreen(acc, nav) }}) - object 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) }}) - object Bookmarks : Route("Bookmarks", R.drawable.ic_bookmarks, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }}) - object Moments : Route("Moments", R.drawable.ic_moments, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }}) + + object Profile : Route("User/{id}", R.drawable.ic_profile, + arguments = listOf(navArgument("id") { type = NavType.StringType } ), + buildScreen = { acc, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) }} + ) object Note : Route("Note/{id}", R.drawable.ic_moments, arguments = listOf(navArgument("id") { type = NavType.StringType } ), @@ -56,12 +56,6 @@ val Routes = listOf( //drawer Route.Profile, - Route.Lists, - Route.Topics, - Route.Bookmarks, - Route.Moments, - - //inner Route.Note, Route.Room ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt index 65722a600..bbc775985 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -71,6 +72,11 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, acc .width(35.dp) .height(35.dp) .clip(shape = CircleShape) + .clickable(onClick = { + userState?.let { + navController.navigate("User/${it.user.pubkeyHex}") + } + }) ) } } 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 index b9039a9da..007db6fb3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -52,7 +52,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr Column(modifier = Modifier.clickable( - onClick = { navController.navigate("Room/${userToComposeOn?.pubkeyHex}") } + onClick = { navController.navigate("Room/${userToComposeOn?.pubkeyHex}") } ) ) { Row( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt index 12bedacee..49be9486d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -71,6 +72,11 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn .width(35.dp).height(35.dp) .height(35.dp) .clip(shape = CircleShape) + .clickable(onClick = { + userState?.let { + navController.navigate("User/${it.user.pubkeyHex}") + } + }) ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 65eafaaf6..b9baaad6f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.note 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 @@ -77,6 +78,11 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool modifier = Modifier .width(55.dp).height(55.dp) .clip(shape = CircleShape) + .clickable(onClick = { + author?.let { + navController.navigate("User/${it.pubkeyHex}") + } + }) ) // boosted picture @@ -91,12 +97,23 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool .align(Alignment.BottomEnd) .background(MaterialTheme.colors.background) .border(2.dp, MaterialTheme.colors.primary, CircleShape) + .clickable(onClick = { + boostedPosts[0].author?.let { + navController.navigate("User/${it.pubkeyHex}") + } + }) ) } } } - Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) { + Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp) + .clickable(onClick = { + note?.let { + navController.navigate("Note/${note.idHex}") + } + }) + ) { Row(verticalAlignment = Alignment.CenterVertically) { if (author != null) UserDisplay(author) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt new file mode 100644 index 000000000..3112320d6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -0,0 +1,80 @@ +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.height +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.User +import com.vitorpamplona.amethyst.ui.screen.FollowButton +import com.vitorpamplona.amethyst.ui.screen.UnfollowButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + + val userState by baseUser.live.observeAsState() + val user = userState?.user ?: return + + Column(modifier = + Modifier.clickable( + onClick = { navController.navigate("User/${user.pubkeyHex}") } + ) + ) { + Row( + modifier = Modifier + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp) + ) { + + AsyncImage( + model = user.profilePicture(), + contentDescription = "Profile Image", + modifier = Modifier + .width(55.dp).height(55.dp) + .clip(shape = CircleShape) + ) + + Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserDisplay(user) + } + + Text( + user.info.about?.take(100) ?: "", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + + Column(modifier = Modifier.padding(start = 10.dp)) { + if (accountState?.account?.userProfile()?.follows?.contains(user) == true) { + UnfollowButton { accountState?.account?.unfollow(user) } + } else { + FollowButton { accountState?.account?.follow(user) } + } + } + } + + 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/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 1d52992f4..75a8fbb8e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class CardFeedViewModel(val dataSource: NostrDataSource): ViewModel() { +class CardFeedViewModel(val dataSource: NostrDataSource): ViewModel() { private val _feedContent = MutableStateFlow(CardFeedState.Loading) val feedContent = _feedContent.asStateFlow() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index c072818e1..c8aff13d6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class FeedViewModel(val dataSource: NostrDataSource): ViewModel() { +class FeedViewModel(val dataSource: NostrDataSource): ViewModel() { private val _feedContent = MutableStateFlow(FeedState.Loading) val feedContent = _feedContent.asStateFlow() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index e368bec03..05638b069 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -164,7 +165,14 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController Modifier .fillMaxWidth() .padding(top = 10.dp)) { - Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp)) { + Row(modifier = Modifier + .padding(start = 12.dp, end = 12.dp) + .clickable(onClick = { + author?.let { + navController.navigate("User/${it.pubkeyHex}") + } + }) + ) { // Draws the boosted picture outside the boosted card. AsyncImage( model = author?.profilePicture(), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt new file mode 100644 index 000000000..61e24c462 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt @@ -0,0 +1,10 @@ +package com.vitorpamplona.amethyst.ui.screen + +import com.vitorpamplona.amethyst.model.User + +sealed class UserFeedState { + object Loading : UserFeedState() + class Loaded(val feed: List) : UserFeedState() + object Empty : UserFeedState() + class FeedError(val errorMessage: String) : UserFeedState() +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt new file mode 100644 index 000000000..2c388375d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -0,0 +1,78 @@ +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.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun UserFeedView(viewModel: UserFeedViewModel, 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 UserFeedState.Empty -> { + FeedEmpty { + isRefreshing = true + } + } + is UserFeedState.FeedError -> { + FeedError(state.errorMessage) { + isRefreshing = true + } + } + is UserFeedState.Loaded -> { + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp, + bottom = 10.dp + ), + state = listState + ) { + itemsIndexed(state.feed, key = { _, item -> item.pubkeyHex }) { index, item -> + UserCompose(item, accountViewModel = accountViewModel, navController = navController) + } + } + } + UserFeedState.Loading -> { + LoadingFeed() + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt new file mode 100644 index 000000000..83bc35dc1 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -0,0 +1,79 @@ +package com.vitorpamplona.amethyst.ui.screen + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.LocalCacheState +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrDataSource +import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource +import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class NostrUserProfileFollowsUserFeedViewModel(): UserFeedViewModel( + NostrUserProfileFollowsDataSource +) + +class NostrUserProfileFollowersUserFeedViewModel(): UserFeedViewModel( + NostrUserProfileFollowersDataSource +) + +open class UserFeedViewModel(val dataSource: NostrDataSource): ViewModel() { + private val _feedContent = MutableStateFlow(UserFeedState.Loading) + val feedContent = _feedContent.asStateFlow() + + fun refresh() { + // For some reason, view Model Scope doesn't call + viewModelScope.launch { + refreshSuspend() + } + } + + fun refreshSuspend() { + val notes = dataSource.loadTop() + + val oldNotesState = feedContent.value + if (oldNotesState is UserFeedState.Loaded) { + if (notes != oldNotesState.feed) { + updateFeed(notes) + } + } else { + updateFeed(notes) + } + } + + fun updateFeed(notes: List) { + if (notes.isEmpty()) { + _feedContent.update { UserFeedState.Empty } + } else { + _feedContent.update { UserFeedState.Loaded(notes) } + } + } + + fun refreshCurrentList() { + val state = feedContent.value + if (state is UserFeedState.Loaded) { + _feedContent.update { UserFeedState.Loaded(state.feed) } + } + } + + private val cacheListener: (LocalCacheState) -> Unit = { + refresh() + } + + init { + LocalCache.live.observeForever(cacheListener) + } + + override fun onCleared() { + LocalCache.live.removeObserver(cacheListener) + + dataSource.stop() + viewModelScope.cancel() + super.onCleared() + } +} \ No newline at end of file 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 index 7b4e09992..77c7ecb2f 100644 --- 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -102,9 +103,9 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro Column(modifier = Modifier .padding(12.dp) - //.clickable( - //onClick = { navController.navigate("User/${author?.pubkeyHex}") } - //) + .clickable( + onClick = { navController.navigate("User/${author?.pubkeyHex}") } + ) ) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 923926846..8aa501495 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -15,9 +15,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) { - val account by accountViewModel.accountLiveData.observeAsState() + val accountState by accountViewModel.accountLiveData.observeAsState() - if (account != null) { + if (accountState != null) { val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrHomeDataSource ) } Column(Modifier.fillMaxHeight()) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index ef9b086db..398d3063c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -5,7 +5,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material.* +import androidx.compose.material.DrawerValue +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.rememberDrawerState +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index df3d4c9e7..95d4891d4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -1,29 +1,346 @@ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text -import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.pagerTabIndicatorOffset +import com.google.accompanist.pager.rememberPagerState +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource +import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource +import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.launch +import nostr.postr.toNpub + +data class TabRowItem( + val title: String, + val screen: @Composable () -> Unit, +) + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account + + val accountUserState by accountViewModel.userLiveData.observeAsState() + val accountUser = accountUserState?.user + + val clipboardManager = LocalClipboardManager.current + + if (userId != null && account != null && accountUser != null) { + DisposableEffect(account) { + NostrUserProfileDataSource.loadUserProfile(userId) + NostrUserProfileFollowersDataSource.loadUserProfile(userId) + NostrUserProfileFollowsDataSource.loadUserProfile(userId) + + onDispose { + NostrUserProfileDataSource.stop() + NostrUserProfileFollowsDataSource.stop() + NostrUserProfileFollowersDataSource.stop() + } + } + + val baseUser = NostrUserProfileDataSource.user ?: return + + val userState by baseUser.live.observeAsState() + val user = userState?.user ?: return + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colors.background + ) { + Column() { + Box { + val banner = user.info.banner + if (banner != null && banner.isNotBlank()) { + AsyncImage( + model = banner, + contentDescription = "Profile Image", + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().height(125.dp) + ) + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = "Profile Banner", + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().height(125.dp) + ) + } + + Column(modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp) + .padding(top = 75.dp)) { + + Row(horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom) { + AsyncImage( + model = user.profilePicture(), + contentDescription = "Profile Image", + modifier = Modifier + .width(100.dp) + .height(100.dp) + .clip(shape = CircleShape) + .border(3.dp, MaterialTheme.colors.background, CircleShape) + .background(MaterialTheme.colors.background) + ) + + Spacer(Modifier.weight(1f)) + + MessageButton(user, navController) + + NPubCopyButton(clipboardManager, user) + + if (accountUser == user) { + EditButton() + } else { + if (accountUser.follows?.contains(user) == true) { + UnfollowButton { account.unfollow(user) } + } else { + FollowButton { account.follow(user) } + } + } + } + + Text( + user.bestDisplayName() ?: "", + modifier = Modifier.padding(top = 7.dp), + fontWeight = FontWeight.Bold, + fontSize = 25.sp + ) + Text(" @${user.bestUsername()}", color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)) + Text( + "${user.info.about}", + color = Color.White, + modifier = Modifier.padding(top = 5.dp, bottom = 5.dp) + ) + + Divider(modifier = Modifier.padding(top = 6.dp)) + } + } + + val pagerState = rememberPagerState() + val coroutineScope = rememberCoroutineScope() + + Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) { + TabRow( + selectedTabIndex = pagerState.currentPage, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), + color = MaterialTheme.colors.primary + ) + }, + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + text = { + Text(text = "Notes") + } + ) + + Tab( + selected = pagerState.currentPage == 1, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + text = { + Text(text = "${user.follows?.size ?: "--"} Following") + } + ) + + Tab( + selected = pagerState.currentPage == 2, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + text = { + Text(text = "${user.followers?.size ?: "--"} Followers") + } + ) + } + HorizontalPager(count = 3, state = pagerState) { + when (pagerState.currentPage) { + 0 -> TabNotes(user, accountViewModel, navController) + 1 -> TabFollows(user, accountViewModel, navController) + 2 -> TabFollowers(user, accountViewModel, navController) + } + } + } + } + } + } +} @Composable -fun ProfileScreen(accountViewModel: AccountViewModel) { - val state = rememberScaffoldState() - val scope = rememberCoroutineScope() +fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + if (accountState != null) { + val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrUserProfileDataSource ) } - Column( - Modifier - .fillMaxHeight() - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text("Profile Screen") + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + FeedView(feedViewModel, accountViewModel, navController) + } + } } -} \ No newline at end of file +} + +@Composable +fun TabFollows(user: User, accountViewModel: AccountViewModel, navController: NavController) { + val feedViewModel: NostrUserProfileFollowsUserFeedViewModel = viewModel() + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + UserFeedView(feedViewModel, accountViewModel, navController) + } + } +} + +@Composable +fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController: NavController) { + val feedViewModel: NostrUserProfileFollowersUserFeedViewModel = viewModel() + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + UserFeedView(feedViewModel, accountViewModel, navController) + } + } +} + +@Composable +private fun NPubCopyButton( + clipboardManager: ClipboardManager, + user: User +) { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())) }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ), + ) { + Text(text = "npub", color = Color.White) + } +} + +@Composable +private fun MessageButton(user: User, navController: NavController) { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { navController.navigate("Room/${user.pubkeyHex}") }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_dm), + "Send a Direct Message", + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + } +} + +@Composable +private fun EditButton() { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = {}, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = "Edit", color = Color.White) + } +} + +@Composable +fun UnfollowButton(onClick: () -> Unit) { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = "Unfollow", color = Color.White) + } +} + +@Composable +fun FollowButton(onClick: () -> Unit) { + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = "Follow", color = Color.White) + } +} + + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt index bcc8acab5..0682c7eb0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier @@ -17,6 +18,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navController: NavController) { val account by accountViewModel.accountLiveData.observeAsState() + DisposableEffect(account) { + onDispose { + NostrThreadDataSource.stop() + } + } + if (account != null && noteId != null) { NostrThreadDataSource.loadThread(noteId) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 8079c5544..b6446afeb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.theme.Purple700 @Composable fun LoginPage(accountViewModel: AccountStateViewModel) { diff --git a/build.gradle b/build.gradle index d0d06ade4..89946fa3b 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ buildscript { compose_ui_version = '1.3.3' nav_version = "2.5.3" room_version = "2.4.3" + accompanist_version = "0.28.0" } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins {