Activates live streams to the top feed bubble

This commit is contained in:
Vitor Pamplona
2025-08-29 16:55:29 -04:00
parent a5a6c875fa
commit e9d9ef197f
17 changed files with 167 additions and 51 deletions

View File

@@ -1588,7 +1588,7 @@ object LocalCache : ILocalCache {
val isVerified =
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (wasVerified || justVerify(event)) {
oldChannel.updateChannelInfo(author, event)
oldChannel.updateChannelInfo(author, event, note)
true
} else {
false

View File

@@ -40,7 +40,10 @@ class PublicChatChannel(
) : Channel() {
var creator: User? = null
var event: ChannelCreateEvent? = null
var eventNote: Note? = null
// Important to keep this long-term reference because LocalCache uses WeakReferences.
var creationEventNote: Note? = null
var updateEventNote: Note? = null
var info = ChannelDataNorm(null, null, null, null)
var infoTags = EmptyTagList
@@ -71,7 +74,7 @@ class PublicChatChannel(
this.infoTags = event.tags.toImmutableListOfLists()
this.updatedMetadataAt = event.createdAt
this.eventNote = eventNote
this.creationEventNote = eventNote
updateChannelInfo()
}
@@ -79,12 +82,14 @@ class PublicChatChannel(
fun updateChannelInfo(
creator: User,
event: ChannelMetadataEvent,
eventNote: Note? = null,
) {
this.creator = creator
this.info = event.channelInfo()
this.infoTags = event.tags.toImmutableListOfLists()
this.updatedMetadataAt = event.createdAt
this.updateEventNote = eventNote
super.updateChannelInfo()
}

View File

@@ -36,6 +36,8 @@ class LiveActivitiesChannel(
) : Channel() {
var creator: User? = null
var info: LiveActivitiesEvent? = null
// Important to keep this long-term reference because LocalCache uses WeakReferences.
var infoNote: Note? = null
fun address() = address

View File

@@ -67,8 +67,8 @@ fun RenderVideoPlayer(
// if we alrady know the size of the frame, this forces the player to stay in the size
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
)
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)

View File

@@ -24,8 +24,8 @@ import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
@@ -41,7 +41,7 @@ import kotlinx.coroutines.launch
@Stable
class ChannelFeedContentState(
val localFilter: AdditiveComplexFeedFilter<EphemeralChatChannel, Note>,
val localFilter: AdditiveComplexFeedFilter<Channel, Note>,
val viewModelScope: CoroutineScope,
) : InvalidatableContent {
private val _feedContent = MutableStateFlow<ChannelFeedState>(ChannelFeedState.Loading)
@@ -92,15 +92,15 @@ class ChannelFeedContentState(
}
}
private fun updateFeed(notes: ImmutableList<EphemeralChatChannel>) {
private fun updateFeed(notes: ImmutableList<Channel>) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.tryEmit(ChannelFeedState.Empty)
} else if (currentState is ChannelFeedState.Loaded) {
currentState.feed.tryEmit(LoadedFeedState<EphemeralChatChannel>(notes, localFilter.showHiddenKey()))
currentState.feed.tryEmit(LoadedFeedState<Channel>(notes, localFilter.showHiddenKey()))
} else {
_feedContent.tryEmit(
ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState<EphemeralChatChannel>(notes, localFilter.showHiddenKey()))),
ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState<Channel>(notes, localFilter.showHiddenKey()))),
)
}
}

View File

@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
@@ -29,7 +29,7 @@ sealed class ChannelFeedState {
object Loading : ChannelFeedState()
class Loaded(
val feed: MutableStateFlow<LoadedFeedState<EphemeralChatChannel>>,
val feed: MutableStateFlow<LoadedFeedState<Channel>>,
) : ChannelFeedState()
object Empty : ChannelFeedState()

View File

@@ -34,7 +34,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivitiesChannelHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent

View File

@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -113,12 +112,10 @@ fun LiveActivityChannelView(
Column(Modifier.fillMaxHeight()) {
Column(
modifier =
remember {
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
},
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true),
) {
ShowVideoStreaming(channel, accountViewModel)
RefreshingChatroomFeedView(

View File

@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
@@ -51,7 +50,7 @@ fun LiveActivityChannelScreen(
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it).statusBarsPadding()) {
Column(Modifier.padding(it)) {
LiveActivityChannelView(channelId, draft, accountViewModel, nav)
}
}

View File

@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChanne
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShowVideoStreaming
import com.vitorpamplona.amethyst.ui.theme.StdPadding
@Composable
@@ -70,7 +71,11 @@ fun LiveActivitiesChannelHeader(
)
if (expanded.value) {
LongLiveActivityChannelHeader(baseChannel, accountViewModel = accountViewModel, nav = nav)
LongLiveActivityChannelHeader(
baseChannel,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}

View File

@@ -25,8 +25,6 @@ import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChanne
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LongLiveActivityChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShortLiveActivityChannelHeader
@Composable
fun LiveActivityTopBar(

View File

@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column

View File

@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp

View File

@@ -60,10 +60,10 @@ import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivitiesChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer

View File

@@ -57,6 +57,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.OnlineChecker.isOnline
import com.vitorpamplona.amethyst.service.location.LocationState
@@ -81,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderEphemeralBubble
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderLiveActivityBubble
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -345,8 +348,11 @@ fun DisplayLiveBubbles(
val feed by liveFeed.feed.collectAsStateWithLifecycle()
LazyRow(HorzPadding, horizontalArrangement = spacedBy(Size5dp)) {
itemsIndexed(feed.list, key = { _, item -> item.roomId.toKey() }) { _, item ->
RenderEphemeralBubble(item, accountViewModel, nav)
itemsIndexed(feed.list, key = { _, item -> item.hashCode() }) { _, item ->
when (item) {
is EphemeralChatChannel -> RenderEphemeralBubble(item, accountViewModel, nav)
is LiveActivitiesChannel -> RenderLiveActivityBubble(item, accountViewModel, nav)
}
}
}
}

View File

@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter
@@ -35,11 +37,13 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
import com.vitorpamplona.quartz.utils.TimeUtils
class HomeLiveFilter(
val account: Account,
) : AdditiveComplexFeedFilter<EphemeralChatChannel, Note>() {
) : AdditiveComplexFeedFilter<Channel, Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun showHiddenKey(): Boolean = false
@@ -52,14 +56,17 @@ class HomeLiveFilter(
fun limitTime() = TimeUtils.fifteenMinutesAgo()
override fun feed(): List<EphemeralChatChannel> {
override fun feed(): List<Channel> {
val filterParams = buildFilterParams(account)
val fiveMinsAgo = limitTime()
val fifteenMinsAgo = limitTime()
val list =
LocalCache.ephemeralChannels.filter { id, channel ->
shouldIncludeChannel(channel, filterParams, fiveMinsAgo)
}
shouldIncludeChannel(channel, filterParams, fifteenMinsAgo)
} +
LocalCache.liveChatChannels.filter { id, channel ->
shouldIncludeChannel(channel, filterParams, fifteenMinsAgo)
}
return sort(list.toSet())
}
@@ -71,18 +78,42 @@ class HomeLiveFilter(
): Boolean =
channel.notes
.filter { key, value ->
acceptableEvent(value, filterParams, timeLimit)
acceptableChatEvent(value, filterParams, timeLimit)
}.isNotEmpty()
fun shouldIncludeChannel(
channel: LiveActivitiesChannel,
filterParams: FilterByListParams,
timeLimit: Long,
): Boolean {
val liveChannel =
channel.info?.let {
it.createdAt > timeLimit &&
it.status() == StatusTag.STATUS.LIVE &&
filterParams.match(it, channel.relays().toList())
}
if (liveChannel == true) {
return true
}
return channel.notes
.filter { key, value ->
acceptableChatEvent(value, filterParams, timeLimit)
}.isNotEmpty()
}
override fun updateListWith(
oldList: List<EphemeralChatChannel>,
oldList: List<Channel>,
newItems: Set<Note>,
): List<EphemeralChatChannel> {
val fiveMinsAgo = limitTime()
): List<Channel> {
val fifteenMinsAgo = limitTime()
val revisedOldList =
oldList.filter { channel ->
(channel.lastNote?.createdAt() ?: 0) > fiveMinsAgo
val channelTime = (channel as? LiveActivitiesChannel)?.info?.createdAt
(channelTime == null || channelTime > fifteenMinsAgo) ||
(channel.lastNote?.createdAt() ?: 0) > fifteenMinsAgo
}
val newItemsToBeAdded = applyFilter(newItems)
@@ -94,7 +125,12 @@ class HomeLiveFilter(
if (room != null) {
LocalCache.getEphemeralChatChannelIfExists(room)
} else {
null
val liveStream = (it.event as? LiveActivitiesChatMessageEvent)?.activityAddress()
if (liveStream != null) {
LocalCache.getLiveActivityChannelIfExists(liveStream)
} else {
null
}
}
}
@@ -109,23 +145,23 @@ class HomeLiveFilter(
val filterParams = buildFilterParams(account)
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams, limitTime())
acceptableChatEvent(it, filterParams, limitTime())
}
}
private fun acceptableEvent(
private fun acceptableChatEvent(
note: Note,
filterParams: FilterByListParams,
timeLimit: Long,
): Boolean {
val createdAt = note.createdAt() ?: return false
val noteEvent = note.event
return (noteEvent is EphemeralChatEvent) &&
return (noteEvent is EphemeralChatEvent || noteEvent is LiveActivitiesChatMessageEvent) &&
createdAt > timeLimit &&
filterParams.match(noteEvent, note.relays)
}
fun sort(collection: Set<EphemeralChatChannel>): List<EphemeralChatChannel> {
fun sort(collection: Set<Channel>): List<Channel> {
val topFilter = account.liveHomeFollowLists.value
val topFilterAuthors =
when (topFilter) {
@@ -145,15 +181,14 @@ class HomeLiveFilter(
collection.associateWith { followsThatParticipateOn(it, followingKeySet) }
return collection.sortedWith(
compareByDescending<EphemeralChatChannel> { followCounts[it] }
.thenByDescending<EphemeralChatChannel> { it.lastNote?.createdAt() ?: 0 }
.thenBy { it.roomId.id }
.thenBy { it.roomId.relayUrl },
compareByDescending<Channel> { followCounts[it] }
.thenByDescending<Channel> { it.lastNote?.createdAt() ?: 0 }
.thenBy { it.hashCode() },
)
}
fun followsThatParticipateOn(
channel: EphemeralChatChannel,
channel: Channel,
followingSet: Set<HexKey>?,
): Int {
var count = 0

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelNoteAuthors
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@Composable
fun RenderLiveActivityBubble(
channel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
FilledTonalButton(
contentPadding = PaddingValues(start = 8.dp, end = 10.dp, bottom = 0.dp, top = 0.dp),
onClick = {
nav.nav { routeFor(channel) }
},
) {
RenderUsers(channel, accountViewModel, nav)
Spacer(StdHorzSpacer)
Text(
channel.toBestDisplayName(),
)
}
}
@Composable
fun RenderUsers(
channel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val authors by observeChannelNoteAuthors(channel, accountViewModel)
Gallery(authors, Modifier, accountViewModel, nav, 3)
}