Base Streaming view
This commit is contained in:
@@ -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,8 +114,10 @@ private fun UserDisplay(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
Spacer(StdHorzSpacer)
|
if (showPlayButton) {
|
||||||
DrawPlayName(bestDisplayName)
|
Spacer(StdHorzSpacer)
|
||||||
|
DrawPlayName(bestDisplayName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +126,10 @@ 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,8 +144,10 @@ private fun UserAndUsernameDisplay(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
Spacer(StdHorzSpacer)
|
if (showPlayButton) {
|
||||||
DrawPlayName(bestDisplayName)
|
Spacer(StdHorzSpacer)
|
||||||
|
DrawPlayName(bestDisplayName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,27 +568,33 @@ 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
|
||||||
|
|
||||||
val streamingUrl by remember(channelState) {
|
if (showVideo) {
|
||||||
derivedStateOf {
|
val streamingUrl by remember(channelState) {
|
||||||
val activity = channel as? LiveActivitiesChannel
|
derivedStateOf {
|
||||||
val description = activity?.info?.title()
|
val activity = channel as? LiveActivitiesChannel
|
||||||
val url = activity?.info?.streaming()
|
val description = activity?.info?.title()
|
||||||
if (url != null) {
|
val url = activity?.info?.streaming()
|
||||||
ZoomableUrlVideo(url, description = description)
|
if (url != null) {
|
||||||
} else {
|
ZoomableUrlVideo(url, description = description)
|
||||||
null
|
} 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) {
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user