Base Streaming view

This commit is contained in:
Vitor Pamplona
2023-07-04 11:44:28 -04:00
parent 71cb18f360
commit ea768fb560
24 changed files with 961 additions and 51 deletions
@@ -55,6 +55,7 @@ private object PrefKeys {
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList" const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" 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_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList) 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.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) 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 defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_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( val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"), getString(PrefKeys.ZAP_AMOUNTS, "[]"),
@@ -349,6 +352,7 @@ object LocalPreferences {
defaultHomeFollowList = defaultHomeFollowList, defaultHomeFollowList = defaultHomeFollowList,
defaultStoriesFollowList = defaultStoriesFollowList, defaultStoriesFollowList = defaultStoriesFollowList,
defaultNotificationFollowList = defaultNotificationFollowList, defaultNotificationFollowList = defaultNotificationFollowList,
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
zapPaymentRequest = zapPaymentRequestServer, zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog, hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog, hideBlockAlertDialog = hideBlockAlertDialog,
@@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
@@ -66,6 +67,7 @@ object ServiceManager {
NostrHomeDataSource.account = myAccount NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
ImageUploader.account = myAccount ImageUploader.account = myAccount
// Notification Elements // Notification Elements
@@ -87,6 +89,7 @@ object ServiceManager {
NostrHomeDataSource.stop() NostrHomeDataSource.stop()
NostrChannelDataSource.stop() NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop() NostrChatroomListDataSource.stop()
NostrDiscoveryDataSource.stop()
NostrSingleChannelDataSource.stop() NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop() NostrSingleEventDataSource.stop()
@@ -62,6 +62,7 @@ class Account(
var defaultHomeFollowList: String = KIND3_FOLLOWS, var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS, var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS, var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
var defaultDiscoveryFollowList: String = GLOBAL_FOLLOWS,
var zapPaymentRequest: Nip47URI? = null, var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false, var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false,
@@ -899,6 +900,12 @@ class Account(
saveable.invalidateData() saveable.invalidateData()
} }
fun changeDefaultDiscoveryFollowList(name: String) {
defaultDiscoveryFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) { fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts zapAmountChoices = newAmounts
live.invalidateData() live.invalidateData()
@@ -279,6 +279,20 @@ open class Note(val idHex: String) {
} }
} }
fun publicZapAuthors(): Set<HexKey> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author?.pubkeyHex
}.toSet()
}
fun reactionAuthors(): Set<HexKey> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author?.pubkeyHex }
}.flatten().toSet()
}
fun isReactedBy(user: User): String? { fun isReactedBy(user: User): String? {
return reactions.filter { return reactions.filter {
it.value.any { it.author?.pubkeyHex == user.pubkeyHex } it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
@@ -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 }
}
}
@@ -25,15 +25,27 @@ class LiveActivitiesEvent(
fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1) 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 starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)
fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.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 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 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 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 { companion object {
const val kind = 30311 const val kind = 30311
const val STATUS_LIVE = "live"
const val STATUS_PLANNED = "planned"
const val STATUS_ENDED = "ended"
fun create( fun create(
privateKey: ByteArray, privateKey: ByteArray,
createdAt: Long = Date().time / 1000 createdAt: Long = Date().time / 1000
@@ -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<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun feed(): List<Note> {
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<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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<Note>): List<Note> {
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
}
}
}
@@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import java.util.Date import java.util.Date
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() { class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -41,7 +42,7 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<No
.asSequence() .asSequence()
.filter { it -> .filter { it ->
val noteEvent = it.event 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)) && (isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true
@@ -48,6 +48,7 @@ val bottomNavigationItems = listOf(
Route.Home, Route.Home,
Route.Message, Route.Message,
Route.Video, Route.Video,
Route.Discover,
Route.Notification Route.Notification
) )
@@ -12,6 +12,7 @@ import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel 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.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel 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.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen 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.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
@@ -42,6 +44,7 @@ fun AppNavigation(
knownFeedViewModel: NostrChatroomListKnownFeedViewModel, knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel, newFeedViewModel: NostrChatroomListNewFeedViewModel,
videoFeedViewModel: NostrVideoFeedViewModel, videoFeedViewModel: NostrVideoFeedViewModel,
discoveryFeedViewModel: NostrDiscoverFeedViewModel,
notifFeedViewModel: NotificationViewModel, notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel, 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 -> Route.Search.let { route ->
composable(route.route, route.arguments, content = { composable(route.route, route.arguments, content = {
SearchScreen( SearchScreen(
@@ -118,6 +118,7 @@ private fun RenderTopRouteBar(
// Route.Profile.route -> TopBarWithBackButton(nav) // Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav) Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Video.base -> StoriesTopBar(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) Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel, nav)
else -> MainTopBar(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 @Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) { fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
GenericTopBar(scaffoldState, accountViewModel, nav) { GenericTopBar(scaffoldState, accountViewModel, nav) {
@@ -57,6 +57,11 @@ sealed class Route(
icon = R.drawable.ic_video icon = R.drawable.ic_video
) )
object Discover : Route(
route = "Discover",
icon = R.drawable.ic_sensors
)
object Notification : Route( object Notification : Route(
route = "Notification", route = "Notification",
icon = R.drawable.ic_notifications, icon = R.drawable.ic_notifications,
@@ -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<Color>? = 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<Color>? = 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<Color>? = 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<Color>? = 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<Color>? = 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<Color>? = null,
accountViewModel: AccountViewModel,
showPopup: () -> Unit,
nav: (String) -> Unit
) {
val newItemColor = MaterialTheme.colors.newItemBackgroundColor
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(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<ImmutableList<User>>(
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)
)
}
@@ -108,6 +108,8 @@ import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent 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.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.Participant import com.vitorpamplona.amethyst.service.model.Participant
import com.vitorpamplona.amethyst.service.model.PeopleListEvent import com.vitorpamplona.amethyst.service.model.PeopleListEvent
@@ -538,7 +540,7 @@ private fun CheckNewAndRenderNote(
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
private fun ClickableNote( fun ClickableNote(
baseNote: Note, baseNote: Note,
modifier: Modifier, modifier: Modifier,
backgroundColor: MutableState<Color>, backgroundColor: MutableState<Color>,
@@ -549,7 +551,7 @@ private fun ClickableNote(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val updatedModifier = remember(backgroundColor.value) { val updatedModifier = remember(baseNote, backgroundColor.value) {
modifier modifier
.combinedClickable( .combinedClickable(
onClick = { onClick = {
@@ -1785,7 +1787,7 @@ private fun ReplyNoteComposition(
} }
@Composable @Composable
private fun SecondUserInfoRow( fun SecondUserInfoRow(
note: Note, note: Note,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
@@ -1809,7 +1811,7 @@ private fun SecondUserInfoRow(
} }
@Composable @Composable
private fun FirstUserInfoRow( fun FirstUserInfoRow(
baseNote: Note, baseNote: Note,
showAuthorPicture: Boolean, showAuthorPicture: Boolean,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -1854,7 +1856,7 @@ private fun BoostedMark() {
} }
@Composable @Composable
private fun MoreOptionsButton( fun MoreOptionsButton(
baseNote: Note, baseNote: Note,
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
) { ) {
@@ -2690,12 +2692,12 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
Crossfade(targetState = status) { Crossfade(targetState = status) {
when (it) { when (it) {
"live" -> { STATUS_LIVE -> {
if (isOnline) { if (isOnline) {
LiveFlag() LiveFlag()
} }
} }
"planned" -> { STATUS_PLANNED -> {
ScheduledFlag() ScheduledFlag()
} }
} }
@@ -2744,7 +2746,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
} }
media?.let { media -> media?.let { media ->
if (status == "live") { if (status == STATUS_LIVE) {
if (isOnline) { if (isOnline) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -2841,7 +2843,7 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
} }
@Composable @Composable
private fun CreateImageHeader( fun CreateImageHeader(
note: Note, note: Note,
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
) { ) {
@@ -16,6 +16,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -33,20 +34,20 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable @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 { val authorState by baseNote.live().metadata.map {
it.note.author it.note.author
}.observeAsState(baseNote.author) }.observeAsState(baseNote.author)
Crossfade(targetState = authorState, modifier = weight) { Crossfade(targetState = authorState, modifier = weight) {
it?.let { it?.let {
UsernameDisplay(it, weight) UsernameDisplay(it, weight, showPlayButton)
} }
} }
} }
@Composable @Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) { fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
val npubDisplay by remember { val npubDisplay by remember {
derivedStateOf { derivedStateOf {
baseUser.pubkeyDisplayHex() baseUser.pubkeyDisplayHex()
@@ -59,7 +60,7 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
Crossfade(targetState = userMetadata, modifier = weight) { Crossfade(targetState = userMetadata, modifier = weight) {
if (it != null) { if (it != null) {
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight) UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton)
} else { } else {
NPubDisplay(npubDisplay, weight) NPubDisplay(npubDisplay, weight)
} }
@@ -72,14 +73,15 @@ private fun UserNameDisplay(
bestDisplayName: String?, bestDisplayName: String?,
npubDisplay: String, npubDisplay: String,
tags: ImmutableListOfLists<String>?, tags: ImmutableListOfLists<String>?,
modifier: Modifier modifier: Modifier,
showPlayButton: Boolean = true
) { ) {
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) { if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier) UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier, showPlayButton)
} else if (bestDisplayName != null) { } else if (bestDisplayName != null) {
UserDisplay(bestDisplayName, tags, modifier) UserDisplay(bestDisplayName, tags, modifier, showPlayButton)
} else if (bestUserName != null) { } else if (bestUserName != null) {
UserDisplay(bestUserName, tags, modifier) UserDisplay(bestUserName, tags, modifier, showPlayButton)
} else { } else {
NPubDisplay(npubDisplay, modifier) NPubDisplay(npubDisplay, modifier)
} }
@@ -100,9 +102,10 @@ private fun NPubDisplay(npubDisplay: String, modifier: Modifier) {
private fun UserDisplay( private fun UserDisplay(
bestDisplayName: String, bestDisplayName: String,
tags: ImmutableListOfLists<String>?, tags: ImmutableListOfLists<String>?,
modifier: Modifier modifier: Modifier,
showPlayButton: Boolean = true
) { ) {
Row(modifier = modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji( CreateTextWithEmoji(
text = bestDisplayName, text = bestDisplayName,
tags = tags, tags = tags,
@@ -111,19 +114,22 @@ private fun UserDisplay(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier modifier = modifier
) )
if (showPlayButton) {
Spacer(StdHorzSpacer) Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName) DrawPlayName(bestDisplayName)
} }
} }
}
@Composable @Composable
private fun UserAndUsernameDisplay( private fun UserAndUsernameDisplay(
bestDisplayName: String, bestDisplayName: String,
tags: ImmutableListOfLists<String>?, tags: ImmutableListOfLists<String>?,
bestUserName: String, bestUserName: String,
modifier: Modifier modifier: Modifier,
showPlayButton: Boolean = true
) { ) {
Row(modifier = modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji( CreateTextWithEmoji(
text = bestDisplayName, text = bestDisplayName,
tags = tags, tags = tags,
@@ -138,10 +144,12 @@ private fun UserAndUsernameDisplay(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier modifier = modifier
) )
if (showPlayButton) {
Spacer(StdHorzSpacer) Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName) DrawPlayName(bestDisplayName)
} }
} }
}
@Composable @Composable
fun DrawPlayName(name: String) { fun DrawPlayName(name: String) {
@@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter 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.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter 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 <NostrDiscoverFeedViewModel : ViewModel> create(modelClass: Class<NostrDiscoverFeedViewModel>): NostrDiscoverFeedViewModel {
return NostrDiscoverFeedViewModel(account) as NostrDiscoverFeedViewModel
}
}
}
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) { class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
class Factory(val noteId: String) : ViewModelProvider.Factory { class Factory(val noteId: String) : ViewModelProvider.Factory {
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel { override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
@@ -17,6 +17,7 @@ object ScrollStateKeys {
const val GLOBAL_SCREEN = "Global" const val GLOBAL_SCREEN = "Global"
const val NOTIFICATION_SCREEN = "Notifications" const val NOTIFICATION_SCREEN = "Notifications"
const val VIDEO_SCREEN = "Video" const val VIDEO_SCREEN = "Video"
const val DISCOVER_SCREEN = "Discover"
val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_FOLLOWS = Route.Home.base + "Follows"
val HOME_REPLIES = Route.Home.base + "FollowsReplies" val HOME_REPLIES = Route.Home.base + "FollowsReplies"
} }
@@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.model.UserState
@@ -241,6 +242,10 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.userProfile().isFollowingCached(user) return account.userProfile().isFollowingCached(user)
} }
fun isFollowing(user: HexKey): Boolean {
return account.userProfile().isFollowingCached(user)
}
val hideDeleteRequestDialog: Boolean val hideDeleteRequestDialog: Boolean
get() = account.hideDeleteRequestDialog get() = account.hideDeleteRequestDialog
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.NostrChannelDataSource 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.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
@@ -516,6 +517,7 @@ fun ChannelHeader(
channelHex: String, channelHex: String,
showVideo: Boolean, showVideo: Boolean,
showBottomDiviser: Boolean, showBottomDiviser: Boolean,
showFlag: Boolean = true,
modifier: Modifier = StdPadding, modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
@@ -535,6 +537,7 @@ fun ChannelHeader(
it, it,
showVideo, showVideo,
showBottomDiviser, showBottomDiviser,
showFlag,
modifier, modifier,
accountViewModel, accountViewModel,
nav nav
@@ -547,6 +550,7 @@ fun ChannelHeader(
baseChannel: Channel, baseChannel: Channel,
showVideo: Boolean, showVideo: Boolean,
showBottomDiviser: Boolean, showBottomDiviser: Boolean,
showFlag: Boolean = true,
modifier: Modifier = StdPadding, modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
@@ -564,6 +568,7 @@ fun ChannelHeader(
val channelState by baseChannel.live.observeAsState() val channelState by baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState?.channel } ?: return val channel = remember(channelState) { channelState?.channel } ?: return
if (showVideo) {
val streamingUrl by remember(channelState) { val streamingUrl by remember(channelState) {
derivedStateOf { derivedStateOf {
val activity = channel as? LiveActivitiesChannel val activity = channel as? LiveActivitiesChannel
@@ -577,14 +582,19 @@ fun ChannelHeader(
} }
} }
if (streamingUrl != null && showVideo) { streamingUrl?.let {
Row(verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) }) { CheckIfUrlIsOnline(it.url) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
) {
ZoomableContentView( ZoomableContentView(
content = streamingUrl!! content = it
) )
} }
} }
}
}
Column(modifier = modifier) { Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (channel is LiveActivitiesChannel) { if (channel is LiveActivitiesChannel) {
@@ -653,7 +663,7 @@ fun ChannelHeader(
ChannelActionOptions(channel, accountViewModel, nav) ChannelActionOptions(channel, accountViewModel, nav)
} }
if (channel is LiveActivitiesChannel) { if (channel is LiveActivitiesChannel) {
LiveChannelActionOptions(channel, accountViewModel, nav) LiveChannelActionOptions(channel, showFlag, accountViewModel, nav)
} }
} }
} }
@@ -702,12 +712,13 @@ private fun ChannelActionOptions(
@Composable @Composable
private fun LiveChannelActionOptions( private fun LiveChannelActionOptions(
channel: LiveActivitiesChannel, channel: LiveActivitiesChannel,
showFlag: Boolean = true,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
val isLive by remember(channel) { val isLive by remember(channel) {
derivedStateOf { derivedStateOf {
channel.info?.status() == "live" channel.info?.status() == STATUS_LIVE
} }
} }
@@ -716,7 +727,7 @@ private fun LiveChannelActionOptions(
} }
note?.let { note?.let {
if (isLive) { if (showFlag && isLive) {
LiveFlag() LiveFlag()
Spacer(modifier = StdHorzSpacer) Spacer(modifier = StdHorzSpacer)
} }
@@ -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
)
}
}
}
@@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource
@@ -211,12 +212,22 @@ private fun FeedLoaded(
@Composable @Composable
fun CheckIfLiveActivityIsOnline(note: Note, whenOnline: @Composable () -> Unit) { 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) } var online by remember { mutableStateOf(false) }
LaunchedEffect(key1 = noteState) { LaunchedEffect(key1 = url) {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
online = OnlineChecker.isOnline((note.event as? LiveActivitiesEvent)?.streaming()) online = OnlineChecker.isOnline(url)
} }
} }
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel 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.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
@@ -98,6 +99,11 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
factory = NostrVideoFeedViewModel.Factory(accountViewModel.account) factory = NostrVideoFeedViewModel.Factory(accountViewModel.account)
) )
val discoveryFeedViewModel: NostrDiscoverFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryFeedViewModel",
factory = NostrDiscoverFeedViewModel.Factory(accountViewModel.account)
)
val notifFeedViewModel: NotificationViewModel = viewModel( val notifFeedViewModel: NotificationViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NotificationViewModel", key = accountViewModel.userProfile().pubkeyHex + "NotificationViewModel",
factory = NotificationViewModel.Factory(accountViewModel.account) factory = NotificationViewModel.Factory(accountViewModel.account)
@@ -136,6 +142,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
Route.Video.base -> { Route.Video.base -> {
videoFeedViewModel.sendToTop() videoFeedViewModel.sendToTop()
} }
Route.Discover.base -> {
discoveryFeedViewModel.sendToTop()
}
Route.Notification.base -> { Route.Notification.base -> {
notifFeedViewModel.invalidateDataAndSendToTop() notifFeedViewModel.invalidateDataAndSendToTop()
} }
@@ -184,6 +193,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
knownFeedViewModel, knownFeedViewModel,
newFeedViewModel, newFeedViewModel,
videoFeedViewModel, videoFeedViewModel,
discoveryFeedViewModel,
notifFeedViewModel, notifFeedViewModel,
userReactionsStatsModel, userReactionsStatsModel,
navController, navController,
@@ -51,6 +51,7 @@ val Size17dp = 17.dp
val Size18dp = 18.dp val Size18dp = 18.dp
val Size19dp = 19.dp val Size19dp = 19.dp
val Size20dp = 20.dp val Size20dp = 20.dp
val Size22dp = 22.dp
val Size24dp = 24.dp val Size24dp = 24.dp
val Size25dp = 25.dp val Size25dp = 25.dp
val Size30dp = 30.dp val Size30dp = 30.dp
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#9c9c9c"
android:pathData="M142.11,817.89Q77.63,753.42 40.02,666.86 2.41,580.29 2.41,480q0,-100.29 37.61,-186.86 37.61,-86.56 102.08,-151.04l51.34,51.34q-54.92,54.92 -87.16,128.35 -32.24,73.43 -32.24,158.2 0,84.77 31.64,157.6 31.64,72.83 87.76,128.95zM277.03,682.97q-38.21,-38.21 -60.89,-90.14 -22.69,-51.94 -22.69,-112.83 0,-60.89 22.69,-112.83 22.69,-51.94 60.89,-90.14l51.34,51.34q-28.66,28.66 -45.97,67.46 -17.31,38.8 -17.31,84.17 0,45.37 16.72,83.58 16.72,38.21 46.56,68.06zM480,575.52q-39.4,0 -67.46,-28.06 -28.06,-28.06 -28.06,-67.46 0,-39.4 28.06,-67.46 28.06,-28.06 67.46,-28.06 39.4,0 67.46,28.06 28.06,28.06 28.06,67.46 0,39.4 -28.06,67.46 -28.06,28.06 -67.46,28.06zM682.97,682.97 L631.63,631.63q28.66,-28.66 45.97,-67.46 17.31,-38.8 17.31,-84.17 0,-45.37 -16.72,-83.58 -16.72,-38.21 -46.56,-68.06l51.34,-51.34q38.21,38.21 60.89,90.14 22.69,51.94 22.69,112.83 0,59.7 -22.69,111.64 -22.69,51.94 -60.89,91.34zM817.89,817.89 L766.55,766.55q54.92,-54.92 87.16,-128.35 32.24,-73.43 32.24,-158.2 0,-84.77 -31.64,-157.6 -31.64,-72.83 -87.76,-128.95l51.34,-51.34q64.47,65.67 102.08,152.23 37.61,86.56 37.61,185.66 0,99.1 -37.61,185.66 -37.61,86.56 -102.08,152.23z"
android:strokeWidth="1.19396"/>
</vector>