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

573 wiersze
22 KiB
Kotlin
Czysty Zwykły widok Historia

2023-01-11 18:31:20 +00:00
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
2023-01-11 18:31:20 +00:00
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
2023-02-06 22:16:27 +00:00
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.*
2023-01-11 18:31:20 +00:00
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
2023-02-06 22:16:27 +00:00
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
2023-02-06 22:16:27 +00:00
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
2023-01-11 18:31:20 +00:00
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
2023-01-11 18:31:20 +00:00
import androidx.compose.ui.unit.dp
2023-01-12 17:47:31 +00:00
import androidx.navigation.NavController
2023-01-11 18:31:20 +00:00
import coil.compose.AsyncImage
2023-02-06 22:16:27 +00:00
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
2023-02-10 22:13:25 +00:00
import com.vitorpamplona.amethyst.RoboHashCache
2023-01-11 18:31:20 +00:00
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
2023-01-11 18:31:20 +00:00
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
2023-01-11 18:31:20 +00:00
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
2023-01-11 18:31:20 +00:00
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following
2023-01-11 18:31:20 +00:00
import nostr.postr.events.TextNoteEvent
@OptIn(ExperimentalFoundationApi::class)
2023-01-11 18:31:20 +00:00
@Composable
fun NoteCompose(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
2023-02-20 23:09:57 +00:00
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
accountViewModel: AccountViewModel,
navController: NavController
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val noteState by baseNote.live().metadata.observeAsState()
2023-01-11 18:31:20 +00:00
val note = noteState?.note
val noteReportsState by baseNote.live().reports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
var popupExpanded by remember { mutableStateOf(false) }
2023-02-04 15:41:43 +00:00
var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
2023-02-12 23:23:02 +00:00
var moreActionsExpanded by remember { mutableStateOf(false) }
val noteEvent = note?.event
2023-02-12 23:23:02 +00:00
if (noteEvent == null) {
BlankNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
2023-02-20 23:09:57 +00:00
), isBoostedNote)
2023-02-04 15:41:43 +00:00
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
modifier,
2023-02-20 23:09:57 +00:00
isBoostedNote,
2023-02-04 15:41:43 +00:00
navController,
onClick = { showHiddenNote = true }
)
2023-01-11 18:31:20 +00:00
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
val lastTime = NotificationCache.load(it, context)
val createdAt = noteEvent.createdAt
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt, context)
isNew = createdAt > lastTime
}
}
}
Column(modifier =
modifier.combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
2023-01-25 16:39:19 +00:00
navController.navigate("Note/${note.idHex}"){
launchSingleTop = true
}
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
).run {
if (isNew) {
this.background(MaterialTheme.colors.primary.copy(0.12f))
} else {
this
}
}
) {
2023-01-12 17:47:31 +00:00
Row(
modifier = Modifier
.padding(
2023-02-20 23:09:57 +00:00
start = if (!isBoostedNote) 12.dp else 0.dp,
end = if (!isBoostedNote) 12.dp else 0.dp,
2023-01-12 17:47:31 +00:00
top = 10.dp)
) {
2023-01-11 18:31:20 +00:00
2023-02-20 23:09:57 +00:00
if (!isBoostedNote && !isQuotedNote) {
2023-02-06 22:16:27 +00:00
Column(Modifier.width(55.dp)) {
// Draws the boosted picture outside the boosted card.
Box(modifier = Modifier
.width(55.dp)
.padding(0.dp)) {
NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
if (noteEvent is RepostEvent) {
2023-02-06 22:16:27 +00:00
note.replyTo?.lastOrNull()?.let {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
NoteAuthorPicture(it, navController, account.userProfile(), 35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
}
}
}
2023-02-06 22:16:27 +00:00
// boosted picture
val baseChannel = note.channel
if (noteEvent is ChannelMessageEvent && baseChannel != null) {
2023-02-06 22:16:27 +00:00
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
if (channel != null) {
Box(
Modifier
.width(30.dp)
.height(30.dp)
2023-02-06 22:16:27 +00:00
.align(Alignment.BottomEnd)) {
2023-02-15 17:31:15 +00:00
AsyncImageProxy(
model = ResizeImage(channel.profilePicture(), 30.dp),
placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
fallback = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
error = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
2023-02-06 22:16:27 +00:00
contentDescription = "Group Picture",
modifier = Modifier
.width(30.dp)
.height(30.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.border(
2.dp,
MaterialTheme.colors.background,
CircleShape
)
)
}
}
}
}
2023-02-06 22:16:27 +00:00
if (noteEvent is RepostEvent) {
2023-02-06 22:16:27 +00:00
note.replyTo?.lastOrNull()?.let {
RelayBadges(it)
}
} else {
RelayBadges(baseNote)
}
2023-01-11 18:31:20 +00:00
}
}
2023-02-20 23:09:57 +00:00
Column(
modifier = Modifier.padding(start = if (!isBoostedNote && !isQuotedNote) 10.dp else 0.dp)
) {
2023-01-11 18:31:20 +00:00
Row(verticalAlignment = Alignment.CenterVertically) {
2023-02-20 23:09:57 +00:00
if (isQuotedNote) {
NoteAuthorPicture(note, navController, account.userProfile(), 25.dp)
Spacer(Modifier.padding(horizontal = 5.dp))
NoteUsernameDisplay(note, Modifier.weight(1f))
} else {
NoteUsernameDisplay(note, Modifier.weight(1f))
2023-02-20 23:09:57 +00:00
}
2023-01-11 18:31:20 +00:00
if (noteEvent !is RepostEvent) {
2023-01-11 18:31:20 +00:00
Text(
timeAgo(noteEvent.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
2023-01-11 18:31:20 +00:00
)
2023-02-12 23:23:02 +00:00
IconButton(
modifier = Modifier.then(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
} else {
Text(
" boosted",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
if (noteEvent is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
ReplyInformation(note.replyTo, note.mentions, navController)
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
note.channel?.let {
ReplyInformationChannel(note.replyTo, note.mentions, it, navController)
}
2023-01-11 18:31:20 +00:00
}
if (noteEvent is ReactionEvent || noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
2023-01-11 18:31:20 +00:00
NoteCompose(
it,
modifier = Modifier,
2023-02-20 23:09:57 +00:00
isBoostedNote = true,
2023-01-12 17:47:31 +00:00
accountViewModel = accountViewModel,
navController = navController
2023-01-11 18:31:20 +00:00
)
}
// Reposts have trash in their contents.
if (noteEvent is ReactionEvent) {
2023-01-11 18:31:20 +00:00
val refactorReactionText =
if (noteEvent.content == "+") "" else noteEvent.content ?: " "
2023-01-11 18:31:20 +00:00
Text(
text = refactorReactionText
)
}
} else if (noteEvent is ReportEvent) {
val reportType = noteEvent.reportType.map {
when (it) {
ReportEvent.ReportType.EXPLICIT -> "Explicit Content"
ReportEvent.ReportType.SPAM -> "Spam"
ReportEvent.ReportType.IMPERSONATION -> "Impersonation"
ReportEvent.ReportType.ILLEGAL -> "Illegal Behavior"
else -> "Unkown"
}
}.joinToString(", ")
Text(
text = reportType
)
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
2023-01-11 18:31:20 +00:00
} else {
val eventContent = noteEvent.content
val canPreview = note.author == account.userProfile()
|| (note.author?.let { account.userProfile().isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
TranslateableRichTextViewer(
eventContent,
canPreview,
Modifier.fillMaxWidth(),
noteEvent.tags,
accountViewModel,
navController
)
}
ReactionsRow(note, accountViewModel)
2023-01-11 18:31:20 +00:00
Divider(
2023-01-12 17:47:31 +00:00
modifier = Modifier.padding(top = 10.dp),
2023-01-11 18:31:20 +00:00
thickness = 0.25.dp
)
}
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
2023-01-11 18:31:20 +00:00
}
}
}
}
}
2023-02-06 22:16:27 +00:00
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
2023-02-06 22:16:27 +00:00
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
val uri = LocalUriHandler.current
2023-02-10 22:13:25 +00:00
val ctx = LocalContext.current.applicationContext
2023-02-06 22:16:27 +00:00
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://").removePrefix("ws://")
2023-02-12 23:23:02 +00:00
Box(
Modifier
.size(15.dp)
.padding(1.dp)) {
2023-02-17 00:41:50 +00:00
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
fallback = BitmapPainter(RoboHashCache.get(ctx, url)),
error = BitmapPainter(RoboHashCache.get(ctx, url)),
2023-02-06 22:16:27 +00:00
contentDescription = "Relay Icon",
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
2023-02-12 23:23:02 +00:00
.clickable(onClick = { uri.openUri("https://" + url) })
2023-02-06 22:16:27 +00:00
)
}
}
}
if (noteRelays.size > 3 && !expanded) {
2023-02-12 23:23:02 +00:00
Row(
Modifier
.fillMaxWidth()
.height(25.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Top) {
2023-02-06 22:16:27 +00:00
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}
}
@Composable
fun NoteAuthorPicture(
note: Note,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
NoteAuthorPicture(note, userAccount, size, pictureModifier) {
navController.navigate("User/${it.pubkeyHex}")
}
}
@Composable
fun NoteAuthorPicture(
baseNote: Note,
baseUserAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = note.author
2023-02-10 22:13:25 +00:00
val ctx = LocalContext.current.applicationContext
Box(
Modifier
.width(size)
.height(size)) {
if (author == null) {
Image(
painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")),
contentDescription = "Unknown Author",
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
)
} else {
UserPicture(author, baseUserAccount, size, pictureModifier, onClick)
}
}
}
@Composable
fun UserPicture(
user: User,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
UserPicture(user, userAccount, size, pictureModifier) {
navController.navigate("User/${it.pubkeyHex}")
}
}
@Composable
fun UserPicture(
baseUser: User,
baseUserAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
2023-02-10 22:13:25 +00:00
val ctx = LocalContext.current.applicationContext
Box(
Modifier
.width(size)
.height(size)) {
2023-02-15 17:31:15 +00:00
AsyncImageProxy(
model = ResizeImage(user.profilePicture(), size),
contentDescription = "Profile Image",
placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null)
this.clickable(onClick = { onClick(user) } )
else
this
}
)
val accountState by baseUserAccount.live().follows.observeAsState()
val accountUser = accountState?.user ?: return
if (accountUser.isFollowing(user) || user == accountUser) {
Box(
Modifier
.width(size.div(3.5f))
.height(size.div(3.5f))
.align(Alignment.TopEnd),
contentAlignment = Alignment.Center
) {
// Background for the transparent checkmark
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
Icon(
painter = painterResource(R.drawable.ic_verified),
"Following",
modifier = Modifier.fillMaxSize(),
tint = Following
)
}
}
}
}
@Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current.applicationContext
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
Text("Copy Text")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkeyNpub() ?: "")); onDismiss() }) {
Text("Copy User PubKey")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); onDismiss() }) {
Text("Copy Note ID")
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text("Broadcast")
}
2023-02-22 21:17:56 +00:00
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile) {
Divider()
DropdownMenuItem(onClick = {
note.author?.let {
accountViewModel.hide(
it,
context
)
}; onDismiss()
}) {
Text("Block & Hide User")
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.SPAM);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Spam / Scam")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Impersonation")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.EXPLICIT);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Illegal Behaviour")
}
}
}
2023-01-11 18:31:20 +00:00
}