amethyst/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt

1934 wiersze
64 KiB
Kotlin

/**
* Copyright (c) 2024 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
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
import com.vitorpamplona.amethyst.ui.components.InvoiceRequestCard
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.DrawPlayName
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.qrcode.ShowQRDialog
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.LnZapFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrUserAppRecommendationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowersUserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowsUserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileNewThreadsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileZapsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.GitHubIdentity
import com.vitorpamplona.quartz.events.IdentityClaim
import com.vitorpamplona.quartz.events.MastodonIdentity
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.TelegramIdentity
import com.vitorpamplona.quartz.events.TwitterIdentity
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
@Composable
fun ProfileScreen(
userId: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
if (userId == null) return
var userBase by remember { mutableStateOf(LocalCache.getUserIfExists(userId)) }
if (userBase == null) {
LaunchedEffect(userId) {
// waits to resolve.
launch(Dispatchers.IO) {
val newUserBase = LocalCache.checkGetOrCreateUser(userId)
if (newUserBase != userBase) {
userBase = newUserBase
}
}
}
}
userBase?.let {
PrepareViewModels(
baseUser = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
fun PrepareViewModels(
baseUser: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileFollowsUserFeedViewModel",
factory =
NostrUserProfileFollowsUserFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
val followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileFollowersUserFeedViewModel",
factory =
NostrUserProfileFollowersUserFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
val appRecommendations: NostrUserAppRecommendationsFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserAppRecommendationsFeedViewModel",
factory =
NostrUserAppRecommendationsFeedViewModel.Factory(
baseUser,
),
)
val zapFeedViewModel: NostrUserProfileZapsFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel",
factory =
NostrUserProfileZapsFeedViewModel.Factory(
baseUser,
),
)
val threadsViewModel: NostrUserProfileNewThreadsFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileNewThreadsFeedViewModel",
factory =
NostrUserProfileNewThreadsFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
val repliesViewModel: NostrUserProfileConversationsFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileConversationsFeedViewModel",
factory =
NostrUserProfileConversationsFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel",
factory =
NostrUserProfileBookmarksFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
val reportsFeedViewModel: NostrUserProfileReportFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel",
factory =
NostrUserProfileReportFeedViewModel.Factory(
baseUser,
),
)
ProfileScreen(
baseUser = baseUser,
threadsViewModel,
repliesViewModel,
followsFeedViewModel,
followersFeedViewModel,
appRecommendations,
zapFeedViewModel,
bookmarksFeedViewModel,
reportsFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun ProfileScreen(
baseUser: User,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
NostrUserProfileDataSource.loadUserProfile(baseUser)
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
NostrUserProfileDataSource.start()
onDispose {
NostrUserProfileDataSource.loadUserProfile(null)
NostrUserProfileDataSource.stop()
}
}
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Profidle Start")
NostrUserProfileDataSource.loadUserProfile(baseUser)
NostrUserProfileDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Profile Stop")
NostrUserProfileDataSource.loadUserProfile(null)
NostrUserProfileDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
RenderSurface(
baseUser,
threadsViewModel,
repliesViewModel,
appRecommendations,
followsFeedViewModel,
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav,
)
}
@Composable
private fun RenderSurface(
baseUser: User,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.background,
) {
var columnSize by remember { mutableStateOf(IntSize.Zero) }
var tabsSize by remember { mutableStateOf(IntSize.Zero) }
Column(
modifier =
Modifier
.fillMaxSize()
.onSizeChanged { columnSize = it },
) {
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val tabRowModifier = remember { Modifier.onSizeChanged { tabsSize = it } }
val pagerModifier =
with(LocalDensity.current) { Modifier.height((columnSize.height - tabsSize.height).toDp()) }
Box(
modifier =
remember {
Modifier
.verticalScroll(scrollState)
.nestedScroll(
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
// When scrolling vertically, scroll the container first.
return if (available.y < 0 && scrollState.canScrollForward) {
coroutineScope.launch { scrollState.scrollBy(-available.y) }
Offset(0f, available.y)
} else {
Offset.Zero
}
}
},
)
.fillMaxHeight()
},
) {
RenderScreen(
baseUser,
tabRowModifier,
pagerModifier,
threadsViewModel,
repliesViewModel,
appRecommendations,
followsFeedViewModel,
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav,
)
}
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderScreen(
baseUser: User,
tabRowModifier: Modifier,
pagerModifier: Modifier,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val pagerState = rememberPagerState { 9 }
Column {
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
ScrollableTabRow(
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
selectedTabIndex = pagerState.currentPage,
edgePadding = 8.dp,
modifier = tabRowModifier,
divider = { HorizontalDivider(thickness = DividerThickness) },
) {
CreateAndRenderTabs(baseUser, pagerState)
}
HorizontalPager(
state = pagerState,
modifier = pagerModifier,
) { page ->
CreateAndRenderPages(
page,
baseUser,
threadsViewModel,
repliesViewModel,
followsFeedViewModel,
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav,
)
}
}
}
@Composable
private fun CreateAndRenderPages(
page: Int,
baseUser: User,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
UpdateThreadsAndRepliesWhenBlockUnblock(
baseUser,
threadsViewModel,
repliesViewModel,
accountViewModel,
)
when (page) {
0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav)
1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav)
2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav)
3 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav)
4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav)
5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav)
6 -> TabFollowedTags(baseUser, accountViewModel, nav)
7 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav)
8 -> TabRelays(baseUser, accountViewModel, nav)
}
}
@Composable
fun UpdateThreadsAndRepliesWhenBlockUnblock(
baseUser: User,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
accountViewModel: AccountViewModel,
) {
val isHidden by
accountViewModel.account.liveHiddenUsers
.map {
it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex)
}
.observeAsState(accountViewModel.account.isHidden(baseUser))
LaunchedEffect(key1 = isHidden) {
threadsViewModel.invalidateData()
repliesViewModel.invalidateData()
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CreateAndRenderTabs(
baseUser: User,
pagerState: PagerState,
) {
val coroutineScope = rememberCoroutineScope()
val tabs =
listOf<@Composable (() -> Unit)?>(
{ Text(text = stringResource(R.string.notes)) },
{ Text(text = stringResource(R.string.replies)) },
{ FollowTabHeader(baseUser) },
{ FollowersTabHeader(baseUser) },
{ ZapTabHeader(baseUser) },
{ BookmarkTabHeader(baseUser) },
{ FollowedTagsTabHeader(baseUser) },
{ ReportsTabHeader(baseUser) },
{ RelaysTabHeader(baseUser) },
)
tabs.forEachIndexed { index, function ->
Tab(
selected = pagerState.currentPage == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = function,
)
}
}
@Composable
private fun RelaysTabHeader(baseUser: User) {
val userState by baseUser.live().relays.observeAsState()
val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" }
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
val userRelays =
remember(userStateRelayInfo) {
userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--"
}
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
}
@Composable
private fun ReportsTabHeader(baseUser: User) {
val userState by baseUser.live().reports.observeAsState()
var userReports by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
val newSize = UserProfileReportsFeedFilter(baseUser).feed().size
if (newSize != userReports) {
userReports = newSize
}
}
}
Text(text = "$userReports ${stringResource(R.string.reports)}")
}
@Composable
private fun FollowedTagsTabHeader(baseUser: User) {
var usertags by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = baseUser) {
launch(Dispatchers.IO) {
val contactList = baseUser.latestContactList
val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0)
if (newTags != usertags) {
usertags = newTags
}
}
}
Text(text = "$usertags ${stringResource(R.string.followed_tags)}")
}
@Composable
private fun BookmarkTabHeader(baseUser: User) {
val userState by baseUser.live().bookmarks.observeAsState()
var userBookmarks by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
val bookmarkList = userState?.user?.latestBookmarkList
val newBookmarks =
(
bookmarkList?.taggedEvents()?.count()
?: 0
) + (bookmarkList?.taggedAddresses()?.count() ?: 0)
if (newBookmarks != userBookmarks) {
userBookmarks = newBookmarks
}
}
}
Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}")
}
@Composable
private fun ZapTabHeader(baseUser: User) {
val userState by baseUser.live().zaps.observeAsState()
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = userState) {
launch(Dispatchers.Default) {
val tempAmount = baseUser.zappedAmount()
if (zapAmount != tempAmount) {
zapAmount = tempAmount
}
}
}
Text(text = "${showAmountAxis(zapAmount)} ${stringResource(id = R.string.zaps)}")
}
@Composable
private fun FollowersTabHeader(baseUser: User) {
val userState by baseUser.live().followers.observeAsState()
var followerCount by remember { mutableStateOf("--") }
val text = stringResource(R.string.followers)
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
val newFollower = (userState?.user?.transientFollowerCount()?.toString() ?: "--") + " " + text
if (followerCount != newFollower) {
followerCount = newFollower
}
}
}
Text(text = followerCount)
}
@Composable
private fun FollowTabHeader(baseUser: User) {
val userState by baseUser.live().follows.observeAsState()
var followCount by remember { mutableStateOf("--") }
val text = stringResource(R.string.follows)
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
val newFollow = (userState?.user?.transientFollowCount()?.toString() ?: "--") + " " + text
if (followCount != newFollow) {
followCount = newFollow
}
}
}
Text(text = followCount)
}
@Composable
private fun ProfileHeader(
baseUser: User,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
nav: (String) -> Unit,
accountViewModel: AccountViewModel,
) {
var popupExpanded by remember { mutableStateOf(false) }
var zoomImageDialogOpen by remember { mutableStateOf(false) }
Box {
DrawBanner(baseUser, accountViewModel)
Box(
modifier =
Modifier
.padding(horizontal = 10.dp)
.size(40.dp)
.align(Alignment.TopEnd),
) {
Button(
modifier =
Modifier
.size(30.dp)
.align(Alignment.Center),
onClick = { popupExpanded = true },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.background,
),
contentPadding = ZeroPadding,
) {
Icon(
tint = MaterialTheme.colorScheme.placeholderText,
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more_options),
)
UserProfileDropDownMenu(
baseUser,
popupExpanded,
{ popupExpanded = false },
accountViewModel,
)
}
}
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.padding(top = 75.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
val clipboardManager = LocalClipboardManager.current
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = 100.dp,
modifier =
Modifier.border(
3.dp,
MaterialTheme.colorScheme.background,
CircleShape,
),
onClick = {
if (baseUser.profilePicture() != null) {
zoomImageDialogOpen = true
}
},
onLongClick = {
it.info?.picture?.let { it1 ->
clipboardManager.setText(
AnnotatedString(it1),
)
}
},
)
Spacer(Modifier.weight(1f))
Row(
modifier =
Modifier
.height(Size35dp)
.padding(bottom = 3.dp),
) {
MessageButton(baseUser, accountViewModel, nav)
ProfileActions(baseUser, accountViewModel)
}
}
DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav)
HorizontalDivider(modifier = Modifier.padding(top = 6.dp))
}
}
val profilePic = baseUser.profilePicture()
if (zoomImageDialogOpen && profilePic != null) {
ZoomableImageDialog(
RichTextParser.parseImageOrVideo(profilePic),
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel,
)
}
}
@Composable
private fun ProfileActions(
baseUser: User,
accountViewModel: AccountViewModel,
) {
val isMe by
remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } }
if (isMe) {
EditButton(accountViewModel.account)
}
WatchIsHiddenUser(baseUser, accountViewModel) { isHidden ->
if (isHidden) {
ShowUserButton { accountViewModel.showUser(baseUser.pubkeyHex) }
} else {
DisplayFollowUnfollowButton(baseUser, accountViewModel)
}
}
}
@Composable
private fun DisplayFollowUnfollowButton(
baseUser: User,
accountViewModel: AccountViewModel,
) {
val isLoggedInFollowingUser by
accountViewModel.account
.userProfile()
.live()
.follows
.map { it.user.isFollowing(baseUser) }
.distinctUntilChanged()
.observeAsState(initial = accountViewModel.account.isFollowing(baseUser))
val isUserFollowingLoggedIn by
baseUser
.live()
.follows
.map { it.user.isFollowing(accountViewModel.account.userProfile()) }
.distinctUntilChanged()
.observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile()))
if (isLoggedInFollowingUser) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_unfollow,
)
} else {
accountViewModel.unfollow(baseUser)
}
}
} else {
if (isUserFollowingLoggedIn) {
FollowButton(R.string.follow_back) {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_follow,
)
} else {
accountViewModel.follow(baseUser)
}
}
} else {
FollowButton(R.string.follow) {
if (!accountViewModel.isWriteable()) {
accountViewModel.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_follow,
)
} else {
accountViewModel.follow(baseUser)
}
}
}
}
}
@Composable
fun WatchIsHiddenUser(
baseUser: User,
accountViewModel: AccountViewModel,
content: @Composable (Boolean) -> Unit,
) {
val isHidden by
accountViewModel.account.liveHiddenUsers
.map {
it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex)
}
.observeAsState(accountViewModel.account.isHidden(baseUser))
content(isHidden)
}
fun getIdentityClaimIcon(identity: IdentityClaim): Int {
return when (identity) {
is TwitterIdentity -> R.drawable.twitter
is TelegramIdentity -> R.drawable.telegram
is MastodonIdentity -> R.drawable.mastodon
is GitHubIdentity -> R.drawable.github
else -> R.drawable.github
}
}
fun getIdentityClaimDescription(identity: IdentityClaim): Int {
return when (identity) {
is TwitterIdentity -> R.string.twitter
is TelegramIdentity -> R.string.telegram
is MastodonIdentity -> R.string.mastodon
is GitHubIdentity -> R.string.github
else -> R.drawable.github
}
}
@Composable
private fun DrawAdditionalInfo(
baseUser: User,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return
val tags = userState?.user?.info?.tags
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
user.toBestDisplayName().let {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) {
CreateTextWithEmoji(
text = it,
tags = tags,
fontWeight = FontWeight.Bold,
fontSize = 25.sp,
)
Spacer(StdHorzSpacer)
DrawPlayName(it)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = user.pubkeyDisplayHex(),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
color = MaterialTheme.colorScheme.placeholderText,
)
IconButton(
modifier =
Modifier
.size(25.dp)
.padding(start = 5.dp),
onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) },
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(id = R.string.copy_npub_to_clipboard),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
var dialogOpen by remember { mutableStateOf(false) }
if (dialogOpen) {
ShowQRDialog(
user,
automaticallyShowProfilePicture,
onScan = {
dialogOpen = false
nav(it)
},
onClose = { dialogOpen = false },
)
}
IconButton(
modifier = Size25Modifier,
onClick = { dialogOpen = true },
) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
contentDescription = stringResource(id = R.string.show_npub_as_a_qr_code),
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
}
DisplayBadges(baseUser, accountViewModel, nav)
DisplayNip05ProfileStatus(user, accountViewModel)
val website = user.info?.website
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = MaterialTheme.colorScheme.placeholderText,
imageVector = Icons.Default.Link,
contentDescription = stringResource(R.string.website),
modifier = Modifier.size(16.dp),
)
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
onClick = {
website.let {
runCatching {
if (it.contains("://")) {
uri.openUri(it)
} else {
uri.openUri("http://$it")
}
}
}
},
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
)
}
}
val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() }
val pubkeyHex = remember { baseUser.pubkeyHex }
DisplayLNAddress(lud16, pubkeyHex, accountViewModel, nav)
val identities = user.latestMetadata?.identityClaims()
if (!identities.isNullOrEmpty()) {
identities.forEach { identity: IdentityClaim ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = Color.Unspecified,
painter = painterResource(id = getIdentityClaimIcon(identity)),
contentDescription = stringResource(getIdentityClaimDescription(identity)),
modifier = Modifier.size(16.dp),
)
ClickableText(
text = AnnotatedString(identity.identity),
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
modifier =
Modifier
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
.weight(1f),
)
}
}
}
user.info?.about?.let {
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp),
) {
val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) }
TranslatableRichTextViewer(
content = it,
canPreview = false,
quotesLeft = 1,
tags = EmptyTagList,
backgroundColor = background,
id = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
DisplayAppRecommendations(appRecommendations, nav)
}
@Composable
fun DisplayLNAddress(
lud16: String?,
userHex: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zapExpanded by remember { mutableStateOf(false) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
scope.launch(Dispatchers.IO) {
val route = routeToMessage(userHex, showErrorMessageDialog, accountViewModel)
nav(route)
}
},
onDismiss = { showErrorMessageDialog = null },
)
}
var showInfoMessageDialog by remember { mutableStateOf<String?>(null) }
if (showInfoMessageDialog != null) {
InformationDialog(
title = context.getString(R.string.payment_successful),
textContent = showInfoMessageDialog ?: "",
) {
showInfoMessageDialog = null
}
}
if (!lud16.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
LightningAddressIcon(modifier = Size16Modifier, tint = BitcoinOrange)
ClickableText(
text = AnnotatedString(lud16),
onClick = { zapExpanded = !zapExpanded },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
modifier =
Modifier
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
.weight(1f),
)
}
if (zapExpanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
InvoiceRequestCard(
lud16,
userHex,
accountViewModel.account,
onSuccess = {
zapExpanded = false
// pay directly
if (accountViewModel.account.hasWalletConnectSetup()) {
accountViewModel.account.sendZapPaymentRequestFor(it, null, onSent = {}) { response ->
if (response is PayInvoiceSuccessResponse) {
showInfoMessageDialog = context.getString(R.string.payment_successful)
} else if (response is PayInvoiceErrorResponse) {
showErrorMessageDialog =
response.error?.message
?: response.error?.code?.toString()
?: context.getString(R.string.error_parsing_error_message)
}
}
} else {
payViaIntent(it, context) { showErrorMessageDialog = it }
}
},
onClose = { zapExpanded = false },
onError = { title, message -> accountViewModel.toast(title, message) },
)
}
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun DisplayAppRecommendations(
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
nav: (String) -> Unit,
) {
val feedState by appRecommendations.feedContent.collectAsStateWithLifecycle()
LaunchedEffect(key1 = Unit) { appRecommendations.invalidateData() }
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100),
) { state ->
when (state) {
is FeedState.Loaded -> {
Column {
Text(stringResource(id = R.string.recommended_apps))
FlowRow(
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 5.dp),
) {
state.feed.value.forEach { app -> WatchApp(app, nav) }
}
}
}
else -> {}
}
}
}
@Composable
private fun WatchApp(
baseApp: Note,
nav: (String) -> Unit,
) {
val appState by baseApp.live().metadata.observeAsState()
var appLogo by remember(baseApp) { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = appState) {
withContext(Dispatchers.Default) {
val newAppLogo =
(appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.picture?.ifBlank { null }
if (newAppLogo != appLogo) {
appLogo = newAppLogo
}
}
}
appLogo?.let {
Box(
remember {
Modifier
.size(Size35dp)
.clickable { nav("Note/${baseApp.idHex}") }
},
) {
AsyncImage(
model = appLogo,
contentDescription = null,
modifier =
remember {
Modifier
.size(Size35dp)
.clip(shape = CircleShape)
},
)
}
}
}
@Composable
private fun DisplayBadges(
baseUser: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
LoadAddressableNote(
aTag =
ATag(
BadgeProfilesEvent.KIND,
baseUser.pubkeyHex,
BadgeProfilesEvent.STANDARD_D_TAG,
null,
),
accountViewModel,
) { note ->
if (note != null) {
WatchAndRenderBadgeList(note, automaticallyShowProfilePicture, nav)
}
}
}
@Composable
private fun WatchAndRenderBadgeList(
note: AddressableNote,
loadProfilePicture: Boolean,
nav: (String) -> Unit,
) {
val badgeList by
note
.live()
.metadata
.map { (it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList() }
.distinctUntilChanged()
.observeAsState()
badgeList?.let { list -> RenderBadgeList(list, loadProfilePicture, nav) }
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun RenderBadgeList(
list: ImmutableList<String>,
loadProfilePicture: Boolean,
nav: (String) -> Unit,
) {
FlowRow(
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 5.dp),
) {
list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, loadProfilePicture, nav) }
}
}
@Composable
private fun LoadAndRenderBadge(
badgeAwardEventHex: String,
loadProfilePicture: Boolean,
nav: (String) -> Unit,
) {
var baseNote by remember { mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEventHex)) }
LaunchedEffect(key1 = badgeAwardEventHex) {
if (baseNote == null) {
launch(Dispatchers.IO) { baseNote = LocalCache.checkGetOrCreateNote(badgeAwardEventHex) }
}
}
baseNote?.let { ObserveAndRenderBadge(it, loadProfilePicture, nav) }
}
@Composable
private fun ObserveAndRenderBadge(
it: Note,
loadProfilePicture: Boolean,
nav: (String) -> Unit,
) {
val badgeAwardState by it.live().metadata.observeAsState()
val baseBadgeDefinition by
remember(badgeAwardState) { derivedStateOf { badgeAwardState?.note?.replyTo?.firstOrNull() } }
baseBadgeDefinition?.let { BadgeThumb(it, loadProfilePicture, nav, Size35dp) }
}
@Composable
fun BadgeThumb(
note: Note,
loadProfilePicture: Boolean,
nav: (String) -> Unit,
size: Dp,
pictureModifier: Modifier = Modifier,
) {
BadgeThumb(note, loadProfilePicture, size, pictureModifier) { nav("Note/${note.idHex}") }
}
@Composable
fun BadgeThumb(
baseNote: Note,
loadProfilePicture: Boolean,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: ((String) -> Unit)? = null,
) {
Box(
remember {
Modifier
.width(size)
.height(size)
},
) {
WatchAndRenderBadgeImage(baseNote, loadProfilePicture, size, pictureModifier, onClick)
}
}
@Composable
private fun WatchAndRenderBadgeImage(
baseNote: Note,
loadProfilePicture: Boolean,
size: Dp,
pictureModifier: Modifier,
onClick: ((String) -> Unit)?,
) {
val noteState by baseNote.live().metadata.observeAsState()
val eventId = remember(noteState) { noteState?.note?.idHex } ?: return
val image by
remember(noteState) {
derivedStateOf {
val event = noteState?.note?.event as? BadgeDefinitionEvent
event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null }
}
}
val bgColor = MaterialTheme.colorScheme.background
if (image == null) {
RobohashAsyncImage(
robot = "authornotfound",
contentDescription = stringResource(R.string.unknown_author),
modifier =
remember {
pictureModifier
.width(size)
.height(size)
.background(bgColor)
},
)
} else {
RobohashFallbackAsyncImage(
robot = eventId,
model = image!!,
contentDescription = stringResource(id = R.string.profile_image),
modifier =
remember {
pictureModifier
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(bgColor)
.run {
if (onClick != null) {
this.clickable(onClick = { onClick(eventId) })
} else {
this
}
}
},
loadProfilePicture = loadProfilePicture,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DrawBanner(
baseUser: User,
accountViewModel: AccountViewModel,
) {
val userState by baseUser.live().metadata.observeAsState()
val banner = remember(userState) { userState?.user?.info?.banner }
val clipboardManager = LocalClipboardManager.current
var zoomImageDialogOpen by remember { mutableStateOf(false) }
if (!banner.isNullOrBlank()) {
AsyncImage(
model = banner,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.fillMaxWidth()
.height(125.dp)
.combinedClickable(
onClick = { zoomImageDialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(banner)) },
),
)
if (zoomImageDialogOpen) {
ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(banner),
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel,
)
}
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(id = R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.fillMaxWidth()
.height(125.dp),
)
}
}
@Composable
fun TabNotesNewThreads(
feedViewModel: NostrUserProfileNewThreadsFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp),
) {
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun TabNotesConversations(
feedViewModel: NostrUserProfileConversationsFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp),
) {
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun TabFollowedTags(
baseUser: User,
account: AccountViewModel,
nav: (String) -> Unit,
) {
Column(Modifier.fillMaxHeight().padding(vertical = 0.dp)) {
baseUser.latestContactList?.let {
it.unverifiedFollowTagSet().forEach { hashtag ->
HashtagHeader(
tag = hashtag,
account = account,
onClick = { nav("Hashtag/$hashtag") },
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
@Composable
fun TabBookmarks(
feedViewModel: NostrUserProfileBookmarksFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
LaunchedEffect(Unit) { feedViewModel.invalidateData() }
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp),
) {
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun TabFollows(
baseUser: User,
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
WatchFollowChanges(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
}
}
}
@Composable
fun TabFollowers(
baseUser: User,
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
WatchFollowerChanges(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
}
}
}
@Composable
private fun WatchFollowChanges(
baseUser: User,
feedViewModel: UserFeedViewModel,
) {
val userState by baseUser.live().follows.observeAsState()
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@Composable
private fun WatchFollowerChanges(
baseUser: User,
feedViewModel: UserFeedViewModel,
) {
val userState by baseUser.live().followers.observeAsState()
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@Composable
fun TabReceivedZaps(
baseUser: User,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel)
Column(Modifier.fillMaxHeight()) {
Column { LnZapFeedView(zapFeedViewModel, accountViewModel, nav) }
}
}
@Composable
private fun WatchZapsAndUpdateFeed(
baseUser: User,
feedViewModel: NostrUserProfileZapsFeedViewModel,
) {
val userState by baseUser.live().zaps.observeAsState()
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@Composable
fun TabReports(
baseUser: User,
feedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
WatchReportsAndUpdateFeed(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column {
RefresheableFeedView(
feedViewModel,
null,
enablePullRefresh = false,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun WatchReportsAndUpdateFeed(
baseUser: User,
feedViewModel: NostrUserProfileReportFeedViewModel,
) {
val userState by baseUser.live().reports.observeAsState()
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@Composable
fun TabRelays(
user: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val feedViewModel: RelayFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(user) {
feedViewModel.subscribeTo(user)
onDispose { feedViewModel.unsubscribeTo(user) }
}
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Profile Relay Start")
feedViewModel.subscribeTo(user)
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Profile Relay Stop")
feedViewModel.unsubscribeTo(user)
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
println("Profile Relay Dispose")
feedViewModel.unsubscribeTo(user)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp),
) {
RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, nav = nav)
}
}
}
@Composable
private fun MessageButton(
user: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val scope = rememberCoroutineScope()
Button(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = {
scope.launch(Dispatchers.IO) { accountViewModel.createChatRoomFor(user) { nav("Room/$it") } }
},
contentPadding = ZeroPadding,
) {
Icon(
painter = painterResource(R.drawable.ic_dm),
stringResource(R.string.send_a_direct_message),
modifier = Modifier.size(20.dp),
tint = Color.White,
)
}
}
@Composable
private fun EditButton(account: Account) {
var wantsToEdit by remember { mutableStateOf(false) }
if (wantsToEdit) {
NewUserMetadataView({ wantsToEdit = false }, account)
}
InnerEditButton { wantsToEdit = true }
}
@Preview
@Composable
private fun InnerEditButtonPreview() {
InnerEditButton {}
}
@Composable
private fun InnerEditButton(onClick: () -> Unit) {
Button(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = onClick,
contentPadding = ZeroPadding,
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.EditNote,
contentDescription = stringResource(R.string.edits_the_user_s_metadata),
)
}
}
@Composable
fun UnfollowButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onClick,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
contentPadding = ButtonPadding,
) {
Text(text = stringResource(R.string.unfollow), color = Color.White)
}
}
@Composable
fun FollowButton(
text: Int = R.string.follow,
onClick: () -> Unit,
) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
contentPadding = ButtonPadding,
) {
Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center)
}
}
@Composable
fun ShowUserButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
contentPadding = ButtonPadding,
) {
Text(text = stringResource(R.string.unblock), color = Color.White)
}
}
@Composable
fun UserProfileDropDownMenu(
user: User,
popupExpanded: Boolean,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
DropdownMenuItem(
text = { Text(stringResource(R.string.copy_user_id)) },
onClick = {
clipboardManager.setText(AnnotatedString(user.pubkeyNpub()))
onDismiss()
},
)
if (accountViewModel.userProfile() != user) {
HorizontalDivider(thickness = DividerThickness)
if (accountViewModel.account.isHidden(user)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.unblock_user)) },
onClick = {
accountViewModel.show(user)
onDismiss()
},
)
} else {
DropdownMenuItem(
text = { Text(stringResource(id = R.string.block_hide_user)) },
onClick = {
accountViewModel.hide(user)
onDismiss()
},
)
}
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = { Text(stringResource(id = R.string.report_spam_scam)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.SPAM)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.report_hateful_speech)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.PROFANITY)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringResource(id = R.string.report_impersonation)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.report_nudity_porn)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.NUDITY)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringResource(id = R.string.report_illegal_behaviour)) },
onClick = {
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL)
onDismiss()
},
)
}
}
}