diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index df3ec608d..a0f66a65d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -55,6 +55,7 @@ private object PrefKeys { const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" + const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" @@ -216,6 +217,7 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList) putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList) + putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList) putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) @@ -256,6 +258,7 @@ object LocalPreferences { val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val zapAmountChoices = gson.fromJson( getString(PrefKeys.ZAP_AMOUNTS, "[]"), @@ -349,6 +352,7 @@ object LocalPreferences { defaultHomeFollowList = defaultHomeFollowList, defaultStoriesFollowList = defaultStoriesFollowList, defaultNotificationFollowList = defaultNotificationFollowList, + defaultDiscoveryFollowList = defaultDiscoveryFollowList, zapPaymentRequest = zapPaymentRequestServer, hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 114d88190..30a7b9de7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource +import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource @@ -66,6 +67,7 @@ object ServiceManager { NostrHomeDataSource.account = myAccount NostrChatroomListDataSource.account = myAccount NostrVideoDataSource.account = myAccount + NostrDiscoveryDataSource.account = myAccount ImageUploader.account = myAccount // Notification Elements @@ -87,6 +89,7 @@ object ServiceManager { NostrHomeDataSource.stop() NostrChannelDataSource.stop() NostrChatroomListDataSource.stop() + NostrDiscoveryDataSource.stop() NostrSingleChannelDataSource.stop() NostrSingleEventDataSource.stop() 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 b935ce564..e948889d8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -62,6 +62,7 @@ class Account( var defaultHomeFollowList: String = KIND3_FOLLOWS, var defaultStoriesFollowList: String = GLOBAL_FOLLOWS, var defaultNotificationFollowList: String = GLOBAL_FOLLOWS, + var defaultDiscoveryFollowList: String = GLOBAL_FOLLOWS, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -899,6 +900,12 @@ class Account( saveable.invalidateData() } + fun changeDefaultDiscoveryFollowList(name: String) { + defaultDiscoveryFollowList = name + live.invalidateData() + saveable.invalidateData() + } + fun changeZapAmounts(newAmounts: List) { zapAmountChoices = newAmounts live.invalidateData() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index ef685134a..860a699af 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -279,6 +279,20 @@ open class Note(val idHex: String) { } } + fun publicZapAuthors(): Set { + // Zaps who the requester was the user + return zaps.mapNotNull { + it.key.author?.pubkeyHex + }.toSet() + } + + fun reactionAuthors(): Set { + // Zaps who the requester was the user + return reactions.values.map { + it.mapNotNull { it.author?.pubkeyHex } + }.flatten().toSet() + } + fun isReactedBy(user: User): String? { return reactions.filter { it.value.any { it.author?.pubkeyHex == user.pubkeyHex } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt new file mode 100644 index 000000000..03187ef92 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -0,0 +1,57 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter + +object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { + lateinit var account: Account + + fun createContextualFilter(): TypedFilter { + val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) + + val followKeys = follows?.map { + it.substring(0, 6) + } + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + authors = followKeys, + kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), + limit = 1000 + ) + ) + } + + fun createFollowTagsFilter(): TypedFilter? { + val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) + + if (hashToLoad.isNullOrEmpty()) return null + + return TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), + tags = mapOf( + "t" to hashToLoad.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 1000 + ) + ) + } + + val discoveryFeedChannel = requestNewChannel() + + override fun updateChannelFilters() { + discoveryFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesEvent.kt index 6b70eeaaa..e7b67dc68 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesEvent.kt @@ -25,15 +25,27 @@ class LiveActivitiesEvent( fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1) fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1) fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1) - fun status() = tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1) + fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)) fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1) fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1) fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) } + fun checkStatus(eventStatus: String?): String? { + return if (eventStatus == STATUS_LIVE && createdAt < Date().time / 1000 - (60 * 60 * 8)) { // 2 hours { + STATUS_ENDED + } else { + eventStatus + } + } + companion object { const val kind = 30311 + const val STATUS_LIVE = "live" + const val STATUS_PLANNED = "planned" + const val STATUS_ENDED = "ended" + fun create( privateKey: ByteArray, createdAt: Long = Date().time / 1000 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverFeedFilter.kt new file mode 100644 index 000000000..c722b249c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverFeedFilter.kt @@ -0,0 +1,66 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED + +class DiscoverFeedFilter(val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList + } + + override fun feed(): List { + val allChannelNotes = LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) } + val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten() + + val notes = innerApplyFilter(allChannelNotes + allMessageNotes) + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + val now = System.currentTimeMillis() / 1000 + val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS + + val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet() + val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet() + + val activities = collection + .asSequence() + .filter { it.event is LiveActivitiesEvent } + .filter { isGlobal || it.author?.pubkeyHex in followingKeySet } + .filter { account.isAcceptable(it) } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() + + return activities + } + + override fun sort(collection: Set): List { + return collection.sortedWith( + compareBy( + { convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) }, + { it.createdAt() }, + { it.idHex } + ) + ).reversed() + } + + fun convertStatusToOrder(status: String?): Int { + return when (status) { + STATUS_LIVE -> 2 + STATUS_PLANNED -> 1 + STATUS_ENDED -> 0 + else -> 0 + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeLiveActivitiesFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeLiveActivitiesFeedFilter.kt index 41f98fabf..04b114979 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeLiveActivitiesFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeLiveActivitiesFeedFilter.kt @@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE import java.util.Date class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter() { @@ -41,7 +42,7 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter val noteEvent = it.event - (noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == "live" && OnlineChecker.isOnline(noteEvent.streaming())) && + (noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming())) && (isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index 1d2e5b942..9fce69507 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -48,6 +48,7 @@ val bottomNavigationItems = listOf( Route.Home, Route.Message, Route.Video, + Route.Discover, Route.Notification ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index e3de2e004..30cb63e8e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -12,6 +12,7 @@ import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel @@ -22,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen @@ -42,6 +44,7 @@ fun AppNavigation( knownFeedViewModel: NostrChatroomListKnownFeedViewModel, newFeedViewModel: NostrChatroomListNewFeedViewModel, videoFeedViewModel: NostrVideoFeedViewModel, + discoveryFeedViewModel: NostrDiscoverFeedViewModel, notifFeedViewModel: NotificationViewModel, userReactionsStatsModel: UserReactionsViewModel, @@ -106,6 +109,16 @@ fun AppNavigation( }) } + Route.Discover.let { route -> + composable(route.route, route.arguments, content = { + DiscoverScreen( + discoveryFeedViewModel = discoveryFeedViewModel, + accountViewModel = accountViewModel, + nav = nav + ) + }) + } + Route.Search.let { route -> composable(route.route, route.arguments, content = { SearchScreen( 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 dfcc348b5..223173a13 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 @@ -118,6 +118,7 @@ private fun RenderTopRouteBar( // Route.Profile.route -> TopBarWithBackButton(nav) Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav) Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel, nav) + Route.Discover.base -> DiscoveryTopBar(followLists, scaffoldState, accountViewModel, nav) Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel, nav) else -> MainTopBar(scaffoldState, accountViewModel, nav) } @@ -178,6 +179,23 @@ fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: Scaffold } } +@Composable +fun DiscoveryTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel -> + val list by accountViewModel.accountLiveData.map { + it.account.defaultDiscoveryFollowList + }.observeAsState(GLOBAL_FOLLOWS) + + FollowList( + followLists, + list, + true + ) { listName -> + accountViewModel.account.changeDefaultDiscoveryFollowList(listName) + } + } +} + @Composable fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { GenericTopBar(scaffoldState, accountViewModel, nav) { 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 b4b7b086d..46c6fb4df 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 @@ -57,6 +57,11 @@ sealed class Route( icon = R.drawable.ic_video ) + object Discover : Route( + route = "Discover", + icon = R.drawable.ic_sensors + ) + object Notification : Route( route = "Notification", icon = R.drawable.ic_notifications, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt new file mode 100644 index 000000000..2aaaf0be1 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -0,0 +1,480 @@ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.BottomStart +import androidx.compose.ui.Alignment.Companion.TopEnd +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED +import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChannelCardCompose( + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val isBlank by baseNote.live().metadata.map { + it.note.event == null + }.distinctUntilChanged().observeAsState(baseNote.event == null) + + Crossfade(targetState = isBlank) { + if (it) { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + BlankNote( + remember { + modifier.combinedClickable( + onClick = { }, + onLongClick = showPopup + ) + }, + false + ) + } + } else { + CheckHiddenChannelCardCompose( + baseNote, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav + ) + } + } +} + +@Composable +fun CheckHiddenChannelCardCompose( + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val isHidden by accountViewModel.accountLiveData.map { + accountViewModel.isNoteHidden(note) + }.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note)) + + Crossfade(targetState = isHidden) { + if (!it) { + LoadedChannelCardCompose( + note, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav + ) + } + } +} + +@Composable +fun LoadedChannelCardCompose( + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + var state by remember { + mutableStateOf( + NoteComposeReportState( + isAcceptable = true, + canPreview = true, + relevantReports = persistentSetOf() + ) + ) + } + + val scope = rememberCoroutineScope() + + WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports -> + if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) { + val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports) + scope.launch(Dispatchers.Main) { + state = newState + } + } + } + + Crossfade(targetState = state) { + RenderChannelCardReportState( + it, + note, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav + ) + } +} + +@Composable +fun RenderChannelCardReportState( + state: NoteComposeReportState, + note: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + var showReportedNote by remember { mutableStateOf(false) } + + Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote -> + if (showHiddenNote) { + HiddenNote( + state.relevantReports, + accountViewModel, + modifier, + false, + nav, + onClick = { showReportedNote = true } + ) + } else { + NormalChannelCard( + note, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + nav + ) + } + } +} + +@Composable +fun NormalChannelCard( + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + CheckNewAndRenderChannelCard( + baseNote, + routeForLastRead, + modifier, + parentBackgroundColor, + accountViewModel, + showPopup, + nav + ) + } +} + +@Composable +private fun CheckNewAndRenderChannelCard( + baseNote: Note, + routeForLastRead: String? = null, + modifier: Modifier = Modifier, + parentBackgroundColor: MutableState? = null, + accountViewModel: AccountViewModel, + showPopup: () -> Unit, + nav: (String) -> Unit +) { + val newItemColor = MaterialTheme.colors.newItemBackgroundColor + val defaultBackgroundColor = MaterialTheme.colors.background + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + + LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) { + launch(Dispatchers.IO) { + routeForLastRead?.let { + val lastTime = accountViewModel.account.loadLastRead(it) + + val createdAt = baseNote.createdAt() + if (createdAt != null) { + accountViewModel.account.markAsRead(it, createdAt) + + val isNew = createdAt > lastTime + + val newBackgroundColor = if (isNew) { + if (parentBackgroundColor != null) { + newItemColor.compositeOver(parentBackgroundColor.value) + } else { + newItemColor.compositeOver(defaultBackgroundColor) + } + } else { + parentBackgroundColor?.value ?: defaultBackgroundColor + } + + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { + backgroundColor.value = newBackgroundColor + } + } + } + } ?: run { + val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor + + if (newBackgroundColor != backgroundColor.value) { + launch(Dispatchers.Main) { + backgroundColor.value = newBackgroundColor + } + } + } + } + } + + ClickableNote( + baseNote = baseNote, + backgroundColor = backgroundColor, + modifier = modifier, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav + ) { + InnerChannelCardWithReactions( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav + ) + } +} + +@Composable +fun InnerChannelCardWithReactions( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + Column() { + RenderNoteRow( + baseNote, + accountViewModel, + nav + ) + } +} + +@Composable +private fun RenderNoteRow( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + when (remember { baseNote.event }) { + is LiveActivitiesEvent -> { + RenderLiveActivityThumb(baseNote, accountViewModel, nav) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return + + val eventUpdates by baseNote.live().metadata.observeAsState() + + val media = remember(eventUpdates) { noteEvent.streaming() } + val cover by remember(eventUpdates) { + derivedStateOf { + noteEvent.image()?.ifBlank { null } + } + } + val subject = remember(eventUpdates) { noteEvent.title()?.ifBlank { null } } + val content = remember(eventUpdates) { noteEvent.summary() } + val participants = remember(eventUpdates) { noteEvent.participants() } + val status = remember(eventUpdates) { noteEvent.status() } + + var isOnline by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = media) { + launch(Dispatchers.IO) { + val newIsOnline = OnlineChecker.isOnline(media) + if (isOnline != newIsOnline) { + isOnline = newIsOnline + } + } + } + + var participantUsers by remember { + mutableStateOf>( + persistentListOf() + ) + } + + LaunchedEffect(key1 = eventUpdates) { + launch(Dispatchers.IO) { + val channel = LocalCache.getChannelIfExists(baseNote.idHex) + val repliesByFollows = channel?.notes?.mapNotNull { it.value.author?.pubkeyHex }?.toSet() ?: emptySet() + + val zappers = baseNote.publicZapAuthors() + val likeAuthors = baseNote.reactionAuthors() + + val allParticipants = (repliesByFollows + zappers + likeAuthors).filter { accountViewModel.isFollowing(it) } + + val newParticipantUsers = ( + participants.mapNotNull { part -> + if (part.key != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(part.key) + } else { + null + } + } + allParticipants.mapNotNull { + if (it != baseNote.author?.pubkeyHex) { + LocalCache.checkGetOrCreateUser(it) + } else { + null + } + } + ).toImmutableList() + + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Box( + contentAlignment = TopEnd, + modifier = Modifier + .aspectRatio(ratio = 16f / 9f) + .fillMaxWidth() + ) { + cover?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(QuoteBorder) + ) + } ?: run { + baseNote.author?.let { + DisplayAuthorBanner(it) + } + } + + Box(Modifier.padding(10.dp)) { + Crossfade(targetState = status) { + when (it) { + STATUS_LIVE -> { + if (media.isNullOrBlank()) { + LiveFlag() + } else if (isOnline) { + LiveFlag() + } else { + OfflineFlag() + } + } + STATUS_ENDED -> { + EndedFlag() + } + STATUS_PLANNED -> { + ScheduledFlag() + } + else -> { + EndedFlag() + } + } + } + } + + Box(Modifier.padding(10.dp).align(BottomStart)) { + if (participantUsers.isNotEmpty()) { + FlowRow() { + participantUsers.forEach { + ClickableUserPicture(it, Size35dp, accountViewModel) + } + } + } + } + } + + ChannelHeader( + channelHex = remember { baseNote.idHex }, + showVideo = false, + showBottomDiviser = false, + showFlag = false, + modifier = remember { + Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp) + }, + accountViewModel = accountViewModel, + nav = nav + ) + } +} + +@Composable +fun DisplayAuthorBanner(author: User) { + val picture by author.live().metadata.map { + it.user.info?.banner?.ifBlank { null } ?: it.user.info?.picture?.ifBlank { null } + }.observeAsState() + + AsyncImage( + model = picture, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(QuoteBorder) + ) +} 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 455e638ce..9b08d82e0 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 @@ -108,6 +108,8 @@ import com.vitorpamplona.amethyst.service.model.GenericRepostEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.Participant import com.vitorpamplona.amethyst.service.model.PeopleListEvent @@ -538,7 +540,7 @@ private fun CheckNewAndRenderNote( @Composable @OptIn(ExperimentalFoundationApi::class) -private fun ClickableNote( +fun ClickableNote( baseNote: Note, modifier: Modifier, backgroundColor: MutableState, @@ -549,7 +551,7 @@ private fun ClickableNote( ) { val scope = rememberCoroutineScope() - val updatedModifier = remember(backgroundColor.value) { + val updatedModifier = remember(baseNote, backgroundColor.value) { modifier .combinedClickable( onClick = { @@ -1785,7 +1787,7 @@ private fun ReplyNoteComposition( } @Composable -private fun SecondUserInfoRow( +fun SecondUserInfoRow( note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -1809,7 +1811,7 @@ private fun SecondUserInfoRow( } @Composable -private fun FirstUserInfoRow( +fun FirstUserInfoRow( baseNote: Note, showAuthorPicture: Boolean, accountViewModel: AccountViewModel, @@ -1854,7 +1856,7 @@ private fun BoostedMark() { } @Composable -private fun MoreOptionsButton( +fun MoreOptionsButton( baseNote: Note, accountViewModel: AccountViewModel ) { @@ -2690,12 +2692,12 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo Crossfade(targetState = status) { when (it) { - "live" -> { + STATUS_LIVE -> { if (isOnline) { LiveFlag() } } - "planned" -> { + STATUS_PLANNED -> { ScheduledFlag() } } @@ -2744,7 +2746,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo } media?.let { media -> - if (status == "live") { + if (status == STATUS_LIVE) { if (isOnline) { Row( verticalAlignment = Alignment.CenterVertically, @@ -2841,7 +2843,7 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView } @Composable -private fun CreateImageHeader( +fun CreateImageHeader( note: Note, accountViewModel: AccountViewModel ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 34c567cb2..847bf78d7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner @@ -33,20 +34,20 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable -fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) { +fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true) { val authorState by baseNote.live().metadata.map { it.note.author }.observeAsState(baseNote.author) Crossfade(targetState = authorState, modifier = weight) { it?.let { - UsernameDisplay(it, weight) + UsernameDisplay(it, weight, showPlayButton) } } } @Composable -fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) { +fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true) { val npubDisplay by remember { derivedStateOf { baseUser.pubkeyDisplayHex() @@ -59,7 +60,7 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) { Crossfade(targetState = userMetadata, modifier = weight) { if (it != null) { - UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight) + UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton) } else { NPubDisplay(npubDisplay, weight) } @@ -72,14 +73,15 @@ private fun UserNameDisplay( bestDisplayName: String?, npubDisplay: String, tags: ImmutableListOfLists?, - modifier: Modifier + modifier: Modifier, + showPlayButton: Boolean = true ) { if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) { - UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier) + UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier, showPlayButton) } else if (bestDisplayName != null) { - UserDisplay(bestDisplayName, tags, modifier) + UserDisplay(bestDisplayName, tags, modifier, showPlayButton) } else if (bestUserName != null) { - UserDisplay(bestUserName, tags, modifier) + UserDisplay(bestUserName, tags, modifier, showPlayButton) } else { NPubDisplay(npubDisplay, modifier) } @@ -100,9 +102,10 @@ private fun NPubDisplay(npubDisplay: String, modifier: Modifier) { private fun UserDisplay( bestDisplayName: String, tags: ImmutableListOfLists?, - modifier: Modifier + modifier: Modifier, + showPlayButton: Boolean = true ) { - Row(modifier = modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { CreateTextWithEmoji( text = bestDisplayName, tags = tags, @@ -111,8 +114,10 @@ private fun UserDisplay( overflow = TextOverflow.Ellipsis, modifier = modifier ) - Spacer(StdHorzSpacer) - DrawPlayName(bestDisplayName) + if (showPlayButton) { + Spacer(StdHorzSpacer) + DrawPlayName(bestDisplayName) + } } } @@ -121,9 +126,10 @@ private fun UserAndUsernameDisplay( bestDisplayName: String, tags: ImmutableListOfLists?, bestUserName: String, - modifier: Modifier + modifier: Modifier, + showPlayButton: Boolean = true ) { - Row(modifier = modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { CreateTextWithEmoji( text = bestDisplayName, tags = tags, @@ -138,8 +144,10 @@ private fun UserAndUsernameDisplay( overflow = TextOverflow.Ellipsis, modifier = modifier ) - Spacer(StdHorzSpacer) - DrawPlayName(bestDisplayName) + if (showPlayButton) { + Spacer(StdHorzSpacer) + DrawPlayName(bestDisplayName) + } } } 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 8e1812a5a..26ad34684 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 @@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DiscoverFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter @@ -65,6 +66,15 @@ class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFil } } } + +class NostrDiscoverFeedViewModel(val account: Account) : FeedViewModel(DiscoverFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverFeedViewModel { + return NostrDiscoverFeedViewModel(account) as NostrDiscoverFeedViewModel + } + } +} + class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) { class Factory(val noteId: String) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrThreadFeedViewModel { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index e6d6f32a1..5d67de16d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -17,6 +17,7 @@ object ScrollStateKeys { const val GLOBAL_SCREEN = "Global" const val NOTIFICATION_SCREEN = "Notifications" const val VIDEO_SCREEN = "Video" + const val DISCOVER_SCREEN = "Discover" val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_REPLIES = Route.Home.base + "FollowsReplies" } 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 20182dd47..6456340a9 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 @@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountState +import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState @@ -241,6 +242,10 @@ class AccountViewModel(val account: Account) : ViewModel() { return account.userProfile().isFollowingCached(user) } + fun isFollowing(user: HexKey): Boolean { + return account.userProfile().isFollowingCached(user) + } + val hideDeleteRequestDialog: Boolean get() = account.hideDeleteRequestDialog diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 3f2609b3f..d0bd0ae9c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.service.NostrChannelDataSource +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE import com.vitorpamplona.amethyst.ui.actions.NewChannelView import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel @@ -516,6 +517,7 @@ fun ChannelHeader( channelHex: String, showVideo: Boolean, showBottomDiviser: Boolean, + showFlag: Boolean = true, modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -535,6 +537,7 @@ fun ChannelHeader( it, showVideo, showBottomDiviser, + showFlag, modifier, accountViewModel, nav @@ -547,6 +550,7 @@ fun ChannelHeader( baseChannel: Channel, showVideo: Boolean, showBottomDiviser: Boolean, + showFlag: Boolean = true, modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -564,27 +568,33 @@ fun ChannelHeader( val channelState by baseChannel.live.observeAsState() val channel = remember(channelState) { channelState?.channel } ?: return - val streamingUrl by remember(channelState) { - derivedStateOf { - val activity = channel as? LiveActivitiesChannel - val description = activity?.info?.title() - val url = activity?.info?.streaming() - if (url != null) { - ZoomableUrlVideo(url, description = description) - } else { - null + if (showVideo) { + val streamingUrl by remember(channelState) { + derivedStateOf { + val activity = channel as? LiveActivitiesChannel + val description = activity?.info?.title() + val url = activity?.info?.streaming() + if (url != null) { + ZoomableUrlVideo(url, description = description) + } else { + null + } + } + } + + streamingUrl?.let { + CheckIfUrlIsOnline(it.url) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = remember { Modifier.heightIn(max = 300.dp) } + ) { + ZoomableContentView( + content = it + ) + } } } } - - if (streamingUrl != null && showVideo) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) }) { - ZoomableContentView( - content = streamingUrl!! - ) - } - } - Column(modifier = modifier) { Row(verticalAlignment = Alignment.CenterVertically) { if (channel is LiveActivitiesChannel) { @@ -653,7 +663,7 @@ fun ChannelHeader( ChannelActionOptions(channel, accountViewModel, nav) } if (channel is LiveActivitiesChannel) { - LiveChannelActionOptions(channel, accountViewModel, nav) + LiveChannelActionOptions(channel, showFlag, accountViewModel, nav) } } } @@ -702,12 +712,13 @@ private fun ChannelActionOptions( @Composable private fun LiveChannelActionOptions( channel: LiveActivitiesChannel, + showFlag: Boolean = true, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { val isLive by remember(channel) { derivedStateOf { - channel.info?.status() == "live" + channel.info?.status() == STATUS_LIVE } } @@ -716,7 +727,7 @@ private fun LiveChannelActionOptions( } note?.let { - if (isLive) { + if (showFlag && isLive) { LiveFlag() Spacer(modifier = StdHorzSpacer) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt new file mode 100644 index 000000000..99e41273e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -0,0 +1,160 @@ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource +import com.vitorpamplona.amethyst.ui.note.ChannelCardCompose +import com.vitorpamplona.amethyst.ui.screen.FeedEmpty +import com.vitorpamplona.amethyst.ui.screen.FeedError +import com.vitorpamplona.amethyst.ui.screen.FeedState +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.amethyst.ui.screen.LoadingFeed +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.RefresheableView +import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState +import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys + +@Composable +fun DiscoverScreen( + discoveryFeedViewModel: NostrDiscoverFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val lifeCycleOwner = LocalLifecycleOwner.current + + WatchAccountForDiscoveryScreen(discoveryViewModel = discoveryFeedViewModel, accountViewModel = accountViewModel) + + DisposableEffect(accountViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Discovery Start") + NostrDiscoveryDataSource.start() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Discovery Stop") + NostrDiscoveryDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + RefresheableView(discoveryFeedViewModel, true) { + SaveableFeedState(discoveryFeedViewModel, scrollStateKey = ScrollStateKeys.DISCOVER_SCREEN) { listState -> + RenderDiscoverFeed(discoveryFeedViewModel, accountViewModel, listState, nav) + } + } + } + } +} + +@Composable +private fun RenderDiscoverFeed( + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit +) { + val feedState by viewModel.feedContent.collectAsState() + + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100) + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { + viewModel.invalidateData() + } + } + + is FeedState.FeedError -> { + FeedError(state.errorMessage) { + viewModel.invalidateData() + } + } + + is FeedState.Loaded -> { + DiscoverFeedLoaded( + state, + listState, + accountViewModel, + nav + ) + } + + is FeedState.Loading -> { + LoadingFeed() + } + } + } +} + +@Composable +fun WatchAccountForDiscoveryScreen(discoveryViewModel: NostrDiscoverFeedViewModel, accountViewModel: AccountViewModel) { + val accountState by accountViewModel.accountLiveData.observeAsState() + + LaunchedEffect(accountViewModel, accountState?.account?.defaultDiscoveryFollowList) { + NostrDiscoveryDataSource.resetFilters() + discoveryViewModel.checkKeysInvalidateDataAndSendToTop() + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun DiscoverFeedLoaded( + state: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp, + bottom = 10.dp + ), + state = listState + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + val defaultModifier = remember { + Modifier.padding(10.dp).animateItemPlacement() + } + + ChannelCardCompose( + item, + modifier = defaultModifier, + accountViewModel = accountViewModel, + nav = nav + ) + } + } +} 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 fd88b4a0e..ba309d19a 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 @@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.NostrHomeDataSource @@ -211,12 +212,22 @@ private fun FeedLoaded( @Composable fun CheckIfLiveActivityIsOnline(note: Note, whenOnline: @Composable () -> Unit) { - val noteState by note.live().metadata.observeAsState() + val url by note.live().metadata.map { + (note.event as? LiveActivitiesEvent)?.streaming() + }.observeAsState() + + url?.let { + CheckIfUrlIsOnline(it, whenOnline) + } +} + +@Composable +fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) { var online by remember { mutableStateOf(false) } - LaunchedEffect(key1 = noteState) { + LaunchedEffect(key1 = url) { launch(Dispatchers.IO) { - online = OnlineChecker.isOnline((note.event as? LiveActivitiesEvent)?.streaming()) + online = OnlineChecker.isOnline(url) } } 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 fa0536403..9241ea50b 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 @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel @@ -98,6 +99,11 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun factory = NostrVideoFeedViewModel.Factory(accountViewModel.account) ) + val discoveryFeedViewModel: NostrDiscoverFeedViewModel = viewModel( + key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryFeedViewModel", + factory = NostrDiscoverFeedViewModel.Factory(accountViewModel.account) + ) + val notifFeedViewModel: NotificationViewModel = viewModel( key = accountViewModel.userProfile().pubkeyHex + "NotificationViewModel", factory = NotificationViewModel.Factory(accountViewModel.account) @@ -136,6 +142,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun Route.Video.base -> { videoFeedViewModel.sendToTop() } + Route.Discover.base -> { + discoveryFeedViewModel.sendToTop() + } Route.Notification.base -> { notifFeedViewModel.invalidateDataAndSendToTop() } @@ -184,6 +193,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun knownFeedViewModel, newFeedViewModel, videoFeedViewModel, + discoveryFeedViewModel, notifFeedViewModel, userReactionsStatsModel, navController, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index f15a27e18..ba076b54a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -51,6 +51,7 @@ val Size17dp = 17.dp val Size18dp = 18.dp val Size19dp = 19.dp val Size20dp = 20.dp +val Size22dp = 22.dp val Size24dp = 24.dp val Size25dp = 25.dp val Size30dp = 30.dp diff --git a/app/src/main/res/drawable/ic_sensors.xml b/app/src/main/res/drawable/ic_sensors.xml new file mode 100644 index 000000000..8338c1838 --- /dev/null +++ b/app/src/main/res/drawable/ic_sensors.xml @@ -0,0 +1,10 @@ + + +