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

1264 wiersze
47 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
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
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-02-27 22:14:15 +00:00
import androidx.compose.foundation.shape.RoundedCornerShape
2023-03-16 18:20:30 +00:00
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
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.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
import androidx.compose.ui.graphics.Color
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.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
2023-02-06 22:16:27 +00:00
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-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-03-23 14:49:01 +00:00
import com.vitorpamplona.amethyst.model.Account
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.service.model.*
2023-03-29 07:00:15 +00:00
import com.vitorpamplona.amethyst.ui.components.*
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
2023-03-08 22:07:56 +00:00
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
2023-03-23 14:49:01 +00:00
import java.math.BigDecimal
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
2023-01-11 18:31:20 +00:00
@OptIn(ExperimentalTime::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,
2023-02-27 22:14:15 +00:00
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
navController: NavController
) {
val (value, elapsed) = measureTimedValue {
NoteComposeInner(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
parentBackgroundColor,
accountViewModel,
navController
)
}
Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NoteComposeInner(
baseNote: 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,
navController: NavController
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
2023-04-07 21:21:21 +00:00
val loggedIn = account.userProfile()
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) }
var isAcceptable by remember { mutableStateOf(true) }
var canPreview by remember { mutableStateOf(true) }
LaunchedEffect(key1 = noteReportsState) {
withContext(Dispatchers.IO) {
2023-04-07 21:21:21 +00:00
canPreview = note?.author === loggedIn ||
(note?.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
isAcceptable = account.isAcceptable(noteForReports)
}
}
val noteEvent = note?.event
val baseChannel = note?.channel()
2023-02-12 23:23:02 +00:00
if (noteEvent == null) {
2023-03-08 22:07:56 +00:00
BlankNote(
modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true }
),
isBoostedNote
)
} else if (!isAcceptable && !showHiddenNote) {
if (!account.isHidden(noteForReports.author!!)) {
HiddenNote(
account.getRelevantReports(noteForReports),
2023-04-07 21:21:21 +00:00
loggedIn,
modifier,
isBoostedNote,
navController,
onClick = { showHiddenNote = true }
)
}
} else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) {
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
2023-03-05 23:34:11 +00:00
} else if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = note)
2023-04-21 21:01:42 +00:00
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
2023-01-11 18:31:20 +00:00
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) {
withContext(Dispatchers.IO) {
routeForLastRead?.let {
2023-03-13 17:47:44 +00:00
val lastTime = NotificationCache.load(it)
val createdAt = note.createdAt()
if (createdAt != null) {
2023-03-13 17:47:44 +00:00
NotificationCache.markAsRead(it, createdAt)
isNew = createdAt > lastTime
}
}
}
}
2023-03-13 17:47:44 +00:00
val backgroundColor = if (isNew) {
val newColor = MaterialTheme.colors.primary.copy(0.12f)
if (parentBackgroundColor != null) {
newColor.compositeOver(parentBackgroundColor)
} else {
newColor.compositeOver(MaterialTheme.colors.background)
}
2023-03-08 22:07:56 +00:00
} else {
parentBackgroundColor ?: MaterialTheme.colors.background
2023-03-08 22:07:56 +00:00
}
Column(
modifier = modifier
.combinedClickable(
onClick = {
if (noteEvent is ChannelMessageEvent) {
baseChannel?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
2023-03-08 22:07:56 +00:00
} else {
navController.navigate("Note/${note.idHex}")
2023-03-08 22:07:56 +00:00
}
},
onLongClick = { popupExpanded = true }
)
.background(backgroundColor)
) {
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,
top = if (addMarginTop) 10.dp else 0.dp
2023-03-08 22:07:56 +00:00
)
2023-01-12 17:47:31 +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)) {
2023-03-08 22:07:56 +00:00
// Draws the boosted picture outside the boosted card.
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
2023-04-07 21:21:21 +00:00
NoteAuthorPicture(note, navController, loggedIn, 55.dp)
2023-02-06 22:16:27 +00:00
if (noteEvent is RepostEvent) {
2023-02-06 22:16:27 +00:00
note.replyTo?.lastOrNull()?.let {
Box(
Modifier
.width(30.dp)
.height(30.dp)
2023-03-08 22:07:56 +00:00
.align(Alignment.BottomEnd)
) {
NoteAuthorPicture(
it,
navController,
2023-04-07 21:21:21 +00:00
loggedIn,
2023-03-08 22:07:56 +00:00
35.dp,
2023-02-06 22:16:27 +00:00
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
}
}
}
2023-02-06 22:16:27 +00:00
// boosted picture
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-03-08 22:07:56 +00:00
.align(Alignment.BottomEnd)
) {
RobohashAsyncImageProxy(
robot = channel.idHex,
2023-02-15 17:31:15 +00:00
model = ResizeImage(channel.profilePicture(), 30.dp),
contentDescription = stringResource(R.string.group_picture),
2023-02-06 22:16:27 +00:00
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-04-20 21:19:34 +00:00
baseNote.replyTo?.lastOrNull()?.let {
2023-02-06 22:16:27 +00:00
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) {
2023-04-07 21:21:21 +00:00
NoteAuthorPicture(note, navController, loggedIn, 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
}
if (noteEvent is RepostEvent) {
2023-01-11 18:31:20 +00:00
Text(
" ${stringResource(id = R.string.boosted)}",
2023-01-11 18:31:20 +00:00
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
2023-03-23 14:49:01 +00:00
} else {
DisplayFollowingHashtagsInPost(noteEvent, account, navController)
2023-03-16 18:20:30 +00:00
}
Text(
timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
IconButton(
2023-04-07 21:37:25 +00:00
modifier = Modifier.size(24.dp),
onClick = { moreActionsExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
2023-03-08 22:07:56 +00:00
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
NoteDropDownMenu(baseNote, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
}
2023-01-11 18:31:20 +00:00
}
2023-03-17 21:08:55 +00:00
if (note.author != null && !makeItShort && !isQuotedNote) {
2023-03-23 14:49:01 +00:00
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
val baseReward = noteEvent.getReward()
if (baseReward != null) {
DisplayReward(baseReward, baseNote, account, navController)
2023-03-23 14:49:01 +00:00
}
2023-03-28 12:46:07 +00:00
val pow = noteEvent.getPoWRank()
if (pow > 20) {
2023-03-28 12:46:07 +00:00
DisplayPoW(pow)
}
2023-03-23 14:49:01 +00:00
}
2023-02-27 22:14:15 +00:00
}
2023-02-27 22:14:15 +00:00
Spacer(modifier = Modifier.height(3.dp))
2023-02-27 00:22:22 +00:00
if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
2023-02-27 22:14:15 +00:00
val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) {
NoteCompose(
baseNote = replyingDirectlyTo,
isQuotedNote = true,
modifier = Modifier
2023-04-22 19:14:00 +00:00
.padding(top = 5.dp)
2023-02-27 22:14:15 +00:00
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
unPackReply = false,
makeItShort = true,
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor),
accountViewModel = accountViewModel,
navController = navController
)
} else {
ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController)
2023-02-27 22:14:15 +00:00
}
Spacer(modifier = Modifier.height(5.dp))
2023-04-07 18:22:48 +00:00
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
2023-04-07 21:21:21 +00:00
.sortedBy { loggedIn.isFollowingCached(it) }
2023-02-26 21:56:12 +00:00
note.channel()?.let {
2023-02-26 21:56:12 +00:00
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
}
Spacer(modifier = Modifier.height(5.dp))
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-02-27 22:14:15 +00:00
unPackReply = false,
parentBackgroundColor = backgroundColor,
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 =
2023-03-13 17:47:44 +00:00
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.reportedPost() + noteEvent.reportedAuthor()).map {
2023-03-01 15:07:59 +00:00
when (it.reportType) {
ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content)
2023-03-01 07:16:59 +00:00
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)
}
2023-02-28 20:24:23 +00:00
}.toSet().joinToString(", ")
Text(
text = reportType
)
Divider(
2023-02-28 20:24:23 +00:00
modifier = Modifier.padding(top = 40.dp),
thickness = 0.25.dp
)
} else if (noteEvent is LongTextNoteEvent) {
2023-04-07 21:21:21 +00:00
LongFormHeader(noteEvent, note, loggedIn)
ReactionsRow(note, accountViewModel, navController)
2023-03-05 23:34:11 +00:00
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
} else if (noteEvent is BadgeAwardEvent && !note.replyTo.isNullOrEmpty()) {
Text(text = stringResource(R.string.award_granted_to))
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
noteEvent.awardees()
.map { LocalCache.getOrCreateUser(it) }
.forEach {
2023-03-08 22:07:56 +00:00
UserPicture(
user = it,
navController = navController,
2023-04-07 21:21:21 +00:00
userAccount = loggedIn,
2023-03-08 22:07:56 +00:00
size = 35.dp
)
}
2023-03-05 23:34:11 +00:00
}
note.replyTo?.firstOrNull()?.let {
NoteCompose(
it,
modifier = Modifier,
isBoostedNote = false,
isQuotedNote = true,
unPackReply = false,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
navController = navController
)
}
ReactionsRow(note, accountViewModel, navController)
2023-03-05 23:34:11 +00:00
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
} else if (noteEvent is PrivateDmEvent &&
2023-04-07 21:21:21 +00:00
noteEvent.recipientPubKey() != loggedIn.pubkeyHex &&
note.author !== loggedIn
) {
2023-04-20 14:07:10 +00:00
val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
2023-03-29 07:00:15 +00:00
TranslatableRichTextViewer(
stringResource(
id = R.string.private_conversation_notification,
"@${note.author?.pubkeyNpub()}",
2023-04-20 14:07:10 +00:00
"@${recipient?.pubkeyNpub()}"
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
navController
)
if (!makeItShort) {
ReactionsRow(note, accountViewModel, navController)
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
2023-01-11 18:31:20 +00:00
} else {
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null) {
2023-04-07 21:21:21 +00:00
if (makeItShort && note.author == loggedIn) {
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
} else {
2023-03-29 07:00:15 +00:00
TranslatableRichTextViewer(
eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
navController
)
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
}
if (noteEvent is PollNoteEvent) {
PollNote(
note,
canPreview = canPreview && !makeItShort,
backgroundColor,
accountViewModel,
navController
)
}
}
2023-03-08 22:07:56 +00:00
if (!makeItShort) {
ReactionsRow(note, accountViewModel, navController)
2023-03-08 22:07:56 +00:00
}
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
)
}
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
2023-01-11 18:31:20 +00:00
}
}
}
}
}
2023-03-23 14:49:01 +00:00
@Composable
fun DisplayFollowingHashtagsInPost(
noteEvent: EventInterface,
account: Account,
navController: NavController
) {
var firstTag by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = noteEvent) {
withContext(Dispatchers.IO) {
firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
}
}
2023-03-23 14:49:01 +00:00
Column() {
Row(verticalAlignment = Alignment.CenterVertically) {
firstTag?.let {
2023-03-23 14:49:01 +00:00
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
}
}
}
}
@Composable
fun DisplayUncitedHashtags(
hashtags: List<String>,
2023-03-23 14:49:01 +00:00
eventContent: String,
navController: NavController
) {
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 = { navController.navigate("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
)
}
2023-03-23 14:49:01 +00:00
@Composable
fun DisplayReward(
baseReward: BigDecimal,
baseNote: Note,
account: Account,
2023-03-23 14:49:01 +00:00
navController: NavController
) {
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 = { navController.navigate("Hashtag/bounty") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
)
val repliesState by baseNote.live().replies.observeAsState()
val replyNote = repliesState?.note
if (replyNote?.hasPledgeBy(account.userProfile()) == true) {
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)
)
}
2023-03-23 14:49:01 +00:00
var rewardAmount by remember {
mutableStateOf<BigDecimal?>(
baseReward
)
}
LaunchedEffect(key1 = repliesState) {
withContext(Dispatchers.IO) {
replyNote?.pledgedAmountByOthers()?.let {
rewardAmount = baseReward.add(it)
}
}
}
Text(
showAmount(rewardAmount),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
if (popupExpanded) {
AddBountyAmountDialog(baseNote, account) {
popupExpanded = false
}
}
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
var backgroundFromImage by remember { mutableStateOf(background) }
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)
2023-03-05 23:34:11 +00:00
) {
Column {
badgeData.image()?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.badge_award_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
onSuccess = {
val backgroundColor = it.result.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
backgroundFromImage = Color(backgroundColor)
}
2023-03-05 23:34:11 +00:00
)
}
badgeData.name()?.let {
Text(
text = it,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
color = if (backgroundFromImage.luminance() > 0.5) lightColors().onBackground else darkColors().onBackground
2023-03-05 23:34:11 +00:00
)
}
badgeData.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-04-21 21:01:42 +00:00
@Composable
fun FileHeaderDisplay(note: Note) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
val blurHash = event.blurhash()
val hash = event.hash()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
if (isImage || isVideo) {
val content = if (isImage) {
ZoomableImage(fullUrl, description, hash, blurHash)
} else {
ZoomableVideo(fullUrl, description, hash)
}
ZoomableContentView(content = content, listOf(content))
} else {
UrlPreview(fullUrl, "$fullUrl ")
}
}
@Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row(
modifier = Modifier
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
) {
Column {
noteEvent.image()?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
} ?: Box() {
note.author?.info?.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, baseUserAccount = loggedIn, size = 55.dp)
}
}
noteEvent.title()?.let {
Text(
text = it,
style = MaterialTheme.typography.body1,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
)
}
noteEvent.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
)
}
?: Text(
text = noteEvent.content.take(200),
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-02-06 22:16:27 +00:00
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
2023-04-20 21:19:34 +00:00
val noteRelays = noteRelaysState?.note ?: return
2023-02-06 22:16:27 +00:00
var expanded by remember { mutableStateOf(false) }
2023-04-20 21:19:34 +00:00
var showShowMore by remember { mutableStateOf(false) }
var lazyRelayList by remember { mutableStateOf(emptyList<String>()) }
2023-02-06 22:16:27 +00:00
2023-04-20 21:19:34 +00:00
LaunchedEffect(key1 = noteRelaysState, key2 = expanded) {
withContext(Dispatchers.IO) {
val relayList = noteRelays.relays.map {
it.removePrefix("wss://").removePrefix("ws://")
}
2023-02-06 22:16:27 +00:00
2023-04-20 21:19:34 +00:00
val relaysToDisplay = if (expanded) relayList else relayList.take(3)
val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size
if (shouldListChange) {
lazyRelayList = relaysToDisplay
}
val nextShowMore = relayList.size > 3 && !expanded
if (nextShowMore != showShowMore) {
// only triggers recomposition when actually different
showShowMore = nextShowMore
}
}
}
2023-02-06 22:16:27 +00:00
Spacer(Modifier.height(10.dp))
2023-04-20 21:19:34 +00:00
VerticalRelayPanelWithFlow(lazyRelayList)
2023-04-20 21:19:34 +00:00
if (showShowMore) {
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-04-20 21:19:34 +00:00
@Composable
@Stable
private fun VerticalRelayPanelWithFlow(
relays: List<String>
) {
// FlowRow Seems to be a lot faster than LazyVerticalGrid
FlowRow() {
relays.forEach { url ->
RelayIconCompose(url)
}
}
}
@Composable
@Stable
private fun RelayIconCompose(url: String) {
val uri = LocalUriHandler.current
Box(
Modifier
.padding(1.dp)
.size(15.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.width(13.dp)
.height(13.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
}
}
@Composable
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.height(25.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Top
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
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 = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
2023-02-06 22:16:27 +00:00
}
}
}
@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,
2023-03-13 17:47:44 +00:00
modifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = note.author
Box(
Modifier
.width(size)
2023-03-08 22:07:56 +00:00
.height(size)
) {
if (author == null) {
RobohashAsyncImage(
robot = "authornotfound",
robotSize = size,
contentDescription = stringResource(R.string.unknown_author),
2023-03-13 17:47:44 +00:00
modifier = modifier
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
)
} else {
2023-03-13 17:47:44 +00:00
UserPicture(author, baseUserAccount, size, modifier, 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,
2023-03-13 17:47:44 +00:00
modifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null,
onLongClick: ((User) -> Unit)? = null
) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val accountState by baseUserAccount.live().follows.observeAsState()
val accountUser = accountState?.user ?: return
val showFollowingMark = accountUser.isFollowingCached(user) || user == accountUser
UserPicture(
userHex = user.pubkeyHex,
userPicture = user.profilePicture(),
showFollowingMark = showFollowingMark,
size = size,
modifier = modifier,
onClick = onClick?.let { { it(user) } },
onLongClick = onLongClick?.let { { it(user) } }
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserPicture(
userHex: String,
userPicture: String?,
showFollowingMark: Boolean,
size: Dp,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null
) {
Box(
Modifier
.width(size)
2023-03-08 22:07:56 +00:00
.height(size)
) {
RobohashAsyncImageProxy(
robot = userHex,
model = ResizeImage(userPicture, size),
contentDescription = stringResource(id = R.string.profile_image),
2023-03-13 17:47:44 +00:00
modifier = modifier
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
2023-03-08 22:07:56 +00:00
if (onClick != null && onLongClick != null) {
this.combinedClickable(onClick = onClick, onLongClick = onLongClick)
2023-03-08 22:07:56 +00:00
} else if (onClick != null) {
this.clickable(onClick = onClick)
2023-03-08 22:07:56 +00:00
} else {
this
2023-03-08 22:07:56 +00:00
}
}
)
if (showFollowingMark) {
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),
stringResource(id = R.string.following),
modifier = Modifier.fillMaxSize(),
tint = Following
)
}
}
}
}
data class DropDownParams(
val isFollowingAuthor: Boolean,
val isPrivateBookmarkNote: Boolean,
val isPublicBookmarkNote: Boolean,
val isLoggedUser: Boolean
)
@Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
val appContext = LocalContext.current.applicationContext
val actContext = LocalContext.current
2023-03-14 17:41:39 +00:00
var reportDialogShowing by remember { mutableStateOf(false) }
var state by remember {
mutableStateOf<DropDownParams>(
DropDownParams(false, false, false, false)
)
}
LaunchedEffect(key1 = note) {
2023-04-18 12:45:34 +00:00
withContext(Dispatchers.IO) {
state = DropDownParams(
accountViewModel.isFollowing(note.author),
accountViewModel.isInPrivateBookmarks(note),
accountViewModel.isInPublicBookmarks(note),
accountViewModel.isLoggedUser(note.author)
)
}
}
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
if (!state.isFollowingAuthor) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
); onDismiss()
}) {
Text(stringResource(R.string.follow))
}
Divider()
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
Text(stringResource(R.string.copy_text))
}
2023-04-24 21:58:35 +00:00
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")); onDismiss() }) {
Text(stringResource(R.string.copy_user_pubkey))
}
2023-04-24 21:58:35 +00:00
DropdownMenuItem(onClick = { 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) {
2023-03-20 22:16:07 +00:00
DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
} else {
DropdownMenuItem(onClick = { accountViewModel.addPrivateBookmark(note); onDismiss() }) {
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (state.isPublicBookmarkNote) {
2023-03-20 22:16:07 +00:00
DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
} else {
DropdownMenuItem(onClick = { accountViewModel.addPublicBookmark(note); onDismiss() }) {
Text(stringResource(R.string.add_to_public_bookmarks))
}
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text(stringResource(R.string.broadcast))
}
Divider()
if (state.isLoggedUser) {
DropdownMenuItem(onClick = { 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
}
}
}
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
}