mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
refactor: chat audit cleanup - shared action catalog and dedup
- New noteActionSections: the full note-action inventory (follow, copy, share, edit, broadcast, timestamp, pin, label, bookmarks, playlists, emoji packs, mute, delete/report) lives once, with all gating, and is rendered by BOTH the 3-dot NoteDropDownMenu (as M3 rows) and the chat long-press sheet (as icon tiles) - the two surfaces can no longer drift. The menu also gains the delete confirmation dialog and the privacy-safe private-rumor delete the sheet got in the audit fixes. - payViaIntentOrManualSplit: the shared zap-payment tail (wallet intent vs manual split screen), replacing three verbatim copies in ReactionsRow and one in the sheet. - Chat bubble shapes move to commons ChatTheme.kt as the single source of truth (18dp geometry incl. grouped variants); Desktop now inherits the modernized corners, and the stale 15dp duplicates are gone. - Deleted the unreferenced RenderCreateChannelNote / RenderChangeChannelMetadataNote card renderers (~360 lines). - ChatSystemMessage renders one Surface with a conditional clickable instead of two duplicated branches. - ChatEngagementDetailSheet rows share one EngagementRow scaffold; delivery tick selection deduped into DeliveryLadderTick / DeliveryStatusTick. - Hardcoded chat font sizes replaced with Font12SP / named constants. Not merged on purpose: ChipReactionGlyph vs MultiSetCompose's gallery glyph dispatch - the gallery variant handles interactive secret emoji and alignment modifiers, so unification would change behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
This commit is contained in:
@@ -1153,6 +1153,31 @@ private data class OnchainZapRequest(
|
||||
val amountSats: Long?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pays a single payable via a wallet intent, or routes multiple payables (zap
|
||||
* splits) to the manual payment screen. The shared tail of every zap flow.
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun payViaIntentOrManualSplit(
|
||||
payables: ImmutableList<ZapPaymentHandler.Payable>,
|
||||
context: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onPaymentError: () -> Unit = {},
|
||||
) {
|
||||
if (payables.size == 1) {
|
||||
val payable = payables.first()
|
||||
payViaIntent(payable.invoice, context, { }) { error ->
|
||||
onPaymentError()
|
||||
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
|
||||
}
|
||||
} else {
|
||||
val uid = Uuid.random().toString()
|
||||
accountViewModel.tempManualPaymentCache.put(uid, payables)
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalUuidApi::class)
|
||||
fun ZapReaction(
|
||||
@@ -1204,17 +1229,7 @@ fun ZapReaction(
|
||||
}
|
||||
},
|
||||
onPayViaIntent = {
|
||||
if (it.size == 1) {
|
||||
val payable = it.first()
|
||||
payViaIntent(payable.invoice, context, { }) { error ->
|
||||
zappingProgress = 0f
|
||||
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
|
||||
}
|
||||
} else {
|
||||
val uid = Uuid.random().toString()
|
||||
accountViewModel.tempManualPaymentCache.put(uid, it)
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
payViaIntentOrManualSplit(it, context, accountViewModel, nav, onPaymentError = { zappingProgress = 0f })
|
||||
},
|
||||
onCustomAmount = {
|
||||
wantsToSetCustomZap = true
|
||||
@@ -1260,17 +1275,7 @@ fun ZapReaction(
|
||||
},
|
||||
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
|
||||
onPayViaIntent = {
|
||||
if (it.size == 1) {
|
||||
val payable = it.first()
|
||||
payViaIntent(payable.invoice, context, { }) { error ->
|
||||
zappingProgress = 0f
|
||||
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
|
||||
}
|
||||
} else {
|
||||
val uid = Uuid.random().toString()
|
||||
accountViewModel.tempManualPaymentCache.put(uid, it)
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
payViaIntentOrManualSplit(it, context, accountViewModel, nav, onPaymentError = { zappingProgress = 0f })
|
||||
},
|
||||
onReloadNutzap = { amount ->
|
||||
wantsToZap = false
|
||||
|
||||
+71
-248
@@ -26,43 +26,33 @@ import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
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.LocalClipboard
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
|
||||
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun MoreOptionsButton(
|
||||
@@ -114,6 +104,7 @@ fun NoteDropDownMenu(
|
||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||
var addLabelDialogShowing by remember { mutableStateOf(false) }
|
||||
var showShareSheet by remember { mutableStateOf(false) }
|
||||
var deleteConfirmationShowing by remember { mutableStateOf(false) }
|
||||
|
||||
// Tapping "Share" hands the note's share options off to the shared Share
|
||||
// drawer (ShareOptionsBottomSheet). We render it INSTEAD of the menu dialog
|
||||
@@ -167,247 +158,79 @@ fun NoteDropDownMenu(
|
||||
)
|
||||
}
|
||||
|
||||
// Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped
|
||||
// deletion — a public NIP-09 would e-tag the rumor id onto public relays.
|
||||
val performDelete = {
|
||||
if (note.isPrivateRumor()) {
|
||||
accountViewModel.deletePrivately(note)
|
||||
} else {
|
||||
accountViewModel.delete(note)
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteConfirmationShowing) {
|
||||
QuickActionAlertDialog(
|
||||
title = stringRes(R.string.quick_action_request_deletion_alert_title),
|
||||
textContent = stringRes(R.string.quick_action_request_deletion_alert_body),
|
||||
buttonIcon = MaterialSymbols.Delete,
|
||||
buttonText = stringRes(R.string.quick_action_delete_dialog_btn),
|
||||
onClickDoOnce = {
|
||||
performDelete()
|
||||
onDismiss()
|
||||
},
|
||||
onClickDontShowAgain = {
|
||||
performDelete()
|
||||
accountViewModel.account.settings.setHideDeleteRequestDialog()
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = { deleteConfirmationShowing = false },
|
||||
)
|
||||
}
|
||||
|
||||
val handlers =
|
||||
NoteActionHandlers(
|
||||
onShare = { showShareSheet = true },
|
||||
onEditPost = { wantsToEditPost.value = true },
|
||||
onEditDraft = { nav.nav { routeEditDraftTo(note, accountViewModel.account) } },
|
||||
onAddLabel = { addLabelDialogShowing = true },
|
||||
onReport = { reportDialogShowing = true },
|
||||
onDeleteRequest = {
|
||||
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
|
||||
performDelete()
|
||||
onDismiss()
|
||||
} else {
|
||||
deleteConfirmationShowing = true
|
||||
}
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
||||
// "Copy Text" copies the newest version of a versioned post when the caller
|
||||
// is looking at one.
|
||||
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
|
||||
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.note_actions_dialog_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Unsealed rumors (private replies/posts received in gift wraps) are
|
||||
// unsigned and must never be referenced by a public event: hide every
|
||||
// action that would publish an e-tag of this note (broadcast, edit,
|
||||
// OTS timestamp, pin, label, public bookmark, deletion request).
|
||||
val isPrivateRumor = note.isPrivateRumor()
|
||||
|
||||
// Follow section
|
||||
M3ActionSection {
|
||||
if (!state.isFollowingAuthor) {
|
||||
M3ActionRow(icon = MaterialSymbols.PersonAdd, text = stringRes(R.string.follow)) {
|
||||
val author = note.author ?: return@M3ActionRow
|
||||
accountViewModel.follow(author)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = MaterialSymbols.PersonRemove, text = stringRes(R.string.unfollow)) {
|
||||
val author = note.author ?: return@M3ActionRow
|
||||
accountViewModel.unfollow(author)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
M3ActionRow(icon = MaterialSymbols.AutoMirrored.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) {
|
||||
val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow
|
||||
nav.nav(Route.PeopleListManagement(authorHexKey))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// Copy & Share section. The copy-to-clipboard rows live here (and only
|
||||
// here); the "Share" row hands off to the shared Share drawer.
|
||||
M3ActionSection {
|
||||
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) {
|
||||
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
|
||||
accountViewModel.decrypt(lastNoteVersion) {
|
||||
scope.launch {
|
||||
clipboardManager.setText(it)
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
|
||||
note.author?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText("nostr:${it.pubkeyNpub()}")
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_note_id)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(note.toNostrUri())
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_raw_json)) {
|
||||
val event = note.event
|
||||
if (event != null) {
|
||||
scope.launch {
|
||||
val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) }
|
||||
clipboardManager.setText(json)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
if (!isPrivateRumor) {
|
||||
M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) {
|
||||
showShareSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit & Broadcast section
|
||||
M3ActionSection {
|
||||
if (state.isLoggedUser && note.isDraft()) {
|
||||
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_draft)) {
|
||||
nav.nav { routeEditDraftTo(note, accountViewModel.account) }
|
||||
}
|
||||
}
|
||||
if (!note.isDraft() && !isPrivateRumor) {
|
||||
if (note.event is TextNoteEvent) {
|
||||
if (state.isLoggedUser) {
|
||||
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_post)) {
|
||||
wantsToEditPost.value = true
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.propose_an_edit)) {
|
||||
wantsToEditPost.value = true
|
||||
}
|
||||
}
|
||||
} else if (note.event is LongTextNoteEvent && state.isLoggedUser) {
|
||||
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_article)) {
|
||||
nav.nav { Route.NewLongFormPost(version = note.idHex) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rumors are rebroadcast as their delivering gift wrap; hidden
|
||||
// when the wrap is unknown (the unsigned rumor must never be
|
||||
// published).
|
||||
if (accountViewModel.canBroadcast(note)) {
|
||||
M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) {
|
||||
accountViewModel.broadcast(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp & Bookmarks section
|
||||
M3ActionSection {
|
||||
if (!isPrivateRumor) {
|
||||
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
|
||||
M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() }
|
||||
} else {
|
||||
M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_it)) {
|
||||
accountViewModel.timestamp(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.isLoggedUser && !isPrivateRumor) {
|
||||
if (state.isPinnedNote) {
|
||||
M3ActionRow(icon = MaterialSymbols.PushPin, text = stringRes(R.string.unpin_from_profile)) {
|
||||
accountViewModel.removePin(note)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = MaterialSymbols.PushPin, text = stringRes(R.string.pin_to_profile)) {
|
||||
accountViewModel.addPin(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isPrivateRumor) {
|
||||
M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) {
|
||||
addLabelDialogShowing = true
|
||||
}
|
||||
}
|
||||
// Pick exactly one curation flow per kind: music tracks go to playlists, emoji
|
||||
// packs go to the emoji list, everything else gets the standard bookmark rows.
|
||||
// Showing both at once is noisy and makes "bookmark" feel like the catch-all when
|
||||
// it really isn't for these kinds.
|
||||
when {
|
||||
isPrivateRumor -> {
|
||||
// No bookmark/playlist/emoji-list rows for private rumors:
|
||||
// those lists reference the note by id, which other devices
|
||||
// can't resolve from relays and public lists would leak.
|
||||
}
|
||||
|
||||
note.event is MusicTrackEvent && note is AddressableNote -> {
|
||||
// Music tracks (kind 36787) belong in playlists (kind 34139). The
|
||||
// sheet behind this nav lets the user toggle membership across all of
|
||||
// their own playlists in one place — that subsumes the private/public
|
||||
// bookmark add+remove pair for this kind.
|
||||
M3ActionRow(icon = MaterialSymbols.AutoMirrored.PlaylistAdd, text = stringRes(R.string.add_to_music_playlist)) {
|
||||
nav.nav(Route.AddToMusicPlaylist(note.address.toValue()))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
note.event is EmojiPackEvent -> {
|
||||
// Emoji packs belong in the user's emoji list (kind 10030), not the
|
||||
// bookmark list.
|
||||
val emojiText =
|
||||
if (state.isEmojiPackInMyList) {
|
||||
stringRes(R.string.remove_from_emoji_list)
|
||||
} else {
|
||||
stringRes(R.string.add_to_emoji_list)
|
||||
}
|
||||
M3ActionRow(icon = MaterialSymbols.EmojiEmotions, text = emojiText) {
|
||||
val address = (note as AddressableNote).address
|
||||
nav.nav(Route.EmojiPackSelection(kind = EmojiPackEvent.KIND, pubKeyHex = address.pubKeyHex, dTag = address.dTag))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
|
||||
M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
|
||||
if (note.event is LongTextNoteEvent) {
|
||||
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
|
||||
} else {
|
||||
nav.nav(Route.PostBookmarkManagement(note.idHex))
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
M3ActionRow(icon = MaterialSymbols.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) {
|
||||
accountViewModel.removePrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = MaterialSymbols.Lock, text = stringRes(R.string.add_to_private_bookmarks)) {
|
||||
accountViewModel.addPrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
if (state.isPublicBookmarkNote) {
|
||||
M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) {
|
||||
accountViewModel.removePublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) {
|
||||
accountViewModel.addPublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Moderation section
|
||||
M3ActionSection {
|
||||
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
|
||||
M3ActionRow(
|
||||
icon = MaterialSymbols.AutoMirrored.VolumeOff,
|
||||
text = stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
|
||||
) {
|
||||
if (isThreadMuted) {
|
||||
accountViewModel.unmuteThread(note)
|
||||
} else {
|
||||
accountViewModel.muteThread(note)
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
if (state.isLoggedUser && !isPrivateRumor) {
|
||||
M3ActionRow(icon = MaterialSymbols.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
M3ActionRow(icon = MaterialSymbols.Report, text = stringRes(R.string.block_report), isDestructive = true) {
|
||||
reportDialogShowing = true
|
||||
// The action inventory is shared with the chat long-press sheet
|
||||
// (noteActionSections), so the two surfaces cannot drift.
|
||||
noteActionSections(
|
||||
note = note,
|
||||
noteVersionToCopy = lastNoteVersion,
|
||||
state = state,
|
||||
handlers = handlers,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
).forEach { section ->
|
||||
M3ActionSection {
|
||||
section.forEach { action ->
|
||||
M3ActionRow(
|
||||
icon = action.symbol,
|
||||
text = action.label,
|
||||
isDestructive = action.isDestructive,
|
||||
onClick = action.onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** One note action, rendered as a menu row or an action-sheet tile. */
|
||||
@Immutable
|
||||
data class NoteAction(
|
||||
val symbol: MaterialSymbol,
|
||||
val label: String,
|
||||
val isDestructive: Boolean = false,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Callbacks for actions whose UI is owned by the rendering surface (dialogs,
|
||||
* sheets), so the shared inventory stays surface-agnostic.
|
||||
*/
|
||||
@Immutable
|
||||
data class NoteActionHandlers(
|
||||
val onShare: () -> Unit,
|
||||
val onEditPost: () -> Unit,
|
||||
val onEditDraft: () -> Unit,
|
||||
val onAddLabel: () -> Unit,
|
||||
val onReport: () -> Unit,
|
||||
val onDeleteRequest: () -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The shared note-action inventory behind both the 3-dot menu (NoteDropDownMenu)
|
||||
* and the chat long-press sheet: one list of sections, each a list of actions with
|
||||
* their visibility gating applied, so the two surfaces can never drift.
|
||||
*
|
||||
* [noteVersionToCopy] lets the menu copy the latest edit of a versioned post; every
|
||||
* other caller passes [note].
|
||||
*/
|
||||
@Composable
|
||||
fun noteActionSections(
|
||||
note: Note,
|
||||
noteVersionToCopy: Note,
|
||||
state: DropDownParams,
|
||||
handlers: NoteActionHandlers,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
): List<List<NoteAction>> {
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Unsealed rumors (private replies/posts received in gift wraps) are
|
||||
// unsigned and must never be referenced by a public event: hide every
|
||||
// action that would publish an e-tag of this note (broadcast, edit,
|
||||
// OTS timestamp, pin, label, public bookmark).
|
||||
val isPrivateRumor = note.isPrivateRumor()
|
||||
|
||||
val author =
|
||||
buildList {
|
||||
if (!state.isLoggedUser) {
|
||||
if (!state.isFollowingAuthor) {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.PersonAdd, stringRes(R.string.follow)) {
|
||||
note.author?.let { accountViewModel.follow(it) }
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.PersonRemove, stringRes(R.string.unfollow)) {
|
||||
note.author?.let { accountViewModel.unfollow(it) }
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
add(
|
||||
NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(R.string.follow_set_add_author_from_note_action)) {
|
||||
note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) }
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val copyAndShare =
|
||||
buildList {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) {
|
||||
accountViewModel.decrypt(noteVersionToCopy) {
|
||||
scope.launch { clipboardManager.setText(it) }
|
||||
}
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
add(
|
||||
NoteAction(MaterialSymbols.AlternateEmail, stringRes(R.string.copy_user_pubkey)) {
|
||||
note.author?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText("nostr:${it.pubkeyNpub()}")
|
||||
handlers.onDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
add(
|
||||
NoteAction(MaterialSymbols.FormatQuote, stringRes(R.string.copy_note_id)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(note.toNostrUri())
|
||||
handlers.onDismiss()
|
||||
}
|
||||
},
|
||||
)
|
||||
add(
|
||||
NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_raw_json)) {
|
||||
val event = note.event
|
||||
if (event != null) {
|
||||
scope.launch {
|
||||
val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) }
|
||||
clipboardManager.setText(json)
|
||||
handlers.onDismiss()
|
||||
}
|
||||
} else {
|
||||
handlers.onDismiss()
|
||||
}
|
||||
},
|
||||
)
|
||||
if (!isPrivateRumor) {
|
||||
add(NoteAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share), onClick = handlers.onShare))
|
||||
}
|
||||
}
|
||||
|
||||
val editAndBroadcast =
|
||||
buildList {
|
||||
if (state.isLoggedUser && note.isDraft()) {
|
||||
add(NoteAction(MaterialSymbols.Edit, stringRes(R.string.edit_draft), onClick = handlers.onEditDraft))
|
||||
}
|
||||
if (!note.isDraft() && !isPrivateRumor) {
|
||||
if (note.event is TextNoteEvent) {
|
||||
add(
|
||||
NoteAction(
|
||||
MaterialSymbols.Edit,
|
||||
stringRes(if (state.isLoggedUser) R.string.edit_post else R.string.propose_an_edit),
|
||||
onClick = handlers.onEditPost,
|
||||
),
|
||||
)
|
||||
} else if (note.event is LongTextNoteEvent && state.isLoggedUser) {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.Edit, stringRes(R.string.edit_article)) {
|
||||
nav.nav { Route.NewLongFormPost(version = note.idHex) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// Rumors are rebroadcast as their delivering gift wrap; hidden when the
|
||||
// wrap is unknown (the unsigned rumor must never be published).
|
||||
if (accountViewModel.canBroadcast(note)) {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.CellTower, stringRes(R.string.broadcast)) {
|
||||
accountViewModel.broadcast(note)
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val organize =
|
||||
buildList {
|
||||
if (!isPrivateRumor) {
|
||||
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
|
||||
add(NoteAction(MaterialSymbols.Schedule, stringRes(R.string.timestamp_pending)) { handlers.onDismiss() })
|
||||
} else {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.Schedule, stringRes(R.string.timestamp_it)) {
|
||||
accountViewModel.timestamp(note)
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.isLoggedUser && !isPrivateRumor) {
|
||||
add(
|
||||
NoteAction(
|
||||
MaterialSymbols.PushPin,
|
||||
stringRes(if (state.isPinnedNote) R.string.unpin_from_profile else R.string.pin_to_profile),
|
||||
) {
|
||||
if (state.isPinnedNote) {
|
||||
accountViewModel.removePin(note)
|
||||
} else {
|
||||
accountViewModel.addPin(note)
|
||||
}
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!isPrivateRumor) {
|
||||
add(NoteAction(MaterialSymbols.Tag, stringRes(R.string.add_hashtag_label), onClick = handlers.onAddLabel))
|
||||
}
|
||||
|
||||
// Pick exactly one curation flow per kind: music tracks go to playlists,
|
||||
// emoji packs go to the emoji list, everything else gets the standard
|
||||
// bookmark rows. No bookmark/playlist/emoji-list rows for private rumors:
|
||||
// those lists reference the note by id, which other devices can't resolve
|
||||
// from relays and public lists would leak.
|
||||
when {
|
||||
isPrivateRumor -> {}
|
||||
|
||||
note.event is MusicTrackEvent && note is AddressableNote -> {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(R.string.add_to_music_playlist)) {
|
||||
nav.nav(Route.AddToMusicPlaylist(note.address.toValue()))
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
note.event is EmojiPackEvent -> {
|
||||
val emojiText =
|
||||
if (state.isEmojiPackInMyList) {
|
||||
stringRes(R.string.remove_from_emoji_list)
|
||||
} else {
|
||||
stringRes(R.string.add_to_emoji_list)
|
||||
}
|
||||
add(
|
||||
NoteAction(MaterialSymbols.EmojiEmotions, emojiText) {
|
||||
val address = (note as AddressableNote).address
|
||||
nav.nav(Route.EmojiPackSelection(kind = EmojiPackEvent.KIND, pubKeyHex = address.pubKeyHex, dTag = address.dTag))
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
|
||||
add(
|
||||
NoteAction(MaterialSymbols.BookmarkAdd, stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
|
||||
if (note.event is LongTextNoteEvent) {
|
||||
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
|
||||
} else {
|
||||
nav.nav(Route.PostBookmarkManagement(note.idHex))
|
||||
}
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.LockOpen, stringRes(R.string.remove_from_private_bookmarks)) {
|
||||
accountViewModel.removePrivateBookmark(note)
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.Lock, stringRes(R.string.add_to_private_bookmarks)) {
|
||||
accountViewModel.addPrivateBookmark(note)
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.isPublicBookmarkNote) {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.BookmarkRemove, stringRes(R.string.remove_from_public_bookmarks)) {
|
||||
accountViewModel.removePublicBookmark(note)
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
NoteAction(MaterialSymbols.Bookmark, stringRes(R.string.add_to_public_bookmarks)) {
|
||||
accountViewModel.addPublicBookmark(note)
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val moderation =
|
||||
buildList {
|
||||
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
|
||||
add(
|
||||
NoteAction(
|
||||
MaterialSymbols.AutoMirrored.VolumeOff,
|
||||
stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
|
||||
) {
|
||||
if (isThreadMuted) {
|
||||
accountViewModel.unmuteThread(note)
|
||||
} else {
|
||||
accountViewModel.muteThread(note)
|
||||
}
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
|
||||
// Own messages always get a delete affordance (the surface routes private
|
||||
// rumors through the gift-wrapped deletion); reporting yourself never
|
||||
// makes sense, so Report is others-only.
|
||||
if (state.isLoggedUser) {
|
||||
add(NoteAction(MaterialSymbols.Delete, stringRes(R.string.request_deletion), isDestructive = true, onClick = handlers.onDeleteRequest))
|
||||
} else {
|
||||
add(NoteAction(MaterialSymbols.Report, stringRes(R.string.block_report), isDestructive = true, onClick = handlers.onReport))
|
||||
}
|
||||
}
|
||||
|
||||
return listOf(author, copyAndShare, editAndBroadcast, organize, moderation).filter { it.isNotEmpty() }
|
||||
}
|
||||
+50
-38
@@ -137,35 +137,16 @@ private fun ZapperRow(
|
||||
nav: INav,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
// Open the zap receipt in its own thread view (replies to the
|
||||
// zap itself); profile stays one tap away on the avatar. Falls
|
||||
// back to the zapper's profile while the receipt is unknown.
|
||||
val destination =
|
||||
zap.zapNote?.let { routeFor(it, accountViewModel.account) }
|
||||
?: zap.user?.let { routeFor(it) }
|
||||
|
||||
destination?.let {
|
||||
nav.nav(it)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
EngagementRow(
|
||||
user = zap.user,
|
||||
// The zap receipt's own thread view (replies to the zap itself);
|
||||
// falls back to the zapper's profile while the receipt is unknown.
|
||||
eventNote = zap.zapNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
zap.user?.let { user ->
|
||||
UserPicture(user, Size25dp, Modifier, accountViewModel, nav)
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
UsernameDisplay(baseUser = user, accountViewModel = accountViewModel)
|
||||
}
|
||||
} ?: Spacer(Modifier.weight(1f))
|
||||
|
||||
zap.amount?.let { amount ->
|
||||
Text(
|
||||
text = "⚡ $amount",
|
||||
@@ -193,6 +174,32 @@ private fun ReactorRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
EngagementRow(
|
||||
user = entry.user,
|
||||
eventNote = entry.reactionNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
ChipReactionGlyph(entry.reactionType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared engagement-list row: avatar + name + a trailing element. The row opens
|
||||
* the engagement event's own thread view (replies/reactions TO the zap or
|
||||
* reaction), falling back to the user's profile; the avatar keeps its built-in
|
||||
* one-tap profile navigation.
|
||||
*/
|
||||
@Composable
|
||||
private fun EngagementRow(
|
||||
user: User?,
|
||||
eventNote: Note?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onDismiss: () -> Unit,
|
||||
trailing: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -201,20 +208,25 @@ private fun ReactorRow(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
// Open the reaction event in its own thread view; the avatar
|
||||
// still navigates to the profile.
|
||||
val destination =
|
||||
routeFor(entry.reactionNote, accountViewModel.account)
|
||||
?: routeFor(entry.user)
|
||||
eventNote?.let { routeFor(it, accountViewModel.account) }
|
||||
?: user?.let { routeFor(it) }
|
||||
|
||||
nav.nav(destination)
|
||||
onDismiss()
|
||||
destination?.let {
|
||||
nav.nav(it)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
) {
|
||||
UserPicture(entry.user, Size25dp, Modifier, accountViewModel, nav)
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
UsernameDisplay(baseUser = entry.user, accountViewModel = accountViewModel)
|
||||
if (user != null) {
|
||||
UserPicture(user, Size25dp, Modifier, accountViewModel, nav)
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
UsernameDisplay(baseUser = user, accountViewModel = accountViewModel)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
ChipReactionGlyph(entry.reactionType)
|
||||
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
+54
-261
@@ -46,12 +46,10 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -67,8 +65,6 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ChangeReactionIcon
|
||||
@@ -77,10 +73,12 @@ import com.vitorpamplona.amethyst.ui.note.RenderReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.AddHashtagLabelDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.NoteActionHandlers
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ShareOptionsBottomSheet
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.noteActionSections
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.observeBookmarksFollowsAndAccount
|
||||
import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
|
||||
@@ -93,16 +91,10 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.reactionBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
// null amount = open the on-chain dialog with no prefill.
|
||||
@Immutable
|
||||
@@ -112,12 +104,9 @@ private data class OnchainZapRequest(
|
||||
|
||||
/**
|
||||
* Long-press surface for a chat message: a quick-reaction row and the unpacked
|
||||
* zap amount presets on top, then every note action the 3-dot menu offers,
|
||||
* grouped by similarity into rows of icon tiles (message, author, organize,
|
||||
* moderation).
|
||||
*
|
||||
* The music-playlist and emoji-pack curation specials from the 3-dot menu are
|
||||
* intentionally absent: those kinds never render in a chat feed.
|
||||
* zap amount presets on top, chat-only tiles (reply, edit draft), then the shared
|
||||
* note-action inventory (noteActionSections — the same one behind the 3-dot menu,
|
||||
* so the two surfaces can never drift) rendered as rows of icon tiles.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -248,267 +237,80 @@ fun ChatMessageActionSheet(
|
||||
SectionDivider()
|
||||
}
|
||||
|
||||
MessageSection(note, state, onWantsToReply, onWantsToEditDraft, onEditPost = { wantsToEditPost = true }, onShare = { showShareSheet = true }, onDismiss, accountViewModel)
|
||||
// Chat-only affordances first, then the shared note-action inventory
|
||||
// (the same one behind the 3-dot menu, so the two surfaces never drift).
|
||||
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
|
||||
|
||||
SectionDivider()
|
||||
|
||||
AuthorSection(note, state, onDismiss, accountViewModel, nav)
|
||||
|
||||
if (!note.isPrivateRumor()) {
|
||||
SectionDivider()
|
||||
OrganizeSection(note, state, onAddLabel = { addLabelDialogShowing = true }, onDismiss, accountViewModel, nav)
|
||||
}
|
||||
|
||||
SectionDivider()
|
||||
|
||||
ModerationSection(
|
||||
note = note,
|
||||
state = state,
|
||||
onReport = { reportDialogShowing = true },
|
||||
onDeleteRequest = {
|
||||
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
|
||||
performDelete()
|
||||
val handlers =
|
||||
NoteActionHandlers(
|
||||
onShare = { showShareSheet = true },
|
||||
onEditPost = { wantsToEditPost = true },
|
||||
onEditDraft = {
|
||||
onWantsToEditDraft(note)
|
||||
onDismiss()
|
||||
} else {
|
||||
deleteConfirmationShowing = true
|
||||
}
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
},
|
||||
onAddLabel = { addLabelDialogShowing = true },
|
||||
onReport = { reportDialogShowing = true },
|
||||
onDeleteRequest = {
|
||||
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
|
||||
performDelete()
|
||||
onDismiss()
|
||||
} else {
|
||||
deleteConfirmationShowing = true
|
||||
}
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
||||
noteActionSections(
|
||||
note = note,
|
||||
noteVersionToCopy = note,
|
||||
state = state,
|
||||
handlers = handlers,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
nav = nav,
|
||||
).forEach { section ->
|
||||
SectionDivider()
|
||||
TileRow {
|
||||
section.forEach { action ->
|
||||
ActionTile(action.symbol, action.label, action.isDestructive, action.onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Action tile sections (the unpacked 3-dot menu) ----------
|
||||
// ---------- Action tile sections ----------
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalUuidApi::class)
|
||||
/** Chat-specific tiles (reply, edit draft) that have no 3-dot menu equivalent. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun MessageSection(
|
||||
private fun ChatOnlyRow(
|
||||
note: Note,
|
||||
state: DropDownParams,
|
||||
onWantsToReply: (Note) -> Unit,
|
||||
onWantsToEditDraft: (Note) -> Unit,
|
||||
onEditPost: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isPrivateRumor = note.isPrivateRumor()
|
||||
|
||||
TileRow {
|
||||
if (!note.isDraft()) {
|
||||
ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(R.string.reply_description)) {
|
||||
onWantsToReply(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoggedUser && note.isDraft()) {
|
||||
if (note.isDraft() && !state.isLoggedUser) return
|
||||
if (note.isDraft() && state.isLoggedUser) {
|
||||
TileRow {
|
||||
ActionTile(MaterialSymbols.Edit, stringRes(R.string.edit_draft)) {
|
||||
onWantsToEditDraft(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (!note.isDraft() && !isPrivateRumor && note.event is TextNoteEvent) {
|
||||
ActionTile(
|
||||
MaterialSymbols.Edit,
|
||||
stringRes(if (state.isLoggedUser) R.string.edit_post else R.string.propose_an_edit),
|
||||
onClick = onEditPost,
|
||||
)
|
||||
}
|
||||
|
||||
ActionTile(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) {
|
||||
accountViewModel.decrypt(note) {
|
||||
scope.launch { clipboardManager.setText(it) }
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
ActionTile(MaterialSymbols.FormatQuote, stringRes(R.string.copy_note_id)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(note.toNostrUri())
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ActionTile(MaterialSymbols.ContentCopy, stringRes(R.string.copy_raw_json)) {
|
||||
val event = note.event
|
||||
if (event != null) {
|
||||
scope.launch {
|
||||
val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) }
|
||||
clipboardManager.setText(json)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPrivateRumor) {
|
||||
ActionTile(MaterialSymbols.Share, stringRes(R.string.quick_action_share), onClick = onShare)
|
||||
}
|
||||
|
||||
// Rumors are rebroadcast as their delivering gift wrap; hidden when the
|
||||
// wrap is unknown (the unsigned rumor must never be published).
|
||||
if (accountViewModel.canBroadcast(note)) {
|
||||
ActionTile(MaterialSymbols.CellTower, stringRes(R.string.broadcast)) {
|
||||
accountViewModel.broadcast(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun AuthorSection(
|
||||
note: Note,
|
||||
state: DropDownParams,
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
TileRow {
|
||||
if (!state.isLoggedUser) {
|
||||
if (!state.isFollowingAuthor) {
|
||||
ActionTile(MaterialSymbols.PersonAdd, stringRes(R.string.follow)) {
|
||||
note.author?.let { accountViewModel.follow(it) }
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
ActionTile(MaterialSymbols.PersonRemove, stringRes(R.string.unfollow)) {
|
||||
note.author?.let { accountViewModel.unfollow(it) }
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionTile(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(R.string.follow_set_add_author_from_note_action)) {
|
||||
note.author?.pubkeyHex?.let {
|
||||
nav.nav(Route.PeopleListManagement(it))
|
||||
}
|
||||
ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(R.string.reply_description)) {
|
||||
onWantsToReply(note)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
ActionTile(MaterialSymbols.AlternateEmail, stringRes(R.string.copy_user_pubkey)) {
|
||||
note.author?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText("nostr:${it.pubkeyNpub()}")
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun OrganizeSection(
|
||||
note: Note,
|
||||
state: DropDownParams,
|
||||
onAddLabel: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
TileRow {
|
||||
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
|
||||
ActionTile(MaterialSymbols.Schedule, stringRes(R.string.timestamp_pending)) { onDismiss() }
|
||||
} else {
|
||||
ActionTile(MaterialSymbols.Schedule, stringRes(R.string.timestamp_it)) {
|
||||
accountViewModel.timestamp(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoggedUser) {
|
||||
ActionTile(
|
||||
MaterialSymbols.PushPin,
|
||||
stringRes(if (state.isPinnedNote) R.string.unpin_from_profile else R.string.pin_to_profile),
|
||||
) {
|
||||
if (state.isPinnedNote) {
|
||||
accountViewModel.removePin(note)
|
||||
} else {
|
||||
accountViewModel.addPin(note)
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ActionTile(MaterialSymbols.Tag, stringRes(R.string.add_hashtag_label), onClick = onAddLabel)
|
||||
|
||||
ActionTile(MaterialSymbols.BookmarkAdd, stringRes(R.string.manage_bookmark_label, stringRes(R.string.post))) {
|
||||
nav.nav(Route.PostBookmarkManagement(note.idHex))
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
ActionTile(MaterialSymbols.LockOpen, stringRes(R.string.remove_from_private_bookmarks)) {
|
||||
accountViewModel.removePrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
ActionTile(MaterialSymbols.Lock, stringRes(R.string.add_to_private_bookmarks)) {
|
||||
accountViewModel.addPrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isPublicBookmarkNote) {
|
||||
ActionTile(MaterialSymbols.BookmarkRemove, stringRes(R.string.remove_from_public_bookmarks)) {
|
||||
accountViewModel.removePublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
ActionTile(MaterialSymbols.Bookmark, stringRes(R.string.add_to_public_bookmarks)) {
|
||||
accountViewModel.addPublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ModerationSection(
|
||||
note: Note,
|
||||
state: DropDownParams,
|
||||
onReport: () -> Unit,
|
||||
onDeleteRequest: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
TileRow {
|
||||
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
|
||||
ActionTile(
|
||||
MaterialSymbols.AutoMirrored.VolumeOff,
|
||||
stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
|
||||
) {
|
||||
if (isThreadMuted) {
|
||||
accountViewModel.unmuteThread(note)
|
||||
} else {
|
||||
accountViewModel.muteThread(note)
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
// Own messages always get a delete affordance (the private-rumor variant
|
||||
// goes through the gift-wrapped deletion); reporting yourself never
|
||||
// makes sense, so Report is others-only.
|
||||
if (state.isLoggedUser) {
|
||||
ActionTile(MaterialSymbols.Delete, stringRes(R.string.request_deletion), isDestructive = true, onClick = onDeleteRequest)
|
||||
} else {
|
||||
ActionTile(MaterialSymbols.Report, stringRes(R.string.block_report), isDestructive = true, onClick = onReport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,16 +461,7 @@ private fun QuickZapAmountRow(
|
||||
}
|
||||
|
||||
val onPayViaIntent = { payables: ImmutableList<ZapPaymentHandler.Payable> ->
|
||||
if (payables.size == 1) {
|
||||
val payable = payables.first()
|
||||
payViaIntent(payable.invoice, context, { }) { error ->
|
||||
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
|
||||
}
|
||||
} else {
|
||||
val uid = Uuid.random().toString()
|
||||
accountViewModel.tempManualPaymentCache.put(uid, payables)
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
payViaIntentOrManualSplit(payables, context, accountViewModel, nav)
|
||||
}
|
||||
|
||||
Row(
|
||||
|
||||
+9
-6
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.note.ObserveZapAmountText
|
||||
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
@@ -72,6 +73,8 @@ private data class ReactionChip(
|
||||
val includesMe: Boolean,
|
||||
)
|
||||
|
||||
private val ChipGlyphFontSize = 13.sp
|
||||
|
||||
/**
|
||||
* Messenger-style pills under a chat bubble summarizing who engaged with the message:
|
||||
* one chip per reaction emoji (with count, highlighted when the logged-in user is
|
||||
@@ -194,7 +197,7 @@ private fun ReactionChipView(
|
||||
if (chip.count > 1) {
|
||||
Text(
|
||||
text = chip.count.toString(),
|
||||
fontSize = 12.sp,
|
||||
fontSize = Font12SP,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
@@ -219,7 +222,7 @@ private fun ZapChip(
|
||||
ZappedIcon(Size14Modifier)
|
||||
Text(
|
||||
text = amount,
|
||||
fontSize = 12.sp,
|
||||
fontSize = Font12SP,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
@@ -246,18 +249,18 @@ internal fun ChipReactionGlyph(reactionType: String) {
|
||||
InLineIconRenderer(
|
||||
persistentListOf(CustomEmoji.ImageUrlType(url)),
|
||||
style = SpanStyle(color = MaterialTheme.colorScheme.onBackground),
|
||||
fontSize = 13.sp,
|
||||
fontSize = ChipGlyphFontSize,
|
||||
maxLines = 1,
|
||||
)
|
||||
} else {
|
||||
when (reactionType) {
|
||||
"+" -> LikedIcon(Size14Modifier)
|
||||
"-" -> Text(text = "👎", fontSize = 13.sp, maxLines = 1)
|
||||
"-" -> Text(text = "👎", fontSize = ChipGlyphFontSize, maxLines = 1)
|
||||
else -> {
|
||||
if (EmojiCoder.isCoded(reactionType)) {
|
||||
AnimatedBorderTextCornerRadius(reactionType, fontSize = 13.sp)
|
||||
AnimatedBorderTextCornerRadius(reactionType, fontSize = ChipGlyphFontSize)
|
||||
} else {
|
||||
Text(text = reactionType, fontSize = 13.sp, maxLines = 1)
|
||||
Text(text = reactionType, fontSize = ChipGlyphFontSize, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -25,16 +25,16 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMe
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeBottom
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeMiddle
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeTop
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThem
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemBottom
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemMiddle
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemTop
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.note.dateFormatter
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMeBottom
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMeMiddle
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMeTop
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThemBottom
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThemMiddle
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThemTop
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
|
||||
+13
-15
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -29,6 +30,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -54,21 +56,17 @@ fun ChatSystemMessage(
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (onClick != null) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = ButtonBorder,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
SystemMessageText(text)
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
shape = ButtonBorder,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
SystemMessageText(text)
|
||||
}
|
||||
Surface(
|
||||
shape = ButtonBorder,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier =
|
||||
if (onClick != null) {
|
||||
Modifier.clip(ButtonBorder).clickable(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
SystemMessageText(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
|
||||
@Composable
|
||||
fun RenderChangeChannelMetadataNote(
|
||||
note: Note,
|
||||
bgColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = note.event as? ChannelMetadataEvent ?: return
|
||||
val channelInfo = remember(noteEvent) { noteEvent.channelInfo() }
|
||||
val tags =
|
||||
remember(noteEvent) {
|
||||
noteEvent.tags.toImmutableListOfLists()
|
||||
}
|
||||
|
||||
RenderChannelData(
|
||||
noteEvent.id,
|
||||
note.toNostrUri(),
|
||||
channelInfo,
|
||||
tags,
|
||||
bgColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
-307
@@ -1,307 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.rememberCoroutineScope
|
||||
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.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RenderCreateChannelNote(
|
||||
note: Note,
|
||||
bgColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = note.event as? ChannelCreateEvent ?: return
|
||||
val channelInfo = remember(noteEvent) { noteEvent.channelInfo() }
|
||||
val tags =
|
||||
remember(noteEvent) {
|
||||
noteEvent.tags.toImmutableListOfLists()
|
||||
}
|
||||
|
||||
RenderChannelData(
|
||||
noteEvent.id,
|
||||
note.toNostrUri(),
|
||||
channelInfo,
|
||||
tags,
|
||||
bgColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderChannelDataPreview() {
|
||||
ThemeComparisonRow {
|
||||
RenderChannelData(
|
||||
id = "bbaacc",
|
||||
uri = "nostr:nevent1...",
|
||||
channelInfo =
|
||||
ChannelDataNorm(
|
||||
"My Group",
|
||||
"Testing About me",
|
||||
"http://test.com",
|
||||
listOf(Constants.mom, Constants.nos),
|
||||
),
|
||||
tags = EmptyTagList,
|
||||
bgColor = remember { mutableStateOf(Color.Transparent) },
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderChannelData(
|
||||
id: HexKey,
|
||||
uri: String,
|
||||
channelInfo: ChannelDataNorm,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
bgColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Column {
|
||||
Row {
|
||||
TranslatableRichTextViewer(
|
||||
content = stringRes(R.string.changed_chat_profile_to),
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier,
|
||||
tags = tags,
|
||||
backgroundColor = bgColor,
|
||||
id = id,
|
||||
callbackUri = uri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
channelInfo.picture?.let {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = id,
|
||||
model = it,
|
||||
contentDescription = stringRes(R.string.channel_image),
|
||||
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif =
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
channelInfo.name?.let {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
) {
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
channelInfo.about?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier,
|
||||
tags = tags,
|
||||
backgroundColor = bgColor,
|
||||
id = id,
|
||||
callbackUri = uri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
channelInfo.relays?.let {
|
||||
Text(
|
||||
stringRes(R.string.public_chat_relays_title) + ": ",
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
)
|
||||
it.forEach {
|
||||
Spacer(StdVertSpacer)
|
||||
RenderRelayLinePublicChat(
|
||||
it,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderRelayLinePreview() {
|
||||
ThemeComparisonRow {
|
||||
RenderRelayLine(
|
||||
"wss://nos.lol",
|
||||
"http://icon.com/icon.ico",
|
||||
Modifier,
|
||||
showPicture = true,
|
||||
loadRobohash = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun RenderRelayLinePublicChat(
|
||||
relay: NormalizedRelayUrl,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val relayInfo by loadRelayInfo(relay)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val clickableModifier =
|
||||
remember(relay) {
|
||||
Modifier.combinedClickable(
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
clipboardManager.setText(relay.url)
|
||||
}
|
||||
},
|
||||
onClick = { nav.nav(Route.RelayInfo(relay.url)) },
|
||||
)
|
||||
}
|
||||
|
||||
RenderRelayLine(
|
||||
relay.displayUrl(),
|
||||
relayInfo.icon,
|
||||
clickableModifier,
|
||||
showPicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderRelayLine(
|
||||
url: String,
|
||||
icon: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
showPicture: Boolean = true,
|
||||
loadRobohash: Boolean = true,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(" -")
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = url,
|
||||
model = icon,
|
||||
contentDescription = stringRes(id = R.string.relay_info, url),
|
||||
colorFilter = RelayIconFilter,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Size20dp)
|
||||
.clip(shape = CircleShape),
|
||||
loadProfilePicture = showPicture,
|
||||
loadRobohash = loadRobohash,
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Text(
|
||||
text = url,
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -39,6 +39,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
// Jumbo sizes step down as the emoji count grows so up to three still fit a line.
|
||||
private val JumboEmojiSingle = 50.sp
|
||||
private val JumboEmojiPair = 40.sp
|
||||
private val JumboEmojiTriple = 32.sp
|
||||
|
||||
@Composable
|
||||
fun RenderRegularTextNote(
|
||||
note: Note,
|
||||
@@ -63,9 +68,9 @@ fun RenderRegularTextNote(
|
||||
text = eventContent.trim(),
|
||||
fontSize =
|
||||
when (jumboCount) {
|
||||
1 -> 50.sp
|
||||
2 -> 40.sp
|
||||
else -> 32.sp
|
||||
1 -> JumboEmojiSingle
|
||||
2 -> JumboEmojiPair
|
||||
else -> JumboEmojiTriple
|
||||
},
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -78,19 +78,9 @@ val ButtonBorder = RoundedCornerShape(20.dp)
|
||||
val LeftHalfCircleButtonBorder = ButtonBorder.copy(topEnd = CornerSize(0f), bottomEnd = CornerSize(0f))
|
||||
val EditFieldBorder = RoundedCornerShape(25.dp)
|
||||
|
||||
// Chat bubble corners: the small 4dp corner is the "tail" pointing at the author's
|
||||
// side. A grouped run reads as one continuous unit: full rounding only at the very
|
||||
// top of the group and the very bottom, every edge that touches a neighboring
|
||||
// message of the same run stays sharp (Top = visually first / oldest, Bottom =
|
||||
// visually last).
|
||||
val ChatBubbleShapeMe = RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp)
|
||||
val ChatBubbleShapeMeTop = RoundedCornerShape(18.dp, 18.dp, 6.dp, 6.dp)
|
||||
val ChatBubbleShapeMeMiddle = RoundedCornerShape(6.dp, 6.dp, 6.dp, 6.dp)
|
||||
val ChatBubbleShapeMeBottom = RoundedCornerShape(6.dp, 6.dp, 4.dp, 18.dp)
|
||||
val ChatBubbleShapeThem = RoundedCornerShape(4.dp, 18.dp, 18.dp, 18.dp)
|
||||
val ChatBubbleShapeThemTop = RoundedCornerShape(4.dp, 18.dp, 6.dp, 6.dp)
|
||||
val ChatBubbleShapeThemMiddle = RoundedCornerShape(6.dp, 6.dp, 6.dp, 6.dp)
|
||||
val ChatBubbleShapeThemBottom = RoundedCornerShape(6.dp, 6.dp, 18.dp, 18.dp)
|
||||
// Chat bubble shapes live in commons (ChatTheme.kt) so Android and Desktop share
|
||||
// one geometry: see ChatBubbleShapeMe/Them and their grouped Top/Middle/Bottom
|
||||
// variants there.
|
||||
|
||||
val StdButtonSizeModifier = Modifier.size(19.dp)
|
||||
|
||||
|
||||
+13
-3
@@ -34,9 +34,19 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// Chat bubble shapes
|
||||
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
|
||||
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
|
||||
// Chat bubble corners — the single source of truth for every front end. The
|
||||
// small 4dp corner is the "tail" pointing at the author's side. A grouped run
|
||||
// reads as one continuous unit: full rounding only at the very top and bottom
|
||||
// of the group, edges that touch a neighboring message of the same run stay
|
||||
// sharp (Top = visually first / oldest, Bottom = visually last).
|
||||
val ChatBubbleShapeMe = RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp)
|
||||
val ChatBubbleShapeMeTop = RoundedCornerShape(18.dp, 18.dp, 6.dp, 6.dp)
|
||||
val ChatBubbleShapeMeMiddle = RoundedCornerShape(6.dp, 6.dp, 6.dp, 6.dp)
|
||||
val ChatBubbleShapeMeBottom = RoundedCornerShape(6.dp, 6.dp, 4.dp, 18.dp)
|
||||
val ChatBubbleShapeThem = RoundedCornerShape(4.dp, 18.dp, 18.dp, 18.dp)
|
||||
val ChatBubbleShapeThemTop = RoundedCornerShape(4.dp, 18.dp, 6.dp, 6.dp)
|
||||
val ChatBubbleShapeThemMiddle = RoundedCornerShape(6.dp, 6.dp, 6.dp, 6.dp)
|
||||
val ChatBubbleShapeThemBottom = RoundedCornerShape(6.dp, 6.dp, 18.dp, 18.dp)
|
||||
|
||||
// Chat bubble modifiers
|
||||
val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f)
|
||||
|
||||
Reference in New Issue
Block a user