mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Merge pull request #3654 from davotoula/feat/share-note-as-qr
Share a note (link) as a QR code
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user