Merge pull request #4185 from vitorpamplona/claude/sharp-keller-7itny0

refactor: Render/Display split, batches 1–3 — shared displays for AccountViewModel-bound composables
This commit is contained in:
Vitor Pamplona
2026-09-23 18:04:53 -04:00
committed by GitHub
49 changed files with 1962 additions and 1591 deletions
@@ -30,7 +30,7 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
import com.vitorpamplona.amethyst.commons.ui.components.DeferredCrossfade
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -20,25 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.collection.mutableScatterMapOf
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.util.fastForEach
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -50,116 +37,12 @@ fun <T> CrossfadeIfEnabled(
label: String = "Crossfade",
accountViewModel: AccountViewModel,
content: @Composable (T) -> Unit,
) {
if (accountViewModel.settings.isPerformanceMode()) {
Box(modifier, contentAlignment) {
content(targetState)
}
} else {
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
}
}
/** Latches the first time a crossfade's target moves off the value it was composed with. */
private class ChangeLatch {
var changed = false
}
/**
* A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there
* is something to animate.
*
* `updateTransition` allocates a transition, its animation list and its seeking state on *first
* composition*, even though first composition has nothing to cross-fade — target and initial state
* are the same value. In a feed that is waste: every card scrolled in builds a transition per
* animated element, and during a scroll essentially none of them run, because the underlying counts
* and icons do not change in the second a card is on screen.
*
* So the plain content renders until the target actually moves. At that point the transition is
* built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at
* the new one, so the first real change still animates exactly as before; every later change
* animates through the now-live transition normally.
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
internal fun <T> DeferredCrossfade(
targetState: T,
modifier: Modifier,
contentAlignment: Alignment,
animationSpec: FiniteAnimationSpec<Float>,
label: String,
content: @Composable (T) -> Unit,
) {
val initial = remember { targetState }
val latch = remember { ChangeLatch() }
if (targetState != initial) latch.changed = true
if (!latch.changed) {
Box(modifier, contentAlignment) {
content(targetState)
}
} else {
val transitionState = remember { MutableTransitionState(initial) }
transitionState.targetState = targetState
val transition = rememberTransition(transitionState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> MyCrossfade(
targetState: T,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
content: @Composable (T) -> Unit,
) {
val transition = updateTransition(targetState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
@ExperimentalAnimationApi
@Composable
fun <T> Transition<T>.MyCrossfade(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
contentKey: (targetState: T) -> Any? = { it },
content: @Composable (targetState: T) -> Unit,
) {
val currentlyVisible = remember { mutableStateListOf<T>().apply { add(currentState) } }
val contentMap = remember { mutableScatterMapOf<T, @Composable () -> Unit>() }
// If not animating, just display the current state
if (currentState == targetState && (currentlyVisible.size != 1 || currentlyVisible[0] != targetState)) {
// Remove all the intermediate items from the list once the animation is finished.
currentlyVisible.removeAll { it != targetState }
contentMap.clear()
}
if (targetState !in contentMap) {
// Replace target with the same key if any
val replacementId =
currentlyVisible.indexOfFirst { contentKey(it) == contentKey(targetState) }
if (replacementId == -1) {
currentlyVisible.add(targetState)
} else {
currentlyVisible[replacementId] = targetState
}
contentMap.clear()
currentlyVisible.fastForEach { stateForContent ->
contentMap[stateForContent] = {
val alpha by
animateFloat(transitionSpec = { animationSpec }) {
if (it == stateForContent) 1f else 0f
}
Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) { content(stateForContent) }
}
}
}
Box(modifier, contentAlignment) {
currentlyVisible.fastForEach { key(contentKey(it)) { contentMap[it]?.invoke() } }
}
}
) = CrossfadeIfEnabled(
targetState = targetState,
enabled = !accountViewModel.settings.isPerformanceMode(),
modifier = modifier,
contentAlignment = contentAlignment,
animationSpec = animationSpec,
label = label,
content = content,
)
@@ -20,29 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_confirm
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_explainer
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_field
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_title
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip32Labeling.ui.HashtagLabelDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -56,53 +36,8 @@ fun AddHashtagLabelDialog(
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
var hashtag by remember { mutableStateOf("") }
// Strip the leading '#', drop whitespace and lowercase so the stored label matches the
// hashtag-feed convention. A blank result disables the confirm button.
val sanitized =
hashtag
.trim()
.removePrefix("#")
.filterNot { it.isWhitespace() }
.lowercase()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.add_hashtag_label_title)) },
text = {
Column {
Text(stringRes(Res.string.add_hashtag_label_explainer))
OutlinedTextField(
value = hashtag,
onValueChange = { hashtag = it },
singleLine = true,
label = { Text(stringRes(Res.string.add_hashtag_label_field)) },
prefix = { Text("#") },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = sanitized.isNotEmpty(),
onClick = {
accountViewModel.labelWithHashtag(note, sanitized)
onDismiss()
},
) {
Text(stringRes(Res.string.add_hashtag_label_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.cancel))
}
},
HashtagLabelDialog(
onConfirm = { hashtag -> accountViewModel.labelWithHashtag(note, hashtag) },
onDismiss = onDismiss,
)
}
@@ -20,24 +20,17 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.commons.ui.note.FollowedHashtagLabel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
@@ -68,25 +61,7 @@ fun DisplayFollowingHashtagsInPost(
}
}
firstTag?.let {
Column(verticalArrangement = Arrangement.Center) {
Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, accountViewModel, nav) }
}
}
}
@Composable
private fun DisplayTagList(
firstTag: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
ClickableTextColor(
"#$firstTag",
linkColor = MaterialTheme.colorScheme.lessImportantLink,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
) {
nav.nav(Route.Hashtag(firstTag.lowercase()))
firstTag?.let { tag ->
FollowedHashtagLabel(tag) { nav.nav(Route.Hashtag(tag.lowercase())) }
}
}
@@ -21,45 +21,23 @@
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.pow_info_description
import com.vitorpamplona.amethyst.commons.resources.pow_settings_title
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.note.ProofOfWorkPill
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@Composable
@Preview
fun DisplayPoWPreview() {
ThemeComparisonColumn(
toPreview = { DisplayPoW(pow = 24, accountViewModel = mockAccountViewModel()) },
)
}
/**
* Compact pill showing the proof of work a received note carries: a gear plus
* the difficulty in leading zero bits. Tapping it explains what the number
* means. Sits inline in note headers, so it stays at text height.
*/
/** Entry: the shared [ProofOfWorkPill], explaining the number in a toast when tapped. */
@Composable
fun DisplayPoW(
pow: Int,
accountViewModel: AccountViewModel,
) {
HeaderPill(
symbol = MaterialSymbols.Manufacturing,
text = pow.toString(),
contentDescription = stringRes(Res.string.pow_settings_title),
onClick = {
accountViewModel.toastManager.toast(
Res.string.pow_settings_title,
Res.string.pow_info_description,
pow.toString(),
)
},
)
ProofOfWorkPill(pow) {
accountViewModel.toastManager.toast(
Res.string.pow_settings_title,
Res.string.pow_info_description,
pow.toString(),
)
}
}
@@ -20,31 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.hashtag_exclusive
import com.vitorpamplona.amethyst.commons.nip73ExternalIds.ui.HashtagExternalIdCard
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
@@ -54,40 +33,7 @@ fun DisplayHashtagExternalId(
accountViewModel: AccountViewModel,
nav: INav,
) {
DisplayHashtagExternalId(externalId.topic) {
HashtagExternalIdCard(externalId.topic) {
nav.nav(Route.Hashtag(externalId.topic.lowercase()))
}
}
@Composable
fun DisplayHashtagExternalId(
topic: String,
linkInteractionListener: LinkInteractionListener,
) {
Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.Tag,
contentDescription = stringRes(id = Res.string.hashtag_exclusive),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(StdHorzSpacer)
Text(
text =
buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("hashtag", null, linkInteractionListener),
) {
append(topic)
}
},
style =
LocalTextStyle.current.copy(
fontWeight = FontWeight.Bold,
),
maxLines = 1,
)
}
}
@@ -20,30 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarRsvpButtons
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@@ -90,88 +72,9 @@ fun CalendarRsvpRow(
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_going),
status = RSVPStatusTag.STATUS.ACCEPTED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_maybe),
status = RSVPStatusTag.STATUS.TENTATIVE,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_not_going),
status = RSVPStatusTag.STATUS.DECLINED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
}
CalendarRsvpButtons(currentStatus, onTap)
}
@Composable
private fun RsvpButton(
label: String,
status: RSVPStatusTag.STATUS,
currentStatus: RSVPStatusTag.STATUS?,
modifier: Modifier,
onClick: (RSVPStatusTag.STATUS) -> Unit,
) {
val selected = status == currentStatus
if (selected) {
FilledTonalButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = colorFor(status),
contentColor = Color.White,
),
) {
RsvpButtonLabel(label)
}
} else {
OutlinedButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
) {
RsvpButtonLabel(label)
}
}
}
private val RsvpButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
@Composable
private fun RsvpButtonLabel(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun colorFor(status: RSVPStatusTag.STATUS) =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
}
/**
* Deterministic per-target d-tag so each user's RSVP for a given event is a single addressable.
* The format mirrors the a-tag coordinate so it's debuggable (`rsvp:31923:<pubkey>:<dtag>`).
@@ -41,6 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip34Git.ui.CompactButtonHeight
import com.vitorpamplona.amethyst.commons.nip34Git.ui.CompactButtonPadding
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.git_pr_changes_retry
import com.vitorpamplona.amethyst.commons.resources.git_pr_loading_changes
@@ -20,33 +20,14 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.GitStatusIndex
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.git_status_close
import com.vitorpamplona.amethyst.commons.resources.git_status_mark_merged
import com.vitorpamplona.amethyst.commons.resources.git_status_reopen
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip34Git.ui.GitStatusButtons
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -60,10 +41,6 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
private enum class StatusTarget { OPEN, CLOSED, APPLIED }
/** Compact sizing shared by the small action buttons on git cards. */
internal val CompactButtonHeight = Modifier.height(32.dp)
internal val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)
/**
* NIP-34 status controls for an issue, patch or pull request. Visible only to the
* people allowed to moderate the thread — the item's author and the repository
@@ -88,42 +65,13 @@ fun GitStatusActions(
val current = index?.get(note.idHex)
val closedOrApplied = current is GitStatusClosedEvent || current is GitStatusAppliedEvent
FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (closedOrApplied) {
FilledTonalButton(
onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) },
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
) {
Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
} else {
if (isPatchOrPr) {
FilledTonalButton(
onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) },
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
) {
Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
}
OutlinedButton(
onClick = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) },
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
) {
Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
}
}
GitStatusButtons(
closedOrApplied = closedOrApplied,
isPatchOrPr = isPatchOrPr,
onReopen = { sendStatus(accountViewModel, note, StatusTarget.OPEN) },
onMarkMerged = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) },
onClose = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) },
)
}
private fun repositoryAddressOf(event: Event): Address? =
@@ -20,40 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.kinds
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.nipsOnNostr.ui.NipCard
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds
@Composable
fun RenderNipContent(
@@ -63,133 +35,5 @@ fun RenderNipContent(
) {
val noteEvent = note.event as? NipTextEvent ?: return
NipNoteHeader(noteEvent, note, accountViewModel, nav)
}
@Composable
private fun NipNoteHeader(
noteEvent: NipTextEvent,
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val title = remember(noteEvent) { noteEvent.title() }
val kinds = remember(noteEvent) { noteEvent.kinds() }
Column(
modifier =
Modifier
.padding(top = Size5dp)
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
QuoteBorder,
),
verticalArrangement = SpacedBy5dp,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
)
}
Text(
text = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content },
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,
)
if (kinds.isNotEmpty()) {
FlowRow(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
horizontalArrangement = SpacedBy5dp,
verticalArrangement = SpacedBy5dp,
itemVerticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(Res.string.kinds),
)
kinds.forEach {
NoPaddingSuggestionChip(
label = it.toString(),
)
}
}
}
}
}
@Preview
@Composable
fun NipNoteHeaderPreview() {
val event =
NipTextEvent(
id = "eb2b05394ff0014bb6a79c2eacfd1c80696821592f4dbf86c950c4bf16614aa0",
pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
createdAt = 1767978398,
content = "Trusted Translations\n--------------------\n\n`draft` `optional`\n\nThis NIP allows anyone to post a translation for any event on a `kind:76`.\n\n```js\n{\n \"kind\": 76,\n \"tags\": [\n [\"e\", \"\u003coriginal_event_id\u003e\", \"\u003crelay\u003e\"]\n [\"k\", \"\u003coriginal_event_kind\u003e\"]\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n [\"s\", \"title\", \"translated title tag\"]\n [\"s\", \"summary\", \"translated summary tag\"]\n ],\n \"content\": \"this is a translated version of the original content\",\n // ...other fields\n}\n```\n\n`e` tag points to the event being translated, `k` tag points to the kind of that event.\n\n`l` tags define the language this was translated to in lowercase codes as defined by ISO 639-1. \n\n`s` tags are the translations for tags in the original event. \n\n`.content` contains the translation of the original `.content`\n\nClients SHOULD request translations by `e` and `l` tags spoken by their user. For every tag being rendered, Clients SHOULD look for their translated versions.\n\nProviders SHOULD use the event id in the filter to know which events need translations.\n\nProviders MAY store their translations behind a paid relay with NIP-42 auth.\n\n## Declaring Translation Providers\n\nKind `10041` lists the user's authorized translation providers. Each `p` tag is followed by the `pubkey` of the service publishing kind 76s, and the relay translations can be found. Users can specify these publicly or privately by JSON-stringifying and encrypting the tag list in the `.content` using NIP-44. \n\n```js\n{\n \"kind\": 10041,\n \"tags\": [\n [\"p\", \"4fd5e210530e4f6b2cb083795834bfe5108324f1ed9f00ab73b9e8fcfe5f12fe\", \"wss://translations.nostr.com\"],\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n //...\n}\n```\n\n`l` tags in this event are the languages the user understands and wants translations to.\n\nProviders SHOULD create the `10041` event and post to the user's outbox relay.",
sig = "7fa9f1d49c41c7bfbdad6d089d1c6685777c86de8fbda7662a630888658839f0444cb1398385f759461c09191b287fa222eec0ffe22b3fa8cf740f71aab9dc21",
tags =
arrayOf(
arrayOf("d", "trusted-translations"),
arrayOf("title", "Trusted Translations"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("client", "nostrhub.io"),
),
)
LocalCache.justConsume(event, null, true)
val note = LocalCache.getOrCreateNote(event.id)
ThemeComparisonColumn(
toPreview = {
NipNoteHeader(
noteEvent = event,
note = note,
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoPaddingSuggestionChip(
label: String,
modifier: Modifier = Modifier,
) {
Surface(
shape = MaterialTheme.shapes.extraSmall, // Use a small shape for chip look
color = MaterialTheme.colorScheme.secondaryContainer, // Default chip color
modifier = modifier,
) {
Text(
text = label,
// Apply desired internal padding to the Text itself
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
NipCard(noteEvent)
}
@@ -20,30 +20,16 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.runtime.Composable
import androidx.compose.runtime.getValue
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.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_to_public_bookmarks
import com.vitorpamplona.amethyst.commons.resources.remove_from_public_bookmarks
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.note.BookmarkToggleButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -56,10 +42,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
* Bookmarked state is read from the public bookmark id/address **sets** (not `List<Note>`
* containment) so it reflects reliably for addressable notes and updates the moment the list
* changes — the icon flipping filled/outline is the feedback, no toast needed.
*
* Rendered as a compact clickable glyph (not a Material [androidx.compose.material3.IconButton],
* whose 48dp minimum touch target would stand taller than the title it sits beside). [iconSize]
* defaults to a single title line so it aligns when placed next to a show/episode title.
*/
@Composable
fun PodcastBookmarkButton(
@@ -82,30 +64,16 @@ fun PodcastBookmarkButton(
}
}
Box(
modifier =
modifier
.clip(CircleShape)
.clickable(
role = Role.Button,
onClick = {
if (isBookmarked) {
accountViewModel.removePublicBookmark(note)
} else {
accountViewModel.addPublicBookmark(note)
}
},
).padding(4.dp),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
contentDescription =
stringRes(
if (isBookmarked) Res.string.remove_from_public_bookmarks else Res.string.add_to_public_bookmarks,
),
tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(iconSize),
)
}
BookmarkToggleButton(
isBookmarked = isBookmarked,
onToggle = {
if (isBookmarked) {
accountViewModel.removePublicBookmark(note)
} else {
accountViewModel.addPublicBookmark(note)
}
},
modifier = modifier,
iconSize = iconSize,
)
}
@@ -20,42 +20,22 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
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.foundation.layout.size
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.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_join_request
import com.vitorpamplona.amethyst.commons.resources.relay_leave_request
import com.vitorpamplona.amethyst.commons.resources.relay_member_added
import com.vitorpamplona.amethyst.commons.resources.relay_member_removed
import com.vitorpamplona.amethyst.commons.resources.relay_members_added
import com.vitorpamplona.amethyst.commons.resources.relay_members_count
import com.vitorpamplona.amethyst.commons.resources.relay_members_removed
import com.vitorpamplona.amethyst.commons.resources.relay_membership_list
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayAddMemberCard
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayJoinRequestCard
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayLeaveRequestCard
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayMembershipListCard
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRemoveMemberCard
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
// Dispatcher entries for the NIP-43 relay-membership kinds. The cards draw only from the event,
// so the entries just decode the note; the rendering is in commonsUI nip43RelayMembers/ui.
@Composable
fun RenderRelayMembershipList(
baseNote: Note,
@@ -63,15 +43,7 @@ fun RenderRelayMembershipList(
nav: INav,
) {
val noteEvent = baseNote.event as? RelayMembershipListEvent ?: return
val memberCount = remember(noteEvent) { noteEvent.members().size }
RelayMemberEventCard(
icon = MaterialSymbols.People,
title = stringRes(Res.string.relay_membership_list),
subtitle = stringRes(Res.string.relay_members_count, memberCount),
nav = nav,
relayPubKey = noteEvent.pubKey,
)
RelayMembershipListCard(noteEvent)
}
@Composable
@@ -81,21 +53,7 @@ fun RenderRelayAddMember(
nav: INav,
) {
val noteEvent = baseNote.event as? RelayAddMemberEvent ?: return
val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(Res.string.relay_member_added)
} else {
stringRes(Res.string.relay_members_added, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = title,
subtitle = subtitle,
relayPubKey = noteEvent.pubKey,
)
RelayAddMemberCard(noteEvent)
}
@Composable
@@ -105,21 +63,7 @@ fun RenderRelayRemoveMember(
nav: INav,
) {
val noteEvent = baseNote.event as? RelayRemoveMemberEvent ?: return
val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(Res.string.relay_member_removed)
} else {
stringRes(Res.string.relay_members_removed, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = MaterialSymbols.PersonRemove,
title = title,
subtitle = subtitle,
relayPubKey = noteEvent.pubKey,
)
RelayRemoveMemberCard(noteEvent)
}
@Composable
@@ -127,129 +71,11 @@ fun RenderRelayJoinRequest(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = stringRes(Res.string.relay_join_request),
subtitle = null,
nav = nav,
relayPubKey = null,
)
}
) = RelayJoinRequestCard()
@Composable
fun RenderRelayLeaveRequest(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayMemberEventCard(
icon = MaterialSymbols.AutoMirrored.ExitToApp,
title = stringRes(Res.string.relay_leave_request),
subtitle = null,
nav = nav,
relayPubKey = null,
)
}
@Composable
private fun RelayMemberEventCard(
icon: MaterialSymbol,
title: String,
subtitle: String?,
nav: INav? = null,
relayPubKey: String? = null,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = title,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Preview
@Composable
private fun RelayMembershipListCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.People,
title = "Relay membership list",
subtitle = "42 members",
)
}
}
@Preview
@Composable
private fun RelayAddMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = "Member added to relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayRemoveMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonRemove,
title = "Member removed from relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayJoinRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = "Relay join request",
subtitle = null,
)
}
}
@Preview
@Composable
private fun RelayLeaveRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.AutoMirrored.ExitToApp,
title = "Relay leave request",
subtitle = null,
)
}
}
) = RelayLeaveRequestCard()
@@ -20,42 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject
import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject_caption
import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message
import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message_placeholder
import com.vitorpamplona.amethyst.commons.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.commons.ui.note.buttons.PostButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.nip17Dm.ui.ChatroomSubjectDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
@@ -67,103 +34,25 @@ fun NewChatroomSubjectDialog(
accountViewModel: AccountViewModel,
room: ChatroomKey,
) {
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
dismissOnClickOutside = false,
),
) {
Surface {
val groupName =
remember {
mutableStateOf(
accountViewModel.account.chatroomList.rooms
.get(room)
?.subject
?.value ?: "",
)
}
val message = remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
ChatroomSubjectDialog(
initialSubject =
accountViewModel.account.chatroomList.rooms
.get(room)
?.subject
?.value ?: "",
onPost = { subject, message ->
accountViewModel.launchSigner {
val template =
ChatMessageEvent.build(
message,
room.users.map { LocalCache.getOrCreateUser(it).toPTag() },
) {
subject.ifBlank { null }?.let { changeSubject(it) }
}
Column(
modifier =
Modifier
.padding(10.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = { onClose() })
PostButton(
onPost = {
accountViewModel.launchSigner {
val template =
ChatMessageEvent.build(
message.value,
room.users.map { LocalCache.getOrCreateUser(it).toPTag() },
) {
groupName.value.ifBlank { null }?.let { changeSubject(it) }
}
accountViewModel.account.sendNip17PrivateMessage(template)
}
onClose()
},
true,
)
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(Res.string.messages_new_message_subject)) },
modifier = Modifier.fillMaxWidth(),
value = groupName.value,
onValueChange = { groupName.value = it },
placeholder = {
Text(
text = stringRes(Res.string.messages_new_message_subject_caption),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(Res.string.messages_new_subject_message)) },
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
value = message.value,
onValueChange = { message.value = it },
placeholder = {
Text(
text = stringRes(Res.string.messages_new_subject_message_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10,
)
accountViewModel.account.sendNip17PrivateMessage(template)
}
}
}
},
onClose = onClose,
)
}
@@ -35,6 +35,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.ui.JoinChannelButton
import com.vitorpamplona.amethyst.commons.chats.ui.LeaveChannelButton
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_image
@@ -47,8 +49,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.JoinChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.LeaveChatButton
@Composable
fun ShortEphemeralChatChannelHeader(
@@ -130,8 +130,8 @@ fun JoinEphemeralChatButtonIfNotAlreadyJoined(
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel)
if (!isFollowing) {
JoinChatButton(channel, accountViewModel, nav)
JoinChannelButton { accountViewModel.follow(channel) }
} else {
LeaveChatButton(channel, accountViewModel, nav)
LeaveChannelButton { accountViewModel.unfollow(channel) }
}
}
@@ -41,7 +41,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.ui.LeaveChannelButton
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.nip28PublicChat.ui.EditChannelButton
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.channel_image
import com.vitorpamplona.amethyst.commons.resources.created_at
@@ -64,8 +67,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.EditButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LeaveChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LinkChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.MuteChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.OpenChatButton
@@ -212,7 +213,7 @@ fun EditButtonIfIamCreator(
}
if (isMe) {
EditButton(channel, accountViewModel, nav)
EditChannelButton { nav.nav(Route.ChannelMetadataEdit(channel.idHex)) }
}
}
@@ -225,6 +226,6 @@ fun LeaveButtonIfFollowing(
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel)
if (isFollowing) {
LeaveChatButton(channel, accountViewModel, nav)
LeaveChannelButton { accountViewModel.unfollow(channel) }
}
}
@@ -37,6 +37,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.ui.JoinChannelButton
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.profile_image
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.JoinChatButton
@Composable
fun ShortPublicChatChannelHeader(
@@ -147,6 +147,6 @@ fun JoinChatButtonIfNotAlreadyJoined(
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel)
if (!isFollowing) {
JoinChatButton(channel, accountViewModel, nav)
JoinChannelButton { accountViewModel.follow(channel) }
}
}
@@ -1,48 +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.publicChannels.nip28PublicChat.header.actions
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.join
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun JoinChatButton(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.follow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.join))
}
}
@@ -1,48 +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.publicChannels.nip28PublicChat.header.actions
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.leave
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun LeaveChatButton(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.unfollow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.leave))
}
}
@@ -20,23 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilledTonalButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.chats.ui.MuteChannelButton
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.mute_notifications
import com.vitorpamplona.amethyst.commons.resources.unmute_notifications
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -47,23 +35,5 @@ fun MuteChatButton(
val mutedChats by accountViewModel.mutedPublicChatsFlow().collectAsStateWithLifecycle()
val isMuted = channel.idHex in mutedChats
val label =
stringRes(
if (isMuted) Res.string.unmute_notifications else Res.string.mute_notifications,
)
FilledTonalButton(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { accountViewModel.toggleMutedPublicChat(channel.idHex) },
contentPadding = ZeroPadding,
) {
Icon(
symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications,
contentDescription = label,
modifier = Size20Modifier,
)
}
MuteChannelButton(isMuted) { accountViewModel.toggleMutedPublicChat(channel.idHex) }
}
@@ -20,25 +20,13 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzPinDropdownItem
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_to_messages
import com.vitorpamplona.amethyst.commons.resources.buzz_pin
import com.vitorpamplona.amethyst.commons.resources.buzz_unpin
import com.vitorpamplona.amethyst.commons.resources.remove_from_messages
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayGroupMessagesDropdownItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -55,21 +43,11 @@ fun BuzzPinDropdownItem(
) {
val stars = accountViewModel.account.buzzChannelStars
val starred by stars.flow.collectAsStateWithLifecycle()
val isStarred = groupId.id in starred
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = null,
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isStarred) Res.string.buzz_unpin else Res.string.buzz_pin)) },
onClick = {
closeMenu()
stars.toggle(groupId.id)
},
BuzzPinDropdownItem(
isPinned = groupId.id in starred,
closeMenu = closeMenu,
onToggle = { stars.toggle(groupId.id) },
)
}
@@ -88,18 +66,11 @@ fun RelayGroupMessagesDropdownItem(
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
val onMyList = channel.groupId in joinedGroupIds
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (onMyList) Res.string.remove_from_messages else Res.string.add_to_messages)) },
onClick = {
closeMenu()
RelayGroupMessagesDropdownItem(
onMyList = onMyList,
closeMenu = closeMenu,
onToggle = {
if (onMyList) {
accountViewModel.removeRelayGroupFromMessages(channel)
} else {
@@ -20,28 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_hint
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_label
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_confirm
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_title
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayGroupInviteCodeDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -56,37 +37,11 @@ fun JoinRelayGroupDialog(
onJoined: () -> Unit,
onDismiss: () -> Unit,
) {
var code by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.relay_group_join_title)) },
text = {
Column {
Text(stringRes(Res.string.relay_group_join_code_hint))
OutlinedTextField(
value = code,
onValueChange = { code = it },
singleLine = true,
label = { Text(stringRes(Res.string.relay_group_join_code_label)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(
enabled = code.isNotBlank(),
onClick = {
accountViewModel.joinRelayGroup(channel, code.trim())
onJoined()
onDismiss()
},
) {
Text(stringRes(Res.string.relay_group_join_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) }
RelayGroupInviteCodeDialog(
onJoin = { code ->
accountViewModel.joinRelayGroup(channel, code)
onJoined()
},
onDismiss = onDismiss,
)
}
@@ -34,8 +34,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SecondaryScrollableTabRow
@@ -52,10 +50,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.DiscoverTab
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.nip23LongContent.ui.NewArticleButton
import com.vitorpamplona.amethyst.commons.nip99Classifieds.ui.NewProductButton
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.discover_chat
import com.vitorpamplona.amethyst.commons.resources.discover_community_v2
@@ -64,8 +62,6 @@ import com.vitorpamplona.amethyst.commons.resources.discover_follows
import com.vitorpamplona.amethyst.commons.resources.discover_live_v2
import com.vitorpamplona.amethyst.commons.resources.discover_marketplace
import com.vitorpamplona.amethyst.commons.resources.discover_reads
import com.vitorpamplona.amethyst.commons.resources.new_long_form_post
import com.vitorpamplona.amethyst.commons.resources.new_product
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed
@@ -79,8 +75,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia
@@ -273,11 +267,11 @@ private fun DiscoverPages(
if (currentPage >= 0 && currentPage < feedTabs.size) {
FabBottomBarPadded(nav) {
if (feedTabs[currentPage].resource == Res.string.discover_marketplace) {
NewProductButton(accountViewModel, nav)
NewProductButton(nav)
}
if (feedTabs[currentPage].resource == Res.string.discover_reads) {
NewLongFormMarkdownButton(accountViewModel, nav)
NewArticleButton(nav)
}
}
}
@@ -372,50 +366,6 @@ private fun RenderDiscoverFeed(
}
}
@Composable
fun NewProductButton(
accountViewModel: AccountViewModel,
nav: INav,
) {
FloatingActionButton(
onClick = {
nav.nav(Route.NewProduct())
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(id = Res.string.new_product),
modifier = Size26Modifier,
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
@Composable
fun NewLongFormMarkdownButton(
accountViewModel: AccountViewModel,
nav: INav,
) {
FloatingActionButton(
onClick = {
nav.nav(Route.NewLongFormPost())
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(id = Res.string.new_long_form_post),
modifier = Size26Modifier,
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
@Composable
private fun RenderDiscoverFeed(
feedContentState: FeedContentState,
@@ -22,39 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches
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.feeds.FeedState
import com.vitorpamplona.amethyst.commons.nipCCGeocaching.ui.rememberFoundCacheIds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
/**
* The addresses the signed-in user has already logged a find on, as `37516:pubkey:d` strings.
*
* Derived from the Finds feed rather than scanning the cache directly, so it tracks a new log
* the moment the feed does — which is what makes a hunt's progress bar move as you walk it.
*/
/** The addresses the signed-in user has already logged a find on, from their Finds feed. */
@Composable
fun rememberMyFoundCacheIds(accountViewModel: AccountViewModel): Set<String> {
val finds by accountViewModel.feedStates.geocacheFindsFeed.feedContent
.collectAsStateWithLifecycle()
return when (val state = finds) {
is FeedState.Loaded -> rememberFoundCacheIds(state)
else -> emptySet()
}
}
/**
* Collects the loaded feed's own flow. [FeedState.Loaded] is handed out once and then mutated
* through its inner flow, so reading `.value` here would freeze the set at whatever the feed held
* when the hunt screen first composed — the progress bar would never move.
*/
@Composable
private fun rememberFoundCacheIds(state: FeedState.Loaded): Set<String> {
val loaded by state.feed.collectAsStateWithLifecycle()
return remember(loaded) {
loaded.list.mapNotNullTo(mutableSetOf()) { (it.event as? GeocacheFoundLogEvent)?.geocacheId() }
}
return rememberFoundCacheIds(finds)
}
@@ -27,6 +27,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.music.ui.NewMusicTrackButton
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
@@ -87,7 +88,7 @@ fun MusicTracksScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewMusicTrackButton(accountViewModel, nav)
NewMusicTrackButton(nav)
}
},
accountViewModel = accountViewModel,
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.nip99Classifieds.ui.NewProductButton
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
@@ -75,7 +76,7 @@ fun ProductsScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewProductButton(accountViewModel, nav)
NewProductButton(nav)
}
},
accountViewModel = accountViewModel,
@@ -67,6 +67,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.profile.ui.RelaysTabHeader
import com.vitorpamplona.amethyst.commons.relayClient.profile.UserProfileFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.gallery
@@ -109,7 +110,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProf
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.RelaysTabHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.TabRelays
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.ReportsTabHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.TabReports
@@ -697,7 +697,7 @@ private fun CreateAndRenderTabs(
ProfileTab.Bookmarks -> BookmarkTabHeader(baseUser, accountViewModel)
ProfileTab.FollowedTags -> FollowedTagsTabHeader(baseUser, accountViewModel)
ProfileTab.Reports -> ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel)
ProfileTab.Relays -> RelaysTabHeader(baseUser, accountViewModel)
ProfileTab.Relays -> RelaysTabHeader()
}
},
)
@@ -20,210 +20,23 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.report
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.block_only
import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_label
import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_placeholder
import com.vitorpamplona.amethyst.commons.resources.report_dialog_block_hide_user_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_blocking_a_user
import com.vitorpamplona.amethyst.commons.resources.report_dialog_illegal
import com.vitorpamplona.amethyst.commons.resources.report_dialog_impersonation
import com.vitorpamplona.amethyst.commons.resources.report_dialog_nudity
import com.vitorpamplona.amethyst.commons.resources.report_dialog_post_report_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_profanity
import com.vitorpamplona.amethyst.commons.resources.report_dialog_reminder_public
import com.vitorpamplona.amethyst.commons.resources.report_dialog_report_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_label
import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_placeholder
import com.vitorpamplona.amethyst.commons.resources.report_dialog_spam
import com.vitorpamplona.amethyst.commons.resources.report_dialog_title
import com.vitorpamplona.amethyst.commons.resources.report_malware
import com.vitorpamplona.amethyst.commons.resources.violence
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.LightRedColor
import com.vitorpamplona.amethyst.commons.nip56Reports.ui.ReportDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportNoteDialog(
note: Note,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val reportTypes =
listOf(
Pair(ReportType.SPAM, stringRes(Res.string.report_dialog_spam)),
Pair(ReportType.PROFANITY, stringRes(Res.string.report_dialog_profanity)),
Pair(ReportType.IMPERSONATION, stringRes(Res.string.report_dialog_impersonation)),
Pair(ReportType.NUDITY, stringRes(Res.string.report_dialog_nudity)),
Pair(ReportType.ILLEGAL, stringRes(Res.string.report_dialog_illegal)),
Pair(ReportType.MALWARE, stringRes(Res.string.report_malware)),
Pair(ReportType.VIOLENCE, stringRes(Res.string.violence)),
)
val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableIntStateOf(-1) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringRes(id = Res.string.report_dialog_title)) },
navigationIcon = { IconButton(onClick = onDismiss) { ArrowBackIcon() } },
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()),
verticalArrangement = Arrangement.SpaceAround,
) {
SpacerH16()
SectionHeader(text = stringRes(id = Res.string.block_only))
SpacerH16()
Text(
text = stringRes(Res.string.report_dialog_blocking_a_user),
)
SpacerH16()
ActionButton(
text = stringRes(Res.string.report_dialog_block_hide_user_btn),
icon = MaterialSymbols.Block,
onClick = {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
},
)
SpacerH16()
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface, thickness = DividerThickness)
SpacerH16()
SectionHeader(text = stringRes(Res.string.report_dialog_report_btn))
SpacerH16()
Text(stringRes(Res.string.report_dialog_reminder_public))
SpacerH16()
TextSpinner(
label = stringRes(Res.string.report_dialog_select_reason_label),
placeholder = stringRes(Res.string.report_dialog_select_reason_placeholder),
options = reasonOptions,
onSelect = { selectedReason = it },
modifier = Modifier.fillMaxWidth(),
)
SpacerH16()
OutlinedTextField(
value = additionalReason,
onValueChange = { additionalReason = it },
placeholder = {
Text(text = stringRes(Res.string.report_dialog_additional_reason_placeholder))
},
label = { Text(stringRes(Res.string.report_dialog_additional_reason_label)) },
modifier = Modifier.fillMaxWidth(),
)
SpacerH16()
ActionButton(
text = stringRes(Res.string.report_dialog_post_report_btn),
icon = MaterialSymbols.Report,
enabled = selectedReason in 0..reportTypes.lastIndex,
onClick = {
accountViewModel.report(
note,
reportTypes[selectedReason].first,
additionalReason,
)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
},
)
}
}
}
}
@Composable private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp))
@Composable
private fun SectionHeader(text: String) =
Text(
text = text,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 18.sp,
ReportDialog(
onBlock = { note.author?.let { accountViewModel.hide(it) } },
onReport = { reason, additionalReason ->
accountViewModel.report(note, reason, additionalReason)
note.author?.let { accountViewModel.hide(it) }
},
onDismiss = onDismiss,
)
@Composable
private fun ActionButton(
text: String,
icon: MaterialSymbol,
enabled: Boolean = true,
onClick: () -> Unit,
) = Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(containerColor = LightRedColor),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
symbol = icon,
contentDescription = null,
tint = Color.White,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = Color.White)
}
}
+1 -1
View File
@@ -142,7 +142,7 @@ type (`LazyListState`, `TextFieldValue`, `TextFieldState`) goes to `commonsUI`.
| `napplet` | mixed | Protocol/permission logic here; `NappletWebContract` (serves the shell/shim from `composeResources`) in `commonsUI`. |
| `favorites`, `nip30CustomEmojis`, `nip34Git`, `nip85TrustedAssertions`, `nip53LiveActivities` | mixed | Logic here; each feature's `ui/` (or the flat `FavoriteAppIcon`, `EmojiSuggestionState`) in `commonsUI`. |
| `chats` | yes | Composables shared by every chat kind (DMs, public chats, relay groups, concord): unread badge, divisors, system messages, author line, send button, reply toggle, new-conversation screen. Chat *models* are in `model/chats`. |
| `nip17Dm`, `nip23LongContent`, `nip28PublicChat`, `nip29RelayGroups`, `nip51Lists`, `nip52Calendar`, `nip56Reports`, `nip72ModCommunities`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `buzz`, `cashu`, `concord`, `ephemChat`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `<feature>/ui` (named after the `quartz` package, or its concern name when `quartz` has none). Where the package also has logic (`cashu`, `browser`, `napplet`, `profile`) that part stays here; the `buzz` and `concord` models are in `model/buzz` and `model/concord`. |
| `nip17Dm`, `nip23LongContent`, `nip28PublicChat`, `nip29RelayGroups`, `nip32Labeling`, `nip43RelayMembers`, `nip51Lists`, `nip52Calendar`, `nip56Reports`, `nip72ModCommunities`, `nip73ExternalIds`, `nip99Classifieds`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `nipsOnNostr`, `buzz`, `cashu`, `concord`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `<feature>/ui` (named after the `quartz` package, or its concern name when `quartz` has none). Where the package also has logic (`cashu`, `browser`, `napplet`, `profile`) that part stays here; the `buzz` and `concord` models are in `model/buzz` and `model/concord`. |
### Mixed (documented debt — see §4)
| Package | UI? | Purpose |
@@ -18,27 +18,45 @@
* 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.commons.ephemChat.ui
package com.vitorpamplona.amethyst.commons.buzz.ui
import androidx.compose.material3.Button
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.join
import com.vitorpamplona.amethyst.commons.resources.buzz_pin
import com.vitorpamplona.amethyst.commons.resources.buzz_unpin
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Pin/Unpin overflow-menu item for a Buzz channel. [isPinned] picks the label and tints the pin;
* clicking closes the menu, then calls [onToggle].
*/
@Composable
fun JoinButton(
onPost: () -> Unit = {},
isActive: Boolean,
modifier: Modifier = Modifier,
fun BuzzPinDropdownItem(
isPinned: Boolean,
closeMenu: () -> Unit,
onToggle: () -> Unit,
) {
Button(
enabled = isActive,
modifier = modifier,
onClick = onPost,
) {
Text(text = stringRes(Res.string.join))
}
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = null,
tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isPinned) Res.string.buzz_unpin else Res.string.buzz_pin)) },
onClick = {
closeMenu()
onToggle()
},
)
}
@@ -0,0 +1,94 @@
/*
* 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.commons.chats.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.join
import com.vitorpamplona.amethyst.commons.resources.leave
import com.vitorpamplona.amethyst.commons.resources.mute_notifications
import com.vitorpamplona.amethyst.commons.resources.unmute_notifications
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
// Membership actions shared by every channel header (NIP-28 public chats and ephemeral chats).
// They take the action as a callback so the header that knows the account decides what
// joining, leaving or muting means.
@Composable
fun JoinChannelButton(onClick: () -> Unit) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = onClick,
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.join))
}
}
@Composable
fun LeaveChannelButton(onClick: () -> Unit) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = onClick,
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.leave))
}
}
/** Bell toggle for a channel's notifications; [isMuted] picks the icon and label. */
@Composable
fun MuteChannelButton(
isMuted: Boolean,
onToggle: () -> Unit,
) {
val label =
stringRes(
if (isMuted) Res.string.unmute_notifications else Res.string.mute_notifications,
)
FilledTonalButton(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = onToggle,
contentPadding = ZeroPadding,
) {
Icon(
symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications,
contentDescription = label,
modifier = Size20Modifier,
)
}
}
@@ -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.screen.loggedIn.music
package com.vitorpamplona.amethyst.commons.music.ui
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
@@ -36,14 +36,9 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@Composable
fun NewMusicTrackButton(
accountViewModel: AccountViewModel,
nav: INav,
) {
fun NewMusicTrackButton(nav: INav) {
FloatingActionButton(
onClick = { nav.nav(Route.NewMusicTrack()) },
modifier = Size55Modifier,
@@ -63,6 +58,6 @@ fun NewMusicTrackButton(
@Composable
private fun NewMusicTrackButtonPreview() {
ThemeComparisonRow {
NewMusicTrackButton(accountViewModel = mockAccountViewModel(), nav = EmptyNav())
NewMusicTrackButton(nav = EmptyNav())
}
}
@@ -0,0 +1,147 @@
/*
* 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.commons.nip17Dm.ui
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject
import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject_caption
import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message
import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message_placeholder
import com.vitorpamplona.amethyst.commons.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.commons.ui.note.buttons.PostButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
/**
* Rename a private group chat (NIP-17): edit the subject and write the message that carries the
* change. [onPost] receives the subject (possibly blank) and the message, then the dialog closes.
*/
@Composable
fun ChatroomSubjectDialog(
initialSubject: String,
onPost: (subject: String, message: String) -> Unit,
onClose: () -> Unit,
) {
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
dismissOnClickOutside = false,
),
) {
Surface {
val groupName = remember { mutableStateOf(initialSubject) }
val message = remember { mutableStateOf("") }
Column(
modifier =
Modifier
.padding(10.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = { onClose() })
PostButton(
onPost = {
onPost(groupName.value, message.value)
onClose()
},
true,
)
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(Res.string.messages_new_message_subject)) },
modifier = Modifier.fillMaxWidth(),
value = groupName.value,
onValueChange = { groupName.value = it },
placeholder = {
Text(
text = stringRes(Res.string.messages_new_message_subject_caption),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(Res.string.messages_new_subject_message)) },
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
value = message.value,
onValueChange = { message.value = it },
placeholder = {
Text(
text = stringRes(Res.string.messages_new_subject_message_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10,
)
}
}
}
}
@@ -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.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions
package com.vitorpamplona.amethyst.commons.nip28PublicChat.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -28,27 +28,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.edits_the_channel_metadata
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/** Opens the channel-metadata editor; shown to the channel's owner in the long header. */
@Composable
fun EditButton(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
fun EditChannelButton(onClick: () -> Unit) {
FilledTonalButton(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { nav.nav(Route.ChannelMetadataEdit(channel.idHex)) },
onClick = onClick,
contentPadding = ZeroPadding,
) {
Icon(
@@ -0,0 +1,86 @@
/*
* 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.commons.nip29RelayGroups.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_hint
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_label
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_confirm
import com.vitorpamplona.amethyst.commons.resources.relay_group_join_title
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Invite-code prompt for joining a closed (invite-only) NIP-29 group. Confirm hands the
* trimmed code to [onJoin] and then closes the dialog through [onDismiss].
*/
@Composable
fun RelayGroupInviteCodeDialog(
onJoin: (code: String) -> Unit,
onDismiss: () -> Unit,
) {
var code by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.relay_group_join_title)) },
text = {
Column {
Text(stringRes(Res.string.relay_group_join_code_hint))
OutlinedTextField(
value = code,
onValueChange = { code = it },
singleLine = true,
label = { Text(stringRes(Res.string.relay_group_join_code_label)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(
enabled = code.isNotBlank(),
onClick = {
onJoin(code.trim())
onDismiss()
},
) {
Text(stringRes(Res.string.relay_group_join_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) }
},
)
}
@@ -0,0 +1,62 @@
/*
* 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.commons.nip29RelayGroups.ui
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_to_messages
import com.vitorpamplona.amethyst.commons.resources.remove_from_messages
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Add/Remove overflow-menu item for whether a relay group shows in Messages. [onMyList] picks
* the label and icon; clicking closes the menu, then calls [onToggle].
*/
@Composable
fun RelayGroupMessagesDropdownItem(
onMyList: Boolean,
closeMenu: () -> Unit,
onToggle: () -> Unit,
) {
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (onMyList) Res.string.remove_from_messages else Res.string.add_to_messages)) },
onClick = {
closeMenu()
onToggle()
},
)
}
@@ -0,0 +1,105 @@
/*
* 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.commons.nip32Labeling.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_confirm
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_explainer
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_field
import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_title
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Asks for a hashtag to label a post with (NIP-32, `#t` namespace). The typed text is
* normalized the way hashtag feeds store tags: leading `#` and whitespace stripped,
* lowercased. Confirm hands that value to [onConfirm] and closes through [onDismiss].
*/
@Composable
fun HashtagLabelDialog(
onConfirm: (hashtag: String) -> Unit,
onDismiss: () -> Unit,
) {
var hashtag by remember { mutableStateOf("") }
// Strip the leading '#', drop whitespace and lowercase so the stored label matches the
// hashtag-feed convention. A blank result disables the confirm button.
val sanitized =
hashtag
.trim()
.removePrefix("#")
.filterNot { it.isWhitespace() }
.lowercase()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.add_hashtag_label_title)) },
text = {
Column {
Text(stringRes(Res.string.add_hashtag_label_explainer))
OutlinedTextField(
value = hashtag,
onValueChange = { hashtag = it },
singleLine = true,
label = { Text(stringRes(Res.string.add_hashtag_label_field)) },
prefix = { Text("#") },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = sanitized.isNotEmpty(),
onClick = {
onConfirm(sanitized)
onDismiss()
},
) {
Text(stringRes(Res.string.add_hashtag_label_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.cancel))
}
},
)
}
@@ -0,0 +1,98 @@
/*
* 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.commons.nip34Git.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.git_status_close
import com.vitorpamplona.amethyst.commons.resources.git_status_mark_merged
import com.vitorpamplona.amethyst.commons.resources.git_status_reopen
import com.vitorpamplona.amethyst.commons.ui.stringRes
/** Compact sizing shared by the small action buttons on git cards. */
val CompactButtonHeight = Modifier.height(32.dp)
val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)
/**
* NIP-34 status buttons for an issue, patch or pull request: "Reopen" when it is
* [closedOrApplied], otherwise "Mark merged" (patches and PRs only, [isPatchOrPr]) and "Close".
* The host decides who may see them and publishes the status event.
*/
@Composable
fun GitStatusButtons(
closedOrApplied: Boolean,
isPatchOrPr: Boolean,
onReopen: () -> Unit,
onMarkMerged: () -> Unit,
onClose: () -> Unit,
) {
FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (closedOrApplied) {
FilledTonalButton(
onClick = onReopen,
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
) {
Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
} else {
if (isPatchOrPr) {
FilledTonalButton(
onClick = onMarkMerged,
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
) {
Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
}
OutlinedButton(
onClick = onClose,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = CompactButtonHeight,
contentPadding = CompactButtonPadding,
) {
Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringRes(Res.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
}
}
}
}
@@ -0,0 +1,224 @@
/*
* 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.commons.nip43RelayMembers.ui
import androidx.compose.foundation.layout.Arrangement
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.foundation.layout.size
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.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_join_request
import com.vitorpamplona.amethyst.commons.resources.relay_leave_request
import com.vitorpamplona.amethyst.commons.resources.relay_member_added
import com.vitorpamplona.amethyst.commons.resources.relay_member_removed
import com.vitorpamplona.amethyst.commons.resources.relay_members_added
import com.vitorpamplona.amethyst.commons.resources.relay_members_count
import com.vitorpamplona.amethyst.commons.resources.relay_members_removed
import com.vitorpamplona.amethyst.commons.resources.relay_membership_list
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
/** NIP-43 kind 13534: the relay's current member list, shown as a count. */
@Composable
fun RelayMembershipListCard(event: RelayMembershipListEvent) {
val memberCount = remember(event) { event.members().size }
RelayMemberEventCard(
icon = MaterialSymbols.People,
title = stringRes(Res.string.relay_membership_list),
subtitle = stringRes(Res.string.relay_members_count, memberCount),
)
}
/** NIP-43 kind 8000: members the relay added. */
@Composable
fun RelayAddMemberCard(event: RelayAddMemberEvent) {
val memberKeys = remember(event) { event.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(Res.string.relay_member_added)
} else {
stringRes(Res.string.relay_members_added, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = title,
subtitle = subtitle,
)
}
/** NIP-43 kind 8001: members the relay removed. */
@Composable
fun RelayRemoveMemberCard(event: RelayRemoveMemberEvent) {
val memberKeys = remember(event) { event.memberPubKeys() }
val title =
if (memberKeys.size == 1) {
stringRes(Res.string.relay_member_removed)
} else {
stringRes(Res.string.relay_members_removed, memberKeys.size)
}
val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } }
RelayMemberEventCard(
icon = MaterialSymbols.PersonRemove,
title = title,
subtitle = subtitle,
)
}
/** NIP-43 kind 28934: a request to join the relay. */
@Composable
fun RelayJoinRequestCard() {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = stringRes(Res.string.relay_join_request),
subtitle = null,
)
}
/** NIP-43 kind 28936: a request to leave the relay. */
@Composable
fun RelayLeaveRequestCard() {
RelayMemberEventCard(
icon = MaterialSymbols.AutoMirrored.ExitToApp,
title = stringRes(Res.string.relay_leave_request),
subtitle = null,
)
}
@Composable
fun RelayMemberEventCard(
icon: MaterialSymbol,
title: String,
subtitle: String?,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = title,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Preview
@Composable
private fun RelayMembershipListCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.People,
title = "Relay membership list",
subtitle = "42 members",
)
}
}
@Preview
@Composable
private fun RelayAddMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = "Member added to relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayRemoveMemberCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonRemove,
title = "Member removed from relay",
subtitle = "a1b2c3d4e5f6a7b8...",
)
}
}
@Preview
@Composable
private fun RelayJoinRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.PersonAdd,
title = "Relay join request",
subtitle = null,
)
}
}
@Preview
@Composable
private fun RelayLeaveRequestCardPreview() {
ThemeComparisonColumn {
RelayMemberEventCard(
icon = MaterialSymbols.AutoMirrored.ExitToApp,
title = "Relay leave request",
subtitle = null,
)
}
}
@@ -0,0 +1,135 @@
/*
* 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.commons.nip52Calendar.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
/**
* Going / Maybe / Not going buttons for a NIP-52 calendar event. The one matching
* [currentStatus] is filled in its status colour; the others are outlined. [onSelect] publishes
* the chosen status.
*/
@Composable
fun CalendarRsvpButtons(
currentStatus: RSVPStatusTag.STATUS?,
onSelect: (RSVPStatusTag.STATUS) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_going),
status = RSVPStatusTag.STATUS.ACCEPTED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onSelect,
)
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_maybe),
status = RSVPStatusTag.STATUS.TENTATIVE,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onSelect,
)
RsvpButton(
label = stringRes(Res.string.calendar_rsvp_not_going),
status = RSVPStatusTag.STATUS.DECLINED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onSelect,
)
}
}
@Composable
private fun RsvpButton(
label: String,
status: RSVPStatusTag.STATUS,
currentStatus: RSVPStatusTag.STATUS?,
modifier: Modifier,
onClick: (RSVPStatusTag.STATUS) -> Unit,
) {
val selected = status == currentStatus
if (selected) {
FilledTonalButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = colorFor(status),
contentColor = Color.White,
),
) {
RsvpButtonLabel(label)
}
} else {
OutlinedButton(
onClick = { onClick(status) },
modifier = modifier,
contentPadding = RsvpButtonPadding,
) {
RsvpButtonLabel(label)
}
}
}
private val RsvpButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
@Composable
private fun RsvpButtonLabel(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun colorFor(status: RSVPStatusTag.STATUS) =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
}
@@ -0,0 +1,227 @@
/*
* 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.commons.nip56Reports.ui
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.block_only
import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_label
import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_placeholder
import com.vitorpamplona.amethyst.commons.resources.report_dialog_block_hide_user_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_blocking_a_user
import com.vitorpamplona.amethyst.commons.resources.report_dialog_illegal
import com.vitorpamplona.amethyst.commons.resources.report_dialog_impersonation
import com.vitorpamplona.amethyst.commons.resources.report_dialog_nudity
import com.vitorpamplona.amethyst.commons.resources.report_dialog_post_report_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_profanity
import com.vitorpamplona.amethyst.commons.resources.report_dialog_reminder_public
import com.vitorpamplona.amethyst.commons.resources.report_dialog_report_btn
import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_label
import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_placeholder
import com.vitorpamplona.amethyst.commons.resources.report_dialog_spam
import com.vitorpamplona.amethyst.commons.resources.report_dialog_title
import com.vitorpamplona.amethyst.commons.resources.report_malware
import com.vitorpamplona.amethyst.commons.resources.violence
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.LightRedColor
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.collections.immutable.toImmutableList
/**
* Block-or-report dialog for a post. [onBlock] hides the author; [onReport] files a public
* NIP-56 report with the chosen reason and optional text (the host also hides the author).
* Both close the dialog through [onDismiss] afterwards.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportDialog(
onBlock: () -> Unit,
onReport: (reason: ReportType, additionalReason: String) -> Unit,
onDismiss: () -> Unit,
) {
val reportTypes =
listOf(
Pair(ReportType.SPAM, stringRes(Res.string.report_dialog_spam)),
Pair(ReportType.PROFANITY, stringRes(Res.string.report_dialog_profanity)),
Pair(ReportType.IMPERSONATION, stringRes(Res.string.report_dialog_impersonation)),
Pair(ReportType.NUDITY, stringRes(Res.string.report_dialog_nudity)),
Pair(ReportType.ILLEGAL, stringRes(Res.string.report_dialog_illegal)),
Pair(ReportType.MALWARE, stringRes(Res.string.report_malware)),
Pair(ReportType.VIOLENCE, stringRes(Res.string.violence)),
)
val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableIntStateOf(-1) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringRes(id = Res.string.report_dialog_title)) },
navigationIcon = { IconButton(onClick = onDismiss) { ArrowBackIcon() } },
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()),
verticalArrangement = Arrangement.SpaceAround,
) {
SpacerH16()
SectionHeader(text = stringRes(id = Res.string.block_only))
SpacerH16()
Text(
text = stringRes(Res.string.report_dialog_blocking_a_user),
)
SpacerH16()
ActionButton(
text = stringRes(Res.string.report_dialog_block_hide_user_btn),
icon = MaterialSymbols.Block,
onClick = {
onBlock()
onDismiss()
},
)
SpacerH16()
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface, thickness = DividerThickness)
SpacerH16()
SectionHeader(text = stringRes(Res.string.report_dialog_report_btn))
SpacerH16()
Text(stringRes(Res.string.report_dialog_reminder_public))
SpacerH16()
TextSpinner(
label = stringRes(Res.string.report_dialog_select_reason_label),
placeholder = stringRes(Res.string.report_dialog_select_reason_placeholder),
options = reasonOptions,
onSelect = { selectedReason = it },
modifier = Modifier.fillMaxWidth(),
)
SpacerH16()
OutlinedTextField(
value = additionalReason,
onValueChange = { additionalReason = it },
placeholder = {
Text(text = stringRes(Res.string.report_dialog_additional_reason_placeholder))
},
label = { Text(stringRes(Res.string.report_dialog_additional_reason_label)) },
modifier = Modifier.fillMaxWidth(),
)
SpacerH16()
ActionButton(
text = stringRes(Res.string.report_dialog_post_report_btn),
icon = MaterialSymbols.Report,
enabled = selectedReason in 0..reportTypes.lastIndex,
onClick = {
onReport(reportTypes[selectedReason].first, additionalReason)
onDismiss()
},
)
}
}
}
}
@Composable private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp))
@Composable
private fun SectionHeader(text: String) =
Text(
text = text,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 18.sp,
)
@Composable
private fun ActionButton(
text: String,
icon: MaterialSymbol,
enabled: Boolean = true,
onClick: () -> Unit,
) = Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(containerColor = LightRedColor),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
symbol = icon,
contentDescription = null,
tint = Color.White,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = Color.White)
}
}
@@ -0,0 +1,79 @@
/*
* 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.commons.nip73ExternalIds.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.hashtag_exclusive
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
/** Header card for a NIP-22 comment scoped to a hashtag (NIP-73 `#t` external id): tag icon + the tagged topic as a link. */
@Composable
fun HashtagExternalIdCard(
topic: String,
linkInteractionListener: LinkInteractionListener,
) {
Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.Tag,
contentDescription = stringRes(id = Res.string.hashtag_exclusive),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(StdHorzSpacer)
Text(
text =
buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("hashtag", null, linkInteractionListener),
) {
append(topic)
}
},
style =
LocalTextStyle.current.copy(
fontWeight = FontWeight.Bold,
),
maxLines = 1,
)
}
}
@@ -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.screen.loggedIn.products
package com.vitorpamplona.amethyst.commons.nip99Classifieds.ui
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
@@ -33,13 +33,9 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewProductButton(
accountViewModel: AccountViewModel,
nav: INav,
) {
fun NewProductButton(nav: INav) {
FloatingActionButton(
onClick = {
nav.nav(Route.NewProduct())
@@ -0,0 +1,56 @@
/*
* 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.commons.nipCCGeocaching.ui
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.feeds.FeedState
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
/**
* The addresses logged as found in a Finds feed ([feedState]), as `37516:pubkey:d` strings.
* Empty until the feed has loaded.
*
* Deriving it from the feed rather than scanning the cache directly means it tracks a new log
* the moment the feed does, which is what makes a hunt's progress bar move as you walk it.
*/
@Composable
fun rememberFoundCacheIds(feedState: FeedState): Set<String> =
when (feedState) {
is FeedState.Loaded -> rememberLoadedFoundCacheIds(feedState)
else -> emptySet()
}
/**
* Collects the loaded feed's own flow. [FeedState.Loaded] is handed out once and then mutated
* through its inner flow, so reading `.value` here would freeze the set at whatever the feed held
* when the hunt screen first composed — the progress bar would never move.
*/
@Composable
private fun rememberLoadedFoundCacheIds(state: FeedState.Loaded): Set<String> {
val loaded by state.feed.collectAsStateWithLifecycle()
return remember(loaded) {
loaded.list.mapNotNullTo(mutableSetOf()) { (it.event as? GeocacheFoundLogEvent)?.geocacheId() }
}
}
@@ -0,0 +1,164 @@
/*
* 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.commons.nipsOnNostr.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.kinds
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds
/** Card for a NIP published on Nostr ("NIPs on Nostr"): title, summary or opening text, and the kinds it defines. */
@Composable
fun NipCard(noteEvent: NipTextEvent) {
val title = remember(noteEvent) { noteEvent.title() }
val kinds = remember(noteEvent) { noteEvent.kinds() }
Column(
modifier =
Modifier
.padding(top = Size5dp)
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
QuoteBorder,
),
verticalArrangement = SpacedBy5dp,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
)
}
Text(
text = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content },
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,
)
if (kinds.isNotEmpty()) {
FlowRow(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
horizontalArrangement = SpacedBy5dp,
verticalArrangement = SpacedBy5dp,
itemVerticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(Res.string.kinds),
)
kinds.forEach {
NoPaddingSuggestionChip(
label = it.toString(),
)
}
}
}
}
}
@Preview
@Composable
fun NipCardPreview() {
val event =
NipTextEvent(
id = "eb2b05394ff0014bb6a79c2eacfd1c80696821592f4dbf86c950c4bf16614aa0",
pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
createdAt = 1767978398,
content = "Trusted Translations\n--------------------\n\n`draft` `optional`\n\nThis NIP allows anyone to post a translation for any event on a `kind:76`.\n\n```js\n{\n \"kind\": 76,\n \"tags\": [\n [\"e\", \"\u003coriginal_event_id\u003e\", \"\u003crelay\u003e\"]\n [\"k\", \"\u003coriginal_event_kind\u003e\"]\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n [\"s\", \"title\", \"translated title tag\"]\n [\"s\", \"summary\", \"translated summary tag\"]\n ],\n \"content\": \"this is a translated version of the original content\",\n // ...other fields\n}\n```\n\n`e` tag points to the event being translated, `k` tag points to the kind of that event.\n\n`l` tags define the language this was translated to in lowercase codes as defined by ISO 639-1. \n\n`s` tags are the translations for tags in the original event. \n\n`.content` contains the translation of the original `.content`\n\nClients SHOULD request translations by `e` and `l` tags spoken by their user. For every tag being rendered, Clients SHOULD look for their translated versions.\n\nProviders SHOULD use the event id in the filter to know which events need translations.\n\nProviders MAY store their translations behind a paid relay with NIP-42 auth.\n\n## Declaring Translation Providers\n\nKind `10041` lists the user's authorized translation providers. Each `p` tag is followed by the `pubkey` of the service publishing kind 76s, and the relay translations can be found. Users can specify these publicly or privately by JSON-stringifying and encrypting the tag list in the `.content` using NIP-44. \n\n```js\n{\n \"kind\": 10041,\n \"tags\": [\n [\"p\", \"4fd5e210530e4f6b2cb083795834bfe5108324f1ed9f00ab73b9e8fcfe5f12fe\", \"wss://translations.nostr.com\"],\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n //...\n}\n```\n\n`l` tags in this event are the languages the user understands and wants translations to.\n\nProviders SHOULD create the `10041` event and post to the user's outbox relay.",
sig = "7fa9f1d49c41c7bfbdad6d089d1c6685777c86de8fbda7662a630888658839f0444cb1398385f759461c09191b287fa222eec0ffe22b3fa8cf740f71aab9dc21",
tags =
arrayOf(
arrayOf("d", "trusted-translations"),
arrayOf("title", "Trusted Translations"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("client", "nostrhub.io"),
),
)
ThemeComparisonColumn(
toPreview = { NipCard(event) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoPaddingSuggestionChip(
label: String,
modifier: Modifier = Modifier,
) {
Surface(
shape = MaterialTheme.shapes.extraSmall, // Use a small shape for chip look
color = MaterialTheme.colorScheme.secondaryContainer, // Default chip color
modifier = modifier,
) {
Text(
text = label,
// Apply desired internal padding to the Text itself
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@@ -18,20 +18,15 @@
* 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.profile.relays
package com.vitorpamplona.amethyst.commons.profile.ui
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relays
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun RelaysTabHeader(
baseUser: User,
accountViewModel: AccountViewModel,
) {
fun RelaysTabHeader() {
Text(text = stringRes(Res.string.relays))
}
@@ -0,0 +1,169 @@
/*
* 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.commons.ui.components
import androidx.collection.mutableScatterMapOf
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.util.fastForEach
/**
* [Crossfade][MyCrossfade] between [targetState]s when [enabled], a plain swap otherwise (the app
* turns it off in performance mode). The animated path defers building its transition until the
* target first changes; see [DeferredCrossfade].
*/
@Composable
fun <T> CrossfadeIfEnabled(
targetState: T,
enabled: Boolean,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
content: @Composable (T) -> Unit,
) {
if (enabled) {
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
} else {
Box(modifier, contentAlignment) {
content(targetState)
}
}
}
/** Latches the first time a crossfade's target moves off the value it was composed with. */
private class ChangeLatch {
var changed = false
}
/**
* A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there
* is something to animate.
*
* `updateTransition` allocates a transition, its animation list and its seeking state on *first
* composition*, even though first composition has nothing to cross-fade — target and initial state
* are the same value. In a feed that is waste: every card scrolled in builds a transition per
* animated element, and during a scroll essentially none of them run, because the underlying counts
* and icons do not change in the second a card is on screen.
*
* So the plain content renders until the target actually moves. At that point the transition is
* built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at
* the new one, so the first real change still animates exactly as before; every later change
* animates through the now-live transition normally.
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> DeferredCrossfade(
targetState: T,
modifier: Modifier,
contentAlignment: Alignment,
animationSpec: FiniteAnimationSpec<Float>,
label: String,
content: @Composable (T) -> Unit,
) {
val initial = remember { targetState }
val latch = remember { ChangeLatch() }
if (targetState != initial) latch.changed = true
if (!latch.changed) {
Box(modifier, contentAlignment) {
content(targetState)
}
} else {
val transitionState = remember { MutableTransitionState(initial) }
transitionState.targetState = targetState
val transition = rememberTransition(transitionState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> MyCrossfade(
targetState: T,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
content: @Composable (T) -> Unit,
) {
val transition = updateTransition(targetState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
@ExperimentalAnimationApi
@Composable
fun <T> Transition<T>.MyCrossfade(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
contentKey: (targetState: T) -> Any? = { it },
content: @Composable (targetState: T) -> Unit,
) {
val currentlyVisible = remember { mutableStateListOf<T>().apply { add(currentState) } }
val contentMap = remember { mutableScatterMapOf<T, @Composable () -> Unit>() }
// If not animating, just display the current state
if (currentState == targetState && (currentlyVisible.size != 1 || currentlyVisible[0] != targetState)) {
// Remove all the intermediate items from the list once the animation is finished.
currentlyVisible.removeAll { it != targetState }
contentMap.clear()
}
if (targetState !in contentMap) {
// Replace target with the same key if any
val replacementId =
currentlyVisible.indexOfFirst { contentKey(it) == contentKey(targetState) }
if (replacementId == -1) {
currentlyVisible.add(targetState)
} else {
currentlyVisible[replacementId] = targetState
}
contentMap.clear()
currentlyVisible.fastForEach { stateForContent ->
contentMap[stateForContent] = {
val alpha by
animateFloat(transitionSpec = { animationSpec }) {
if (it == stateForContent) 1f else 0f
}
Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) { content(stateForContent) }
}
}
}
Box(modifier, contentAlignment) {
currentlyVisible.fastForEach { key(contentKey(it)) { contentMap[it]?.invoke() } }
}
}
@@ -0,0 +1,77 @@
/*
* 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.commons.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_to_public_bookmarks
import com.vitorpamplona.amethyst.commons.resources.remove_from_public_bookmarks
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Round bookmark toggle: a filled, primary-tinted bookmark when [isBookmarked], an outlined
* "add" bookmark otherwise. [onToggle] adds or removes the bookmark.
*
* A compact clickable glyph rather than a Material IconButton, whose 48dp minimum touch target
* would stand taller than a title it sits beside; [iconSize] defaults to one title line.
*/
@Composable
fun BookmarkToggleButton(
isBookmarked: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
) {
Box(
modifier =
modifier
.clip(CircleShape)
.clickable(
role = Role.Button,
onClick = onToggle,
).padding(4.dp),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
contentDescription =
stringRes(
if (isBookmarked) Res.string.remove_from_public_bookmarks else Res.string.add_to_public_bookmarks,
),
tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(iconSize),
)
}
}
@@ -18,31 +18,36 @@
* 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.publicChannels.ephemChat.header.actions
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.leave
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.commons.ui.theme.lessImportantLink
/**
* The `#tag` link a note header shows when the note carries a hashtag the viewer follows,
* explaining why it is in their feed. Single line, ellipsized.
*/
@Composable
fun LeaveChatButton(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
fun FollowedHashtagLabel(
tag: String,
onClick: () -> Unit,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.unfollow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.leave))
Column(verticalArrangement = Arrangement.Center) {
Row(verticalAlignment = Alignment.CenterVertically) {
ClickableTextColor(
"#$tag",
linkColor = MaterialTheme.colorScheme.lessImportantLink,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
onClick = onClick,
)
}
}
}
@@ -18,31 +18,38 @@
* 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.publicChannels.ephemChat.header.actions
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.join
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.resources.pow_settings_title
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Composable
fun JoinChatButton(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.follow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(Res.string.join))
}
@Preview
fun ProofOfWorkPillPreview() {
ThemeComparisonColumn(
toPreview = { ProofOfWorkPill(pow = 24, onClick = {}) },
)
}
/**
* Compact pill showing the proof of work a received note carries: a gear plus
* the difficulty in leading zero bits. Sits inline in note headers, so it stays
* at text height. [onClick] is where the host explains what the number means.
*/
@Composable
fun ProofOfWorkPill(
pow: Int,
onClick: () -> Unit,
) {
HeaderPill(
symbol = MaterialSymbols.Manufacturing,
text = pow.toString(),
contentDescription = stringRes(Res.string.pow_settings_title),
onClick = onClick,
)
}