From 3b3ca06c1cb0dcf12d629d9dddb0f8366611b247 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 5 Mar 2024 09:30:47 -0500 Subject: [PATCH] Massive refactoring to reduce the size of NoteCompose --- .../amethyst/ui/actions/EditPostView.kt | 2 +- .../ui/actions/JoinUserOrChannelView.kt | 28 +- .../amethyst/ui/actions/NewPostView.kt | 25 +- .../amethyst/ui/components/CashuRedeem.kt | 5 +- .../amethyst/ui/components/InvoicePreview.kt | 5 +- .../amethyst/ui/components/InvoiceRequest.kt | 5 +- .../ui/components/SelectTextDialog.kt | 5 +- .../amethyst/ui/components/TextSpinner.kt | 6 +- .../ui/components/ZapRaiserRequest.kt | 5 +- .../amethyst/ui/layouts/ChatHeaderLayout.kt | 8 +- .../amethyst/ui/navigation/AppBottomBar.kt | 4 +- .../amethyst/ui/navigation/AppTopBar.kt | 28 +- .../amethyst/ui/navigation/DrawerContent.kt | 6 +- .../amethyst/ui/note/BadgeCompose.kt | 5 +- .../amethyst/ui/note/BlankNote.kt | 5 +- .../amethyst/ui/note/ChannelCardCompose.kt | 4 +- .../vitorpamplona/amethyst/ui/note/Loaders.kt | 243 ++ .../amethyst/ui/note/MessageSetCompose.kt | 5 +- .../amethyst/ui/note/MultiSetCompose.kt | 5 +- .../amethyst/ui/note/NoteCompose.kt | 3778 +---------------- .../amethyst/ui/note/NoteQuickActionMenu.kt | 26 +- .../amethyst/ui/note/ReactionsRow.kt | 3 +- .../amethyst/ui/note/RelayCompose.kt | 4 +- .../ui/note/UpdateReactionTypeDialog.kt | 1 + .../amethyst/ui/note/UpdateZapAmountDialog.kt | 4 +- .../amethyst/ui/note/UserCompose.kt | 4 +- .../amethyst/ui/note/UserProfilePicture.kt | 331 -- .../amethyst/ui/note/ZapNoteCompose.kt | 4 +- .../amethyst/ui/note/ZapTheDevsCard.kt | 2 +- .../amethyst/ui/note/ZapUserSetCompose.kt | 4 +- .../{ => note}/elements/AddRemoveButtons.kt | 2 +- .../amethyst/ui/note/elements/BoostedMark.kt | 41 + .../ui/note/elements/DefaultImageHeader.kt | 70 + .../{ => note}/elements/DisplayCommunity.kt | 2 +- .../ui/note/elements/DisplayEditStatus.kt | 59 + .../ui/{ => note}/elements/DisplayHashtags.kt | 2 +- .../ui/note/elements/DisplayLocation.kt | 53 + .../amethyst/ui/note/elements/DisplayOts.kt | 102 + .../ui/{ => note}/elements/DisplayPoW.kt | 2 +- .../ui/{ => note}/elements/DisplayReward.kt | 2 +- .../elements/DisplayUncitedHashtags.kt | 2 +- .../{ => note}/elements/DisplayZapSplits.kt | 2 +- .../amethyst/ui/note/elements/DropDownMenu.kt | 393 ++ .../amethyst/ui/note/elements/ForkInfo.kt | 155 + .../amethyst/ui/note/elements/TimeAgo.kt | 50 + .../amethyst/ui/note/types/AppDefinition.kt | 245 ++ .../amethyst/ui/note/types/AudioTrack.kt | 235 + .../amethyst/ui/note/types/Badge.kt | 233 + .../amethyst/ui/note/types/Classifieds.kt | 161 + .../amethyst/ui/note/types/CommunityHeader.kt | 393 ++ .../amethyst/ui/note/types/Emoji.kt | 309 ++ .../amethyst/ui/note/types/FileHeader.kt | 85 + .../amethyst/ui/note/types/FileStorage.kt | 118 + .../amethyst/ui/note/types/Git.kt | 384 ++ .../amethyst/ui/note/types/Highlight.kt | 201 + .../amethyst/ui/note/types/LiveActivity.kt | 217 + .../amethyst/ui/note/types/LongForm.kt | 133 + .../amethyst/ui/note/types/MedicalData.kt | 265 ++ .../amethyst/ui/note/types/PeopleList.kt | 126 + .../amethyst/ui/note/types/PinList.kt | 131 + .../amethyst/ui/note/types/Poll.kt | 94 + .../amethyst/ui/note/types/PrivateMessage.kt | 118 + .../amethyst/ui/note/types/Reaction.kt | 58 + .../amethyst/ui/note/types/RelayList.kt | 178 + .../ui/note/types/RenderPostApproval.kt | 97 + .../amethyst/ui/note/types/Report.kt | 108 + .../amethyst/ui/note/types/Text.kt | 123 + .../ui/note/types/TextModification.kt | 279 ++ .../amethyst/ui/note/types/Video.kt | 187 + .../amethyst/ui/screen/ChatroomFeedView.kt | 6 +- .../amethyst/ui/screen/ThreadFeedView.kt | 94 +- .../ui/screen/loggedIn/ChannelScreen.kt | 8 +- .../ui/screen/loggedIn/ChatroomListScreen.kt | 4 +- .../ui/screen/loggedIn/ChatroomScreen.kt | 6 +- .../ui/screen/loggedIn/GeoHashScreen.kt | 4 +- .../ui/screen/loggedIn/HashtagScreen.kt | 4 +- .../ui/screen/loggedIn/HiddenUsersScreen.kt | 8 +- .../ui/screen/loggedIn/NotificationScreen.kt | 4 +- .../ui/screen/loggedIn/ProfileScreen.kt | 10 +- .../ui/screen/loggedIn/ReportNoteDialog.kt | 5 +- .../ui/screen/loggedIn/SearchScreen.kt | 6 +- .../ui/screen/loggedIn/VideoScreen.kt | 6 +- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 4 + .../components/TranslatableRichTextViewer.kt | 7 +- 84 files changed, 5886 insertions(+), 4265 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt rename app/src/main/java/com/vitorpamplona/amethyst/ui/{ => note}/elements/AddRemoveButtons.kt (98%) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt rename app/src/main/java/com/vitorpamplona/amethyst/ui/{ => note}/elements/DisplayCommunity.kt (98%) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt rename app/src/main/java/com/vitorpamplona/amethyst/ui/{ => note}/elements/DisplayHashtags.kt (98%) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt rename app/src/main/java/com/vitorpamplona/amethyst/ui/{ => note}/elements/DisplayPoW.kt (97%) rename app/src/main/java/com/vitorpamplona/amethyst/ui/{ => note}/elements/DisplayReward.kt (99%) rename app/src/main/java/com/vitorpamplona/amethyst/ui/{ => note}/elements/DisplayUncitedHashtags.kt (97%) rename app/src/main/java/com/vitorpamplona/amethyst/ui/{ => note}/elements/DisplayZapSplits.kt (98%) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 177d858cc..70d6db904 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -385,7 +385,7 @@ fun EditPostView( fontWeight = FontWeight.W500, ) - Divider() + HorizontalDivider(thickness = DividerThickness) MyTextField( value = postViewModel.subject, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 8406c09bd..d6bf6f367 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -37,7 +37,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -142,7 +142,10 @@ fun JoinUserOrChannelView( ) { Surface { Column( - modifier = Modifier.padding(10.dp).heightIn(min = 500.dp), + modifier = + Modifier + .padding(10.dp) + .heightIn(min = 500.dp), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -267,7 +270,10 @@ private fun SearchEditTextForJoin( } Row( - modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(), + modifier = + Modifier + .padding(horizontal = 10.dp) + .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -280,7 +286,8 @@ private fun SearchEditTextForJoin( }, leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) }, modifier = - Modifier.weight(1f, true) + Modifier + .weight(1f, true) .defaultMinSize(minHeight = 20.dp) .focusRequester(focusRequester) .onFocusChanged { @@ -330,7 +337,11 @@ private fun RenderSearchResults( } Row( - modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(vertical = 10.dp), ) { LazyColumn( modifier = Modifier.fillMaxHeight(), @@ -411,7 +422,10 @@ fun UserComposeForChat( ClickableUserPicture(baseUser, Size55dp, accountViewModel) Column( - modifier = Modifier.padding(start = 10.dp).weight(1f), + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), ) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } @@ -419,7 +433,7 @@ fun UserComposeForChat( } } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 1518064a0..563fde46d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -70,8 +70,8 @@ import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -154,6 +154,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.QuoteBorder @@ -725,7 +726,7 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = stringResource(R.string.add_sensitive_content_explainer), @@ -772,7 +773,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -806,7 +807,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) } } @@ -847,7 +848,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -890,7 +891,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -955,7 +956,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { } } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -1019,7 +1020,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { } } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, @@ -1053,7 +1054,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { ) } - Divider() + HorizontalDivider(thickness = DividerThickness) } } @@ -1105,7 +1106,7 @@ fun FowardZapTo( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = stringResource(R.string.zap_split_explainer), @@ -1209,7 +1210,7 @@ fun LocationAsHash(postViewModel: NewPostViewModel) { DisplayLocationObserver(postViewModel) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = stringResource(R.string.geohash_explainer), @@ -1722,7 +1723,7 @@ fun ImageVideoDescription( } } - Divider() + HorizontalDivider(thickness = DividerThickness) Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index f7c1009ba..739a470fd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -35,8 +35,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card -import androidx.compose.material3.Divider import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size18Modifier @@ -195,7 +196,7 @@ fun CashuPreview( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index e3afdfa93..1b2a8efd1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.payViaIntent +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder @@ -148,7 +149,7 @@ fun InvoicePreview( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) amount?.let { Text( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt index 12a91502a..22b913d43 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -131,7 +132,7 @@ fun InvoiceRequest( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) var message by remember { mutableStateOf("") } var amount by remember { mutableStateOf(1000L) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt index afda98789..52b3844c7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SelectTextDialog.kt @@ -30,7 +30,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size24dp @Composable @@ -75,7 +76,7 @@ fun SelectTextDialog( } Text(text = stringResource(R.string.select_text_dialog_top)) } - Divider() + HorizontalDivider(thickness = DividerThickness) Column( modifier = Modifier.verticalScroll(rememberScrollState()), ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index 8c2141b05..aceca0ecf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -34,7 +34,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -182,7 +182,7 @@ fun SpinnerSelectionDialog( fontWeight = FontWeight.Bold, ) } - Divider(color = Color.LightGray, thickness = DividerThickness) + HorizontalDivider(color = Color.LightGray, thickness = DividerThickness) } } itemsIndexed(options) { index, item -> @@ -192,7 +192,7 @@ fun SpinnerSelectionDialog( Column { onRenderItem(item) } } if (index < options.lastIndex) { - Divider(color = Color.LightGray, thickness = DividerThickness) + HorizontalDivider(color = Color.LightGray, thickness = DividerThickness) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt index 428e1086f..8d59c824c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt @@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -72,7 +73,7 @@ fun ZapRaiserRequest( ) } - Divider() + HorizontalDivider(thickness = DividerThickness) Text( text = stringResource(R.string.zapraiser_explainer), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index d40a92afe..ec810f28f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -41,7 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.NewItemsBubble -import com.vitorpamplona.amethyst.ui.note.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -73,7 +73,7 @@ fun ChannelNamePreview() { onClick = {}, ) - Divider() + HorizontalDivider(thickness = DividerThickness) ListItem( headlineContent = { @@ -135,7 +135,7 @@ fun ChatHeaderLayout( } } - Divider( + HorizontalDivider( modifier = StdTopPadding, thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index 102a143eb..008800784 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.size -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar @@ -132,7 +132,7 @@ private fun RenderBottomMenu( nav: (Route, Boolean) -> Unit, ) { Column(modifier = BottomTopHeight) { - Divider( + HorizontalDivider( thickness = DividerThickness, ) NavigationBar(tonalElevation = Size0dp) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index ca5e7ff4c..e661a20e9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -39,9 +39,9 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.Divider import androidx.compose.material3.DrawerState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -107,12 +107,12 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadChannel import com.vitorpamplona.amethyst.ui.note.LoadCityName import com.vitorpamplona.amethyst.ui.note.LoadUser -import com.vitorpamplona.amethyst.ui.note.LongCommunityHeader import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.SearchIcon -import com.vitorpamplona.amethyst.ui.note.ShortCommunityHeader import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader +import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.DislayGeoTagHeader @@ -394,8 +394,6 @@ private fun ChannelTopBar( } } -@Composable fun NoTopBar() {} - @Composable fun StoriesTopBar( followLists: FollowListViewModel, @@ -403,7 +401,7 @@ fun StoriesTopBar( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + GenericMainTopBar(drawerState, accountViewModel, nav) { val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle() FollowListWithRoutes( @@ -422,7 +420,7 @@ fun HomeTopBar( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + GenericMainTopBar(drawerState, accountViewModel, nav) { val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle() FollowListWithRoutes( @@ -445,7 +443,7 @@ fun NotificationTopBar( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + GenericMainTopBar(drawerState, accountViewModel, nav) { val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle() FollowListWithoutRoutes( @@ -464,7 +462,7 @@ fun DiscoveryTopBar( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel -> + GenericMainTopBar(drawerState, accountViewModel, nav) { val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle() FollowListWithoutRoutes( @@ -491,7 +489,7 @@ fun GenericMainTopBar( drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit, - content: @Composable (AccountViewModel) -> Unit, + content: @Composable () -> Unit, ) { Column(modifier = BottomTopHeight) { TopAppBar( @@ -510,7 +508,7 @@ fun GenericMainTopBar( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - content(accountViewModel) + content() } } } @@ -521,7 +519,7 @@ fun GenericMainTopBar( }, actions = { SearchButton { nav(Route.Search.route) } }, ) - Divider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) } } @@ -889,7 +887,7 @@ fun TopBarWithBackButton( }, actions = {}, ) - Divider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) } } @@ -907,7 +905,7 @@ fun FlexibleTopBarWithBackButton( actions = {}, ) Spacer(modifier = HalfVertSpacer) - Divider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) } } @@ -1064,8 +1062,6 @@ fun MyExtensibleTopAppBar( } } -private val AppBarHeight = 50.dp - // TODO: this should probably be part of the touch target of the start and end icons, clarify this private val AppBarHorizontalPadding = 4.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 9a72230aa..fb24a1803 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -45,8 +45,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Send import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Divider import androidx.compose.material3.DrawerState +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -153,7 +153,7 @@ fun DrawerContent( FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser) - Divider( + HorizontalDivider( thickness = DividerThickness, modifier = Modifier.padding(top = 20.dp), ) @@ -732,7 +732,7 @@ fun BottomContent( var dialogOpen by remember { mutableStateOf(false) } Column(modifier = Modifier) { - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 15.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 30e2c7759..d6fef2970 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -32,7 +32,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MilitaryTech import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -54,6 +54,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.BadgeCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -183,7 +184,7 @@ fun BadgeCompose( ) } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 22e42d5aa..6774fde06 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -183,7 +182,7 @@ fun HiddenNote( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } @@ -235,7 +234,7 @@ fun HiddenNoteByMe( } if (!isQuote) { - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index e1c59eede..7443a653f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -36,7 +36,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -400,7 +400,7 @@ fun InnerCardRow( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt new file mode 100644 index 000000000..4ec5f2eac --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -0,0 +1,243 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.fonfon.kgeohash.toGeoHash +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.CachedGeoLocations +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.encoders.ATag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun LoadDecryptedContent( + note: Note, + accountViewModel: AccountViewModel, + inner: @Composable (String) -> Unit, +) { + var decryptedContent by + remember(note.event) { + mutableStateOf( + accountViewModel.cachedDecrypt(note), + ) + } + + decryptedContent?.let { inner(it) } + ?: run { + LaunchedEffect(key1 = decryptedContent) { + accountViewModel.decrypt(note) { decryptedContent = it } + } + } +} + +@Composable +fun LoadDecryptedContentOrNull( + note: Note, + accountViewModel: AccountViewModel, + inner: @Composable (String?) -> Unit, +) { + var decryptedContent by + remember(note.event) { + mutableStateOf( + accountViewModel.cachedDecrypt(note), + ) + } + + if (decryptedContent == null) { + LaunchedEffect(key1 = decryptedContent) { + accountViewModel.decrypt(note) { decryptedContent = it } + } + } + + inner(decryptedContent) +} + +@Composable +fun LoadAddressableNote( + aTagHex: String, + accountViewModel: AccountViewModel, + content: @Composable (AddressableNote?) -> Unit, +) { + var note by + remember(aTagHex) { + mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) + } + + if (note == null) { + LaunchedEffect(key1 = aTagHex) { + accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> + if (newNote != note) { + note = newNote + } + } + } + } + + content(note) +} + +@Composable +fun LoadAddressableNote( + aTag: ATag, + accountViewModel: AccountViewModel, + content: @Composable (AddressableNote?) -> Unit, +) { + var note by + remember(aTag) { + mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag())) + } + + if (note == null) { + LaunchedEffect(key1 = aTag) { + accountViewModel.getOrCreateAddressableNote(aTag) { newNote -> + if (newNote != note) { + note = newNote + } + } + } + } + + content(note) +} + +@Composable +fun LoadStatuses( + user: User, + accountViewModel: AccountViewModel, + content: @Composable (ImmutableList) -> Unit, +) { + var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) } + + val userStatus by user.live().statuses.observeAsState() + + LaunchedEffect(key1 = userStatus) { + accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses -> + if (!equalImmutableLists(statuses, newStatuses)) { + statuses = newStatuses + } + } + } + + content(statuses) +} + +@Composable +fun LoadOts( + note: Note, + accountViewModel: AccountViewModel, + whenConfirmed: @Composable (Long) -> Unit, + whenPending: @Composable () -> Unit, +) { + var earliestDate: GenericLoadable by remember { mutableStateOf(GenericLoadable.Loading()) } + + val noteStatus by note.live().innerOts.observeAsState() + + LaunchedEffect(key1 = noteStatus) { + accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts -> + earliestDate = + if (newOts == null) { + GenericLoadable.Empty() + } else { + GenericLoadable.Loaded(newOts) + } + } + } + + (earliestDate as? GenericLoadable.Loaded)?.let { + whenConfirmed(it.loaded) + } ?: run { + val account = accountViewModel.account.saveable.observeAsState() + if (account.value?.account?.hasPendingAttestations(note) == true) { + whenPending() + } + } +} + +@Composable +fun LoadCityName( + geohashStr: String, + onLoading: (@Composable () -> Unit)? = null, + content: @Composable (String) -> Unit, +) { + var cityName by remember(geohashStr) { mutableStateOf(CachedGeoLocations.cached(geohashStr)) } + + if (cityName == null) { + if (onLoading != null) { + onLoading() + } + + val context = LocalContext.current + + LaunchedEffect(key1 = geohashStr, context) { + launch(Dispatchers.IO) { + val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull() + if (geoHash != null) { + val newCityName = + CachedGeoLocations.geoLocate(geohashStr, geoHash.toLocation(), context) + ?.ifBlank { null } + if (newCityName != null && newCityName != cityName) { + cityName = newCityName + } + } + } + } + } else { + cityName?.let { content(it) } + } +} + +@Composable +fun LoadChannel( + baseChannelHex: String, + accountViewModel: AccountViewModel, + content: @Composable (Channel) -> Unit, +) { + var channel by + remember(baseChannelHex) { + mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) + } + + if (channel == null) { + LaunchedEffect(key1 = baseChannelHex) { + accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> + launch(Dispatchers.Main) { channel = newChannel } + } + } + } + + channel?.let { content(it) } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index c48dc99d7..e5beeccf9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -30,7 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.MessageSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -133,7 +134,7 @@ fun MessageSetCompose( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 15e302737..5a25e557e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -36,7 +36,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.authorRouteFor import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.screen.CombinedZap import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -170,7 +171,7 @@ fun MultiSetCompose( NoteDropDownMenu(baseNote, popupExpanded, null, accountViewModel, nav) } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 295462b07..9ad4e2e6d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -20,174 +20,105 @@ */ package com.vitorpamplona.amethyst.ui.note -import android.graphics.Bitmap -import android.util.Log import androidx.compose.animation.Crossfade 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 -import androidx.compose.foundation.layout.IntrinsicSize 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 -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.CutCornerShape -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.Divider import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip 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 -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.get import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map -import coil.compose.AsyncImage -import coil.compose.AsyncImagePainter -import coil.request.SuccessResult -import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.BaseMediaContent -import com.vitorpamplona.amethyst.commons.MediaLocalImage -import com.vitorpamplona.amethyst.commons.MediaLocalVideo -import com.vitorpamplona.amethyst.commons.MediaUrlImage -import com.vitorpamplona.amethyst.commons.MediaUrlVideo -import com.vitorpamplona.amethyst.commons.RichTextParser import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Bundle import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.FhirElementDatabase -import com.vitorpamplona.amethyst.model.LensSpecification import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.Patient -import com.vitorpamplona.amethyst.model.Practitioner -import com.vitorpamplona.amethyst.model.RelayBriefInfoCache -import com.vitorpamplona.amethyst.model.Resource -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.VisionPrescription -import com.vitorpamplona.amethyst.model.findReferenceInDb -import com.vitorpamplona.amethyst.model.parseResourceBundleOrNull -import com.vitorpamplona.amethyst.service.CachedGeoLocations -import com.vitorpamplona.amethyst.ui.actions.EditPostView -import com.vitorpamplona.amethyst.ui.actions.NewRelayListView -import com.vitorpamplona.amethyst.ui.components.ClickableUrl -import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji -import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.components.VideoView -import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog -import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth -import com.vitorpamplona.amethyst.ui.elements.AddButton -import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost -import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost -import com.vitorpamplona.amethyst.ui.elements.DisplayPoW -import com.vitorpamplona.amethyst.ui.elements.DisplayReward -import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags -import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits -import com.vitorpamplona.amethyst.ui.elements.RemoveButton -import com.vitorpamplona.amethyst.ui.elements.Reward import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists +import com.vitorpamplona.amethyst.ui.note.elements.BoostedMark +import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus +import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost +import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost +import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation +import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts +import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW +import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward +import com.vitorpamplona.amethyst.ui.note.elements.DisplayZapSplits +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.note.elements.Reward +import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation +import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.CommunityHeader +import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList +import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack +import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward +import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds +import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack +import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource +import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight +import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent +import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPoll +import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval +import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderReaction +import com.vitorpamplona.amethyst.ui.note.types.RenderReport +import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent +import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckIfUrlIsOnline -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CrossfadeCheckIfUrlIsOnline -import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfPadding -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size15Modifier -import com.vitorpamplona.amethyst.ui.theme.Size16Modifier -import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size34dp -import com.vitorpamplona.amethyst.ui.theme.Size35Modifier -import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp -import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight @@ -195,20 +126,11 @@ import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier import com.vitorpamplona.amethyst.ui.theme.boostedNoteModifier import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier import com.vitorpamplona.amethyst.ui.theme.grayText -import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.amethyst.ui.theme.innerPostModifier -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink -import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor -import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.amethyst.ui.theme.normalNoteModifier import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier -import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyBackground import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.amethyst.ui.theme.subtleBorder -import com.vitorpamplona.quartz.encoders.ATag -import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.events.AppDefinitionEvent import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent @@ -222,10 +144,6 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.EmojiPackEvent -import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.events.EmojiUrl -import com.vitorpamplona.quartz.events.EmptyTagList -import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FhirResourceEvent import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent @@ -236,11 +154,7 @@ import com.vitorpamplona.quartz.events.GitRepositoryEvent import com.vitorpamplona.quartz.events.HighlightEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent -import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED -import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE -import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED import com.vitorpamplona.quartz.events.LongTextNoteEvent -import com.vitorpamplona.quartz.events.Participant import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent @@ -251,26 +165,13 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent -import com.vitorpamplona.quartz.events.UserMetadata -import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.WikiNoteEvent -import com.vitorpamplona.quartz.events.toImmutableListOfLists -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.net.URL -import java.text.DecimalFormat -import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @OptIn(ExperimentalFoundationApi::class) @Composable @@ -618,313 +519,6 @@ fun NormalNote( } } -@Composable -fun CommunityHeader( - baseNote: AddressableNote, - showBottomDiviser: Boolean, - sendToCommunity: Boolean, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val expanded = remember { mutableStateOf(false) } - - Column(Modifier.fillMaxWidth()) { - Column( - verticalArrangement = Arrangement.Center, - modifier = - Modifier.clickable { - if (sendToCommunity) { - routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } - } else { - expanded.value = !expanded.value - } - }, - ) { - ShortCommunityHeader( - baseNote = baseNote, - accountViewModel = accountViewModel, - nav = nav, - ) - - if (expanded.value) { - Column(Modifier.verticalScroll(rememberScrollState())) { - LongCommunityHeader( - baseNote = baseNote, - lineModifier = modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - - if (showBottomDiviser) { - Divider( - thickness = DividerThickness, - ) - } - } -} - -@Composable -fun LongCommunityHeader( - baseNote: AddressableNote, - lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp), - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return - - Row( - lineModifier, - ) { - val rulesLabel = stringResource(id = R.string.rules) - val summary = - remember(noteState) { - val subject = noteEvent.subject()?.ifEmpty { null } - val body = noteEvent.description()?.ifBlank { null } - val rules = noteEvent.rules()?.ifBlank { null } - - if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { - if (rules == null) { - "### $subject\n$body" - } else { - "### $subject\n$body\n\n### $rulesLabel\n\n$rules" - } - } else { - if (rules == null) { - body - } else { - "$body\n\n$rulesLabel\n$rules" - } - } - } - - Column( - Modifier.weight(1f), - ) { - Row(verticalAlignment = CenterVertically) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - - TranslatableRichTextViewer( - content = summary ?: stringResource(id = R.string.community_no_descriptor), - canPreview = false, - tags = EmptyTagList, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (summary != null && noteEvent.hasHashtags()) { - DisplayUncitedHashtags( - remember(noteEvent) { noteEvent.hashtags().toImmutableList() }, - summary ?: "", - nav, - ) - } - } - - Column { - Row { - Spacer(DoubleHorzSpacer) - LongCommunityActionOptions(baseNote, accountViewModel, nav) - } - } - } - - Row( - lineModifier, - verticalAlignment = CenterVertically, - ) { - Text( - text = stringResource(id = R.string.owner), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) - Spacer(DoubleHorzSpacer) - NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }) - } - - var participantUsers by - remember(baseNote) { - mutableStateOf>>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = noteState) { - val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() - - if (participants != null) { - accountViewModel.loadParticipants(participants) { newParticipantUsers -> - if ( - newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) - ) { - participantUsers = newParticipantUsers - } - } - } - } - - participantUsers.forEach { - Row( - lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, - verticalAlignment = CenterVertically, - ) { - it.first.role?.let { it1 -> - Text( - text = it1.capitalize(Locale.ROOT), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - } - Spacer(DoubleHorzSpacer) - ClickableUserPicture(it.second, Size25dp, accountViewModel) - Spacer(DoubleHorzSpacer) - UsernameDisplay(it.second, remember { Modifier.weight(1f) }) - } - } - - Row( - lineModifier, - verticalAlignment = CenterVertically, - ) { - Text( - text = stringResource(id = R.string.created_at), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f)) - MoreOptionsButton(baseNote, null, accountViewModel, nav) - } -} - -@Composable -fun ShortCommunityHeader( - baseNote: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = - remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return - - val automaticallyShowProfilePicture = - remember { - accountViewModel.settings.showProfilePictures.value - } - - Row(verticalAlignment = CenterVertically) { - noteEvent.image()?.let { - RobohashFallbackAsyncImage( - robot = baseNote.idHex, - model = it, - contentDescription = stringResource(R.string.profile_image), - contentScale = ContentScale.Crop, - modifier = HeaderPictureModifier, - loadProfilePicture = automaticallyShowProfilePicture, - ) - } - - Column( - modifier = - Modifier - .padding(start = 10.dp) - .height(Size35dp) - .weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Row(verticalAlignment = CenterVertically) { - Text( - text = remember(noteState) { noteEvent.dTag() }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Row( - modifier = - Modifier - .height(Size35dp) - .padding(start = 5.dp), - verticalAlignment = CenterVertically, - ) { - ShortCommunityActionOptions(baseNote, accountViewModel, nav) - } - } -} - -@Composable -private fun ShortCommunityActionOptions( - note: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - Spacer(modifier = StdHorzSpacer) - LikeReaction( - baseNote = note, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = note, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - - WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> - if (!isFollowing) { - Spacer(modifier = StdHorzSpacer) - JoinCommunityButton(accountViewModel, note, nav) - } - } -} - -@Composable -fun WatchAddressableNoteFollows( - note: AddressableNote, - accountViewModel: AccountViewModel, - onFollowChanges: @Composable (Boolean) -> Unit, -) { - val showFollowingMark by - remember { - accountViewModel.userFollows - .map { it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false } - .distinctUntilChanged() - } - .observeAsState(false) - - onFollowChanges(showFollowingMark) -} - -@Composable -private fun LongCommunityActionOptions( - note: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> - if (isFollowing) { - LeaveCommunityButton(accountViewModel, note, nav) - } - } -} - @Composable private fun CheckNewAndRenderNote( baseNote: Note, @@ -1109,75 +703,14 @@ fun InnerNoteWithReactions( } if (notBoostedNorQuote) { - Divider( + HorizontalDivider( thickness = DividerThickness, ) } } -@Stable -class EditState() { - private var modificationsList: List = persistentListOf() - private var modificationToShowIndex: Int = -1 - - val modificationToShow: MutableState = mutableStateOf(null) - val showingVersion: MutableState = mutableStateOf(0) - - fun hasModificationsToShow(): Boolean = modificationsList.isNotEmpty() - - fun isOriginal(): Boolean = modificationToShowIndex < 0 - - fun isLatest(): Boolean = modificationToShowIndex == modificationsList.lastIndex - - fun originalVersionId() = 0 - - fun lastVersionId() = modificationsList.size - - fun versionId() = modificationToShowIndex + 1 - - fun latest() = modificationsList.lastOrNull() - - fun latestBefore(createdAt: Long?): Note? { - if (createdAt == null) return latest() - return modificationsList.lastOrNull { (it.createdAt() ?: Long.MAX_VALUE) < createdAt } - } - - fun nextModification() { - if (modificationToShowIndex < 0) { - modificationToShowIndex = 0 - modificationToShow.value = modificationsList.getOrNull(0) - } else { - modificationToShowIndex++ - if (modificationToShowIndex >= modificationsList.size) { - modificationToShowIndex = -1 - modificationToShow.value = null - } else { - modificationToShow.value = modificationsList.getOrNull(modificationToShowIndex) - } - } - - showingVersion.value = versionId() - } - - fun updateModifications(newModifications: List) { - if (modificationsList != newModifications) { - modificationsList = newModifications - - if (newModifications.isEmpty()) { - modificationToShow.value = null - modificationToShowIndex = -1 - } else { - modificationToShowIndex = newModifications.lastIndex - modificationToShow.value = newModifications.last() - } - } - - showingVersion.value = versionId() - } -} - @Composable -private fun NoteBody( +fun NoteBody( baseNote: Note, showAuthorPicture: Boolean = false, unPackReply: Boolean = true, @@ -1404,841 +937,6 @@ private fun RenderNoteRow( } } -@Composable -fun LoadDecryptedContent( - note: Note, - accountViewModel: AccountViewModel, - inner: @Composable (String) -> Unit, -) { - var decryptedContent by - remember(note.event) { - mutableStateOf( - accountViewModel.cachedDecrypt(note), - ) - } - - decryptedContent?.let { inner(it) } - ?: run { - LaunchedEffect(key1 = decryptedContent) { - accountViewModel.decrypt(note) { decryptedContent = it } - } - } -} - -@Composable -fun LoadDecryptedContentOrNull( - note: Note, - accountViewModel: AccountViewModel, - inner: @Composable (String?) -> Unit, -) { - var decryptedContent by - remember(note.event) { - mutableStateOf( - accountViewModel.cachedDecrypt(note), - ) - } - - if (decryptedContent == null) { - LaunchedEffect(key1 = decryptedContent) { - accountViewModel.decrypt(note) { decryptedContent = it } - } - } - - inner(decryptedContent) -} - -@Composable -fun RenderTextEvent( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - editState: State>, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - LoadDecryptedContent( - note, - accountViewModel, - ) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - val newBody = - if (editState.value is GenericLoadable.Loaded) { - val state = (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow - state?.value?.event?.content() ?: body - } else { - body - } - - if (!subject.isNullOrBlank() && !newBody.split("\n")[0].contains(subject)) { - "### $subject\n$newBody" - } else { - newBody - } - } - } - - val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } - - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (note.event?.hasHashtags() == true) { - val hashtags = - remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } - } -} - -@Composable -fun RenderTextModificationEvent( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? TextNoteModificationEvent ?: return - val noteAuthor = note.author ?: return - - val wantsToEditPost = - remember { - mutableStateOf(false) - } - - val isAuthorTheLoggedUser = - remember { - val authorOfTheOriginalNote = noteEvent.editedNote()?.let { accountViewModel.getNoteIfExists(it)?.author } - - mutableStateOf(accountViewModel.isLoggedUser(authorOfTheOriginalNote)) - } - - noteEvent.editedNote()?.let { - LoadNote(baseNoteHex = it, accountViewModel = accountViewModel) { baseOriginalNote -> - baseOriginalNote?.let { - } - } - } - - Card( - modifier = MaterialTheme.colorScheme.imageModifier, - ) { - Column(Modifier.fillMaxWidth().padding(Size10dp)) { - Text( - text = stringResource(id = R.string.proposal_to_edit), - style = - TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ), - ) - - Spacer(modifier = StdVertSpacer) - - noteEvent.summary()?.let { - TranslatableRichTextViewer( - content = it, - canPreview = canPreview && !makeItShort, - modifier = Modifier.fillMaxWidth(), - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - Spacer(modifier = StdVertSpacer) - } - - noteEvent.editedNote()?.let { - LoadNote(baseNoteHex = it, accountViewModel = accountViewModel) { baseNote -> - baseNote?.let { - val noteState by baseNote.live().metadata.observeAsState() - - val editStateOriginalNote = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) - - val editState = - remember(note) { - derivedStateOf { - val loadable = editStateOriginalNote.value as? GenericLoadable.Loaded - - val state = EditState() - - val latestChangeByAuthor = - if (loadable != null && loadable.loaded.hasModificationsToShow()) { - loadable.loaded.latestBefore(note.createdAt()) - } else { - null - } - - state.updateModifications(listOfNotNull(latestChangeByAuthor, note)) - - GenericLoadable.Loaded(state) - } - } - - LaunchedEffect(key1 = noteState) { - val newAuthor = accountViewModel.isLoggedUser(noteState?.note?.author) - - if (isAuthorTheLoggedUser.value != newAuthor) { - isAuthorTheLoggedUser.value = newAuthor - } - } - - Column( - modifier = - MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp).clickable { - routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } - }, - ) { - NoteBody( - baseNote = baseNote, - showAuthorPicture = true, - unPackReply = false, - makeItShort = false, - canPreview = true, - showSecondRow = false, - backgroundColor = backgroundColor, - editState = editState, - accountViewModel = accountViewModel, - nav = nav, - ) - - if (wantsToEditPost.value) { - EditPostView( - onClose = { - wantsToEditPost.value = false - }, - edit = baseNote, - versionLookingAt = note, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - } - } - - if (isAuthorTheLoggedUser.value) { - Spacer(modifier = StdVertSpacer) - - Button( - onClick = { wantsToEditPost.value = true }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = stringResource(id = R.string.accept_the_suggestion)) - } - } - } - } -} - -@Composable -fun RenderPoll( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? PollNoteEvent ?: return - val eventContent = noteEvent.content() - - if (makeItShort && accountViewModel.isLoggedUser(note.author)) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = remember { Modifier.fillMaxWidth() }, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - - PollNote( - note, - canPreview = canPreview && !makeItShort, - backgroundColor, - accountViewModel, - nav, - ) - } - - if (noteEvent.hasHashtags()) { - val hashtags = remember { noteEvent.hashtags().toImmutableList() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } -} - -@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(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 = RichTextParser.parseImageOrVideo(it.banner!!), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) - } - } else { - Image( - painter = painterResource(R.drawable.profile_banner), - contentDescription = stringResource(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .fillMaxWidth() - .height(125.dp), - ) - } - - 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.colorScheme.background, - CircleShape, - ) - .clip(shape = CircleShape) - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .combinedClickable( - onClick = { zoomImageDialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(it)) }, - ), - ) - } - } - - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) - } - - Spacer(Modifier.weight(1f)) - - Row( - modifier = - Modifier - .height(Size35dp) - .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() ?: emptyArray()).toImmutableListOfLists() }, - fontWeight = FontWeight.Bold, - fontSize = 25.sp, - ) - } - } - - val website = remember(it) { it.website } - if (!website.isNullOrEmpty()) { - Row(verticalAlignment = CenterVertically) { - LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) - - ClickableText( - text = AnnotatedString(website.removePrefix("https://")), - onClick = { website.let { runCatching { uri.openUri(it) } } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.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() ?: EmptyTagList } - val bgColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(bgColor) } - TranslatableRichTextViewer( - content = it, - canPreview = false, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - } - } -} - -@Composable -private fun RenderHighlight( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val quote = remember { (note.event as? HighlightEvent)?.quote() ?: "" } - val author = remember { (note.event as? HighlightEvent)?.author() } - val url = remember { (note.event as? HighlightEvent)?.inUrl() } - val postHex = remember { (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() } - - DisplayHighlight( - highlight = quote, - authorHex = author, - url = url, - postAddress = postHex, - makeItShort = makeItShort, - canPreview = canPreview, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -private fun RenderPrivateMessage( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? PrivateDmEvent ?: return - - val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } - if (withMe) { - LoadDecryptedContent(note, accountViewModel) { eventContent -> - 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() ?: EmptyTagList } - - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (noteEvent.hasHashtags()) { - val hashtags = - remember(note.event?.id()) { - note.event?.hashtags()?.toImmutableList() ?: persistentListOf() - } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } - } - } else { - val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone" - - TranslatableRichTextViewer( - stringResource( - id = R.string.private_conversation_notification, - "@${note.author?.pubkeyNpub()}", - "@$recipient", - ), - canPreview = !makeItShort, - Modifier.fillMaxWidth(), - EmptyTagList, - backgroundColor, - accountViewModel, - nav, - ) - } -} - -@Composable -fun DisplayRelaySet( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = baseNote.event as? RelaySetEvent ?: return - - val relays by - remember(baseNote) { - mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.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( - text = relay.displayUrl, - 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.url, accountViewModel, nav) - } - } - } - } - - if (relays.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } - } - } -} - -@Composable -private fun RelayOptionsAction( - relay: String, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - 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, nav = nav) - } - - if (isCurrentlyOnTheUsersList) { - AddRelayButton { wantsToAddRelay = relay } - } else { - RemoveRelayButton { wantsToAddRelay = relay } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun DisplayPeopleList( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = baseNote.event as? PeopleListEvent ?: return - - var members by remember { mutableStateOf>(persistentListOf()) } - - var expanded by remember { mutableStateOf(false) } - - val toMembersShow = - if (expanded) { - members - } else { - members.take(3) - } - - val name by remember { derivedStateOf { "#${noteEvent.dTag()}" } } - - Text( - text = name, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center, - ) - - LaunchedEffect(Unit) { accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { members = it } } - - Box { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - toMembersShow.forEach { user -> - Row(modifier = Modifier.fillMaxWidth()) { - UserCompose( - user, - overallModifier = Modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - - if (members.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun RenderBadgeAward( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - if (note.replyTo.isNullOrEmpty()) return - - val noteEvent = note.event as? BadgeAwardEvent ?: return - var awardees by remember { mutableStateOf>(listOf()) } - - Text(text = stringResource(R.string.award_granted_to)) - - LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } } - - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - awardees.take(100).forEach { user -> - Row( - modifier = - Modifier - .size(size = Size35dp) - .clickable { nav("User/${user.pubkeyHex}") }, - verticalAlignment = CenterVertically, - ) { - ClickableUserPicture( - baseUser = user, - accountViewModel = accountViewModel, - size = Size35dp, - ) - } - } - - if (awardees.size > 100) { - Text(" and ${awardees.size - 100} others", maxLines = 1) - } - } - - note.replyTo?.firstOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = false, - isQuotedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -private fun RenderReaction( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - note.replyTo?.lastOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - unPackReply = false, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - // Reposts have trash in their contents. - val refactorReactionText = if (note.event?.content() == "+") "❤" else note.event?.content() ?: "" - - Text( - text = refactorReactionText, - maxLines = 1, - ) -} - @Composable fun RenderRepost( note: Note, @@ -2261,328 +959,6 @@ fun RenderRepost( } } -@Composable -fun RenderPostApproval( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - if (note.replyTo.isNullOrEmpty()) return - - val noteEvent = note.event as? CommunityPostApprovalEvent ?: return - - Column(Modifier.fillMaxWidth()) { - noteEvent.communities().forEach { - LoadAddressableNote(it, accountViewModel) { - it?.let { - NoteCompose( - it, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - - Text( - text = stringResource(id = R.string.community_approved_posts), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(5.dp), - textAlign = TextAlign.Center, - ) - - note.replyTo?.forEach { - NoteCompose( - it, - modifier = MaterialTheme.colorScheme.replyModifier, - unPackReply = false, - makeItShort = true, - isQuotedNote = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } -} - -@Composable -fun LoadAddressableNote( - aTagHex: String, - accountViewModel: AccountViewModel, - content: @Composable (AddressableNote?) -> Unit, -) { - var note by - remember(aTagHex) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTagHex)) - } - - if (note == null) { - LaunchedEffect(key1 = aTagHex) { - accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote -> - if (newNote != note) { - note = newNote - } - } - } - } - - content(note) -} - -@Composable -fun LoadAddressableNote( - aTag: ATag, - accountViewModel: AccountViewModel, - content: @Composable (AddressableNote?) -> Unit, -) { - var note by - remember(aTag) { - mutableStateOf(accountViewModel.getAddressableNoteIfExists(aTag.toTag())) - } - - if (note == null) { - LaunchedEffect(key1 = aTag) { - accountViewModel.getOrCreateAddressableNote(aTag) { newNote -> - if (newNote != note) { - note = newNote - } - } - } - } - - content(note) -} - -@Composable -public fun RenderEmojiPack( - baseNote: Note, - actionable: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null, -) { - val noteEvent by - baseNote - .live() - .metadata - .map { it.note.event } - .distinctUntilChanged() - .observeAsState(baseNote.event) - - if (noteEvent == null || noteEvent !is EmojiPackEvent) return - - (noteEvent as? EmojiPackEvent)?.let { - RenderEmojiPack( - noteEvent = it, - baseNote = baseNote, - actionable = actionable, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - onClick = onClick, - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -public fun RenderEmojiPack( - noteEvent: EmojiPackEvent, - baseNote: Note, - actionable: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - onClick: ((EmojiUrl) -> Unit)? = null, -) { - var expanded by remember { mutableStateOf(false) } - - val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() } - - val emojisToShow = - if (expanded) { - allEmojis - } else { - allEmojis.take(60) - } - - Row(verticalAlignment = CenterVertically) { - Text( - text = remember(noteEvent) { "#${noteEvent.dTag()}" }, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .weight(1F) - .padding(5.dp), - textAlign = TextAlign.Center, - ) - - if (actionable) { - EmojiListOptions(accountViewModel, baseNote) - } - } - - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { - FlowRow(modifier = Modifier.padding(top = 5.dp)) { - emojisToShow.forEach { emoji -> - if (onClick != null) { - IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { - AsyncImage( - model = emoji.url, - contentDescription = null, - modifier = Size35Modifier, - ) - } - } else { - Box( - modifier = Size35Modifier, - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = emoji.url, - contentDescription = null, - modifier = Size35Modifier, - ) - } - } - } - } - - if (allEmojis.size > 60 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } - } - } -} - -@Composable -private fun EmojiListOptions( - accountViewModel: AccountViewModel, - emojiPackNote: Note, -) { - LoadAddressableNote( - aTag = - ATag( - EmojiPackSelectionEvent.KIND, - accountViewModel.userProfile().pubkeyHex, - "", - null, - ), - accountViewModel, - ) { - it?.let { usersEmojiList -> - val hasAddedThis by - remember { - usersEmojiList - .live() - .metadata - .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } - .distinctUntilChanged() - } - .observeAsState() - - Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") { - if (it != true) { - AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } - } else { - RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } - } - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun RenderPinListEvent( - baseNote: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = baseNote.event as? PinListEvent ?: return - - val pins by remember { mutableStateOf(noteEvent.pins()) } - - 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) { - PinIcon( - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.onBackground.copy(0.32f), - ) - - Spacer(modifier = Modifier.width(5.dp)) - - TranslatableRichTextViewer( - content = pin, - canPreview = true, - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - - if (pins.size > 3 && !expanded) { - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(getGradient(backgroundColor)), - ) { - ShowMoreButton { expanded = !expanded } - } - } - } -} - fun getGradient(backgroundColor: MutableState): Brush { return Brush.verticalGradient( colors = @@ -2593,104 +969,6 @@ fun getGradient(backgroundColor: MutableState): Brush { ) } -@Composable -private fun RenderAudioTrack( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? AudioTrackEvent ?: return - - AudioTrackHeader(noteEvent, note, accountViewModel, nav) -} - -@Composable -private fun RenderAudioHeader( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? AudioHeaderEvent ?: return - - AudioHeader(noteEvent, note, accountViewModel, nav) -} - -@Composable -private fun RenderLongFormContent( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? LongTextNoteEvent ?: return - - LongFormHeader(noteEvent, note, accountViewModel) -} - -@Composable -private fun RenderReport( - note: Note, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? ReportEvent ?: return - - val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } - - val reportType = - base - .map { - 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) - ReportEvent.ReportType.OTHER -> stringResource(R.string.other) - } - } - .toSet() - .joinToString(", ") - - val content = - remember { - reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "") - } - - TranslatableRichTextViewer( - content = content, - canPreview = true, - modifier = Modifier, - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - - note.replyTo?.lastOrNull()?.let { - NoteCompose( - baseNote = it, - isQuotedNote = true, - modifier = - Modifier - .padding(top = 5.dp) - .fillMaxWidth() - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - unPackReply = false, - makeItShort = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - @Composable private fun ReplyRow( note: Note, @@ -2835,228 +1113,6 @@ fun SecondUserInfoRow( } } -@Composable -fun DisplayOts( - note: Note, - accountViewModel: AccountViewModel, -) { - LoadOts( - note, - accountViewModel, - whenConfirmed = { unixtimestamp -> - val context = LocalContext.current - val timeStr by remember(unixtimestamp) { mutableStateOf(timeAgoNoDot(unixtimestamp, context = context)) } - - ClickableText( - text = buildAnnotatedString { append(stringResource(id = R.string.existed_since, timeStr)) }, - onClick = { - val fullDateTime = - SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)) - - accountViewModel.toast( - context.getString(R.string.ots_info_title), - context.getString(R.string.ots_info_description, fullDateTime), - ) - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, - ) - }, - whenPending = { - Text( - stringResource(id = R.string.timestamp_pending_short), - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - }, - ) -} - -@Composable -private fun ShowForkInformation( - noteEvent: BaseTextNoteEvent, - modifier: Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } - val forkedEvent = remember(noteEvent) { noteEvent.forkFromVersion() } - if (forkedAddress != null) { - LoadAddressableNote(aTag = forkedAddress, accountViewModel = accountViewModel) { addressableNote -> - if (addressableNote != null) { - ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav) - } - } - } else if (forkedEvent != null) { - LoadNote(forkedEvent, accountViewModel = accountViewModel) { event -> - if (event != null) { - ForkInformationRowLightColor(event, modifier, accountViewModel, nav) - } - } - } -} - -@Composable -fun ForkInformationRowLightColor( - originalVersion: Note, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState by originalVersion.live().metadata.observeAsState() - val note = noteState?.note ?: return - val author = note.author ?: return - val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } - - if (route != null) { - Row(modifier) { - ClickableText( - text = - buildAnnotatedString { - append(stringResource(id = R.string.forked_from)) - append(" ") - }, - onClick = { nav(route) }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), - maxLines = 1, - overflow = TextOverflow.Visible, - ) - - val userState by author.live().metadata.observeAsState() - val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = - remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - - if (userDisplayName != null) { - CreateClickableTextWithEmoji( - clickablePart = userDisplayName, - maxLines = 1, - route = route, - overrideColor = MaterialTheme.colorScheme.nip05, - fontSize = Font14SP, - nav = nav, - tags = userTags, - ) - } - } - } -} - -@Composable -fun LoadStatuses( - user: User, - accountViewModel: AccountViewModel, - content: @Composable (ImmutableList) -> Unit, -) { - var statuses: ImmutableList by remember { mutableStateOf(persistentListOf()) } - - val userStatus by user.live().statuses.observeAsState() - - LaunchedEffect(key1 = userStatus) { - accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses -> - if (!equalImmutableLists(statuses, newStatuses)) { - statuses = newStatuses - } - } - } - - content(statuses) -} - -@Composable -fun LoadOts( - note: Note, - accountViewModel: AccountViewModel, - whenConfirmed: @Composable (Long) -> Unit, - whenPending: @Composable () -> Unit, -) { - var earliestDate: GenericLoadable by remember { mutableStateOf(GenericLoadable.Loading()) } - - val noteStatus by note.live().innerOts.observeAsState() - - LaunchedEffect(key1 = noteStatus) { - accountViewModel.findOtsEventsForNote(noteStatus?.note ?: note) { newOts -> - earliestDate = - if (newOts == null) { - GenericLoadable.Empty() - } else { - GenericLoadable.Loaded(newOts) - } - } - } - - (earliestDate as? GenericLoadable.Loaded)?.let { - whenConfirmed(it.loaded) - } ?: run { - val account = accountViewModel.account.saveable.observeAsState() - if (account.value?.account?.hasPendingAttestations(note) == true) { - whenPending() - } - } -} - -@Composable -fun LoadCityName( - geohashStr: String, - onLoading: (@Composable () -> Unit)? = null, - content: @Composable (String) -> Unit, -) { - var cityName by remember(geohashStr) { mutableStateOf(CachedGeoLocations.cached(geohashStr)) } - - if (cityName == null) { - if (onLoading != null) { - onLoading() - } - - val context = LocalContext.current - - LaunchedEffect(key1 = geohashStr, context) { - launch(Dispatchers.IO) { - val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull() - if (geoHash != null) { - val newCityName = - CachedGeoLocations.geoLocate(geohashStr, geoHash.toLocation(), context)?.ifBlank { null } - if (newCityName != null && newCityName != cityName) { - cityName = newCityName - } - } - } - } - } else { - cityName?.let { content(it) } - } -} - -@Composable -fun DisplayLocation( - geohashStr: String, - nav: (String) -> Unit, -) { - LoadCityName(geohashStr) { cityName -> - ClickableText( - text = AnnotatedString(cityName), - onClick = { nav("Geohash/$geohashStr") }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, - ) - } -} - @Composable fun FirstUserInfoRow( baseNote: Note, @@ -3102,7 +1158,7 @@ fun FirstUserInfoRow( } } - TimeAgo(baseNote) + com.vitorpamplona.amethyst.ui.note.elements.TimeAgo(baseNote) MoreOptionsButton(baseNote, editState, accountViewModel, nav) } @@ -3160,87 +1216,6 @@ fun observeEdits( return editState } -@Composable -fun DisplayEditStatus(editState: EditState) { - ClickableText( - text = - buildAnnotatedString { - if (editState.showingVersion.value == editState.originalVersionId()) { - append(stringResource(id = R.string.original)) - } else if (editState.showingVersion.value == editState.lastVersionId()) { - append(stringResource(id = R.string.edited)) - } else { - append(stringResource(id = R.string.edited_number, editState.versionId())) - } - }, - onClick = { - editState.nextModification() - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.placeholderText, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, - modifier = HalfStartPadding, - ) -} - -@Composable -private fun BoostedMark() { - Text( - stringResource(id = R.string.boosted), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - modifier = HalfStartPadding, - ) -} - -@Composable -fun MoreOptionsButton( - baseNote: Note, - editState: State>? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val popupExpanded = remember { mutableStateOf(false) } - val enablePopup = remember { { popupExpanded.value = true } } - - IconButton( - modifier = Size24Modifier, - onClick = enablePopup, - ) { - VerticalDotsIcon(R.string.note_options) - - NoteDropDownMenu( - baseNote, - popupExpanded, - editState, - accountViewModel, - nav, - ) - } -} - -@Composable -fun TimeAgo(note: Note) { - val time = remember(note) { note.createdAt() } ?: return - TimeAgo(time) -} - -@Composable -fun TimeAgo(time: Long) { - val context = LocalContext.current - val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) } - - Text( - text = timeStr, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) -} - @Composable private fun AuthorAndRelayInformation( baseNote: Note, @@ -3300,28 +1275,6 @@ private fun RenderAuthorImages( } } -@Composable -fun LoadChannel( - baseChannelHex: String, - accountViewModel: AccountViewModel, - content: @Composable (Channel) -> Unit, -) { - var channel by - remember(baseChannelHex) { - mutableStateOf(accountViewModel.getChannelIfExists(baseChannelHex)) - } - - if (channel == null) { - LaunchedEffect(key1 = baseChannelHex) { - accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel -> - launch(Dispatchers.Main) { channel = newChannel } - } - } - } - - channel?.let { content(it) } -} - @Composable private fun ChannelNotePicture( baseChannel: Channel, @@ -3367,1644 +1320,3 @@ private fun RepostNoteAuthorPicture( }, ) } - -@Composable -fun DisplayHighlight( - highlight: String, - authorHex: String?, - url: String?, - postAddress: ATag?, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val quote = - remember { - highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } - } - - TranslatableRichTextViewer( - quote, - canPreview = canPreview && !makeItShort, - remember { Modifier.fillMaxWidth() }, - EmptyTagList, - backgroundColor, - accountViewModel, - nav, - ) - - DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav) -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun DisplayQuoteAuthor( - authorHex: String, - url: String?, - postAddress: ATag?, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) } - - if (userBase == null) { - LaunchedEffect(Unit) { - accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> userBase = newUserBase } - } - } - - val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(spaceWidth), - verticalArrangement = Arrangement.Center, - ) { - userBase?.let { userBase -> LoadAndDisplayUser(userBase, nav) } - - url?.let { url -> LoadAndDisplayUrl(url) } - - postAddress?.let { address -> LoadAndDisplayPost(address, accountViewModel, nav) } - } -} - -@Composable -private fun LoadAndDisplayPost( - postAddress: ATag, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - LoadAddressableNote(aTag = postAddress, accountViewModel) { - it?.let { note -> - val noteEvent by - note.live().metadata.map { it.note.event }.distinctUntilChanged().observeAsState(note.event) - - val title = remember(noteEvent) { (noteEvent as? LongTextNoteEvent)?.title() } - - title?.let { - Text(remember { "-" }, maxLines = 1) - ClickableText( - text = AnnotatedString(title), - onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav(it) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), - ) - } - } - } -} - -@Composable -private fun LoadAndDisplayUrl(url: String) { - val validatedUrl = - remember { - try { - URL(url) - } catch (e: Exception) { - Log.w("Note Compose", "Invalid URI: $url") - null - } - } - - validatedUrl?.host?.let { host -> - Text(remember { "-" }, maxLines = 1) - ClickableUrl(urlText = host, url = url) - } -} - -@Composable -fun LoadAndDisplayUser( - userBase: User, - nav: (String) -> Unit, -) { - LoadAndDisplayUser(userBase, "User/${userBase.pubkeyHex}", nav) -} - -@Composable -fun LoadAndDisplayUser( - userBase: User, - route: String, - nav: (String) -> Unit, -) { - val userState by userBase.live().metadata.observeAsState() - val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = - remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } - - if (userDisplayName != null) { - CreateClickableTextWithEmoji( - clickablePart = userDisplayName, - maxLines = 1, - route = route, - nav = nav, - tags = userTags, - ) - } -} - -@Composable -fun BadgeDisplay(baseNote: Note) { - val background = MaterialTheme.colorScheme.background - val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return - - 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)) } - var imageResult by remember { mutableStateOf(null) } - - LaunchedEffect(key1 = imageResult) { - launch(Dispatchers.IO) { - imageResult?.let { - val backgroundColor = - it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) - val colorFromImage = Color(backgroundColor) - val textBackground = - if (colorFromImage.luminance() > 0.5) { - lightColorScheme().onBackground - } else { - darkColorScheme().onBackground - } - - launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) } - } - } - } - - Row( - modifier = - Modifier - .padding(10.dp) - .clip(shape = CutCornerShape(20, 20, 20, 20)) - .border( - 5.dp, - MaterialTheme.colorScheme.mediumImportanceLink, - CutCornerShape(20), - ) - .background(backgroundFromImage.first), - ) { - RenderBadge( - image, - name, - backgroundFromImage.second, - description, - ) { - if (imageResult == null) { - imageResult = it.result - } - } - } -} - -@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, - ) - } - - name?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp), - color = backgroundFromImage, - ) - } - - description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -fun FileHeaderDisplay( - note: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel, -) { - val event = (note.event as? FileHeaderEvent) ?: return - val fullUrl = event.url() ?: return - - val content by - remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.content.ifEmpty { null } ?: event.alt() - val isImage = RichTextParser.isImageUrl(fullUrl) - val uri = note.toNostrUri() - - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - ) - }, - ) - } - - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - accountViewModel = accountViewModel, - ) - } -} - -@Composable -fun VideoDisplay( - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val event = (note.event as? VideoEvent) ?: return - val fullUrl = event.url() ?: return - - val title = event.title() - val summary = event.content.ifBlank { null }?.takeIf { title != it } - val image = event.thumb() ?: event.image() - val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - val content by - remember(note) { - val blurHash = event.blurhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.content.ifBlank { null } ?: event.alt() - val isImage = RichTextParser.isImageUrl(fullUrl) - val uri = note.toNostrUri() - - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - artworkUri = event.thumb() ?: event.image(), - ) - }, - ) - } - - SensitivityWarning(note = note, accountViewModel = accountViewModel) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 5.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (isYouTube) { - val uri = LocalUriHandler.current - Row( - modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } }, - ) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = MaterialTheme.colorScheme.imageModifier, - ) - } - ?: CreateImageHeader(note, accountViewModel) - } - } else { - ZoomableContentView( - content = content, - roundedCorner = true, - accountViewModel = accountViewModel, - ) - } - - title?.let { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 5.dp), - ) - } - - summary?.let { - TranslatableRichTextViewer( - content = it, - canPreview = canPreview && !makeItShort, - modifier = Modifier.fillMaxWidth(), - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (event.hasHashtags()) { - Row( - Modifier.fillMaxWidth(), - ) { - DisplayUncitedHashtags( - remember(event) { event.hashtags().toImmutableList() }, - summary ?: "", - nav, - ) - } - } - } - } -} - -@Composable -fun FileStorageHeaderDisplay( - baseNote: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel, -) { - val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return - val dataEventId = eventHeader.dataEventId() ?: return - - LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote -> - if (contentNote != null) { - ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel) - } - } -} - -@Composable -private fun ObserverAndRenderNIP95( - header: Note, - content: Note, - roundedCorner: Boolean, - accountViewModel: AccountViewModel, -) { - val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return - - val appContext = LocalContext.current.applicationContext - - val noteState by content.live().metadata.observeAsState() - - val content by - remember(noteState) { - // Creates a new object when the event arrives to force an update of the image. - val note = noteState?.note - val uri = header.toNostrUri() - val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) } - val blurHash = eventHeader.blurhash() - val dimensions = eventHeader.dimensions() - val description = eventHeader.alt() ?: eventHeader.content - val mimeType = eventHeader.mimeType() - - val newContent = - if (mimeType?.startsWith("image") == true) { - MediaLocalImage( - localFile = localDir, - mimeType = mimeType, - description = description, - dim = dimensions, - blurhash = blurHash, - isVerified = true, - uri = uri, - ) - } else { - MediaLocalVideo( - localFile = localDir, - mimeType = mimeType, - description = description, - dim = dimensions, - isVerified = true, - uri = uri, - authorName = header.author?.toBestDisplayName(), - ) - } - - mutableStateOf(newContent) - } - - Crossfade(targetState = content) { - if (it != null) { - SensitivityWarning(note = header, accountViewModel = accountViewModel) { - ZoomableContentView( - content = it, - roundedCorner = roundedCorner, - accountViewModel = accountViewModel, - ) - } - } - } -} - -@Composable -fun AudioTrackHeader( - noteEvent: AudioTrackEvent, - note: Note, - 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>>(emptyList()) } - - LaunchedEffect(key1 = participants) { - accountViewModel.loadParticipants(participants) { participantUsers = it } - } - - Row(modifier = Modifier.padding(top = 5.dp)) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Row { - subject?.let { - Row( - verticalAlignment = 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 = CenterVertically, - modifier = - Modifier - .padding(top = 5.dp, start = 10.dp, end = 10.dp) - .clickable { - nav("User/${it.second.pubkeyHex}") - }, - ) { - ClickableUserPicture(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.colorScheme.placeholderText, - maxLines = 1, - ) - } - } - } - - media?.let { media -> - Row( - verticalAlignment = CenterVertically, - ) { - cover?.let { cover -> - LoadThumbAndThenVideoView( - videoUri = media, - title = noteEvent.subject(), - thumbUri = cover, - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - nostrUriCallback = "nostr:${note.toNEvent()}", - accountViewModel = accountViewModel, - ) - } - ?: VideoView( - videoUri = media, - title = noteEvent.subject(), - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - ) - } - } - } - } -} - -@Composable -fun AudioHeader( - noteEvent: AudioHeaderEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val media = remember { noteEvent.stream() ?: noteEvent.download() } - val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } - val content = remember { noteEvent.content().ifBlank { null } } - - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - val tags = remember(noteEvent) { noteEvent.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - Row(modifier = Modifier.padding(top = 5.dp)) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - media?.let { media -> - Row( - verticalAlignment = CenterVertically, - ) { - VideoView( - videoUri = media, - waveform = waveform, - title = noteEvent.subject(), - authorName = note.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - nostrUriCallback = note.toNostrUri(), - ) - } - } - - content?.let { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 5.dp), - ) { - TranslatableRichTextViewer( - content = it, - canPreview = true, - tags = tags, - backgroundColor = background, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - - if (noteEvent.hasHashtags()) { - Row(Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) { - val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() } - DisplayUncitedHashtags(hashtags, content ?: "", nav) - } - } - } - } -} - -@Composable -fun RenderGitPatchEvent( - baseNote: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val event = baseNote.event as? GitPatchEvent ?: return - - RenderGitPatchEvent(event, baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) -} - -@Composable -private fun RenderShortRepositoryHeader( - baseNote: AddressableNote, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState by baseNote.live().metadata.observeAsState() - val noteEvent = noteState?.note?.event as? GitRepositoryEvent ?: return - - Column( - modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), - ) { - val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } - Text( - text = stringResource(id = R.string.git_repository, title), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - - noteEvent.description()?.let { - Spacer(modifier = DoubleVertSpacer) - Text( - text = it, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -private fun RenderGitPatchEvent( - noteEvent: GitPatchEvent, - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val repository = remember(noteEvent) { noteEvent.repository() } - - if (repository != null) { - LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { - if (it != null) { - RenderShortRepositoryHeader(it, accountViewModel, nav) - Spacer(modifier = DoubleVertSpacer) - } - } - } - - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } - } - } - - val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } - - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (note.event?.hasHashtags() == true) { - val hashtags = - remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } - } -} - -@Composable -fun RenderGitIssueEvent( - baseNote: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val event = baseNote.event as? GitIssueEvent ?: return - - RenderGitIssueEvent(event, baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) -} - -@Composable -private fun RenderGitIssueEvent( - noteEvent: GitIssueEvent, - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val repository = remember(noteEvent) { noteEvent.repository() } - - if (repository != null) { - LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { - if (it != null) { - RenderShortRepositoryHeader(it, accountViewModel, nav) - Spacer(modifier = DoubleVertSpacer) - } - } - } - - LoadDecryptedContent(note, accountViewModel) { body -> - val eventContent by - remember(note.event) { - derivedStateOf { - val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } - - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" - } else { - body - } - } - } - - val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } - - if (makeItShort && isAuthorTheLoggedUser) { - Text( - text = eventContent, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } else { - SensitivityWarning( - note = note, - accountViewModel = accountViewModel, - ) { - val modifier = remember(note) { Modifier.fillMaxWidth() } - val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } - - TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - modifier = modifier, - tags = tags, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (note.event?.hasHashtags() == true) { - val hashtags = - remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() } - DisplayUncitedHashtags(hashtags, eventContent, nav) - } - } - } -} - -@Composable -fun RenderGitRepositoryEvent( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val event = baseNote.event as? GitRepositoryEvent ?: return - - RenderGitRepositoryEvent(event, baseNote, accountViewModel, nav) -} - -@Composable -private fun RenderGitRepositoryEvent( - noteEvent: GitRepositoryEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } - val summary = remember(noteEvent) { noteEvent.description() } - val web = remember(noteEvent) { noteEvent.web() } - val clone = remember(noteEvent) { noteEvent.clone() } - - Row( - modifier = - Modifier - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ).padding(Size10dp), - ) { - Column { - Text( - text = stringResource(id = R.string.git_repository, title), - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - - summary?.let { - Text( - text = it, - modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - HorizontalDivider(thickness = DividerThickness) - - web?.let { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringResource(id = R.string.git_web_address), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - ClickableUrl( - url = it, - urlText = it.removePrefix("https://").removePrefix("http://"), - ) - } - } - - clone?.let { - Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { - Text( - text = stringResource(id = R.string.git_clone_address), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - ClickableUrl( - url = it, - urlText = it.removePrefix("https://").removePrefix("http://"), - ) - } - } - } - } -} - -@Composable -fun RenderLiveActivityEvent( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - Row(modifier = Modifier.padding(top = 5.dp)) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav) - } - } -} - -@Composable -fun RenderLiveActivityEventInner( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return - - val eventUpdates by baseNote.live().metadata.observeAsState() - - val media = remember(eventUpdates) { noteEvent.streaming() } - val cover = remember(eventUpdates) { noteEvent.image() } - val subject = remember(eventUpdates) { noteEvent.title() } - val content = remember(eventUpdates) { noteEvent.summary() } - val participants = remember(eventUpdates) { noteEvent.participants() } - val status = remember(eventUpdates) { noteEvent.status() } - val starts = remember(eventUpdates) { noteEvent.starts() } - - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .padding(vertical = 5.dp) - .fillMaxWidth(), - ) { - subject?.let { - Text( - text = it, - fontWeight = FontWeight.Bold, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = StdHorzSpacer) - - Crossfade(targetState = status, label = "RenderLiveActivityEventInner") { - when (it) { - STATUS_LIVE -> { - media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } } - } - STATUS_PLANNED -> { - ScheduledFlag(starts) - } - } - } - } - - var participantUsers by remember { - mutableStateOf>>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = eventUpdates) { - accountViewModel.loadParticipants(participants) { newParticipantUsers -> - if (!equalImmutableLists(newParticipantUsers, participantUsers)) { - participantUsers = newParticipantUsers - } - } - } - - media?.let { media -> - if (status == STATUS_LIVE) { - CheckIfUrlIsOnline(media, accountViewModel) { isOnline -> - if (isOnline) { - Row( - verticalAlignment = CenterVertically, - ) { - VideoView( - videoUri = media, - title = subject, - artworkUri = cover, - authorName = baseNote.author?.toBestDisplayName(), - roundedCorner = true, - accountViewModel = accountViewModel, - nostrUriCallback = "nostr:${baseNote.toNEvent()}", - ) - } - } else { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .padding(10.dp) - .height(100.dp), - ) { - Text( - text = stringResource(id = R.string.live_stream_is_offline), - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold, - ) - } - } - } - } else if (status == STATUS_ENDED) { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .padding(10.dp) - .height(100.dp), - ) { - Text( - text = stringResource(id = R.string.live_stream_has_ended), - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold, - ) - } - } - } - - participantUsers.forEach { - Row( - verticalAlignment = CenterVertically, - modifier = - Modifier - .padding(vertical = 5.dp) - .clickable { nav("User/${it.second.pubkeyHex}") }, - ) { - ClickableUserPicture(it.second, 25.dp, accountViewModel) - Spacer(StdHorzSpacer) - UsernameDisplay(it.second, Modifier.weight(1f)) - Spacer(StdHorzSpacer) - it.first.role?.let { - Text( - text = it.capitalize(Locale.ROOT), - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - } - } - } -} - -@Composable -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()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } - } - - Row( - modifier = - Modifier - .padding(top = Size5dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { - Column { - val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } - - if (automaticallyShowUrlPreview) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - ?: CreateImageHeader(note, accountViewModel) - } - - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp), - ) - } - - summary?.let { - Spacer(modifier = StdVertSpacer) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - -@Composable -private fun RenderWikiContent( - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteEvent = note.event as? WikiNoteEvent ?: return - - WikiNoteHeader(noteEvent, note, accountViewModel, nav) -} - -@Composable -private fun WikiNoteHeader( - noteEvent: WikiNoteEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { - noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } - } - val image = remember(noteEvent) { noteEvent.image() } - - Row( - modifier = - Modifier - .padding(top = Size5dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { - Column { - val automaticallyShowUrlPreview = remember { accountViewModel.settings.showUrlPreview.value } - - if (automaticallyShowUrlPreview) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - ?: CreateImageHeader(note, accountViewModel) - } - - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp), - ) - } - - summary?.let { - Spacer(modifier = StdVertSpacer) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - -@Composable -private fun RenderClassifieds( - noteEvent: ClassifiedsEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val image = remember(noteEvent) { noteEvent.image() } - val title = remember(noteEvent) { noteEvent.title() } - val summary = - remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } - val price = remember(noteEvent) { noteEvent.price() } - val location = remember(noteEvent) { noteEvent.location() } - - Row( - modifier = - Modifier - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - ) { - Column { - Row { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringResource( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - ?: CreateImageHeader(note, accountViewModel) - } - - Row( - Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), - verticalAlignment = CenterVertically, - ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - - price?.let { - val priceTag = - remember(noteEvent) { - val newAmount = - price.amount.toBigDecimalOrNull()?.let { showAmount(it) } ?: price.amount - - if (price.frequency != null && price.currency != null) { - "$newAmount ${price.currency}/${price.frequency}" - } else if (price.currency != null) { - "$newAmount ${price.currency}" - } else { - newAmount - } - } - - Text( - text = priceTag, - maxLines = 1, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = - remember { - Modifier - .clip(SmallBorder) - .padding(start = 5.dp) - }, - ) - } - } - - if (summary != null || location != null) { - Row( - Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), - verticalAlignment = CenterVertically, - ) { - summary?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - /* - Column { - location?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 5.dp) - ) - } - - Button( - modifier = Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { - note.author?.let { - accountViewModel.createChatRoomFor(it) { - nav("Room/$it") - } - } - }, - contentPadding = ZeroPadding, - colors = ButtonDefaults - .buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - painter = painterResource(R.drawable.ic_dm), - stringResource(R.string.send_a_direct_message), - modifier = Modifier.size(20.dp), - tint = Color.White - ) - } - } - - */ - } - } - - Spacer(modifier = DoubleVertSpacer) - } - } -} - -@Composable -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 = - remember { - Modifier - .fillMaxWidth() - .height(150.dp) - }, - ) - - Box( - remember { - Modifier - .width(75.dp) - .height(75.dp) - .padding(10.dp) - .align(Alignment.BottomStart) - }, - ) { - NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp) - } - } -} - -@Preview -@Composable -fun RenderEyeGlassesPrescriptionPreview() { - val prescriptionEvent = Event.fromJson("{\"id\":\"0c15d2bc6f7dcc42fa4426d35d30d09840c9afa5b46d100415006e41d6471416\",\"pubkey\":\"bcd4715cc34f98dce7b52fddaf1d826e5ce0263479b7e110a5bd3c3789486ca8\",\"created_at\":1709074097,\"kind\":82,\"tags\":[],\"content\":\"{\\\"resourceType\\\":\\\"Bundle\\\",\\\"id\\\":\\\"bundle-vision-test\\\",\\\"type\\\":\\\"document\\\",\\\"entry\\\":[{\\\"resourceType\\\":\\\"Practitioner\\\",\\\"id\\\":\\\"2\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Careful\\\",\\\"given\\\":[\\\"Adam\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"Patient\\\",\\\"id\\\":\\\"1\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Duck\\\",\\\"given\\\":[\\\"Donald\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"VisionPrescription\\\",\\\"status\\\":\\\"active\\\",\\\"created\\\":\\\"2014-06-15\\\",\\\"patient\\\":{\\\"reference\\\":\\\"#1\\\"},\\\"dateWritten\\\":\\\"2014-06-15\\\",\\\"prescriber\\\":{\\\"reference\\\":\\\"#2\\\"},\\\"lensSpecification\\\":[{\\\"eye\\\":\\\"right\\\",\\\"sphere\\\":-2,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"down\\\"}],\\\"add\\\":2},{\\\"eye\\\":\\\"left\\\",\\\"sphere\\\":-1,\\\"cylinder\\\":-0.5,\\\"axis\\\":180,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"up\\\"}],\\\"add\\\":2}]}]}\",\"sig\":\"dc58f6109111ca06920c0c711aeaf8e2ee84975afa60d939828d4e01e2edea738f735fb5b1fcadf6d5496e36ac429abf7020a55fd1e4ed215738afc8d07cb950\"}") as FhirResourceEvent - - RenderFhirResource(prescriptionEvent) -} - -@Composable -fun RenderFhirResource( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val event = baseNote.event as? FhirResourceEvent ?: return - - RenderFhirResource(event) -} - -@Composable -fun RenderFhirResource(event: FhirResourceEvent) { - val state by produceState(initialValue = FhirElementDatabase(), key1 = event) { - withContext(Dispatchers.Default) { - parseResourceBundleOrNull(event.content)?.let { - value = it - } - } - } - - state.baseResource?.let { resource -> - when (resource) { - is Bundle -> { - val vision = resource.entry.filterIsInstance(VisionPrescription::class.java) - - vision.firstOrNull()?.let { - RenderEyeGlassesPrescription(it, state.localDb) - } - } - is VisionPrescription -> { - RenderEyeGlassesPrescription(resource, state.localDb) - } - else -> { - } - } - } -} - -@Composable -fun RenderEyeGlassesPrescription( - visionPrescription: VisionPrescription, - db: ImmutableMap, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = Size10dp), - ) { - val rightEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "right" } - val leftEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "left" } - - Text( - "Eyeglasses Prescription", - modifier = Modifier.padding(4.dp).fillMaxWidth(), - textAlign = TextAlign.Center, - ) - - Spacer(StdVertSpacer) - - visionPrescription.patient?.reference?.let { - val patient = findReferenceInDb(it, db) as? Patient - - patient?.name?.firstOrNull()?.assembleName()?.let { - Text( - text = "Patient: $it", - modifier = Modifier.padding(4.dp).fillMaxWidth(), - ) - } - } - visionPrescription.status?.let { - Text( - text = "Status: ${it.capitalize()}", - modifier = Modifier.padding(4.dp).fillMaxWidth(), - ) - } - - Spacer(DoubleVertSpacer) - - RenderEyeGlassesPrescriptionHeaderRow() - HorizontalDivider(thickness = DividerThickness) - - rightEye?.let { - RenderEyeGlassesPrescriptionRow(data = it) - HorizontalDivider(thickness = DividerThickness) - } - - leftEye?.let { - RenderEyeGlassesPrescriptionRow(data = it) - HorizontalDivider(thickness = DividerThickness) - } - - visionPrescription.prescriber?.reference?.let { - val practitioner = findReferenceInDb(it, db) as? Practitioner - - practitioner?.name?.firstOrNull()?.assembleName()?.let { - Spacer(DoubleVertSpacer) - Text( - text = "Signed by: $it", - modifier = Modifier.padding(4.dp).fillMaxWidth(), - textAlign = TextAlign.Right, - ) - } - } - } -} - -@Composable -fun RenderEyeGlassesPrescriptionHeaderRow() { - Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "Eye", - modifier = Modifier.padding(4.dp).weight(1f), - ) - VerticalDivider(thickness = DividerThickness) - Text( - text = "Sph", - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - Text( - text = "Cyl", - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - Text( - text = "Axis", - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - VerticalDivider(thickness = DividerThickness) - Text( - text = "Add", - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - } -} - -@Composable -fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) { - Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - val numberFormat = DecimalFormat("##.00") - val integerFormat = DecimalFormat("###") - - Text( - text = data.eye?.capitalize() ?: "Unknown", - modifier = Modifier.padding(4.dp).weight(1f), - ) - VerticalDivider(thickness = DividerThickness) - Text( - text = formatOrBlank(data.sphere, numberFormat), - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - Text( - text = formatOrBlank(data.cylinder, numberFormat), - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - Text( - text = formatOrBlank(data.axis, integerFormat), - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - VerticalDivider(thickness = DividerThickness) - Text( - text = formatOrBlank(data.add, numberFormat), - textAlign = TextAlign.Right, - modifier = Modifier.padding(4.dp).weight(1f), - ) - } -} - -fun formatOrBlank( - amount: Double?, - numberFormat: NumberFormat, -): String { - if (amount == null) return "" - if (Math.abs(amount) < 0.01) return "" - return numberFormat.format(amount) -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index d2bbb7f59..a60b15af4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -52,11 +51,12 @@ import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -128,13 +128,6 @@ val externalLinkForNote = { note: Note -> } } -@Composable -private fun VerticalDivider(color: Color) = - Divider( - color = color, - modifier = Modifier.fillMaxHeight().width(1.dp), - ) - @Composable fun LongPressToQuickAction( baseNote: Note, @@ -263,7 +256,7 @@ private fun RenderMainPopup( onDismiss() } - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id), @@ -274,7 +267,7 @@ private fun RenderMainPopup( onDismiss() } } - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id), @@ -287,7 +280,7 @@ private fun RenderMainPopup( } if (!isOwnNote) { - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( Icons.Default.Block, @@ -302,9 +295,8 @@ private fun RenderMainPopup( } } } - Divider( + HorizontalDivider( color = primaryLight, - modifier = Modifier.fillMaxWidth().width(1.dp), ) Row(modifier = Modifier.height(IntrinsicSize.Min)) { if (isOwnNote) { @@ -337,7 +329,7 @@ private fun RenderMainPopup( } } - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( icon = ImageVector.vectorResource(id = R.drawable.relays), label = stringResource(R.string.broadcast), @@ -346,7 +338,7 @@ private fun RenderMainPopup( // showSelectTextDialog = true onDismiss() } - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( icon = Icons.Default.Share, label = stringResource(R.string.quick_action_share), @@ -375,7 +367,7 @@ private fun RenderMainPopup( } if (!isOwnNote) { - VerticalDivider(primaryLight) + VerticalDivider(color = primaryLight) NoteQuickActionItem( Icons.Default.Report, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 64986be17..49c4f39bd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DarkerGreen @@ -306,7 +307,7 @@ fun RenderZapRaiser( LinearProgressIndicator( modifier = remember(details) { Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp) }, color = color, - progress = zapraiserStatus.progress, + progress = { zapraiserStatus.progress }, ) if (details) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 55a1a16e7..a39aa5c26 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -101,7 +101,7 @@ fun RelayCompose( } } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index b6861152c..68d1109c8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.placeholderText diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index a38762655..bd8599b8a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -51,7 +51,7 @@ import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -408,7 +408,7 @@ fun UpdateZapAmountDialog( ) } - Divider( + HorizontalDivider( modifier = Modifier.padding(vertical = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index d808056d3..6026fb233 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -68,7 +68,7 @@ fun UserCompose( } if (showDiviser) { - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 50f77676d..523afae1d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note -import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.fadeIn @@ -34,50 +33,30 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.Divider -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.Dp -import androidx.core.content.ContextCompat import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.actions.EditPostView -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.events.TextNoteEvent import kotlinx.collections.immutable.ImmutableSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun NoteAuthorPicture( @@ -443,313 +422,3 @@ fun WatchUserFollows( onFollowChanges(showFollowingMark) } - -@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: MutableState, - editState: State>? = null, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - var reportDialogShowing by remember { mutableStateOf(false) } - - var state by remember { - mutableStateOf( - DropDownParams( - isFollowingAuthor = false, - isPrivateBookmarkNote = false, - isPublicBookmarkNote = false, - isLoggedUser = false, - isSensitive = false, - showSensitiveContent = null, - ), - ) - } - - val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } } - - val wantsToEditPost = - remember { - mutableStateOf(false) - } - - if (wantsToEditPost.value) { - // avoids changing while drafting a note and a new event shows up. - val versionLookingAt = - remember { - (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value - } - - EditPostView( - onClose = { - popupExpanded.value = false - wantsToEditPost.value = false - }, - edit = note, - versionLookingAt = versionLookingAt, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - DropdownMenu( - expanded = popupExpanded.value, - 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( - text = { Text(stringResource(R.string.follow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem - accountViewModel.follow(author) - onDismiss() - }, - ) - Divider() - } - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_text)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) } - onDismiss() - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_user_pubkey)) }, - onClick = { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) - onDismiss() - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_note_id)) }, - onClick = { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) - onDismiss() - } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.quick_action_share)) }, - 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() - }, - ) - Divider() - if (note.event is TextNoteEvent) { - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringResource(R.string.edit_post)) }, - onClick = { - wantsToEditPost.value = true - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.propose_an_edit)) }, - onClick = { - wantsToEditPost.value = true - }, - ) - } - } - DropdownMenuItem( - text = { Text(stringResource(R.string.broadcast)) }, - onClick = { - accountViewModel.broadcast(note) - onDismiss() - }, - ) - Divider() - if (accountViewModel.account.hasPendingAttestations(note)) { - DropdownMenuItem( - text = { Text(stringResource(R.string.timestamp_pending)) }, - onClick = { - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.timestamp_it)) }, - onClick = { - accountViewModel.timestamp(note) - onDismiss() - }, - ) - } - Divider() - if (state.isPrivateBookmarkNote) { - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_from_private_bookmarks)) }, - onClick = { - accountViewModel.removePrivateBookmark(note) - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.add_to_private_bookmarks)) }, - onClick = { - accountViewModel.addPrivateBookmark(note) - onDismiss() - }, - ) - } - if (state.isPublicBookmarkNote) { - DropdownMenuItem( - text = { Text(stringResource(R.string.remove_from_public_bookmarks)) }, - onClick = { - accountViewModel.removePublicBookmark(note) - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.add_to_public_bookmarks)) }, - onClick = { - accountViewModel.addPublicBookmark(note) - onDismiss() - }, - ) - } - Divider() - if (state.showSensitiveContent == null || state.showSensitiveContent == true) { - DropdownMenuItem( - text = { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.hideSensitiveContent() - onDismiss() - } - }, - ) - } - if (state.showSensitiveContent == null || state.showSensitiveContent == false) { - DropdownMenuItem( - text = { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.disableContentWarnings() - onDismiss() - } - }, - ) - } - if (state.showSensitiveContent != null) { - DropdownMenuItem( - text = { Text(stringResource(R.string.content_warning_see_warnings)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.seeContentWarnings() - onDismiss() - } - }, - ) - } - Divider() - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringResource(R.string.request_deletion)) }, - onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.delete(note) - onDismiss() - } - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringResource(R.string.block_report)) }, - onClick = { reportDialogShowing = true }, - ) - } - } - - if (reportDialogShowing) { - ReportNoteDialog(note = note, accountViewModel = accountViewModel) { - reportDialogShowing = false - onDismiss() - } - } -} - -@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 showSensitiveContent by - accountViewModel.showSensitiveContentChanges.observeAsState( - accountViewModel.account.showSensitiveContent, - ) - - LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { - launch(Dispatchers.IO) { - accountViewModel.isInPrivateBookmarks(note) { - val newState = - DropDownParams( - isFollowingAuthor = accountViewModel.isFollowing(note.author), - isPrivateBookmarkNote = it, - isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), - isLoggedUser = accountViewModel.isLoggedUser(note.author), - isSensitive = note.event?.isSensitive() ?: false, - showSensitiveContent = showSensitiveContent, - ) - - launch(Dispatchers.Main) { - onNew( - newState, - ) - } - } - } - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 91d114954..d5d293cd6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -91,7 +91,7 @@ fun ZapNoteCompose( ) { baseAuthor?.let { RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt index 593f6bc3a..f00c680f8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapTheDevsCard.kt @@ -69,9 +69,9 @@ import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.components.ClickableText import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.navigation.routeToMessage +import com.vitorpamplona.amethyst.ui.note.elements.DisplayZapSplits import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 9f93e498e..2400fc84e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -29,7 +29,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -132,7 +132,7 @@ fun ZapUserSetCompose( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt similarity index 98% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt index e656751b1..9ec25686a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/AddRemoveButtons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddRemoveButtons.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt new file mode 100644 index 000000000..acb98798c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun BoostedMark() { + Text( + stringResource(id = R.string.boosted), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + modifier = HalfStartPadding, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt new file mode 100644 index 000000000..055f77bd0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader +import com.vitorpamplona.amethyst.ui.theme.imageHeaderBannerSize + +@Composable +fun DefaultImageHeader( + note: Note, + accountViewModel: AccountViewModel, +) { + 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 = imageHeaderBannerSize, + ) + + Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) { + NoteAuthorPicture(baseNote = note, accountViewModel = accountViewModel, size = Size55dp) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt similarity index 98% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 0685e1090..1459b0c81 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayCommunity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt new file mode 100644 index 000000000..0981fc846 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun DisplayEditStatus(editState: EditState) { + ClickableText( + text = + buildAnnotatedString { + if (editState.showingVersion.value == editState.originalVersionId()) { + append(stringResource(id = R.string.original)) + } else if (editState.showingVersion.value == editState.lastVersionId()) { + append(stringResource(id = R.string.edited)) + } else { + append(stringResource(id = R.string.edited_number, editState.versionId())) + } + }, + onClick = { + editState.nextModification() + }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.placeholderText, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + modifier = HalfStartPadding, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt similarity index 98% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index cd9f71574..bd286ea84 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt new file mode 100644 index 000000000..4f15be3cc --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.ui.note.LoadCityName +import com.vitorpamplona.amethyst.ui.theme.Font14SP + +@Composable +fun DisplayLocation( + geohashStr: String, + nav: (String) -> Unit, +) { + LoadCityName(geohashStr) { cityName -> + ClickableText( + text = AnnotatedString(cityName), + onClick = { nav("Geohash/$geohashStr") }, + style = + LocalTextStyle.current.copy( + color = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.52f, + ), + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt new file mode 100644 index 000000000..4c3488c64 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.LoadOts +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink +import java.text.SimpleDateFormat +import java.util.Date + +@Composable +fun DisplayOts( + note: Note, + accountViewModel: AccountViewModel, +) { + LoadOts( + note, + accountViewModel, + whenConfirmed = { unixtimestamp -> + val context = LocalContext.current + val timeStr by remember(unixtimestamp) { + mutableStateOf( + timeAgoNoDot( + unixtimestamp, + context = context, + ), + ) + } + + ClickableText( + text = + buildAnnotatedString { + append( + stringResource( + id = R.string.existed_since, + timeStr, + ), + ) + }, + onClick = { + val fullDateTime = + SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)) + + accountViewModel.toast( + context.getString(R.string.ots_info_title), + context.getString(R.string.ots_info_description, fullDateTime), + ) + }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + }, + whenPending = { + Text( + stringResource(id = R.string.timestamp_pending_short), + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + }, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt similarity index 97% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index 184ebe35c..842dc8c5e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayPoW.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt similarity index 99% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 2c7520103..5fc6a69ed 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayReward.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt similarity index 97% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt index e185ab24f..551f60689 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayUncitedHashtags.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayZapSplits.kt similarity index 98% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayZapSplits.kt index ecdf5ad57..40d63f7e2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/elements/DisplayZapSplits.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayZapSplits.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.elements +package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt new file mode 100644 index 000000000..27d37e018 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -0,0 +1,393 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import android.content.Intent +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.EditPostView +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.note.externalLinkForNote +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.quartz.events.TextNoteEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun MoreOptionsButton( + baseNote: Note, + editState: State>? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val popupExpanded = remember { mutableStateOf(false) } + + IconButton( + modifier = Size24Modifier, + onClick = { popupExpanded.value = true }, + ) { + VerticalDotsIcon(R.string.note_options) + + NoteDropDownMenu( + baseNote, + popupExpanded, + editState, + accountViewModel, + nav, + ) + } +} + +@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: MutableState, + editState: State>? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var reportDialogShowing by remember { mutableStateOf(false) } + + var state by remember { + mutableStateOf( + DropDownParams( + isFollowingAuthor = false, + isPrivateBookmarkNote = false, + isPublicBookmarkNote = false, + isLoggedUser = false, + isSensitive = false, + showSensitiveContent = null, + ), + ) + } + + val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } } + + val wantsToEditPost = + remember { + mutableStateOf(false) + } + + if (wantsToEditPost.value) { + // avoids changing while drafting a note and a new event shows up. + val versionLookingAt = + remember { + (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value + } + + EditPostView( + onClose = { + popupExpanded.value = false + wantsToEditPost.value = false + }, + edit = note, + versionLookingAt = versionLookingAt, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + DropdownMenu( + expanded = popupExpanded.value, + 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( + text = { Text(stringResource(R.string.follow)) }, + onClick = { + val author = note.author ?: return@DropdownMenuItem + accountViewModel.follow(author) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_text)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) } + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_user_pubkey)) }, + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.copy_note_id)) }, + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.quick_action_share)) }, + 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() + }, + ) + HorizontalDivider(thickness = DividerThickness) + if (note.event is TextNoteEvent) { + if (state.isLoggedUser) { + DropdownMenuItem( + text = { Text(stringResource(R.string.edit_post)) }, + onClick = { + wantsToEditPost.value = true + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.propose_an_edit)) }, + onClick = { + wantsToEditPost.value = true + }, + ) + } + } + DropdownMenuItem( + text = { Text(stringResource(R.string.broadcast)) }, + onClick = { + accountViewModel.broadcast(note) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + if (accountViewModel.account.hasPendingAttestations(note)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.timestamp_pending)) }, + onClick = { + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.timestamp_it)) }, + onClick = { + accountViewModel.timestamp(note) + onDismiss() + }, + ) + } + HorizontalDivider(thickness = DividerThickness) + if (state.isPrivateBookmarkNote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_from_private_bookmarks)) }, + onClick = { + accountViewModel.removePrivateBookmark(note) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.add_to_private_bookmarks)) }, + onClick = { + accountViewModel.addPrivateBookmark(note) + onDismiss() + }, + ) + } + if (state.isPublicBookmarkNote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_from_public_bookmarks)) }, + onClick = { + accountViewModel.removePublicBookmark(note) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.add_to_public_bookmarks)) }, + onClick = { + accountViewModel.addPublicBookmark(note) + onDismiss() + }, + ) + } + HorizontalDivider(thickness = DividerThickness) + if (state.showSensitiveContent == null || state.showSensitiveContent == true) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.hideSensitiveContent() + onDismiss() + } + }, + ) + } + if (state.showSensitiveContent == null || state.showSensitiveContent == false) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.disableContentWarnings() + onDismiss() + } + }, + ) + } + if (state.showSensitiveContent != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.content_warning_see_warnings)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.seeContentWarnings() + onDismiss() + } + }, + ) + } + HorizontalDivider(thickness = DividerThickness) + if (state.isLoggedUser) { + DropdownMenuItem( + text = { Text(stringResource(R.string.request_deletion)) }, + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.delete(note) + onDismiss() + } + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.block_report)) }, + onClick = { reportDialogShowing = true }, + ) + } + } + + if (reportDialogShowing) { + ReportNoteDialog(note = note, accountViewModel = accountViewModel) { + reportDialogShowing = false + onDismiss() + } + } +} + +@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 showSensitiveContent by + accountViewModel.showSensitiveContentChanges.observeAsState( + accountViewModel.account.showSensitiveContent, + ) + + LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { + launch(Dispatchers.IO) { + accountViewModel.isInPrivateBookmarks(note) { + val newState = + DropDownParams( + isFollowingAuthor = accountViewModel.isFollowing(note.author), + isPrivateBookmarkNote = it, + isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), + isLoggedUser = accountViewModel.isLoggedUser(note.author), + isSensitive = note.event?.isSensitive() ?: false, + showSensitiveContent = showSensitiveContent, + ) + + launch(Dispatchers.Main) { + onNew( + newState, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt new file mode 100644 index 000000000..62ccc2ce3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.nip05 +import com.vitorpamplona.quartz.events.BaseTextNoteEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists + +@Composable +fun ShowForkInformation( + noteEvent: BaseTextNoteEvent, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } + val forkedEvent = remember(noteEvent) { noteEvent.forkFromVersion() } + if (forkedAddress != null) { + LoadAddressableNote( + aTag = forkedAddress, + accountViewModel = accountViewModel, + ) { addressableNote -> + if (addressableNote != null) { + ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav) + } + } + } else if (forkedEvent != null) { + LoadNote(forkedEvent, accountViewModel = accountViewModel) { event -> + if (event != null) { + ForkInformationRowLightColor(event, modifier, accountViewModel, nav) + } + } + } +} + +@Composable +fun ForkInformationRowLightColor( + originalVersion: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by originalVersion.live().metadata.observeAsState() + val note = noteState?.note ?: return + val author = note.author ?: return + val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + + if (route != null) { + Row(modifier) { + ClickableText( + text = + buildAnnotatedString { + append(stringResource(id = R.string.forked_from)) + append(" ") + }, + onClick = { nav(route) }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.nip05, + fontSize = Font14SP, + ), + maxLines = 1, + overflow = TextOverflow.Visible, + ) + + val userState by author.live().metadata.observeAsState() + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userTags = + remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } + + if (userDisplayName != null) { + CreateClickableTextWithEmoji( + clickablePart = userDisplayName, + maxLines = 1, + route = route, + overrideColor = MaterialTheme.colorScheme.nip05, + fontSize = Font14SP, + nav = nav, + tags = userTags, + ) + } + } + } +} + +@Composable +fun ForkInformationRow( + originalVersion: Note, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by originalVersion.live().metadata.observeAsState() + val note = noteState?.note ?: return + val author = note.author ?: return + val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } + + if (route != null) { + Row(modifier) { + Text(stringResource(id = R.string.forked_from)) + Spacer(modifier = StdHorzSpacer) + + val userMetadata by author.live().userMetadataInfo.observeAsState() + + CreateClickableTextWithEmoji( + clickablePart = userMetadata?.bestDisplayName() ?: userMetadata?.bestUsername() ?: author.pubkeyDisplayHex(), + maxLines = 1, + route = route, + nav = nav, + tags = userMetadata?.tags, + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt new file mode 100644 index 000000000..9c48a2393 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.elements + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun TimeAgo(note: Note) { + val time = remember(note) { note.createdAt() } ?: return + TimeAgo(time) +} + +@Composable +fun TimeAgo(time: Long) { + val context = LocalContext.current + val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) } + + Text( + text = timeStr, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt new file mode 100644 index 000000000..607354e1b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -0,0 +1,245 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.shape.CircleShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.note.LinkIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.AppDefinitionEvent +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.UserMetadata +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@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(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 = RichTextParser.parseImageOrVideo(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } + } else { + Image( + painter = painterResource(R.drawable.profile_banner), + contentDescription = stringResource(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .fillMaxWidth() + .height(125.dp), + ) + } + + 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.colorScheme.background, + CircleShape, + ) + .clip(shape = CircleShape) + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .combinedClickable( + onClick = { zoomImageDialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(it)) }, + ), + ) + } + } + + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } + + Spacer(Modifier.weight(1f)) + + Row( + modifier = + Modifier + .height(Size35dp) + .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() ?: emptyArray()).toImmutableListOfLists() + }, + fontWeight = FontWeight.Bold, + fontSize = 25.sp, + ) + } + } + + val website = remember(it) { it.website } + if (!website.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) + + ClickableText( + text = AnnotatedString(website.removePrefix("https://")), + onClick = { website.let { runCatching { uri.openUri(it) } } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.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() ?: EmptyTagList + } + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + TranslatableRichTextViewer( + content = it, + canPreview = false, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt new file mode 100644 index 000000000..3a70d2d6b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -0,0 +1,235 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.VideoView +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.AudioHeaderEvent +import com.vitorpamplona.quartz.events.AudioTrackEvent +import com.vitorpamplona.quartz.events.Participant +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.toImmutableList +import java.util.Locale + +@Composable +fun RenderAudioTrack( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? AudioTrackEvent ?: return + + AudioTrackHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +fun AudioTrackHeader( + noteEvent: AudioTrackEvent, + note: Note, + 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>>(emptyList()) } + + LaunchedEffect(key1 = participants) { + accountViewModel.loadParticipants(participants) { participantUsers = 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, + modifier = + Modifier + .padding(top = 5.dp, start = 10.dp, end = 10.dp) + .clickable { + nav("User/${it.second.pubkeyHex}") + }, + ) { + ClickableUserPicture(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.colorScheme.placeholderText, + maxLines = 1, + ) + } + } + } + + media?.let { media -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + cover?.let { cover -> + LoadThumbAndThenVideoView( + videoUri = media, + title = noteEvent.subject(), + thumbUri = cover, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } + ?: VideoView( + videoUri = media, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + } + } + } +} + +@Composable +fun RenderAudioHeader( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? AudioHeaderEvent ?: return + + AudioHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +fun AudioHeader( + noteEvent: AudioHeaderEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val media = remember { noteEvent.stream() ?: noteEvent.download() } + val waveform = remember { noteEvent.wavefrom()?.toImmutableList()?.ifEmpty { null } } + val content = remember { noteEvent.content().ifBlank { null } } + + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + val tags = remember(noteEvent) { noteEvent.tags().toImmutableListOfLists() } + + Row(modifier = Modifier.padding(top = 5.dp)) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + media?.let { media -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + VideoView( + videoUri = media, + waveform = waveform, + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + nostrUriCallback = note.toNostrUri(), + ) + } + } + + content?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 5.dp), + ) { + TranslatableRichTextViewer( + content = it, + canPreview = true, + tags = tags, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + if (noteEvent.hasHashtags()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() } + DisplayUncitedHashtags(hashtags, content ?: "", nav) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt new file mode 100644 index 000000000..f6deaa3fe --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -0,0 +1,233 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import android.graphics.Bitmap +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.get +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.request.SuccessResult +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink +import com.vitorpamplona.quartz.events.BadgeAwardEvent +import com.vitorpamplona.quartz.events.BadgeDefinitionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun BadgeDisplay(baseNote: Note) { + val background = MaterialTheme.colorScheme.background + val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return + + 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)) } + var imageResult by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = imageResult) { + launch(Dispatchers.IO) { + imageResult?.let { + val backgroundColor = + it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199) + val colorFromImage = Color(backgroundColor) + val textBackground = + if (colorFromImage.luminance() > 0.5) { + lightColorScheme().onBackground + } else { + darkColorScheme().onBackground + } + + launch(Dispatchers.Main) { backgroundFromImage = Pair(colorFromImage, textBackground) } + } + } + } + + Row( + modifier = + Modifier + .padding(10.dp) + .clip(shape = CutCornerShape(20, 20, 20, 20)) + .border( + 5.dp, + MaterialTheme.colorScheme.mediumImportanceLink, + CutCornerShape(20), + ) + .background(backgroundFromImage.first), + ) { + RenderBadge( + image, + name, + backgroundFromImage.second, + description, + ) { + if (imageResult == null) { + imageResult = it.result + } + } + } +} + +@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, + ) + } + + name?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp), + color = backgroundFromImage, + ) + } + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderBadgeAward( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (note.replyTo.isNullOrEmpty()) return + + val noteEvent = note.event as? BadgeAwardEvent ?: return + var awardees by remember { mutableStateOf>(listOf()) } + + Text(text = stringResource(R.string.award_granted_to)) + + LaunchedEffect(key1 = note) { accountViewModel.loadUsers(noteEvent.awardees()) { awardees = it } } + + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + awardees.take(100).forEach { user -> + Row( + modifier = + Modifier + .size(size = Size35dp) + .clickable { nav("User/${user.pubkeyHex}") }, + verticalAlignment = Alignment.CenterVertically, + ) { + ClickableUserPicture( + baseUser = user, + accountViewModel = accountViewModel, + size = Size35dp, + ) + } + } + + if (awardees.size > 100) { + Text(" and ${awardees.size - 100} others", maxLines = 1) + } + } + + note.replyTo?.firstOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = false, + isQuotedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt new file mode 100644 index 000000000..6d586ca52 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.events.ClassifiedsEvent + +@Composable +fun RenderClassifieds( + noteEvent: ClassifiedsEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val image = remember(noteEvent) { noteEvent.image() } + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } } + val price = remember(noteEvent) { noteEvent.price() } + val location = remember(noteEvent) { noteEvent.location() } + + Row( + modifier = + Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + Row { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: DefaultImageHeader(note, accountViewModel) + } + + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + price?.let { + val priceTag = + remember(noteEvent) { + val newAmount = + price.amount.toBigDecimalOrNull()?.let { showAmount(it) } + ?: price.amount + + if (price.frequency != null && price.currency != null) { + "$newAmount ${price.currency}/${price.frequency}" + } else if (price.currency != null) { + "$newAmount ${price.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = + remember { + Modifier + .clip(SmallBorder) + .padding(start = 5.dp) + }, + ) + } + } + + if (summary != null || location != null) { + Row( + Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + summary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Spacer(modifier = DoubleVertSpacer) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt new file mode 100644 index 000000000..3dcf3a8cb --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -0,0 +1,393 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.NormalTimeAgo +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.quartz.events.CommunityDefinitionEvent +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.Participant +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.util.Locale + +@Composable +fun CommunityHeader( + baseNote: AddressableNote, + showBottomDiviser: Boolean, + sendToCommunity: Boolean, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val expanded = remember { mutableStateOf(false) } + + Column(Modifier.fillMaxWidth()) { + Column( + verticalArrangement = Arrangement.Center, + modifier = + Modifier.clickable { + if (sendToCommunity) { + routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } + } else { + expanded.value = !expanded.value + } + }, + ) { + ShortCommunityHeader( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (expanded.value) { + Column(Modifier.verticalScroll(rememberScrollState())) { + LongCommunityHeader( + baseNote = baseNote, + lineModifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + if (showBottomDiviser) { + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} + +@Composable +fun LongCommunityHeader( + baseNote: AddressableNote, + lineModifier: Modifier = Modifier.padding(horizontal = Size10dp, vertical = Size5dp), + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = + remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + + Row( + lineModifier, + ) { + val rulesLabel = stringResource(id = R.string.rules) + val summary = + remember(noteState) { + val subject = noteEvent.subject()?.ifEmpty { null } + val body = noteEvent.description()?.ifBlank { null } + val rules = noteEvent.rules()?.ifBlank { null } + + if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) { + if (rules == null) { + "### $subject\n$body" + } else { + "### $subject\n$body\n\n### $rulesLabel\n\n$rules" + } + } else { + if (rules == null) { + body + } else { + "$body\n\n$rulesLabel\n$rules" + } + } + } + + Column( + Modifier.weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = summary ?: stringResource(id = R.string.community_no_descriptor), + canPreview = false, + tags = EmptyTagList, + backgroundColor = background, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (summary != null && noteEvent.hasHashtags()) { + DisplayUncitedHashtags( + remember(noteEvent) { noteEvent.hashtags().toImmutableList() }, + summary ?: "", + nav, + ) + } + } + + Column { + Row { + Spacer(DoubleHorzSpacer) + LongCommunityActionOptions(baseNote, accountViewModel, nav) + } + } + } + + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.owner), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp) + Spacer(DoubleHorzSpacer) + NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }) + } + + var participantUsers by + remember(baseNote) { + mutableStateOf>>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = noteState) { + val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators() + + if (participants != null) { + accountViewModel.loadParticipants(participants) { newParticipantUsers -> + if ( + newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) + ) { + participantUsers = newParticipantUsers + } + } + } + } + + participantUsers.forEach { + Row( + lineModifier.clickable { nav("User/${it.second.pubkeyHex}") }, + verticalAlignment = Alignment.CenterVertically, + ) { + it.first.role?.let { it1 -> + Text( + text = it1.capitalize(Locale.ROOT), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + } + Spacer(DoubleHorzSpacer) + ClickableUserPicture(it.second, Size25dp, accountViewModel) + Spacer(DoubleHorzSpacer) + UsernameDisplay(it.second, remember { Modifier.weight(1f) }) + } + } + + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.created_at), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f)) + MoreOptionsButton(baseNote, null, accountViewModel, nav) + } +} + +@Composable +fun ShortCommunityHeader( + baseNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = + remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return + + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } + + Row(verticalAlignment = Alignment.CenterVertically) { + noteEvent.image()?.let { + RobohashFallbackAsyncImage( + robot = baseNote.idHex, + model = it, + contentDescription = stringResource(R.string.profile_image), + contentScale = ContentScale.Crop, + modifier = HeaderPictureModifier, + loadProfilePicture = automaticallyShowProfilePicture, + ) + } + + Column( + modifier = + Modifier + .padding(start = 10.dp) + .height(Size35dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(noteState) { noteEvent.dTag() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + modifier = + Modifier + .height(Size35dp) + .padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ShortCommunityActionOptions(baseNote, accountViewModel, nav) + } + } +} + +@Composable +private fun ShortCommunityActionOptions( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Spacer(modifier = StdHorzSpacer) + LikeReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + + WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> + if (!isFollowing) { + Spacer(modifier = StdHorzSpacer) + JoinCommunityButton(accountViewModel, note, nav) + } + } +} + +@Composable +private fun LongCommunityActionOptions( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> + if (isFollowing) { + LeaveCommunityButton(accountViewModel, note, nav) + } + } +} + +@Composable +fun WatchAddressableNoteFollows( + note: AddressableNote, + accountViewModel: AccountViewModel, + onFollowChanges: @Composable (Boolean) -> Unit, +) { + val showFollowingMark by + remember { + accountViewModel.userFollows + .map { it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false } + .distinctUntilChanged() + } + .observeAsState(false) + + onFollowChanges(showFollowingMark) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt new file mode 100644 index 000000000..2e767e945 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -0,0 +1,309 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.elements.AddButton +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.RemoveButton +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.events.EmojiPackEvent +import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.events.EmojiUrl +import com.vitorpamplona.quartz.events.WikiNoteEvent + +@Composable +fun RenderWikiContent( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? WikiNoteEvent ?: return + + WikiNoteHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +private fun WikiNoteHeader( + noteEvent: WikiNoteEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val title = remember(noteEvent) { noteEvent.title() } + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + val image = remember(noteEvent) { noteEvent.image() } + + Row( + modifier = + Modifier + .padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + val automaticallyShowUrlPreview = + remember { accountViewModel.settings.showUrlPreview.value } + + if (automaticallyShowUrlPreview) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: DefaultImageHeader(note, accountViewModel) + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + + summary?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +public fun RenderEmojiPack( + baseNote: Note, + actionable: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + onClick: ((EmojiUrl) -> Unit)? = null, +) { + val noteEvent by + baseNote + .live() + .metadata + .map { it.note.event } + .distinctUntilChanged() + .observeAsState(baseNote.event) + + if (noteEvent == null || noteEvent !is EmojiPackEvent) return + + (noteEvent as? EmojiPackEvent)?.let { + RenderEmojiPack( + noteEvent = it, + baseNote = baseNote, + actionable = actionable, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + onClick = onClick, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +public fun RenderEmojiPack( + noteEvent: EmojiPackEvent, + baseNote: Note, + actionable: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + onClick: ((EmojiUrl) -> Unit)? = null, +) { + var expanded by remember { mutableStateOf(false) } + + val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() } + + val emojisToShow = + if (expanded) { + allEmojis + } else { + allEmojis.take(60) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(noteEvent) { "#${noteEvent.dTag()}" }, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .weight(1F) + .padding(5.dp), + textAlign = TextAlign.Center, + ) + + if (actionable) { + EmojiListOptions(accountViewModel, baseNote) + } + } + + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + emojisToShow.forEach { emoji -> + if (onClick != null) { + IconButton(onClick = { onClick(emoji) }, modifier = Size35Modifier) { + AsyncImage( + model = emoji.url, + contentDescription = null, + modifier = Size35Modifier, + ) + } + } else { + Box( + modifier = Size35Modifier, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = emoji.url, + contentDescription = null, + modifier = Size35Modifier, + ) + } + } + } + } + + if (allEmojis.size > 60 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } +} + +@Composable +private fun EmojiListOptions( + accountViewModel: AccountViewModel, + emojiPackNote: Note, +) { + LoadAddressableNote( + aTag = + ATag( + EmojiPackSelectionEvent.KIND, + accountViewModel.userProfile().pubkeyHex, + "", + null, + ), + accountViewModel, + ) { + it?.let { usersEmojiList -> + val hasAddedThis by + remember { + usersEmojiList + .live() + .metadata + .map { usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex) } + .distinctUntilChanged() + } + .observeAsState() + + Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") { + if (it != true) { + AddButton { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } + } else { + RemoveButton { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt new file mode 100644 index 000000000..cda8eda50 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.BaseMediaContent +import com.vitorpamplona.amethyst.commons.MediaUrlImage +import com.vitorpamplona.amethyst.commons.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.FileHeaderEvent + +@Composable +fun FileHeaderDisplay( + note: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, +) { + val event = (note.event as? FileHeaderEvent) ?: return + val fullUrl = event.url() ?: return + + val content by + remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.content.ifEmpty { null } ?: event.alt() + val isImage = RichTextParser.isImageUrl(fullUrl) + val uri = note.toNostrUri() + + mutableStateOf( + if (isImage) { + MediaUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + ) + } else { + MediaUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + ) + }, + ) + } + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + accountViewModel = accountViewModel, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt new file mode 100644 index 000000000..d28d51d0f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.animation.Crossfade +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.commons.BaseMediaContent +import com.vitorpamplona.amethyst.commons.MediaLocalImage +import com.vitorpamplona.amethyst.commons.MediaLocalVideo +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import java.io.File + +@Composable +fun FileStorageHeaderDisplay( + baseNote: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, +) { + val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return + val dataEventId = eventHeader.dataEventId() ?: return + + LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote -> + if (contentNote != null) { + ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel) + } + } +} + +@Composable +private fun ObserverAndRenderNIP95( + header: Note, + content: Note, + roundedCorner: Boolean, + accountViewModel: AccountViewModel, +) { + val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return + + val appContext = LocalContext.current.applicationContext + + val noteState by content.live().metadata.observeAsState() + + val content by + remember(noteState) { + // Creates a new object when the event arrives to force an update of the image. + val note = noteState?.note + val uri = header.toNostrUri() + val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) } + val blurHash = eventHeader.blurhash() + val dimensions = eventHeader.dimensions() + val description = eventHeader.alt() ?: eventHeader.content + val mimeType = eventHeader.mimeType() + + val newContent = + if (mimeType?.startsWith("image") == true) { + MediaLocalImage( + localFile = localDir, + mimeType = mimeType, + description = description, + dim = dimensions, + blurhash = blurHash, + isVerified = true, + uri = uri, + ) + } else { + MediaLocalVideo( + localFile = localDir, + mimeType = mimeType, + description = description, + dim = dimensions, + isVerified = true, + uri = uri, + authorName = header.author?.toBestDisplayName(), + ) + } + + mutableStateOf(newContent) + } + + Crossfade(targetState = content) { + if (it != null) { + SensitivityWarning(note = header, accountViewModel = accountViewModel) { + ZoomableContentView( + content = it, + roundedCorner = roundedCorner, + accountViewModel = accountViewModel, + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt new file mode 100644 index 000000000..bdf0986d1 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -0,0 +1,384 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.GitIssueEvent +import com.vitorpamplona.quartz.events.GitPatchEvent +import com.vitorpamplona.quartz.events.GitRepositoryEvent +import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RenderGitPatchEvent( + baseNote: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? GitPatchEvent ?: return + + RenderGitPatchEvent( + event, + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +private fun RenderShortRepositoryHeader( + baseNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteState by baseNote.live().metadata.observeAsState() + val noteEvent = noteState?.note?.event as? GitRepositoryEvent ?: return + + Column( + modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), + ) { + val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } + Text( + text = stringResource(id = R.string.git_repository, title), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + noteEvent.description()?.let { + Spacer(modifier = DoubleVertSpacer) + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun RenderGitPatchEvent( + noteEvent: GitPatchEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val repository = remember(noteEvent) { noteEvent.repository() } + + if (repository != null) { + LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { + if (it != null) { + RenderShortRepositoryHeader(it, accountViewModel, nav) + Spacer(modifier = DoubleVertSpacer) + } + } + } + + LoadDecryptedContent(note, accountViewModel) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + + if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { + "### $subject\n$body" + } else { + body + } + } + } + + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } + } +} + +@Composable +fun RenderGitIssueEvent( + baseNote: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? GitIssueEvent ?: return + + RenderGitIssueEvent( + event, + baseNote, + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +private fun RenderGitIssueEvent( + noteEvent: GitIssueEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val repository = remember(noteEvent) { noteEvent.repository() } + + if (repository != null) { + LoadAddressableNote(aTag = repository, accountViewModel = accountViewModel) { + if (it != null) { + RenderShortRepositoryHeader(it, accountViewModel, nav) + Spacer(modifier = DoubleVertSpacer) + } + } + } + + LoadDecryptedContent(note, accountViewModel) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + + if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { + "### $subject\n$body" + } else { + body + } + } + } + + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } + } +} + +@Composable +fun RenderGitRepositoryEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? GitRepositoryEvent ?: return + + RenderGitRepositoryEvent(event, baseNote, accountViewModel, nav) +} + +@Composable +private fun RenderGitRepositoryEvent( + noteEvent: GitRepositoryEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val title = remember(noteEvent) { noteEvent.name() ?: noteEvent.dTag() } + val summary = remember(noteEvent) { noteEvent.description() } + val web = remember(noteEvent) { noteEvent.web() } + val clone = remember(noteEvent) { noteEvent.clone() } + + Row( + modifier = + Modifier + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ).padding(Size10dp), + ) { + Column { + Text( + text = stringResource(id = R.string.git_repository, title), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + summary?.let { + Text( + text = it, + modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + web?.let { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringResource(id = R.string.git_web_address), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + ClickableUrl( + url = it, + urlText = it.removePrefix("https://").removePrefix("http://"), + ) + } + } + + clone?.let { + Row(Modifier.fillMaxWidth().padding(top = Size5dp)) { + Text( + text = stringResource(id = R.string.git_clone_address), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + ClickableUrl( + url = it, + urlText = it.removePrefix("https://").removePrefix("http://"), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt new file mode 100644 index 000000000..7fe5f703b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -0,0 +1,201 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.HighlightEvent +import com.vitorpamplona.quartz.events.LongTextNoteEvent +import java.net.URL + +@Composable +fun RenderHighlight( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val quote = remember { (note.event as? HighlightEvent)?.quote() ?: "" } + val author = remember { (note.event as? HighlightEvent)?.author() } + val url = remember { (note.event as? HighlightEvent)?.inUrl() } + val postHex = remember { (note.event as? HighlightEvent)?.taggedAddresses()?.firstOrNull() } + + DisplayHighlight( + highlight = quote, + authorHex = author, + url = url, + postAddress = postHex, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun DisplayHighlight( + highlight: String, + authorHex: String?, + url: String?, + postAddress: ATag?, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val quote = + remember { + highlight.split("\n").joinToString("\n") { "> *${it.removeSuffix(" ")}*" } + } + + TranslatableRichTextViewer( + quote, + canPreview = canPreview && !makeItShort, + remember { Modifier.fillMaxWidth() }, + EmptyTagList, + backgroundColor, + accountViewModel, + nav, + ) + + DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DisplayQuoteAuthor( + authorHex: String, + url: String?, + postAddress: ATag?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var userBase by remember { mutableStateOf(accountViewModel.getUserIfExists(authorHex)) } + + if (userBase == null) { + LaunchedEffect(Unit) { + accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase -> + userBase = newUserBase + } + } + } + + val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(spaceWidth), + verticalArrangement = Arrangement.Center, + ) { + userBase?.let { userBase -> + val userMetadata by userBase.live().userMetadataInfo.observeAsState() + + CreateClickableTextWithEmoji( + clickablePart = userMetadata?.bestDisplayName() ?: userMetadata?.bestUsername() ?: userBase.pubkeyDisplayHex(), + maxLines = 1, + route = "User/${userBase.pubkeyHex}", + nav = nav, + tags = userMetadata?.tags, + ) + } + + url?.let { url -> LoadAndDisplayUrl(url) } + + postAddress?.let { address -> LoadAndDisplayPost(address, accountViewModel, nav) } + } +} + +@Composable +fun LoadAndDisplayUrl(url: String) { + val validatedUrl = + remember { + try { + URL(url) + } catch (e: Exception) { + Log.w("Note Compose", "Invalid URI: $url") + null + } + } + + validatedUrl?.host?.let { host -> + Text(remember { "-" }, maxLines = 1) + ClickableUrl(urlText = host, url = url) + } +} + +@Composable +private fun LoadAndDisplayPost( + postAddress: ATag, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadAddressableNote(aTag = postAddress, accountViewModel) { + it?.let { note -> + val noteEvent by + note.live().metadata.map { it.note.event }.distinctUntilChanged().observeAsState(note.event) + + val title = remember(noteEvent) { (noteEvent as? LongTextNoteEvent)?.title() } + + title?.let { + Text(remember { "-" }, maxLines = 1) + ClickableText( + text = AnnotatedString(title), + onClick = { routeFor(note, accountViewModel.userProfile())?.let { nav(it) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary), + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt new file mode 100644 index 000000000..36f25aaed --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -0,0 +1,217 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.VideoView +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckIfUrlIsOnline +import com.vitorpamplona.amethyst.ui.screen.loggedIn.CrossfadeCheckIfUrlIsOnline +import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.LiveActivitiesEvent +import com.vitorpamplona.quartz.events.Participant +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.util.Locale + +@Composable +fun RenderLiveActivityEvent( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Row(modifier = Modifier.padding(top = 5.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RenderLiveActivityEventInner(baseNote = baseNote, accountViewModel, nav) + } + } +} + +@Composable +fun RenderLiveActivityEventInner( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return + + val eventUpdates by baseNote.live().metadata.observeAsState() + + val media = remember(eventUpdates) { noteEvent.streaming() } + val cover = remember(eventUpdates) { noteEvent.image() } + val subject = remember(eventUpdates) { noteEvent.title() } + val content = remember(eventUpdates) { noteEvent.summary() } + val participants = remember(eventUpdates) { noteEvent.participants() } + val status = remember(eventUpdates) { noteEvent.status() } + val starts = remember(eventUpdates) { noteEvent.starts() } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(vertical = 5.dp) + .fillMaxWidth(), + ) { + subject?.let { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = StdHorzSpacer) + + Crossfade(targetState = status, label = "RenderLiveActivityEventInner") { + when (it) { + LiveActivitiesEvent.STATUS_LIVE -> { + media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } } + } + + LiveActivitiesEvent.STATUS_PLANNED -> { + ScheduledFlag(starts) + } + } + } + } + + var participantUsers by remember { + mutableStateOf>>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = eventUpdates) { + accountViewModel.loadParticipants(participants) { newParticipantUsers -> + if (!equalImmutableLists(newParticipantUsers, participantUsers)) { + participantUsers = newParticipantUsers + } + } + } + + media?.let { media -> + if (status == LiveActivitiesEvent.STATUS_LIVE) { + CheckIfUrlIsOnline(media, accountViewModel) { isOnline -> + if (isOnline) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + VideoView( + videoUri = media, + title = subject, + artworkUri = cover, + authorName = baseNote.author?.toBestDisplayName(), + roundedCorner = true, + accountViewModel = accountViewModel, + nostrUriCallback = "nostr:${baseNote.toNEvent()}", + ) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(10.dp) + .height(100.dp), + ) { + Text( + text = stringResource(id = R.string.live_stream_is_offline), + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + ) + } + } + } + } else if (status == LiveActivitiesEvent.STATUS_ENDED) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(10.dp) + .height(100.dp), + ) { + Text( + text = stringResource(id = R.string.live_stream_has_ended), + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + ) + } + } + } + + participantUsers.forEach { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(vertical = 5.dp) + .clickable { nav("User/${it.second.pubkeyHex}") }, + ) { + ClickableUserPicture(it.second, 25.dp, accountViewModel) + Spacer(StdHorzSpacer) + UsernameDisplay(it.second, Modifier.weight(1f)) + Spacer(StdHorzSpacer) + it.first.role?.let { + Text( + text = it.capitalize(Locale.ROOT), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt new file mode 100644 index 000000000..f4584efbe --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.events.LongTextNoteEvent + +@Composable +fun RenderLongFormContent( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? LongTextNoteEvent ?: return + + LongFormHeader(noteEvent, note, accountViewModel) +} + +@Composable +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()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + + Row( + modifier = + Modifier + .padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) { + Column { + val automaticallyShowUrlPreview = + remember { accountViewModel.settings.showUrlPreview.value } + + if (automaticallyShowUrlPreview) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + ?: DefaultImageHeader(note, accountViewModel) + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + + summary?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt new file mode 100644 index 000000000..0231b8d60 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MedicalData.kt @@ -0,0 +1,265 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Bundle +import com.vitorpamplona.amethyst.model.FhirElementDatabase +import com.vitorpamplona.amethyst.model.LensSpecification +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.Patient +import com.vitorpamplona.amethyst.model.Practitioner +import com.vitorpamplona.amethyst.model.Resource +import com.vitorpamplona.amethyst.model.VisionPrescription +import com.vitorpamplona.amethyst.model.findReferenceInDb +import com.vitorpamplona.amethyst.model.parseResourceBundleOrNull +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.FhirResourceEvent +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.text.DecimalFormat +import java.text.NumberFormat + +@Preview +@Composable +fun RenderEyeGlassesPrescriptionPreview() { + val prescriptionEvent = + Event.fromJson( + "{\"id\":\"0c15d2bc6f7dcc42fa4426d35d30d09840c9afa5b46d100415006e41d6471416\",\"pubkey\":\"bcd4715cc34f98dce7b52fddaf1d826e5ce0263479b7e110a5bd3c3789486ca8\",\"created_at\":1709074097,\"kind\":82,\"tags\":[],\"content\":\"{\\\"resourceType\\\":\\\"Bundle\\\",\\\"id\\\":\\\"bundle-vision-test\\\",\\\"type\\\":\\\"document\\\",\\\"entry\\\":[{\\\"resourceType\\\":\\\"Practitioner\\\",\\\"id\\\":\\\"2\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Careful\\\",\\\"given\\\":[\\\"Adam\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"Patient\\\",\\\"id\\\":\\\"1\\\",\\\"active\\\":true,\\\"name\\\":[{\\\"use\\\":\\\"official\\\",\\\"family\\\":\\\"Duck\\\",\\\"given\\\":[\\\"Donald\\\"]}],\\\"gender\\\":\\\"male\\\"},{\\\"resourceType\\\":\\\"VisionPrescription\\\",\\\"status\\\":\\\"active\\\",\\\"created\\\":\\\"2014-06-15\\\",\\\"patient\\\":{\\\"reference\\\":\\\"#1\\\"},\\\"dateWritten\\\":\\\"2014-06-15\\\",\\\"prescriber\\\":{\\\"reference\\\":\\\"#2\\\"},\\\"lensSpecification\\\":[{\\\"eye\\\":\\\"right\\\",\\\"sphere\\\":-2,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"down\\\"}],\\\"add\\\":2},{\\\"eye\\\":\\\"left\\\",\\\"sphere\\\":-1,\\\"cylinder\\\":-0.5,\\\"axis\\\":180,\\\"prism\\\":[{\\\"amount\\\":0.5,\\\"base\\\":\\\"up\\\"}],\\\"add\\\":2}]}]}\",\"sig\":\"dc58f6109111ca06920c0c711aeaf8e2ee84975afa60d939828d4e01e2edea738f735fb5b1fcadf6d5496e36ac429abf7020a55fd1e4ed215738afc8d07cb950\"}", + ) as FhirResourceEvent + + RenderFhirResource(prescriptionEvent) +} + +@Composable +fun RenderFhirResource( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = baseNote.event as? FhirResourceEvent ?: return + + RenderFhirResource(event) +} + +@Composable +fun RenderFhirResource(event: FhirResourceEvent) { + val state by produceState(initialValue = FhirElementDatabase(), key1 = event) { + withContext(Dispatchers.Default) { + parseResourceBundleOrNull(event.content)?.let { + value = it + } + } + } + + state.baseResource?.let { resource -> + when (resource) { + is Bundle -> { + val vision = resource.entry.filterIsInstance(VisionPrescription::class.java) + + vision.firstOrNull()?.let { + RenderEyeGlassesPrescription(it, state.localDb) + } + } + is VisionPrescription -> { + RenderEyeGlassesPrescription(resource, state.localDb) + } + else -> { + } + } + } +} + +@Composable +fun RenderEyeGlassesPrescription( + visionPrescription: VisionPrescription, + db: ImmutableMap, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size10dp), + ) { + val rightEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "right" } + val leftEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "left" } + + Text( + "Eyeglasses Prescription", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Spacer(StdVertSpacer) + + visionPrescription.patient?.reference?.let { + val patient = findReferenceInDb(it, db) as? Patient + + patient?.name?.firstOrNull()?.assembleName()?.let { + Text( + text = "Patient: $it", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + ) + } + } + visionPrescription.status?.let { + Text( + text = "Status: ${it.capitalize()}", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + ) + } + + Spacer(DoubleVertSpacer) + + RenderEyeGlassesPrescriptionHeaderRow() + HorizontalDivider(thickness = DividerThickness) + + rightEye?.let { + RenderEyeGlassesPrescriptionRow(data = it) + HorizontalDivider(thickness = DividerThickness) + } + + leftEye?.let { + RenderEyeGlassesPrescriptionRow(data = it) + HorizontalDivider(thickness = DividerThickness) + } + + visionPrescription.prescriber?.reference?.let { + val practitioner = findReferenceInDb(it, db) as? Practitioner + + practitioner?.name?.firstOrNull()?.assembleName()?.let { + Spacer(DoubleVertSpacer) + Text( + text = "Signed by: $it", + modifier = Modifier.padding(4.dp).fillMaxWidth(), + textAlign = TextAlign.Right, + ) + } + } + } +} + +@Composable +fun RenderEyeGlassesPrescriptionHeaderRow() { + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Eye", + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = "Sph", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = "Cyl", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = "Axis", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = "Add", + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + } +} + +@Composable +fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) { + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val numberFormat = DecimalFormat("##.00") + val integerFormat = DecimalFormat("###") + + Text( + text = data.eye?.capitalize() ?: "Unknown", + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = formatOrBlank(data.sphere, numberFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = formatOrBlank(data.cylinder, numberFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + Text( + text = formatOrBlank(data.axis, integerFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + VerticalDivider(thickness = DividerThickness) + Text( + text = formatOrBlank(data.add, numberFormat), + textAlign = TextAlign.Right, + modifier = Modifier.padding(4.dp).weight(1f), + ) + } +} + +fun formatOrBlank( + amount: Double?, + numberFormat: NumberFormat, +): String { + if (amount == null) return "" + if (Math.abs(amount) < 0.01) return "" + return numberFormat.format(amount) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt new file mode 100644 index 000000000..a5d6c2ae0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.PeopleListEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DisplayPeopleList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? PeopleListEvent ?: return + + var members by remember { mutableStateOf>(persistentListOf()) } + + var expanded by remember { mutableStateOf(false) } + + val toMembersShow = + if (expanded) { + members + } else { + members.take(3) + } + + val name by remember { derivedStateOf { "#${noteEvent.dTag()}" } } + + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(5.dp), + textAlign = TextAlign.Center, + ) + + LaunchedEffect(Unit) { + accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) { + members = it + } + } + + Box { + FlowRow(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { user -> + Row(modifier = Modifier.fillMaxWidth()) { + UserCompose( + user, + overallModifier = Modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + if (members.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt new file mode 100644 index 000000000..b5261a7dd --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.PinIcon +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.PinListEvent + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderPinListEvent( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? PinListEvent ?: return + + val pins by remember { mutableStateOf(noteEvent.pins()) } + + 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 = Alignment.CenterVertically, + ) { + PinIcon( + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.onBackground.copy(0.32f), + ) + + Spacer(modifier = Modifier.width(5.dp)) + + TranslatableRichTextViewer( + content = pin, + canPreview = true, + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + if (pins.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt new file mode 100644 index 000000000..2dfc1ed79 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.PollNote +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.PollNoteEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RenderPoll( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? PollNoteEvent ?: return + val eventContent = noteEvent.content() + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = remember { Modifier.fillMaxWidth() }, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + + PollNote( + note, + canPreview = canPreview && !makeItShort, + backgroundColor, + accountViewModel, + nav, + ) + } + + if (noteEvent.hasHashtags()) { + val hashtags = remember { noteEvent.hashtags().toImmutableList() } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt new file mode 100644 index 000000000..537a9ee6d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.encoders.toNpub +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.PrivateDmEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RenderPrivateMessage( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? PrivateDmEvent ?: return + + val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) } + if (withMe) { + LoadDecryptedContent(note, accountViewModel) { eventContent -> + 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() ?: EmptyTagList } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (noteEvent.hasHashtags()) { + val hashtags = + remember(note.event?.id()) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } + } + } else { + val recipient = noteEvent.recipientPubKeyBytes()?.toNpub() ?: "Someone" + + TranslatableRichTextViewer( + stringResource( + id = R.string.private_conversation_notification, + "@${note.author?.pubkeyNpub()}", + "@$recipient", + ), + canPreview = !makeItShort, + Modifier.fillMaxWidth(), + EmptyTagList, + backgroundColor, + accountViewModel, + nav, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt new file mode 100644 index 000000000..d42515700 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun RenderReaction( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + note.replyTo?.lastOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + unPackReply = false, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + // Reposts have trash in their contents. + val refactorReactionText = if (note.event?.content() == "+") "❤" else note.event?.content() ?: "" + + Text( + text = refactorReactionText, + maxLines = 1, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt new file mode 100644 index 000000000..ea134909e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -0,0 +1,178 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.RelayBriefInfoCache +import com.vitorpamplona.amethyst.ui.actions.NewRelayListView +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.note.AddRelayButton +import com.vitorpamplona.amethyst.ui.note.RemoveRelayButton +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.RelaySetEvent +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun DisplayRelaySet( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = baseNote.event as? RelaySetEvent ?: return + + val relays by + remember(baseNote) { + mutableStateOf( + noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.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 = Alignment.CenterVertically, + ) { + Text( + text = relay.displayUrl, + 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.url, accountViewModel, nav) + } + } + } + } + + if (relays.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } +} + +@Composable +private fun RelayOptionsAction( + relay: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + 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, nav = nav) + } + + if (isCurrentlyOnTheUsersList) { + AddRelayButton { wantsToAddRelay = relay } + } else { + RemoveRelayButton { wantsToAddRelay = relay } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt new file mode 100644 index 000000000..d9e3c4197 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent + +@Composable +fun RenderPostApproval( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + if (note.replyTo.isNullOrEmpty()) return + + val noteEvent = note.event as? CommunityPostApprovalEvent ?: return + + Column(Modifier.fillMaxWidth()) { + noteEvent.communities().forEach { + LoadAddressableNote(it, accountViewModel) { + it?.let { + NoteCompose( + it, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + + Text( + text = stringResource(id = R.string.community_approved_posts), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(5.dp), + textAlign = TextAlign.Center, + ) + + note.replyTo?.forEach { + NoteCompose( + it, + modifier = MaterialTheme.colorScheme.replyModifier, + unPackReply = false, + makeItShort = true, + isQuotedNote = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt new file mode 100644 index 000000000..50902b48d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.ReportEvent + +@Composable +fun RenderReport( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? ReportEvent ?: return + + val base = remember { (noteEvent.reportedPost() + noteEvent.reportedAuthor()) } + + val reportType = + base + .map { + 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) + ReportEvent.ReportType.OTHER -> stringResource(R.string.other) + } + } + .toSet() + .joinToString(", ") + + val content = + remember { + reportType + (note.event?.content()?.ifBlank { null }?.let { ": $it" } ?: "") + } + + TranslatableRichTextViewer( + content = content, + canPreview = true, + modifier = Modifier, + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + + note.replyTo?.lastOrNull()?.let { + NoteCompose( + baseNote = it, + isQuotedNote = true, + modifier = + Modifier + .padding(top = 5.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + unPackReply = false, + makeItShort = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt new file mode 100644 index 000000000..cfea566bf --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -0,0 +1,123 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RenderTextEvent( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + editState: State>, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadDecryptedContent( + note, + accountViewModel, + ) { body -> + val eventContent by + remember(note.event) { + derivedStateOf { + val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + val newBody = + if (editState.value is GenericLoadable.Loaded) { + val state = + (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow + state?.value?.event?.content() ?: body + } else { + body + } + + if (!subject.isNullOrBlank() && !newBody.split("\n")[0].contains(subject)) { + "### $subject\n$newBody" + } else { + newBody + } + } + } + + val isAuthorTheLoggedUser = + remember(note.event) { accountViewModel.isLoggedUser(note.author) } + + if (makeItShort && isAuthorTheLoggedUser) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = + remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + modifier = modifier, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (note.event?.hasHashtags() == true) { + val hashtags = + remember(note.event) { + note.event?.hashtags()?.toImmutableList() ?: persistentListOf() + } + DisplayUncitedHashtags(hashtags, eventContent, nav) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt new file mode 100644 index 000000000..41412de94 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt @@ -0,0 +1,279 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.EditPostView +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.note.NoteBody +import com.vitorpamplona.amethyst.ui.note.observeEdits +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.TextNoteModificationEvent +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun RenderTextModificationEvent( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? TextNoteModificationEvent ?: return + val noteAuthor = note.author ?: return + + val wantsToEditPost = + remember { + mutableStateOf(false) + } + + val isAuthorTheLoggedUser = + remember { + val authorOfTheOriginalNote = + noteEvent.editedNote()?.let { accountViewModel.getNoteIfExists(it)?.author } + + mutableStateOf(accountViewModel.isLoggedUser(authorOfTheOriginalNote)) + } + + noteEvent.editedNote()?.let { + LoadNote(baseNoteHex = it, accountViewModel = accountViewModel) { baseOriginalNote -> + baseOriginalNote?.let { + } + } + } + + Card( + modifier = MaterialTheme.colorScheme.imageModifier, + ) { + Column(Modifier.fillMaxWidth().padding(Size10dp)) { + Text( + text = stringResource(id = R.string.proposal_to_edit), + style = + TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ), + ) + + Spacer(modifier = StdVertSpacer) + + noteEvent.summary()?.let { + TranslatableRichTextViewer( + content = it, + canPreview = canPreview && !makeItShort, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + + noteEvent.editedNote()?.let { + LoadNote(baseNoteHex = it, accountViewModel = accountViewModel) { baseNote -> + baseNote?.let { + val noteState by baseNote.live().metadata.observeAsState() + + val editStateOriginalNote = + observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) + + val editState = + remember(note) { + derivedStateOf { + val loadable = + editStateOriginalNote.value as? GenericLoadable.Loaded + + val state = EditState() + + val latestChangeByAuthor = + if (loadable != null && loadable.loaded.hasModificationsToShow()) { + loadable.loaded.latestBefore(note.createdAt()) + } else { + null + } + + state.updateModifications( + listOfNotNull( + latestChangeByAuthor, + note, + ), + ) + + GenericLoadable.Loaded(state) + } + } + + LaunchedEffect(key1 = noteState) { + val newAuthor = accountViewModel.isLoggedUser(noteState?.note?.author) + + if (isAuthorTheLoggedUser.value != newAuthor) { + isAuthorTheLoggedUser.value = newAuthor + } + } + + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp) + .clickable { + routeFor( + baseNote, + accountViewModel.userProfile(), + )?.let { nav(it) } + }, + ) { + NoteBody( + baseNote = baseNote, + showAuthorPicture = true, + unPackReply = false, + makeItShort = false, + canPreview = true, + showSecondRow = false, + backgroundColor = backgroundColor, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (wantsToEditPost.value) { + EditPostView( + onClose = { + wantsToEditPost.value = false + }, + edit = baseNote, + versionLookingAt = note, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } + + if (isAuthorTheLoggedUser.value) { + Spacer(modifier = StdVertSpacer) + + Button( + onClick = { wantsToEditPost.value = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(id = R.string.accept_the_suggestion)) + } + } + } + } +} + +@Stable +class EditState() { + private var modificationsList: List = persistentListOf() + private var modificationToShowIndex: Int = -1 + + val modificationToShow: MutableState = mutableStateOf(null) + val showingVersion: MutableState = mutableStateOf(0) + + fun hasModificationsToShow(): Boolean = modificationsList.isNotEmpty() + + fun isOriginal(): Boolean = modificationToShowIndex < 0 + + fun isLatest(): Boolean = modificationToShowIndex == modificationsList.lastIndex + + fun originalVersionId() = 0 + + fun lastVersionId() = modificationsList.size + + fun versionId() = modificationToShowIndex + 1 + + fun latest() = modificationsList.lastOrNull() + + fun latestBefore(createdAt: Long?): Note? { + if (createdAt == null) return latest() + return modificationsList.lastOrNull { (it.createdAt() ?: Long.MAX_VALUE) < createdAt } + } + + fun nextModification() { + if (modificationToShowIndex < 0) { + modificationToShowIndex = 0 + modificationToShow.value = modificationsList.getOrNull(0) + } else { + modificationToShowIndex++ + if (modificationToShowIndex >= modificationsList.size) { + modificationToShowIndex = -1 + modificationToShow.value = null + } else { + modificationToShow.value = modificationsList.getOrNull(modificationToShowIndex) + } + } + + showingVersion.value = versionId() + } + + fun updateModifications(newModifications: List) { + if (modificationsList != newModifications) { + modificationsList = newModifications + + if (newModifications.isEmpty()) { + modificationToShow.value = null + modificationToShowIndex = -1 + } else { + modificationToShowIndex = newModifications.lastIndex + modificationToShow.value = newModifications.last() + } + } + + showingVersion.value = versionId() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt new file mode 100644 index 000000000..e82869266 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.BaseMediaContent +import com.vitorpamplona.amethyst.commons.MediaUrlImage +import com.vitorpamplona.amethyst.commons.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.quartz.events.EmptyTagList +import com.vitorpamplona.quartz.events.VideoEvent +import com.vitorpamplona.quartz.events.toImmutableListOfLists +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun VideoDisplay( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val event = (note.event as? VideoEvent) ?: return + val fullUrl = event.url() ?: return + + val title = event.title() + val summary = event.content.ifBlank { null }?.takeIf { title != it } + val image = event.thumb() ?: event.image() + val isYouTube = fullUrl.contains("youtube.com") || fullUrl.contains("youtu.be") + val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList } + + val content by + remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.content.ifBlank { null } ?: event.alt() + val isImage = RichTextParser.isImageUrl(fullUrl) + val uri = note.toNostrUri() + + mutableStateOf( + if (isImage) { + MediaUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + ) + } else { + MediaUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + artworkUri = event.thumb() ?: event.image(), + ) + }, + ) + } + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 5.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isYouTube) { + val uri = LocalUriHandler.current + Row( + modifier = Modifier.clickable { runCatching { uri.openUri(fullUrl) } }, + ) { + image?.let { + AsyncImage( + model = it, + contentDescription = + stringResource( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + modifier = MaterialTheme.colorScheme.imageModifier, + ) + } + ?: DefaultImageHeader(note, accountViewModel) + } + } else { + ZoomableContentView( + content = content, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } + + title?.let { + Text( + text = it, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 5.dp), + ) + } + + summary?.let { + TranslatableRichTextViewer( + content = it, + canPreview = canPreview && !makeItShort, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (event.hasHashtags()) { + Row( + Modifier.fillMaxWidth(), + ) { + DisplayUncitedHashtags( + remember(event) { event.hashtags().toImmutableList() }, + summary ?: "", + nav, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index da5cf1f55..89c5c6b52 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -150,7 +150,7 @@ fun NewSubject(note: Note) { @Composable fun NewSubject(newSubject: String) { Row(verticalAlignment = Alignment.CenterVertically) { - Divider( + HorizontalDivider( modifier = Modifier.weight(1f), ) Text( @@ -159,7 +159,7 @@ fun NewSubject(newSubject: String) { fontSize = Font14SP, modifier = HalfPadding, ) - Divider( + HorizontalDivider( modifier = Modifier.weight(1f), ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 9822f1d67..9ca328593 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -40,7 +40,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -90,53 +90,52 @@ import com.vitorpamplona.amethyst.ui.components.InlineCarrousel import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost -import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost -import com.vitorpamplona.amethyst.ui.elements.DisplayPoW -import com.vitorpamplona.amethyst.ui.elements.DisplayReward -import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits -import com.vitorpamplona.amethyst.ui.elements.Reward -import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.navigation.routeToMessage -import com.vitorpamplona.amethyst.ui.note.AudioHeader -import com.vitorpamplona.amethyst.ui.note.AudioTrackHeader -import com.vitorpamplona.amethyst.ui.note.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.BlankNote -import com.vitorpamplona.amethyst.ui.note.CreateImageHeader -import com.vitorpamplona.amethyst.ui.note.DisplayEditStatus -import com.vitorpamplona.amethyst.ui.note.DisplayHighlight -import com.vitorpamplona.amethyst.ui.note.DisplayLocation -import com.vitorpamplona.amethyst.ui.note.DisplayOts -import com.vitorpamplona.amethyst.ui.note.DisplayPeopleList -import com.vitorpamplona.amethyst.ui.note.DisplayRelaySet -import com.vitorpamplona.amethyst.ui.note.EditState -import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay -import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.note.LoadAndDisplayUser import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.ReactionsRow -import com.vitorpamplona.amethyst.ui.note.RenderAppDefinition -import com.vitorpamplona.amethyst.ui.note.RenderEmojiPack -import com.vitorpamplona.amethyst.ui.note.RenderFhirResource -import com.vitorpamplona.amethyst.ui.note.RenderGitIssueEvent -import com.vitorpamplona.amethyst.ui.note.RenderGitPatchEvent -import com.vitorpamplona.amethyst.ui.note.RenderGitRepositoryEvent -import com.vitorpamplona.amethyst.ui.note.RenderPinListEvent -import com.vitorpamplona.amethyst.ui.note.RenderPoll -import com.vitorpamplona.amethyst.ui.note.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.RenderRepost -import com.vitorpamplona.amethyst.ui.note.RenderTextEvent -import com.vitorpamplona.amethyst.ui.note.RenderTextModificationEvent -import com.vitorpamplona.amethyst.ui.note.VideoDisplay +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus +import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost +import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost +import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation +import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts +import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW +import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward +import com.vitorpamplona.amethyst.ui.note.elements.DisplayZapSplits +import com.vitorpamplona.amethyst.ui.note.elements.ForkInformationRow +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu +import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.types.AudioHeader +import com.vitorpamplona.amethyst.ui.note.types.AudioTrackHeader +import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.DisplayHighlight +import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList +import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack +import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource +import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPoll +import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval +import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent +import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThinSendButton @@ -625,7 +624,7 @@ fun NoteMaster( ReactionsRow(note, true, editState, accountViewModel, nav) - Divider( + HorizontalDivider( thickness = DividerThickness, ) } @@ -665,7 +664,7 @@ private fun RenderClassifiedsReaderForThread( ) } } else { - CreateImageHeader(note, accountViewModel) + DefaultImageHeader(note, accountViewModel) } Row( @@ -973,24 +972,3 @@ private fun RenderWikiHeaderForThread( } } } - -@Composable -fun ForkInformationRow( - originalVersion: Note, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, -) { - val noteState by originalVersion.live().metadata.observeAsState() - val note = noteState?.note ?: return - val author = note.author ?: return - val route = remember(note) { routeFor(note, accountViewModel.userProfile()) } - - if (route != null) { - Row(modifier) { - Text(stringResource(id = R.string.forked_from)) - Spacer(modifier = StdHorzSpacer) - LoadAndDisplayUser(author, route, nav) - } - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 86f9e325d..5f9b58238 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -50,10 +50,10 @@ import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -121,18 +121,18 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.LoadChannel -import com.vitorpamplona.amethyst.ui.note.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel @@ -640,7 +640,7 @@ fun ChannelHeader( } if (showBottomDiviser) { - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index d5c0b5985..be3c1f6f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -33,9 +33,9 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -170,7 +170,7 @@ fun ChatroomListTwoPane( Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { ChannelFabColumn(accountViewModel, nav) } - Divider( + HorizontalDivider( modifier = Modifier.fillMaxHeight() // fill the max height .width(DividerThickness), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 1e621e109..048738d96 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -45,7 +45,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Send import androidx.compose.material3.Button -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -603,7 +603,7 @@ fun ChatroomHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } @@ -643,7 +643,7 @@ fun GroupChatroomHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt index e1f4515b0..725154bbd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/GeoHashScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -157,7 +157,7 @@ fun GeoHashHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt index de08249ff..2132d88ae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -160,7 +160,7 @@ fun HashtagHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 73346dd8d..a2ff9649a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -34,7 +34,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ScrollableTabRow @@ -67,7 +67,7 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.elements.AddButton +import com.vitorpamplona.amethyst.ui.note.elements.AddButton import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHiddenWordsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrSpammerAccountsFeedViewModel @@ -177,7 +177,7 @@ fun HiddenUsersScreen( edgePadding = 8.dp, selectedTabIndex = pagerState.currentPage, modifier = TabRowHeight, - divider = { Divider(thickness = DividerThickness) }, + divider = { HorizontalDivider(thickness = DividerThickness) }, ) { Tab( selected = pagerState.currentPage == 0, @@ -313,7 +313,7 @@ fun MutedWordHeader( } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 6e6e5bc81..2ba1c7532 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -230,7 +230,7 @@ fun SummaryBar(model: UserReactionsViewModel) { } } - Divider( + HorizontalDivider( thickness = DividerThickness, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 094682cbe..d12f2de31 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -57,9 +57,9 @@ import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -476,7 +476,7 @@ private fun RenderScreen( selectedTabIndex = pagerState.currentPage, edgePadding = 8.dp, modifier = tabRowModifier, - divider = { Divider(thickness = DividerThickness) }, + divider = { HorizontalDivider(thickness = DividerThickness) }, ) { CreateAndRenderTabs(baseUser, pagerState) } @@ -819,7 +819,7 @@ private fun ProfileHeader( DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) - Divider(modifier = Modifier.padding(top = 6.dp)) + HorizontalDivider(modifier = Modifier.padding(top = 6.dp)) } } @@ -1870,7 +1870,7 @@ fun UserProfileDropDownMenu( ) if (accountViewModel.userProfile() != user) { - Divider() + HorizontalDivider(thickness = DividerThickness) if (accountViewModel.account.isHidden(user)) { DropdownMenuItem( text = { Text(stringResource(R.string.unblock_user)) }, @@ -1888,7 +1888,7 @@ fun UserProfileDropDownMenu( }, ) } - Divider() + HorizontalDivider(thickness = DividerThickness) DropdownMenuItem( text = { Text(stringResource(id = R.string.report_spam_scam)) }, onClick = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt index 69c921b61..4186d0720 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt @@ -33,8 +33,8 @@ import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Report import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -61,6 +61,7 @@ import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.WarningColor import com.vitorpamplona.quartz.events.ReportEvent import kotlinx.collections.immutable.toImmutableList @@ -126,7 +127,7 @@ fun ReportNoteDialog( ) SpacerH16() - Divider(color = MaterialTheme.colorScheme.onSurface, thickness = 0.25.dp) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface, thickness = DividerThickness) SpacerH16() SectionHeader(text = stringResource(R.string.report_dialog_report_btn)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 57cc6bbca..437c0e12b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -36,8 +36,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -446,7 +446,7 @@ fun HashtagLine( } } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) @@ -481,7 +481,7 @@ fun UserLine( } } - Divider( + HorizontalDivider( modifier = Modifier.padding(top = 10.dp), thickness = DividerThickness, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 06fb614be..59eb824e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -70,18 +70,18 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.BoostReaction -import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay -import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture -import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.RenderRelay import com.vitorpamplona.amethyst.ui.note.ReplyReaction import com.vitorpamplona.amethyst.ui.note.ViewCountReaction import com.vitorpamplona.amethyst.ui.note.WatchForReports import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu +import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.screen.FeedEmpty import com.vitorpamplona.amethyst.ui.screen.FeedError import com.vitorpamplona.amethyst.ui.screen.FeedState diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 8b39b9e70..585516526 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -193,6 +193,10 @@ val IconRowModifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizont val emptyLineItemModifier = Modifier.height(Size75dp).fillMaxWidth() +val imageHeaderBannerSize = Modifier.fillMaxWidth().height(150.dp) + +val authorNotePictureForImageHeader = Modifier.size(75.dp).padding(10.dp) + val normalNoteModifier = Modifier.fillMaxWidth() .padding( diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 41f4eb250..5c1111c69 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -60,6 +60,7 @@ import androidx.core.os.ConfigurationCompat import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.events.ImmutableListOfLists import kotlinx.coroutines.Dispatchers @@ -243,7 +244,7 @@ private fun TranslationMessage( } }, ) - Divider() + HorizontalDivider(thickness = DividerThickness) DropdownMenuItem( text = { if (accountViewModel.account.preferenceBetween(source, target) == source) { @@ -300,7 +301,7 @@ private fun TranslationMessage( } }, ) - Divider() + HorizontalDivider(thickness = DividerThickness) val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration) for (i in 0 until languageList.size()) {