amethyst/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt

2967 wiersze
94 KiB
Kotlin
Czysty Zwykły widok Historia

2023-01-11 18:31:20 +00:00
package com.vitorpamplona.amethyst.ui.note
import android.content.Intent
import android.graphics.Bitmap
import android.util.Log
2023-05-07 12:53:52 +00:00
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.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
2023-05-11 18:39:05 +00:00
import androidx.compose.foundation.layout.PaddingValues
2023-05-07 12:53:52 +00:00
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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
2023-01-11 18:31:20 +00:00
import androidx.compose.foundation.shape.CircleShape
2023-03-05 23:34:11 +00:00
import androidx.compose.foundation.shape.CutCornerShape
2023-03-16 18:20:30 +00:00
import androidx.compose.foundation.text.ClickableText
2023-05-11 18:39:05 +00:00
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
2023-05-07 12:53:52 +00:00
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.darkColors
2023-02-06 22:16:27 +00:00
import androidx.compose.material.icons.Icons
2023-03-23 14:49:01 +00:00
import androidx.compose.material.icons.filled.Bolt
2023-02-06 22:16:27 +00:00
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Link
2023-02-06 22:16:27 +00:00
import androidx.compose.material.icons.filled.MoreVert
2023-05-16 02:18:12 +00:00
import androidx.compose.material.icons.filled.PushPin
2023-05-07 12:53:52 +00:00
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
2023-05-07 12:53:52 +00:00
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
2023-05-07 12:53:52 +00:00
import androidx.compose.runtime.getValue
2023-01-11 18:31:20 +00:00
import androidx.compose.runtime.livedata.observeAsState
2023-05-07 12:53:52 +00:00
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
2023-01-11 18:31:20 +00:00
import androidx.compose.ui.Alignment
2023-05-16 02:18:12 +00:00
import androidx.compose.ui.Alignment.Companion.CenterVertically
2023-06-01 19:16:03 +00:00
import androidx.compose.ui.Alignment.Companion.TopEnd
2023-01-11 18:31:20 +00:00
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
2023-05-11 18:39:05 +00:00
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
2023-01-11 18:31:20 +00:00
import androidx.compose.ui.text.font.FontWeight
2023-03-05 23:34:11 +00:00
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
2023-01-11 18:31:20 +00:00
import androidx.compose.ui.unit.dp
2023-03-28 12:46:07 +00:00
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.get
2023-01-11 18:31:20 +00:00
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
2023-06-01 00:08:30 +00:00
import coil.request.SuccessResult
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
2023-05-05 01:57:19 +00:00
import com.vitorpamplona.amethyst.model.Channel
2023-03-08 22:18:25 +00:00
import com.vitorpamplona.amethyst.model.LocalCache
2023-01-11 18:31:20 +00:00
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.service.model.AppDefinitionEvent
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
2023-05-07 12:53:52 +00:00
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
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.EventInterface
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.Participant
2023-05-11 18:39:05 +00:00
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
2023-05-16 02:18:12 +00:00
import com.vitorpamplona.amethyst.service.model.PinListEvent
2023-05-07 12:53:52 +00:00
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
2023-06-07 21:16:48 +00:00
import com.vitorpamplona.amethyst.service.model.RelaySetEvent
2023-05-07 12:53:52 +00:00
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
2023-06-07 21:16:48 +00:00
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
2023-05-07 12:53:52 +00:00
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
2023-05-16 01:26:59 +00:00
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView
2023-05-07 12:53:52 +00:00
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
2023-06-07 21:16:48 +00:00
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
2023-05-07 12:53:52 +00:00
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.VideoView
2023-05-07 12:53:52 +00:00
import com.vitorpamplona.amethyst.ui.components.ZoomableContent
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
2023-05-07 12:53:52 +00:00
import com.vitorpamplona.amethyst.ui.components.ZoomableLocalImage
import com.vitorpamplona.amethyst.ui.components.ZoomableLocalVideo
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
2023-05-07 12:53:52 +00:00
import com.vitorpamplona.amethyst.ui.components.imageExtensions
2023-06-07 22:50:29 +00:00
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
2023-01-11 18:31:20 +00:00
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
2023-03-14 17:41:39 +00:00
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
2023-03-23 14:49:01 +00:00
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
2023-03-08 22:07:56 +00:00
import com.vitorpamplona.amethyst.ui.theme.Following
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
2023-06-04 01:46:26 +00:00
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
2023-06-04 01:46:26 +00:00
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.toNpub
import java.io.File
2023-03-23 14:49:01 +00:00
import java.math.BigDecimal
import java.net.URL
import java.util.Locale
2023-01-11 18:31:20 +00:00
@OptIn(ExperimentalFoundationApi::class)
2023-01-11 18:31:20 +00:00
@Composable
fun NoteCompose(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = remember { Modifier },
2023-02-20 23:09:57 +00:00
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
2023-02-27 22:14:15 +00:00
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event }
2023-01-11 18:31:20 +00:00
if (noteEvent == null) {
var popupExpanded by remember { mutableStateOf(false) }
2023-03-08 22:07:56 +00:00
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true }
)
},
isBoostedNote || isQuotedNote
2023-03-08 22:07:56 +00:00
)
NoteQuickActionMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
2023-01-11 18:31:20 +00:00
} else {
CheckHiddenNoteCompose(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
@Composable
fun CheckHiddenNoteCompose(
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val isHidden by remember(accountState) {
derivedStateOf {
val isSensitive = note.event?.isSensitive() ?: false
accountState?.account?.isHidden(note.author!!) == true || (isSensitive && accountState?.account?.showSensitiveContent == false)
}
}
if (!isHidden) {
LoadedNoteCompose(
note,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
@Immutable
data class NoteComposeReportState(
val isAcceptable: Boolean,
val canPreview: Boolean,
val relevantReports: ImmutableSet<Note>
)
@Composable
fun LoadedNoteCompose(
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
)
}
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
state = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
2023-03-08 22:07:56 +00:00
}
}
2023-03-08 22:07:56 +00:00
var showReportedNote by remember { mutableStateOf(false) }
val showHiddenNote by remember(state, showReportedNote) {
derivedStateOf {
!state.isAcceptable && !showReportedNote
}
}
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
2023-06-01 19:16:03 +00:00
accountViewModel,
modifier,
isBoostedNote,
nav,
onClick = { showReportedNote = true }
)
} else {
val canPreview by remember(state, showReportedNote) {
derivedStateOf {
(!state.isAcceptable && showReportedNote) || state.canPreview
}
}
NormalNote(
note,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
canPreview,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
@Composable
private fun WatchForReports(
note: Note,
accountViewModel: AccountViewModel,
onChange: (Boolean, Boolean, Set<Note>) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val noteReportsState by note.live().reports.observeAsState()
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
launch(Dispatchers.Default) {
accountState?.account?.let { loggedIn ->
val newCanPreview = note.author?.pubkeyHex == loggedIn.userProfile().pubkeyHex ||
(note.author?.let { loggedIn.userProfile().isFollowingCached(it) } ?: true) ||
noteReportsState?.note?.hasAnyReports() != true
val newIsAcceptable = noteReportsState?.note?.let {
loggedIn.isAcceptable(it)
} ?: true
val newRelevantReports = noteReportsState?.note?.let {
loggedIn.getRelevantReports(it)
} ?: emptySet()
onChange(newIsAcceptable, newCanPreview, newRelevantReports)
}
}
}
}
@Composable
fun NormalNote(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
canPreview: Boolean = true,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = remember { baseNote.event }
val channelHex = remember { baseNote.channelHex() }
if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && channelHex != null) {
2023-06-01 19:16:03 +00:00
ChannelHeader(channelHex = channelHex, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = baseNote)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(baseNote)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(baseNote)
} else {
NoteWithReactions(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
canPreview,
parentBackgroundColor,
accountViewModel,
nav
)
}
}
2023-03-23 14:49:01 +00:00
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun NoteWithReactions(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
canPreview: Boolean = true,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var isNew by remember { mutableStateOf<Boolean>(false) }
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = routeForLastRead) {
launch(Dispatchers.IO) {
routeForLastRead?.let {
val lastTime = NotificationCache.load(it)
val createdAt = baseNote.createdAt()
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt)
val newIsNew = createdAt > lastTime
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
}
}
val primaryColor = MaterialTheme.colors.newItemBackgroundColor
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember(isNew, parentBackgroundColor) {
if (isNew) {
if (parentBackgroundColor != null) {
primaryColor.compositeOver(parentBackgroundColor)
} else {
primaryColor.compositeOver(defaultBackgroundColor)
}
} else {
parentBackgroundColor ?: defaultBackgroundColor
}
}
2023-02-27 00:22:22 +00:00
val columnModifier = remember(backgroundColor) {
modifier
.combinedClickable(
onClick = {
scope.launch {
routeFor(baseNote, accountViewModel.userProfile())?.let {
nav(it)
}
}
},
onLongClick = { popupExpanded = true }
)
.background(backgroundColor)
}
val notBoostedNorQuote by remember {
derivedStateOf {
!isBoostedNote && !isQuotedNote
}
}
val showSecondRow by remember {
derivedStateOf {
baseNote.event !is RepostEvent && !isBoostedNote && !isQuotedNote
}
}
Column(modifier = columnModifier) {
Row(
modifier = remember {
Modifier
.padding(
start = if (!isBoostedNote) 12.dp else 0.dp,
end = if (!isBoostedNote) 12.dp else 0.dp,
top = if (addMarginTop && !isBoostedNote) 10.dp else 0.dp
2023-06-09 14:49:10 +00:00
// Don't add margin to the bottom because of the Divider down below
)
}
) {
if (notBoostedNorQuote) {
DrawAuthorImages(baseNote, accountViewModel, nav)
2023-06-09 14:49:10 +00:00
Spacer(modifier = Modifier.width(10.dp))
}
2023-05-06 15:49:53 +00:00
NoteBody(
baseNote,
isQuotedNote,
unPackReply,
makeItShort,
canPreview,
showSecondRow,
backgroundColor,
accountViewModel,
nav
)
NoteQuickActionMenu(
baseNote,
popupExpanded,
{ popupExpanded = false },
accountViewModel
)
}
2023-01-11 18:31:20 +00:00
if (!makeItShort && baseNote.event !is RepostEvent) {
ReactionsRow(
baseNote,
notBoostedNorQuote,
accountViewModel,
nav
)
} else {
2023-06-09 15:27:02 +00:00
if (!isBoostedNote && baseNote.event !is RepostEvent) {
Spacer(modifier = Modifier.height(10.dp))
}
}
2023-01-11 18:31:20 +00:00
if (!isQuotedNote && !isBoostedNote) {
Divider(
thickness = 0.25.dp
)
}
}
}
2023-01-11 18:31:20 +00:00
@Composable
private fun NoteBody(
baseNote: Note,
showAuthorPicture: Boolean = false,
unPackReply: Boolean = true,
makeItShort: Boolean = false,
canPreview: Boolean = true,
showSecondRow: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
2023-06-09 14:49:10 +00:00
Column() {
FirstUserInfoRow(
baseNote = baseNote,
showAuthorPicture = showAuthorPicture,
accountViewModel = accountViewModel,
nav = nav
)
if (showSecondRow) {
SecondUserInfoRow(
baseNote,
accountViewModel,
nav
)
}
Spacer(modifier = Modifier.height(2.dp))
if (!makeItShort) {
ReplyRow(
baseNote,
unPackReply,
backgroundColor,
accountViewModel,
nav
)
}
RenderNoteRow(
baseNote,
backgroundColor,
makeItShort,
canPreview,
accountViewModel,
nav
)
}
}
@Composable
private fun RenderNoteRow(
baseNote: Note,
backgroundColor: Color,
makeItShort: Boolean,
canPreview: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = remember { baseNote.event }
when (noteEvent) {
is AppDefinitionEvent -> {
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is ReactionEvent -> {
RenderReaction(baseNote, backgroundColor, accountViewModel, nav)
}
2023-05-11 18:39:05 +00:00
is RepostEvent -> {
RenderRepost(baseNote, backgroundColor, accountViewModel, nav)
}
is ReportEvent -> {
RenderReport(baseNote, backgroundColor, accountViewModel, nav)
}
2023-06-07 21:16:48 +00:00
is LongTextNoteEvent -> {
RenderLongFormContent(baseNote, accountViewModel, nav)
}
2023-05-16 02:18:12 +00:00
is BadgeAwardEvent -> {
RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
}
is PeopleListEvent -> {
RenderPeopleList(baseNote, backgroundColor, accountViewModel, nav)
}
is RelaySetEvent -> {
RelaySetList(baseNote, backgroundColor, accountViewModel, nav)
}
is AudioTrackEvent -> {
RenderAudioTrack(baseNote, accountViewModel, nav)
}
is PinListEvent -> {
RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav)
}
is PrivateDmEvent -> {
RenderPrivateMessage(
baseNote,
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
nav
)
}
2023-06-07 22:50:29 +00:00
is HighlightEvent -> {
RenderHighlight(
baseNote,
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
nav
)
}
2023-06-07 22:50:29 +00:00
is PollNoteEvent -> {
RenderPoll(
baseNote,
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
nav
)
}
else -> {
RenderTextEvent(
baseNote,
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
nav
2023-06-07 22:50:29 +00:00
)
2023-05-06 15:49:53 +00:00
}
}
}
2023-05-06 15:49:53 +00:00
fun routeFor(note: Note, loggedIn: User): String? {
val noteEvent = note.event
2023-05-06 15:49:53 +00:00
if (noteEvent is ChannelMessageEvent || noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) {
note.channelHex()?.let {
return "Channel/$it"
2023-05-06 15:49:53 +00:00
}
} else if (noteEvent is PrivateDmEvent) {
return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}"
2023-05-06 15:49:53 +00:00
} else {
return "Note/${note.idHex}"
}
2023-05-06 15:49:53 +00:00
return null
}
2023-05-06 15:49:53 +00:00
@Composable
private fun RenderTextEvent(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
val eventContent = remember(note.event) { accountViewModel.decrypt(note) }
2023-05-06 15:49:53 +00:00
if (eventContent != null) {
val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) }
if (makeItShort && isAuthorTheLoggedUser) {
2023-05-06 15:49:53 +00:00
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
} else {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
) {
val modifier = remember(note) { Modifier.fillMaxWidth() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
modifier = modifier,
tags = tags,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
val hashtags = remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
DisplayUncitedHashtags(hashtags, eventContent, nav)
2023-05-06 15:49:53 +00:00
}
}
}
@Composable
private fun RenderPoll(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
val noteEvent = note.event as? PollNoteEvent ?: return
2023-06-01 00:08:07 +00:00
val eventContent = remember { noteEvent.content() }
2023-05-06 15:49:53 +00:00
if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
} else {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
modifier = remember { Modifier.fillMaxWidth() },
tags = tags,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
2023-05-06 15:49:53 +00:00
PollNote(
note,
canPreview = canPreview && !makeItShort,
backgroundColor,
accountViewModel,
nav
)
}
2023-05-06 15:49:53 +00:00
var hashtags = remember { noteEvent.hashtags().toImmutableList() }
DisplayUncitedHashtags(hashtags, eventContent, nav)
2023-05-06 15:49:53 +00:00
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RenderAppDefinition(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = note.event as? AppDefinitionEvent ?: return
var metadata by remember {
mutableStateOf<UserMetadata?>(null)
}
LaunchedEffect(key1 = noteEvent) {
launch(Dispatchers.Default) {
metadata = noteEvent.appMetaData()
}
}
metadata?.let {
Box {
val clipboardManager = LocalClipboardManager.current
val uri = LocalUriHandler.current
if (!it.banner.isNullOrBlank()) {
var zoomImageDialogOpen by remember { mutableStateOf(false) }
AsyncImage(
model = it.banner,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
.combinedClickable(
onClick = {},
onLongClick = {
clipboardManager.setText(AnnotatedString(it.banner!!))
}
)
)
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false })
}
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(id = R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.padding(top = 75.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
var zoomImageDialogOpen by remember { mutableStateOf(false) }
Box(Modifier.size(100.dp)) {
it.picture?.let {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.border(
3.dp,
MaterialTheme.colors.background,
CircleShape
)
.clip(shape = CircleShape)
.fillMaxSize()
.background(MaterialTheme.colors.background)
.combinedClickable(
onClick = { zoomImageDialogOpen = true },
onLongClick = {
clipboardManager.setText(AnnotatedString(it))
}
)
)
}
}
if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false })
}
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier
.height(35.dp)
.padding(bottom = 3.dp)
) {
}
}
val name = remember(it) { it.anyName() }
name?.let {
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
CreateTextWithEmoji(
text = it,
tags = remember { (note.event?.tags() ?: emptyList()).toImmutableListOfLists() },
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
}
}
val website = remember(it) { it.website }
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
imageVector = Icons.Default.Link,
contentDescription = stringResource(R.string.website),
modifier = Modifier.size(16.dp)
)
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
onClick = { website.let { runCatching { uri.openUri(it) } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
}
}
it.about?.let {
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) {
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = tags,
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
}
}
2023-05-06 15:49:53 +00:00
@Composable
private fun RenderHighlight(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
val quote = remember {
(note.event as? HighlightEvent)?.quote() ?: ""
}
val author = remember() {
(note.event as? HighlightEvent)?.author()
}
val url = remember() {
(note.event as? HighlightEvent)?.inUrl()
}
2023-05-06 15:49:53 +00:00
DisplayHighlight(
quote,
author,
url,
2023-05-06 15:49:53 +00:00
makeItShort,
canPreview,
backgroundColor,
accountViewModel,
nav
2023-05-06 15:49:53 +00:00
)
}
@Composable
private fun RenderPrivateMessage(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
val noteEvent = note.event as? PrivateDmEvent ?: return
val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
2023-05-06 15:49:53 +00:00
if (withMe) {
val eventContent = remember { accountViewModel.decrypt(note) }
2023-05-06 15:49:53 +00:00
val hashtags = remember(note.event?.id()) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
2023-05-06 15:49:53 +00:00
if (eventContent != null) {
if (makeItShort && isAuthorTheLoggedUser) {
2023-05-06 15:49:53 +00:00
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
} else {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
modifier = modifier,
tags = tags,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
2023-05-06 15:49:53 +00:00
DisplayUncitedHashtags(hashtags, eventContent, nav)
2023-05-06 15:49:53 +00:00
}
}
} else {
val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone"
2023-05-06 15:49:53 +00:00
TranslatableRichTextViewer(
stringResource(
id = R.string.private_conversation_notification,
"@${note.author?.pubkeyNpub()}",
"@$recipient"
2023-05-06 15:49:53 +00:00
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
ImmutableListOfLists(),
2023-05-06 15:49:53 +00:00
backgroundColor,
accountViewModel,
nav
2023-05-06 15:49:53 +00:00
)
}
}
2023-06-07 21:16:48 +00:00
@Composable
fun RelaySetList(
baseNote: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav)
}
2023-05-11 18:39:05 +00:00
@Composable
fun RenderPeopleList(
baseNote: Note,
2023-05-11 18:39:05 +00:00
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-11 18:39:05 +00:00
) {
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
2023-05-11 18:39:05 +00:00
}
2023-06-07 21:16:48 +00:00
@Composable
fun DisplayRelaySet(
baseNote: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = baseNote.event as? RelaySetEvent ?: return
val relays by remember {
mutableStateOf<ImmutableList<String>>(
noteEvent.relays().toImmutableList()
)
}
var expanded by remember {
mutableStateOf(false)
}
val toMembersShow = if (expanded) {
relays
} else {
relays.take(3)
}
val relayListName by remember {
derivedStateOf {
"#${noteEvent.dTag()}"
}
}
val relayDescription by remember {
derivedStateOf {
noteEvent.description()
}
}
Text(
text = relayListName,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center
)
relayDescription?.let {
Text(
text = it,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center,
color = Color.Gray
)
}
Box {
Column(modifier = Modifier.padding(top = 5.dp)) {
toMembersShow.forEach { relay ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
Text(
relay.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/"),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(start = 10.dp, bottom = 5.dp)
.weight(1f)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
RelayOptionsAction(relay, accountViewModel)
}
}
}
}
if (relays.size > 3 && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundColor.copy(alpha = 0f),
backgroundColor
)
)
)
) {
ShowMoreButton {
expanded = !expanded
}
}
}
}
}
@Composable
private fun RelayOptionsAction(
relay: String,
accountViewModel: AccountViewModel
) {
val userStateRelayInfo by accountViewModel.account.userProfile().live().relayInfo.observeAsState()
val isCurrentlyOnTheUsersList by remember(userStateRelayInfo) {
derivedStateOf {
userStateRelayInfo?.user?.latestContactList?.relays()?.none { it.key == relay } == true
}
}
var wantsToAddRelay by remember {
mutableStateOf("")
}
if (wantsToAddRelay.isNotEmpty()) {
NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay)
}
if (isCurrentlyOnTheUsersList) {
AddRelayButton { wantsToAddRelay = relay }
} else {
RemoveRelayButton { wantsToAddRelay = relay }
}
}
@OptIn(ExperimentalLayoutApi::class)
2023-05-11 18:39:05 +00:00
@Composable
fun DisplayPeopleList(
baseNote: Note,
2023-05-11 18:39:05 +00:00
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-11 18:39:05 +00:00
) {
val noteEvent = baseNote.event as? PeopleListEvent ?: return
2023-05-11 18:39:05 +00:00
var members by remember { mutableStateOf<List<User>>(listOf()) }
val account = accountViewModel.userProfile()
var expanded by remember {
mutableStateOf(false)
}
val toMembersShow = if (expanded) {
members
} else {
members.take(3)
}
Text(
text = "#${noteEvent.dTag()}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center
)
LaunchedEffect(Unit) {
2023-06-01 19:16:03 +00:00
launch(Dispatchers.IO) {
2023-05-11 18:39:05 +00:00
members = noteEvent.bookmarkedPeople().mapNotNull { hex ->
LocalCache.checkGetOrCreateUser(hex)
}.sortedBy { account.isFollowing(it) }.reversed()
}
}
Box {
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
toMembersShow.forEach { user ->
Row(modifier = Modifier.fillMaxWidth()) {
UserCompose(
user,
overallModifier = Modifier,
accountViewModel = accountViewModel,
nav = nav
2023-05-11 18:39:05 +00:00
)
}
}
}
if (members.size > 3 && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundColor.copy(alpha = 0f),
backgroundColor
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { expanded = !expanded },
shape = ButtonBorder,
2023-05-11 18:39:05 +00:00
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_more), color = Color.White)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
2023-05-06 15:49:53 +00:00
@Composable
private fun RenderBadgeAward(
note: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
if (note.replyTo.isNullOrEmpty()) return
val noteEvent = note.event as? BadgeAwardEvent ?: return
var awardees by remember { mutableStateOf<List<User>>(listOf()) }
2023-05-06 15:49:53 +00:00
2023-05-07 12:53:52 +00:00
val account = accountViewModel.userProfile()
2023-05-06 15:49:53 +00:00
Text(text = stringResource(R.string.award_granted_to))
2023-05-07 00:02:54 +00:00
LaunchedEffect(key1 = note) {
2023-06-01 19:16:03 +00:00
launch(Dispatchers.IO) {
2023-05-07 00:02:54 +00:00
awardees = noteEvent.awardees().mapNotNull { hex ->
LocalCache.checkGetOrCreateUser(hex)
2023-05-07 12:53:52 +00:00
}.sortedBy { account.isFollowing(it) }.reversed()
}
2023-05-07 00:02:54 +00:00
}
2023-05-06 15:49:53 +00:00
2023-05-07 00:02:54 +00:00
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
2023-05-07 12:53:52 +00:00
awardees.take(100).forEach { user ->
2023-05-07 00:03:04 +00:00
Row(
modifier = Modifier
.size(size = 35.dp)
.clickable {
nav("User/${user.pubkeyHex}")
},
2023-05-07 00:02:54 +00:00
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(
baseUser = user,
2023-06-01 19:16:03 +00:00
accountViewModel = accountViewModel,
2023-05-07 00:02:54 +00:00
size = 35.dp
)
2023-05-06 15:49:53 +00:00
}
2023-05-07 00:02:54 +00:00
}
2023-05-07 12:53:52 +00:00
if (awardees.size > 100) {
Text(" and ${awardees.size - 100} others")
}
2023-05-07 00:02:54 +00:00
}
2023-05-06 15:49:53 +00:00
2023-05-07 00:02:54 +00:00
note.replyTo?.firstOrNull()?.let {
NoteCompose(
it,
modifier = Modifier,
isBoostedNote = false,
isQuotedNote = true,
unPackReply = false,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
2023-05-07 00:02:54 +00:00
)
}
2023-05-06 15:49:53 +00:00
}
@Composable
private fun RenderReaction(
note: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
note.replyTo?.lastOrNull()?.let {
NoteCompose(
it,
modifier = Modifier,
isBoostedNote = true,
unPackReply = false,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
2023-05-06 15:49:53 +00:00
)
}
// Reposts have trash in their contents.
val refactorReactionText =
if (note.event?.content() == "+") "" else note.event?.content() ?: ""
Text(
text = refactorReactionText
)
}
@Composable
private fun RenderRepost(
note: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
val boostedNote = remember {
note.replyTo?.lastOrNull()
}
boostedNote?.let {
2023-05-06 15:49:53 +00:00
NoteCompose(
it,
modifier = remember { Modifier },
2023-05-06 15:49:53 +00:00
isBoostedNote = true,
unPackReply = false,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
2023-05-06 15:49:53 +00:00
)
}
}
2023-05-16 02:18:12 +00:00
@Composable
private fun RenderPinListEvent(
baseNote: Note,
2023-05-16 02:18:12 +00:00
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-16 02:18:12 +00:00
) {
PinListHeader(baseNote, backgroundColor, accountViewModel, nav)
2023-05-16 02:18:12 +00:00
}
@OptIn(ExperimentalLayoutApi::class)
2023-05-16 02:18:12 +00:00
@Composable
fun PinListHeader(
baseNote: Note,
2023-05-16 02:18:12 +00:00
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-16 02:18:12 +00:00
) {
val noteEvent = baseNote.event as? PinListEvent ?: return
2023-05-16 02:18:12 +00:00
val pins by remember { mutableStateOf(noteEvent.pins()) }
2023-05-16 02:18:12 +00:00
var expanded by remember {
mutableStateOf(false)
}
val pinsToShow = if (expanded) {
pins
} else {
pins.take(3)
}
Text(
text = "#${noteEvent.dTag()}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center
)
Box {
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
pinsToShow.forEach { pin ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = null,
2023-05-16 12:32:03 +00:00
tint = MaterialTheme.colors.onBackground.copy(0.32f),
2023-05-16 02:18:12 +00:00
modifier = Modifier.size(15.dp)
)
Spacer(modifier = Modifier.width(5.dp))
TranslatableRichTextViewer(
content = pin,
canPreview = true,
tags = remember { ImmutableListOfLists() },
2023-05-16 02:18:12 +00:00
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
2023-05-16 02:18:12 +00:00
)
}
}
}
if (pins.size > 3 && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundColor.copy(alpha = 0f),
backgroundColor
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { expanded = !expanded },
shape = ButtonBorder,
2023-05-16 02:18:12 +00:00
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_more), color = Color.White)
}
}
}
}
}
@Composable
private fun RenderAudioTrack(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = note.event as? AudioTrackEvent ?: return
2023-06-01 19:16:03 +00:00
AudioTrackHeader(noteEvent, accountViewModel, nav)
}
2023-05-06 15:49:53 +00:00
@Composable
private fun RenderLongFormContent(
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
val noteEvent = note.event as? LongTextNoteEvent ?: return
2023-06-01 19:16:03 +00:00
LongFormHeader(noteEvent, note, accountViewModel)
2023-05-06 15:49:53 +00:00
}
@Composable
private fun RenderReport(
note: Note,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = note.event as? ReportEvent ?: return
val base = remember {
(noteEvent.reportedPost() + noteEvent.reportedAuthor())
}
2023-05-06 15:49:53 +00:00
val reportType = base.map {
2023-05-06 15:49:53 +00:00
when (it.reportType) {
ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content)
ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity)
ReportEvent.ReportType.PROFANITY -> stringResource(R.string.profanity_hateful_speech)
ReportEvent.ReportType.SPAM -> stringResource(R.string.spam)
ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation)
ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior)
}
}.toSet().joinToString(", ")
val content = remember {
reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "")
}
TranslatableRichTextViewer(
content = content,
canPreview = true,
modifier = remember { Modifier },
tags = remember { ImmutableListOfLists() },
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
2023-05-06 15:49:53 +00:00
)
note.replyTo?.lastOrNull()?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
modifier = Modifier
.padding(top = 5.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
QuoteBorder
),
unPackReply = false,
makeItShort = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
2023-05-06 15:49:53 +00:00
}
@Composable
private fun ReplyRow(
note: Note,
unPackReply: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
val noteEvent = note.event
2023-05-07 00:02:54 +00:00
if (noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) {
val replyingDirectlyTo = remember {
note.replyTo?.lastOrNull()
}
2023-05-06 15:49:53 +00:00
if (replyingDirectlyTo != null && unPackReply) {
NoteCompose(
baseNote = replyingDirectlyTo,
isQuotedNote = true,
modifier = Modifier
.padding(top = 5.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
2023-05-06 15:49:53 +00:00
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
QuoteBorder
2023-05-06 15:49:53 +00:00
),
unPackReply = false,
makeItShort = true,
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
accountViewModel = accountViewModel,
nav = nav
2023-05-06 15:49:53 +00:00
)
} else {
// ReplyInformation(note.replyTo, noteEvent.mentions(), accountViewModel, nav)
2023-05-06 15:49:53 +00:00
}
Spacer(modifier = Modifier.height(5.dp))
2023-05-07 00:02:54 +00:00
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) {
val channelHex = note.channelHex()
channelHex?.let {
val replies = remember { note.replyTo?.toImmutableList() }
val mentions = remember { noteEvent.mentions().toImmutableList() }
ReplyInformationChannel(replies, mentions, it, accountViewModel, nav)
2023-05-06 15:49:53 +00:00
}
Spacer(modifier = Modifier.height(5.dp))
}
}
@Composable
private fun SecondUserInfoRow(
note: Note,
2023-06-01 19:16:03 +00:00
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
val noteEvent = remember { note.event } ?: return
val noteAuthor = remember { note.author } ?: return
2023-05-06 15:49:53 +00:00
Row(verticalAlignment = Alignment.CenterVertically) {
2023-06-04 16:17:02 +00:00
ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) })
2023-05-06 15:49:53 +00:00
val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } }
2023-05-06 15:49:53 +00:00
if (baseReward != null) {
2023-06-01 19:16:03 +00:00
DisplayReward(baseReward, note, accountViewModel, nav)
2023-05-06 15:49:53 +00:00
}
val pow = remember { noteEvent.getPoWRank() }
2023-05-06 15:49:53 +00:00
if (pow > 20) {
DisplayPoW(pow)
}
}
}
@Composable
private fun FirstUserInfoRow(
baseNote: Note,
showAuthorPicture: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-06 15:49:53 +00:00
) {
val eventNote = remember { baseNote.event } ?: return
val time = remember { baseNote.createdAt() } ?: return
val padding = remember {
Modifier.padding(horizontal = 5.dp)
}
2023-05-06 15:49:53 +00:00
Row(verticalAlignment = Alignment.CenterVertically) {
if (showAuthorPicture) {
NoteAuthorPicture(baseNote, nav, accountViewModel, remember { 25.dp })
Spacer(padding)
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
2023-05-06 15:49:53 +00:00
} else {
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
2023-05-06 15:49:53 +00:00
}
if (eventNote is RepostEvent) {
2023-05-06 15:49:53 +00:00
Text(
" ${stringResource(id = R.string.boosted)}",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else {
2023-06-01 19:16:03 +00:00
DisplayFollowingHashtagsInPost(eventNote, accountViewModel, nav)
2023-01-11 18:31:20 +00:00
}
2023-05-06 15:49:53 +00:00
TimeAgo(time)
2023-05-06 15:49:53 +00:00
MoreOptionsButton(baseNote, accountViewModel)
}
}
2023-05-06 15:49:53 +00:00
@Composable
private fun MoreOptionsButton(
baseNote: Note,
accountViewModel: AccountViewModel
) {
var moreActionsExpanded by remember { mutableStateOf(false) }
IconButton(
modifier = Modifier.size(24.dp),
onClick = { moreActionsExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
NoteDropDownMenu(
baseNote,
moreActionsExpanded,
{ moreActionsExpanded = false },
accountViewModel
)
2023-01-11 18:31:20 +00:00
}
}
@Composable
fun TimeAgo(time: Long) {
val context = LocalContext.current
var timeStr by remember { mutableStateOf("") }
LaunchedEffect(key1 = time) {
2023-06-04 01:46:26 +00:00
launch(Dispatchers.IO) {
val newTimeStr = timeAgo(time, context = context)
if (newTimeStr != timeStr) {
timeStr = newTimeStr
}
}
}
Text(
timeStr,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
}
2023-05-05 01:57:19 +00:00
@Composable
2023-06-01 19:16:03 +00:00
private fun DrawAuthorImages(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseChannelHex = remember { baseNote.channelHex() }
val modifier = remember { Modifier.width(55.dp) }
2023-05-05 01:57:19 +00:00
Column(modifier) {
2023-05-05 01:57:19 +00:00
// Draws the boosted picture outside the boosted card.
Box(modifier = modifier, contentAlignment = Alignment.BottomEnd) {
NoteAuthorPicture(baseNote, nav, accountViewModel, 55.dp)
2023-05-05 01:57:19 +00:00
if (baseNote.event is RepostEvent) {
RepostNoteAuthorPicture(baseNote, accountViewModel, nav)
2023-05-05 01:57:19 +00:00
}
if (baseNote.event is ChannelMessageEvent && baseChannelHex != null) {
LoadChannel(baseChannelHex) { channel ->
ChannelNotePicture(channel)
}
2023-05-05 01:57:19 +00:00
}
}
if (baseNote.event is RepostEvent) {
val baseReply = remember {
baseNote.replyTo?.lastOrNull()
}
baseReply?.let {
2023-05-05 01:57:19 +00:00
RelayBadges(it)
}
} else {
RelayBadges(baseNote)
}
}
}
@Composable
fun LoadChannel(baseChannelHex: String, content: @Composable (Channel) -> Unit) {
var channel by remember(baseChannelHex) {
mutableStateOf<Channel?>(null)
}
LaunchedEffect(key1 = baseChannelHex) {
if (channel == null) {
launch(Dispatchers.IO) {
channel = LocalCache.checkGetOrCreateChannel(baseChannelHex)
}
}
}
channel?.let {
content(it)
}
}
2023-05-05 01:57:19 +00:00
@Composable
private fun ChannelNotePicture(baseChannel: Channel) {
val channelState by baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState?.channel } ?: return
2023-05-05 01:57:19 +00:00
val modifier = remember {
Modifier
.width(30.dp)
.height(30.dp)
.clip(shape = CircleShape)
}
val boxModifier = remember {
Modifier
.width(30.dp)
.height(30.dp)
}
val model = remember(channelState) {
ResizeImage(channel.profilePicture(), 30.dp)
}
Box(boxModifier) {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = model,
contentDescription = stringResource(R.string.group_picture),
modifier = modifier
.background(MaterialTheme.colors.background)
.border(
2.dp,
MaterialTheme.colors.background,
CircleShape
)
)
2023-05-05 01:57:19 +00:00
}
}
@Composable
private fun RepostNoteAuthorPicture(
baseNote: Note,
2023-06-01 19:16:03 +00:00
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-05-05 01:57:19 +00:00
) {
val baseRepost = remember { baseNote.replyTo?.lastOrNull() }
val modifier = remember {
Modifier.size(30.dp)
}
baseRepost?.let {
Box(modifier) {
2023-05-05 01:57:19 +00:00
NoteAuthorPicture(
2023-06-01 19:16:03 +00:00
baseNote = it,
nav = nav,
accountViewModel = accountViewModel,
size = remember { 30.dp },
2023-05-05 01:57:19 +00:00
pictureModifier = Modifier.border(
2.dp,
MaterialTheme.colors.background,
CircleShape
)
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayHighlight(
highlight: String,
authorHex: String?,
url: String?,
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val quote =
remember {
highlight
.split("\n")
.map { "> *${it.removeSuffix(" ")}*" }
.joinToString("\n")
}
2023-05-06 15:49:53 +00:00
TranslatableRichTextViewer(
quote,
canPreview = canPreview && !makeItShort,
remember { Modifier.fillMaxWidth() },
remember { ImmutableListOfLists<String>(emptyList()) },
2023-05-06 15:49:53 +00:00
backgroundColor,
accountViewModel,
nav
2023-05-06 15:49:53 +00:00
)
var userBase by remember { mutableStateOf<User?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
if (authorHex != null) {
userBase = LocalCache.checkGetOrCreateUser(authorHex)
}
}
}
FlowRow() {
authorHex?.let { authorHex ->
userBase?.let { userBase ->
val userState by userBase.live().metadata.observeAsState()
val route = remember { "User/${userBase.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
if (userDisplayName != null) {
2023-05-16 01:26:59 +00:00
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = " ",
2023-05-16 01:26:59 +00:00
tags = userTags,
route = route,
nav = nav
)
}
}
}
url?.let { url ->
val validatedUrl = remember {
try {
URL(url)
} catch (e: Exception) {
Log.w("Note Compose", "Invalid URI: $url")
null
}
}
validatedUrl?.host?.let { host ->
Text("on ")
ClickableUrl(urlText = host, url = url)
}
}
}
}
2023-03-23 14:49:01 +00:00
@Composable
fun DisplayFollowingHashtagsInPost(
noteEvent: EventInterface,
2023-06-01 19:16:03 +00:00
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-03-23 14:49:01 +00:00
) {
2023-06-01 19:16:03 +00:00
val accountState by accountViewModel.accountLiveData.observeAsState()
var firstTag by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = accountState) {
launch(Dispatchers.Default) {
val followingTags = accountState?.account?.followingTagSet() ?: emptySet()
val newFirstTag = noteEvent.firstIsTaggedHashes(followingTags)
if (firstTag != newFirstTag) {
firstTag = newFirstTag
}
}
}
2023-03-23 14:49:01 +00:00
Column() {
Row(verticalAlignment = Alignment.CenterVertically) {
firstTag?.let {
2023-06-01 19:16:03 +00:00
val displayTag = remember(firstTag) { AnnotatedString(" #$firstTag") }
val route = remember(firstTag) { "Hashtag/$firstTag" }
2023-03-23 14:49:01 +00:00
ClickableText(
2023-06-01 19:16:03 +00:00
text = displayTag,
onClick = { nav(route) },
2023-03-23 14:49:01 +00:00
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
2023-03-23 14:49:01 +00:00
@Composable
fun DisplayUncitedHashtags(
hashtags: ImmutableList<String>,
2023-03-23 14:49:01 +00:00
eventContent: String,
nav: (String) -> Unit
2023-03-23 14:49:01 +00:00
) {
if (hashtags.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(top = 5.dp)
) {
hashtags.forEach { hashtag ->
if (!eventContent.contains(hashtag, true)) {
2023-03-23 14:49:01 +00:00
ClickableText(
2023-03-23 15:07:02 +00:00
text = AnnotatedString("#$hashtag "),
onClick = { nav("Hashtag/$hashtag") },
2023-03-23 14:49:01 +00:00
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
}
}
}
}
}
2023-03-28 12:46:07 +00:00
@Composable
fun DisplayPoW(
pow: Int
) {
Text(
"PoW-$pow",
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
),
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
@Stable
data class Reward(val amount: BigDecimal)
2023-03-23 14:49:01 +00:00
@Composable
fun DisplayReward(
baseReward: Reward,
2023-03-23 14:49:01 +00:00
baseNote: Note,
2023-06-01 19:16:03 +00:00
accountViewModel: AccountViewModel,
nav: (String) -> Unit
2023-03-23 14:49:01 +00:00
) {
var popupExpanded by remember { mutableStateOf(false) }
2023-03-23 14:49:01 +00:00
Column() {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { popupExpanded = true }
) {
2023-03-23 14:49:01 +00:00
ClickableText(
text = AnnotatedString("#bounty"),
onClick = { nav("Hashtag/bounty") },
2023-03-23 14:49:01 +00:00
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
2023-06-01 19:16:03 +00:00
RenderPledgeAmount(baseNote, baseReward, accountViewModel)
}
2023-06-01 19:16:03 +00:00
if (popupExpanded) {
AddBountyAmountDialog(baseNote, accountViewModel) {
popupExpanded = false
2023-03-23 14:49:01 +00:00
}
2023-06-01 19:16:03 +00:00
}
}
}
2023-03-23 14:49:01 +00:00
2023-06-01 19:16:03 +00:00
@Composable
private fun RenderPledgeAmount(
baseNote: Note,
baseReward: Reward,
2023-06-01 19:16:03 +00:00
accountViewModel: AccountViewModel
) {
val repliesState by baseNote.live().replies.observeAsState()
var reward by remember {
mutableStateOf<String>(
showAmount(baseReward.amount)
2023-06-01 19:16:03 +00:00
)
}
2023-03-23 14:49:01 +00:00
2023-06-01 19:16:03 +00:00
var hasPledge by remember {
mutableStateOf<Boolean>(
false
)
}
2023-06-01 19:16:03 +00:00
LaunchedEffect(key1 = repliesState) {
launch(Dispatchers.IO) {
2023-06-01 19:16:03 +00:00
repliesState?.note?.pledgedAmountByOthers()?.let {
val newRewardAmount = showAmount(baseReward.amount.add(it))
if (newRewardAmount != reward) {
reward = newRewardAmount
}
}
val newHasPledge = repliesState?.note?.hasPledgeBy(accountViewModel.userProfile()) == true
if (hasPledge != newHasPledge) {
hasPledge = newHasPledge
}
}
2023-03-23 14:49:01 +00:00
}
2023-06-01 19:16:03 +00:00
if (hasPledge) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Text(
text = reward,
2023-06-01 19:16:03 +00:00
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
2023-03-23 14:49:01 +00:00
}
2023-03-05 23:34:11 +00:00
@Composable
fun BadgeDisplay(baseNote: Note) {
val background = MaterialTheme.colors.background
2023-03-05 23:34:11 +00:00
val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
2023-06-01 00:08:30 +00:00
val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() }
val name = remember { badgeData.name() }
val description = remember { badgeData.description() }
var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) }
2023-06-01 00:08:30 +00:00
var imageResult by remember { mutableStateOf<SuccessResult?>(null) }
LaunchedEffect(key1 = imageResult) {
2023-06-01 00:08:30 +00:00
launch(Dispatchers.IO) {
imageResult?.let {
2023-06-01 00:08:30 +00:00
val backgroundColor = it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
val colorFromImage = Color(backgroundColor)
2023-06-01 00:08:30 +00:00
val textBackground = if (colorFromImage.luminance() > 0.5) {
lightColors().onBackground
} else {
darkColors().onBackground
}
backgroundFromImage = Pair(colorFromImage, textBackground)
}
}
}
2023-03-05 23:34:11 +00:00
Row(
modifier = Modifier
.padding(10.dp)
.clip(shape = CutCornerShape(20, 20, 20, 20))
2023-03-05 23:34:11 +00:00
.border(
5.dp,
MaterialTheme.colors.primary.copy(alpha = 0.32f),
CutCornerShape(20)
)
.background(backgroundFromImage.first)
2023-03-05 23:34:11 +00:00
) {
2023-06-01 00:08:30 +00:00
RenderBadge(
image,
name,
backgroundFromImage.second,
description
) {
if (imageResult == null) {
imageResult = it.result
2023-03-05 23:34:11 +00:00
}
2023-06-01 00:08:30 +00:00
}
}
}
2023-03-05 23:34:11 +00:00
2023-06-01 00:08:30 +00:00
@Composable
private fun RenderBadge(
image: String?,
name: String?,
backgroundFromImage: Color,
description: String?,
onSuccess: (AsyncImagePainter.State.Success) -> Unit
) {
Column {
image.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.badge_award_image_for,
name ?: ""
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
onSuccess = onSuccess
)
}
2023-03-05 23:34:11 +00:00
2023-06-01 00:08:30 +00:00
name?.let {
Text(
text = it,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
color = backgroundFromImage
)
}
description?.let {
Text(
text = it,
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
2023-03-05 23:34:11 +00:00
}
}
}
2023-04-21 21:01:42 +00:00
@Composable
fun FileHeaderDisplay(note: Note) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
2023-04-25 12:20:51 +00:00
var content by remember { mutableStateOf<ZoomableContent?>(null) }
LaunchedEffect(key1 = event.id) {
2023-06-01 01:32:46 +00:00
if (content == null) {
launch(Dispatchers.IO) {
val blurHash = event.blurhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val uri = "nostr:" + note.toNEvent()
content = if (isImage) {
ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri)
} else {
ZoomableUrlVideo(fullUrl, description, hash, uri)
}
2023-04-25 12:20:51 +00:00
}
2023-04-21 21:01:42 +00:00
}
}
2023-04-25 12:20:51 +00:00
content?.let {
ZoomableContentView(content = it)
}
2023-04-21 21:01:42 +00:00
}
2023-04-26 18:22:49 +00:00
@Composable
fun FileStorageHeaderDisplay(baseNote: Note) {
val appContext = LocalContext.current.applicationContext
2023-04-26 22:04:38 +00:00
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
var fileNote by remember { mutableStateOf<Note?>(null) }
2023-04-26 18:22:49 +00:00
LaunchedEffect(key1 = eventHeader.id) {
2023-06-01 01:32:46 +00:00
launch(Dispatchers.IO) {
fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) }
}
}
2023-04-26 18:22:49 +00:00
2023-05-16 13:47:10 +00:00
fileNote?.let { fileNote2 ->
val noteState by fileNote2.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note }
2023-04-26 18:22:49 +00:00
2023-05-16 13:47:10 +00:00
var content by remember { mutableStateOf<ZoomableContent?>(null) }
2023-04-26 18:22:49 +00:00
2023-05-16 13:47:10 +00:00
LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) {
2023-06-01 01:32:46 +00:00
if (content == null) {
launch(Dispatchers.IO) {
val uri = "nostr:" + baseNote.toNEvent()
val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.content
val mimeType = eventHeader.mimeType()
content = if (mimeType?.startsWith("image") == true) {
ZoomableLocalImage(
localFile = localDir,
mimeType = mimeType,
description = description,
blurhash = blurHash,
dim = dimensions,
isVerified = true,
uri = uri
)
} else {
ZoomableLocalVideo(
localFile = localDir,
mimeType = mimeType,
description = description,
dim = dimensions,
isVerified = true,
uri = uri
)
}
2023-05-16 13:47:10 +00:00
}
2023-04-26 18:22:49 +00:00
}
}
2023-05-16 13:47:10 +00:00
content?.let {
ZoomableContentView(content = it)
2023-05-16 13:47:10 +00:00
}
}
2023-04-26 18:22:49 +00:00
}
@Composable
2023-06-01 19:16:03 +00:00
fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val media = remember { noteEvent.media() }
val cover = remember { noteEvent.cover() }
val subject = remember { noteEvent.subject() }
val content = remember { noteEvent.content() }
val participants = remember { noteEvent.participants() }
var participantUsers by remember { mutableStateOf<List<Pair<Participant, User>>>(emptyList()) }
LaunchedEffect(key1 = participants) {
withContext(Dispatchers.IO) {
participantUsers = participants.mapNotNull { part -> LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } }
}
}
Row(modifier = Modifier.padding(top = 5.dp)) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Row() {
subject?.let {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)) {
Text(
text = it,
fontWeight = FontWeight.Bold,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
}
}
participantUsers.forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
2023-05-16 02:18:12 +00:00
modifier = Modifier
.padding(top = 5.dp, start = 10.dp, end = 10.dp)
.clickable {
nav("User/${it.second.pubkeyHex}")
2023-05-16 02:18:12 +00:00
}
) {
2023-06-01 19:16:03 +00:00
UserPicture(it.second, 25.dp, accountViewModel)
Spacer(Modifier.width(5.dp))
UsernameDisplay(it.second, Modifier.weight(1f))
Spacer(Modifier.width(5.dp))
it.first.role?.let {
Text(
text = it.capitalize(Locale.ROOT),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
}
}
}
media?.let { media ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp)
) {
cover?.let { cover ->
LoadThumbAndThenVideoView(
videoUri = media,
description = noteEvent.subject(),
thumbUri = cover
)
}
?: VideoView(
videoUri = media,
noteEvent.subject()
)
}
}
}
}
}
@Composable
2023-06-01 19:16:03 +00:00
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountViewModel: AccountViewModel) {
val image = remember(noteEvent) { noteEvent.image() }
val title = remember(noteEvent) { noteEvent.title() }
val summary = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } }
Row(
modifier = Modifier
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
QuoteBorder
)
) {
Column {
2023-06-01 19:16:03 +00:00
image?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
2023-06-01 19:16:03 +00:00
} ?: CreateImageHeader(note, accountViewModel)
2023-06-01 19:16:03 +00:00
title?.let {
Text(
text = it,
style = MaterialTheme.typography.body1,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
)
}
2023-06-01 19:16:03 +00:00
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
2023-06-01 19:16:03 +00:00
}
}
}
@Composable
private fun CreateImageHeader(
note: Note,
accountViewModel: AccountViewModel
) {
val banner = remember(note.author?.info) { note.author?.info?.banner }
Box() {
banner?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
Box(
Modifier
.width(75.dp)
.height(75.dp)
.padding(10.dp)
.align(Alignment.BottomStart)
) {
NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = 55.dp)
}
}
}
2023-02-06 22:16:27 +00:00
@Composable
private fun RelayBadges(baseNote: Note) {
var expanded by remember { mutableStateOf(false) }
2023-04-20 21:19:34 +00:00
var showShowMore by remember { mutableStateOf(false) }
2023-06-07 22:50:29 +00:00
var lazyRelayList by remember { mutableStateOf<ImmutableList<String>>(persistentListOf()) }
2023-06-07 22:50:29 +00:00
var shortRelayList by remember { mutableStateOf<ImmutableList<String>>(persistentListOf()) }
2023-02-06 22:16:27 +00:00
2023-06-04 01:46:26 +00:00
WatchRelayLists(baseNote) { relayList ->
2023-06-07 22:50:29 +00:00
if (!equalImmutableLists(relayList, lazyRelayList)) {
lazyRelayList = relayList.toImmutableList()
shortRelayList = relayList.take(3).toImmutableList()
2023-06-04 01:46:26 +00:00
}
2023-04-20 21:19:34 +00:00
2023-06-07 22:50:29 +00:00
val nextShowMore = relayList.size > 3
2023-06-04 01:46:26 +00:00
if (nextShowMore != showShowMore) {
// only triggers recomposition when actually different
showShowMore = nextShowMore
2023-04-20 21:19:34 +00:00
}
}
2023-02-06 22:16:27 +00:00
Spacer(Modifier.height(10.dp))
2023-06-07 22:50:29 +00:00
if (expanded) {
VerticalRelayPanelWithFlow(lazyRelayList)
} else {
VerticalRelayPanelWithFlow(shortRelayList)
}
2023-06-07 22:50:29 +00:00
if (showShowMore && !expanded) {
2023-04-20 21:19:34 +00:00
ShowMoreRelaysButton {
expanded = true
2023-02-06 22:16:27 +00:00
}
}
2023-04-20 21:19:34 +00:00
}
2023-02-06 22:16:27 +00:00
2023-06-04 01:46:26 +00:00
@Composable
private fun WatchRelayLists(baseNote: Note, onListChanges: (ImmutableList<String>) -> Unit) {
val noteRelaysState by baseNote.live().relays.observeAsState()
LaunchedEffect(key1 = noteRelaysState) {
launch(Dispatchers.IO) {
val relayList = noteRelaysState?.note?.relays?.map {
it.removePrefix("wss://").removePrefix("ws://")
} ?: emptyList()
onListChanges(relayList.toImmutableList())
}
}
}
@OptIn(ExperimentalLayoutApi::class)
2023-04-20 21:19:34 +00:00
@Composable
@Stable
private fun VerticalRelayPanelWithFlow(
relays: ImmutableList<String>
2023-04-20 21:19:34 +00:00
) {
// FlowRow Seems to be a lot faster than LazyVerticalGrid
FlowRow() {
relays.forEach { url ->
2023-05-27 20:04:20 +00:00
RenderRelay(url)
2023-04-20 21:19:34 +00:00
}
}
}
@Composable
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
val boxModifier = remember {
2023-04-20 21:19:34 +00:00
Modifier
.fillMaxWidth()
.height(25.dp)
}
val iconButtonModifier = remember { Modifier.size(24.dp) }
val iconModifier = remember { Modifier.size(15.dp) }
Row(
boxModifier,
2023-04-20 21:19:34 +00:00
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Top
) {
IconButton(
modifier = iconButtonModifier,
2023-04-20 21:19:34 +00:00
onClick = onClick
2023-03-08 22:07:56 +00:00
) {
2023-04-20 21:19:34 +00:00
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = iconModifier,
2023-04-20 21:19:34 +00:00
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
2023-02-06 22:16:27 +00:00
}
}
}
@Composable
fun NoteAuthorPicture(
2023-05-06 15:49:53 +00:00
baseNote: Note,
nav: (String) -> Unit,
2023-06-01 19:16:03 +00:00
accountViewModel: AccountViewModel,
size: Dp,
pictureModifier: Modifier = Modifier
) {
2023-06-01 19:16:03 +00:00
NoteAuthorPicture(baseNote, size, accountViewModel, pictureModifier) {
nav("User/${it.pubkeyHex}")
}
}
@Composable
fun NoteAuthorPicture(
baseNote: Note,
size: Dp,
2023-06-01 19:16:03 +00:00
accountViewModel: AccountViewModel,
2023-03-13 17:47:44 +00:00
modifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val noteState by baseNote.live().metadata.observeAsState()
val author by remember(noteState) {
derivedStateOf {
noteState?.note?.author
}
}
if (author == null) {
val nullModifier = remember {
modifier
.size(size)
.clip(shape = CircleShape)
}
RobohashAsyncImage(
robot = "authornotfound",
robotSize = size,
contentDescription = stringResource(R.string.unknown_author),
modifier = nullModifier.background(MaterialTheme.colors.background)
)
} else {
UserPicture(author!!, size, accountViewModel, modifier, onClick)
}
}
@Composable
fun UserPicture(
user: User,
nav: (String) -> Unit,
2023-06-01 19:16:03 +00:00
accountViewModel: AccountViewModel,
size: Dp,
pictureModifier: Modifier = Modifier
) {
2023-06-01 19:16:03 +00:00
UserPicture(user, size, accountViewModel, pictureModifier) {
nav("User/${it.pubkeyHex}")
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserPicture(
baseUser: User,
size: Dp,
2023-06-01 19:16:03 +00:00
accountViewModel: AccountViewModel,
modifier: Modifier = remember { Modifier },
onClick: ((User) -> Unit)? = null,
onLongClick: ((User) -> Unit)? = null
) {
val userState by baseUser.live().metadata.observeAsState()
val userPubkey = remember {
baseUser.pubkeyHex
}
2023-05-30 17:58:00 +00:00
val userProfile by remember(userState) {
derivedStateOf {
userState?.user?.profilePicture()
}
}
// BaseUser is the same reference as accountState.user
val myModifier = remember {
if (onClick != null && onLongClick != null) {
Modifier.combinedClickable(
onClick = { onClick(baseUser) },
onLongClick = { onLongClick(baseUser) }
)
} else if (onClick != null) {
Modifier.clickable(onClick = { onClick(baseUser) })
} else {
Modifier
}
}
Row(modifier = myModifier) {
UserPicture(
userHex = userPubkey,
userPicture = userProfile,
size = size,
2023-06-01 19:16:03 +00:00
modifier = modifier,
accountViewModel = accountViewModel
)
}
}
@Composable
fun UserPicture(
userHex: String,
userPicture: String?,
size: Dp,
2023-06-01 19:16:03 +00:00
modifier: Modifier = Modifier,
accountViewModel: AccountViewModel
) {
val myBoxModifier = remember {
Modifier.size(size)
}
val myImageModifier = remember {
modifier
.size(size)
.clip(shape = CircleShape)
}
val myIconSize = remember(size) { size.div(3.5f) }
2023-06-01 19:16:03 +00:00
Box(myBoxModifier, contentAlignment = TopEnd) {
RobohashAsyncImageProxy(
robot = userHex,
2023-06-01 19:16:03 +00:00
model = remember(userPicture) {
ResizeImage(userPicture, size)
},
contentDescription = stringResource(id = R.string.profile_image),
modifier = myImageModifier.background(MaterialTheme.colors.background)
)
ObserveAndDisplayFollowingMark(userHex, myIconSize, accountViewModel)
2023-06-01 19:16:03 +00:00
}
}
@Composable
private fun ObserveAndDisplayFollowingMark(userHex: String, iconSize: Dp, accountViewModel: AccountViewModel) {
val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState()
var showFollowingMark by remember { mutableStateOf(false) }
LaunchedEffect(key1 = accountFollowsState) {
launch(Dispatchers.Default) {
val newShowFollowingMark =
accountFollowsState?.user?.isFollowingCached(userHex) == true ||
(userHex == accountViewModel.account.userProfile().pubkeyHex)
if (newShowFollowingMark != showFollowingMark) {
showFollowingMark = newShowFollowingMark
}
2023-06-01 00:08:07 +00:00
}
}
2023-06-01 19:16:03 +00:00
if (showFollowingMark) {
FollowingIcon(iconSize)
}
2023-06-01 00:08:07 +00:00
}
2023-06-01 00:08:07 +00:00
@Composable
2023-06-01 19:16:03 +00:00
private fun FollowingIcon(iconSize: Dp) {
val myIconBoxModifier = remember {
Modifier.size(iconSize)
}
2023-06-01 00:08:07 +00:00
Box(myIconBoxModifier, contentAlignment = Alignment.Center) {
val myIconBackgroundModifier = remember {
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
}
2023-06-01 00:08:07 +00:00
Box(
myIconBackgroundModifier.background(MaterialTheme.colors.background)
)
2023-06-01 00:08:07 +00:00
val myIconModifier = remember {
2023-06-01 19:16:03 +00:00
Modifier.size(iconSize)
}
2023-06-01 00:08:07 +00:00
Icon(
painter = painterResource(R.drawable.ic_verified),
stringResource(id = R.string.following),
modifier = myIconModifier,
tint = Following
)
}
}
@Immutable
data class DropDownParams(
val isFollowingAuthor: Boolean,
val isPrivateBookmarkNote: Boolean,
val isPublicBookmarkNote: Boolean,
val isLoggedUser: Boolean,
val isSensitive: Boolean,
val showSensitiveContent: Boolean?
)
@Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
2023-03-14 17:41:39 +00:00
var reportDialogShowing by remember { mutableStateOf(false) }
var state by remember {
mutableStateOf<DropDownParams>(
DropDownParams(
isFollowingAuthor = false,
isPrivateBookmarkNote = false,
isPublicBookmarkNote = false,
isLoggedUser = false,
isSensitive = false,
showSensitiveContent = null
2023-04-18 12:45:34 +00:00
)
)
}
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
val clipboardManager = LocalClipboardManager.current
val appContext = LocalContext.current.applicationContext
val actContext = LocalContext.current
WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
if (state != newState) {
state = newState
}
}
val scope = rememberCoroutineScope()
if (!state.isFollowingAuthor) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
); onDismiss()
}) {
Text(stringResource(R.string.follow))
}
Divider()
}
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: ""))
onDismiss()
}
}
) {
Text(stringResource(R.string.copy_text))
}
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
onDismiss()
}
}
) {
Text(stringResource(R.string.copy_user_pubkey))
}
DropdownMenuItem(onClick = {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent()))
onDismiss()
}
}) {
Text(stringResource(R.string.copy_note_id))
}
DropdownMenuItem(onClick = {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note)
)
putExtra(Intent.EXTRA_TITLE, actContext.getString(R.string.quick_action_share_browser_link))
}
val shareIntent = Intent.createChooser(sendIntent, appContext.getString(R.string.quick_action_share))
ContextCompat.startActivity(actContext, shareIntent, null)
onDismiss()
}) {
Text(stringResource(R.string.quick_action_share))
}
Divider()
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePrivateBookmark(note); onDismiss() } }) {
2023-03-20 22:16:07 +00:00
Text(stringResource(R.string.remove_from_private_bookmarks))
}
} else {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPrivateBookmark(note); onDismiss() } }) {
2023-03-20 22:16:07 +00:00
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (state.isPublicBookmarkNote) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePublicBookmark(note); onDismiss() } }) {
2023-03-20 22:16:07 +00:00
Text(stringResource(R.string.remove_from_public_bookmarks))
}
} else {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPublicBookmark(note); onDismiss() } }) {
2023-03-20 22:16:07 +00:00
Text(stringResource(R.string.add_to_public_bookmarks))
}
}
Divider()
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.broadcast(note); onDismiss() } }) {
Text(stringResource(R.string.broadcast))
}
Divider()
if (state.isLoggedUser) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) {
2023-03-11 04:00:35 +00:00
Text(stringResource(R.string.request_deletion))
}
} else {
2023-03-14 17:41:39 +00:00
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
Text("Block / Report")
2023-02-22 21:17:56 +00:00
}
}
Divider()
if (state.showSensitiveContent == null || state.showSensitiveContent == true) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.hideSensitiveContent(); onDismiss() } }) {
Text(stringResource(R.string.content_warning_hide_all_sensitive_content))
}
}
if (state.showSensitiveContent == null || state.showSensitiveContent == false) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.disableContentWarnings(); onDismiss() } }) {
Text(stringResource(R.string.content_warning_show_all_sensitive_content))
}
}
if (state.showSensitiveContent != null) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.seeContentWarnings(); onDismiss() } }) {
Text(stringResource(R.string.content_warning_see_warnings))
}
}
}
2023-03-14 17:41:39 +00:00
if (reportDialogShowing) {
ReportNoteDialog(note = note, accountViewModel = accountViewModel) {
reportDialogShowing = false
onDismiss()
}
}
2023-03-05 21:42:19 +00:00
}
@Composable
fun WatchBookmarksFollowsAndAccount(note: Note, accountViewModel: AccountViewModel, onNew: (DropDownParams) -> Unit) {
val followState by accountViewModel.userProfile().live().follows.observeAsState()
val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState()
val accountState by accountViewModel.accountLiveData.observeAsState()
LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = accountState) {
launch(Dispatchers.IO) {
onNew(
DropDownParams(
isFollowingAuthor = accountViewModel.isFollowing(note.author),
isPrivateBookmarkNote = accountViewModel.isInPrivateBookmarks(note),
isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note),
isLoggedUser = accountViewModel.isLoggedUser(note.author),
isSensitive = note.event?.isSensitive() ?: false,
showSensitiveContent = accountState?.account?.showSensitiveContent
)
)
}
}
}