Merge origin/main into claude/intelligent-babbage-23komv

Conflict: main rewrote the calendar RSVP renderer to merge the appointment into the card,
using the calendar screen helpers (date badge, range formatting, relative time), which are still
Android-only. The renderer goes back to the app and the shared NoteCompose reaches it through a
new NotePlatform.RenderCalendarRSVPEvent slot; the default draws the bare RSVP card.

FollowsGoing and CalendarAppointmentView move from commons/jvmAndroid to commonMain: they are pure
Kotlin, and the Home filter that calls them is common on this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-29 01:16:45 +00:00
66 changed files with 3049 additions and 387 deletions
+2 -22
View File
@@ -338,6 +338,8 @@ implementation('com.vitorpamplona.quartz:quartz-android:1.16.0')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.16.0')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.16.0')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.16.0')
implementation('com.vitorpamplona.quartz:quartz-macosarm64:1.16.0')
implementation('com.vitorpamplona.quartz:quartz-linuxx64:1.16.0')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
@@ -469,28 +471,6 @@ When your app goes to the background, you can use NostrClient's `connect` and `d
methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect`
to `onPause` methods.
### Feature Parity Table
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
|:-------------------------|:-------------------------------|:---------------------:|:-----------:|:-----------------------------------------------------------------------|
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ✅ Full | |
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ✅ Full | |
| | AES Encryption (CBC & GCM) | ✅ Full | ✅ Full | |
| | Hashing (SHA-256, etc.) | ✅ Full | ✅ Full | |
| | MAC (HmacSHA256, etc.) | ✅ Full | ✅ Full | |
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ✅ Full | A fully custom implementation exists in `commonMain`. |
| | GZip Compression | ✅ Full | ✅ Full | |
| | BitSet | ✅ Full | ✅ Full | |
| | LargeCache | ✅ Full | ✅ Full | |
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ✅ Full | |
| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
| **Utilities** | URL Encoding / Decoding | ✅ Full | ✅ Full | |
| | Unicode Normalization | ✅ Full | ✅ Full | |
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
## Contributing
See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full guide — workflow,
@@ -34,6 +34,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.utils.Log
import kotlin.coroutines.cancellation.CancellationException
/**
* Content-extraction helpers shared by the per-kind notification renderers:
@@ -176,15 +178,24 @@ object NotificationContent {
return RenderedText(text, resolved.citedUsers, imageUrl)
}
/**
* A private zap request is signed by a one-time key; the real zapper and message are encrypted
* for the zapped author. Decrypts them when [signer] is that recipient, otherwise (or when the
* signer can't) returns the request as-is.
*/
suspend fun decryptZapContentAuthor(
event: ZapRequestEvent,
signer: NostrSigner,
): Event? =
if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) {
): Event? {
if (!event.isPrivateZap() || signer.pubKey !in event.zappedAuthor()) return event
return try {
signer.decryptZapEvent(event)
} else {
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("NotificationContent", "Could not decrypt private zap ${event.id}", e)
event
}
}
suspend fun decryptContent(
note: Note,
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioFromIMeta as AppRende
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader as AppRenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack as AppRenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWithWaveform as AppRenderAudioWithWaveform
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent as AppRenderCalendarRSVPEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame as AppRenderChessGame
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent as AppRenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent as AppRenderGitPatchEvent
@@ -436,6 +437,17 @@ object AndroidNotePlatform : NotePlatform {
nav = nav,
)
@Composable
override fun RenderCalendarRSVPEvent(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) = AppRenderCalendarRSVPEvent(
note = baseNote,
accountViewModel = accountViewModel,
nav = nav,
)
@Composable
override fun RenderMeetingSpaceEvent(
baseNote: Note,
@@ -0,0 +1,297 @@
/*
* 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.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarAppointmentLines
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarDateBadge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detailRouteFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
/**
* Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is
* in the cache, and renders the shared commons [CalendarRsvpCard] with that appointment merged
* into its frame — "going" is only interesting next to *what*. Until the appointment loads the
* card shows its status strip over a "loading" line.
*
* [LoadAddressableNote] creates the [com.vitorpamplona.amethyst.commons.model.AddressableNote] in
* `LocalCache` — a shell with a null event if we have never seen the appointment — and
* [WatchNoteEvent]'s event-finder subscription then asks relays for it: `filterMissingAddressables`
* picks up exactly those addressables whose `event == null` and queries the address author's
* outbox relays plus any stored hints.
*
* Both halves matter beyond drawing this card. `EventBroadcaster` routes an RSVP by following its
* `a` tag into the appointment and reading the participants off it, and every step of that walk
* is a `LocalCache` lookup. An RSVP seen in a feed for an appointment that was never cached would
* otherwise leave the cache with no entry to walk, so answering it from here would reach the host
* (whose pubkey the coordinate carries) but none of the other invitees.
*
* Composition-scoped like every other per-note subscription: the row unsubscribes ~30s after it
* scrolls away or the app backgrounds.
*/
@Composable
fun RenderCalendarRSVPEvent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? CalendarRSVPEvent ?: return
val address = remember(event) { event.calendarEventAddress() }
if (address == null) {
CalendarRsvpCard(event)
return
}
LoadAddressableNote(address) { appointment ->
if (appointment == null) {
CalendarRsvpCard(event)
} else {
// WatchNoteEvent subscribes the event finder only while the appointment is missing.
// Once it is here the card has everything it draws; a standing subscription would
// keep asking relays for the appointment's reactions and zaps on every RSVP in the feed.
WatchNoteEvent(
baseNote = appointment,
onNoteEventFound = { RsvpWithAppointment(event, appointment, accountViewModel, nav) },
onBlank = { CalendarRsvpCard(event) },
accountViewModel = accountViewModel,
)
}
}
}
/**
* The appointment drawn *inside* the RSVP's frame, under its status strip: cover image full
* width, then the same date badge and text lines the calendar list uses. No author header — the
* post already says who answered — and no card of its own, so there is one frame, not two.
* "In 3 days" moves up into the strip, next to the answer it qualifies.
*/
@Composable
private fun RsvpWithAppointment(
event: CalendarRSVPEvent,
appointment: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val view = appointment.appointmentView()
if (view == null) {
CalendarRsvpCard(event)
return
}
val context = LocalContext.current
val appointmentEvent = appointment.event
val range = remember(appointmentEvent) { formatCalendarRange(appointment, context) }
val relative = rememberRelativeTimeLabel(view, appointmentEvent?.id)
val route = remember(appointment) { detailRouteFor(appointment) }
CalendarRsvpCard(
event = event,
statusDetail = relative,
onClick = { nav.nav(route) },
) {
val image = view.image
if (!image.isNullOrBlank()) {
MyAsyncImage(
imageUrl = image,
contentDescription = view.title,
contentScale = ContentScale.Crop,
mainImageModifier = CoverImageModifier,
loadedImageModifier = Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = { Box(CoverImageModifier) },
onError = null,
)
}
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top,
) {
CalendarDateBadge(view.startSeconds)
Spacer(modifier = Modifier.size(12.dp))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
CalendarAppointmentLines(view, range, relative = null)
}
}
}
}
private val CoverImageModifier = Modifier.fillMaxWidth().aspectRatio(2f)
// --------------------------------------------------------------------------------------------
// Previews
// --------------------------------------------------------------------------------------------
private const val PREVIEW_HOST = "c07b08396b7bffdb659f862dd7ead57ae169caea65ed573d161e13c1cd6d490c"
private const val PREVIEW_FRIEND = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
private fun previewConference() =
CalendarTimeSlotEvent(
id = "5c1a0e3b8f2d4a6e9b7c1d3f5a7e9c2b4d6f8a1c3e5b7d9f2a4c6e8b1d3f5a7e",
pubKey = PREVIEW_HOST,
createdAt = 1788000000,
tags =
arrayOf(
arrayOf("d", "nostr-conference-2026"),
arrayOf("title", "Nostr Conference 2026"),
arrayOf("image", "https://image.nostr.build/conference-cover.jpg"),
arrayOf("start", "1791622800"),
arrayOf("end", "1791655200"),
arrayOf("start_tzid", "Europe/Lisbon"),
arrayOf("location", "Lisbon, Portugal"),
),
content = "Two days of talks, workshops and hacking on the protocol.",
sig = "0".repeat(128),
)
private fun previewMeetup() =
CalendarDateSlotEvent(
id = "8e2c4a6f1b3d5e7a9c2e4b6d8f1a3c5e7b9d2f4a6c8e1b3d5f7a9c2e4b6d8f1a",
pubKey = PREVIEW_HOST,
createdAt = 1788000000,
tags =
arrayOf(
arrayOf("d", "bitcoin-meetup"),
arrayOf("title", "Bitcoin Meetup: open discussion"),
arrayOf("start", "2026-10-24"),
arrayOf("location", "Restaurant Bjørk, Bodø"),
),
content = "",
sig = "0".repeat(128),
)
private fun previewRsvp(
id: String,
target: Address,
status: RSVPStatusTag.STATUS,
comment: String,
) = CalendarRSVPEvent(
id = id,
pubKey = PREVIEW_FRIEND,
createdAt = 1789000000,
tags =
arrayOf(
arrayOf("d", "rsvp-$id"),
arrayOf("a", target.toValue()),
status.toTagArray(),
arrayOf("p", target.pubKeyHex),
),
content = comment,
sig = "0".repeat(128),
)
@Composable
private fun PreviewRsvpOf(
appointment: Event,
rsvp: CalendarRSVPEvent,
) {
LocalCache.justConsume(appointment, null, true)
LocalCache.justConsume(rsvp, null, true)
val note = LocalCache.getOrCreateNote(rsvp.id)
ThemeComparisonColumn {
RenderCalendarRSVPEvent(note, mockAccountViewModel(), EmptyNav())
}
}
@Composable
@Preview
fun RenderCalendarRsvpGoingPreview() {
val conference = previewConference()
PreviewRsvpOf(
conference,
previewRsvp(
id = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
target = conference.address(),
status = RSVPStatusTag.STATUS.ACCEPTED,
comment = "See you all there!",
),
)
}
@Composable
@Preview
fun RenderCalendarRsvpMaybeNoImagePreview() {
val meetup = previewMeetup()
PreviewRsvpOf(
meetup,
previewRsvp(
id = "b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1",
target = meetup.address(),
status = RSVPStatusTag.STATUS.TENTATIVE,
comment = "",
),
)
}
/** The appointment has not arrived yet: the status strip over the loading line. */
@Composable
@Preview
fun CalendarRsvpCardLoadingPreview() {
val rsvp =
previewRsvp(
id = "c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2",
target = Address(CalendarTimeSlotEvent.KIND, PREVIEW_HOST, "not-fetched-yet"),
status = RSVPStatusTag.STATUS.ACCEPTED,
comment = "Who else is coming?",
)
ThemeComparisonColumn {
CalendarRsvpCard(rsvp)
}
}
@@ -49,6 +49,7 @@ 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.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.CalendarAppointmentView
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -75,6 +76,8 @@ fun CalendarEventListCard(
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
// Drawn inside the card under the appointment, for lenses that annotate it (who's going).
footer: (@Composable () -> Unit)? = null,
) {
val view = note.appointmentView() ?: return
val context = LocalContext.current
@@ -118,51 +121,7 @@ fun CalendarEventListCard(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
view.title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
range?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
relative?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
view.location?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.LocationOn,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
CalendarAppointmentLines(view, range, relative)
val image = view.image
val summary = view.summary
if (!image.isNullOrBlank()) {
@@ -189,11 +148,71 @@ fun CalendarEventListCard(
}
}
}
footer?.invoke()
}
}
/**
* Title, time range, relative time and location of an appointment — the text half of every
* calendar row. Shared by the calendar list card and the feed's RSVP card so the two cannot
* disagree about how an event reads. Pass a null [relative] where the caller shows it elsewhere.
*/
@Composable
internal fun CalendarAppointmentLines(
view: CalendarAppointmentView,
range: String?,
relative: String?,
) {
view.title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
range?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
relative?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
view.location?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.LocationOn,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun CalendarDateBadge(startSeconds: Long?) {
internal fun CalendarDateBadge(startSeconds: Long?) {
if (startSeconds == null) {
Box(
modifier = Modifier.size(width = 52.dp, height = 60.dp),
@@ -0,0 +1,154 @@
/*
* 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.calendars
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.AppointmentAttendance
import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarEmptyState
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_count
import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_empty_subtitle
import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_empty_title
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.types.UserGallery
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.Size25dp
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
/** Faces drawn on a row before the rest collapse into "+N". */
private const val ATTENDEE_FACES = 6
/**
* Keeps a relay request from growing without bound when the list follows people with a long RSVP
* history of appointments this device has never seen.
*/
private const val MAX_APPOINTMENTS_TO_FETCH = 100
/**
* The upcoming events the people in the selected list said they're going to, soonest first, each
* with the faces of who's going.
*
* The RSVPs come in on the screen's calendar subscription (it asks the list's authors for their
* kind-31925s). The appointments they point at are usually published by someone else — a
* conference organizer the viewer does not follow — so the ones the cache is missing are fetched
* here, by address, through the per-note event finder.
*/
@Composable
fun CalendarFollowsGoingView(
model: CalendarsViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val state by model.followsGoing.collectAsStateWithLifecycle()
val going = state
if (going == null) {
Box(Modifier.fillMaxSize())
return
}
FetchMissingAppointments(going.unresolved, accountViewModel)
if (going.upcoming.isEmpty()) {
CalendarEmptyState(
title = stringRes(Res.string.calendar_follows_going_empty_title),
subtitle = stringRes(Res.string.calendar_follows_going_empty_subtitle),
)
return
}
LazyColumn(
state = model.followsGoingListState,
contentPadding = rememberFeedContentPadding(FeedPadding),
modifier = Modifier.fillMaxSize(),
) {
items(going.upcoming, key = { it.appointment.idHex }) { entry ->
CalendarEventListCard(
note = entry.appointment,
accountViewModel = accountViewModel,
nav = nav,
footer = { AttendeesRow(entry, accountViewModel, nav) },
)
}
}
}
@Composable
private fun FetchMissingAppointments(
unresolved: List<AddressableNote>,
accountViewModel: AccountViewModel,
) {
unresolved.take(MAX_APPOINTMENTS_TO_FETCH).forEach { appointment ->
key(appointment.idHex) {
EventFinderFilterAssemblerSubscription(appointment, accountViewModel)
}
}
}
@Composable
private fun AttendeesRow(
entry: AppointmentAttendance,
accountViewModel: AccountViewModel,
nav: INav,
) {
val shown = remember(entry) { entry.attendees.take(ATTENDEE_FACES).map(LocalCache::getOrCreateUser) }
val total = entry.attendees.size
Row(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
UserGallery(shown, total) { user ->
UserPicture(user, Size25dp, accountViewModel = accountViewModel, nav = nav)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = pluralStringRes(Res.plurals.calendar_follows_going_count, total, total),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -40,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import kotlinx.coroutines.launch
@Composable
fun CalendarsScreen(
@@ -69,8 +71,10 @@ fun CalendarsScreen(
// itself goes. See [CalendarsViewModel].
val model: CalendarsViewModel = viewModel()
model.init(accountViewModel.userProfile().pubkeyHex, feedState)
model.bindAttendeeFilter(accountViewModel.account.liveCalendarsFollowLists, accountViewModel.account.hiddenUsers.flow)
val filterDTag by model.filterDTag.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
DisappearingScaffold(
isInvertedLayout = false,
@@ -80,19 +84,31 @@ fun CalendarsScreen(
onViewModeChange = { model.viewMode = it },
accountViewModel = accountViewModel,
nav = nav,
trailing = {
CalendarFilterChip(
selectedDTag = filterDTag,
onSelect = model::selectCalendar,
model = model,
)
},
// The membership filter narrows the viewer's own calendars; the follows lens is
// built from other people's RSVPs, so the chip would claim a filter it ignores.
trailing =
if (model.viewMode == CalendarsViewMode.FOLLOWS_GOING) {
null
} else {
{
CalendarFilterChip(
selectedDTag = filterDTag,
onSelect = model::selectCalendar,
model = model,
)
}
},
)
},
bottomBar = {
AppBottomBar(Route.Calendars, nav, accountViewModel) { route ->
if (route == Route.Calendars) {
feedState.sendToTop()
if (model.viewMode == CalendarsViewMode.FOLLOWS_GOING) {
// Built from RSVPs, not the feed, so the feed's scroll signal never reaches it.
scope.launch { model.followsGoingListState.animateScrollToItem(0) }
} else {
feedState.sendToTop()
}
} else {
nav.navBottomBar(route)
}
@@ -109,6 +125,7 @@ fun CalendarsScreen(
Column(modifier = Modifier.fillMaxSize()) {
when (model.viewMode) {
CalendarsViewMode.FEED -> CalendarFeedView(feedState, model, accountViewModel, nav)
CalendarsViewMode.FOLLOWS_GOING -> CalendarFollowsGoingView(model, accountViewModel, nav)
CalendarsViewMode.MONTH -> CalendarMonthView(model, accountViewModel, nav)
CalendarsViewMode.WEEK -> CalendarWeekView(model, accountViewModel, nav)
CalendarsViewMode.DAY -> CalendarDayView(model, accountViewModel, nav)
@@ -33,6 +33,7 @@ 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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -84,17 +85,12 @@ fun CalendarsTopBar(
)
}
Row(
CalendarsViewModeTabs(
current = viewMode,
onChange = onViewModeChange,
trailing = trailing,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
CalendarsViewModeTabs(
current = viewMode,
onChange = onViewModeChange,
modifier = Modifier.weight(1f),
)
trailing?.invoke()
}
)
}
}
@@ -120,13 +116,19 @@ private fun CalendarsTopNavFilterBar(
private fun CalendarsViewModeTabs(
current: CalendarsViewMode,
onChange: (CalendarsViewMode) -> Unit,
trailing: (@Composable () -> Unit)?,
modifier: Modifier = Modifier,
) {
// The trailing control scrolls with the lenses instead of being pinned beside them: pinned,
// it took its width out of the scrolling part, and once the lenses outgrew what was left
// (on an 800px tablet, after "Friends going" joined them) the last lens sat half-hidden
// against it with nothing to say the row scrolls. A chip cut by the screen edge does.
Row(
modifier =
modifier
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CalendarsViewMode.entries.forEach { mode ->
FilterChip(
@@ -143,5 +145,6 @@ private fun CalendarsViewModeTabs(
shape = MaterialTheme.shapes.small,
)
}
trailing?.invoke()
}
}
@@ -31,31 +31,49 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.feeds.FilterByListParams
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.FollowsGoing
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MonthGridBarSegment
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeFollowsGoing
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavFilter
import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarsViewMode
import com.vitorpamplona.amethyst.commons.relayClient.calendars.CalendarAppointmentKinds
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.YearMonth
import java.util.concurrent.ConcurrentHashMap
/**
* Everything the calendar screen is *looking at*: which lens is open, which calendar the feed is
@@ -260,6 +278,122 @@ class CalendarsViewModel : ViewModel() {
UpcomingPastSplit(emptyList(), emptyList()),
)
// ------------------------------------------------------------------------------------------
// Where the people in the selected list are going
// ------------------------------------------------------------------------------------------
/**
* What decides whose RSVPs count: the screen's top-nav list and the account's mute state,
* handed over as the account's own flows so a list switch or a new mute re-runs the fold.
*/
private data class AttendeeInputs(
val lists: StateFlow<IFeedTopNavFilter>,
val hidden: StateFlow<LiveHiddenUsers>,
)
private val attendeeInputs = MutableStateFlow<AttendeeInputs?>(null)
/**
* Every kind-31925 note seen while this screen lives, held strongly.
*
* `LocalCache.addressables` keeps its values by weak reference, and nothing else in the app
* references an RSVP by someone else: the calendar feed lists appointments, not answers. So
* the RSVPs the screen's subscription pulls in while the user is on another lens could be
* collected before they ever open "Friends going" — and the subscription's EOSE cursor has
* already moved past them, so they would not be asked for again. Pinning them here, from the
* moment the screen binds, is what lets the lens be opened later and still see them. They
* are released with the screen's back-stack entry. Kind-31925 events are a few hundred bytes.
*/
private val heldRsvps: MutableSet<AddressableNote> = ConcurrentHashMap.newKeySet()
private var rsvpPinner: Job? = null
/** Idempotent — the screen calls it on every composition. */
fun bindAttendeeFilter(
lists: StateFlow<IFeedTopNavFilter>,
hidden: StateFlow<LiveHiddenUsers>,
) {
val bound = AttendeeInputs(lists, hidden)
if (attendeeInputs.value != bound) attendeeInputs.value = bound
if (rsvpPinner == null) {
rsvpPinner =
viewModelScope.launch(Dispatchers.Default) {
heldRsvps.addAll(LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND))
LocalCache
.observeNewEvents<CalendarRSVPEvent>(Filter(kinds = listOf(CalendarRSVPEvent.KIND)))
.collect { rsvp -> LocalCache.getAddressableNoteIfExists(rsvp.address())?.let(heldRsvps::add) }
}
}
}
/**
* The upcoming appointments the people in the selected list said they're going to, soonest
* first, plus the appointments they RSVP'd to that are not in the cache yet (for the lens to
* fetch). Null until the first fold lands, so the lens can tell "loading" from "nobody".
*
* Wakes on one cache observer covering both kinds it reads: RSVPs, and appointments, because
* an RSVP usually lands before the appointment it answers — the host is often someone the
* viewer does not follow — and the row can only be placed once the appointment's date is
* known. The observer is only a wake-up signal; each fold re-reads the RSVPs from the
* addressable cache's kind index, which holds exactly the latest version of each one.
*
* That replaced `observeEvents`, whose seed walks every regular note in the cache (its
* `filter()` scans `notes` whatever the kinds) and which then copies its whole list and id
* set on every insert — quadratic on the burst of RSVPs a list switch pulls in, each copy
* followed by a full re-fold. Here the cheap part (combining the signal with the list and
* mute state) runs per event, and [conflate] lets the fold skip every signal that arrived
* while the previous fold was running.
*
* `now` is read on each fold rather than ticking on a timer: an event that ends while the lens
* is open lingers until the next RSVP or appointment arrives, which is harmless.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val followsGoing: StateFlow<FollowsGoing?> =
attendeeInputs
.filterNotNull()
.flatMapLatest { (lists, hidden) ->
combine(
LocalCache
.observeNewEvents<Event>(Filter(kinds = FOLLOWS_GOING_KINDS))
.conflate()
.map { }
.onStart { emit(Unit) },
lists,
hidden,
) { _, list, hiddenUsers ->
FilterByListParams.create(list, hiddenUsers)
}.conflate()
.map(::foldFollowsGoing)
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
private fun foldFollowsGoing(params: FilterByListParams): FollowsGoing {
// The index scan sees an RSVP the pinner has not reached yet; the pin set holds the rest.
val rsvpNotes = LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND)
heldRsvps.addAll(rsvpNotes)
val rsvps = rsvpNotes.mapNotNull { it.event as? CalendarRSVPEvent }
// Relays only matter to a relay-scoped list, so the others skip the lookup.
val relaysOf: (CalendarRSVPEvent) -> List<NormalizedRelayUrl> =
if (params.followLists is RelayTopNavFilter) {
val byId = rsvpNotes.associateBy({ it.event?.id }, { it.relays })
({ byId[it.id] ?: emptyList() })
} else {
({ emptyList() })
}
return computeFollowsGoing(
rsvps = rsvps,
isAttendee = { params.match(it, relaysOf(it)) },
appointmentFor = LocalCache::getOrCreateAddressableNote,
nowSeconds = TimeUtils.now(),
isAppointmentVisible = { params.isHiddenList || params.isNotHidden(it.pubKeyHex) },
)
}
val followsGoingListState = LazyListState()
// ------------------------------------------------------------------------------------------
// Paging
// ------------------------------------------------------------------------------------------
@@ -304,6 +438,8 @@ class CalendarsViewModel : ViewModel() {
* list, short enough that a backgrounded screen stops holding observers open.
*/
private const val STOP_TIMEOUT_MS = 5_000L
private val FOLLOWS_GOING_KINDS = CalendarAppointmentKinds + CalendarRSVPEvent.KIND
}
}
@@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -498,7 +499,7 @@ private fun EncryptedKeyCard(
label = { Text(stringRes(Res.string.account_backup_encrypted_password)) },
supportingText = {
Text(
text = stringRes(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH),
text = pluralStringRes(Res.plurals.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH, Nip49.MIN_PASSWORD_LENGTH),
color = if (longEnough) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.resources.conversations
import com.vitorpamplona.amethyst.commons.resources.home_content_type_articles
import com.vitorpamplona.amethyst.commons.resources.home_content_type_attestations
import com.vitorpamplona.amethyst.commons.resources.home_content_type_birds
import com.vitorpamplona.amethyst.commons.resources.home_content_type_calendar_rsvps
import com.vitorpamplona.amethyst.commons.resources.home_content_type_chess
import com.vitorpamplona.amethyst.commons.resources.home_content_type_classifieds
import com.vitorpamplona.amethyst.commons.resources.home_content_type_comments
@@ -183,6 +184,7 @@ private val HOME_FEED_TYPES =
HomeFeedTypeUi(HomeFeedType.PODCASTS, Res.string.home_content_type_podcasts, MaterialSymbols.Podcasts),
HomeFeedTypeUi(HomeFeedType.FUNDRAISERS, Res.string.home_content_type_fundraisers, MaterialSymbols.Paid),
HomeFeedTypeUi(HomeFeedType.RATINGS, Res.string.home_content_type_ratings, MaterialSymbols.Star),
HomeFeedTypeUi(HomeFeedType.CALENDAR_RSVPS, Res.string.home_content_type_calendar_rsvps, MaterialSymbols.CalendarMonth),
)
/**
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.amethyst.calendar
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import org.junit.Assert.assertFalse
@@ -33,10 +35,23 @@ import org.junit.Test
* chain may cancel itself. Getting it wrong in either direction is a bug:
* a false "could fire" keeps waking the process forever; a false "can't fire"
* silently kills a reminder the user RSVP'd to.
*
* Events go in through [consumePinned], which takes each event's note from the cache *before*
* consuming it and keeps it for the test's lifetime. `LocalCache` holds notes weakly
* (LargeSoftCache) and the worker finds RSVPs and their time slots by reading the cache, so a
* GC before that read would lose them — a lost slot reads as "not fetched yet" and flips
* `couldStillFire` to true.
*/
class CalendarReminderCandidatesTest {
private val now = 2_000_000_000L
private val pinned = mutableListOf<Note>()
private fun consumePinned(event: Event) {
pinned.add(LocalCache.getOrCreateNote(event))
LocalCache.justConsume(event, null, true)
}
// Unique per-test-class identities so the shared LocalCache singleton
// doesn't collide with other suites running in the same JVM.
private val organizer = "b0b0000000000000000000000000000000000000000000000000000000000001"
@@ -85,8 +100,8 @@ class CalendarReminderCandidatesTest {
fun acceptedRsvpsInCache_returnsAcceptedAndSkipsDeclined() {
val accepted = rsvp("cand-accepted", "cand-target-1")
val declined = rsvp("cand-declined", "cand-target-2", status = "declined")
LocalCache.justConsume(accepted, null, true)
LocalCache.justConsume(declined, null, true)
consumePinned(accepted)
consumePinned(declined)
val found = CalendarReminderWorker.acceptedRsvpsInCache()
assertTrue("accepted RSVP must be found", found.any { it.dTag() == "cand-accepted" })
@@ -97,8 +112,8 @@ class CalendarReminderCandidatesTest {
fun futureTarget_couldStillFire() {
val slot = timeSlot("cand-future", startSeconds = now + 3600)
val r = rsvp("cand-rsvp-future", "cand-future")
LocalCache.justConsume(slot, null, true)
LocalCache.justConsume(r, null, true)
consumePinned(slot)
consumePinned(r)
assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now))
}
@@ -107,8 +122,8 @@ class CalendarReminderCandidatesTest {
fun pastTarget_cannotFireAnymore() {
val slot = timeSlot("cand-past", startSeconds = now - 3600)
val r = rsvp("cand-rsvp-past", "cand-past")
LocalCache.justConsume(slot, null, true)
LocalCache.justConsume(r, null, true)
consumePinned(slot)
consumePinned(r)
assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now))
}
@@ -118,7 +133,7 @@ class CalendarReminderCandidatesTest {
// The RSVP points at an event the cache hasn't fetched yet: the start
// is unknown, so the worker must NOT cancel its chain.
val r = rsvp("cand-rsvp-unresolved", "cand-never-fetched")
LocalCache.justConsume(r, null, true)
consumePinned(r)
assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now))
}
@@ -128,8 +143,8 @@ class CalendarReminderCandidatesTest {
// Target resolved but carries no start tag: still unknown, keep alive.
val slot = timeSlot("cand-no-start", startSeconds = null)
val r = rsvp("cand-rsvp-no-start", "cand-no-start")
LocalCache.justConsume(slot, null, true)
LocalCache.justConsume(r, null, true)
consumePinned(slot)
consumePinned(r)
assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now))
}
@@ -137,7 +152,7 @@ class CalendarReminderCandidatesTest {
@Test
fun rsvpWithoutTargetAddress_doesNotKeepTheChainAlive() {
val r = rsvp("cand-rsvp-no-a-tag", targetDTag = null)
LocalCache.justConsume(r, null, true)
consumePinned(r)
assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now))
}
@@ -120,10 +120,14 @@ class CalendarsViewModelFlowTest {
* Consumes [calendars] and hands back the notes they landed in, so the caller can hold them
* for the length of the test: `LocalCache.addressables` is a weak cache, and with nothing
* holding a reference a calendar can be collected out from under the assertions.
*
* The notes are taken *before* consuming: fetching them afterwards leaves the window between
* the consume and the fetch open, and a GC there drops the calendar before it is ever held.
*/
private fun consume(vararg calendars: CalendarCollectionEvent): List<AddressableNote?> {
private fun consume(vararg calendars: CalendarCollectionEvent): List<AddressableNote> {
val held = calendars.map { LocalCache.getOrCreateAddressableNote(it.address()) }
calendars.forEach { LocalCache.justConsumeMyOwnEvent(it) }
return calendars.map { LocalCache.getAddressableNoteIfExists(it.address()) }
return held
}
/**
@@ -168,6 +168,10 @@ class BuzzWorkspaceChannelTest {
// overlay its message — it is anchored on the message note, independent of any relay.
val channelId = newChannelId()
val original = streamMessage(channelId, "original")
// Taken before consuming and held: with no relay the message joins no channel
// timeline, so nothing else holds its note in LocalCache's weak store, and a GC
// before the read-back would drop it along with its edit overlay.
val target = LocalCache.getOrCreateNote(original.id)
LocalCache.checkDeletionAndConsume(original, null, true)
val edit =
@@ -176,7 +180,6 @@ class BuzzWorkspaceChannelTest {
)
LocalCache.checkDeletionAndConsume(edit, null, true)
val target = LocalCache.getNoteIfExists(original.id)!!
val newest = target.edits.filter { it.event is StreamMessageEditEvent }.maxByOrNull { it.createdAt() ?: 0L }
assertEquals("edited offline", newest?.event?.content)
}
@@ -20,12 +20,14 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cachedDvmAnnouncements
import com.vitorpamplona.amethyst.commons.model.dvmHeartbeatOf
import com.vitorpamplona.amethyst.commons.model.hasFreshDvmHeartbeat
import com.vitorpamplona.amethyst.commons.model.nip90DVMs.DvmHeartbeatRegistry
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -38,10 +40,23 @@ import org.junit.Test
/**
* `LocalCache` is a process-wide object and JUnit 4 runs methods in hash order, so every test
* uses its own pubkeys/dTags/ids (same discipline as ReportNamingIndexIngestionTest).
*
* Events go in through [consumePinned], which takes each event's note from the cache *before*
* consuming it and keeps it for the test's lifetime. Beat and announcement notes live in
* `LocalCache`'s weakly-held store, and `dvmHeartbeatOf` / `cachedDvmAnnouncements` read them from
* there, so a GC between the consume and the read would make them vanish. Fetching the note after
* the consume is too late: the window is already open.
*/
class DvmHeartbeatTest {
private val appDefPubKey = "f1".repeat(32)
private val pinned = mutableListOf<Note>()
private fun consumePinned(event: Event) {
pinned.add(LocalCache.getOrCreateNote(event))
LocalCache.justConsume(event, null, true)
}
private fun appDef(
dTag: String,
pubKey: String = appDefPubKey,
@@ -76,7 +91,7 @@ class DvmHeartbeatTest {
@Test
fun aConsumedHeartbeatLandsAtTheAnnouncementMirrorAddress() {
val app = appDef("dvm-one")
LocalCache.justConsume(beat("dvm-one", createdAt = 1_760_000_100L, id = "f3".repeat(32)), null, true)
consumePinned(beat("dvm-one", createdAt = 1_760_000_100L, id = "f3".repeat(32)))
val found = LocalCache.dvmHeartbeatOf(app)
assertTrue("heartbeat should be found via the announcement's address", found != null)
@@ -93,8 +108,8 @@ class DvmHeartbeatTest {
val staleApp = appDef("dvm-two-stale")
assertNull("no beat yet", LocalCache.dvmHeartbeatOf(freshApp))
LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 900, id = "f4".repeat(32)), null, true)
LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 901, id = "f5".repeat(32)), null, true)
consumePinned(beat("dvm-two-fresh", createdAt = now - 900, id = "f4".repeat(32)))
consumePinned(beat("dvm-two-stale", createdAt = now - 901, id = "f5".repeat(32)))
assertTrue("exactly 900s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now))
assertFalse("901s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now))
@@ -104,8 +119,8 @@ class DvmHeartbeatTest {
fun theNewestBeatPerAddressWins() {
val now = 1_760_000_000L
val app = appDef("dvm-three")
LocalCache.justConsume(beat("dvm-three", createdAt = now - 600, id = "f6".repeat(32)), null, true)
LocalCache.justConsume(beat("dvm-three", createdAt = now - 60, id = "f7".repeat(32)), null, true)
consumePinned(beat("dvm-three", createdAt = now - 600, id = "f6".repeat(32)))
consumePinned(beat("dvm-three", createdAt = now - 60, id = "f7".repeat(32)))
assertEquals(now - 60, LocalCache.dvmHeartbeatOf(app)?.createdAt)
}
@@ -115,7 +130,7 @@ class DvmHeartbeatTest {
val now = 1_760_000_000L
val appA = appDef("dvm-a")
val appB = appDef("dvm-b")
LocalCache.justConsume(beat("dvm-a", createdAt = now - 60, id = "f8".repeat(32)), null, true)
consumePinned(beat("dvm-a", createdAt = now - 60, id = "f8".repeat(32)))
assertTrue(LocalCache.hasFreshDvmHeartbeat(appA, now))
assertFalse("no beat for dvm-b", LocalCache.hasFreshDvmHeartbeat(appB, now))
@@ -177,11 +192,10 @@ class DvmHeartbeatTest {
// scanning for them clears the lot and the scan returns nothing — which is exactly how
// this failed on CI, where the heap is tighter than a dev box's. A fixture has to hold
// what it expects to find, the same discipline LargeCacheAddressableFilterTest spells
// out; the forced GC below is what keeps that honest rather than assumed.
val held =
listOf(alive, appDef("dvm-x"), subscriptionApp, nonDiscoveryApp, secondSubscriptionApp)
.onEach { LocalCache.justConsume(it, null, true) }
.map { LocalCache.getOrCreateNote(it) }
// out, and it has to take hold BEFORE consuming (see [consumePinned]); the forced GC
// below is what keeps that honest rather than assumed.
listOf(alive, appDef("dvm-x"), subscriptionApp, nonDiscoveryApp, secondSubscriptionApp)
.forEach { consumePinned(it) }
System.gc()
@@ -192,6 +206,6 @@ class DvmHeartbeatTest {
assertFalse("k=9999 apps are not content-discovery DVMs", scanned.any { it.dTag() == "other" })
assertEquals("newest-first so the cap keeps the most relevant announcements", scanned.sortedByDescending { it.createdAt }, scanned)
assertTrue("capped", scanned.size <= 100)
assertEquals("the fixture notes must stay reachable for the whole test", 5, held.size)
assertEquals("the fixture notes must stay reachable for the whole test", 5, pinned.size)
}
}
@@ -21,10 +21,12 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent
import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -38,10 +40,23 @@ import org.junit.Test
*
* `LocalCache` is a process-wide object and JUnit 4's method order is hash-based, so every test
* below uses its own author and identifier.
*
* Events go in through [consumePinned], which takes each event's note from the cache *before*
* consuming it and keeps it for the test's lifetime. `LocalCache` holds notes weakly
* (LargeSoftCache), so a note nothing references can be collected between two consumes — the
* older rating then lands on a fresh, empty slot — or before the read-back. In the app the
* screen showing the rating is that strong referent.
*/
class EntityRatingIngestionTest {
private val publisher = "b1".repeat(32)
private val pinned = mutableListOf<Note>()
private fun consumePinned(event: Event): Boolean {
pinned.add(LocalCache.getOrCreateNote(event))
return LocalCache.justConsume(event, null, true)
}
private fun rating(
id: String,
author: String,
@@ -75,7 +90,7 @@ class EntityRatingIngestionTest {
val author = "b2".repeat(32)
val event = rating("b3".repeat(32), author, "consumed-book", 5)
assertTrue("LocalCache must accept kind 34259", LocalCache.justConsume(event, null, true))
assertTrue("LocalCache must accept kind 34259", consumePinned(event))
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
assertNotNull("the rating must land in the addressable index", note)
@@ -88,8 +103,8 @@ class EntityRatingIngestionTest {
val first = rating("b5".repeat(32), author, "replaced-book", 1, createdAt = 1_000_000L)
val second = rating("b6".repeat(32), author, "replaced-book", 5, createdAt = 2_000_000L)
LocalCache.justConsume(first, null, true)
LocalCache.justConsume(second, null, true)
consumePinned(first)
consumePinned(second)
val note = LocalCache.getAddressableNoteIfExists(first.addressTag())
assertEquals("the later rating wins the (pubkey, d) slot", second.id, note?.event?.id)
@@ -102,8 +117,8 @@ class EntityRatingIngestionTest {
val newer = rating("b8".repeat(32), author, "ordered-book", 5, createdAt = 2_000_000L)
val older = rating("b9".repeat(32), author, "ordered-book", 1, createdAt = 1_000_000L)
LocalCache.justConsume(newer, null, true)
LocalCache.justConsume(older, null, true)
consumePinned(newer)
consumePinned(older)
val note = LocalCache.getAddressableNoteIfExists(newer.addressTag())
assertEquals(newer.id, note?.event?.id)
@@ -114,8 +129,8 @@ class EntityRatingIngestionTest {
val one = rating("c1".repeat(32), "c2".repeat(32), "shared-book", 5)
val two = rating("c3".repeat(32), "c4".repeat(32), "shared-book", 2)
LocalCache.justConsume(one, null, true)
LocalCache.justConsume(two, null, true)
consumePinned(one)
consumePinned(two)
assertEquals(one.id, LocalCache.getAddressableNoteIfExists(one.addressTag())?.event?.id)
assertEquals(two.id, LocalCache.getAddressableNoteIfExists(two.addressTag())?.event?.id)
@@ -134,7 +149,7 @@ class EntityRatingIngestionTest {
sig = "sig",
)
assertTrue("LocalCache must accept kind 30040", LocalCache.justConsume(index, null, true))
assertTrue("LocalCache must accept kind 30040", consumePinned(index))
assertEquals("Wuthering Heights", (LocalCache.getAddressableNoteIfExists(index.addressTag())?.event as PublicationIndexEvent).title())
}
@@ -146,7 +161,7 @@ class EntityRatingIngestionTest {
val author = "c6".repeat(32)
val event = rating("c7".repeat(32), author, "new-thread-book", 4)
LocalCache.justConsume(event, null, true)
consumePinned(event)
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())!!
assertTrue("replyTo must stay empty", note.replyTo.isNullOrEmpty())
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -45,6 +46,11 @@ import java.io.File
*
* `LocalCache` is a process-wide object and JUnit's method order is hash-based, so the corpus is
* loaded once and every assertion here is read-only.
*
* The corpus' notes are taken from the cache *before* consuming and kept in [pinned] for the whole
* class. `LocalCache` holds notes weakly (LargeSoftCache) and nothing else references a corpus note,
* so without the pin any GC between loading and a query drops notes and the query comes back short.
* In the app the screen showing a note is that strong referent.
*/
class LocalCacheSearchParityTest {
companion object {
@@ -53,6 +59,9 @@ class LocalCacheSearchParityTest {
private lateinit var corpus: List<Event>
/** Strong references to every corpus note. See the class KDoc. */
private lateinit var pinned: List<Note>
@BeforeClass
@JvmStatic
fun loadCorpus() {
@@ -69,6 +78,8 @@ class LocalCacheSearchParityTest {
.map { Event.fromJson(it.toString()) }
.distinctBy { it.id }
pinned = corpus.map { LocalCache.getOrCreateNote(it) }
// LocalCache.consume refuses the main thread; a plain JVM test has no Looper, so the
// check passes and the events land synchronously.
corpus.forEach { LocalCache.justConsumeMyOwnEvent(it) }
@@ -24,14 +24,20 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* `LocalCache` is a process-wide object (`LocalCache.kt:353`) and JUnit 4's default method order is
* hash-based, not source order. So each test method uses its **own** reporter, target and event ids —
* sharing them across methods would let one test's consumed report inflate another's count depending
* on which ran first.
* on which ran first. The ids must also be unused by every OTHER test class in the JVM: this suite
* once shared `c3…`/`d3…`/`e5…` with the rating, appointment and calendar suites, and whenever one
* of their notes was still alive the report here was treated as already seen and never indexed.
*
* Each test takes the reported users from the cache *before* consuming and keeps them. `LocalCache`
* holds users weakly (LargeSoftCache) and nothing else references a user that is only named by a
* report, so a GC before the read-back would drop the user and the report index with it. In the app
* the screen showing the user is that strong referent.
*/
class ReportNamingIndexIngestionTest {
private fun reportNamingBothNoteAndAuthor(
@@ -54,48 +60,45 @@ class ReportNamingIndexIngestionTest {
@Test
fun aReportFiledFromANoteStillReachesTheUserNamingIndex() {
val reporter = "c1".repeat(32)
val target = "c2".repeat(32)
val reporter = "7a".repeat(32)
val target = "7b".repeat(32)
val reported = LocalCache.getOrCreateUser(target)
LocalCache.consume(
reportNamingBothNoteAndAuthor("c3".repeat(32), reporter, target, "c4".repeat(32)),
reportNamingBothNoteAndAuthor("7c".repeat(32), reporter, target, "7d".repeat(32)),
null,
true,
)
val reported = LocalCache.getUserIfExists(target)
assertTrue("the reported author must exist in the cache", reported != null)
assertEquals(1, reported!!.reports().reportsNaming(setOf(reporter)).size)
assertEquals(1, reported.reports().reportsNaming(setOf(reporter)).size)
}
@Test
fun theHideThresholdCountIsUnaffectedByNoteFiledReports() {
val reporter = "d1".repeat(32)
val target = "d2".repeat(32)
val reporter = "8a".repeat(32)
val target = "8b".repeat(32)
val reported = LocalCache.getOrCreateUser(target)
LocalCache.consume(
reportNamingBothNoteAndAuthor("d3".repeat(32), reporter, target, "d4".repeat(32)),
reportNamingBothNoteAndAuthor("8c".repeat(32), reporter, target, "8d".repeat(32)),
null,
true,
)
val reported = LocalCache.getUserIfExists(target)!!
assertEquals(1, reported.reports().reportsNaming(setOf(reporter)).size)
assertEquals(0, reported.reports().countReportAuthorsBy(setOf(reporter)))
}
@Test
fun aBareCoMentionedPTagIsNotIndexedWhileTheExplicitlyTypedOffenderIs() {
val reporter = "e1".repeat(32)
val offender = "e2".repeat(32)
val bystander = "e3".repeat(32)
val reportedNoteId = "e4".repeat(32)
val reporter = "9a".repeat(32)
val offender = "9b".repeat(32)
val bystander = "9c".repeat(32)
val reportedNoteId = "9e".repeat(32)
val report =
ReportEvent(
id = "e5".repeat(32),
id = "7e".repeat(32),
pubKey = reporter,
createdAt = 1_700_000_000L,
tags =
@@ -110,14 +113,12 @@ class ReportNamingIndexIngestionTest {
sig = "sig",
)
val reportedOffender = LocalCache.getOrCreateUser(offender)
val reportedBystander = LocalCache.getOrCreateUser(bystander)
LocalCache.consume(report, null, true)
val reportedOffender = LocalCache.getUserIfExists(offender)
assertTrue("the offender must exist in the cache", reportedOffender != null)
assertEquals(1, reportedOffender!!.reports().reportsNaming(setOf(reporter)).size)
val reportedBystander = LocalCache.getUserIfExists(bystander)
assertTrue("the bystander must exist in the cache", reportedBystander != null)
assertEquals(0, reportedBystander!!.reports().reportsNaming(setOf(reporter)).size)
assertEquals(1, reportedOffender.reports().reportsNaming(setOf(reporter)).size)
assertEquals(0, reportedBystander.reports().reportsNaming(setOf(reporter)).size)
}
}
@@ -0,0 +1,110 @@
/*
* 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.service.notifications
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
/**
* A private zap is signed by a one-time key and carries the real sender and message encrypted
* for the zapped author. The notification must decrypt it with the recipient's signer so the
* shade names the real zapper, and fall back to the request itself when it can't.
*/
class NotificationContentPrivateZapTest {
private val relays = setOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example/")!!)
private val recipient = NostrSignerInternal(KeyPair())
private val sender = NostrSignerInternal(KeyPair())
private val zappedPost =
Event(
id = "a".repeat(64),
pubKey = recipient.pubKey,
createdAt = 1000L,
kind = 1,
tags = emptyArray(),
content = "Hello world",
sig = "b".repeat(128),
)
private fun zapPost(zapType: ZapReceiptEvent.ZapType) =
runBlocking {
ZapRequestEvent.create(
zappedEvent = zappedPost,
relays = relays,
signer = sender,
pollOption = null,
message = "great post",
zapType = zapType,
toUserPubHex = null,
)
}
@Test
fun privateZapOnAPostIsDecryptedForTheRecipient() =
runBlocking {
val decrypted = NotificationContent.decryptZapContentAuthor(zapPost(ZapReceiptEvent.ZapType.PRIVATE), recipient)!!
assertEquals(sender.pubKey, decrypted.pubKey)
assertEquals("great post", decrypted.content)
}
@Test
fun privateProfileZapIsDecryptedForTheRecipient() =
runBlocking {
val zap =
ZapRequestEvent.create(
userHex = recipient.pubKey,
relays = relays,
signer = sender,
message = "thanks",
zapType = ZapReceiptEvent.ZapType.PRIVATE,
)
val decrypted = NotificationContent.decryptZapContentAuthor(zap, recipient)!!
assertEquals(sender.pubKey, decrypted.pubKey)
assertEquals("thanks", decrypted.content)
}
@Test
fun privateZapTheSignerCannotOpenFallsBackToTheRequest() =
runBlocking {
val zap = zapPost(ZapReceiptEvent.ZapType.PRIVATE)
assertSame(zap, NotificationContent.decryptZapContentAuthor(zap, NostrSignerInternal(KeyPair())))
}
@Test
fun publicZapIsReturnedAsIs() =
runBlocking {
val zap = zapPost(ZapReceiptEvent.ZapType.PUBLIC)
assertSame(zap, NotificationContent.decryptZapContentAuthor(zap, recipient))
}
}
@@ -29,6 +29,8 @@ import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.isLatestAnswerIn
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.latestRsvpAnswers
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
@@ -45,12 +47,19 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
@@ -87,6 +96,7 @@ class HomeNewThreadFeedFilter(
AddressableNormalVideoEvent.KIND,
AddressableShortVideoEvent.KIND,
EntityRatingEvent.KIND,
CalendarRSVPEvent.KIND,
)
}
@@ -105,18 +115,19 @@ class HomeNewThreadFeedFilter(
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
val latestRsvps = lazy(LazyThreadSafetyMode.NONE) { cachedRsvpAnswers() }
val notes =
LocalCache.notes.filterIntoSet { _, note ->
// Avoids processing addressables twice.
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds)
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds, latestRsvps)
}
val longFormNotes =
LocalCache.addressables.filterIntoSet(
kinds = ADDRESSABLE_KINDS,
) { _, note ->
acceptableEvent(note, filterParams, disabledKinds)
acceptableEvent(note, filterParams, disabledKinds, latestRsvps)
}
return sort(notes + longFormNotes)
@@ -124,12 +135,28 @@ class HomeNewThreadFeedFilter(
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
/**
* The additive merge only ever adds, so a "Going" card already on screen would outlive the
* author changing their mind: when a batch carries RSVPs, the old list first loses every
* RSVP that is no longer its author's latest answer.
*/
override fun updateListWith(
oldList: List<Note>,
newItems: Set<Note>,
): List<Note> {
if (newItems.none { it.event is CalendarRSVPEvent }) return super.updateListWith(oldList, newItems)
val latest = cachedRsvpAnswers()
val stillCurrent = oldList.filter { (it.event as? CalendarRSVPEvent)?.isLatestAnswerIn(latest) ?: true }
return super.updateListWith(stillCurrent, newItems)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
val latestRsvps = lazy(LazyThreadSafetyMode.NONE) { cachedRsvpAnswers() }
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams, disabledKinds)
acceptableEvent(it, filterParams, disabledKinds, latestRsvps)
}
}
@@ -137,6 +164,7 @@ class HomeNewThreadFeedFilter(
it: Note,
filterParams: FilterByListParams,
disabledKinds: Set<Int>,
latestRsvps: Lazy<Map<Pair<Address, HexKey>, CalendarRSVPEvent>>,
): Boolean {
val noteEvent = it.event ?: return false
if (noteEvent.kind in disabledKinds) return false
@@ -174,7 +202,8 @@ class HomeNewThreadFeedFilter(
noteEvent is AttestorRecommendationEvent ||
noteEvent is AttestorProficiencyEvent ||
// A rating with nothing to point at cannot be rendered.
(noteEvent is EntityRatingEvent && noteEvent.hasTarget())
(noteEvent is EntityRatingEvent && noteEvent.hasTarget()) ||
noteEvent.isGoingRsvp(latestRsvps)
) &&
filterParams.match(noteEvent, it.relays) &&
it.isNewThread()
@@ -190,3 +219,20 @@ class HomeNewThreadFeedFilter(
}
}.sortedByDefaultFeedOrder()
}
/**
* Only "going" RSVPs reach the home feed: the point is to see where your people are going.
* Maybes and declines are still visible on the appointment itself, but as posts they are noise.
* And only the author's current answer: someone who said "going" and later "can't go" under a
* new d tag still has the old RSVP live, and it must not keep saying they are going.
*/
private fun Event.isGoingRsvp(latestRsvps: Lazy<Map<Pair<Address, HexKey>, CalendarRSVPEvent>>): Boolean {
if (this !is CalendarRSVPEvent || status() != RSVPStatusTag.STATUS.ACCEPTED) return false
// An `a` tag at anything but an appointment has no event card to draw next to "Going".
val target = calendarEventAddress() ?: return false
if (target.kind != CalendarTimeSlotEvent.KIND && target.kind != CalendarDateSlotEvent.KIND) return false
return isLatestAnswerIn(latestRsvps.value)
}
// Built from the cache's RSVP kind index, and only when a pass actually meets an RSVP.
private fun cachedRsvpAnswers(): Map<Pair<Address, HexKey>, CalendarRSVPEvent> = latestRsvpAnswers(LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND).mapNotNull { it.event as? CalendarRSVPEvent })
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent
@@ -108,6 +109,7 @@ enum class HomeFeedType(
PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)),
FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)),
RATINGS("ratings", listOf(EntityRatingEvent.KIND)),
CALENDAR_RSVPS("calendar_rsvps", listOf(CalendarRSVPEvent.KIND)),
;
companion object {
@@ -0,0 +1,154 @@
/*
* 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.model.nip52Calendar
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
/**
* One appointment and the people (already narrowed to the ones the viewer cares about) whose
* latest RSVP to it says "going". [attendees] is newest-RSVP first, so the faces a row shows are
* the people who decided most recently.
*
* A data class on purpose: the fold re-runs on every RSVP or appointment that lands, and value
* equality is what lets the StateFlow drop a fold that changed nothing and lets an unchanged row
* skip recomposition. [appointment] compares by identity, which is right — the cache hands out
* one [AddressableNote] per address.
*/
@Immutable
data class AppointmentAttendance(
val appointment: AddressableNote,
val attendees: List<HexKey>,
)
/**
* [upcoming] is what a "where are my follows going" list draws, soonest first. [unresolved] are
* the appointments someone RSVP'd to that the cache has no event for yet, most recently answered
* first: whether they are upcoming at all is unknown until they load, so they are handed back for
* the caller to fetch instead of being drawn as blank rows. The order matters to a caller that
* can only fetch a bounded number — a fresh RSVP is the one most likely to be for a future event.
*/
@Immutable
data class FollowsGoing(
val upcoming: List<AppointmentAttendance>,
val unresolved: List<AddressableNote>,
)
/**
* Folds a pile of kind-31925 RSVPs into the appointments the accepted authors are going to.
*
* - Only an author's **latest** RSVP to a given appointment counts. RSVPs are addressable with a
* free-form `d` tag, so a person who said "going" and later "can't go" may have two live events
* (two d-tags); the newest one is their answer.
* - [isAttendee] runs on that latest RSVP only: the follow-list / mute check belongs to the caller.
* - [isAppointmentVisible] runs on the appointment's address before it is resolved, so an event
* hosted by someone the viewer muted is neither drawn nor fetched.
* - An appointment counts as upcoming while it has not ended (an ongoing multi-day conference is
* still somewhere your friends are), matching the feed lens's upcoming/past split.
*
* [nowSeconds] is a parameter so the split is deterministic under test.
*/
fun computeFollowsGoing(
rsvps: Collection<CalendarRSVPEvent>,
isAttendee: (CalendarRSVPEvent) -> Boolean,
appointmentFor: (Address) -> AddressableNote,
nowSeconds: Long,
isAppointmentVisible: (Address) -> Boolean = { true },
): FollowsGoing {
val latest = latestRsvpAnswers(rsvps)
val goingByAppointment = HashMap<Address, MutableList<CalendarRSVPEvent>>()
latest.forEach { (key, rsvp) ->
if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED && isAttendee(rsvp)) {
goingByAppointment.getOrPut(key.first) { mutableListOf() }.add(rsvp)
}
}
val upcoming = ArrayList<Pair<Long, AppointmentAttendance>>()
val unresolved = ArrayList<Pair<Long, AddressableNote>>()
goingByAppointment.forEach { (address, going) ->
if (!isAppointmentVisible(address)) return@forEach
going.sortWith(NEWEST_FIRST)
val appointment = appointmentFor(address)
if (appointment.event == null) {
unresolved.add(going.first().createdAt to appointment)
return@forEach
}
val start = appointment.calendarStartSeconds() ?: return@forEach
val end = appointment.calendarEndSeconds() ?: start
if (end < nowSeconds) return@forEach
upcoming.add(start to AppointmentAttendance(appointment, going.map { it.pubKey }))
}
upcoming.sortWith(
compareBy<Pair<Long, AppointmentAttendance>> { it.first }
.thenByDescending { it.second.attendees.size }
.thenBy { it.second.appointment.idHex },
)
unresolved.sortWith(
compareByDescending<Pair<Long, AddressableNote>> { it.first }
.thenBy { it.second.idHex },
)
return FollowsGoing(upcoming.map { it.second }, unresolved.map { it.second })
}
/**
* Each author's newest RSVP to each appointment, keyed (appointment, author). RSVPs are
* addressable with a free-form `d` tag, so one person can hold several live answers to the same
* appointment (said "going", then "can't go" under a new d tag); only the newest is their answer.
* RSVPs pointing at anything but an appointment kind are skipped.
*/
fun latestRsvpAnswers(rsvps: Collection<CalendarRSVPEvent>): Map<Pair<Address, HexKey>, CalendarRSVPEvent> {
val latest = HashMap<Pair<Address, HexKey>, CalendarRSVPEvent>()
rsvps.forEach { rsvp ->
val target = rsvp.calendarEventAddress() ?: return@forEach
if (target.kind != CalendarTimeSlotEvent.KIND && target.kind != CalendarDateSlotEvent.KIND) return@forEach
val key = target to rsvp.pubKey
val current = latest[key]
if (current == null || rsvp.isNewerThan(current)) {
latest[key] = rsvp
}
}
return latest
}
/** Whether this RSVP is its author's current answer in [latest] (see [latestRsvpAnswers]). */
fun CalendarRSVPEvent.isLatestAnswerIn(latest: Map<Pair<Address, HexKey>, CalendarRSVPEvent>): Boolean {
val target = calendarEventAddress() ?: return false
return latest[target to pubKey]?.id == id
}
// Newest first; the id breaks a same-second tie so the answer never depends on input order.
private val NEWEST_FIRST =
compareByDescending<CalendarRSVPEvent> { it.createdAt }.thenBy { it.id }
private fun CalendarRSVPEvent.isNewerThan(other: CalendarRSVPEvent) = NEWEST_FIRST.compare(this, other) < 0
@@ -30,6 +30,11 @@ import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
// a second round-trip, but they don't drive the timeline DAL.
val CalendarAppointmentKinds = listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND)
// What an author publishes as the owner of a calendar, as opposed to answering someone else's.
val CalendarOwnKinds = listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, CalendarCollectionEvent.KIND)
val CalendarRsvpKinds = listOf(CalendarRSVPEvent.KIND)
val AllCalendarKinds =
listOf(
CalendarTimeSlotEvent.KIND,
@@ -42,7 +42,21 @@ fun filterCalendarsByAuthors(
ExplainedFilter(
purpose = SubPurpose.TOPIC_FEED,
authors = authorList,
kinds = AllCalendarKinds,
kinds = CalendarOwnKinds,
limit = 500,
since = since,
),
),
// RSVPs get their own limit: they are what the "follows going" lens is built from, and
// sharing one 500-event window with every appointment and calendar these authors ever
// published let a busy organizer starve out everyone else's answers.
RelayBasedFilter(
relay = relay,
filter =
ExplainedFilter(
purpose = SubPurpose.TOPIC_FEED,
authors = authorList,
kinds = CalendarRsvpKinds,
limit = 500,
since = since,
),
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent
@@ -79,6 +80,9 @@ val HomePostsNewThreadKinds1 =
VideoShortEvent.KIND,
AddressableNormalVideoEvent.KIND,
AddressableShortVideoEvent.KIND,
// "Going to <event>" from people you follow. Low-volume per author, so it rides the main
// leg instead of the 5-item rare-kinds leg below, where it would be starved by the others.
CalendarRSVPEvent.KIND,
)
val HomePostsNewThreadKinds2 =
@@ -34,7 +34,7 @@ class UploadFallbackTest {
}
@Test
fun `a server is never tried twice, even under another spelling`() {
fun `a server is never tried twice even under another spelling`() {
val primalAgain = ServerName("primal", "https://BLOSSOM.PRIMAL.NET")
assertEquals(listOf(primal, band), blossomUploadOrder(primal, listOf(primalAgain, band, band)))
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.preview
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
@@ -41,7 +42,7 @@ import platform.Foundation.create
* to their `NSStringEncoding`; anything else falls back to UTF-8 (matching the
* "defaults to UTF-8" behaviour of the charset sniffer).
*/
@OptIn(ExperimentalForeignApi::class)
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
actual fun decodeBytes(
bytes: ByteArray,
charsetName: String?,
@@ -55,7 +56,7 @@ actual fun decodeBytes(
NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
}
return (NSString.create(data, encoding) as String?)
return NSString.create(data = data, encoding = encoding)?.toString()
?: bytes.decodeToString()
}
@@ -0,0 +1,249 @@
/*
* 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.model.nip52Calendar
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FollowsGoingTest {
private val now = 1_800_000_000L
private val host = "a".repeat(64)
private val alice = "1".repeat(64)
private val bob = "2".repeat(64)
private val carol = "3".repeat(64)
private val notes = HashMap<Address, AddressableNote>()
private fun appointmentFor(address: Address) = notes.getOrPut(address) { AddressableNote(address) }
private fun timeSlot(
dTag: String,
start: Long,
end: Long? = null,
): Address {
val address = Address(CalendarTimeSlotEvent.KIND, host, dTag)
val tags =
buildList {
add(arrayOf("d", dTag))
add(arrayOf("title", dTag))
add(arrayOf("start", start.toString()))
end?.let { add(arrayOf("end", it.toString())) }
}.toTypedArray()
appointmentFor(address).event = CalendarTimeSlotEvent("id-$dTag", host, 0L, tags, "", "sig")
return address
}
private var rsvpCounter = 0
private fun rsvp(
author: String,
target: Address,
status: String,
createdAt: Long,
dTag: String = "rsvp-${rsvpCounter++}",
) = CalendarRSVPEvent(
id = "rsvp-${rsvpCounter++}",
pubKey = author,
createdAt = createdAt,
tags =
arrayOf(
arrayOf("d", dTag),
arrayOf("a", target.toValue()),
arrayOf("status", status),
),
content = "",
sig = "sig",
)
private fun fold(
rsvps: List<CalendarRSVPEvent>,
isAttendee: (CalendarRSVPEvent) -> Boolean = { true },
) = computeFollowsGoing(rsvps, isAttendee, ::appointmentFor, now)
@Test
fun groupsGoingAuthorsPerAppointment_newestAnswerFirst() {
val conf = timeSlot("conf", start = now + 86_400)
val result =
fold(
listOf(
rsvp(alice, conf, "accepted", createdAt = 10),
rsvp(bob, conf, "accepted", createdAt = 20),
),
)
assertEquals(1, result.upcoming.size)
assertEquals(listOf(bob, alice), result.upcoming.single().attendees)
}
@Test
fun onlyTheLatestAnswerPerAuthorCounts_evenAcrossDTags() {
val conf = timeSlot("conf", start = now + 86_400)
// Alice said going, then changed her mind in a second RSVP with a different d tag.
val result =
fold(
listOf(
rsvp(alice, conf, "accepted", createdAt = 10, dTag = "first"),
rsvp(alice, conf, "declined", createdAt = 20, dTag = "second"),
rsvp(bob, conf, "tentative", createdAt = 30),
),
)
assertTrue(result.upcoming.isEmpty())
}
@Test
fun attendeeFilterRunsOnTheLatestAnswer() {
val conf = timeSlot("conf", start = now + 86_400)
val result =
fold(
listOf(
rsvp(alice, conf, "accepted", createdAt = 10),
rsvp(carol, conf, "accepted", createdAt = 20),
),
isAttendee = { it.pubKey != carol },
)
assertEquals(listOf(alice), result.upcoming.single().attendees)
}
@Test
fun dropsEndedEvents_keepsOngoingOnes_sortsSoonestFirst() {
val past = timeSlot("past", start = now - 7_200, end = now - 3_600)
val ongoing = timeSlot("ongoing", start = now - 3_600, end = now + 3_600)
val later = timeSlot("later", start = now + 10 * 86_400)
val sooner = timeSlot("sooner", start = now + 86_400)
val result =
fold(
listOf(past, ongoing, later, sooner).map { rsvp(alice, it, "accepted", createdAt = 10) },
)
assertEquals(
listOf(ongoing, sooner, later),
result.upcoming.map { it.appointment.address },
)
}
@Test
fun appointmentsNotInTheCacheAreReportedForFetching() {
val missing = Address(CalendarDateSlotEvent.KIND, host, "not-loaded")
val result = fold(listOf(rsvp(alice, missing, "accepted", createdAt = 10)))
assertTrue(result.upcoming.isEmpty())
assertEquals(listOf(missing), result.unresolved.map { it.address })
}
@Test
fun ignoresRsvpsThatPointAtNonAppointments() {
val calendar = Address(CalendarCollectionEvent.KIND, host, "cal")
val result = fold(listOf(rsvp(alice, calendar, "accepted", createdAt = 10)))
assertTrue(result.upcoming.isEmpty())
assertTrue(result.unresolved.isEmpty())
}
@Test
fun appointmentsByAHiddenHostAreNeitherShownNorFetched() {
val conf = timeSlot("conf", start = now + 86_400)
val missing = Address(CalendarTimeSlotEvent.KIND, host, "not-loaded")
val result =
computeFollowsGoing(
rsvps = listOf(rsvp(alice, conf, "accepted", 10), rsvp(alice, missing, "accepted", 11)),
isAttendee = { true },
appointmentFor = ::appointmentFor,
nowSeconds = now,
isAppointmentVisible = { it.pubKeyHex != host },
)
assertTrue(result.upcoming.isEmpty())
assertTrue(result.unresolved.isEmpty())
}
@Test
fun unresolvedAreOrderedByTheMostRecentRsvp() {
val old = Address(CalendarTimeSlotEvent.KIND, host, "old")
val fresh = Address(CalendarTimeSlotEvent.KIND, host, "fresh")
val middle = Address(CalendarTimeSlotEvent.KIND, host, "middle")
val result =
fold(
listOf(
rsvp(alice, old, "accepted", createdAt = 10),
rsvp(alice, fresh, "accepted", createdAt = 30),
rsvp(alice, middle, "accepted", createdAt = 15),
// Bob's newer answer makes "middle" the most recent of all.
rsvp(bob, middle, "accepted", createdAt = 40),
),
)
assertEquals(listOf(middle, fresh, old), result.unresolved.map { it.address })
}
@Test
fun anUnchangedFoldIsEqualToThePreviousOne() {
val conf = timeSlot("conf", start = now + 86_400)
val rsvps = listOf(rsvp(alice, conf, "accepted", 10), rsvp(bob, conf, "accepted", 20))
// The view model's StateFlow relies on this to drop no-op folds.
assertEquals(fold(rsvps), fold(rsvps.reversed()))
}
@Test
fun anOlderGoingIsNotTheCurrentAnswerOnceTheAuthorDeclines() {
// The Home feed's rule: the "going" under the old d tag is still live, but it is not
// Alice's answer any more.
val conf = timeSlot("conf", start = now + 86_400)
val going = rsvp(alice, conf, "accepted", createdAt = 10)
val declined = rsvp(alice, conf, "declined", createdAt = 20)
val latest = latestRsvpAnswers(listOf(going, declined))
assertTrue(declined.isLatestAnswerIn(latest))
assertFalse(going.isLatestAnswerIn(latest))
}
@Test
fun anAnswerStaysCurrentWhenOnlyOtherPeopleOrOtherEventsChange() {
val conf = timeSlot("conf", start = now + 86_400)
val party = timeSlot("party", start = now + 2 * 86_400)
val aliceConf = rsvp(alice, conf, "accepted", createdAt = 10)
val aliceParty = rsvp(alice, party, "declined", createdAt = 30)
val bobConf = rsvp(bob, conf, "declined", createdAt = 40)
val latest = latestRsvpAnswers(listOf(aliceConf, aliceParty, bobConf))
assertTrue(aliceConf.isLatestAnswerIn(latest))
}
}
@@ -669,6 +669,29 @@
<string name="mark_all_known_as_read">Označit všechny známé jako přečtené</string>
<string name="mark_all_new_as_read">Označit všechny nové jako přečtené</string>
<string name="mark_all_as_read">Označit všechny jako přečtené</string>
<string name="account_backup_headline">Váš tajný klíč je váš účet</string>
<string name="account_backup_intro">Kdokoli ho má, může za vás publikovat. Pokud ho ztratíte, nikdo ho nedokáže obnovit, ani Amethyst.</string>
<string name="account_backup_tap_to_reveal">Klepnutím zobrazíte</string>
<string name="account_backup_write_down_hint">Zapište si ho v tomto pořadí. Na velikosti písmen nezáleží.</string>
<string name="account_backup_qr_code">QR kód</string>
<string name="account_backup_encrypted_title">Kopie chráněná heslem</string>
<string name="account_backup_encrypted_password">Heslo</string>
<string name="account_backup_encrypted_body">ncryptsec1…, který funguje jen s vaším heslem.</string>
<plurals name="account_backup_password_min_length">
<item quantity="one">Alespoň %1$d znak</item>
<item quantity="few">Alespoň %1$d znaky</item>
<item quantity="many">Alespoň %1$d znaku</item>
<item quantity="other">Alespoň %1$d znaků</item>
</plurals>
<string name="account_backup_encrypted_repeat_password">Zopakujte heslo</string>
<string name="account_backup_encrypted_password_mismatch">Hesla se neshodují</string>
<string name="account_backup_encrypt">Zašifrovat klíč</string>
<string name="account_backup_encrypting">Šifrování…</string>
<string name="account_backup_encrypted_saved_hint">Můžete ho bezpečně uložit do správce hesel nebo do poznámek v cloudu: bez hesla ho nelze otevřít a zapomenuté heslo nelze obnovit.</string>
<string name="account_backup_encrypt_again">Použít jiné heslo</string>
<string name="account_backup_tip_storage">Uložte papír tam, kde ho najdete jen vy, nebo si klíč uložte do správce hesel.</string>
<string name="account_backup_tip_trust">Nikdy nevkládejte svůj klíč na weby nebo do aplikací, kterým nedůvěřujete.</string>
<string name="account_backup_tip_developers">Vývojáři Amethystu po vás klíč nikdy nebudou chtít.</string>
<string name="backup_keys_nudge_title">Zálohujte si klíče</string>
<string name="backup_keys_nudge_body">Váš tajný klíč je jediný způsob, jak se k tomuto účtu dostat. Pokud ho ztratíte, nelze ho už nikdy obnovit. Uložte si ho někam do bezpečí.</string>
<string name="backup_keys_nudge_backup_now">Zálohovat nyní</string>
@@ -702,6 +725,8 @@
<string name="quick_action_request_deletion_gallery_alert_body_v2">Odstranit toto médium z vaší galerie.</string>
<string name="quick_action_request_deletion_alert_title">Požadavek na smazání</string>
<string name="quick_action_request_deletion_alert_body">Amethyst požádá o smazání vaší poznámky ze spojek, ke kterým jste v současnosti připojeni. Není zaručeno, že vaše poznámka bude trvale smazána z těchto spojek nebo z dalších spojek, kde může být uložena.</string>
<string name="quick_action_request_deletion_alert_body_group">Amethyst požádá všechny v této šifrované skupině, aby tuto zprávu smazali. Jejich aplikace by ji měly odstranit, ale není zaručeno, že každý člen svou kopii smaže.</string>
<string name="quick_action_request_deletion_alert_body_private">Amethyst pošle lidem v této konverzaci šifrovaný požadavek na smazání. Jejich aplikace by měly zprávu odstranit, ale není zaručeno, že budou smazány všechny kopie.</string>
<string name="quick_action_block_dialog_btn">Blokovat</string>
<string name="quick_action_delete_dialog_btn">Smazat</string>
<string name="quick_action_block">Blokovat</string>
@@ -1736,6 +1761,14 @@
<string name="relay_group_field_name">Název</string>
<string name="relay_group_field_about">Popis</string>
<string name="relay_group_field_picture">Obrázek skupiny</string>
<string name="relay_group_field_banner">URL obrázku banneru</string>
<string name="relay_group_migration_moved_title">Tato skupina se možná přesunula</string>
<string name="relay_group_migration_fork_title">Tato skupina možná má odnož</string>
<string name="relay_group_migration_by_admins">Její administrátoři ji nyní uvádějí na %1$s</string>
<string name="relay_group_migration_by_friends">Lidé, které sledujete, ji nyní uvádějí na %1$s</string>
<string name="relay_group_migration_open">Otevřít tam</string>
<string name="relay_group_migration_move">Přesunout</string>
<string name="relay_group_migration_dismiss">Zavřít</string>
<string name="relay_group_add_photo">Přidat fotku</string>
<string name="relay_group_change_photo">Změnit fotku</string>
<string name="relay_group_section_discovery">Objevování</string>
@@ -1840,6 +1873,9 @@
<string name="tor_connection_failed_body">Amethystu se nepodařilo dokončit připojení k síti Tor. Chcete použít běžné připojení (bez Toru), aby se feed mohl načíst? Tuto volbu si zapamatujeme na hodinu a poté znovu zkusíme Tor.</string>
<string name="tor_continue_without_for_session">Použít běžné připojení</string>
<string name="tor_keep_waiting">Pokračovat v čekání</string>
<string name="tor_splash_connecting">Připojování k Toru…</string>
<string name="tor_splash_error">Chyba Toru: %1$s</string>
<string name="tor_splash_explainer">Amethyst používá Tor, aby skryl vaši IP adresu před relayi. Spuštění Toru může trvat až minutu a vyžaduje přístup k internetu.</string>
<string name="notification_settings_categories_explainer">Klepněte na kategorii a otevřete pro ni nastavení oznámení Androidu — zvuk, důležitost, odznaky a režim Nerušit najdete tam.</string>
<string name="notification_channel_status_on">Zapnuto</string>
<string name="notification_channel_status_silent">Ztlumeno</string>
@@ -2472,6 +2508,8 @@
<string name="select_list_to_filter">Vyberte seznam pro filtrování kanálu</string>
<string name="add_dvm_to_favorites">Přidat algoritmus zdroje k oblíbeným</string>
<string name="remove_dvm_from_favorites">Odebrat z oblíbených</string>
<string name="add_follow_set_to_favorites">Přidat seznam sledování do oblíbených kanálů</string>
<string name="remove_follow_set_from_favorites">Odebrat seznam sledování z oblíbených kanálů</string>
<string name="favorite_dvms_explainer">Algoritmy zdrojů, které zde označíte hvězdičkou, se zobrazí jako filtry na hlavním zdroji. Otevřete Objevovat pro přidání dalších.</string>
<string name="favorite_dvms_empty_headline">Připněte si oblíbené algoritmy</string>
<string name="favorite_dvms_empty_step1">Klepněte na "%1$s" níže pro procházení algoritmů.</string>
@@ -2692,6 +2730,20 @@
<string name="nickname_private">Viditelné pouze pro vás</string>
<string name="cast_to_device_dialog_title">Odeslat do…</string>
<string name="cast_searching_for_devices">Hledání zařízení ve vaší Wi-Fi…</string>
<string name="cast_local_network_permission_title">Vyžadováno oprávnění</string>
<string name="cast_local_network_permission_message">Amethyst potřebuje přístup k místní síti, aby našel zařízení Cast ve vaší Wi-Fi. Povolte ho prosím v nastavení aplikace.</string>
<string name="cast_local_network_permission_open_settings">Otevřít nastavení</string>
<string name="cast_generic_receiver">Cílové zařízení</string>
<string name="cast_error_unsupported_media">%1$s nepodporuje formát tohoto videa</string>
<string name="cast_error_playback_failed">Toto video se na %1$s nepodařilo přehrát</string>
<string name="cast_error_receiver_not_responding">%1$s přestává odpovídat. Obvykle pomůže zařízení restartovat.</string>
<string name="cast_error_connect_failed">Nepodařilo se připojit k %1$s. Obvykle pomůže zařízení restartovat.</string>
<string name="cast_error_device_offline">%1$s je offline</string>
<string name="cast_error_load_failed">Toto video se na %1$s nepodařilo načíst</string>
<string name="cast_error_play_services_unavailable">Odesílání vyžaduje služby Google Play, které na tomto zařízení nejsou k dispozici</string>
<string name="cast_error_unsupported_media_detail">%1$s nepodporuje formát tohoto videa (%2$s)</string>
<string name="cast_error_playback_failed_detail">Toto video se na %1$s nepodařilo přehrát (%2$s)</string>
<string name="cast_error_receiver_not_responding_detail">Přehrávání tohoto videa se na %1$s vůbec nespustilo (%2$s). Zařízení možná tento formát nepodporuje, nebo může pomoci restart.</string>
<string name="hls_pick_video_primary">Vyberte video</string>
<string name="hls_pick_video_helper">Vaše video bude přepsáno do více rozlišení, aby si diváci užili plynulé přehrávání na jakémkoli připojení.</string>
<string name="hls_change_video">Změnit</string>
@@ -2803,8 +2855,13 @@
<string name="relay_management_no_moderation_events">Žádné události k moderování</string>
<string name="relay_management_banned_events">Zakázané události</string>
<string name="relay_management_no_banned_events">Žádné zakázané události</string>
<string name="relay_management_allowed_events">Povolené události</string>
<string name="relay_management_no_allowed_events">Žádné povolené události</string>
<string name="relay_management_allow_event">Povolit událost</string>
<string name="relay_management_allowed_kinds">Povolené druhy</string>
<string name="relay_management_no_allowed_kinds">Žádné povolené druhy</string>
<string name="relay_management_disallowed_kinds">Nepovolené druhy</string>
<string name="relay_management_no_disallowed_kinds">Žádné nepovolené druhy</string>
<string name="relay_management_blocked_ips">Blokované IP adresy</string>
<string name="relay_management_no_blocked_ips">Žádné blokované IP adresy</string>
<string name="relay_management_add">Přidat</string>
@@ -2837,6 +2894,9 @@
<string name="relay_members_join_sent">Žádost o připojení odeslána</string>
<string name="relay_members_leave_sent">Žádost o opuštění odeslána</string>
<string name="relay_members_you_are_member">Jste členem</string>
<string name="relay_members_invite_code">Kód pozvánky</string>
<string name="relay_members_invite_code_hint">Tento relay přijímá členy s kódem pozvánky od svého provozovatele</string>
<string name="relay_members_unverifiable">Tento relay nezveřejňuje svůj identifikační klíč (NIP-11 self), takže jeho seznam členů nelze ověřit</string>
<string name="relay_membership_list">Seznam členů relé</string>
<string name="relay_member_added">Člen přidán do relé</string>
<string name="relay_members_added">%1$d členů přidáno do relé</string>
@@ -4172,6 +4232,11 @@
<string name="marmot_admin_granted">Administrátorská práva udělena</string>
<string name="marmot_admin_revoked">Administrátorská práva odebrána</string>
<string name="marmot_change_photo">Změnit fotku</string>
<string name="marmot_out_of_sync_body">Zprávy od ostatních členů už na tomto zařízení nelze přečíst. Resetujte svou kopii skupiny a pak požádejte administrátora, aby vás odebral a znovu přidal.</string>
<string name="marmot_out_of_sync_reset">Resetovat</string>
<string name="marmot_out_of_sync_confirm_title">Resetovat tuto skupinu?</string>
<string name="marmot_out_of_sync_confirm_body">Toto zařízení přestane být členem, dokud vás administrátor znovu nepřidá. Zprávy, které už máte, tu zůstanou.</string>
<string name="marmot_awaiting_reinvite">Čeká se na opětovné přidání. Požádejte administrátora, aby vás odebral a znovu přidal.</string>
<string name="marmot_enable_encrypted_media_needs_server">Nejprve přidejte v Nastavení mediální server Blossom — skupina potřebuje místo, kam nahrávat přílohy.</string>
<string name="marmot_encrypted_media_enabled_toast">Tato skupina nyní používá šifrované přílohy</string>
<string name="marmot_failed_to_add_user">Nepodařilo se přidat %1$s: %2$s</string>
@@ -4712,6 +4777,7 @@
<string name="podcast_trailer_upload_hint">Krátký zvukový nebo video náhled</string>
<string name="podcast_value_error_title">Chyba Value-for-Value</string>
<string name="podcast_value_keysend_requires_nwc">Připojte peněženku Nostr Wallet Connect pro odesílání příjemcům keysend (uzel).</string>
<string name="podcast_value_keysend_not_supported">Vaše peněženka nepodporuje keysend, proto byli příjemci keysend (uzel) přeskočeni.</string>
<string name="podcast_value_no_recipients">Tento podcast nemá žádné platitelné příjemce hodnoty.</string>
<string name="podcast_value_stream_requires_wallet">Připojte peněženku Nostr Wallet Connect nebo debetní peněženku pro streamování sats během poslechu.</string>
<string name="podcast_value_user_no_lnaddress">Tento uživatel nemá Lightning adresu</string>
@@ -5411,6 +5477,7 @@
</plurals>
<string name="wallet_transactions_load_failed">Nepodařilo se načíst transakce</string>
<string name="wallet_transactions_load_more_failed">Nepodařilo se načíst další transakce</string>
<string name="wallet_transactions_not_supported">Vaše peněženka nenabízí historii transakcí</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Zobrazí varovnou zprávu, když příspěvky mají 5 nebo více hlášení od vašich sledovaných</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Upozornit na hlášení</string>
<string name="web_bookmark_add_title">Přidat webovou záložku</string>
@@ -653,6 +653,27 @@
<string name="mark_all_known_as_read">Alle Kontakte als gelesen markieren</string>
<string name="mark_all_new_as_read">Alle Unbekannten als gelesen markieren</string>
<string name="mark_all_as_read">Alle als gelesen markieren</string>
<string name="account_backup_headline">Dein geheimer Schlüssel ist dein Konto</string>
<string name="account_backup_intro">Wer ihn hat, kann in deinem Namen posten. Wenn du ihn verlierst, kann ihn niemand wiederherstellen, nicht einmal Amethyst.</string>
<string name="account_backup_tap_to_reveal">Tippen zum Anzeigen</string>
<string name="account_backup_write_down_hint">Schreibe ihn in dieser Reihenfolge auf. Groß- und Kleinschreibung spielen keine Rolle.</string>
<string name="account_backup_qr_code">QR-Code</string>
<string name="account_backup_encrypted_title">Passwortgeschützte Kopie</string>
<string name="account_backup_encrypted_password">Passwort</string>
<string name="account_backup_encrypted_body">Ein ncryptsec1…, das nur mit deinem Passwort funktioniert.</string>
<plurals name="account_backup_password_min_length">
<item quantity="one">Mindestens %1$d Zeichen</item>
<item quantity="other">Mindestens %1$d Zeichen</item>
</plurals>
<string name="account_backup_encrypted_repeat_password">Passwort wiederholen</string>
<string name="account_backup_encrypted_password_mismatch">Passwörter stimmen nicht überein</string>
<string name="account_backup_encrypt">Schlüssel verschlüsseln</string>
<string name="account_backup_encrypting">Verschlüsseln…</string>
<string name="account_backup_encrypted_saved_hint">Kann sicher in einem Passwortmanager oder in Cloud-Notizen aufbewahrt werden: Ohne das Passwort lässt es sich nicht öffnen, und ein vergessenes Passwort kann nicht wiederhergestellt werden.</string>
<string name="account_backup_encrypt_again">Anderes Passwort verwenden</string>
<string name="account_backup_tip_storage">Bewahre das Papier an einem Ort auf, den nur du kennst, oder speichere den Schlüssel in einem Passwortmanager.</string>
<string name="account_backup_tip_trust">Füge deinen Schlüssel niemals in Websites oder Apps ein, denen du nicht vertraust.</string>
<string name="account_backup_tip_developers">Amethyst-Entwickler werden dich niemals nach deinem Schlüssel fragen.</string>
<string name="backup_keys_nudge_title">Sichere deine Schlüssel</string>
<string name="backup_keys_nudge_body">Dein geheimer Schlüssel ist der einzige Weg zu diesem Konto. Wenn du ihn verlierst, kann er nie wiederhergestellt werden. Speichere ihn jetzt an einem sicheren Ort.</string>
<string name="backup_keys_nudge_backup_now">Jetzt sichern</string>
@@ -686,6 +707,8 @@
<string name="quick_action_request_deletion_gallery_alert_body_v2">Entferne dieses Medium von deiner Galerie.</string>
<string name="quick_action_request_deletion_alert_title">Löschung anfordern</string>
<string name="quick_action_request_deletion_alert_body">Amethyst wird darum bitten, dass dein Beitrag von den Relays gelöscht wird, mit denen du derzeit verbunden bist. Es gibt keine Garantie dafür, dass dein Beitrag dauerhaft von diesen oder anderen Relays, auf denen er gespeichert sein könnte, gelöscht wird.</string>
<string name="quick_action_request_deletion_alert_body_group">Amethyst bittet alle in dieser verschlüsselten Gruppe, diese Nachricht zu löschen. Ihre Apps sollten sie entfernen, aber es gibt keine Garantie, dass jedes Mitglied seine Kopie löscht.</string>
<string name="quick_action_request_deletion_alert_body_private">Amethyst sendet eine verschlüsselte Löschanfrage an die Personen in dieser Unterhaltung. Ihre Apps sollten die Nachricht entfernen, aber es gibt keine Garantie, dass jede Kopie gelöscht wird.</string>
<string name="quick_action_block_dialog_btn">Blockieren</string>
<string name="quick_action_delete_dialog_btn">Löschen</string>
<string name="quick_action_block">Blockieren</string>
@@ -1716,6 +1739,14 @@
<string name="relay_group_field_name">Name</string>
<string name="relay_group_field_about">Beschreibung</string>
<string name="relay_group_field_picture">Gruppenbild</string>
<string name="relay_group_field_banner">Banner-Bild-URL</string>
<string name="relay_group_migration_moved_title">Diese Gruppe ist möglicherweise umgezogen</string>
<string name="relay_group_migration_fork_title">Diese Gruppe hat möglicherweise einen Fork</string>
<string name="relay_group_migration_by_admins">Ihre Admins führen sie jetzt auf %1$s</string>
<string name="relay_group_migration_by_friends">Personen, denen du folgst, führen sie jetzt auf %1$s</string>
<string name="relay_group_migration_open">Dort öffnen</string>
<string name="relay_group_migration_move">Verschieben</string>
<string name="relay_group_migration_dismiss">Verwerfen</string>
<string name="relay_group_add_photo">Foto hinzufügen</string>
<string name="relay_group_change_photo">Foto ändern</string>
<string name="relay_group_section_discovery">Entdeckung</string>
@@ -1820,6 +1851,9 @@
<string name="tor_connection_failed_body">Amethyst konnte den Tor-Start nicht abschließen. Eine reguläre (Nicht-Tor-)Verbindung verwenden, damit der Feed laden kann? Wir merken uns diese Auswahl für die nächste Stunde und versuchen Tor danach erneut.</string>
<string name="tor_continue_without_for_session">Verbindung ohne Tor verwenden</string>
<string name="tor_keep_waiting">Weiter warten</string>
<string name="tor_splash_connecting">Verbindung zu Tor wird hergestellt…</string>
<string name="tor_splash_error">Tor-Fehler: %1$s</string>
<string name="tor_splash_explainer">Amethyst nutzt Tor, um deine IP-Adresse vor Relays zu verbergen. Der Start von Tor kann eine Minute dauern und benötigt Internetzugang.</string>
<string name="notification_settings_categories_explainer">Tippe auf eine Kategorie, um die Android-Benachrichtigungseinstellungen dafür zu öffnen — Ton, Wichtigkeit, Badges und „Nicht stören" findest du dort.</string>
<string name="notification_channel_status_on">An</string>
<string name="notification_channel_status_silent">Lautlos</string>
@@ -2452,6 +2486,8 @@
<string name="select_list_to_filter">Liste zum Filtern des Feeds auswählen</string>
<string name="add_dvm_to_favorites">Feed-Algorithmus zu Favoriten hinzufügen</string>
<string name="remove_dvm_from_favorites">Aus Favoriten entfernen</string>
<string name="add_follow_set_to_favorites">Follower-Liste zu Lieblings-Feeds hinzufügen</string>
<string name="remove_follow_set_from_favorites">Follower-Liste aus Lieblings-Feeds entfernen</string>
<string name="favorite_dvms_explainer">Feed-Algorithmen, die du mit einem Stern markierst, erscheinen als Filter-Chips im Home-Feed. Öffne Entdecken, um weitere hinzuzufügen.</string>
<string name="favorite_dvms_empty_headline">Lieblingsalgorithmen anheften</string>
<string name="favorite_dvms_empty_step1">Tippe unten auf "%1$s", um Algorithmen zu durchsuchen.</string>
@@ -2672,6 +2708,20 @@
<string name="nickname_private">Nur für dich sichtbar</string>
<string name="cast_to_device_dialog_title">Streamen an…</string>
<string name="cast_searching_for_devices">Suche nach Geräten in deinem WLAN…</string>
<string name="cast_local_network_permission_title">Berechtigung erforderlich</string>
<string name="cast_local_network_permission_message">Amethyst benötigt Zugriff auf das lokale Netzwerk, um Cast-Geräte in deinem WLAN zu finden. Bitte aktiviere ihn in den App-Einstellungen.</string>
<string name="cast_local_network_permission_open_settings">Einstellungen öffnen</string>
<string name="cast_generic_receiver">Das Streaming-Gerät</string>
<string name="cast_error_unsupported_media">%1$s kann das Format dieses Videos nicht abspielen</string>
<string name="cast_error_playback_failed">%1$s konnte dieses Video nicht abspielen</string>
<string name="cast_error_receiver_not_responding">%1$s reagiert nicht mehr. Ein Neustart des Geräts hilft meistens.</string>
<string name="cast_error_connect_failed">Verbindung zu %1$s fehlgeschlagen. Ein Neustart des Geräts hilft meistens.</string>
<string name="cast_error_device_offline">%1$s ist offline</string>
<string name="cast_error_load_failed">Dieses Video konnte auf %1$s nicht geladen werden</string>
<string name="cast_error_play_services_unavailable">Streamen erfordert Google Play-Dienste, die auf diesem Gerät nicht verfügbar sind</string>
<string name="cast_error_unsupported_media_detail">%1$s kann das Format dieses Videos nicht abspielen (%2$s)</string>
<string name="cast_error_playback_failed_detail">%1$s konnte dieses Video nicht abspielen (%2$s)</string>
<string name="cast_error_receiver_not_responding_detail">%1$s hat die Wiedergabe dieses Videos nie gestartet (%2$s). Vielleicht wird das Format nicht unterstützt, oder ein Neustart des Geräts hilft.</string>
<string name="hls_pick_video_primary">Video auswählen</string>
<string name="hls_pick_video_helper">Dein Video wird in mehrere Auflösungen transkodiert, damit Zuschauer eine reibungslose Wiedergabe auf jeder Verbindung erhalten.</string>
<string name="hls_change_video">Ändern</string>
@@ -2783,8 +2833,13 @@
<string name="relay_management_no_moderation_events">Keine Ereignisse zur Moderation</string>
<string name="relay_management_banned_events">Gesperrte Ereignisse</string>
<string name="relay_management_no_banned_events">Keine gesperrten Ereignisse</string>
<string name="relay_management_allowed_events">Erlaubte Ereignisse</string>
<string name="relay_management_no_allowed_events">Keine erlaubten Ereignisse</string>
<string name="relay_management_allow_event">Ereignis erlauben</string>
<string name="relay_management_allowed_kinds">Erlaubte Arten</string>
<string name="relay_management_no_allowed_kinds">Keine erlaubten Arten</string>
<string name="relay_management_disallowed_kinds">Nicht erlaubte Arten</string>
<string name="relay_management_no_disallowed_kinds">Keine nicht erlaubten Arten</string>
<string name="relay_management_blocked_ips">Blockierte IPs</string>
<string name="relay_management_no_blocked_ips">Keine blockierten IPs</string>
<string name="relay_management_add">Hinzufügen</string>
@@ -2817,6 +2872,9 @@
<string name="relay_members_join_sent">Beitrittsanfrage gesendet</string>
<string name="relay_members_leave_sent">Austrittsanfrage gesendet</string>
<string name="relay_members_you_are_member">Du bist Mitglied</string>
<string name="relay_members_invite_code">Einladungscode</string>
<string name="relay_members_invite_code_hint">Dieses Relay lässt Mitglieder mit einem Einladungscode seines Betreibers zu</string>
<string name="relay_members_unverifiable">Dieses Relay veröffentlicht seinen Identitätsschlüssel nicht (NIP-11 self), daher kann seine Mitgliederliste nicht überprüft werden</string>
<string name="relay_membership_list">Relay-Mitgliederliste</string>
<string name="relay_member_added">Mitglied zum Relay hinzugefügt</string>
<string name="relay_members_added">%1$d Mitglieder zum Relay hinzugefügt</string>
@@ -4074,6 +4132,11 @@
<string name="marmot_admin_granted">Administratorrechte erteilt</string>
<string name="marmot_admin_revoked">Administratorrechte widerrufen</string>
<string name="marmot_change_photo">Foto ändern</string>
<string name="marmot_out_of_sync_body">Nachrichten der anderen Mitglieder können auf diesem Gerät nicht mehr gelesen werden. Setze deine Kopie der Gruppe zurück und bitte dann einen Admin, dich zu entfernen und wieder hinzuzufügen.</string>
<string name="marmot_out_of_sync_reset">Zurücksetzen</string>
<string name="marmot_out_of_sync_confirm_title">Diese Gruppe zurücksetzen?</string>
<string name="marmot_out_of_sync_confirm_body">Dieses Gerät ist kein Mitglied mehr, bis ein Admin dich wieder hinzufügt. Deine bisherigen Nachrichten bleiben hier.</string>
<string name="marmot_awaiting_reinvite">Warte darauf, wieder hinzugefügt zu werden. Bitte einen Admin, dich zu entfernen und erneut hinzuzufügen.</string>
<string name="marmot_enable_encrypted_media_needs_server">Füge zuerst in den Einstellungen einen Blossom-Medienserver hinzu — die Gruppe braucht einen Ort, an den Anhänge hochgeladen werden.</string>
<string name="marmot_encrypted_media_enabled_toast">Diese Gruppe verwendet jetzt verschlüsselte Anhänge</string>
<string name="marmot_failed_to_add_user">%1$s konnte nicht hinzugefügt werden: %2$s</string>
@@ -4556,6 +4619,7 @@
<string name="podcast_trailer_upload_hint">Kurze Audio- oder Videovorschau</string>
<string name="podcast_value_error_title">Value-for-Value-Fehler</string>
<string name="podcast_value_keysend_requires_nwc">Verbinde eine Nostr-Wallet-Connect-Wallet, um an Keysend-(Node-)Empfänger zu senden.</string>
<string name="podcast_value_keysend_not_supported">Deine Wallet unterstützt kein Keysend, daher wurden Node-Empfänger übersprungen.</string>
<string name="podcast_value_no_recipients">Dieser Podcast hat keine zahlbaren Value-Empfänger.</string>
<string name="podcast_value_stream_requires_wallet">Verbinde eine Nostr-Wallet-Connect- oder Debit-Wallet, um Sats beim Zuhören zu streamen.</string>
<string name="podcast_value_user_no_lnaddress">Dieser Nutzer hat keine Lightning-Adresse</string>
@@ -5189,6 +5253,7 @@
</plurals>
<string name="wallet_transactions_load_failed">Transaktionen konnten nicht geladen werden</string>
<string name="wallet_transactions_load_more_failed">Weitere Transaktionen konnten nicht geladen werden</string>
<string name="wallet_transactions_not_supported">Deine Wallet bietet keinen Transaktionsverlauf</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Zeigt eine Warnmeldung an, wenn die Beiträge 5 oder mehr Berichte von den folgenden Beiträgen enthalten</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Bei Berichten warnen</string>
<string name="web_bookmark_add_title">Web-Lesezeichen hinzufügen</string>
@@ -313,7 +313,7 @@
<string name="geocache_hunt_needs_caches">न्यूनतम एक कोष जोडें।</string>
<string name="geocache_publish_failed">प्रकाशन असफल। पुनःप्रसारक संयोजन का जाँच करें तथा पुनःप्रयास करें।</string>
<string name="podcast_value_zap_split_hint">इसको ज्साप इनमें विभाजित :</string>
<string name="podcast_value_split_percent">%1$d प्रतिशतप्रतिशत</string>
<string name="podcast_value_split_percent">%1$d प्रतिशत</string>
<string name="podcast_value_for_value">मूल्य के लिए मूल्य</string>
<string name="relay_monitor_rtt_open">खोलें</string>
<string name="relay_monitor_rtt_read">पढें</string>
@@ -661,7 +661,10 @@
<string name="account_backup_encrypted_title">पारणशब्द रक्षित अनुकृति</string>
<string name="account_backup_encrypted_password">पारणशब्द</string>
<string name="account_backup_encrypted_body">एक ncryptsec1… जो केवल आपके पारणशब्द से कार्य करता है।</string>
<string name="account_backup_password_min_length">न्यूनतम %1$d अक्षर</string>
<plurals name="account_backup_password_min_length">
<item quantity="one">न्यूनतम %1$d अक्षर</item>
<item quantity="other">न्यूनतम %1$d अक्षर</item>
</plurals>
<string name="account_backup_encrypted_repeat_password">पारणशब्द दोहराएँ</string>
<string name="account_backup_encrypted_password_mismatch">पारणशब्दों में मेल नहीं</string>
<string name="account_backup_encrypt">रहस्यीकरण कुंचिका</string>
@@ -704,6 +707,8 @@
<string name="quick_action_request_deletion_gallery_alert_body_v2">इस श्रव्यदृश्याभिलेख को आपके चित्रालय से हटाएँ।</string>
<string name="quick_action_request_deletion_alert_title">हटाने की याचना</string>
<string name="quick_action_request_deletion_alert_body">अमेथिस्ट अनुरोध करेगा कि आपका टीका मिटा दिया जाए उन पुनःप्रसारकों से जिनके साथ आप अब जुडे हुए हैं। कोई आश्वासन नहीं कि आपका टीका सर्वदा के लिए मिटा दिया जाएगा उन पुनःप्रसारकों से, अथवा अन्य पुनःप्रसारकों में से जहाँ यह रखा गया हो।</string>
<string name="quick_action_request_deletion_alert_body_group">अमेथिस्ट इस रहस्यीकृत समूह में सबसे अनुरोध करेगा इस सन्देश को मिटाने के लिए। उनके क्रमक इसको हटाना चाहिए पर कोई आश्वासन नहीं कि सभी सदस्य अपनी अनुकृति मिटा देंगे।</string>
<string name="quick_action_request_deletion_alert_body_private">अमेथिस्ट एक रहस्यीकृत विलोपन अनुरोध इस वार्तालाप में सभी लोगों को भेजेगा। उनके क्रमक सन्देश को हटाना चाहिए पर कोई आश्वासन नहीं कि सभी अनुकृतियाँ मिटा दिए जाएँगे।</string>
<string name="quick_action_block_dialog_btn">बाधित करें</string>
<string name="quick_action_delete_dialog_btn">मिटाएँ</string>
<string name="quick_action_block">बाधित करें</string>
@@ -1846,6 +1851,9 @@
<string name="tor_connection_failed_body">अमेथिस्ट द्वारा टोर॰ का स्वयम उत्थापन सम्पन्न नहीं हुआ। क्या एक समान्य संयोजन (टोर॰ अतिरिक्त) का प्रयोग करें जिससे कि सूचनावली का आवहन हो सके। इस चयन का स्मरण रखा जाएगा अगले घण्टे तक तथा टोर॰ का पुनःप्रयास किया जाएगा तत्पश्चात।</string>
<string name="tor_continue_without_for_session">सामान्य संयोजन का प्रयोग करें</string>
<string name="tor_keep_waiting">प्रतीक्षा करते रहें</string>
<string name="tor_splash_connecting">टोर से संयोजन किया जा रहा है…</string>
<string name="tor_splash_error">टोर अपक्रम। %1$s</string>
<string name="tor_splash_explainer">अमेथिस्ट टोर का उपयोग करता है आपके अंकीय जालपता छिपाने के लिए पुनःप्रसारकों से। टोर को आरम्भ करने के लिए एक मिनुट लग सकता है। इसके लिए संचारजाल अभिगमन आवश्यक।</string>
<string name="notification_settings_categories_explainer">श्रेणि दबाएँ उसके आण्ड्रोइड सूचना स्थापना विकल्पों को खोलने के लिए। ध्वनि महत्व पदक तथा विघ्नविराम वहाँ हैं।</string>
<string name="notification_channel_status_on">चालू</string>
<string name="notification_channel_status_silent">मौन</string>
@@ -3138,7 +3146,7 @@
<item quantity="other">%1$d पुनःप्रसारक</item>
</plurals>
<string name="active_subs_search_keywords">ग्राहकताएँ छलनियाँ पुनःप्रसारक अनुरोध अनु॰ संयोजन क्यों निदानतन्त्र</string>
<string name="active_subs_share">%1$dप्रतिशतप्रतिशत सब में से</string>
<string name="active_subs_share">%1$d प्रतिशत सब में से</string>
<string name="active_subs_title">सक्रिय पुनःप्रसारक ग्राहकताएँ</string>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d छलनी आरोपित नहीं अब तक</item>
@@ -4124,6 +4132,11 @@
<string name="marmot_admin_granted">प्रबन्धन अधिकार दिए गए</string>
<string name="marmot_admin_revoked">प्रबन्धन अधिकार हटाए गए</string>
<string name="marmot_change_photo">चित्र परिवर्तन</string>
<string name="marmot_out_of_sync_body">अन्य सदस्यों से सन्देश इस यन्त्र पर आगे से पढे नहीं जा सकते। समूह की आपकी अनुकृति को पुनःस्थापित करें। तत्पश्चात एक प्रशासक को पूछें कि आपको हटाएँ तथा पुनःजोडें।</string>
<string name="marmot_out_of_sync_reset">पुनःस्थापन</string>
<string name="marmot_out_of_sync_confirm_title">क्या इस समूह को पुनःस्थापित करें।</string>
<string name="marmot_out_of_sync_confirm_body">इस यन्त्र की सदस्यता रुक जाती है जब तक कि एक प्रशासक आपको पुनःजोडता नहीं। सन्देश जो आपके पास पहले से हैं यहीं रहते हैं।</string>
<string name="marmot_awaiting_reinvite">पुनःजोडे जाने की प्रतीक्षा में। एक प्रशासक को पूछें कि आपको हटाएँ तथा पुनःजोडें।</string>
<string name="marmot_enable_encrypted_media_needs_server">पहले एक ब्लोस्सम श्रव्यदृश्याभिलेख सेवासंगणक जोडें स्थापना विकल्पों में। समूह के लिए कोई स्थान आवश्यक है संलग्न अभिलेखों का आरोहण करने के लिए।</string>
<string name="marmot_encrypted_media_enabled_toast">यह समूह अब रहस्यीकृत संलग्न अभिलेखों का उपयोग करता है</string>
<string name="marmot_failed_to_add_user">%1$s जोडने में असफल : %2$s</string>
@@ -5185,7 +5198,7 @@
<string name="upload_error_voice_message_exception">ध्वनि आरोहण असफल : %1$s</string>
<string name="upload_error_voice_message_failed">ध्वनि सन्देश आरोहण असफल</string>
<string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</string>
<string name="uptime">%1$d% समय निरन्तर उपलब्ध</string>
<string name="uptime">%1$d प्रतिशत समय निरन्तर उपलब्ध</string>
<string name="use_internal_tor">सक्रिय टोर उपकरण</string>
<string name="use_internal_tor_explainer">आन्तरीय संस्करण का प्रयोग करें अथवा ओर्बोट</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
@@ -5539,7 +5552,7 @@
<string name="browser_pill_info_https">संयोजन रहस्यीकृत</string>
<string name="browser_pill_info_https_desc">जानकारी जो आप भेजते हैं मार्गमध्य पठन असाध्य।</string>
<string name="browser_pill_info_http">संयोजन अरहस्यीकृत</string>
<string name="browser_pill_info_http_desc">पारणशब्द अथवा भुगतान विवरणों का प्रविष्ट ना करें।</string>
<string name="browser_pill_info_http_desc">पारणशब्द अथवा भुगतान विवरणों को प्रविष्ट ना करें।</string>
<string name="browser_pill_info_tor">टोर पर बहुरहस्यावृत संचार द्वारा</string>
<string name="browser_pill_info_open">खुला जाल</string>
<string name="browser_pill_info_certificate">प्रमाणपत्र</string>
@@ -5882,7 +5895,7 @@
<string name="cordn_action_unpin">टाँका हटाएँ</string>
<string name="cordn_admin_action_failed">वह परिवर्तन असफल।</string>
<string name="cordn_admin_coordinator_silent">समायोजक ने उत्तर नहीं दिया। इसलिए कुछ भी परिवर्तित नहीं। कुछ क्षण पश्चात पुनःप्रयास करें।</string>
<string name="cordn_admin_no_key_package">%1$s ने कुंचिका पेटली प्रकाशित नहीं की अब तक इस समायोजक के लिए। इसलिए वे जोडे नहीं जा सकते अब तक। उनसे पूछें पहले कोर्डन को खोलने के लिए इस समायोजक पर।</string>
<string name="cordn_admin_no_key_package">%1$s ने कुंचिका पोटली प्रकाशित नहीं की अब तक इस समायोजक के लिए। इसलिए वे जोडे नहीं जा सकते अब तक। उनसे पूछें पहले कोर्डन को खोलने के लिए इस समायोजक पर।</string>
<string name="cordn_admin_no_self_remove">आप अपने को हटा नहीं सकते एक कोर्डन समूह से।</string>
<string name="cordn_admin_no_welcome">%1$s को समूह में आने की विधि नहीं दे सके। इसलिए जोडने का प्रयास परित्यक्त।</string>
<string name="cordn_admin_not_a_member">%1$s इस समुह में नहीं है।</string>
@@ -5897,7 +5910,7 @@
<string name="cordn_backup_passphrase">पारणवाक्याम्श</string>
<string name="cordn_backup_restore">पुनःस्थापन</string>
<string name="cordn_backup_restore_body">पुनःस्थापन से इस यन्त्र पर कोर्डन समूह प्रतिस्थापित किए जाएँगे अभिलेखित समूहों के साथ। यह एक संचारयन्त्र पर एक अन्य से विस्थापित करने के लिए है। समूहों को दो यन्त्रों पर एक साथ उपयोग करने की विधि नहीं।</string>
<string name="cordn_backup_restore_confirm_body">सभी कोर्डन समूह जो इस यन्त्र पर हैं हटाए जाएँगे तथा अभिलेख में उपलब्ध से प्रतिस्थापित होंगे।\n\nदूसरे संचारयन्त्र में पुनःस्थापन ना करें जब पहला का भी उपयोग हो रहा है। दोनों में समान समूह कुंचिकाएँ होंगी। तथा जब दोनों एक सन्देश भेजेंगे तब समूह सब के लिए टूट जाएगा। मौनतः तथा पूर्ववत करने की विधि के बिना।</string>
<string name="cordn_backup_restore_confirm_body">सभी कोर्डन समूह जो इस यन्त्र पर हैं हटाए जाएँगे तथा अभिलेख में उपलब्ध से प्रतिस्थापित होंगे।\n\nदूसरे संचारयन्त्र में पुनःस्थापन ना करें जब पहला का भी उपयोग हो रहा है। दोनों में समान समूह कुंचिकाएँ होंगी। तथा जब दोनों एक सन्देश भेजेंगे तब समूह सब के लिए टूट जाएगा। मौनतः। तथा पूर्ववत करने की विधि के बिना।</string>
<string name="cordn_backup_restore_confirm_title">क्या इस यन्त्र के कोर्डन समूहों का प्रतिस्थापन करें।</string>
<string name="cordn_backup_restore_title">पुनःस्थापन</string>
<string name="cordn_backup_restored">पुनःस्थापित।</string>
@@ -5936,7 +5949,7 @@
<string name="cordn_coordinators_more">अधिक क्रियाएँ</string>
<string name="cordn_coordinators_none">कोई समायोजक नहीं अब तक।</string>
<string name="cordn_coordinators_purge">मिटाएँ</string>
<string name="cordn_coordinators_purge_body">हटाने से समूह इस यन्त्र पर रहते हैं। इसलिए समायोजक को पुनःजोडने से समूह भी लौट आते हैं। मिटानेसे समूह मिटते हैं। समूह कुंचिकाएँ तथा पठनस्थान तथा आपके सभी कुंचिका पोटलियाँ सभी चले जाते हैं। तथा नए आमन्त्रण से ही उन समूहों का पुनःप्रवेश कर सकते हैं।\n\nसमायोजक को बताया नहीं जाता। तथा जो उसके पास पहले से था वह उसे रखता है।</string>
<string name="cordn_coordinators_purge_body">हटाने से समूह इस यन्त्र पर रहते हैं। इसलिए समायोजक को पुनःजोडने से समूह भी लौट आते हैं। मिटानेसे समूह मिटते हैं। समूह कुंचिकाएँ तथा पठनस्थान तथा आपके सभी कुंचिका पोटलियाँ सभी चले जाते हैं। तथा नए आमन्त्रण से ही उन समूहों में पुनःप्रवेश कर सकते हैं।\n\nसमायोजक को बताया नहीं जाता। तथा जो उसके पास पहले से था वह उसे रखता है।</string>
<string name="cordn_coordinators_purge_title">क्या इस समायोजक को मिटा दें।</string>
<string name="cordn_coordinators_relays">%1$s</string>
<string name="cordn_coordinators_relays_label">उत्तर सक्रिय</string>
@@ -5997,7 +6010,7 @@
<string name="cordn_create_people_done">हो गया</string>
<string name="cordn_create_people_empty">कोई भी जोडे नहीं गए अब तक। समूह रिक्त रूप में बनाया जा सकता है तथा आमन्त्रण तत्पश्चात भेजे जा सकते हैं। पर व्यक्तियों को अब नामित करने से पता चलेगा किस समायोजक उन तक अभिगम्य।</string>
<string name="cordn_create_people_none">कोई नहीं अब तक। दबाएँ लोगों को जोडने के लिए</string>
<string name="cordn_create_people_note">लोगों को नामित अब करने से ही पता चलेगा किस समायोजक उन तक अभिगम्य। प्रत्येक को एक स्वागत प्राप्त होता है जो उस समायोजक पर छोडा जाता है। जिसे वे उठाते हैं अगली बार वे अपने क्रमक खोलते हैं।</string>
<string name="cordn_create_people_note">लोगों को नामित अब करने से ही पता चलेगा किस समायोजक उन तक अभिगम्य। प्रत्येक को एक स्वागत प्राप्त होता है जो उस समायोजक पर छोडा जाता है। जिसे वे उठाते हैं अगली बार जब वे अपने क्रमक खोलते हैं।</string>
<plurals name="cordn_create_reach_count">
<item quantity="one">अभिगम्य %1$d समायोजक पर</item>
<item quantity="other">अभिगम्य %1$d समायोजकों पर</item>
@@ -6075,7 +6088,7 @@
<string name="cordn_keypackages_disclosure">प्रकाशन से एक अभिलेख हस्ताक्षरित होता है आपकी अपनी लेखा कुंचिका के साथ। तब समायोजक जान जाता है कि यह लेखा है तथा आमन्त्रणशक्य है। स्थायी रूप से। तथा आप को किसी ने आमन्त्रित किया अथवा नहीं इस पर आश्रित नहीं।</string>
<string name="cordn_keypackages_explainer">अन्य लोग आपको एक कोर्डन समूह में जोड सकते हैं केवल आपके द्वारा प्रकाशित कुंचिका पोटली को समायोजक तक ले जाने से। कोर्डन में कोई अन्य स्थल नहीं एक को रखने के लिए। इसलिए प्रकाशित नहीं तो कोई भी आपको आमन्त्रण नहीं दे सकते। तथा कुछ भी उनको बताता नहीं कि यह क्यों है।</string>
<string name="cordn_keypackages_failed">कुंचिका पोटलियाँ पढने में असफल।</string>
<string name="cordn_keypackages_last_resort_no">कोई अन्तिम उपाय पोटली नहीं। एकप्रयोग वाले जब समाप्त हो जाते हैं तब आमन्त्रण असफल हो जाते हैं।</string>
<string name="cordn_keypackages_last_resort_no">कोई अन्तिम उपाय पोटली नहीं। एकप्रयोग वाले जब समाप्त हो जाते हैं तब से आमन्त्रण असफल हो जाते हैं।</string>
<string name="cordn_keypackages_last_resort_yes">एक अन्तिम उपाय पोटली प्रकाशित है। इसलिए एक आमन्त्रण तब भी बनाया जा सकता है सभी एकप्रयोग वाले समाप्त होने के पश्चात।</string>
<plurals name="cordn_keypackages_orphans">
<item quantity="one">%1$d पोटली जिसे यह यन्त्र नहीं खोल सकता</item>
@@ -661,7 +661,10 @@
<string name="account_backup_encrypted_title">Jelszóval védett másolat</string>
<string name="account_backup_encrypted_password">Jelszó</string>
<string name="account_backup_encrypted_body">Egy ncryptsec1…, amely csak az Ön jelszavával működik.</string>
<string name="account_backup_password_min_length">Legalább %1$d karakter</string>
<plurals name="account_backup_password_min_length">
<item quantity="one">Legalább %1$d karakter</item>
<item quantity="other">Legalább %1$d karakter</item>
</plurals>
<string name="account_backup_encrypted_repeat_password">Jelszó ismét</string>
<string name="account_backup_encrypted_password_mismatch">A jelszavak nem egyeznek</string>
<string name="account_backup_encrypt">Kulcs titkosítása</string>
@@ -704,6 +707,8 @@
<string name="quick_action_request_deletion_gallery_alert_body_v2">Távolítsa el ezt a médiát a galériából.</string>
<string name="quick_action_request_deletion_alert_title">Törlés kérése</string>
<string name="quick_action_request_deletion_alert_body">Az Amethyst kérni fogja, hogy a bejegyzését töröljék azokról az átjátszókról, amelyekhez jelenleg csatlakozik. Nem garantálható, hogy a bejegyzése véglegesen törlődik ezekről az átjátszókról, vagy más átjátszókról, ahol esetleg tárolva van.</string>
<string name="quick_action_request_deletion_alert_body_group">Az Amethyst arra fogja kérni a titkosított csoport minden tagját, hogy töröljék ezt az üzenetet. Az alkalmazásoknak el kellene távolítaniuk azt, de nincs rá garancia, hogy minden tag törli a saját példányát.</string>
<string name="quick_action_request_deletion_alert_body_private">Az Amethyst titkosított törlési kérelmet küld a beszélgetés résztvevőinek. Az alkalmazásaiknak el kellene távolítaniuk az üzenetet, de nincs garancia arra, hogy minden példány törlődik.</string>
<string name="quick_action_block_dialog_btn">Letiltás</string>
<string name="quick_action_delete_dialog_btn">Törlés</string>
<string name="quick_action_block">Letiltás</string>
@@ -1846,6 +1851,9 @@
<string name="tor_connection_failed_body">Az Amethystnek nem sikerült befejeznie a Tor előkészítését. Legyen normál (nem Tor) kapcsolat használva a hírfolyam betöltéséhez? A választás egy óráig érvényes, utána a rendszer ismételten megkísérli a Tor hálózathoz való kapcsolódást.</string>
<string name="tor_continue_without_for_session">Normál kapcsolat használata</string>
<string name="tor_keep_waiting">Tovább várok</string>
<string name="tor_splash_connecting">Kapcsolódás a TOR-hálózathoz…</string>
<string name="tor_splash_error">TOR-hálózati hiba: %1$s</string>
<string name="tor_splash_explainer">Az Amethyst a TOR-hálózatot használja, hogy elrejtse az IP-címet az átjátszók elől. A Tor elindítása több percig is eltarthat, és internetkapcsolatra van szükség hozzá.</string>
<string name="notification_settings_categories_explainer">Koppintson egy kategóriára, hogy megnyissa az ahhoz tartozó Android értesítési beállításokat – itt találhatók a hang-, a fontossági-, az értesítési jelölők- és a „Ne zavarjanak” beállítások.</string>
<string name="notification_channel_status_on">Be</string>
<string name="notification_channel_status_silent">Néma</string>
@@ -4124,6 +4132,11 @@
<string name="marmot_admin_granted">Adminisztrátori jogosultságok megadva</string>
<string name="marmot_admin_revoked">Adminisztrátori jogosultságok visszavonva</string>
<string name="marmot_change_photo">Fotó módosítása</string>
<string name="marmot_out_of_sync_body">A többi tag üzenetei ezen az eszközön már nem olvashatók. Állítsa vissza a csoportot, majd kérje meg az egyik rendszergazdát, hogy távolítsa el Önt a csoportból, majd adja hozzá újra.</string>
<string name="marmot_out_of_sync_reset">Visszaállítás</string>
<string name="marmot_out_of_sync_confirm_title">Visszaállítja a csoportot?</string>
<string name="marmot_out_of_sync_confirm_body">Ez az eszköz elveszíti a tagságát, amíg egy adminisztrátor hozzá nem adja újra. A már meglévő üzenetek itt maradnak.</string>
<string name="marmot_awaiting_reinvite">Várakozás a visszavételre. Kérjen meg egy adminisztrátort, hogy távolítsa el Önt, majd adja hozzá újra.</string>
<string name="marmot_enable_encrypted_media_needs_server">Először adjon hozzá egy Blossom médiakiszolgálót a beállításokban, mert a csoportnak szüksége van egy helyre, ahová a mellékleteket feltöltheti.</string>
<string name="marmot_encrypted_media_enabled_toast">Ez a csoport mostantól titkosított mellékleteket használ</string>
<string name="marmot_failed_to_add_user">Nem sikerült hozzáadni a következőt: %1$s: %2$s</string>
@@ -6123,7 +6136,7 @@
<string name="cordn_requests_accept">Hozzáadás</string>
<string name="cordn_requests_admin_only">Csak az adminisztrátor fogadhat el. Egy adminisztrátorok nélküli csoportban ez mindenki.</string>
<string name="cordn_requests_check">Kérelmek ellenőrzése</string>
<string name="cordn_requests_decline">Elvetés</string>
<string name="cordn_requests_decline">Eltüntetés</string>
<string name="cordn_requests_failed">Nem sikerült beolvasni a kérelmeket.</string>
<string name="cordn_requests_none">Senki sem vár a csatlakozásra.</string>
<string name="cordn_requests_title">Csatlakozási kérelmek</string>
@@ -593,7 +593,7 @@
<string name="concord_invite_revoke_explainer">Każdy nadal posiadający ten link nie będzie mógł już dołączyć. Osoby, które już się do niej dołączyły, pozostają w społeczności. Tego nie można cofnąć.</string>
<string name="concord_invite_revoke_confirm">Unieważnij</string>
<string name="concord_invite_preview_unknown_name">Nazwa społeczności zostanie ujawniona dopiero po dołączeniu</string>
<string name="concord_invite_preview_explainer">Dołączenie do transmiterów tego zaproszenia, publikuje ogłoszenie o dołączeniu podpisane przez twoje konto i dodaje społeczność do listy. Nic nie zostanie wysłane, dopóki nie klikniesz Dołącz.</string>
<string name="concord_invite_preview_explainer">Dołączenie do transmiterów tego zaproszenia, publikuje komunikaty o dołączeniu podpisane przez twoje konto i dodaje społeczność do listy. Nic nie zostanie wysłane, dopóki nie klikniesz Dołącz.</string>
<string name="concord_invite_preview_relays">Transmiter tego zaproszenia skontaktuje się: %1$s</string>
<string name="concord_home_empty">Nie dołączyłeś(aś) jeszcze do kanału Concord. Utwórz kanał lub otwórz link z zaproszeniem.</string>
<string name="concord_show_all_channels">Pokaż wszystkie kanały</string>
@@ -677,7 +677,12 @@
<string name="account_backup_encrypted_title">Kopia chroniona hasłem</string>
<string name="account_backup_encrypted_password">Hasło</string>
<string name="account_backup_encrypted_body">Ncryptsec1… który działa tylko z twoim hasłem.</string>
<string name="account_backup_password_min_length">Co najmniej %1$d znaków</string>
<plurals name="account_backup_password_min_length">
<item quantity="one">Co najmniej %1$d znak</item>
<item quantity="few">Co najmniej %1$d znaki</item>
<item quantity="many">Co najmniej %1$d znaków</item>
<item quantity="other">Co najmniej %1$d znaku</item>
</plurals>
<string name="account_backup_encrypted_repeat_password">Powtórz hasło</string>
<string name="account_backup_encrypted_password_mismatch">Hasła nie są identyczne</string>
<string name="account_backup_encrypt">Zaszyfruj klucz</string>
@@ -720,6 +725,8 @@
<string name="quick_action_request_deletion_gallery_alert_body_v2">Usuń ten plik z Galerii.</string>
<string name="quick_action_request_deletion_alert_title">Poproś o usunięcie</string>
<string name="quick_action_request_deletion_alert_body">Amethyst poprosi o usunięcie Twojego wpisu z aktualnie podłączonych transmiterów. Nie ma gwarancji, że Twój wpis zostanie trwale usunięty z tych lub z innych transmiterów, gdzie może być przechowywany.</string>
<string name="quick_action_request_deletion_alert_body_group">Amethyst poprosi wszystkich w tej zaszyfrowanej grupie o usunięcie tej wiadomości. Ich aplikacje powinny ją usunąć, ale nie ma gwarancji, że każdy członek usunie swoją kopię.</string>
<string name="quick_action_request_deletion_alert_body_private">Ametyst wyśle zaszyfrowaną prośbę o usunięcie do osób w tej rozmowie. Ich aplikacje powinny usunąć wiadomość, ale nie ma gwarancji, że każda kopia zostanie usunięta.</string>
<string name="quick_action_block_dialog_btn">Zablokuj</string>
<string name="quick_action_delete_dialog_btn">Usuń</string>
<string name="quick_action_block">Zablokuj</string>
@@ -1754,6 +1761,14 @@
<string name="relay_group_field_name">Nazwa</string>
<string name="relay_group_field_about">Opis</string>
<string name="relay_group_field_picture">Zdjęcie grupy</string>
<string name="relay_group_field_banner">Adres URL banera</string>
<string name="relay_group_migration_moved_title">Ta grupa mogła się przenieść</string>
<string name="relay_group_migration_fork_title">Ta grupa może mieć fork</string>
<string name="relay_group_migration_by_admins">Administratorzy wyświetlają teraz listę na %1$s</string>
<string name="relay_group_migration_by_friends">Osoby, których obserwujesz teraz na liście %1$s</string>
<string name="relay_group_migration_open">Otwórz tutaj</string>
<string name="relay_group_migration_move">Przenieś</string>
<string name="relay_group_migration_dismiss">Odrzuć</string>
<string name="relay_group_add_photo">Dodaj zdjęcie</string>
<string name="relay_group_change_photo">Zmień zdjęcie</string>
<string name="relay_group_section_discovery">Odkryj</string>
@@ -1858,6 +1873,9 @@
<string name="tor_connection_failed_body">Amethyst nie zdołał uruchomić sieci Tor. Czy można skorzystać ze zwykłego połączenia (nie przez Tor), aby strona mogła się załadować? Zapamiętamy ten wybór na najbliższą godzinę, a potem spróbujemy ponownie połączyć się przez Tor.</string>
<string name="tor_continue_without_for_session">Użyj zwykłego połączenia</string>
<string name="tor_keep_waiting">Czekaj</string>
<string name="tor_splash_connecting">Łączenie z Tor…</string>
<string name="tor_splash_error">Błąd Tor: %1$s</string>
<string name="tor_splash_explainer">Amethyst używa Tor, aby ukryć Twój adres IP przed transmiterami. Uruchamianie Tor może potrwać minutę i wymaga dostępu do Internetu.</string>
<string name="notification_settings_categories_explainer">Kliknij kategorię, aby otworzyć jej ustawienia powiadomień w systemie Android — znajdziesz tam opcje dotyczące dźwięku, priorytetu, ikon powiadomień oraz trybu „Nie przeszkadzać”.</string>
<string name="notification_channel_status_on">Wł.</string>
<string name="notification_channel_status_silent">Wyciszony</string>
@@ -2424,7 +2442,7 @@
<string name="git_repo_tab_code">Kod</string>
<string name="git_repo_code_loading">Ładowanie repozytorium…</string>
<string name="git_repo_code_error">Nie udało się załadować repozytorium z adresu URL klonu.</string>
<string name="git_repo_no_clone_url">W ogłoszeniu repozytorium nie podano adresu URL do klonowania za pomocą protokołu http(s), który umożliwia przeglądanie zawartości.</string>
<string name="git_repo_no_clone_url">W komunikacie repozytorium nie podano adresu URL do klonowania za pomocą protokołu http(s), który umożliwia przeglądanie zawartości.</string>
<string name="git_repo_readme_missing">To repozytorium nie ma pliku README.</string>
<string name="git_repo_file_load_error">Nie można załadować tego pliku.</string>
<string name="git_repo_binary_file">Plik binarny (%1$s) — podgląd niedostępny.</string>
@@ -2490,6 +2508,8 @@
<string name="select_list_to_filter">Wybierz opcję, aby filtrować aktualności</string>
<string name="add_dvm_to_favorites">Dodaj algorytm kanału do ulubionych</string>
<string name="remove_dvm_from_favorites">Usuń z ulubionych</string>
<string name="add_follow_set_to_favorites">Dodaj zestaw obserwowanych do ulubionych kanałów</string>
<string name="remove_follow_set_from_favorites">Usuń zestaw obserwowanych z ulubionych kanałów</string>
<string name="favorite_dvms_explainer">Algorytmy kanałów, które tutaj oznaczyłeś gwiazdką, pojawiają się jako ikony filtrów na stronie głównej. Otwórz sekcję Odkryj, aby dodać kolejne.</string>
<string name="favorite_dvms_empty_headline">Przypnij swoje ulubione algorytmy</string>
<string name="favorite_dvms_empty_step1">Kliknij "%1$s" poniżej, aby przeglądać algorytmy.</string>
@@ -2713,10 +2733,12 @@
<string name="cast_local_network_permission_title">Wymagane uprawnienia</string>
<string name="cast_local_network_permission_message">Aplikacja Amethyst potrzebuje dostępu do sieci lokalnej, aby wykryć urządzenia obsługujące funkcję Cast w Twojej sieci Wi-Fi. Włącz tę opcję w ustawieniach aplikacji.</string>
<string name="cast_local_network_permission_open_settings">Otwórz ustawienia</string>
<string name="cast_generic_receiver">Urządzenie castujące</string>
<string name="cast_error_unsupported_media">%1$s nie może odtworzyć tego formatu wideo</string>
<string name="cast_error_playback_failed">%1$s nie mógł odtworzyć tego filmu</string>
<string name="cast_error_receiver_not_responding">%1$s przestał odpowiadać. Ponowne uruchomienie urządzenia zwykle naprawia.</string>
<string name="cast_error_connect_failed">Nie można połączyć się z %1$s. Ponowne uruchomienie urządzenia zwykle je naprawia.</string>
<string name="cast_error_device_offline">%1$s jest offline</string>
<string name="cast_error_load_failed">Nie można załadować tego filmu na %1$s</string>
<string name="cast_error_play_services_unavailable">Udostępnianie wymaga usług Google Play, które nie są dostępne na tym urządzeniu</string>
<string name="cast_error_unsupported_media_detail">%1$s nie może odtworzyć formatu tego wideo (%2$s)</string>
@@ -2833,8 +2855,13 @@
<string name="relay_management_no_moderation_events">Brak wydarzeń wymagających moderacji</string>
<string name="relay_management_banned_events">Zbanowane wydarzenia</string>
<string name="relay_management_no_banned_events">Brak zbanowanych wydarzeń</string>
<string name="relay_management_allowed_events">Dozwolone wydarzenia</string>
<string name="relay_management_no_allowed_events">Brak dozwolonych wydarzeń</string>
<string name="relay_management_allow_event">Zezwól na wydarzenie</string>
<string name="relay_management_allowed_kinds">Dozwolone typy</string>
<string name="relay_management_no_allowed_kinds">Brak dozwolonych typów</string>
<string name="relay_management_disallowed_kinds">Niedozwolone typy</string>
<string name="relay_management_no_disallowed_kinds">Brak niedozwolonych typów</string>
<string name="relay_management_blocked_ips">Zablokowane adresy IP</string>
<string name="relay_management_no_blocked_ips">Brak zablokowanych adresów IP</string>
<string name="relay_management_add">Dodaj</string>
@@ -2867,6 +2894,9 @@
<string name="relay_members_join_sent">Wysłano prośbę o dołączenie</string>
<string name="relay_members_leave_sent">Wysłano prośbę o opuszczenie</string>
<string name="relay_members_you_are_member">Jesteś członkiem</string>
<string name="relay_members_invite_code">Kod zaproszenia</string>
<string name="relay_members_invite_code_hint">Ten transmiter przyjmuje członków posiadających kod zaproszenia od jego operatora</string>
<string name="relay_members_unverifiable">Ten transmiter nie publikuje swojego klucza tożsamości (NIP-11 self), więc nie można zweryfikować listy jego członków</string>
<string name="relay_membership_list">Lista użytkowników transmitera</string>
<string name="relay_member_added">Użytkownik dodany do transmitera</string>
<string name="relay_members_added">%1$d użytkowników dodanych do transmitera</string>
@@ -4202,6 +4232,11 @@
<string name="marmot_admin_granted">Przyznane uprawnienia administracyjne</string>
<string name="marmot_admin_revoked">Uprawnienia administratora cofnięte</string>
<string name="marmot_change_photo">Zmień zdjęcie</string>
<string name="marmot_out_of_sync_body">Wiadomości od innych użytkowników nie mogą być już odczytywane na tym urządzeniu. Zresetuj swoją kopię grupy, a następnie poproś administratora o usunięcie cię i dodaj cię ponownie.</string>
<string name="marmot_out_of_sync_reset">Reset</string>
<string name="marmot_out_of_sync_confirm_title">Zresetować tę grupę?</string>
<string name="marmot_out_of_sync_confirm_body">To urządzenie przestaje być członkiem grupy, dopóki administrator nie doda cię ponownie. Twoje dotychczasowe wiadomości pozostaną tutaj.</string>
<string name="marmot_awaiting_reinvite">Oczekiwanie na ponowne dodanie. Poproś administratora o usunięcie i dodanie Cię ponownie.</string>
<string name="marmot_enable_encrypted_media_needs_server">Dodaj w ustawieniach serwer multimediów „Blossom” — grupa potrzebuje miejsca, do którego będzie można przesyłać załączniki.</string>
<string name="marmot_encrypted_media_enabled_toast">Ta grupa używa zaszyfrowanych załączników</string>
<string name="marmot_failed_to_add_user">Nie udało się dodać %1$s: %2$s</string>
@@ -4742,6 +4777,7 @@
<string name="podcast_trailer_upload_hint">Krótki fragment audio lub wideo</string>
<string name="podcast_value_error_title">Błąd typu „wartość za wartość”</string>
<string name="podcast_value_keysend_requires_nwc">Podłącz portfel Nostr Wallet Connect, aby wysyłać wiadomości do odbiorców korzystających z usługi keysend (node).</string>
<string name="podcast_value_keysend_not_supported">Twój portfel nie obsługuje klucza, więc odbiorcy węzłów zostali pominięci.</string>
<string name="podcast_value_no_recipients">Ten podcast nie ma odbiorców, płacących za niego.</string>
<string name="podcast_value_stream_requires_wallet">Podłącz portfel Nostr Wallet Connect lub portfel debetowy, aby przesyłać satosze podczas słuchania.</string>
<string name="podcast_value_user_no_lnaddress">Ten użytkownik nie posiada adresu Lightning</string>
@@ -5441,6 +5477,7 @@
</plurals>
<string name="wallet_transactions_load_failed">Nie udało się wczytać transakcji</string>
<string name="wallet_transactions_load_more_failed">Nie udało się wczytać kolejnych transakcji</string>
<string name="wallet_transactions_not_supported">Twój portfel nie oferuje historii transakcji</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Wyświetla komunikat ostrzegawczy, gdy posty zostały zgłoszone przez 5 lub więcej obserwowanych osób</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Ostrzeżenie o zgłoszeniach</string>
<string name="web_bookmark_add_title">Dodaj zakładkę</string>
@@ -6192,4 +6229,132 @@
<string name="cordn_coordinators_copy_key">Skopiuj npub koordynatora</string>
<string name="cordn_coordinators_discover_action">Szukaj koordynatorów</string>
<string name="cordn_coordinators_discover_add">Dodaj</string>
<string name="cordn_coordinators_discover_explainer">Odczytuje komunikaty twoich transmiterów. Nie nawiązuje się połączenia z żadnym koordynatorem, więc żaden z nich nie dowiaduje się, że je przeglądałeś.</string>
<string name="cordn_coordinators_discover_failed">Nie można odczytać komunikatów</string>
<string name="cordn_coordinators_discover_none">Nikt nie nadaje na twoich transmiterach.</string>
<string name="cordn_coordinators_discover_seen">Ogłoszono %1$s temu</string>
<string name="cordn_coordinators_discover_seen_on">Ostatnio ogłoszone %1$s</string>
<plurals name="cordn_coordinators_discover_unheard">
<item quantity="one">Nie znaleziono żadnych wyników, a %1$d z twoich transmiterów nie odpowiedział.</item>
<item quantity="few">Nie znaleziono żadnych wyników, a %1$d z twoich transmiterów nie odpowiedziało.</item>
<item quantity="many">Nie znaleziono żadnych wyników, a %1$d z twoich transmiterów nie odpowiedziało.</item>
<item quantity="other">Nie znaleziono żadnych wyników, a %1$d z twoich transmiterów nie odpowiedziały.</item>
</plurals>
<string name="cordn_coordinators_explainer">Koordynator porządkuje komunikaty jednej grupy i jest jedyną instancją, która może to robić. Utrata koordynatora powoduje utratę porządku, a dodanie drugiego nie powoduje odzwierciedlenia stanu pierwszego — są to więc konkretne elementy, które sam wybierasz, a nie zamienne transmitery.</string>
<plurals name="cordn_coordinators_health_down">
<item quantity="one">Brak odpowiedzi — %1$d połączenie z rzędu zakończyło się niepowodzeniem. Grupy w nim zawarte się nie synchronizują.</item>
<item quantity="few">Brak odpowiedzi — %1$d kolejnych połączeń zakończyło się niepowodzeniem. Grupy w tym obszarze się nie synchronizują.</item>
<item quantity="many">Brak odpowiedzi — %1$d kolejnych połączeń zakończyło się niepowodzeniem. Grupy w tym obszarze się nie synchronizują.</item>
<item quantity="other">Brak odpowiedzi — %1$d kolejne połączenia zakończyły się niepowodzeniem. Grupy w tym obszarze się nie synchronizują.</item>
</plurals>
<string name="cordn_coordinators_health_ok">Odpowiadam.</string>
<string name="cordn_coordinators_health_retrying">Połączenie nie powiodło się. Ponawiam próbę.</string>
<string name="cordn_coordinators_health_unknown">Jeszcze nikt o to nie pytał.</string>
<string name="cordn_coordinators_hide_older">Ukryj te, które przestały ogłaszać</string>
<string name="cordn_coordinators_identify">Zapytaj, kto to jest</string>
<string name="cordn_coordinators_key">Klucz koordynatora</string>
<string name="cordn_coordinators_label">Nazwa (możesz podać własną, opcjonalnie)</string>
<string name="cordn_coordinators_label_note">Ta nazwa należy do Ciebie i pozostaje na tym urządzeniu. Koordynator nie może zweryfikować nazwy, dlatego nigdy nie jest o nią pytany.</string>
<string name="cordn_coordinators_more">Więcej czynności</string>
<string name="cordn_coordinators_none">Brak koordynatorów.</string>
<string name="cordn_coordinators_purge">Czyszczenie</string>
<string name="cordn_coordinators_purge_body">Usunięcie powoduje zachowanie grup na tym urządzeniu, więc ponowne dodanie koordynatora spowoduje ich przywrócenie. Całkowite usunięcie powoduje ich trwałą likwidację: klucze grup, pozycje odczytu oraz pakiety kluczy użytkownika zostaną usunięte, a ponowne dołączenie do tych grup będzie możliwe wyłącznie poprzez nowe zaproszenie.\n\nKoordynator nie otrzymuje o tym informacji i zachowuje wszystko, co już posiadał.</string>
<string name="cordn_coordinators_purge_title">Usunąć tego koordynatora?</string>
<string name="cordn_coordinators_relays">%1$s</string>
<string name="cordn_coordinators_relays_label">Odpowiedzi na</string>
<string name="cordn_coordinators_relays_more">%1$s +%2$d więcej</string>
<string name="cordn_coordinators_remove">Usuń</string>
<string name="cordn_coordinators_rename">Zmień nazwę</string>
<string name="cordn_coordinators_server">Podaje, że to: %1$s</string>
<string name="cordn_coordinators_server_claim">Nazwa i wersja koordynatora to ciągi znaków, które sam wybrał. Jego klucz publiczny jest jedyną rzeczą, która go identyfikuje.</string>
<string name="cordn_coordinators_server_silent">Odpowiedział, ale niczego nie nazwał.</string>
<string name="cordn_coordinators_show_all">Pokaż wszystkie %1$d</string>
<string name="cordn_coordinators_show_fewer">Pokaż mniej</string>
<string name="cordn_coordinators_stale_note">Od ponad miesiąca nie pojawiały się żadne komunikaty. Próba utworzenia grupy na platformie, która przestała działać, zakończy się niepowodzeniem.</string>
<string name="cordn_create_action">Utwórz Grupę</string>
<plurals name="cordn_create_action_invite">
<item quantity="one">Utwórz i zaproś %1$d osobę</item>
<item quantity="few">Utwórz i zaproś %1$d osób</item>
<item quantity="many">Utwórz i zaproś %1$d osób</item>
<item quantity="other">Utwórz i zaproś %1$d osoby</item>
</plurals>
<string name="cordn_create_add_member">Dodaj kogoś</string>
<string name="cordn_create_admin">Admin</string>
<plurals name="cordn_create_admin_count">
<item quantity="one">%1$d osoba może dodawać i usuwać</item>
<item quantity="few">%1$d osób może dodawać i usuwać</item>
<item quantity="many">%1$d osób może dodawać i usuwać</item>
<item quantity="other">%1$d osoby mogą dodawać i usuwać</item>
</plurals>
<string name="cordn_create_admin_only_me">Tylko ja zarządzam tą grupą</string>
<string name="cordn_create_admin_only_me_on">Tylko Ty możesz zaprosić, usunąć członków lub zmienić dane grupy. Możesz to później przekazać komuś innemu.</string>
<string name="cordn_create_coordinator">Koordynator</string>
<string name="cordn_create_coordinator_change">Zmiana</string>
<string name="cordn_create_coordinator_discover">Znajdź koordynatorów</string>
<string name="cordn_create_coordinator_hide">Gotowe</string>
<string name="cordn_create_coordinator_new">Inny koordynator</string>
<string name="cordn_create_coordinator_pubkey">Klucz publiczny koordynatora (hex lub npub)</string>
<string name="cordn_create_coordinator_relays">Transmitery, na których odpowiada – po jednym w każdym wierszu</string>
<plurals name="cordn_create_coverage_all">
<item quantity="one">Osiąga %1$d osobę</item>
<item quantity="few">Osiąga %1$d osób</item>
<item quantity="many">Osiąga %1$d osób</item>
<item quantity="other">Osiąga %1$d osoby</item>
</plurals>
<string name="cordn_create_coverage_partial">Osiąga %1$d z %2$d &#8212; reszta zostanie pominięta</string>
<string name="cordn_create_coverage_unknown">Nie zaznaczone &#8212; po zaznaczeniu pojawi się pytanie</string>
<string name="cordn_create_description">Opis (opcjonalnie)</string>
<string name="cordn_create_egalitarian">Każdy może dodać i usunąć</string>
<string name="cordn_create_egalitarian_note">Każdy członek grupy może zapraszać i usuwać członków oraz zmieniać dane grupy. Każdy członek może później wyznaczyć administratorów, co kończy tę procedurę.</string>
<string name="cordn_create_explainer">Grupa Cordn jest zarządzana przez jednego koordynatora, którego sam wybierasz. Utworzenie grupy odbywa się na tym urządzeniu — koordynator dowiaduje się o istnieniu grupy dopiero wtedy, gdy zaprosisz kogoś lub wyślesz wiadomość.</string>
<string name="cordn_create_failed">Nie można utworzyć grupy.</string>
<string name="cordn_create_name">Nazwa grupy</string>
<string name="cordn_create_no_coordinator">Nie wybrano koordynatora</string>
<string name="cordn_create_outcome_explainer">Każde zaproszenie to powitanie pozostawione koordynatorowi, dzięki czemu może on odrzucać je pojedynczo. Nikt nie otrzymał powiadomienia: użytkownicy zobaczą je dopiero przy następnym uruchomieniu aplikacji.</string>
<string name="cordn_create_outcome_failed">Koordynator odrzucił zaproszenie</string>
<string name="cordn_create_outcome_link">Wyślij link</string>
<string name="cordn_create_outcome_no_welcome">Dodano do grupy, ale zaproszenie nie dotarło do nich &#8212; spróbuj ponownie z ekranu grupy</string>
<string name="cordn_create_outcome_open">Otwórz czat</string>
<string name="cordn_create_outcome_skipped">Nie ma tu klucza, więc nie można było zostawić wiadomości powitalnej</string>
<string name="cordn_create_outcome_title">Grupa istnieje</string>
<string name="cordn_create_outcome_waiting">Oczekiwanie na nie</string>
<string name="cordn_create_people_done">Gotowe</string>
<string name="cordn_create_people_empty">Jeszcze nikt nie został dodany. Grupę można utworzyć jako pustą, a zaproszenia wysłać później, ale właśnie podanie nazwisk osób już teraz pozwala ustalić, który koordynator może się z nimi skontaktować.</string>
<string name="cordn_create_people_none">Na razie nikt &#8212; kliknij, aby dodać osoby</string>
<string name="cordn_create_people_note">Wskazanie konkretnych osób pozwala nam ustalić, który koordynator może się z nimi skontaktować. Każda z tych osób otrzymuje wiadomość powitalną od tego koordynatora, którą odbierze przy następnym uruchomieniu aplikacji.</string>
<plurals name="cordn_create_reach_count">
<item quantity="one">Osiągalny na koordynatorze %1$d</item>
<item quantity="few">Osiągalny na koordynatorach %1$d</item>
<item quantity="many">Osiągalny na koordynatorach %1$d</item>
<item quantity="other">Osiągalny na koordynatorach %1$d</item>
</plurals>
<string name="cordn_create_reach_none">Nie ma klucza dla żadnego z używanych koordynatorów</string>
<string name="cordn_create_reach_unknown">Jeszcze nie sprawdzono</string>
<string name="cordn_create_step_admins">Kto może dodać i usunąć</string>
<string name="cordn_create_step_name">Nazwij</string>
<string name="cordn_create_step_people">Kto jest w nim</string>
<string name="cordn_create_step_where">Gdzie występuje</string>
<string name="cordn_create_title">Nowa grupa Cordn</string>
<plurals name="cordn_create_unreachable_count">
<item quantity="one">Żaden z koordynatorów, z których usług korzystasz, nie jest w stanie skontaktować się z %1$d osobą</item>
<item quantity="few">%1$d osób jest poza zasięgiem wszystkich koordynatorów, z których korzystasz</item>
<item quantity="many">%1$d osób jest poza zasięgiem wszystkich koordynatorów, z których korzystasz</item>
<item quantity="other">%1$d osoby są poza zasięgiem wszystkich koordynatorów, z których korzystasz</item>
</plurals>
<string name="cordn_create_you">Ty, zawsze jako administrator</string>
<string name="cordn_delete_confirm_body">Wszyscy członkowie grupy zobaczą tę wiadomość jako wycofaną, a aplikacja Amethyst przestanie wyświetlać jej treść. Koordynator nadal posiada zaszyfrowaną kopię, w której została wysłana, a każdy, kto już ją przeczytał, nadal ma dostęp do jej treści.</string>
<string name="cordn_delete_confirm_title">Wycofaj wiadomość</string>
<string name="cordn_delivery_accepted">Zaakceptowane przez koordynatora</string>
<string name="cordn_exposure_close">Zrozumiałe</string>
<string name="cordn_exposure_open">Co ten koordynator może zobaczyć</string>
<string name="cordn_keypackages_last_resort_yes">Opublikowano pakiet na wypadek ostateczności, dzięki czemu nadal będzie można wysłać zaproszenie, gdy skończą się zaproszenia jednorazowego użytku.</string>
<plurals name="cordn_keypackages_orphans">
<item quantity="one">%1$d pakiet, którego to urządzenie nie może otworzyć</item>
<item quantity="few">%1$d pakietów, których to urządzenie nie może otworzyć</item>
<item quantity="many">%1$d pakietów, których to urządzenie nie może otworzyć</item>
<item quantity="other">%1$d pakiety, których to urządzenie nie może otworzyć</item>
</plurals>
<string name="cordn_keypackages_orphans_body">Koordynator przekaże je każdemu, kto cię zaprosi, ale część prywatna nie znajduje się na tym urządzeniu — należy ona do innej instalacji. Jeśli ta instalacja zniknie, zaproszenie wysłane przy użyciu jednego z tych plików spowoduje wygenerowanie wiadomości powitalnej, której nikt nigdy nie będzie mógł otworzyć.</string>
<string name="cordn_keypackages_publish">Opublikuj jeden</string>
<string name="cordn_keypackages_publish_last_resort">Opublikuj ostatnią instancję</string>
</resources>
@@ -653,6 +653,27 @@
<string name="mark_all_known_as_read">Marcar todos conhecidos como lidos</string>
<string name="mark_all_new_as_read">Marcar todas novas solicitações como lidas</string>
<string name="mark_all_as_read">Marcar todas como lidas</string>
<string name="account_backup_headline">Sua chave secreta é a sua conta</string>
<string name="account_backup_intro">Qualquer pessoa que a tenha pode publicar como você. Se você a perder, ninguém poderá recuperá-la, nem mesmo o Amethyst.</string>
<string name="account_backup_tap_to_reveal">Toque para revelar</string>
<string name="account_backup_write_down_hint">Anote nesta ordem. Tanto maiúsculas quanto minúsculas funcionam.</string>
<string name="account_backup_qr_code">Código QR</string>
<string name="account_backup_encrypted_title">Cópia protegida por senha</string>
<string name="account_backup_encrypted_password">Senha</string>
<string name="account_backup_encrypted_body">Um ncryptsec1… que só funciona com a sua senha.</string>
<plurals name="account_backup_password_min_length">
<item quantity="one">Pelo menos %1$d caractere</item>
<item quantity="other">Pelo menos %1$d caracteres</item>
</plurals>
<string name="account_backup_encrypted_repeat_password">Repita a senha</string>
<string name="account_backup_encrypted_password_mismatch">As senhas não coincidem</string>
<string name="account_backup_encrypt">Criptografar chave</string>
<string name="account_backup_encrypting">Criptografando…</string>
<string name="account_backup_encrypted_saved_hint">Pode ser guardado com segurança em um gerenciador de senhas ou em notas na nuvem: não pode ser aberto sem a senha, e uma senha esquecida não pode ser recuperada.</string>
<string name="account_backup_encrypt_again">Usar outra senha</string>
<string name="account_backup_tip_storage">Guarde o papel em um lugar que só você encontre, ou salve a chave em um gerenciador de senhas.</string>
<string name="account_backup_tip_trust">Nunca cole sua chave em sites ou apps em que você não confia.</string>
<string name="account_backup_tip_developers">Os desenvolvedores do Amethyst nunca vão pedir sua chave.</string>
<string name="backup_keys_nudge_title">Faça backup das suas chaves</string>
<string name="backup_keys_nudge_body">Sua chave secreta é a única forma de acessar esta conta. Se você perdê-la, ela nunca poderá ser recuperada. Guarde-a em um lugar seguro agora.</string>
<string name="backup_keys_nudge_backup_now">Fazer backup agora</string>
@@ -686,6 +707,8 @@
<string name="quick_action_request_deletion_gallery_alert_body_v2">Remover essa mídia de sua galeria.</string>
<string name="quick_action_request_deletion_alert_title">Pedir para excluir</string>
<string name="quick_action_request_deletion_alert_body">Amethyst solicitará que sua nota seja excluída dos relays aos quais você está conectado no momento. Não há garantia de que sua nota será excluída permanentemente desses relays ou de outros relays onde possa estar armazenada.</string>
<string name="quick_action_request_deletion_alert_body_group">O Amethyst vai pedir a todos neste grupo criptografado que excluam esta mensagem. Os apps deles devem removê-la, mas não há garantia de que todos os membros excluirão sua cópia.</string>
<string name="quick_action_request_deletion_alert_body_private">O Amethyst vai enviar um pedido de exclusão criptografado às pessoas desta conversa. Os apps delas devem remover a mensagem, mas não há garantia de que todas as cópias serão excluídas.</string>
<string name="quick_action_block_dialog_btn">Bloquear</string>
<string name="quick_action_delete_dialog_btn">Excluir</string>
<string name="quick_action_block">Bloquear</string>
@@ -1716,6 +1739,14 @@
<string name="relay_group_field_name">Nome</string>
<string name="relay_group_field_about">Descrição</string>
<string name="relay_group_field_picture">Imagem do grupo</string>
<string name="relay_group_field_banner">URL da imagem do banner</string>
<string name="relay_group_migration_moved_title">Este grupo pode ter se mudado</string>
<string name="relay_group_migration_fork_title">Este grupo pode ter um fork</string>
<string name="relay_group_migration_by_admins">Os admins agora o listam em %1$s</string>
<string name="relay_group_migration_by_friends">Pessoas que você segue agora o listam em %1$s</string>
<string name="relay_group_migration_open">Abrir lá</string>
<string name="relay_group_migration_move">Mover</string>
<string name="relay_group_migration_dismiss">Dispensar</string>
<string name="relay_group_add_photo">Adicionar foto</string>
<string name="relay_group_change_photo">Alterar foto</string>
<string name="relay_group_section_discovery">Descoberta</string>
@@ -1820,6 +1851,9 @@
<string name="tor_connection_failed_body">O Amethyst não conseguiu concluir a inicialização do Tor. Usar uma conexão normal (sem Tor) para que o feed possa carregar? Vamos lembrar dessa escolha pela próxima hora e tentar o Tor novamente depois.</string>
<string name="tor_continue_without_for_session">Usar conexão normal</string>
<string name="tor_keep_waiting">Continuar esperando</string>
<string name="tor_splash_connecting">Conectando ao Tor…</string>
<string name="tor_splash_error">Erro do Tor: %1$s</string>
<string name="tor_splash_explainer">O Amethyst usa o Tor para ocultar seu endereço IP dos relays. Iniciar o Tor pode levar um minuto e requer acesso à internet.</string>
<string name="notification_settings_categories_explainer">Toque em uma categoria para abrir as configurações de notificação do Android — som, importância, selos e Não Perturbe ficam lá.</string>
<string name="notification_channel_status_on">Ativado</string>
<string name="notification_channel_status_silent">Silencioso</string>
@@ -2452,6 +2486,8 @@
<string name="select_list_to_filter">Selecione uma lista para filtrar o feed</string>
<string name="add_dvm_to_favorites">Adicionar algoritmo de feed aos favoritos</string>
<string name="remove_dvm_from_favorites">Remover dos favoritos</string>
<string name="add_follow_set_to_favorites">Adicionar lista de seguidores aos feeds favoritos</string>
<string name="remove_follow_set_from_favorites">Remover lista de seguidores dos feeds favoritos</string>
<string name="favorite_dvms_explainer">Os algoritmos de feed marcados com estrela aparecem como chips de filtro no feed Início. Abra Descobrir para adicionar mais.</string>
<string name="favorite_dvms_empty_headline">Fixe seus algoritmos favoritos</string>
<string name="favorite_dvms_empty_step1">Toque em "%1$s" abaixo para navegar pelos algoritmos.</string>
@@ -2672,6 +2708,20 @@
<string name="nickname_private">Visível apenas para você</string>
<string name="cast_to_device_dialog_title">Transmitir para…</string>
<string name="cast_searching_for_devices">Procurando dispositivos na sua Wi-Fi…</string>
<string name="cast_local_network_permission_title">Permissão necessária</string>
<string name="cast_local_network_permission_message">O Amethyst precisa de acesso à rede local para encontrar dispositivos Cast na sua Wi-Fi. Ative-o nas configurações do app.</string>
<string name="cast_local_network_permission_open_settings">Abrir configurações</string>
<string name="cast_generic_receiver">O dispositivo de transmissão</string>
<string name="cast_error_unsupported_media">%1$s não consegue reproduzir o formato deste vídeo</string>
<string name="cast_error_playback_failed">%1$s não conseguiu reproduzir este vídeo</string>
<string name="cast_error_receiver_not_responding">%1$s parou de responder. Reiniciar o dispositivo geralmente resolve.</string>
<string name="cast_error_connect_failed">Não foi possível conectar a %1$s. Reiniciar o dispositivo geralmente resolve.</string>
<string name="cast_error_device_offline">%1$s ficou offline</string>
<string name="cast_error_load_failed">Não foi possível carregar este vídeo em %1$s</string>
<string name="cast_error_play_services_unavailable">A transmissão requer o Google Play Services, que não está disponível neste dispositivo</string>
<string name="cast_error_unsupported_media_detail">%1$s não consegue reproduzir o formato deste vídeo (%2$s)</string>
<string name="cast_error_playback_failed_detail">%1$s não conseguiu reproduzir este vídeo (%2$s)</string>
<string name="cast_error_receiver_not_responding_detail">%1$s nunca começou a reproduzir este vídeo (%2$s). Talvez não suporte esse formato, ou reiniciar o dispositivo pode ajudar.</string>
<string name="hls_pick_video_primary">Escolher um vídeo</string>
<string name="hls_pick_video_helper">Seu vídeo será transcodificado em múltiplas resoluções para que os espectadores tenham reprodução suave em qualquer conexão.</string>
<string name="hls_change_video">Trocar</string>
@@ -2783,8 +2833,13 @@
<string name="relay_management_no_moderation_events">Nenhum evento para moderar</string>
<string name="relay_management_banned_events">Eventos Banidos</string>
<string name="relay_management_no_banned_events">Nenhum evento banido</string>
<string name="relay_management_allowed_events">Eventos Permitidos</string>
<string name="relay_management_no_allowed_events">Nenhum evento permitido</string>
<string name="relay_management_allow_event">Permitir Evento</string>
<string name="relay_management_allowed_kinds">Tipos Permitidos</string>
<string name="relay_management_no_allowed_kinds">Nenhum tipo permitido</string>
<string name="relay_management_disallowed_kinds">Tipos Não Permitidos</string>
<string name="relay_management_no_disallowed_kinds">Nenhum tipo não permitido</string>
<string name="relay_management_blocked_ips">IPs Bloqueados</string>
<string name="relay_management_no_blocked_ips">Nenhum IP bloqueado</string>
<string name="relay_management_add">Adicionar</string>
@@ -2817,6 +2872,9 @@
<string name="relay_members_join_sent">Solicitação de entrada enviada</string>
<string name="relay_members_leave_sent">Solicitação de saída enviada</string>
<string name="relay_members_you_are_member">Você é membro</string>
<string name="relay_members_invite_code">Código de convite</string>
<string name="relay_members_invite_code_hint">Este relay admite membros com um código de convite do seu operador</string>
<string name="relay_members_unverifiable">Este relay não publica sua chave de identidade (NIP-11 self), então sua lista de membros não pode ser verificada</string>
<string name="relay_membership_list">Lista de membros do relay</string>
<string name="relay_member_added">Membro adicionado ao relay</string>
<string name="relay_members_added">%1$d membros adicionados ao relay</string>
@@ -4074,6 +4132,11 @@
<string name="marmot_admin_granted">Privilégios de administrador concedidos</string>
<string name="marmot_admin_revoked">Privilégios de administrador revogados</string>
<string name="marmot_change_photo">Alterar foto</string>
<string name="marmot_out_of_sync_body">As mensagens dos outros membros não podem mais ser lidas neste dispositivo. Redefina sua cópia do grupo e depois peça a um admin para remover você e adicioná-lo de volta.</string>
<string name="marmot_out_of_sync_reset">Redefinir</string>
<string name="marmot_out_of_sync_confirm_title">Redefinir este grupo?</string>
<string name="marmot_out_of_sync_confirm_body">Este dispositivo deixa de ser membro até que um admin adicione você de volta. As mensagens que você já tem continuam aqui.</string>
<string name="marmot_awaiting_reinvite">Aguardando ser adicionado de volta. Peça a um admin para remover você e adicioná-lo novamente.</string>
<string name="marmot_enable_encrypted_media_needs_server">Adicione primeiro um servidor de mídia Blossom nas Configurações — o grupo precisa de um lugar para onde enviar os anexos.</string>
<string name="marmot_encrypted_media_enabled_toast">Este grupo agora usa anexos criptografados</string>
<string name="marmot_failed_to_add_user">Falha ao adicionar %1$s: %2$s</string>
@@ -4556,6 +4619,7 @@
<string name="podcast_trailer_upload_hint">Prévia curta de áudio ou vídeo</string>
<string name="podcast_value_error_title">Erro de Value-for-Value</string>
<string name="podcast_value_keysend_requires_nwc">Conecte uma carteira Nostr Wallet Connect para enviar a destinatários keysend (nó).</string>
<string name="podcast_value_keysend_not_supported">Sua carteira não suporta keysend, então os destinatários keysend (nó) foram ignorados.</string>
<string name="podcast_value_no_recipients">Este podcast não tem destinatários de valor pagáveis.</string>
<string name="podcast_value_stream_requires_wallet">Conecte uma carteira Nostr Wallet Connect ou de débito para transmitir sats enquanto ouve.</string>
<string name="podcast_value_user_no_lnaddress">Este usuário não tem endereço Lightning</string>
@@ -5187,6 +5251,7 @@
</plurals>
<string name="wallet_transactions_load_failed">Não foi possível carregar as transações</string>
<string name="wallet_transactions_load_more_failed">Não foi possível carregar mais transações</string>
<string name="wallet_transactions_not_supported">Sua carteira não oferece histórico de transações</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Mostra uma mensagem de aviso quando as postagens tiverem 5 ou mais relatórios de suas seguintes</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Avisar em relatórios</string>
<string name="web_bookmark_add_title">Adicionar Marcador Web</string>
@@ -653,6 +653,27 @@
<string name="mark_all_known_as_read">Markera alla kända som lästa</string>
<string name="mark_all_new_as_read">Markera alla nya som lästa</string>
<string name="mark_all_as_read">Markera allt som läst</string>
<string name="account_backup_headline">Din hemliga nyckel är ditt konto</string>
<string name="account_backup_intro">Den som har den kan posta som du. Om du förlorar den kan ingen återställa den, inte ens Amethyst.</string>
<string name="account_backup_tap_to_reveal">Tryck för att visa</string>
<string name="account_backup_write_down_hint">Skriv ner den i den här ordningen. Stora och små bokstäver fungerar båda.</string>
<string name="account_backup_qr_code">QR-kod</string>
<string name="account_backup_encrypted_title">Lösenordsskyddad kopia</string>
<string name="account_backup_encrypted_password">Lösenord</string>
<string name="account_backup_encrypted_body">En ncryptsec1… som bara fungerar med ditt lösenord.</string>
<plurals name="account_backup_password_min_length">
<item quantity="one">Minst %1$d tecken</item>
<item quantity="other">Minst %1$d tecken</item>
</plurals>
<string name="account_backup_encrypted_repeat_password">Upprepa lösenord</string>
<string name="account_backup_encrypted_password_mismatch">Lösenorden matchar inte</string>
<string name="account_backup_encrypt">Kryptera nyckel</string>
<string name="account_backup_encrypting">Krypterar…</string>
<string name="account_backup_encrypted_saved_hint">Säker att förvara i en lösenordshanterare eller i molnanteckningar: den kan inte öppnas utan lösenordet, och ett glömt lösenord kan inte återställas.</string>
<string name="account_backup_encrypt_again">Använd ett annat lösenord</string>
<string name="account_backup_tip_storage">Förvara papperet där bara du hittar det, eller spara nyckeln i en lösenordshanterare.</string>
<string name="account_backup_tip_trust">Klistra aldrig in din nyckel på webbplatser eller i appar som du inte litar på.</string>
<string name="account_backup_tip_developers">Amethysts utvecklare kommer aldrig att be om din nyckel.</string>
<string name="backup_keys_nudge_title">Säkerhetskopiera dina nycklar</string>
<string name="backup_keys_nudge_body">Din hemliga nyckel är enda sättet att komma åt det här kontot. Om du förlorar den kan den aldrig återskapas. Spara den på ett säkert ställe nu.</string>
<string name="backup_keys_nudge_backup_now">Säkerhetskopiera nu</string>
@@ -686,6 +707,8 @@
<string name="quick_action_request_deletion_gallery_alert_body_v2">Ta bort detta media från ditt galleri.</string>
<string name="quick_action_request_deletion_alert_title">Begär radering</string>
<string name="quick_action_request_deletion_alert_body">Amethyst kommer att begära att din anteckning tas bort från de reläer du för närvarande är ansluten till. Det finns ingen garanti för att din anteckning kommer att raderas permanent från dessa reläer eller från andra reläer där den kan lagras.</string>
<string name="quick_action_request_deletion_alert_body_group">Amethyst ber alla i den här krypterade gruppen att radera meddelandet. Deras appar bör ta bort det, men det finns ingen garanti för att varje medlem raderar sin kopia.</string>
<string name="quick_action_request_deletion_alert_body_private">Amethyst skickar en krypterad raderingsbegäran till personerna i den här konversationen. Deras appar bör ta bort meddelandet, men det finns ingen garanti för att varje kopia raderas.</string>
<string name="quick_action_block_dialog_btn">Blockera</string>
<string name="quick_action_delete_dialog_btn">Radera</string>
<string name="quick_action_block">Blockera</string>
@@ -1716,6 +1739,14 @@
<string name="relay_group_field_name">Namn</string>
<string name="relay_group_field_about">Beskrivning</string>
<string name="relay_group_field_picture">Gruppbild</string>
<string name="relay_group_field_banner">URL för bannerbild</string>
<string name="relay_group_migration_moved_title">Den här gruppen kan ha flyttat</string>
<string name="relay_group_migration_fork_title">Den här gruppen kan ha en fork</string>
<string name="relay_group_migration_by_admins">Dess admins listar den nu på %1$s</string>
<string name="relay_group_migration_by_friends">Personer du följer listar den nu på %1$s</string>
<string name="relay_group_migration_open">Öppna där</string>
<string name="relay_group_migration_move">Flytta</string>
<string name="relay_group_migration_dismiss">Avfärda</string>
<string name="relay_group_add_photo">Lägg till foto</string>
<string name="relay_group_change_photo">Ändra foto</string>
<string name="relay_group_section_discovery">Upptäckt</string>
@@ -1820,6 +1851,9 @@
<string name="tor_connection_failed_body">Amethyst kunde inte slutföra uppstart av Tor. Vill du använda en vanlig (icke-Tor) anslutning så att flödet kan laddas? Vi kommer ihåg ditt val under nästa timme och försöker sedan Tor igen.</string>
<string name="tor_continue_without_for_session">Använd vanlig anslutning</string>
<string name="tor_keep_waiting">Fortsätt vänta</string>
<string name="tor_splash_connecting">Ansluter till Tor…</string>
<string name="tor_splash_error">Tor-fel: %1$s</string>
<string name="tor_splash_explainer">Amethyst använder Tor för att dölja din IP-adress för reläer. Att starta Tor kan ta en minut och kräver internetåtkomst.</string>
<string name="notification_settings_categories_explainer">Tryck på en kategori för att öppna Androids aviseringsinställningar — ljud, prioritet, märken och Stör ej finns där.</string>
<string name="notification_channel_status_on">På</string>
<string name="notification_channel_status_silent">Tyst</string>
@@ -2452,6 +2486,8 @@
<string name="select_list_to_filter">Välj en lista för att filtrera flödet</string>
<string name="add_dvm_to_favorites">Lägg till flödesalgoritm i favoriter</string>
<string name="remove_dvm_from_favorites">Ta bort från favoriter</string>
<string name="add_follow_set_to_favorites">Lägg till följelista i favoritflöden</string>
<string name="remove_follow_set_from_favorites">Ta bort följelista från favoritflöden</string>
<string name="favorite_dvms_explainer">Flödesalgoritmer du stjärnmarkerar visas som filterchips på Hem-flödet. Öppna Upptäck för att lägga till fler.</string>
<string name="favorite_dvms_empty_headline">Fäst dina favoritalgoritmer</string>
<string name="favorite_dvms_empty_step1">Tryck på "%1$s" nedan för att bläddra bland algoritmer.</string>
@@ -2672,6 +2708,20 @@
<string name="nickname_private">Endast synligt för dig</string>
<string name="cast_to_device_dialog_title">Casta till…</string>
<string name="cast_searching_for_devices">Söker efter enheter på ditt Wi-Fi…</string>
<string name="cast_local_network_permission_title">Behörighet krävs</string>
<string name="cast_local_network_permission_message">Amethyst behöver åtkomst till det lokala nätverket för att hitta Cast-enheter på ditt Wi-Fi. Aktivera det i appinställningarna.</string>
<string name="cast_local_network_permission_open_settings">Öppna inställningar</string>
<string name="cast_generic_receiver">Cast-enheten</string>
<string name="cast_error_unsupported_media">%1$s kan inte spela upp den här videons format</string>
<string name="cast_error_playback_failed">%1$s kunde inte spela upp den här videon</string>
<string name="cast_error_receiver_not_responding">%1$s slutade svara. En omstart av enheten brukar lösa det.</string>
<string name="cast_error_connect_failed">Kunde inte ansluta till %1$s. En omstart av enheten brukar lösa det.</string>
<string name="cast_error_device_offline">%1$s gick offline</string>
<string name="cast_error_load_failed">Kunde inte läsa in den här videon på %1$s</string>
<string name="cast_error_play_services_unavailable">Casting kräver Google Play-tjänster, som inte är tillgängliga på den här enheten</string>
<string name="cast_error_unsupported_media_detail">%1$s kan inte spela upp den här videons format (%2$s)</string>
<string name="cast_error_playback_failed_detail">%1$s kunde inte spela upp den här videon (%2$s)</string>
<string name="cast_error_receiver_not_responding_detail">%1$s började aldrig spela upp den här videon (%2$s). Formatet kanske inte stöds, eller så kan en omstart av enheten hjälpa.</string>
<string name="hls_pick_video_primary">Välj en video</string>
<string name="hls_pick_video_helper">Din video transkodas till flera upplösningar så att tittare får mjuk uppspelning på alla anslutningar.</string>
<string name="hls_change_video">Ändra</string>
@@ -2783,8 +2833,13 @@
<string name="relay_management_no_moderation_events">Inga händelser att moderera</string>
<string name="relay_management_banned_events">Bannlysta händelser</string>
<string name="relay_management_no_banned_events">Inga bannlysta händelser</string>
<string name="relay_management_allowed_events">Tillåtna händelser</string>
<string name="relay_management_no_allowed_events">Inga tillåtna händelser</string>
<string name="relay_management_allow_event">Tillåt händelse</string>
<string name="relay_management_allowed_kinds">Tillåtna typer</string>
<string name="relay_management_no_allowed_kinds">Inga tillåtna typer</string>
<string name="relay_management_disallowed_kinds">Otillåtna typer</string>
<string name="relay_management_no_disallowed_kinds">Inga otillåtna typer</string>
<string name="relay_management_blocked_ips">Blockerade IP-adresser</string>
<string name="relay_management_no_blocked_ips">Inga blockerade IP-adresser</string>
<string name="relay_management_add">Lägg till</string>
@@ -2817,6 +2872,9 @@
<string name="relay_members_join_sent">Begäran om att gå med skickad</string>
<string name="relay_members_leave_sent">Begäran om att lämna skickad</string>
<string name="relay_members_you_are_member">Du är medlem</string>
<string name="relay_members_invite_code">Inbjudningskod</string>
<string name="relay_members_invite_code_hint">Det här reläet släpper in medlemmar med en inbjudningskod från operatören</string>
<string name="relay_members_unverifiable">Det här reläet publicerar inte sin identitetsnyckel (NIP-11 self), så dess medlemslista kan inte verifieras</string>
<string name="relay_membership_list">Relämedlemslista</string>
<string name="relay_member_added">Medlem tillagd i relä</string>
<string name="relay_members_added">%1$d medlemmar tillagda i relä</string>
@@ -4074,6 +4132,11 @@
<string name="marmot_admin_granted">Administratörsbehörighet beviljad</string>
<string name="marmot_admin_revoked">Administratörsbehörighet återkallad</string>
<string name="marmot_change_photo">Byt foto</string>
<string name="marmot_out_of_sync_body">Meddelanden från de andra medlemmarna kan inte längre läsas på den här enheten. Återställ din kopia av gruppen och be sedan en admin att ta bort dig och lägga till dig igen.</string>
<string name="marmot_out_of_sync_reset">Återställ</string>
<string name="marmot_out_of_sync_confirm_title">Återställa den här gruppen?</string>
<string name="marmot_out_of_sync_confirm_body">Den här enheten slutar vara medlem tills en admin lägger till dig igen. De meddelanden du redan har finns kvar här.</string>
<string name="marmot_awaiting_reinvite">Väntar på att läggas till igen. Be en admin att ta bort dig och lägga till dig igen.</string>
<string name="marmot_enable_encrypted_media_needs_server">Lägg först till en Blossom-medieserver i Inställningar — gruppen behöver någonstans att ladda upp bilagor till.</string>
<string name="marmot_encrypted_media_enabled_toast">Den här gruppen använder nu krypterade bilagor</string>
<string name="marmot_failed_to_add_user">Misslyckades med att lägga till %1$s: %2$s</string>
@@ -4556,6 +4619,7 @@
<string name="podcast_trailer_upload_hint">Kort ljud- eller videoförhandsvisning</string>
<string name="podcast_value_error_title">Value-for-Value-fel</string>
<string name="podcast_value_keysend_requires_nwc">Anslut en Nostr Wallet Connect-plånbok för att skicka till keysend-mottagare (noder).</string>
<string name="podcast_value_keysend_not_supported">Din plånbok stöder inte keysend, så keysend-mottagare (noder) hoppades över.</string>
<string name="podcast_value_no_recipients">Den här podden har inga betalbara värdemottagare.</string>
<string name="podcast_value_stream_requires_wallet">Anslut en Nostr Wallet Connect- eller debiteringsplånbok för att streama sats medan du lyssnar.</string>
<string name="podcast_value_user_no_lnaddress">Den här användaren har ingen Lightning-adress</string>
@@ -5187,6 +5251,7 @@
</plurals>
<string name="wallet_transactions_load_failed">Kunde inte läsa in transaktioner</string>
<string name="wallet_transactions_load_more_failed">Kunde inte läsa in fler transaktioner</string>
<string name="wallet_transactions_not_supported">Din plånbok erbjuder ingen transaktionshistorik</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Visar ett varningsmeddelande när inlägg har 5 eller fler rapporter från följande</string>
<string name="warn_when_posts_have_reports_from_your_follows_title">Varna vid rapporter</string>
<string name="web_bookmark_add_title">Lägg till webbbokmärke</string>
@@ -340,6 +340,7 @@
<string name="relay_discovery_geohash">Location</string>
<string name="calendar_rsvp_going">Going</string>
<string name="calendar_rsvp_maybe">Maybe</string>
<string name="calendar_rsvp_loading_event">Loading event…</string>
<string name="calendar_rsvp_not_going">Can't go</string>
<plurals name="calendar_collection_count">
<item quantity="one">%1$d event</item>
@@ -675,7 +676,10 @@
<string name="account_backup_encrypted_title">Password-protected copy</string>
<string name="account_backup_encrypted_password">Password</string>
<string name="account_backup_encrypted_body">An ncryptsec1… that only works with your password.</string>
<string name="account_backup_password_min_length">At least %1$d characters</string>
<plurals name="account_backup_password_min_length">
<item quantity="one">At least %1$d character</item>
<item quantity="other">At least %1$d characters</item>
</plurals>
<string name="account_backup_encrypted_repeat_password">Repeat password</string>
<string name="account_backup_encrypted_password_mismatch">Passwords don't match</string>
<string name="account_backup_encrypt">Encrypt key</string>
@@ -3458,6 +3462,13 @@
<string name="calendar_reminder_settings_title">Calendar reminders</string>
<string name="calendar_view_day">Day</string>
<string name="calendar_view_feed">Feed</string>
<string name="calendar_view_follows_going">Friends going</string>
<string name="calendar_follows_going_empty_title">No plans yet</string>
<string name="calendar_follows_going_empty_subtitle">When people in this list RSVP “Going” to an upcoming event, it shows up here.</string>
<plurals name="calendar_follows_going_count">
<item quantity="one">%1$d going</item>
<item quantity="other">%1$d going</item>
</plurals>
<string name="calendar_view_month">Month</string>
<string name="calendar_view_week">Week</string>
<string name="call_accept">Accept</string>
@@ -3905,6 +3916,7 @@
<string name="home_content_type_podcasts">Podcasts</string>
<string name="home_content_type_polls">Polls</string>
<string name="home_content_type_ratings">Ratings and reviews</string>
<string name="home_content_type_calendar_rsvps">Event RSVPs</string>
<string name="home_content_type_reposts">Reposts</string>
<string name="home_content_type_shorts">Shorts</string>
<string name="home_content_type_text_notes">Text notes</string>
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.nip52Calendar.ui
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_view_day
import com.vitorpamplona.amethyst.commons.resources.calendar_view_feed
import com.vitorpamplona.amethyst.commons.resources.calendar_view_follows_going
import com.vitorpamplona.amethyst.commons.resources.calendar_view_month
import com.vitorpamplona.amethyst.commons.resources.calendar_view_week
import org.jetbrains.compose.resources.StringResource
@@ -31,11 +32,15 @@ import org.jetbrains.compose.resources.StringResource
* Lenses on the same appointment timeline. Calendar *collections* (kind 31924) live on their
* own screen ([CalendarCollectionsScreen]) since they're a sibling feed, not a different view
* of the appointment data.
*
* [FOLLOWS_GOING] is the one lens not drawn from the appointment timeline: it is built from the
* RSVPs of the people in the selected list, showing the upcoming events they said they'd attend.
*/
enum class CalendarsViewMode(
val labelRes: StringResource,
) {
FEED(Res.string.calendar_view_feed),
FOLLOWS_GOING(Res.string.calendar_view_follows_going),
MONTH(Res.string.calendar_view_month),
WEEK(Res.string.calendar_view_week),
DAY(Res.string.calendar_view_day),
@@ -20,7 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -34,84 +37,135 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.calendar_rsvp_going
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_loading_event
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.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import org.jetbrains.compose.resources.stringResource
/** Card for a NIP-52 calendar RSVP (kind 31925): the going/maybe/not-going status, note, and target. */
/**
* A NIP-52 calendar RSVP (kind 31925) as one card: the author's note as post text, then a single
* quote-style frame whose tinted strip says how they answered and whose body is the appointment
* they answered.
*
* The status lives *inside* the appointment's frame on purpose. It is a property of "Alice → this
* event", so a header line floating above a separately framed event read as two unrelated posts;
* and the frame is the feed's plain quote border, not a raised card, so the RSVP never nests a
* box inside a box.
*
* [appointment] draws the event once the front end has it (no header of its own — the post's
* author is who answered, and a second avatar row made it read like a quote of a quote). Until
* then the strip stands over a muted "loading" line. [statusDetail] is the strip's trailing text,
* e.g. "in 3 days". [onClick] makes the whole frame open the event.
*/
@Composable
fun CalendarRsvpCard(event: CalendarRSVPEvent) {
fun CalendarRsvpCard(
event: CalendarRSVPEvent,
statusDetail: String? = null,
onClick: (() -> Unit)? = null,
appointment: (@Composable ColumnScope.() -> Unit)? = null,
) {
val status = remember(event) { event.status() }
val targetAddress = remember(event) { event.calendarEventAddress() }
val freebusy = remember(event) { event.freebusy() }
val statusLabel =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> stringResource(Res.string.calendar_rsvp_going)
RSVPStatusTag.STATUS.TENTATIVE -> stringResource(Res.string.calendar_rsvp_maybe)
RSVPStatusTag.STATUS.DECLINED -> stringResource(Res.string.calendar_rsvp_not_going)
null -> "—"
}
val statusColor =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
null -> Color.Gray
}
Column(MaterialTheme.colorScheme.replyModifier) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = statusColor,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = statusLabel,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = statusColor,
)
val frame =
if (onClick != null) {
MaterialTheme.colorScheme.replyModifier.clickable(onClick = onClick)
} else {
MaterialTheme.colorScheme.replyModifier
}
Column {
if (event.content.isNotBlank()) {
Spacer(modifier = StdVertSpacer)
Text(
text = event.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, end = 10.dp),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 4.dp),
)
}
targetAddress?.let { addr ->
Spacer(modifier = StdVertSpacer)
Text(
text = "→ ${addr.toValue()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
)
}
Column(frame) {
RsvpStatusStrip(status, statusDetail)
if (freebusy != null) {
Spacer(modifier = StdVertSpacer)
if (appointment != null) {
appointment()
} else {
Text(
text = stringResource(Res.string.calendar_rsvp_loading_event),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(12.dp),
)
}
}
}
}
@Composable
private fun RsvpStatusStrip(
status: RSVPStatusTag.STATUS?,
detail: String?,
) {
val (label, color, symbol) = rsvpStatusLook(status)
Row(
modifier =
Modifier
.fillMaxWidth()
.background(color.copy(alpha = 0.12f))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = color,
)
Spacer(modifier = Modifier.size(6.dp))
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = color,
modifier = Modifier.weight(1f),
maxLines = 1,
)
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private data class RsvpStatusLook(
val label: String,
val color: Color,
val symbol: MaterialSymbol,
)
@Composable
private fun rsvpStatusLook(status: RSVPStatusTag.STATUS?): RsvpStatusLook =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED ->
RsvpStatusLook(stringResource(Res.string.calendar_rsvp_going), MaterialTheme.colorScheme.primary, MaterialSymbols.CheckCircle)
RSVPStatusTag.STATUS.TENTATIVE ->
RsvpStatusLook(stringResource(Res.string.calendar_rsvp_maybe), MaterialTheme.colorScheme.tertiary, MaterialSymbols.Schedule)
RSVPStatusTag.STATUS.DECLINED ->
RsvpStatusLook(stringResource(Res.string.calendar_rsvp_not_going), MaterialTheme.colorScheme.error, MaterialSymbols.Cancel)
null ->
RsvpStatusLook("—", Color.Gray, MaterialSymbols.CalendarMonth)
}
@@ -125,6 +125,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.formatDateTime
import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderAudioHeader
import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderAudioTrack
import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderCalendarRSVPEvent
import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderChessGame
import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderGitIssueEvent
import com.vitorpamplona.amethyst.commons.ui.note.platform.RenderGitPatchEvent
@@ -174,7 +175,6 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBolt12Zap
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBookshelfDirectory
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderCalendarRSVPEvent
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderChannelInvite
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderChannelMessage
@@ -37,8 +37,10 @@ import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
import com.vitorpamplona.amethyst.commons.ui.note.types.EditState
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.podcasts.PodcastAudio
import kotlinx.collections.immutable.ImmutableList
@@ -290,6 +292,23 @@ interface NotePlatform {
nav: INav,
) {}
// Calendar
/**
* A NIP-52 RSVP with the appointment it answers merged into its frame. Draws the bare
* [com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard] by default: the appointment
* half still uses the platform's calendar formatting.
*/
@Composable
fun RenderCalendarRSVPEvent(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = baseNote.event as? CalendarRSVPEvent ?: return
CalendarRsvpCard(event)
}
// Meeting rooms (audio spaces)
@Composable
@@ -387,6 +387,17 @@ fun RenderGitPullRequestUpdateEvent(
nav = nav,
)
@Composable
fun RenderCalendarRSVPEvent(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) = LocalNotePlatform.current.RenderCalendarRSVPEvent(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav,
)
@Composable
fun RenderMeetingSpaceEvent(
baseNote: Note,
@@ -1,81 +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.commons.ui.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
/**
* Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is
* in the cache, and renders the shared commons [CalendarRsvpCard].
*/
@Composable
fun RenderCalendarRSVPEvent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? CalendarRSVPEvent ?: return
LoadAppointmentBehind(event, accountViewModel)
CalendarRsvpCard(event)
}
/**
* Resolves the appointment this RSVP answers, from the `a` tag that is the only thing tying the
* two together.
*
* [LoadAddressableNote] creates the [com.vitorpamplona.amethyst.commons.model.AddressableNote] in
* `LocalCache` — a shell with a null event if we have never seen the appointment — and
* [EventFinderFilterAssemblerSubscription] then asks relays for it: `filterMissingAddressables`
* picks up exactly those addressables whose `event == null` and queries the address author's
* outbox relays plus any stored hints.
*
* Both halves matter beyond drawing this card. `EventBroadcaster` routes an RSVP by following its
* `a` tag into the appointment and reading the participants off it, and every step of that walk
* is a `LocalCache` lookup. An RSVP seen in a feed for an appointment that was never cached would
* otherwise leave the cache with no entry to walk, so answering it from here would reach the host
* (whose pubkey the coordinate carries) but none of the other invitees.
*
* Composition-scoped like every other per-note subscription: the row unsubscribes ~30s after it
* scrolls away or the app backgrounds.
*/
@Composable
private fun LoadAppointmentBehind(
event: CalendarRSVPEvent,
accountViewModel: AccountViewModel,
) {
val address = remember(event) { event.calendarEventAddress() } ?: return
LoadAddressableNote(address) { appointment ->
if (appointment != null) {
EventFinderFilterAssemblerSubscription(appointment, accountViewModel)
}
}
}
@@ -99,6 +99,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
private const val TOTAL_STEPS = 3
@@ -414,7 +415,7 @@ private fun EncryptedCopySection(nsec: String) {
if (error) {
Text(stringResource(Res.string.backup_keys_encrypt_failed))
} else {
Text(stringResource(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH))
Text(pluralStringResource(Res.plurals.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH, Nip49.MIN_PASSWORD_LENGTH))
}
},
visualTransformation =
@@ -96,6 +96,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
/**
@@ -378,7 +379,7 @@ private fun EncryptedCopy(nsec: String) {
if (error) {
Text(stringResource(Res.string.backup_keys_encrypt_failed))
} else {
Text(stringResource(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH))
Text(pluralStringResource(Res.plurals.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH, Nip49.MIN_PASSWORD_LENGTH))
}
},
visualTransformation =
@@ -55,6 +55,12 @@ import kotlin.test.assertTrue
*
* Key invariant being tested: when coordinator.consumeEvent() is called,
* the event should flow through cache → eventStream → ViewModel.feedState.
*
* Tests that expect a note in a feed take it from the cache *before* it is consumed and
* keep it ([pinned]). [DesktopLocalCache] holds notes weakly (LargeSoftCache) and the feed
* filters find notes by scanning it, so a note nothing references can be collected during
* the bundler wait and never reach the feed. In the app the screen showing it is that
* strong referent.
*/
class CoordinatorPipelineTest {
private val userPubKey = "a".repeat(64)
@@ -64,6 +70,13 @@ class CoordinatorPipelineTest {
private suspend fun waitForBundler() = delay(600)
/** Strong references to notes a test expects to see in a feed. See the class KDoc. */
private val pinned = mutableListOf<Any>()
private fun DesktopLocalCache.pinNote(id: HexKey) {
pinned.add(getOrCreateNote(id))
}
/**
* Stub INostrClient — records subscription calls but doesn't connect to any relay.
* This lets us test the coordinator's event routing without network dependencies.
@@ -171,6 +184,7 @@ class CoordinatorPipelineTest {
content = "Hello from relay",
sig = dummySig,
)
cache.pinNote(event.id)
coordinator.consumeEvent(event, relayUrl, wasVerified = true)
waitForBundler()
@@ -278,6 +292,7 @@ class CoordinatorPipelineTest {
content = "Note from followed user",
sig = dummySig,
)
cache.pinNote(textEvent.id)
coordinator.consumeEvent(textEvent, relayUrl, wasVerified = true)
waitForBundler()
@@ -352,6 +367,8 @@ class CoordinatorPipelineTest {
sig = dummySig,
)
cache.pinNote(event.id)
// Consume same event twice (can happen with multiple relays)
coordinator.consumeEvent(event, relayUrl, wasVerified = true)
coordinator.consumeEvent(event, NormalizedRelayUrl("wss://relay2.test/"), wasVerified = true)
@@ -33,6 +33,12 @@ import kotlin.test.assertTrue
* NIP-88 poll consumption: a kind-1068 poll becomes a renderable Note, and a kind-1018
* response is linked into that poll Note's `pollState()` tally. Second identical response
* (a relay echo) must not double-count.
*
* Each test takes the poll [com.vitorpamplona.amethyst.commons.model.Note] from the cache
* *before* consuming and keeps it. [DesktopLocalCache] holds notes weakly (LargeSoftCache),
* so a poll note nothing references can be collected between the poll's consume and the
* response's — the response would then tally into a fresh, empty note — or before the
* read-back. In the app the screen showing the poll is that strong referent.
*/
class DesktopLocalCachePollTest {
private val relayUrl = NormalizedRelayUrl("wss://relay.test/")
@@ -77,13 +83,13 @@ class DesktopLocalCachePollTest {
val voter = NostrSignerSync(KeyPair())
val poll = signedPoll(author, createdAt = 1_700_000_000)
val pollNote = cache.getOrCreateNote(poll.id)
assertTrue(cache.consume(poll, relayUrl, wasVerified = true), "poll should be consumed")
val response = signedResponse(voter, poll.id, option = "0", createdAt = 1_700_000_100)
assertTrue(cache.consume(response, relayUrl, wasVerified = true), "response should be consumed")
val pollNote = cache.getNoteIfExists(poll.id)
assertTrue(pollNote != null, "poll note must exist")
assertTrue(pollNote.event is PollEvent, "poll note must hold the poll")
val tally = pollNote.pollState().responses.value
assertEquals(1, tally.totalVoters())
assertEquals("0", tally.winning())
@@ -96,6 +102,7 @@ class DesktopLocalCachePollTest {
val voter = NostrSignerSync(KeyPair())
val poll = signedPoll(author, createdAt = 1_700_000_000)
val pollNote = cache.getOrCreateNote(poll.id)
cache.consume(poll, relayUrl, wasVerified = true)
val response = signedResponse(voter, poll.id, option = "1", createdAt = 1_700_000_100)
@@ -103,11 +110,7 @@ class DesktopLocalCachePollTest {
// Same signed event echoed back by another relay — id-dedup must reject it.
assertTrue(!cache.consume(response, relayUrl, wasVerified = true))
val tally =
cache
.getNoteIfExists(poll.id)!!
.pollState()
.responses.value
val tally = pollNote.pollState().responses.value
assertEquals(1, tally.totalVoters())
}
}
@@ -36,6 +36,12 @@ import kotlin.test.assertTrue
* only collected kind:6/kind:16 reposts — so quote-reposts never showed in the
* quoted note's reaction row. These tests pin the fix: consuming a `q`-tagged note
* adds it as a boost of the quoted note.
*
* Each test takes the quoted note from the cache *before* consuming and keeps it.
* [DesktopLocalCache] holds notes weakly (LargeSoftCache), and nothing else references the
* quoted note — a quote is kept out of `replyTo` — so a GC before the read-back would drop
* it along with the boost it just received. In the app the screen showing it is that
* strong referent.
*/
class DesktopLocalCacheQuoteBoostTest {
private val relayUrl = NormalizedRelayUrl("wss://relay.test/")
@@ -65,13 +71,12 @@ class DesktopLocalCacheQuoteBoostTest {
fun `a quote-repost counts as a boost of the quoted note`() {
val cache = DesktopLocalCache()
val quote = Event.fromJson(quoteJson)
val quotedNote = cache.getOrCreateNote(quotedId)
// wasVerified = true: this test pins the boost wiring, not signature checks.
val consumed = cache.consume(quote, relayUrl, wasVerified = true)
assertTrue(consumed, "The quote-repost should be consumed")
val quotedNote = cache.getNoteIfExists(quotedId)
assertTrue(quotedNote != null, "The quoted note placeholder should exist")
assertEquals(1, quotedNote.boosts.size, "The quote should count as one boost")
assertEquals(quote.id, quotedNote.boosts.first().idHex)
}
@@ -88,6 +93,7 @@ class DesktopLocalCacheQuoteBoostTest {
tags = emptyArray(),
content = "the original post",
)
val originalNote = cache.getOrCreateNote(original.id)
cache.consume(original, relayUrl, wasVerified = true)
val quote =
@@ -99,8 +105,6 @@ class DesktopLocalCacheQuoteBoostTest {
)
cache.consume(quote, relayUrl, wasVerified = true)
val originalNote = cache.getNoteIfExists(original.id)
assertTrue(originalNote != null)
assertEquals(1, originalNote.boosts.size, "The quote should boost the original")
assertEquals(quote.id, originalNote.boosts.first().idHex)
}
@@ -117,6 +121,7 @@ class DesktopLocalCacheQuoteBoostTest {
tags = emptyArray(),
content = "target",
)
val targetNote = cache.getOrCreateNote(target.id)
cache.consume(target, relayUrl, wasVerified = true)
val plain =
@@ -128,6 +133,6 @@ class DesktopLocalCacheQuoteBoostTest {
)
cache.consume(plain, relayUrl, wasVerified = true)
assertEquals(0, cache.getNoteIfExists(target.id)?.boosts?.size)
assertEquals(0, targetNote.boosts.size)
}
}
@@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -37,6 +36,11 @@ import kotlin.test.assertTrue
* desktop equivalent of Amethyst Android's `LocalCache.justVerify` guard
* and closes the receive-time verification gap flagged in the Quartz
* security review (item 2.1, finding #1).
*
* The acceptance tests take the note from the cache *before* consuming and keep it:
* [DesktopLocalCache] holds notes weakly (LargeSoftCache), so a note nothing references
* can be collected before the read-back and look as if it was never stored. The
* rejection tests look notes up without pinning on purpose — pinning would create one.
*/
class DesktopLocalCacheVerifyTest {
private val relayUrl = NormalizedRelayUrl("wss://relay.test/")
@@ -102,13 +106,12 @@ class DesktopLocalCacheVerifyTest {
fun `consume accepts a properly signed event`() {
val cache = DesktopLocalCache()
val signed = signedTextNote(content = "authentic")
val note = cache.getOrCreateNote(signed.id)
val consumed = cache.consume(signed, relayUrl)
assertTrue(consumed, "Signed event should be accepted")
val note = cache.getNoteIfExists(signed.id)
assertNotNull(note, "Signed event must reach the cache")
assertEquals(signed.id, note.event?.id)
assertEquals(signed.id, note.event?.id, "Signed event must reach the cache")
}
@Test
@@ -141,10 +144,11 @@ class DesktopLocalCacheVerifyTest {
content = "synthetic",
sig = "0".repeat(128),
)
val note = cache.getOrCreateNote(event.id)
val consumed = cache.consume(event, relayUrl, wasVerified = true)
assertTrue(consumed, "wasVerified=true must skip the signature check")
assertNotNull(cache.getNoteIfExists(event.id))
assertEquals(event.id, note.event?.id)
}
}
@@ -40,10 +40,13 @@ class FindUsersTest {
* and `findUsersStartingWith` could collect them and make the search return fewer results
* (flaky failure at line 115). Every test keeps its users in a strong-reference list that
* stays reachable through the assertions to pin them in the cache.
*
* The pin must be taken BEFORE `consumeMetadata`: pinning afterwards leaves the window
* between the consume and the pin open, and a GC there drops the user with its metadata.
*/
private val retained = mutableListOf<User?>()
private val retained = mutableListOf<User>()
private fun DesktopLocalCache.retainUser(pubkey: String): User? = getUserIfExists(pubkey).also { retained.add(it) }
private fun DesktopLocalCache.pinUser(pubkey: String): User = getOrCreateUser(pubkey).also { retained.add(it) }
private fun fakeMetadata(
pubKey: String,
@@ -64,7 +67,7 @@ class FindUsersTest {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
retained.add(cache.getOrCreateUser(pubkey))
cache.pinUser(pubkey)
val results = cache.findUsersStartingWith("test", 10)
assertTrue(results.isEmpty(), "User without metadata should not match name search")
@@ -75,8 +78,8 @@ class FindUsersTest {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
cache.pinUser(pubkey)
cache.consumeMetadata(fakeMetadata(pubkey, "vitor", "Vitor Pamplona"))
cache.retainUser(pubkey)
val results = cache.findUsersStartingWith("Vitor", 10)
assertEquals(1, results.size, "Should find user by display name")
@@ -88,8 +91,8 @@ class FindUsersTest {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
cache.pinUser(pubkey)
cache.consumeMetadata(fakeMetadata(pubkey, "vitor"))
cache.retainUser(pubkey)
val results = cache.findUsersStartingWith("vit", 10)
assertEquals(1, results.size, "Should find user by name prefix")
@@ -100,8 +103,8 @@ class FindUsersTest {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
cache.pinUser(pubkey)
cache.consumeMetadata(fakeMetadata(pubkey, "Vitor", "Vitor Pamplona"))
cache.retainUser(pubkey)
val lower = cache.findUsersStartingWith("vitor", 10)
assertEquals(1, lower.size, "Should find case-insensitively (lowercase)")
@@ -115,7 +118,7 @@ class FindUsersTest {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
retained.add(cache.getOrCreateUser(pubkey))
cache.pinUser(pubkey)
val results = cache.findUsersStartingWith(pubkey.take(8), 10)
assertEquals(1, results.size, "Should find user by pubkey prefix")
@@ -127,8 +130,8 @@ class FindUsersTest {
listOf("alice", "bob", "alex").forEach { name ->
val pubkey = KeyPair().pubKey.toHexKey()
cache.pinUser(pubkey)
cache.consumeMetadata(fakeMetadata(pubkey, name))
cache.retainUser(pubkey)
}
val results = cache.findUsersStartingWith("al", 10)
@@ -140,7 +143,7 @@ class FindUsersTest {
val cache = createCache()
// Simulate users created from kind 1 notes (no metadata)
repeat(10) { retained.add(cache.getOrCreateUser(KeyPair().pubKey.toHexKey())) }
repeat(10) { cache.pinUser(KeyPair().pubKey.toHexKey()) }
assertEquals(10, cache.userCount())
@@ -153,10 +156,10 @@ class FindUsersTest {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
val user = cache.pinUser(pubkey)
cache.consumeMetadata(fakeMetadata(pubkey, "testuser", "Test User"))
val user = cache.retainUser(pubkey)
val metadata = user?.metadataOrNull()
val metadata = user.metadataOrNull()
assertNotNull(metadata, "Metadata should exist after consumeMetadata")
assertTrue(
@@ -37,7 +37,6 @@ import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -208,14 +207,20 @@ class LocalRelayStoreHydrationTest {
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
val store = newStore()
// Pin the note across hydrate -> assert: the cache holds notes weakly
// (LargeSoftCache) and nothing else references a hydrated note here, so a
// GC in between would drop it and read as "never hydrated". In the app the
// feed showing the note is that strong referent.
val pinned = cache.getOrCreateNote(recentNote.id)
try {
store.hydrate(cache)
} finally {
store.close()
}
val note = cache.getNoteIfExists(recentNote.id)
assertNotNull(note, "Recent text note must be hydrated into cache")
assertEquals(recentNote.id, pinned.event?.id, "Recent text note must be hydrated into cache")
}
@Test
@@ -258,15 +263,16 @@ class LocalRelayStoreHydrationTest {
val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey }
val store = newStore()
// Pinned for the same reason as in recentTextNotesWithinSevenDayWindowAreHydrated.
val pinned = cache.getOrCreateNote(note.id)
try {
store.hydrate(cache)
} finally {
store.close()
}
assertFalse(
cache.getNoteIfExists(note.id) == null,
"Round-trip through hydrate must not drop a valid note",
)
assertEquals(note.id, pinned.event?.id, "Round-trip through hydrate must not drop a valid note")
}
}
+6 -6
View File
@@ -123,6 +123,12 @@
"Vietnamese"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "summoner001",
"languages": [
@@ -135,12 +141,6 @@
"Hindi"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "markkks",
"languages": [
+15 -15
View File
@@ -11,7 +11,7 @@ accompanistAdaptive = "0.37.3"
# stdlib Map operators (filter/map/groupBy/associate/count). Bumping needs a port to
# the new forEach/forEachKey/forEachValue API, verified on an Apple target.
cachemapVersion = "0.2.4"
composeMultiplatform = "1.12.0"
composeMultiplatform = "1.12.1"
activityCompose = "1.13.0"
# 9.4.0's PerModuleBundleTask rejects an AAB entry whose name contains a colon, which every
# .kotlin_module named after a Gradle project path has. Worked around in amethyst/build.gradle.kts
@@ -26,20 +26,20 @@ audiowaveform = "1.1.2"
benchmark = "1.5.0"
uiautomator = "2.4.0"
biometricKtx = "1.4.0-alpha02"
coil = "3.6.2"
coil = "3.6.3"
composeBom = "2026.09.00"
tracingPerfetto = "1.0.1"
composeRuntimeAnnotation = "1.12.1"
coreKtx = "1.19.0"
coreKtx = "1.19.1"
datastore = "1.2.1"
devWhyolegCryptography = "0.6.0"
espressoCore = "3.7.0"
firebaseBom = "34.19.0"
fragmentKtx = "1.9.0"
fragmentKtx = "1.9.1"
gms = "4.5.0"
healthConnect = "1.1.0"
jackson = "2.22.2"
jacksonModuleKotlin = "2.22.2"
jackson = "2.22.3"
jacksonModuleKotlin = "2.22.3"
javaKeyring = "1.0.4"
kmpTorRuntime = "2.6.0"
kmpTorResource = "409.5.0"
@@ -60,7 +60,7 @@ lightcompressor-enhanced = "2.2.3"
jlatexmath = "1.5"
markdown = "4d3b3dd173"
highlights = "1.1.0"
okio = "3.18.1"
okio = "3.18.2"
# JetBrains material3 must track Compose Multiplatform's foundation: CMP 1.12.0 changed
# the CustomStyle.applyStyle signature, so the stable 1.9.0 (and the 1.11.0-alpha07 that
# kodein-emoji pulls in) crash at runtime with AbstractMethodError in OutlinedTextField.
@@ -71,34 +71,34 @@ media3 = "1.11.1"
mockk = "1.14.11"
kotlinx-coroutines-test = "1.11.0"
negentropyKmp = "v1.2.0"
navigationCompose = "2.10.1"
navigationCompose = "2.10.2"
okhttp = "5.5.0"
osmdroid = "6.1.20"
runner = "1.7.0"
secp256k1KmpJniAndroid = "0.24.0"
schnorr256k1Kmp = "1.0.5"
securityCryptoKtx = "1.1.0"
slf4j = "2.0.19"
slf4j = "2.0.20"
sonarqubeGradlePlugin = "7.5.0.8588"
spotless = "8.10.2"
spotless = "8.10.3"
streamWebrtcAndroid = "1.3.10"
translate = "17.0.3"
jetbrainsCompose = "1.12.0"
jetbrainsCompose = "1.12.1"
unifiedpush = "3.0.10"
playServicesCast = "22.3.1"
vico-charts-compose = "3.3.1"
zelory = "3.0.1"
zoomable = "2.13.0"
zoomable = "2.14.0"
composemediaplayer = "0.11.4"
jcodec = "0.2.5"
commonsImaging = "1.0.0-alpha6"
thumbnailator = "0.4.21"
zxing = "3.5.4"
webkit = "1.17.0"
webkit = "1.17.1"
# Cross-process UI embedding (SurfaceControlViewHost wrapper) for the in-app browser surface. Apache-2.0.
privacysandboxUi = "1.0.0-alpha17"
windowCoreAndroid = "1.5.1"
workRuntime = "2.11.2"
workRuntime = "2.12.0"
androidxCamera = "1.6.2"
androidxCollection = "1.6.0"
androidxProfileinstaller = "1.4.1"
@@ -107,7 +107,7 @@ kotlinTest = "2.4.20"
core = "1.7.0"
mavenPublish = "0.37.0"
sqlite = "2.7.1"
ktor = "3.5.2"
ktor = "3.6.0"
fourkoma = "1.2.0"
# Phase 2 (Gemini App Functions) — both still pre-stable as of May 2026.
+2 -2
View File
@@ -1,7 +1,7 @@
#Wed Jan 04 09:23:50 EST 2023
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapRequestBuilder
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
@@ -325,13 +326,27 @@ class NostrSignerRemote(
throw convertExceptions("Could not get public key", result)
}
/**
* NIP-46 has no private-zap command, but a private zap's payload is NIP-04-compatible, so the
* recipient can open it with the bunker's `nip04_decrypt`. The sender's copy is keyed by a
* hash of their raw private key, which a bunker never reveals, so that side can't be decrypted.
*/
override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent {
TODO("Not yet implemented")
if (event.zappedAuthor().firstOrNull() != pubKey) {
throw SignerExceptions.CouldNotPerformException(
"Remote (NIP-46) signers can only decrypt private zaps sent to this user",
)
}
return PrivateZapRequestBuilder().decryptReceivedZapEvent(event, this)
}
override suspend fun deriveKey(nonce: HexKey): HexKey {
TODO("Not yet implemented")
}
/**
* Key derivation needs the raw private key, and NIP-46 has no command for it.
*/
override suspend fun deriveKey(nonce: HexKey): HexKey =
throw SignerExceptions.UnsupportedMethodException(
"Remote (NIP-46) signers do not support key derivation",
)
/**
* NIP-BC `sign_psbt` over NIP-46. The bunker-side command is not yet
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip57Zaps
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -59,6 +60,19 @@ class PrivateZapEncryption {
return encryptedMsgBech32 + "_" + ivBech32
}
/**
* Re-encodes a private zap's `anon` payload as a NIP-04 payload. Both are AES-256-CBC
* under the same ECDH shared secret, so any signer's `nip04Decrypt(payload, zapRequest.pubKey)`
* opens it for the zap recipient, including remote signers that never expose the key.
*/
fun privateZapMessageToNip04(msg: String): String {
val parts = msg.split("_")
require(parts.size == 2) { "Invalid message format" }
val encryptedMsg = Bech32.five2eight(Bech32.decode(parts[0]).second, 0)
val iv = Bech32.five2eight(Bech32.decode(parts[1]).second, 0)
return EncryptedInfo.encode(encryptedMsg, iv)
}
fun decryptPrivateZapMessage(
msg: String,
privkey: ByteArray,
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -125,20 +126,46 @@ class PrivateZapRequestBuilder {
return privateEvent
}
/**
* Decrypts a private zap addressed to [signer]'s user using only `nip04Decrypt`, so it works
* for signers that don't hold the private key (NIP-46). Only the recipient can do this: the
* sender's side needs the raw key to re-derive the per-zap key.
*/
suspend fun decryptReceivedZapEvent(
event: ZapRequestEvent,
signer: NostrSigner,
): PrivateZapEvent {
val payload =
try {
PrivateZapEncryption.privateZapMessageToNip04(event.getAnonTag())
} catch (e: Exception) {
throw IllegalStateException("Could not decrypt private zap. ${e.message}")
}
return parsePrivateZap(signer.nip04Decrypt(payload, event.pubKey))
}
fun decryptAnonTag(
encNote: String,
privateKey: ByteArray,
pubKey: HexKey,
): PrivateZapEvent =
try {
val note = PrivateZapEncryption.decryptPrivateZapMessage(encNote, privateKey, pubKey.hexToByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
decryptedEvent as PrivateZapEvent
} else {
throw IllegalStateException("The decrypted event is not a private zap.")
}
parsePrivateZap(PrivateZapEncryption.decryptPrivateZapMessage(encNote, privateKey, pubKey.hexToByteArray()))
} catch (e: Exception) {
throw IllegalStateException("Could not decrypt private zap. ${e.message}")
}
private fun parsePrivateZap(json: String): PrivateZapEvent {
val decryptedEvent =
try {
fromJson(json)
} catch (e: Exception) {
throw IllegalStateException("Could not decrypt private zap. ${e.message}")
}
if (decryptedEvent.kind == PrivateZapEvent.KIND) {
return decryptedEvent as PrivateZapEvent
} else {
throw IllegalStateException("The decrypted event is not a private zap.")
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,225 @@
/*
* 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.quartz.utils.unicode
/**
* Pure-Kotlin Unicode NFKC (UAX #15) for targets whose platform has no
* normalizer: Linux native has no ICU. JVM/Android use `java.text.Normalizer`
* and Apple uses `NSString`.
*
* Runs the standard algorithm (full compatibility decomposition, canonical
* ordering, canonical composition) over the [NfkcData] tables generated from
* the Unicode Character Database. Hangul syllables are handled algorithmically.
* The tables are parsed on first use of non-ASCII input.
*/
internal object NfkcNormalizer {
private const val S_BASE = 0xAC00
private const val L_BASE = 0x1100
private const val V_BASE = 0x1161
private const val T_BASE = 0x11A7
private const val L_COUNT = 19
private const val V_COUNT = 21
private const val T_COUNT = 28
private const val N_COUNT = V_COUNT * T_COUNT
private const val S_COUNT = L_COUNT * N_COUNT
// Every code point below U+00A0 is its own NFKC form.
private const val FIRST_CHANGING_CHAR = ' '
private val combiningClasses: Map<Int, Int> by lazy {
val map = HashMap<Int, Int>()
forEachEntry(NfkcData.CCC) { entry ->
val dash = entry.indexOf('-')
val colon = entry.indexOf(':')
val combiningClass = entry.substring(colon + 1).toInt(16)
for (cp in entry.substring(0, dash).toInt(16)..entry.substring(dash + 1, colon).toInt(16)) {
map[cp] = combiningClass
}
}
map
}
private val decompositions: Map<Int, IntArray> by lazy {
val map = HashMap<Int, IntArray>()
forEachEntry(NfkcData.DECOMP) { entry ->
val colon = entry.indexOf(':')
map[entry.substring(0, colon).toInt(16)] =
entry
.substring(colon + 1)
.split(' ')
.map { it.toInt(16) }
.toIntArray()
}
map
}
private val compositions: Map<Long, Int> by lazy {
val map = HashMap<Long, Int>()
forEachEntry(NfkcData.COMPOSE) { entry ->
val parts = entry.split(' ')
map[pairKey(parts[0].toInt(16), parts[1].toInt(16))] = parts[2].toInt(16)
}
map
}
fun normalize(input: String): String {
if (input.all { it < FIRST_CHANGING_CHAR }) return input
val buffer = decompose(input)
canonicalOrder(buffer)
val length = compose(buffer)
val out = StringBuilder(length)
for (i in 0 until length) out.appendCodePoint(buffer.values[i])
return out.toString()
}
private fun decompose(input: String): CodePointBuffer {
val out = CodePointBuffer(input.length + 8)
var i = 0
while (i < input.length) {
val c = input[i]
val cp =
if (c.isHighSurrogate() && i + 1 < input.length && input[i + 1].isLowSurrogate()) {
i++
0x10000 + ((c.code - 0xD800) shl 10) + (input[i].code - 0xDC00)
} else {
c.code
}
i++
if (cp >= S_BASE && cp < S_BASE + S_COUNT) {
val index = cp - S_BASE
out.add(L_BASE + index / N_COUNT)
out.add(V_BASE + (index % N_COUNT) / T_COUNT)
val trailing = index % T_COUNT
if (trailing != 0) out.add(T_BASE + trailing)
} else {
val mapping = decompositions[cp]
if (mapping != null) {
for (m in mapping) out.add(m)
} else {
out.add(cp)
}
}
}
return out
}
/** Stable-sorts each run of non-starters by combining class (UAX #15 canonical ordering). */
private fun canonicalOrder(buffer: CodePointBuffer) {
val values = buffer.values
for (i in 1 until buffer.size) {
val cp = values[i]
val cc = combiningClass(cp)
if (cc == 0) continue
var j = i
while (j > 0 && combiningClass(values[j - 1]) > cc) {
values[j] = values[j - 1]
j--
}
values[j] = cp
}
}
/** Canonical composition in place (the UAX #15 reference algorithm). Returns the new length. */
private fun compose(buffer: CodePointBuffer): Int {
val values = buffer.values
if (buffer.size == 0) return 0
var starterPos = 0
var starter = values[0]
// A leading non-starter has no starter to combine with: 256 blocks it.
var lastClass = if (combiningClass(starter) == 0) 0 else 256
var writePos = 1
for (readPos in 1 until buffer.size) {
val cp = values[readPos]
val cc = combiningClass(cp)
val composite = composePair(starter, cp)
if (composite >= 0 && (lastClass < cc || lastClass == 0)) {
values[starterPos] = composite
starter = composite
} else {
if (cc == 0) {
starterPos = writePos
starter = cp
}
lastClass = cc
values[writePos++] = cp
}
}
return writePos
}
private fun composePair(
first: Int,
second: Int,
): Int {
if (first >= L_BASE && first < L_BASE + L_COUNT && second >= V_BASE && second < V_BASE + V_COUNT) {
return S_BASE + ((first - L_BASE) * V_COUNT + (second - V_BASE)) * T_COUNT
}
if (first >= S_BASE && first < S_BASE + S_COUNT && (first - S_BASE) % T_COUNT == 0 &&
second > T_BASE && second < T_BASE + T_COUNT
) {
return first + (second - T_BASE)
}
return compositions[pairKey(first, second)] ?: -1
}
private fun combiningClass(cp: Int): Int = if (cp < 0x300) 0 else combiningClasses[cp] ?: 0
private fun pairKey(
first: Int,
second: Int,
): Long = (first.toLong() shl 21) or second.toLong()
private inline fun forEachEntry(
chunks: Array<String>,
action: (String) -> Unit,
) {
for (chunk in chunks) {
for (entry in chunk.split(';')) action(entry)
}
}
private fun StringBuilder.appendCodePoint(cp: Int) {
if (cp < 0x10000) {
append(cp.toChar())
} else {
val offset = cp - 0x10000
append((0xD800 + (offset shr 10)).toChar())
append((0xDC00 + (offset and 0x3FF)).toChar())
}
}
private class CodePointBuffer(
capacity: Int,
) {
var values = IntArray(capacity)
var size = 0
fun add(cp: Int) {
if (size == values.size) values = values.copyOf(size * 2)
values[size++] = cp
}
}
}
@@ -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.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor
import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision
import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer
import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* A bunker holds the user's key, so [NostrSignerRemote] can only do what NIP-46 exposes.
* Private zaps sent to the user open through `nip04_decrypt`; the sender's copy and key
* derivation need the raw key and must fail with a [SignerExceptions] callers handle.
*/
class NostrSignerRemotePrivateZapTest {
private val relay = NormalizedRelayUrl("wss://relay.example.com/")
private class AllowAuthorizer : Nip46RequestAuthorizer {
override suspend fun isPaired(clientPubKey: HexKey) = true
override suspend fun onConnect(
clientPubKey: HexKey,
request: BunkerRequestConnect,
) = Nip46ConnectDecision.Accept(request.secret ?: "ack")
override suspend fun authorize(
clientPubKey: HexKey,
request: BunkerRequest,
) = true
override suspend fun onLogout(clientPubKey: HexKey) {}
}
private suspend fun privateZap(
from: NostrSignerInternal,
to: HexKey,
) = ZapRequestEvent.create(
userHex = to,
relays = setOf(relay),
signer = from,
message = "for your eyes only",
zapType = ZapReceiptEvent.ZapType.PRIVATE,
)
private fun remoteWithoutBunker(user: HexKey) =
NostrSignerRemote(
signer = NostrSignerInternal(KeyPair()),
remotePubkey = NostrSignerInternal(KeyPair()).pubKey,
relays = setOf(relay),
client = EmptyNostrClient(),
).also { it.bindUserPubkey(user) }
@Test
fun recipientDecryptsPrivateZapThroughBunker() =
runTest {
withContext(Dispatchers.Default) {
val user = NostrSignerInternal(KeyPair())
val sender = NostrSignerInternal(KeyPair())
val zap = privateZap(sender, user.pubKey)
val server = NostrServer(EventStore(null))
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client =
NostrClient(
object : WebsocketBuilder {
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
) = InProcessWebSocket(server, out)
},
scope,
)
// The bunker: holds the user's key behind its own transport key.
val bunkerTransport = NostrSignerInternal(KeyPair())
val processor = BunkerRequestProcessor(user, { setOf(relay) }, AllowAuthorizer())
val service = NostrConnectSignerService(client, bunkerTransport, processor, setOf(relay))
// UNDISPATCHED so the bunker's REQ is registered before the client publishes a request.
scope.launch(start = CoroutineStart.UNDISPATCHED) { service.run() }
val remote =
NostrSignerRemote(
signer = NostrSignerInternal(KeyPair()),
remotePubkey = bunkerTransport.pubKey,
relays = setOf(relay),
client = client,
)
remote.bindUserPubkey(user.pubKey)
remote.openSubscription()
try {
val decrypted = withTimeout(20_000) { remote.decryptZapEvent(zap) }
assertEquals("for your eyes only", decrypted.content)
assertEquals(sender.pubKey, decrypted.pubKey, "the private zap reveals the real sender")
assertEquals(user.decryptZapEvent(zap).id, decrypted.id)
} finally {
remote.closeSubscription()
scope.cancel()
server.close()
}
}
}
@Test
fun senderSideOfPrivateZapIsReportedAsNotPerformable() =
runTest {
val user = NostrSignerInternal(KeyPair())
val zap = privateZap(user, NostrSignerInternal(KeyPair()).pubKey)
assertFailsWith<SignerExceptions.CouldNotPerformException> {
remoteWithoutBunker(user.pubKey).decryptZapEvent(zap)
}
}
@Test
fun deriveKeyIsUnsupported() =
runTest {
assertFailsWith<SignerExceptions.UnsupportedMethodException> {
remoteWithoutBunker(NostrSignerInternal(KeyPair()).pubKey).deriveKey("00".repeat(32))
}
}
}
@@ -0,0 +1,42 @@
/*
* 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.quartz.nip49PrivKeyEnc
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Opens a key another client encrypted under an un-normalized password, on every target.
* Only passes where the platform NFKC-normalizes the password the same way the producer did.
*/
class Nip49NormalizedPasswordTest {
@Test
fun decryptsForeignKeyTypedWithUnnormalizedPassword() {
// Produced by nostr-tools 2.25.2 (see jvmTest Nip49InteropTest).
val ncryptsec =
"ncryptsec1qgggze6805h7slevp5evyekxhh42fl2qk8hlla2uej8934l0f65v7xfhd2njmtdnm2ws9grf6tuzeugj59hctn3rg8dp03s8e62vycvynmf9ymcxp9sczvhx7mntl8wm8gujg00vx9rjvxxt4s8nq893"
assertEquals(
"3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683",
Nip49().decrypt(ncryptsec, "ÅΩẛ̣"),
)
}
}
@@ -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.quartz.utils.unicode
import com.vitorpamplona.quartz.utils.UnicodeNormalizer
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
/**
* Runs on every target, so each platform's [UnicodeNormalizer] (JDK, NSString, and the
* pure-Kotlin [NfkcNormalizer] on Linux) must agree with these UAX #15 results.
*/
class UnicodeNormalizerTest {
private val cases =
listOf(
// NIP-49 spec example: the password must be NFKC-normalized before scrypt.
"ÅΩẛ̣" to "ÅΩṩ",
// Decomposed Latin recomposes.
"é" to "é",
"Å" to "Å",
// Compatibility mappings.
" " to " ",
"fi" to "fi",
"①" to "1",
"Abc" to "Abc",
"½" to "1⁄2",
"㎒" to "MHz",
// Canonical ordering: dot below (220) sorts before circumflex (230), then composes.
"ậ" to "ậ",
"ậ" to "ậ",
// Hangul: conjoining jamo compose into syllables, syllables stay composed.
"각" to "각",
"각" to "각",
// Supplementary plane: mathematical bold A maps to A.
"𝐀" to "A",
// A leading combining mark has nothing to compose with.
"́e" to "́e",
// Singleton decomposition: ANGSTROM SIGN is never recomposed.
"Å" to "Å",
// Composition exclusion: DEVANAGARI QA stays decomposed.
"क़" to "क़",
)
@Test
fun platformNormalizerMatchesReferenceResults() {
val normalizer = UnicodeNormalizer()
cases.forEach { (input, expected) ->
assertEquals(expected, normalizer.normalizeNFKC(input), "input: ${input.codePoints()}")
}
}
@Test
fun pureKotlinNormalizerMatchesReferenceResults() {
cases.forEach { (input, expected) ->
assertEquals(expected, NfkcNormalizer.normalize(input), "input: ${input.codePoints()}")
}
}
@Test
fun asciiIsReturnedUnchanged() {
val ascii = "correct horse battery staple 123!@#"
assertSame(ascii, NfkcNormalizer.normalize(ascii))
assertEquals("", NfkcNormalizer.normalize(""))
}
private fun String.codePoints() = map { it.code.toString(16) }
}
@@ -0,0 +1,83 @@
/*
* 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.quartz.utils.unicode
import java.text.Normalizer
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Checks the pure-Kotlin [NfkcNormalizer] (used on Linux native) against the JDK's
* `java.text.Normalizer`. The JDK can trail the Unicode version of [NfkcData], so only
* code points the JDK knows are compared; normalization stability guarantees their
* results don't change in later Unicode versions.
*/
class NfkcNormalizerJdkParityTest {
private val definedCodePoints =
(0..0x10FFFF).filter { Character.isDefined(it) && Character.getType(it) != Character.SURROGATE.toInt() }
private fun jdk(input: String) = Normalizer.normalize(input, Normalizer.Form.NFKC)
private fun check(input: String) {
val expected = jdk(input)
val actual = NfkcNormalizer.normalize(input)
if (expected != actual) {
assertEquals(expected.hex(), actual.hex(), "input: ${input.hex()}")
}
}
@Test
fun everyCodePointAlone() {
definedCodePoints.forEach { check(Character.toString(it)) }
}
@Test
fun everyCodePointFollowedByCombiningMarks() {
definedCodePoints.forEach {
val cp = Character.toString(it)
check(cp + "̣̂")
check(cp + "̣̂")
check(cp + "̈")
check("a" + cp + "́")
}
}
@Test
fun randomSequencesOfInterestingCodePoints() {
// Code points that change under NFKD or take part in reordering/composition.
val interesting =
definedCodePoints.filter {
val s = Character.toString(it)
Normalizer.normalize(s, Normalizer.Form.NFKD) != s || Character.getType(it) == Character.NON_SPACING_MARK.toInt()
} + (0x1100..0x11FF) + listOf('a'.code, 'e'.code, 'A'.code, ' '.code)
val random = Random(49)
repeat(200_000) {
val length = random.nextInt(1, 8)
val sb = StringBuilder()
repeat(length) { sb.appendCodePoint(interesting[random.nextInt(interesting.size)]) }
check(sb.toString())
}
}
private fun String.hex() = codePoints().toArray().joinToString(" ") { it.toString(16) }
}
@@ -20,11 +20,9 @@
*/
package com.vitorpamplona.quartz.utils
import com.vitorpamplona.quartz.utils.unicode.NfkcNormalizer
// Linux native has no platform normalizer (no ICU), so it uses Quartz's pure-Kotlin one.
actual class UnicodeNormalizer {
actual fun normalizeNFKC(input: String): String {
// NFKC normalization on Linux native without ICU
// For most Nostr use cases (NIP-05, etc.) ASCII-safe content works without normalization.
// Full NFKC support would require linking against ICU or a pure-Kotlin normalizer.
return input
}
actual fun normalizeNFKC(input: String): String = NfkcNormalizer.normalize(input)
}
+24
View File
@@ -0,0 +1,24 @@
# unicode-nfkc
Generates `NfkcData.kt`, the tables behind Quartz's pure-Kotlin NFKC normalizer
(`quartz/.../utils/unicode/NfkcNormalizer.kt`). Linux native uses it because it
has no platform normalizer. JVM/Android use `java.text.Normalizer` and Apple
uses `NSString`. NIP-49 NFKC-normalizes passwords before scrypt, so a wrong
normalizer silently breaks key decryption.
```bash
V=17.0.0
mkdir -p /tmp/ucd && for f in UnicodeData.txt DerivedNormalizationProps.txt; do
curl -sSfo /tmp/ucd/$f https://www.unicode.org/Public/$V/ucd/$f
done
tools/unicode-nfkc/generate.py /tmp/ucd \
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/unicode/NfkcData.kt
```
Commit the regenerated file. `NfkcNormalizerJdkParityTest` (jvmTest) checks the
result against the JDK for every code point the JDK knows. To check a new
Unicode version fully, run the normalizer over that version's
`NormalizationTest.txt` (column 4 is the NFKC of columns 1-5).
The data is derived from the Unicode Character Database, distributed under the
permissive [Unicode License v3](https://www.unicode.org/license.txt).
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Generate Quartz's pure-Kotlin NFKC tables from the Unicode Character Database.
Usage:
tools/unicode-nfkc/generate.py <ucd-dir> <output.kt>
<ucd-dir> must hold UnicodeData.txt and DerivedNormalizationProps.txt from
https://www.unicode.org/Public/<version>/ucd/. The version is read from the
DerivedNormalizationProps.txt header and stamped into the output.
Three tables are emitted, all as hex strings the Kotlin side parses once:
CCC - "start-end:ccc" ranges of non-zero canonical combining classes
DECOMP - "cp:x y z" full compatibility (NFKD) decompositions, already
expanded recursively. Hangul syllables are algorithmic and omitted.
COMPOSE - "first second composite" primary composites, i.e. two-code-point
canonical decompositions minus Full_Composition_Exclusion.
"""
import os
import re
import sys
CHUNK = 16000 # keeps each string literal far below the JVM's 64 KB constant limit
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
line = line.split("#", 1)[0].strip()
if line:
yield line
def parse_range(text):
if ".." in text:
a, b = text.split("..")
return range(int(a, 16), int(b, 16) + 1)
return range(int(text, 16), int(text, 16) + 1)
def main(ucd_dir, out_path):
props_path = os.path.join(ucd_dir, "DerivedNormalizationProps.txt")
with open(props_path, encoding="utf-8") as f:
version = re.search(r"DerivedNormalizationProps-([\d.]+)\.txt", f.readline()).group(1)
ccc = {}
decomp = {} # cp -> (is_compat, [cps])
for line in read_lines(os.path.join(ucd_dir, "UnicodeData.txt")):
fields = line.split(";")
cp = int(fields[0], 16)
if int(fields[3]):
ccc[cp] = int(fields[3])
if fields[5]:
parts = fields[5].split()
compat = parts[0].startswith("<")
if compat:
parts = parts[1:]
decomp[cp] = (compat, [int(p, 16) for p in parts])
exclusions = set()
for line in read_lines(props_path):
fields = [x.strip() for x in line.split(";")]
if fields[1] == "Full_Composition_Exclusion":
exclusions.update(parse_range(fields[0]))
def expand(cp):
if cp not in decomp:
return [cp]
out = []
for c in decomp[cp][1]:
out.extend(expand(c))
return out
ccc_ranges = []
for cp in sorted(ccc):
last = ccc_ranges[-1] if ccc_ranges else None
if last and last[1] == cp - 1 and last[2] == ccc[cp]:
last[1] = cp
else:
ccc_ranges.append([cp, cp, ccc[cp]])
ccc_entries = ["%X-%X:%X" % (a, b, c) for a, b, c in ccc_ranges]
decomp_entries = [
"%X:%s" % (cp, " ".join("%X" % c for c in expand(cp))) for cp in sorted(decomp)
]
compose_entries = [
"%X %X %X" % (parts[0], parts[1], cp)
for cp, (compat, parts) in sorted(decomp.items())
if not compat and len(parts) == 2 and cp not in exclusions
]
def chunks(entries):
out, cur = [], ""
for e in entries:
if cur and len(cur) + len(e) + 1 > CHUNK:
out.append(cur)
cur = ""
cur = e if not cur else cur + ";" + e
if cur:
out.append(cur)
return out
def kotlin_array(name, entries):
body = "".join(' "%s",\n' % c for c in chunks(entries))
return " val %s =\n arrayOf(\n%s )\n" % (name, body)
header_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "quartz", "src",
"commonMain", "kotlin", "com", "vitorpamplona", "quartz", "utils",
"UnicodeNormalizer.kt")
with open(header_path, encoding="utf-8") as f:
license_header = f.read().split("package ", 1)[0]
with open(out_path, "w", encoding="utf-8") as out:
out.write(license_header)
out.write("package com.vitorpamplona.quartz.utils.unicode\n\n")
out.write("// GENERATED by tools/unicode-nfkc/generate.py from the Unicode %s Character Database.\n" % version)
out.write("// Do not edit by hand; re-run the script to update.\n")
out.write("//\n")
out.write("// Derived from Unicode data files, Copyright (c) 1991-2025 Unicode, Inc.,\n")
out.write("// distributed under the Unicode License v3: https://www.unicode.org/license.txt\n")
out.write("internal object NfkcData {\n")
out.write(' const val UNICODE_VERSION = "%s"\n\n' % version)
out.write(kotlin_array("CCC", ccc_entries))
out.write("\n")
out.write(kotlin_array("DECOMP", decomp_entries))
out.write("\n")
out.write(kotlin_array("COMPOSE", compose_entries))
out.write("}\n")
print("Unicode %s: %d ccc ranges, %d decompositions, %d compositions"
% (version, len(ccc_ranges), len(decomp_entries), len(compose_entries)))
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2])