Merge pull request #3654 from davotoula/feat/share-note-as-qr

Share a note (link) as a QR code
This commit is contained in:
Vitor Pamplona
2026-07-21 09:57:06 -04:00
committed by GitHub
10 changed files with 912 additions and 2 deletions

View File

@@ -567,6 +567,7 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)

View File

@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -583,6 +584,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImage> { ShareNoteAsImageScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImageFile> { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsQr> { ShareNoteAsQrScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }

View File

@@ -507,6 +507,10 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class ShareNoteAsQr(
val id: String,
) : Route()
@Serializable data class ContactListUsers(
val noteId: String,
) : Route()

View File

@@ -38,8 +38,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
* both from the reaction-row Share button and from the note's 3-dot menu).
*
* Only the true "send it somewhere" options live here — browser link, image
* file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so
* they are intentionally NOT part of this shared element.
* file, image URL, and the display-only QR code. The copy-to-clipboard
* options stay in the 3-dot menu, so they are intentionally NOT part of this
* shared element.
*
* Callers only render these for non-private notes: every option exposes the
* note publicly (a shareable web link, or an image of it), which must never
@@ -76,4 +77,8 @@ fun ShareActionRows(
nav.nav(Route.ShareNoteAsImage(shareId))
onDismiss()
}
M3ActionRow(icon = MaterialSymbols.QrCode2, text = stringRes(R.string.share_as_qr)) {
nav.nav(Route.ShareNoteAsQr(shareId))
onDismiss()
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.share
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
/** Which of the two payloads the QR code currently encodes. */
enum class QrPayloadMode {
/**
* An `https://njump.to/…` link. The default, because a stock phone camera will offer to
* open an http(s) URL but may treat a bare custom scheme as inert text.
*/
Web,
/**
* A `nostr:nevent1…` / `nostr:naddr1…` URI. Resolves without a web round-trip and is what
* in-app scanners expect.
*/
Nostr,
}
/**
* The string encoded into the QR code for [note] in [mode].
*
* Both payloads carry the note's relay hint: [externalLinkForNote] builds its URL from
* `toNAddr()` / `toNEvent()`, which already call `relayHintUrl()`.
*/
fun qrPayloadFor(
note: Note,
mode: QrPayloadMode,
): String =
when (mode) {
QrPayloadMode.Web -> externalLinkForNote(note)
QrPayloadMode.Nostr -> note.toNostrUri()
}

View File

@@ -0,0 +1,258 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.share
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable
// resolution on low-density screens. Deriving the size from the available column width keeps
// enough real pixels per module; this only bounds it from growing unreasonably large on tablets.
private val QrMaxSize = 320.dp
/**
* Display-only screen presenting [id]'s note as a scannable QR code.
*
* There is no export or save action by design — the screen exists to be held up and
* photographed by another device.
*
* F6: the Scaffold (and its back button) lives in this id-based wrapper, OUTSIDE LoadNote's
* null check, so an id that never resolves to a note still leaves the user a way back — only
* the body inside is empty in that case. Rendering nothing else for an unresolved id is
* deliberate, matching ShareNoteAsImageScreen; only the missing chrome was the bug.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareNoteAsQrScreen(
id: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
) { pad ->
LoadNote(id, accountViewModel) { note ->
if (note != null) {
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareNoteAsQrScreen(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
) { pad ->
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShareNoteAsQrScreenContent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
pad: PaddingValues,
) {
var mode by remember { mutableStateOf(QrPayloadMode.Web) }
// F4: keyed on the observed note state, not on `note` alone (a stable object identity that
// does not change while the event and the author's relay list are still loading). A payload
// computed before then would omit the relay hint (Note.relayHintUrl()) and never recompute.
// Keying on `noteState` re-derives the payload once the event arrives — the same observation
// SharedNoteCard uses for its own sensitivity gate (F1).
val noteState by observeNote(note, accountViewModel)
val payload = remember(noteState, mode) { qrPayloadFor(note, mode) }
KeepScreenBrightAndAwake()
// F5: scrollable so the toggle and hint — the screen's only controls — stay reachable on a
// short viewport (landscape, split screen, large font scale) where the square QR plus the
// card above it can otherwise exceed the available height. A plain fillMaxSize() Column would
// silently place that overflow outside its bounds instead of clipping or scrolling to it.
Column(
modifier =
Modifier
.padding(pad)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SharedNoteCard(note, accountViewModel, nav)
val qrContentDescription =
when (mode) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_code_description_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_code_description_nostr)
}
QrCodeDrawer(
contents = payload,
modifier =
Modifier
.widthIn(max = QrMaxSize)
.fillMaxWidth()
.semantics { contentDescription = qrContentDescription },
)
// Fill the width so each button gets an equal, generous half (a bare
// SingleChoiceSegmentedButtonRow shrinks to content and clips longer labels), and pass an
// empty `icon` so the selected-state checkmark never steals horizontal room from the
// label — selection is already signalled by the fill colour. Both matter for translated
// labels, which are often longer than the English "Web link" / "Nostr link".
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
val modes = listOf(QrPayloadMode.Web, QrPayloadMode.Nostr)
modes.forEachIndexed { index, candidate ->
SegmentedButton(
selected = mode == candidate,
onClick = { mode = candidate },
shape = SegmentedButtonDefaults.itemShape(index = index, count = modes.size),
icon = {},
) {
Text(
text =
when (candidate) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_mode_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_mode_nostr)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Text(
text =
when (mode) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_hint_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_hint_nostr)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
/**
* Raises the screen to full brightness and prevents it sleeping while the QR is displayed,
* restoring both on exit.
*
* This is functional, not polish: the screen exists to be photographed, and a dark-theme phone
* on auto-brightness in a dim room is exactly the case that fails.
*
* Residual hazard, not reachable today: [LocalView.current] is the Activity's single shared root
* `ComposeView`, not a view scoped to this screen. During a nav transition two compositions can
* briefly coexist on that same root view, so this screen's `onDispose` could in theory clobber
* brightness/`keepScreenOn` state an incoming screen has already set. Nothing in the current nav
* graph triggers that overlap, so this is left as a comment rather than code.
*/
@Composable
private fun KeepScreenBrightAndAwake() {
val view = LocalView.current
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
// chain (WindowUtils.kt:39-46).
val window = getActivityWindow()
DisposableEffect(window, view) {
// Capture the RAW attribute, not a computed fraction. When no override is set this is
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
// brightness. Restoring a *computed* fraction would install an override where none
// existed and silently disable auto-brightness for the rest of the session.
val previousBrightness = window?.attributes?.screenBrightness
// F8: same capture/replay discipline as brightness above, and for the same reason.
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
// Hard-setting `false` on dispose — instead of restoring what was here before this
// screen took it over — would clobber that ownership: navigating back from the QR
// screen while audio or video is still playing would let the screen sleep mid-playback.
val previousKeepScreenOn = view.keepScreenOn
window?.let {
it.attributes = it.attributes.apply { screenBrightness = 1f }
}
view.keepScreenOn = true
onDispose {
// Restore the captured value rather than calling a release helper: resetting to
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
// already had, e.g. one left by the fullscreen video controls.
window?.let { w ->
previousBrightness?.let { prev ->
w.attributes = w.attributes.apply { screenBrightness = prev }
}
}
view.keepScreenOn = previousKeepScreenOn
}
}
}

View File

@@ -0,0 +1,320 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.share
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThumbnail
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.alt
import com.vitorpamplona.quartz.nip71Video.blurhash
import com.vitorpamplona.quartz.nip71Video.mimeType
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
private val CardHeight = 72.dp
private val ThumbSize = 56.dp
private val ThumbShape = RoundedCornerShape(9.dp)
/**
* A fixed-height preview of the note being shared, shown above the QR code.
*
* The height is fixed on purpose: it keeps the QR code in the same screen position for every
* note, so the screen is predictable to hold up to a scanner. That is why this does not use
* [com.vitorpamplona.amethyst.ui.note.NoteCompose] — see the design spec for the full reasoning,
* but in short, `isQuotedNote` never reaches the media renderer and note images render at their
* natural aspect ratio with no height ceiling.
*/
@Composable
fun SharedNoteCard(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().height(CardHeight).padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
// Plain SensitivityWarning is NOT enough here: it gates on event.isSensitiveOrNSFW(),
// which only reads the note-level content-warning tag / nsfw hashtag. GalleryThumbnail's
// inner gate (GalleryThumb.kt:236) reads each media entry's per-imeta `contentWarning`,
// but none of GalleryThumb.kt's four media-construction sites ever set that field, so it
// is always null and that inner gate is permanently dead on this path. A note whose only
// warning lives inside an `imeta` tag (e.g. a kind 20 PictureEvent) would then render
// completely unblurred. `hasImetaContentWarning` below checks every imeta for the mere
// PRESENCE of a `content-warning` key, not for non-blank reason text: an imeta warning
// with an empty reason string (`["content-warning"]` with no second element) still counts
// — collectContentWarningReasons()'s `takeIf { it.isNotBlank() }` would silently drop that
// exact case, which is what let it slip the gate before. collectContentWarningReasons()
// is still called for the human-readable *reason text*, shown in the covered box's
// accessibility label when one exists — it never drives the show/hide decision.
//
// `note.event` is a plain field read that never recomposes if this composable is
// rendered before the note's event has arrived over the relay (id-only reference, e.g.
// straight off a deep link). observeNote() subscribes both the relay finder and the
// LocalCache flow, so `event` below updates — and this whole gate recomputes — the
// moment the event loads or changes, the same idiom GalleryThumbnail itself already uses
// (GalleryThumb.kt:78).
//
// This deliberately does NOT use the shared ContentWarningGate: its overlay
// (ContentWarningOverlayBody, SensitivityWarning.kt:254+) opens with an 80.dp icon box
// that consumes this card's entire 56.dp thumbnail height, pushing the title, reasons,
// and "Show anyway" button below the clipped, tappable area. Reshaping that shared
// composable was rejected — six other screens depend on its current layout — so this
// call site renders its own compact, permanently-covered state instead: no reveal
// affordance, because this screen is held up in public and pointed at someone else's
// camera. `accountViewModel.showSensitiveContent()` is still honoured exactly as
// ContentWarningGate honours it (SensitivityWarning.kt:138-140), so a user who has
// opted into seeing sensitive content globally sees the real thumbnail here too. Either
// way the thumbnail box stays a fixed 56.dp, keeping this Row's height fixed at 72.dp.
//
// `nav` is passed because GalleryThumbnail's signature demands it, but it is unused
// there (GalleryThumb.kt:76) — navigation comes from ClickableNote at its other call
// site. This card is not tappable.
val noteState by observeNote(note, accountViewModel)
val event = noteState.note.event
val reasons = remember(event) { event?.let { collectContentWarningReasons(it) } ?: emptySet() }
val hasImetaContentWarning =
remember(event) {
event?.imetas()?.any { it.properties.containsKey(ContentWarningTag.TAG_NAME) } ?: false
}
val isSensitive =
remember(event, hasImetaContentWarning) {
event != null && (event.isSensitiveOrNSFW() || hasImetaContentWarning)
}
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
val isGated = isSensitive && showSensitiveContent != true
// Thumbnail source, in priority order:
// 1. structured media event (kind 20/21/22, gallery, live clip) -> GalleryThumbnail;
// 2. an image carried in an `imeta` tag of an otherwise-unstructured note (a kind 1
// image post — content is typically just the media URL) -> render that image;
// 3. no media at all -> the author's round AVATAR, which (unlike GalleryThumbnail's own
// DisplayGalleryAuthorBanner fallback, a banner crop) cannot be mistaken for the
// note's own picture (F7).
// hasStructuredMedia() mirrors GalleryThumbnail's own per-kind checks (GalleryThumb.kt:
// 82-186); contentImage covers the case GalleryThumbnail does NOT handle — a bare image
// URL in an imeta on a kind 1 — so those posts show the picture instead of avatar + URL.
val hasStructuredMedia = remember(event) { event != null && hasStructuredMedia(event) }
val contentImage = remember(event) { event?.let { firstContentImage(it) } }
Box(Modifier.size(ThumbSize).clip(ThumbShape)) {
if (isGated) {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription =
reasons.firstOrNull()?.let { stringRes(R.string.content_warning_with_reason, it) }
?: stringRes(R.string.share_as_qr_thumbnail_hidden_sensitive),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else if (hasStructuredMedia) {
GalleryThumbnail(note, accountViewModel, nav)
} else if (contentImage != null) {
// UrlImageView crops to fill (ContentScale.Crop) and honours the account's
// show-images setting and blossom bridge on its own; the enclosing 56.dp Box
// bounds it so the card height stays fixed. No extra SensitivityWarning: the
// isGated branch above already covered the sensitive case.
UrlImageView(contentImage, accountViewModel)
} else {
NoteAuthorPicture(note, ThumbSize, accountViewModel)
}
}
Column(Modifier.weight(1f)) {
Text(
text = note.author?.toBestDisplayName() ?: "",
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = secondaryLineFor(event, isGated, contentImage != null),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* Whether [event] carries the kind of structured media [GalleryThumbnail] would render (a
* profile-gallery entry, kind 20 picture, kind 21/22 video, or live-activity clip with a video
* URL) — used only to pick between the media thumbnail and the author-avatar fallback (F7).
* Mirrors GalleryThumbnail's own when-branch conditions (GalleryThumb.kt:82-186) rather than
* duplicating its full [com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent] construction.
*/
private fun hasStructuredMedia(event: Event): Boolean =
when (event) {
is ProfileGalleryEntryEvent -> event.urls().isNotEmpty()
is PictureEvent -> event.imetaTags().isNotEmpty()
is VideoEvent -> event.imetaTags().isNotEmpty()
is LiveActivitiesClipEvent -> event.videoUrl() != null
else -> false
}
/** True when this `imeta` describes an image — by declared mime type, or failing that its URL. */
internal fun IMetaTag.isImage(): Boolean {
val mime = mimeType()?.firstOrNull()
return if (mime != null) mime.startsWith("image/") else RichTextParser.isImageUrl(url)
}
/**
* The first image carried in an [event]'s `imeta` tags, as a renderable [MediaUrlImage], or null
* if the note has none. Covers the common kind-1 image post whose content is just a media URL —
* a case [GalleryThumbnail] does not handle (its when-branches fall through to the author banner).
* Only images are returned; a video-only imeta yields null and the card falls back to the avatar
* rather than feeding a video URL to the image loader.
*/
internal fun firstContentImage(event: Event): MediaUrlImage? {
val imeta = event.imetas().firstOrNull { it.isImage() } ?: return null
return MediaUrlImage(
url = imeta.url,
description = imeta.alt()?.firstOrNull(),
blurhash = imeta.blurhash()?.firstOrNull(),
mimeType = imeta.mimeType()?.firstOrNull(),
)
}
/**
* The one-or-two-line description under the author name: an article's title, else the note's own
* text (with any embedded media URLs stripped), else the image's alt text, else a kind label.
*
* F3: when [isGated] (the same sensitive-and-not-opted-in decision computed for the thumbnail,
* passed in rather than recomputed) title, content and alt text are all skipped in favor of the
* neutral kind label — otherwise this line would render the sensitive note's own text unblurred
* right next to the covered thumbnail, which is exactly what `Text.kt`'s `SensitivityWarning`
* wrapping exists to prevent for note bodies elsewhere in the app. The author name stays visible
* either way; only this line is affected.
*
* [hasContentImage] lets an image-only post whose text and alt are both empty fall back to a
* "Picture" label rather than a blank line.
*/
@Composable
private fun secondaryLineFor(
event: Event?,
isGated: Boolean,
hasContentImage: Boolean,
): String {
if (event == null) return ""
// F10: remembered against (event, isGated) so a long article body is not re-trimmed on every
// recomposition — only when the underlying event or the gate decision actually changes.
val bodyText = remember(event, isGated) { secondaryBodyTextFor(event, isGated) }
if (bodyText != null) return bodyText
return when {
event is PictureEvent -> stringRes(R.string.share_as_qr_kind_picture)
event is VideoEvent -> stringRes(R.string.kind_video)
event is LongTextNoteEvent -> stringRes(R.string.article)
hasContentImage -> stringRes(R.string.share_as_qr_kind_picture)
else -> ""
}
}
// Plain (non-@Composable) so it can be wrapped in `remember` — `stringRes` calls, which the
// kind-label fallback needs, are not allowed inside a remember calculation lambda.
internal fun secondaryBodyTextFor(
event: Event,
isGated: Boolean,
): String? {
// Gated: never surface the note's own title, content or alt text, only the kind label above.
if (isGated) return null
if (event is LongTextNoteEvent) {
val title = event.title()
if (!title.isNullOrBlank()) return title
}
// An image-only post's content is typically just the media URL(s). Strip any that appear
// verbatim (only when the note actually has imeta media, so a plain article — no imeta — is
// never scanned) so the line does not show a bare CDN URL. If nothing meaningful remains,
// fall through to the alt text, then the kind label.
val mediaUrls = event.imetas().map { it.url }
val stripped =
if (mediaUrls.isEmpty()) {
event.content
} else {
mediaUrls.fold(event.content) { acc, url -> acc.replace(url, "") }
}
// F10: bounded prefix — this line only ever shows two lines of bodySmall text, so there is
// no need to trim() a full long-form article body (potentially tens of KB) to get there.
val content = stripped.take(MAX_SECONDARY_LINE_CHARS).trim()
if (content.isNotEmpty()) return content
return event
.imetas()
.firstOrNull { it.isImage() }
?.alt()
?.firstOrNull()
?.takeIf { it.isNotBlank() }
}
private const val MAX_SECONDARY_LINE_CHARS = 280

View File

@@ -531,6 +531,15 @@
<string name="share_as_image_url">Share as Image Url</string>
<string name="share_as_image_generating">Generating preview…</string>
<string name="share_as_image_watermark">Shared via Amethyst</string>
<string name="share_as_qr">Share as QR</string>
<string name="share_as_qr_mode_web">Web link</string>
<string name="share_as_qr_mode_nostr">Nostr link</string>
<string name="share_as_qr_hint_web">Scan with any phone camera</string>
<string name="share_as_qr_hint_nostr">Scan with a Nostr app</string>
<string name="share_as_qr_kind_picture">Picture</string>
<string name="share_as_qr_code_description_web">QR code containing a web link to this note</string>
<string name="share_as_qr_code_description_nostr">QR code containing a Nostr link to this note</string>
<string name="share_as_qr_thumbnail_hidden_sensitive">Thumbnail hidden for sensitive content</string>
<string name="quick_action_copy_user_id">Author ID</string>
<string name="quick_action_copy_note_id">Note ID</string>
<string name="quick_action_copy_text">Copy Text</string>

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.share
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class QrPayloadTest {
private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
private suspend fun textNote(): Note {
val event = TextNoteEvent.build("hello qr") {}.let { aliceSigner.sign(it) }
val note = Note(event.id)
note.event = event
return note
}
@Test
fun webMode_returnsAnHttpsNjumpLink() =
runTest {
val payload = qrPayloadFor(textNote(), QrPayloadMode.Web)
assertTrue(
"web mode must produce an https URL so a stock phone camera can action it, got: $payload",
payload.startsWith("https://"),
)
assertTrue(payload.contains("nevent1"))
}
@Test
fun nostrMode_returnsANostrUriWithNevent() =
runTest {
val payload = qrPayloadFor(textNote(), QrPayloadMode.Nostr)
assertTrue(payload.startsWith("nostr:nevent1"))
}
@Test
fun bothModes_encodeTheSameNote() =
runTest {
val note = textNote()
val web = qrPayloadFor(note, QrPayloadMode.Web)
val nostr = qrPayloadFor(note, QrPayloadMode.Nostr)
// The bech32 body must be identical; only the wrapper differs.
assertEquals(nostr.removePrefix("nostr:"), web.substringAfterLast('/'))
}
@Test
fun addressableNote_nostrMode_yieldsNaddrNotNevent() =
runTest {
// build(description, title, summary, image, publishedAt, dTag, createdAt, init)
// — `title` is a required positional; `dTag` must be named.
// LongTextNoteEvent.kt:148-157.
val event =
LongTextNoteEvent
.build("body", "My Article", dTag = "my-article") {}
.let { aliceSigner.sign(it) }
val note = AddressableNote(event.address())
note.event = event
val payload = qrPayloadFor(note, QrPayloadMode.Nostr)
assertTrue(
"AddressableNote must encode as naddr via the toNEvent() override, got: $payload",
payload.startsWith("nostr:naddr1"),
)
}
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.share
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip92IMeta.imetas
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SharedNoteCardTest {
private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
private val blossomUrl = "https://blossom.primal.net/66332885d206714439e39e441b6539622a07c108"
private suspend fun kind1(
content: String,
imetaUrl: String? = null,
mime: String? = null,
alt: String? = null,
): Event =
TextNoteEvent
.build(content) {
if (imetaUrl != null) {
imetas(
listOf(
IMetaTagBuilder(imetaUrl)
.apply {
mime?.let { add("m", it) }
alt?.let { add("alt", it) }
}.build(),
),
)
}
}.let { aliceSigner.sign(it) }
// ---- firstContentImage ----
@Test
fun imageImeta_isPickedAsTheThumbnailImage() =
runTest {
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg")
val image = firstContentImage(note)
assertEquals(blossomUrl, image?.url)
}
@Test
fun plainTextNote_hasNoContentImage() =
runTest {
val note = kind1(content = "just some words, no media")
assertNull(firstContentImage(note))
}
@Test
fun videoImeta_isNotRenderedAsAnImage() =
runTest {
// A video-only imeta must not be fed to the image loader; the card falls back to the avatar.
val note = kind1(content = "clip", imetaUrl = "https://cdn.example/v", mime = "video/mp4")
assertNull(firstContentImage(note))
}
// ---- secondaryBodyTextFor ----
@Test
fun imageOnlyPost_stripsTheBareUrlToNothing() =
runTest {
// content is exactly the media URL — the whole point of the fix: never show the raw CDN URL.
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg")
assertNull(secondaryBodyTextFor(note, isGated = false))
}
@Test
fun imageOnlyPostWithAlt_fallsBackToAltText() =
runTest {
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg", alt = "A sunset")
assertEquals("A sunset", secondaryBodyTextFor(note, isGated = false))
}
@Test
fun textPlusImage_keepsTheTextWithoutTheUrl() =
runTest {
val note = kind1(content = "check this out $blossomUrl", imetaUrl = blossomUrl, mime = "image/jpeg")
assertEquals("check this out", secondaryBodyTextFor(note, isGated = false))
}
@Test
fun gatedNote_yieldsNoBodyText() =
runTest {
val note = kind1(content = "something sensitive", imetaUrl = blossomUrl, mime = "image/jpeg", alt = "nsfw")
assertNull(secondaryBodyTextFor(note, isGated = true))
}
@Test
fun article_prefersItsTitle() =
runTest {
val note =
LongTextNoteEvent
.build("the body", "My Article", dTag = "a1") {}
.let { aliceSigner.sign(it) }
assertEquals("My Article", secondaryBodyTextFor(note, isGated = false))
}
// ---- IMetaTag.isImage ----
@Test
fun isImage_trueForImageMime_falseForVideoMime() =
runTest {
val img = IMetaTagBuilder("https://x/y").add("m", "image/png").build()
val vid = IMetaTagBuilder("https://x/y").add("m", "video/mp4").build()
assertTrue(img.isImage())
assertTrue(!vid.isImage())
}
@Test
fun isImage_fallsBackToUrlExtensionWhenNoMime() =
runTest {
val withExt = IMetaTagBuilder("https://x/photo.jpg").build()
val extensionless = IMetaTagBuilder("https://blossom.example/deadbeef").build()
assertTrue(withExt.isImage())
assertTrue(!extensionless.isImage())
}
}