mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cece7eafb | ||
|
|
dcf2807e75 | ||
|
|
ea2472468b | ||
|
|
1f4d9728be | ||
|
|
25c4516270 | ||
|
|
d56d0cf725 | ||
|
|
f7cf257365 | ||
|
|
edd36b467c | ||
|
|
5b357cd2fc | ||
|
|
c47afe6427 | ||
|
|
a2e199bd57 | ||
|
|
2d7ef2fd93 | ||
|
|
21623cfd3f | ||
|
|
08b3cf266b | ||
|
|
f6e68f31d4 | ||
|
|
27c863925a | ||
|
|
c4c8649fcc | ||
|
|
112fbb8379 | ||
|
|
6fbcb880dd | ||
|
|
60e3df72b2 | ||
|
|
14046fd0ce | ||
|
|
91bd2b44ee | ||
|
|
09902fe470 | ||
|
|
14b615b10d | ||
|
|
8678c88db6 | ||
|
|
6fac964d16 | ||
|
|
96a896f78e | ||
|
|
1c24ba9cf6 | ||
|
|
9e4aedee05 | ||
|
|
eae0ca814d | ||
|
|
ec60834046 | ||
|
|
2684d2d4a8 | ||
|
|
fd6696da82 | ||
|
|
00c46bcc92 | ||
|
|
26ad072993 | ||
|
|
52643fb48f | ||
|
|
202163f4b9 | ||
|
|
097c3e874d | ||
|
|
adc36017d1 | ||
|
|
65073c351c | ||
|
|
34633624cc | ||
|
|
9f367269e6 | ||
|
|
9f77245860 | ||
|
|
b185f58ada | ||
|
|
f79741e611 | ||
|
|
ff40d49b78 | ||
|
|
439bc5d5f9 | ||
|
|
bde562b4a6 | ||
|
|
1793ee5ffe | ||
|
|
07267e254b | ||
|
|
16030599d9 | ||
|
|
ee8623bd20 | ||
|
|
7e6170390d | ||
|
|
de1c4905cc | ||
|
|
0279938e05 | ||
|
|
536d3cf1a5 | ||
|
|
979ce7a96f | ||
|
|
7cda707380 | ||
|
|
b58543fb2d | ||
|
|
707cffec6d | ||
|
|
d44264a1ac | ||
|
|
60161e3fae | ||
|
|
ca61eb107d | ||
|
|
9d2c5e0cee | ||
|
|
e6bce31249 | ||
|
|
38e5c848a4 | ||
|
|
ac426531f3 | ||
|
|
967d51b6f9 | ||
|
|
21329b6dab | ||
|
|
240f4e67a6 | ||
|
|
a41db22030 | ||
|
|
417147cfe7 | ||
|
|
afaf5f582b | ||
|
|
c13d2840b9 | ||
|
|
bdc21fee1e | ||
|
|
22383e7a1c | ||
|
|
6ba53918c4 | ||
|
|
c422a9d022 | ||
|
|
3510296673 | ||
|
|
f723310dc7 | ||
|
|
6e2a8f00f4 | ||
|
|
22503b8fe3 | ||
|
|
422bf10337 | ||
|
|
97ff971542 | ||
|
|
34fa103dbd | ||
|
|
b4798331e8 | ||
|
|
0703797bdb | ||
|
|
f98859afa8 | ||
|
|
3e583fd425 | ||
|
|
a89aaed9fa | ||
|
|
6035e5f0e7 | ||
|
|
4d21165d7d | ||
|
|
ab5e4116ad | ||
|
|
0e48a8c85b | ||
|
|
9a5b36d0ba | ||
|
|
646459e367 |
@@ -295,6 +295,75 @@
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- "New Picture" share target: a gallery (or camera) shares an image and it goes straight
|
||||
into the picture feed as a NIP-68 kind-20 post instead of a text note with a link. The
|
||||
android:name simple class ("ShareAsPictureAlias") is matched at runtime by
|
||||
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME; keep the two in sync. -->
|
||||
<activity-alias
|
||||
android:name=".ui.ShareAsPictureAlias"
|
||||
android:exported="true"
|
||||
android:label="@string/share_target_as_picture"
|
||||
android:targetActivity=".ui.MainActivity">
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_picture">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_picture">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
|
||||
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
|
||||
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
|
||||
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
|
||||
<activity-alias
|
||||
android:name=".ui.ShareAsShortVideoAlias"
|
||||
android:exported="true"
|
||||
android:label="@string/share_target_as_short_video"
|
||||
android:targetActivity=".ui.MainActivity">
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_short_video">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_short_video">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- "New Video" share target: opens the Video feed's media composer, which publishes a
|
||||
NIP-71 video event (kind 21 or 22 by orientation) instead of a text note. The
|
||||
android:name simple class ("ShareAsVideoAlias") is matched at runtime by
|
||||
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
|
||||
<activity-alias
|
||||
android:name=".ui.ShareAsVideoAlias"
|
||||
android:exported="true"
|
||||
android:label="@string/share_target_as_video"
|
||||
android:targetActivity=".ui.MainActivity">
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_video">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/share_target_as_video">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- "New Highlight" share target: a browser (or reader) shares a selected passage of
|
||||
text and it opens the NIP-84 highlight composer. Text-only — a highlight is a text
|
||||
passage, so no image/video filters here. The android:name simple class
|
||||
|
||||
@@ -56,11 +56,36 @@ import java.io.File
|
||||
*/
|
||||
class Amethyst : Application() {
|
||||
init {
|
||||
Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR
|
||||
Log.minLevel = DEFAULT_LOG_LEVEL
|
||||
Log.d("AmethystApp") { "Creating App $this" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Restores the full firehose in debug builds: per-socket relay lifecycle
|
||||
* (`Connecting…`/`Disconnected`/`OnOpen`), per-second event throughput, per-stream Arti
|
||||
* SOCKS errors and per-relay census detail.
|
||||
*
|
||||
* Off by default. A cold start on the outbox model dials ~400 relays, and those sources
|
||||
* alone emit ~3.7k of the ~6.2k lines an 80s boot produces — which buries the boot
|
||||
* narrative and the relay-protocol warnings (`auth-required`, `rate-limited`,
|
||||
* `unsupported: too many filters`) that are the actionable ones. Flip this, or set
|
||||
* [Log.minLevel] at runtime, when you need the per-socket detail back.
|
||||
*/
|
||||
const val VERBOSE_LOGS = false
|
||||
|
||||
/**
|
||||
* Debug defaults to INFO so a boot reads as a narrative plus warnings; release defaults to
|
||||
* WARN rather than ERROR so relay-protocol refusals stay visible in the field — they are
|
||||
* the highest-value-per-line diagnostics we emit and were previously dropped entirely.
|
||||
*/
|
||||
val DEFAULT_LOG_LEVEL: LogLevel =
|
||||
when {
|
||||
!BuildConfig.DEBUG -> LogLevel.WARN
|
||||
VERBOSE_LOGS -> LogLevel.DEBUG
|
||||
else -> LogLevel.INFO
|
||||
}
|
||||
|
||||
lateinit var instance: AppModules
|
||||
private set
|
||||
}
|
||||
@@ -83,10 +108,15 @@ class Amethyst : Application() {
|
||||
// is self-contained (WebView + IPC), so we skip all app init there and
|
||||
// leave `instance` unset; any accidental use fails fast.
|
||||
if (isNappletSandbox) {
|
||||
Log.d("AmethystApp") { "Skipping AppModules init in sandbox process" }
|
||||
// Milestone, not chatter: this is the one line that explains why `instance` is unset
|
||||
// and `LocalCache` is empty in this process. Without it a napplet-process log looks
|
||||
// like a broken app rather than a deliberately secret-free sandbox.
|
||||
Log.i("AmethystApp") { "Napplet sandbox process starting — no account, no AppModules" }
|
||||
return
|
||||
}
|
||||
|
||||
Log.i("AmethystApp") { "Amethyst ${BuildConfig.VERSION_NAME} starting in main process (log level ${Log.minLevel})" }
|
||||
|
||||
instance = AppModules(this)
|
||||
|
||||
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
|
||||
|
||||
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
@@ -287,6 +288,11 @@ class AppModules(
|
||||
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
|
||||
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
|
||||
|
||||
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
|
||||
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
|
||||
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
|
||||
val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope)
|
||||
|
||||
// Service that will run at all times to receive events from Pokey
|
||||
val pokeyReceiver = PokeyReceiver()
|
||||
|
||||
@@ -1224,6 +1230,14 @@ class AppModules(
|
||||
blossomResolver.uriToUrlCache.evictAll()
|
||||
blossomResolver.blossomHitCache.cache.evictAll()
|
||||
localBlossomCacheProbe.invalidate()
|
||||
// Re-probe immediately so enabling the feature activates it
|
||||
// this session. Otherwise `available` only advances when a
|
||||
// `blossom:` URI is resolved, and the common feed-image path
|
||||
// never routes through the resolver until `available` is
|
||||
// already true — so a freshly-enabled toggle (or a cache that
|
||||
// came up after launch) would stay dormant and the settings
|
||||
// "detected" chip would read stale.
|
||||
localBlossomCacheProbe.isAvailable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -325,6 +326,9 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
if (!newSystemOfAccounts.isNullOrEmpty()) {
|
||||
// How many accounts are in play is the first thing you need when reading any
|
||||
// boot log: nearly every per-account subsystem below multiplies by this number.
|
||||
Log.i("LocalPreferences") { "Found ${newSystemOfAccounts.size} saved account(s)" }
|
||||
newSystemOfAccounts
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
|
||||
@@ -712,6 +716,7 @@ object LocalPreferences {
|
||||
|
||||
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
|
||||
Log.d("LocalPreferences") { "Load account from file $npub" }
|
||||
val startedAtMs = TimeUtils.nowMillis()
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
@@ -1017,7 +1022,11 @@ object LocalPreferences {
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("LocalPreferences") { "Loaded account from file $npub" }
|
||||
// Milestone with its cost attached. Decrypting and parsing one account's settings is one of
|
||||
// the most expensive things a cold start does (it resolves a fan of backup events), it runs
|
||||
// once per account, and "which account was slow" is the first question when a boot drags.
|
||||
// The six intermediate steps above stay at DEBUG.
|
||||
Log.i("LocalPreferences") { "Loaded account $npub in ${TimeUtils.nowMillis() - startedAtMs}ms" }
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
|
||||
@@ -3465,6 +3466,10 @@ class Account(
|
||||
val template = DeleteGroupEvent.build(channel.groupId.id)
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
unfollow(channel)
|
||||
// Remember the deletion so the channel leaves the community's browse list immediately and
|
||||
// stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a
|
||||
// stale re-announced 44100 on a Buzz relay) would otherwise keep it visible.
|
||||
RelayGroupDeletions.markDeleted(channel.groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3671,6 +3676,10 @@ class Account(
|
||||
parent: String? = channel.parentGroupId(),
|
||||
children: List<String> = channel.childGroupIds(),
|
||||
) {
|
||||
// On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT
|
||||
// read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on
|
||||
// edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag.
|
||||
val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)
|
||||
val template =
|
||||
EditMetadataEvent.build(
|
||||
channel.groupId.id,
|
||||
@@ -3682,10 +3691,24 @@ class Account(
|
||||
geohashes = geohashes,
|
||||
parent = parent,
|
||||
children = children,
|
||||
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
|
||||
)
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The
|
||||
* relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its
|
||||
* history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces.
|
||||
*/
|
||||
suspend fun archiveRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
archived: Boolean,
|
||||
) {
|
||||
val template = EditMetadataEvent.build(channel.groupId.id, archived = archived)
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community))
|
||||
|
||||
suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community))
|
||||
@@ -4122,6 +4145,7 @@ class Account(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
originalHash: String? = null,
|
||||
videoKind: VideoPostKind = VideoPostKind.AUTO,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -4165,7 +4189,10 @@ class Account(
|
||||
thumbhash = headerInfo.thumbHash?.thumbhash,
|
||||
)
|
||||
|
||||
if (headerInfo.dim.height > headerInfo.dim.width) {
|
||||
// The composer forces the kind when it was opened from a feed that only reads one of
|
||||
// them (Shorts, Longs) or from that feed's share target, so the post lands where the
|
||||
// user asked for it. Everywhere else the orientation decides.
|
||||
if (videoKind.isShort(headerInfo.dim)) {
|
||||
VideoShortEvent.build(videoMeta, alt ?: "") {
|
||||
contentWarningReason?.let { contentWarning(contentWarningReason) }
|
||||
}
|
||||
|
||||
@@ -89,7 +89,13 @@ class AntiSpamFilter {
|
||||
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
|
||||
val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2
|
||||
|
||||
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
|
||||
// it is already reported where it can be acted on — relayStats.newSpam below and
|
||||
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
|
||||
// with a pair of njump links each, which is the widest line in the log and says
|
||||
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
|
||||
// open the two events and compare them.
|
||||
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
|
||||
// Log down offenders
|
||||
val spammer = logOffender(hash, event)
|
||||
@@ -118,7 +124,13 @@ class AntiSpamFilter {
|
||||
// LRU cache while the spammer record still matches this hash.
|
||||
val link1 = existingEvent?.let { njumpLink(NEvent.create(it, null, null, relay)) } ?: link2
|
||||
|
||||
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
|
||||
// it is already reported where it can be acted on — relayStats.newSpam below and
|
||||
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
|
||||
// with a pair of njump links each, which is the widest line in the log and says
|
||||
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
|
||||
// open the two events and compare them.
|
||||
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
|
||||
// Log down offenders
|
||||
val spammer = logOffender(hash, event)
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
|
||||
@@ -114,6 +115,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
|
||||
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
|
||||
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
||||
@@ -2223,6 +2225,30 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz kind-40099 system message. It renders as a narration row in the channel feed (via
|
||||
* [consumeBuzzTimelineEvent]), but a `channel_deleted` one is also the **authoritative signal that
|
||||
* a channel is gone**: the relay soft-deletes the channel and its 39000/39001/39002 discovery
|
||||
* events but emits no member-removed notification and never retracts the kind-44100 that seeds the
|
||||
* browse list — so without this the deleted channel keeps re-appearing (a stale 44100 re-announced
|
||||
* every restart, its metadata now blank so it shows optimistically). Recording the delete in
|
||||
* [RelayGroupDeletions] filters it out of every list and persists it across restarts.
|
||||
*
|
||||
* Gated on [isRelaySignedGroupEvent] (the 40099 is signed by the relay keypair) so a spoofed
|
||||
* system message from a stray author can't hide a channel. Cross-device by construction: the relay
|
||||
* replays this on subscribe, so a channel deleted on Buzz web/desktop is honored here too.
|
||||
*/
|
||||
private fun consume(
|
||||
event: SystemMessageEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeBuzzTimelineEvent(event, relay, wasVerified).also {
|
||||
if (relay != null && isRelaySignedGroupEvent(event, relay) && event.payload()?.type == SystemMessagePayload.CHANNEL_DELETED) {
|
||||
event.channel()?.let { channelId -> RelayGroupDeletions.markDeleted(GroupId(channelId, relay)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Store-only consume for Buzz kinds that carry no channel timeline row. */
|
||||
private fun consumeBuzzRegularEvent(
|
||||
event: Event,
|
||||
@@ -4798,7 +4824,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is StreamMessageEditEvent -> consume(event, relay, wasVerified)
|
||||
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is SystemMessageEvent -> consume(event, relay, wasVerified)
|
||||
is CanvasEvent -> consume(event, relay, wasVerified)
|
||||
// Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003)
|
||||
// and votes (45002) are store-only: the forum-thread detail loads them on demand by root.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
|
||||
/**
|
||||
* Which NIP-71 kind a video upload is published as. Only videos are affected — whether an upload
|
||||
* is a picture (NIP-68 kind 20) or a video is decided by the file's mime type and can't be
|
||||
* overridden.
|
||||
*
|
||||
* The composer picks this from the feed it was opened on, so a post always lands in the feed the
|
||||
* user was standing in (or shared to): the Shorts feed reads kind 22, the Longs feed reads kind 21
|
||||
* and the Video feed reads both.
|
||||
*/
|
||||
enum class VideoPostKind {
|
||||
/** Derive from the video's dimensions: portrait -> kind 22, landscape -> kind 21. */
|
||||
AUTO,
|
||||
|
||||
/** Always NIP-71 kind 22 (short-form video), regardless of orientation. */
|
||||
SHORT,
|
||||
|
||||
/** Always NIP-71 kind 21 (normal video), regardless of orientation. */
|
||||
NORMAL,
|
||||
|
||||
;
|
||||
|
||||
/** True when a video of [dim] should be published as a NIP-71 kind 22 short. */
|
||||
fun isShort(dim: DimensionTag): Boolean =
|
||||
when (this) {
|
||||
SHORT -> true
|
||||
NORMAL -> false
|
||||
AUTO -> dim.height > dim.width
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.preferences
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the set of deleted NIP-29 relay-group channels ([RelayGroupDeletions]),
|
||||
* so a channel the user deleted (kind-9008) stays gone across a restart — even if the host relay keeps
|
||||
* re-announcing a stale kind-44100 for it. Mirrors [BuzzChannelStarPreferences]: app-wide (not
|
||||
* per-account), loads the saved keys into the singleton on construction, then writes every later change
|
||||
* back. Construct once, eagerly.
|
||||
*/
|
||||
@Stable
|
||||
class RelayGroupDeletionPreferences(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
init {
|
||||
scope.launch {
|
||||
restoreFromDisk()
|
||||
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
|
||||
RelayGroupDeletions.flow.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
|
||||
if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayGroupDeletionPrefs") { "Error reading deleted channels: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persist(keys: Set<String>) {
|
||||
try {
|
||||
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringSetPreferencesKey("nip29.deletedChannels")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -389,7 +389,7 @@ object NotificationUtils {
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
return NotificationCompat.Action
|
||||
.Builder(R.drawable.ic_notif_reply, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
|
||||
.Builder(R.drawable.ic_action_reply, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
|
||||
.addRemoteInput(replyRemoteInput(applicationContext))
|
||||
.setAllowGeneratedReplies(true)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
@@ -460,7 +460,7 @@ object NotificationUtils {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
return NotificationCompat.Action
|
||||
.Builder(R.drawable.ic_notif_message, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
|
||||
.Builder(R.drawable.ic_action_mark_read, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||
.build()
|
||||
}
|
||||
|
||||
+20
-12
@@ -56,7 +56,10 @@ import kotlin.concurrent.thread
|
||||
*/
|
||||
class BootRelayDiagnostics(
|
||||
val client: INostrClient,
|
||||
val dumpAtSeconds: List<Long> = listOf(20, 45, 90),
|
||||
// 5s first: the pool is assembled and dialling well before 20s, and the early datapoint is what
|
||||
// distinguishes "slow to connect" from "connected fine, slow to serve". Affordable because the
|
||||
// rollup is now 3 INFO lines rather than 5 — the ===== banners moved to DEBUG.
|
||||
val dumpAtSeconds: List<Long> = listOf(5, 20, 45, 90),
|
||||
) {
|
||||
companion object {
|
||||
const val TAG = "BootRelayDiag"
|
||||
@@ -197,8 +200,13 @@ class BootRelayDiagnostics(
|
||||
fun detach() = client.removeConnectionListener(listener)
|
||||
|
||||
/**
|
||||
* One line per relay plus a rollup. Kept to a single Log.w per line so the whole census
|
||||
* One line per relay plus a rollup. Kept to a single Log call per line so the whole census
|
||||
* survives logcat's per-tag rate limiting on a busy boot.
|
||||
*
|
||||
* The rollup goes out at INFO — it is the one boot line worth reading by default, and it
|
||||
* carries the aggregate that per-socket failure logging used to spell out a few hundred
|
||||
* times. The per-relay WASTE/SERVE tables are DEBUG: useful when you are chasing a specific
|
||||
* relay, too long (up to 45 lines a census) to sit in the default log.
|
||||
*/
|
||||
fun dump(atSeconds: Long) {
|
||||
val snapshot = records.toMap()
|
||||
@@ -214,27 +222,27 @@ class BootRelayDiagnostics(
|
||||
r.closed.forEach { (k, v) -> closedTotals[k] = (closedTotals[k] ?: 0) + v.get() }
|
||||
}
|
||||
|
||||
Log.w(TAG, "===== boot census @${atSeconds}s =====")
|
||||
Log.w(
|
||||
Log.d(TAG, "===== boot census @${atSeconds}s =====")
|
||||
Log.i(
|
||||
TAG,
|
||||
"pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
|
||||
"census @${atSeconds}s pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
|
||||
"dials=${snapshot.values.sumOf { it.tentatives.get() }} " +
|
||||
"events=${snapshot.values.sumOf { it.events.get() }} " +
|
||||
"reqs=${snapshot.values.sumOf { it.reqsSent.get() }} " +
|
||||
"auths=${snapshot.values.sumOf { it.authsSent.get() }}",
|
||||
)
|
||||
Log.w(TAG, "failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
Log.w(TAG, "closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
Log.i(TAG, "census @${atSeconds}s failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
Log.i(TAG, "census @${atSeconds}s closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
|
||||
// Relays that cost us dials and gave nothing back, worst first: the wasted-effort list.
|
||||
Log.w(TAG, "--- top wasted dials (no events received) ---")
|
||||
Log.d(TAG, "--- top wasted dials (no events received) ---")
|
||||
snapshot
|
||||
.filter { it.value.events.get() == 0 }
|
||||
.entries
|
||||
.sortedByDescending { it.value.tentatives.get() }
|
||||
.take(25)
|
||||
.forEach { (url, r) ->
|
||||
Log.w(
|
||||
Log.d(
|
||||
TAG,
|
||||
"WASTE ${url.url} dials=${r.tentatives.get()} opens=${r.opens.get()} " +
|
||||
"fail=[${r.failures.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
|
||||
@@ -245,17 +253,17 @@ class BootRelayDiagnostics(
|
||||
|
||||
// The relays actually carrying the boot, so a suppression change can be checked for
|
||||
// coverage loss rather than just CLOSED reduction.
|
||||
Log.w(TAG, "--- top event providers ---")
|
||||
Log.d(TAG, "--- top event providers ---")
|
||||
served.entries
|
||||
.sortedByDescending { it.value.events.get() }
|
||||
.take(20)
|
||||
.forEach { (url, r) ->
|
||||
Log.w(
|
||||
Log.d(
|
||||
TAG,
|
||||
"SERVE ${url.url} events=${r.events.get()} reqs=${r.reqsSent.get()} eose=${r.eoses.get()} " +
|
||||
"openMs=${r.firstOpenAtMs.get()} eoseMs=${r.firstEoseAtMs.get()} dials=${r.tentatives.get()}",
|
||||
)
|
||||
}
|
||||
Log.w(TAG, "===== end census @${atSeconds}s =====")
|
||||
Log.d(TAG, "===== end census @${atSeconds}s =====")
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -29,11 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@@ -57,9 +60,16 @@ class BlossomServerResolver(
|
||||
suspend fun findServers(uriStr: String): BlossomUriServer? {
|
||||
uriToUrlCache[uriStr]?.let { return it }
|
||||
|
||||
// Confined to Dispatchers.IO: this is reached from Compose
|
||||
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
|
||||
// which run on the main dispatcher. The pre-suspension work here —
|
||||
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
|
||||
// build, and the server-list flow setup — must stay off the UI thread.
|
||||
val result =
|
||||
withTimeoutOrNull(10000) {
|
||||
findServersInner(uriStr)
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeoutOrNull(10000) {
|
||||
findServersInner(uriStr)
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
|
||||
+32
-23
@@ -22,10 +22,13 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
|
||||
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -77,33 +80,39 @@ class LocalBlossomCacheProbe(
|
||||
cachedAtMs = 0L
|
||||
}
|
||||
|
||||
// Confined to Dispatchers.IO because callers reach this through suspend
|
||||
// resolvers invoked from Compose `LaunchedEffect`/`produceState`, which run
|
||||
// on the main dispatcher: building the OkHttp client and issuing the HEAD
|
||||
// must not touch the UI thread.
|
||||
private suspend fun probe(): Boolean =
|
||||
try {
|
||||
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
|
||||
val client =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
|
||||
val client =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$LOCAL_CACHE_BASE/")
|
||||
.head()
|
||||
.build()
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$LOCAL_CACHE_BASE/")
|
||||
.head()
|
||||
.build()
|
||||
|
||||
client.newCall(request).executeAsync().use { response ->
|
||||
// Spec says HEAD / returns 2xx when available. Some implementations
|
||||
// may answer 405 (method not allowed) while still being a working
|
||||
// Blossom cache, so treat that as available too.
|
||||
response.isSuccessful || response.code == 405
|
||||
client.newCall(request).executeAsync().use { response ->
|
||||
// Spec says HEAD / returns 2xx when available. Some implementations
|
||||
// may answer 405 (method not allowed) while still being a working
|
||||
// Blossom cache, so treat that as available too.
|
||||
response.isSuccessful || response.code == 405
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
false
|
||||
}
|
||||
|
||||
private fun currentTimeMs(): Long = System.currentTimeMillis()
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.VideoPostKind
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
@@ -73,15 +74,21 @@ open class NewMediaModel : ViewModel() {
|
||||
// Strip location and sensitive metadata from files before upload
|
||||
var stripMetadata by mutableStateOf(true)
|
||||
|
||||
// Which NIP-71 kind videos in this batch are published as. Set by the composer's host feed.
|
||||
var videoKind: VideoPostKind = VideoPostKind.AUTO
|
||||
|
||||
open fun load(
|
||||
account: Account,
|
||||
uris: ImmutableList<SelectedMedia>,
|
||||
videoKind: VideoPostKind = VideoPostKind.AUTO,
|
||||
caption: String = "",
|
||||
) {
|
||||
this.caption = ""
|
||||
this.caption = caption
|
||||
this.account = account
|
||||
this.multiOrchestrator = MultiOrchestrator(uris)
|
||||
this.selectedServer = defaultServer()
|
||||
this.stripMetadata = account.settings.stripLocationOnUpload
|
||||
this.videoKind = videoKind
|
||||
}
|
||||
|
||||
fun isImage(
|
||||
@@ -180,6 +187,7 @@ open class NewMediaModel : ViewModel() {
|
||||
alt = caption,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
originalHash = it.uploadedHash,
|
||||
videoKind = videoKind,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.VideoPostKind
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
|
||||
@@ -79,14 +80,16 @@ fun NewMediaView(
|
||||
postViewModel: NewMediaModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
videoKind: VideoPostKind = VideoPostKind.AUTO,
|
||||
initialCaption: String = "",
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(uris) {
|
||||
postViewModel.load(account, uris)
|
||||
LaunchedEffect(uris, videoKind, initialCaption) {
|
||||
postViewModel.load(account, uris, videoKind, initialCaption)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
+20
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -40,3 +43,20 @@ suspend fun resolveSharedMedia(
|
||||
SelectedMedia(uri, context.contentResolver.getType(uri))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch form for SEND_MULTIPLE shares: resolves every URI in one trip to the IO dispatcher
|
||||
* instead of one context switch per file. Blank entries are dropped, so an empty list in means
|
||||
* an empty list out and the composer stays closed.
|
||||
*/
|
||||
suspend fun resolveSharedMedia(
|
||||
context: Context,
|
||||
uriStrings: List<String>,
|
||||
): ImmutableList<SelectedMedia> {
|
||||
val uris = uriStrings.mapNotNull { it.ifBlank { null }?.toUri() }
|
||||
if (uris.isEmpty()) return persistentListOf()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
uris.map { SelectedMedia(it, context.contentResolver.getType(it)) }.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,13 +52,10 @@ fun LoadUrlPreview(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadUrlPreviewDirect(
|
||||
fun rememberUrlPreviewState(
|
||||
url: String,
|
||||
urlText: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav? = null,
|
||||
) {
|
||||
): UrlPreviewState {
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
val urlPreviewState by
|
||||
produceState(
|
||||
@@ -69,6 +66,18 @@ fun LoadUrlPreviewDirect(
|
||||
accountViewModel.urlPreview(url) { value = it }
|
||||
}
|
||||
}
|
||||
return urlPreviewState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadUrlPreviewDirect(
|
||||
url: String,
|
||||
urlText: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav? = null,
|
||||
) {
|
||||
val urlPreviewState = rememberUrlPreviewState(url, accountViewModel)
|
||||
|
||||
CrossfadeIfEnabled(
|
||||
targetState = urlPreviewState,
|
||||
|
||||
@@ -23,13 +23,17 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
@@ -37,12 +41,14 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -53,6 +59,7 @@ fun UrlPreviewCard(
|
||||
url: String,
|
||||
previewInfo: UrlInfoItem,
|
||||
onUrlComments: (() -> Unit)? = null,
|
||||
onCardClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val popupExpanded =
|
||||
@@ -95,7 +102,11 @@ fun UrlPreviewCard(
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
runCatching { uri.openUri(url) }
|
||||
if (onCardClick != null) {
|
||||
onCardClick()
|
||||
} else {
|
||||
runCatching { uri.openUri(url) }
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
popupExpanded.value = true
|
||||
@@ -109,14 +120,34 @@ fun UrlPreviewCard(
|
||||
modifier = previewCardImageModifier,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
// Only meaningful when the card's own tap does something else (e.g. opening the
|
||||
// comment thread); otherwise it would duplicate the card's open-in-browser tap.
|
||||
if (onCardClick != null) {
|
||||
IconButton(
|
||||
onClick = { runCatching { uri.openUri(url) } },
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
|
||||
contentDescription = stringRes(R.string.url_preview_open_in_browser),
|
||||
modifier = Size14Modifier,
|
||||
tint = Color.Gray,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = previewInfo.title,
|
||||
|
||||
@@ -47,6 +47,7 @@ fun FeedLoaded(
|
||||
routeForLastRead: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
header: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -54,6 +55,12 @@ fun FeedLoaded(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
if (header != null) {
|
||||
item {
|
||||
header()
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> item.idHex },
|
||||
|
||||
@@ -51,6 +51,8 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.image
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.scope
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
|
||||
@@ -313,10 +315,10 @@ private class WarmTargets {
|
||||
|
||||
/**
|
||||
* Collects everything worth warming for this note. Reads NIP-92 imeta tags (every
|
||||
* kind), then the free-text body: text notes and comments go through the shared
|
||||
* render cache — which both warms the renderer and yields a fully classified body
|
||||
* — while every other kind gets a cheap URL scan. Returns null only when the note
|
||||
* has no event.
|
||||
* kind), the NIP-73 external-content scope (kind 1111 only), then the free-text
|
||||
* body: text notes and comments go through the shared render cache — which both
|
||||
* warms the renderer and yields a fully classified body — while every other kind
|
||||
* gets a cheap URL scan. Returns null only when the note has no event.
|
||||
*/
|
||||
private fun Note.collectWarmTargets(): WarmTargets? {
|
||||
val ev = event ?: return null
|
||||
@@ -335,6 +337,13 @@ private fun Note.collectWarmTargets(): WarmTargets? {
|
||||
// imetas() above misses these entirely.
|
||||
targets.addFileHeaderMedia(ev)
|
||||
|
||||
// NIP-73 external-content scope. Only kind 1111 carries one, so the type check
|
||||
// keeps every other kind out of the tag walk. Reuses scope() — the same accessor
|
||||
// the renderer uses — so we only warm a URL that will actually be shown.
|
||||
if (ev is CommentEvent) {
|
||||
(ev.scope() as? UrlId)?.let { targets.link(it.url) }
|
||||
}
|
||||
|
||||
// Free-text body. The reproducible-key kinds parse through the shared cache
|
||||
// (which also warms the render); everything else is scanned for URLs only.
|
||||
val rendered = warmRenderCache(ev)
|
||||
|
||||
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinato
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.MediaFeedRoute
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute
|
||||
@@ -428,7 +429,7 @@ fun BuildNavigation(
|
||||
) {
|
||||
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
|
||||
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
|
||||
composableCapped<Route.Video> { VideoScreen(accountViewModel, nav) }
|
||||
composableArgs<Route.Video> { VideoScreen(accountViewModel, nav, it.attachments, it.message) }
|
||||
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
|
||||
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||
@@ -439,7 +440,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ProfileAppRecommendations> { ProfileAppRecommendationsScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Pictures> { PicturesScreen(accountViewModel, nav, it.attachments, it.message) }
|
||||
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.GitRepositories> { GitRepositoriesScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -469,7 +470,7 @@ fun BuildNavigation(
|
||||
}
|
||||
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
|
||||
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Shorts> { ShortsScreen(accountViewModel, nav, it.attachments, it.message) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.BuzzDmList> { BuzzDmListScreen(it.relayUrl, accountViewModel, nav) }
|
||||
@@ -790,6 +791,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.RelayGroupCreate> {
|
||||
RelayGroupCreateScreen(
|
||||
relayUrl = it.relayUrl,
|
||||
isForum = it.isForum,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -970,6 +972,42 @@ fun BuildNavigation(
|
||||
}
|
||||
}
|
||||
|
||||
/** True for both share flavors: a single file/text (SEND) and a multi-file selection (SEND_MULTIPLE). */
|
||||
private fun Intent.isShareAction(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
|
||||
|
||||
/** The text Android sent with the share — a caption, a URL, or the whole payload of a text-only share. */
|
||||
private fun Intent.sharedText(): String? = getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
|
||||
|
||||
/**
|
||||
* Every content URI the share carries, in the order the sending app listed them. SEND_MULTIPLE
|
||||
* puts them in a parcelable ArrayList; plain SEND has at most one. Only the media targets declare
|
||||
* SEND_MULTIPLE, so every other caller can just take the first.
|
||||
*/
|
||||
private fun Intent.sharedStreamUris(): List<String> =
|
||||
if (action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
IntentCompat
|
||||
.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
?.map { it.toString() }
|
||||
.orEmpty()
|
||||
} else {
|
||||
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)?.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a media feed on a share. Sharing again while an earlier share is still on screen replaces
|
||||
* it instead of stacking a second copy of the feed — but only when the entry on top is itself a
|
||||
* share (it carries attachments). A feed the user opened from the bottom bar carries none, so it
|
||||
* stays put underneath and keeps its tab-root marker.
|
||||
*/
|
||||
private inline fun <reified T> Nav.navToSharedFeed(route: T) where T : Route, T : MediaFeedRoute {
|
||||
val current = getRouteWithArguments(T::class, controller)
|
||||
if (current is MediaFeedRoute && current.attachments.isNotEmpty()) {
|
||||
popUpTo(route, T::class)
|
||||
} else {
|
||||
newStack(route)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavigateIfIntentRequested(
|
||||
nav: Nav,
|
||||
@@ -986,49 +1024,43 @@ private fun NavigateIfIntentRequested(
|
||||
|
||||
val activity = LocalContext.current.getActivity()
|
||||
|
||||
if (activity.intent.action == Intent.ACTION_SEND) {
|
||||
val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className)
|
||||
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(activity.intent.component?.className)
|
||||
if (activity.intent.isShareAction()) {
|
||||
val target = ShareIntentRouting.targetOf(activity.intent.component?.className)
|
||||
|
||||
// avoids restarting the destination screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions
|
||||
if (isShareAsHighlight) {
|
||||
if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
|
||||
} else if (isShareAsDm) {
|
||||
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
|
||||
} else {
|
||||
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
|
||||
// Microsoft's swift key sends Gifs as new actions.
|
||||
// The media targets land on a feed the user may well be standing on already, so they can't
|
||||
// guard on the destination — they rely on the intent being consumed below instead.
|
||||
when (target) {
|
||||
ShareTarget.HIGHLIGHT -> if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
|
||||
ShareTarget.DIRECT_MESSAGE -> if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
|
||||
ShareTarget.NEW_POST -> if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
|
||||
ShareTarget.PICTURE, ShareTarget.SHORT_VIDEO, ShareTarget.VIDEO -> Unit
|
||||
}
|
||||
|
||||
// saves the intent to avoid processing again
|
||||
var message by remember {
|
||||
mutableStateOf(
|
||||
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
it.ifBlank { null }
|
||||
},
|
||||
)
|
||||
}
|
||||
val message = remember { activity.intent.sharedText() }
|
||||
val attachments = remember { activity.intent.sharedStreamUris() }
|
||||
|
||||
var media by remember {
|
||||
mutableStateOf(
|
||||
IntentCompat.getParcelableExtra(activity.intent, Intent.EXTRA_STREAM, Uri::class.java),
|
||||
)
|
||||
}
|
||||
|
||||
if (isShareAsHighlight) {
|
||||
val parsed = message?.let { SharedHighlightParser.parse(it) }
|
||||
nav.newStack(
|
||||
Route.NewHighlight(
|
||||
quote = parsed?.quote,
|
||||
url = parsed?.url,
|
||||
prefix = parsed?.prefix,
|
||||
suffix = parsed?.suffix,
|
||||
),
|
||||
)
|
||||
} else if (isShareAsDm) {
|
||||
nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
|
||||
} else {
|
||||
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
|
||||
when (target) {
|
||||
ShareTarget.HIGHLIGHT -> {
|
||||
val parsed = message?.let { SharedHighlightParser.parse(it) }
|
||||
nav.newStack(
|
||||
Route.NewHighlight(
|
||||
quote = parsed?.quote,
|
||||
url = parsed?.url,
|
||||
prefix = parsed?.prefix,
|
||||
suffix = parsed?.suffix,
|
||||
),
|
||||
)
|
||||
}
|
||||
// The single-file composers take the first URI: only the media targets declare
|
||||
// SEND_MULTIPLE, so anything else can only be carrying one.
|
||||
ShareTarget.DIRECT_MESSAGE -> nav.newStack(Route.ShareToDM(message = message, attachment = attachments.firstOrNull()))
|
||||
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
|
||||
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
|
||||
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
|
||||
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = attachments.firstOrNull()))
|
||||
}
|
||||
|
||||
// Consume the launch intent so a later recomposition can't re-fire
|
||||
@@ -1095,38 +1127,44 @@ private fun NavigateIfIntentRequested(
|
||||
DisposableEffect(nav, activity) {
|
||||
val consumer =
|
||||
Consumer<Intent> { intent ->
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className)
|
||||
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(intent.component?.className)
|
||||
// avoids restarting the destination screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions
|
||||
if (isShareAsHighlight) {
|
||||
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
|
||||
val parsed = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { SharedHighlightParser.parse(it) }
|
||||
nav.newStack(
|
||||
Route.NewHighlight(
|
||||
quote = parsed?.quote,
|
||||
url = parsed?.url,
|
||||
prefix = parsed?.prefix,
|
||||
suffix = parsed?.suffix,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if (isShareAsDm) {
|
||||
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
|
||||
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
|
||||
val attachment =
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
|
||||
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
|
||||
}
|
||||
} else if (!isBaseRoute<Route.NewShortNote>(nav.controller)) {
|
||||
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
nav.newStack(Route.NewShortNote(message = it))
|
||||
}
|
||||
if (intent.isShareAction()) {
|
||||
val target = ShareIntentRouting.targetOf(intent.component?.className)
|
||||
val message = intent.sharedText()
|
||||
val attachments = intent.sharedStreamUris()
|
||||
val attachment = attachments.firstOrNull()
|
||||
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let {
|
||||
nav.newStack(Route.NewShortNote(attachment = it.toString()))
|
||||
}
|
||||
// avoids restarting the destination screen when the intent is for the screen.
|
||||
// Microsoft's swift key sends Gifs as new actions.
|
||||
// The media targets land on a feed the user may well be standing on already, so
|
||||
// they always navigate: the route carries the attachment, so the composer opens
|
||||
// on the newly shared file even when the feed itself is already on screen.
|
||||
when (target) {
|
||||
ShareTarget.HIGHLIGHT ->
|
||||
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
|
||||
val parsed = message?.let { SharedHighlightParser.parse(it) }
|
||||
nav.newStack(
|
||||
Route.NewHighlight(
|
||||
quote = parsed?.quote,
|
||||
url = parsed?.url,
|
||||
prefix = parsed?.prefix,
|
||||
suffix = parsed?.suffix,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
ShareTarget.DIRECT_MESSAGE ->
|
||||
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
|
||||
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
|
||||
}
|
||||
|
||||
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
|
||||
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
|
||||
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
|
||||
|
||||
ShareTarget.NEW_POST ->
|
||||
if (!isBaseRoute<Route.NewShortNote>(nav.controller) && (message != null || attachment != null)) {
|
||||
nav.newStack(Route.NewShortNote(message = message, attachment = attachment))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val uri = intent.data?.toString()
|
||||
|
||||
+54
-5
@@ -20,10 +20,31 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
/** Which entry of Android's share sheet the user picked. */
|
||||
enum class ShareTarget {
|
||||
/** The default "New Post" target: a kind-1 note with the shared text/media. */
|
||||
NEW_POST,
|
||||
|
||||
/** "Send as DM": a NIP-17 private message. */
|
||||
DIRECT_MESSAGE,
|
||||
|
||||
/** "New Highlight": a NIP-84 highlight of the shared passage. */
|
||||
HIGHLIGHT,
|
||||
|
||||
/** "New Picture": a NIP-68 picture post, straight into the picture feed. */
|
||||
PICTURE,
|
||||
|
||||
/** "New Short": a NIP-71 kind 22 video, straight into the Shorts feed. */
|
||||
SHORT_VIDEO,
|
||||
|
||||
/** "New Video": a NIP-71 video, straight into the Video feed. */
|
||||
VIDEO,
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguishes the extra share targets ("Send as DM", "New Highlight") from the default
|
||||
* "New Post" share target. Every SEND intent-filter resolves to MainActivity; they are told
|
||||
* apart by the component class name of the launching intent (the activity-alias name).
|
||||
* Tells the share targets apart. Every SEND intent-filter resolves to MainActivity; which target
|
||||
* the user picked is only visible in the component class name of the launching intent (the
|
||||
* `<activity-alias>` name), so each alias below maps to the composer it opens.
|
||||
*/
|
||||
object ShareIntentRouting {
|
||||
/**
|
||||
@@ -41,7 +62,35 @@ object ShareIntentRouting {
|
||||
*/
|
||||
const val SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME = "ShareAsHighlightAlias"
|
||||
|
||||
fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
|
||||
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
|
||||
const val SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME = "ShareAsPictureAlias"
|
||||
|
||||
fun isShareAsHighlight(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME") == true
|
||||
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
|
||||
const val SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsShortVideoAlias"
|
||||
|
||||
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
|
||||
const val SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsVideoAlias"
|
||||
|
||||
private val TARGET_BY_ALIAS =
|
||||
mapOf(
|
||||
SHARE_AS_DM_ALIAS_SIMPLE_NAME to ShareTarget.DIRECT_MESSAGE,
|
||||
SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME to ShareTarget.HIGHLIGHT,
|
||||
SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME to ShareTarget.PICTURE,
|
||||
SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.SHORT_VIDEO,
|
||||
SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.VIDEO,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves the launching component to its target. Flavors can change the resolved package
|
||||
* prefix, so the match is on the simple name; anything that isn't one of our aliases (chiefly
|
||||
* MainActivity itself) is the default New Post target.
|
||||
*/
|
||||
fun targetOf(componentClassName: String?): ShareTarget {
|
||||
if (componentClassName == null) return ShareTarget.NEW_POST
|
||||
|
||||
return TARGET_BY_ALIAS.entries
|
||||
.firstOrNull { componentClassName.endsWith(".${it.key}") }
|
||||
?.value
|
||||
?: ShareTarget.NEW_POST
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -112,7 +112,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
id = NavBarItem.VIDEO,
|
||||
labelRes = R.string.route_video,
|
||||
icon = MaterialSymbols.Subscriptions,
|
||||
resolveRoute = { Route.Video },
|
||||
resolveRoute = { Route.Video() },
|
||||
),
|
||||
NavBarItem.DISCOVER to
|
||||
NavBarItemDef(
|
||||
@@ -231,7 +231,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
id = NavBarItem.PICTURES,
|
||||
labelRes = R.string.pictures,
|
||||
icon = MaterialSymbols.Photo,
|
||||
resolveRoute = { Route.Pictures },
|
||||
resolveRoute = { Route.Pictures() },
|
||||
),
|
||||
NavBarItem.WORKOUTS to
|
||||
NavBarItemDef(
|
||||
@@ -308,7 +308,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
id = NavBarItem.SHORTS,
|
||||
labelRes = R.string.shorts,
|
||||
icon = MaterialSymbols.PlayCircle,
|
||||
resolveRoute = { Route.Shorts },
|
||||
resolveRoute = { Route.Shorts() },
|
||||
),
|
||||
NavBarItem.MUSIC_TRACKS to
|
||||
NavBarItemDef(
|
||||
|
||||
@@ -30,12 +30,32 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* A feed whose composer can be pre-loaded from an Android share. [attachments] holds the shared
|
||||
* content URIs and [message] the text sent alongside them; both are empty/null for every other way
|
||||
* of reaching the feed, which is how the share flow tells its own entries apart from an ordinary
|
||||
* bottom-bar visit.
|
||||
*/
|
||||
sealed interface MediaFeedRoute {
|
||||
val attachments: List<String>
|
||||
val message: String?
|
||||
}
|
||||
|
||||
sealed class Route {
|
||||
@Serializable object Home : Route()
|
||||
|
||||
@Serializable object Message : Route()
|
||||
|
||||
@Serializable object Video : Route()
|
||||
/**
|
||||
* The mixed media feed. [attachments] and [message] are set by the "New Video" share target: the content
|
||||
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
|
||||
* composer and its caption. Empty for every other way of reaching the feed.
|
||||
*/
|
||||
@Serializable data class Video(
|
||||
override val attachments: List<String> = emptyList(),
|
||||
override val message: String? = null,
|
||||
) : Route(),
|
||||
MediaFeedRoute
|
||||
|
||||
@Serializable data class Discover(
|
||||
val initialTab: DiscoverTab? = null,
|
||||
@@ -81,7 +101,16 @@ sealed class Route {
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object Pictures : Route()
|
||||
/**
|
||||
* The picture feed. [attachments] and [message] are set by the "New Picture" share target: the content
|
||||
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
|
||||
* composer and its caption. Empty for every other way of reaching the feed.
|
||||
*/
|
||||
@Serializable data class Pictures(
|
||||
override val attachments: List<String> = emptyList(),
|
||||
override val message: String? = null,
|
||||
) : Route(),
|
||||
MediaFeedRoute
|
||||
|
||||
@Serializable object Workouts : Route()
|
||||
|
||||
@@ -175,7 +204,16 @@ sealed class Route {
|
||||
|
||||
@Serializable object Products : Route()
|
||||
|
||||
@Serializable object Shorts : Route()
|
||||
/**
|
||||
* The short-video feed. [attachments] and [message] are set by the "New Short" share target: the content
|
||||
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
|
||||
* composer and its caption. Empty for every other way of reaching the feed.
|
||||
*/
|
||||
@Serializable data class Shorts(
|
||||
override val attachments: List<String> = emptyList(),
|
||||
override val message: String? = null,
|
||||
) : Route(),
|
||||
MediaFeedRoute
|
||||
|
||||
@Serializable object PublicChats : Route()
|
||||
|
||||
@@ -741,6 +779,9 @@ sealed class Route {
|
||||
|
||||
@Serializable data class RelayGroupCreate(
|
||||
val relayUrl: String,
|
||||
// Buzz only: start the create flow on a `forum` channel (threaded posts) instead of a
|
||||
// `stream` (chat) one — set by the community screen's per-section "+" buttons.
|
||||
val isForum: Boolean = false,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupEdit(
|
||||
|
||||
@@ -327,10 +327,7 @@ import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
@@ -1514,19 +1511,9 @@ private fun RenderNoteRow(
|
||||
FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel)
|
||||
}
|
||||
|
||||
is VideoHorizontalEvent -> {
|
||||
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is VideoVerticalEvent -> {
|
||||
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is VideoNormalEvent -> {
|
||||
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is VideoShortEvent -> {
|
||||
// Covers every NIP-71 video kind (21, 22, 34235, 34236) via the shared interface,
|
||||
// so new video subtypes render automatically instead of falling through to text.
|
||||
is VideoEvent -> {
|
||||
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
|
||||
}
|
||||
|
||||
|
||||
+41
-7
@@ -28,6 +28,7 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
@@ -41,6 +42,9 @@ import com.vitorpamplona.amethyst.R
|
||||
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.ui.components.UrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard
|
||||
import com.vitorpamplona.amethyst.ui.components.rememberUrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -52,6 +56,14 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
|
||||
|
||||
/**
|
||||
* The normalized scope (e.g. [ExternalId.toScope]) of the external content a screen is
|
||||
* currently dedicated to, if any. A screen built entirely around one external scope (e.g.
|
||||
* the URL thread screen) provides this so nested comments sharing that same scope don't
|
||||
* redundantly repeat the preview the screen itself already shows.
|
||||
*/
|
||||
val LocalCurrentExternalScope = staticCompositionLocalOf<String?> { null }
|
||||
|
||||
@Composable
|
||||
fun DisplayExternalId(
|
||||
externalId: ExternalId,
|
||||
@@ -68,7 +80,7 @@ fun DisplayExternalId(
|
||||
}
|
||||
|
||||
is UrlId -> {
|
||||
DisplayUrlExternalId(externalId, nav)
|
||||
DisplayUrlExternalId(externalId, accountViewModel, nav)
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -80,14 +92,36 @@ fun DisplayExternalId(
|
||||
@Composable
|
||||
fun DisplayUrlExternalId(
|
||||
externalId: UrlId,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
DisplayExternalIdChip(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = stringRes(id = R.string.external_url_scope),
|
||||
label = externalId.url,
|
||||
linkInteractionListener = { nav.nav(Route.Url(externalId.url)) },
|
||||
)
|
||||
val url = externalId.url
|
||||
val chip =
|
||||
@Composable {
|
||||
DisplayExternalIdChip(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = stringRes(id = R.string.external_url_scope),
|
||||
label = url,
|
||||
linkInteractionListener = { nav.nav(Route.Url(url)) },
|
||||
)
|
||||
}
|
||||
|
||||
// Respect the data-saver/privacy gate: fetching the preview reaches out to the
|
||||
// third-party page, so when previews are off this stays a plain link chip.
|
||||
if (!accountViewModel.settings.showUrlPreview()) {
|
||||
chip()
|
||||
return
|
||||
}
|
||||
|
||||
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
UrlPreviewCard(url, state.previewInfo, onCardClick = { nav.nav(Route.Url(url)) })
|
||||
}
|
||||
|
||||
else -> {
|
||||
chip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+18
-3
@@ -49,13 +49,16 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayNameChip
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
|
||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/**
|
||||
@@ -100,7 +103,14 @@ fun RenderRelayGroupMessage(
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact, tappable header naming the Buzz group (or DM participant) a message belongs to. */
|
||||
/**
|
||||
* A compact, tappable header naming the Buzz group (or DM participant) a message belongs to, plus a
|
||||
* [RelayNameChip] naming its host relay — the same pairing the Messages row uses, and the NIP-29
|
||||
* analog of the [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill]
|
||||
* a Concord message wears on these same cards. A group id is only unique within its host relay, so
|
||||
* without the relay a notification from `#general` doesn't say *which* `#general` it came from. The
|
||||
* chip taps through to the relay's channel list, while the name/avatar open the room itself.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupChannelHeader(
|
||||
channel: RelayGroupChannel,
|
||||
@@ -129,16 +139,21 @@ fun RelayGroupChannelHeader(
|
||||
)
|
||||
|
||||
if (dmOther != null) {
|
||||
RelayGroupDmName(dmOther, channel, accountViewModel, Modifier.weight(1f))
|
||||
RelayGroupDmName(dmOther, channel, accountViewModel, Modifier.weight(1f, fill = false))
|
||||
} else {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
|
||||
RelayNameChip(
|
||||
label = channel.groupId.relayUrl.displayUrl(),
|
||||
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
|
||||
@@ -142,9 +143,11 @@ fun RenderTextEvent(
|
||||
}
|
||||
} else if (!makeItShort && noteEvent is CommentEvent) {
|
||||
// A comment scoped to an external identifier (`I` tag) has no in-cache parent
|
||||
// note. Show the scope itself as the reply context.
|
||||
// note. Show the scope itself as the reply context -- unless the screen we're
|
||||
// in is already dedicated to this exact scope (e.g. the URL thread screen),
|
||||
// in which case every row would otherwise redundantly repeat the same preview.
|
||||
val scope = remember(note) { noteEvent.scope() }
|
||||
if (scope != null) {
|
||||
if (scope != null && scope.toScope() != LocalCurrentExternalScope.current) {
|
||||
DisplayExternalId(scope, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
|
||||
+25
@@ -1675,6 +1675,15 @@ class AccountViewModel(
|
||||
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
|
||||
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) }
|
||||
|
||||
/**
|
||||
* Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without
|
||||
* destroying it, and is reversible. Owner/admin only; the relay enforces it.
|
||||
*/
|
||||
fun archiveRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
archived: Boolean,
|
||||
) = launchSigner { account.archiveRelayGroup(channel, archived) }
|
||||
|
||||
/**
|
||||
* Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops
|
||||
* showing, but send no kind-9022 — I stay in the relay roster and can still read/post, and re-joining
|
||||
@@ -1708,6 +1717,22 @@ class AccountViewModel(
|
||||
*/
|
||||
fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel)
|
||||
|
||||
/**
|
||||
* Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay
|
||||
* republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it.
|
||||
*/
|
||||
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) }
|
||||
|
||||
/**
|
||||
* Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with
|
||||
* the same [participants] (a kind-41010 resolving to the same canonical channel), which drops it
|
||||
* from the 30622 hidden snapshot.
|
||||
*/
|
||||
fun unhideBuzzDm(
|
||||
relay: NormalizedRelayUrl,
|
||||
participants: List<HexKey>,
|
||||
) = launchSigner { account.openBuzzDm(relay, participants) }
|
||||
|
||||
/**
|
||||
* Keep the channel off Messages without touching membership. Local and reversible — I stay in the
|
||||
* roster and can still open and post; [leaveChannelInvite] is the one that actually removes me.
|
||||
|
||||
+6
-1
@@ -608,7 +608,12 @@ class GroupEventHandler(
|
||||
return
|
||||
}
|
||||
if (!manager.isMember(groupId)) {
|
||||
Log.w("MarmotDbg") {
|
||||
// Debug, not warn: relays serve kind:445 for every group they carry, so traffic for
|
||||
// groups we are not in is the expected steady state, not an anomaly — unlike the null
|
||||
// manager and missing 'h' tag above, which stay warnings because they mean we cannot
|
||||
// process a group we *should* be handling. Fires ~22 times a boot (the same handful of
|
||||
// events, re-offered on each pass), which drowns the two real warnings next to it.
|
||||
Log.d("MarmotDbg") {
|
||||
"GroupEventHandler.add: not a member of group=${groupId.take(8)}… — dropping kind:445 ${event.id.take(8)}…"
|
||||
}
|
||||
return
|
||||
|
||||
+22
-119
@@ -32,16 +32,11 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -63,48 +58,39 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordAuthorFacepile
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordUnreadBadge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.recentAuthorHexes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelUnreadCountFlow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/** How many recent-poster avatars a channel row's facepile shows at most. */
|
||||
private const val FACEPILE_MAX = 4
|
||||
|
||||
/** A first screen's worth of recent messages to prefetch per visible card, so previews fill in. */
|
||||
private const val CARD_WARMUP_LIMIT = 10
|
||||
|
||||
/**
|
||||
* One channel row in a Buzz workspace's community view: a channel the user is a member of (via
|
||||
* kind-44100), rendered like the Concord server view — a colored monogram, the channel name with a
|
||||
* recent-posters facepile, a preview of the last message (author + snippet, or the Buzz activity
|
||||
* summary for system/diff/job rows), the relative time of that message, and an unread-count badge.
|
||||
* Tapping the card opens the channel ([onOpen]); the trailing overflow (3-dot) menu holds the
|
||||
* per-channel actions — Pin/Unpin and the Add/Remove-from-Messages toggle ([isAdded] says which half
|
||||
* to show, and it must come from the live kind-10009 list) — so the row stays clean.
|
||||
* kind-44100), rendered like the Concord server view — a colored monogram, the channel name with the
|
||||
* last message's relative time, and below it a preview of the last message (author + snippet, or the
|
||||
* Buzz activity summary for system/diff/job rows) with an unread-count badge.
|
||||
* Tapping the card opens the channel ([onOpen]); the row itself is a clean tap-to-open target — its
|
||||
* per-channel actions (Pin/Unpin, Add/Remove-from-Messages) live in the opened channel's/forum's
|
||||
* top-bar overflow, not on the row. A pinned channel still shows a pin marker here ([isStarred]).
|
||||
*
|
||||
* Reused by the relay group-list screen where Buzz membership discovery is folded in.
|
||||
*
|
||||
* [showActivityPreview] gates the chat-activity machinery — the recent-message warmup, the
|
||||
* last-message preview, the recent-posters facepile and the unread badge. Enable it for **chat**
|
||||
* channels (whose content lives in [RelayGroupChannel.notes]); leave it off for **forum** channels,
|
||||
* whose posts are threads (a separate store), so the row doesn't open a kind-9 chat subscription that
|
||||
* would return nothing and drives a member-count summary instead.
|
||||
* last-message preview and the unread badge. Enable it for **chat** channels (whose content lives in
|
||||
* [RelayGroupChannel.notes]); leave it off for **forum** channels, whose posts are threads (a
|
||||
* separate store), so the row doesn't open a kind-9 chat subscription that would return nothing and
|
||||
* drives a member-count summary instead.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzImportRow(
|
||||
groupId: GroupId,
|
||||
isAdded: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
onOpen: (() -> Unit)? = null,
|
||||
isStarred: Boolean = false,
|
||||
onToggleStar: (() -> Unit)? = null,
|
||||
showActivityPreview: Boolean = true,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
@@ -131,11 +117,10 @@ fun BuzzImportRow(
|
||||
val memberCount = channel.memberCount()
|
||||
val isPrivate = channel.isPrivate()
|
||||
|
||||
// The channel's own notes flow drives the preview/facepile so they update the moment a message
|
||||
// folds in, independent of the metadata-scoped [observeChannel] above. Only collected for chat
|
||||
// channels; a forum row shows a member-count summary with no facepile/unread.
|
||||
// The channel's own notes flow drives the preview so it updates the moment a message folds in,
|
||||
// independent of the metadata-scoped [observeChannel] above. Only collected for chat channels; a
|
||||
// forum row shows a member-count summary with no unread.
|
||||
val lastNote: Note?
|
||||
val faceAuthors: List<String>
|
||||
val unread: Int
|
||||
if (showActivityPreview) {
|
||||
val notesState by channel
|
||||
@@ -143,14 +128,12 @@ fun BuzzImportRow(
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
lastNote = remember(notesState) { channel.newestTimelineNote(account) }
|
||||
faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) }
|
||||
unread =
|
||||
remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) }
|
||||
.collectAsStateWithLifecycle(0)
|
||||
.value
|
||||
} else {
|
||||
lastNote = null
|
||||
faceAuthors = emptyList()
|
||||
unread = 0
|
||||
}
|
||||
val hasUnread = unread > 0
|
||||
@@ -163,14 +146,9 @@ fun BuzzImportRow(
|
||||
isPrivate = isPrivate,
|
||||
memberCount = memberCount,
|
||||
lastNote = lastNote,
|
||||
faceAuthors = faceAuthors,
|
||||
unread = unread,
|
||||
hasUnread = hasUnread,
|
||||
isAdded = isAdded,
|
||||
isStarred = isStarred,
|
||||
onToggleStar = onToggleStar,
|
||||
onAdd = onAdd,
|
||||
onRemove = onRemove,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -192,25 +170,20 @@ private fun BuzzImportRowContent(
|
||||
isPrivate: Boolean,
|
||||
memberCount: Int,
|
||||
lastNote: Note?,
|
||||
faceAuthors: List<String>,
|
||||
unread: Int,
|
||||
hasUnread: Boolean,
|
||||
isAdded: Boolean,
|
||||
isStarred: Boolean,
|
||||
onToggleStar: (() -> Unit)?,
|
||||
onAdd: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp),
|
||||
modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BuzzImportAvatar(name = name, seed = seed)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
// Line 1: a lock (private), the channel name, a pin marker (starred), and the recent-
|
||||
// posters facepile pushed to the right.
|
||||
// Line 1: a lock (private), the channel name, a pin marker (starred), and the last-message
|
||||
// time pushed to the right.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (isPrivate) {
|
||||
Icon(
|
||||
@@ -236,13 +209,6 @@ private fun BuzzImportRowContent(
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
ConcordAuthorFacepile(faceAuthors, accountViewModel)
|
||||
}
|
||||
// Line 2: the last-message preview, then the time + unread badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel)
|
||||
}
|
||||
lastNote?.createdAt()?.let { ts ->
|
||||
Text(
|
||||
timeAgo(ts, LocalContext.current, prefix = ""),
|
||||
@@ -251,16 +217,15 @@ private fun BuzzImportRowContent(
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Line 2: the last-message preview, then the unread-message badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel)
|
||||
}
|
||||
ConcordUnreadBadge(unread)
|
||||
}
|
||||
}
|
||||
BuzzChannelRowMenu(
|
||||
isAdded = isAdded,
|
||||
onAdd = onAdd,
|
||||
onRemove = onRemove,
|
||||
isStarred = isStarred,
|
||||
onToggleStar = onToggleStar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,68 +273,6 @@ private fun BuzzChannelPreviewLine(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-channel overflow (3-dot) menu: Pin/Unpin and Add-to-my-list. Moved off the row itself so a
|
||||
* channel card reads as a clean Concord-style row, with its actions one tap behind the kebab.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuzzChannelRowMenu(
|
||||
isAdded: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
isStarred: Boolean,
|
||||
onToggleStar: (() -> Unit)?,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MoreVert,
|
||||
contentDescription = stringRes(R.string.more_options),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
if (onToggleStar != null) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PushPin,
|
||||
contentDescription = null,
|
||||
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onToggleStar()
|
||||
},
|
||||
)
|
||||
}
|
||||
// A toggle, not a one-way "Added" badge: a channel already on the kind-10009 list offers
|
||||
// the way back off it. Neither half touches the relay roster, so the channel stays in this
|
||||
// list (and readable) either way — only whether it shows on Messages changes.
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = if (isAdded) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
if (isAdded) onRemove() else onAdd()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A round monogram whose color is derived deterministically from the channel id. */
|
||||
@Composable
|
||||
private fun BuzzImportAvatar(
|
||||
|
||||
+34
-5
@@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -154,22 +156,49 @@ class BuzzRelayImportViewModel : ViewModel() {
|
||||
false
|
||||
}
|
||||
|
||||
// 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load.
|
||||
// 2. Fetch each channel's NIP-29 metadata (39000-39003, `#d`-scoped) so its name + Buzz
|
||||
// `t` type load, AND the relay's kind-40099 system messages (`#h`-scoped) so a
|
||||
// `channel_deleted` one is seen. The relay soft-deletes a deleted channel's 39000
|
||||
// and never retracts the kind-44100 that seeded `channelIds`, so without pulling the
|
||||
// 40099 a deleted channel — its metadata now blank — would show optimistically here
|
||||
// forever. LocalCache records the delete into RelayGroupDeletions on consume.
|
||||
if (channelIds.isNotEmpty()) {
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))),
|
||||
filters =
|
||||
mapOf(
|
||||
relay to
|
||||
listOf(
|
||||
Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())),
|
||||
Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())),
|
||||
),
|
||||
),
|
||||
timeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { _, _ -> false }
|
||||
}
|
||||
|
||||
// 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived
|
||||
// (type unknown) is optimistically shown as a workspace channel.
|
||||
// 3. Keep only the non-DM workspace channels the relay still serves metadata for.
|
||||
//
|
||||
// A deleted (or never-really-there) channel keeps its kind-44100 — the relay never
|
||||
// retracts it — but the relay soft-deletes its 39000, so it arrives here with NO
|
||||
// metadata. That absence is the reliable "it's gone" signal (the relay does not serve
|
||||
// a `channel_deleted` 40099 for an already-deleted channel, so the fetch above can't
|
||||
// catch this case). Before, such a channel showed optimistically — as a bare UUID row
|
||||
// with "No messages yet", which is exactly the leak reported.
|
||||
//
|
||||
// So drop channels with no metadata — but ONLY when the fetch clearly worked (at least
|
||||
// one channel came back with a 39000). If none did, the read failed (auth/connectivity)
|
||||
// and we keep the whole set rather than blank the community; a genuinely new channel
|
||||
// whose 39000 is merely slow re-appears on the next bind once its metadata is cached.
|
||||
val channels = channelIds.associateWith { LocalCache.getOrCreateRelayGroupChannel(GroupId(it, relay)) }
|
||||
val fetchHadMetadata = channels.values.any { it.event != null }
|
||||
_channels.value =
|
||||
channelIds
|
||||
.mapNotNull { id ->
|
||||
val groupId = GroupId(id, relay)
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
|
||||
if (RelayGroupDeletions.isDeleted(groupId)) return@mapNotNull null
|
||||
val channel = channels.getValue(id)
|
||||
if (fetchHadMetadata && channel.event == null) return@mapNotNull null
|
||||
if (channel.event?.isBuzzDm() == true) null else groupId
|
||||
}.sortedBy { it.id }
|
||||
_status.value = Status.Ready
|
||||
|
||||
+9
-15
@@ -46,7 +46,6 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -74,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader
|
||||
@@ -223,7 +223,7 @@ fun ConcordChannelListScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
ShorterTopAppBar(
|
||||
title = { Text(communityName, maxLines = 1) },
|
||||
navigationIcon = {
|
||||
// Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place.
|
||||
@@ -403,8 +403,6 @@ private fun ConcordChannelListRow(
|
||||
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
|
||||
.collectAsStateWithLifecycle(0)
|
||||
val hasUnread = unread > 0
|
||||
// The recent posters' faces — recomputed as the channel's notes change (keyed on channelState).
|
||||
val faceAuthors = remember(channelState) { channel.recentAuthorHexes(FACEPILE_MAX) }
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
@@ -421,7 +419,7 @@ private fun ConcordChannelListRow(
|
||||
tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
// Line 1: channel name + the recent-posters facepile pushed to the right.
|
||||
// Line 1: channel name + the last-message time pushed to the right.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
channelName,
|
||||
@@ -431,13 +429,6 @@ private fun ConcordChannelListRow(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
ConcordAuthorFacepile(faceAuthors, accountViewModel)
|
||||
}
|
||||
// Line 2: the last-message preview (or a live "typing…"), then the time + unread badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
|
||||
}
|
||||
lastNote?.createdAt()?.let { ts ->
|
||||
Text(
|
||||
timeAgo(ts, LocalContext.current, prefix = ""),
|
||||
@@ -446,6 +437,12 @@ private fun ConcordChannelListRow(
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Line 2: the last-message preview (or a live "typing…"), then the unread-message badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
|
||||
}
|
||||
ConcordUnreadBadge(unread)
|
||||
}
|
||||
}
|
||||
@@ -458,9 +455,6 @@ private fun ConcordChannelListRow(
|
||||
}
|
||||
}
|
||||
|
||||
/** How many recent-poster avatars a channel row's facepile shows at most. */
|
||||
private const val FACEPILE_MAX = 4
|
||||
|
||||
/**
|
||||
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
|
||||
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* A horizontal strip of the recent posters in a channel — the "who's here" cue that makes a busy
|
||||
* channel feel alive. Each poster is drawn with the app's standard profile avatar
|
||||
* ([ClickableUserPicture]), so it carries the same following badge (top-right) and trust-score tag
|
||||
* (bottom-centre) shown everywhere else a user appears, instead of a bare cropped image. Laid out
|
||||
* with a small gap rather than an overlapping stack so those badges stay readable; the newest poster
|
||||
* is leftmost. Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordAuthorFacepile(
|
||||
authorHexes: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
avatarSize: Dp = 24.dp,
|
||||
maxShown: Int = 4,
|
||||
) {
|
||||
if (authorHexes.isEmpty()) return
|
||||
val shown = authorHexes.take(maxShown)
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
shown.forEach { hex ->
|
||||
ClickableUserPicture(
|
||||
baseUserHex = hex,
|
||||
size = avatarSize,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -39,7 +39,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -68,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
|
||||
@@ -115,7 +115,7 @@ fun ConcordHomeScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
ShorterTopAppBar(
|
||||
title = { Text(stringRes(R.string.concord_home_title)) },
|
||||
navigationIcon = {
|
||||
// Back arrow only when this is a pushed screen (from the drawer / a deep link);
|
||||
|
||||
-20
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -135,22 +134,3 @@ private fun ConcordChannel.newMessagesSince(
|
||||
notes.count { _, note ->
|
||||
(note.createdAt() ?: 0L) > sinceSecs && isConcordTimelineMessage(note, account)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the
|
||||
* facepile shown on a channel row. One O(notes) pass keeps each author's latest post time, so a
|
||||
* chatty author counts once (at their newest message) rather than crowding out quieter voices.
|
||||
*/
|
||||
fun ConcordChannel.recentAuthorHexes(limit: Int): List<HexKey> {
|
||||
val latestByAuthor = HashMap<HexKey, Long>()
|
||||
for (note in notes.values()) {
|
||||
val author = note.author?.pubkeyHex ?: continue
|
||||
val at = note.createdAt() ?: continue
|
||||
val prev = latestByAuthor[author]
|
||||
if (prev == null || at > prev) latestByAuthor[author] = at
|
||||
}
|
||||
return latestByAuthor.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { it.key }
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel
|
||||
* list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean
|
||||
* tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzPinDropdownItem(
|
||||
groupId: GroupId,
|
||||
closeMenu: () -> Unit,
|
||||
) {
|
||||
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
|
||||
val isStarred = groupId.id in starred
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PushPin,
|
||||
contentDescription = null,
|
||||
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
BuzzChannelStars.toggle(groupId.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/Remove this relay-group [channel] from my kind-10009 list (whether it shows in Messages). A
|
||||
* reversible toggle that never touches my relay membership — same split as "Leave" — so it stays
|
||||
* readable either way. Reads the live kind-10009 list so it flips as the change lands. Moved off the
|
||||
* per-channel list row into the opened screen's top-bar overflow.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupMessagesDropdownItem(
|
||||
channel: RelayGroupChannel,
|
||||
accountViewModel: AccountViewModel,
|
||||
closeMenu: () -> Unit,
|
||||
) {
|
||||
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
|
||||
.collectAsStateWithLifecycle()
|
||||
val onMyList = channel.groupId in joinedGroupIds
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) },
|
||||
onClick = {
|
||||
closeMenu()
|
||||
if (onMyList) {
|
||||
accountViewModel.removeRelayGroupFromMessages(channel)
|
||||
} else {
|
||||
accountViewModel.addRelayGroupToMessages(channel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+231
-107
@@ -36,8 +36,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -47,6 +45,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
@@ -73,7 +72,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
|
||||
@@ -100,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -114,9 +114,37 @@ import kotlinx.coroutines.launch
|
||||
/** A first screen's worth of recent messages to prefetch per visible group card, ahead of a tap. */
|
||||
private const val CHANNEL_LIST_WARMUP_LIMIT = 10
|
||||
|
||||
/** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */
|
||||
/**
|
||||
* How long this relay's socket must stay down before the Tor→clearnet escape hatch is offered, so a
|
||||
* slow first connect (or a reconnect) isn't mistaken for a relay that blocks Tor exits.
|
||||
*/
|
||||
private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L
|
||||
|
||||
/**
|
||||
* Whether to offer the Tor→clearnet escape hatch for this relay ([TorClearnetBanner]).
|
||||
*
|
||||
* The banner accuses *this relay* of blocking Tor exits and offers a privacy downgrade to work
|
||||
* around it, so it has to rule out every other explanation — and be able to deliver:
|
||||
*
|
||||
* - [usesTor]: this relay is *actually* routed over Tor right now (`TorRelayEvaluation.useTor`, the
|
||||
* same predicate the pool dials with). Tor being enabled globally says nothing about one relay —
|
||||
* onion, localhost and the per-role presets (trusted / DM / new) each decide independently.
|
||||
* - [torIsUp]: the SOCKS proxy is Active. While Tor is still bootstrapping *nothing* Tor-routed
|
||||
* connects, which is Tor's problem (and its own dialog's), not this relay's.
|
||||
* - [trustingMovesToClearnet]: the action on offer — adding the relay to the kind-10089 Trusted list —
|
||||
* would actually change its routing. Under a preset that keeps trusted relays on Tor it wouldn't,
|
||||
* and the button would be inert.
|
||||
* - [disconnectedLongEnough]: the socket has been continuously down for [TOR_CLEARNET_HINT_DELAY_MS].
|
||||
* A relay that answers is reachable by definition, and one that merely reconnects is not a relay
|
||||
* that blocks Tor — only a sustained silence is.
|
||||
*/
|
||||
internal fun shouldOfferTorClearnetFallback(
|
||||
usesTor: Boolean,
|
||||
torIsUp: Boolean,
|
||||
trustingMovesToClearnet: Boolean,
|
||||
disconnectedLongEnough: Boolean,
|
||||
): Boolean = usesTor && torIsUp && trustingMovesToClearnet && disconnectedLongEnough
|
||||
|
||||
/**
|
||||
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
|
||||
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
|
||||
@@ -172,6 +200,11 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Channels this device has deleted (kind-9008). A delete is terminal — the relay drops the group —
|
||||
// but our cached 39000 (and a Buzz relay's stale re-announced 44100) would keep it in the list, so
|
||||
// filter them out everywhere below. Collected as a StateFlow so a delete removes the row live.
|
||||
val deletedChannels by RelayGroupDeletions.flow.collectAsStateWithLifecycle()
|
||||
|
||||
// Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`).
|
||||
// Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if
|
||||
// NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while
|
||||
@@ -179,20 +212,22 @@ fun RelayGroupChannelListScreen(
|
||||
// its de-facto signer — so its relay-signed groups still show while a stray user-published 39000
|
||||
// (a different author) stays filtered.
|
||||
val channels =
|
||||
remember(allChannels, relayInfo) {
|
||||
remember(allChannels, relayInfo, deletedChannels) {
|
||||
val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null
|
||||
if (nip11Known) {
|
||||
allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) }
|
||||
} else {
|
||||
val dominantSigner =
|
||||
allChannels
|
||||
.mapNotNull { it.event?.pubKey }
|
||||
.groupingBy { it }
|
||||
.eachCount()
|
||||
.maxByOrNull { it.value }
|
||||
?.key
|
||||
if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels
|
||||
}
|
||||
val signed =
|
||||
if (nip11Known) {
|
||||
allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) }
|
||||
} else {
|
||||
val dominantSigner =
|
||||
allChannels
|
||||
.mapNotNull { it.event?.pubKey }
|
||||
.groupingBy { it }
|
||||
.eachCount()
|
||||
.maxByOrNull { it.value }
|
||||
?.key
|
||||
if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels
|
||||
}
|
||||
signed.filterNot { it.groupId.toKey() in deletedChannels }
|
||||
}
|
||||
|
||||
// Buzz relays expose no public group directory (membership is server-side), so `channels` above
|
||||
@@ -200,6 +235,15 @@ fun RelayGroupChannelListScreen(
|
||||
// (kind-44100) so browsing the relay lists the channels you already belong to — each addable to
|
||||
// your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse.
|
||||
val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true
|
||||
|
||||
// A Buzz relay lets any community member create a channel/forum (the creator becomes its owner),
|
||||
// and the relay rejects a non-member's kind-9007. We don't hard-gate the "+" on membership: the
|
||||
// NIP-43 roster (kind 13534) isn't fetched on this screen, so gating on it hid the "+" even from
|
||||
// admins. Instead we offer it on any Buzz community and let the relay enforce — the same approach
|
||||
// as the workspace overflow menu (Add people / Invite), whose own doc notes "any member sees them,
|
||||
// the relay only serves the owner/admin ones."
|
||||
val myPubkey = accountViewModel.account.signer.pubKey
|
||||
|
||||
val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}")
|
||||
LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) }
|
||||
val buzzChannels by buzzVm.channels.collectAsStateWithLifecycle()
|
||||
@@ -226,9 +270,13 @@ fun RelayGroupChannelListScreen(
|
||||
// ids so nothing the old flat list showed disappears.
|
||||
val channelsById = remember(allChannels) { allChannels.associateBy { it.groupId.id } }
|
||||
val buzzGroupIds =
|
||||
remember(buzzChannels, channels) {
|
||||
remember(buzzChannels, channels, deletedChannels) {
|
||||
val seen = LinkedHashSet<String>()
|
||||
(buzzChannels + channels.map { it.groupId }).filter { seen.add(it.id) }
|
||||
// `channels` is already delete-filtered; also drop deleted ids from the membership-scoped
|
||||
// `buzzChannels` (kind-44100), which the relay can keep re-announcing after a delete.
|
||||
(buzzChannels + channels.map { it.groupId })
|
||||
.filterNot { it.toKey() in deletedChannels }
|
||||
.filter { seen.add(it.id) }
|
||||
}
|
||||
|
||||
fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType()
|
||||
@@ -245,21 +293,34 @@ fun RelayGroupChannelListScreen(
|
||||
|
||||
fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id
|
||||
|
||||
// Archived channels (relay-signed `archived` tag on the 39000) drop out of their normal section and
|
||||
// gather in a collapsed "Archived" tail — the same hide-from-the-sidebar behavior the Buzz client
|
||||
// has. They stay reachable there so an admin can open one and Unarchive it from the top bar.
|
||||
fun isArchived(groupId: GroupId): Boolean = channelsById[groupId.id]?.isArchived() == true
|
||||
|
||||
val buzzChatChannels =
|
||||
remember(buzzGroupIds, channelsById, starred) {
|
||||
buzzGroupIds
|
||||
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } }
|
||||
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } && !isArchived(it) }
|
||||
.sortedWith(compareByDescending<GroupId> { it.id in starred }.thenBy { buzzSortKey(it) })
|
||||
}
|
||||
val buzzForumChannels =
|
||||
remember(buzzGroupIds, channelsById, starred) {
|
||||
buzzGroupIds
|
||||
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM }
|
||||
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM && !isArchived(it) }
|
||||
.sortedWith(compareByDescending<GroupId> { it.id in starred }.thenBy { buzzSortKey(it) })
|
||||
}
|
||||
// Every archived non-DM channel (chat + forum together), newest section at the bottom.
|
||||
val buzzArchivedChannels =
|
||||
remember(buzzGroupIds, channelsById) {
|
||||
buzzGroupIds
|
||||
.filter { buzzTypeOf(it) != BUZZ_CHANNEL_TYPE_DM && isArchived(it) }
|
||||
.sortedBy { buzzSortKey(it) }
|
||||
}
|
||||
|
||||
// Which sections the user has collapsed (session-scoped). Keyed by section id below.
|
||||
var collapsedSections by remember { mutableStateOf(emptySet<String>()) }
|
||||
// Which sections the user has collapsed (session-scoped). Keyed by section id below. Archived
|
||||
// starts collapsed — it's the out-of-the-way tail, expanded only when someone goes looking.
|
||||
var collapsedSections by remember { mutableStateOf(setOf("archived")) }
|
||||
|
||||
fun toggleSection(key: String) {
|
||||
collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key
|
||||
@@ -271,19 +332,54 @@ fun RelayGroupChannelListScreen(
|
||||
var showAddPeople by remember { mutableStateOf(false) }
|
||||
|
||||
// Tor-failure escape hatch: a Cloudflare-fronted (or otherwise Tor-hostile) relay times out over
|
||||
// Tor. When Tor is on, the relay isn't an onion, it isn't already trusted, and nothing has loaded
|
||||
// after a grace period, offer to reach it over clearnet — which adds it to the kind-10089 Trusted
|
||||
// Relay List (connected over clearnet even while Tor stays on for everything else).
|
||||
val torType by Amethyst.instance.torPrefs.torType
|
||||
.collectAsStateWithLifecycle(TorType.OFF)
|
||||
val isOnion = remember(relay) { relay.url.contains(".onion") }
|
||||
var connectTimedOut by remember(relay) { mutableStateOf(false) }
|
||||
LaunchedEffect(relay) {
|
||||
delay(TOR_CLEARNET_HINT_DELAY_MS)
|
||||
connectTimedOut = true
|
||||
// Tor. Offer to reach it over clearnet — which adds it to the kind-10089 Trusted Relay List
|
||||
// (connected over clearnet even while Tor stays on for everything else).
|
||||
//
|
||||
// Every input below is a live signal, because a grace timer on its own says nothing about the
|
||||
// relay: the banner used to fire on `torType != OFF && !onion && !trusted` plus a 6s delay, so on
|
||||
// a working, answering relay it appeared after six seconds and never went away — the socket state
|
||||
// was never consulted, and neither was whether this relay is Tor-routed at all (Tor being *on*
|
||||
// doesn't mean this relay goes through it; the per-role presets decide).
|
||||
val torEvaluation =
|
||||
Amethyst.instance.torEvaluatorFlow.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
// The same predicate the relay pool itself dials with, so the banner can't claim Tor for a relay
|
||||
// the app is reaching over clearnet (onion / localhost / trusted-off-Tor are all folded in here).
|
||||
val usesTor by remember(relay) { derivedStateOf { torEvaluation.value.useTor(relay) } }
|
||||
|
||||
// While Tor is bootstrapping every Tor-routed relay is silent — that's Tor's own failure (and its
|
||||
// own dialog), so don't let it read as "this relay blocks Tor exits".
|
||||
val torStatus by Amethyst.instance.torManager.status
|
||||
.collectAsStateWithLifecycle()
|
||||
val torIsUp = torStatus is TorServiceStatus.Active
|
||||
|
||||
// The offer adds the relay to the kind-10089 Trusted list, which only moves it to clearnet while
|
||||
// trusted relays are *off* Tor. Under the Small-Payloads / Full-Privacy presets they are on Tor,
|
||||
// so the button would add an entry and change no routing at all — don't offer what can't help.
|
||||
val trustingMovesToClearnet by remember { derivedStateOf { !torEvaluation.value.torSettings.trustedRelaysViaTor } }
|
||||
|
||||
// Global flow (any relay's connect/disconnect re-emits), so derive this relay's boolean.
|
||||
val connectedRelays =
|
||||
accountViewModel.account.client
|
||||
.connectedRelaysFlow()
|
||||
.collectAsStateWithLifecycle()
|
||||
val isConnected by remember(relay) { derivedStateOf { relay in connectedRelays.value } }
|
||||
|
||||
// Debounced on the *connection* rather than armed once per screen: the countdown restarts every
|
||||
// time the socket drops and clears the moment it comes back, so a reconnect can't flash the
|
||||
// banner, and circuits that die mid-session still surface it. Keying the wait on cached content
|
||||
// instead would have hidden the offer exactly when a working relay went dark with a full screen.
|
||||
var disconnectedLongEnough by remember(relay) { mutableStateOf(false) }
|
||||
LaunchedEffect(relay, isConnected) {
|
||||
if (isConnected) {
|
||||
disconnectedLongEnough = false
|
||||
} else {
|
||||
delay(TOR_CLEARNET_HINT_DELAY_MS)
|
||||
disconnectedLongEnough = true
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val showTorHint = torType != TorType.OFF && !isOnion && relay !in trustedRelays && connectTimedOut
|
||||
val showTorHint = shouldOfferTorClearnetFallback(usesTor, torIsUp, trustingMovesToClearnet, disconnectedLongEnough)
|
||||
|
||||
// A pinned relay works both as a pushed detail (from the drawer or another screen) and as a
|
||||
// bottom-nav tab. Read once here (it is @Composable): the back arrow shows only when pushed;
|
||||
@@ -326,17 +422,20 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription =
|
||||
stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
// A Buzz community creates channels/forums from the per-section "+" in their labels (like
|
||||
// Direct Messages), so no FAB there. A vanilla NIP-29 relay is a flat directory with no
|
||||
// sections, so it keeps the FAB to create a group.
|
||||
if (!isBuzz) {
|
||||
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = stringRes(R.string.relay_group_create_title),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
// A Buzz relay is a community, so always render its sectioned list (Channels, Forums, Direct
|
||||
// Messages, Agent Console) even before anything loads, rather than the generic "empty" text.
|
||||
if (channels.isEmpty() && !isBuzz) {
|
||||
@@ -366,9 +465,11 @@ fun RelayGroupChannelListScreen(
|
||||
// overlays content by design, so clearing it is the list's job. As contentPadding (not a
|
||||
// modifier) so rows scroll *through* that strip and only come to rest clear of it; the
|
||||
// modifier form would shrink the viewport and leave the FAB floating over dead space.
|
||||
// Only the vanilla NIP-29 path has a FAB now; a Buzz community creates from its section
|
||||
// headers, so it needs no bottom clearance.
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(bottom = FAB_CLEARANCE),
|
||||
contentPadding = PaddingValues(bottom = if (isBuzz) 0.dp else FAB_CLEARANCE),
|
||||
) {
|
||||
if (showTorHint) {
|
||||
item(key = "tor-hint") {
|
||||
@@ -382,16 +483,15 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
|
||||
if (isBuzz) {
|
||||
// While the membership fetch is still running and nothing has loaded, show a
|
||||
// "Loading…" line. The old "you're not a member — accept the invite in the browser"
|
||||
// empty text is gone: the section labels below now each carry a "+" to create a
|
||||
// channel/forum, so an empty community is a starting point, not a dead end.
|
||||
val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty()
|
||||
if (noChannelsYet) {
|
||||
item(key = "buzz-no-channels") {
|
||||
if (noChannelsYet && buzzStatus is BuzzRelayImportViewModel.Status.Loading) {
|
||||
item(key = "buzz-loading") {
|
||||
Text(
|
||||
text =
|
||||
if (buzzStatus is BuzzRelayImportViewModel.Status.Loading) {
|
||||
stringRes(R.string.buzz_import_loading)
|
||||
} else {
|
||||
stringRes(R.string.buzz_import_empty_body)
|
||||
},
|
||||
text = stringRes(R.string.buzz_import_loading),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
@@ -399,57 +499,61 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu)
|
||||
if (buzzChatChannels.isNotEmpty()) {
|
||||
// -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB
|
||||
// moved here, like Direct Messages). Add-all lives in the top-bar overflow menu.
|
||||
// The header always shows so the "+" is available even before any channel loads;
|
||||
// the collapse toggle is offered only when there's something to collapse.
|
||||
run {
|
||||
val channelsCollapsed = "channels" in collapsedSections
|
||||
item(key = "sec-channels") {
|
||||
RelayGroupSectionHeader(
|
||||
title = stringRes(R.string.relay_group_section_channels),
|
||||
collapsed = channelsCollapsed,
|
||||
onToggle = { toggleSection("channels") },
|
||||
)
|
||||
onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null,
|
||||
) {
|
||||
SectionAddButton(stringRes(R.string.buzz_channel_create_title)) {
|
||||
nav.nav(Route.RelayGroupCreate(relay.url))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!channelsCollapsed) {
|
||||
if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) {
|
||||
itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId ->
|
||||
RowHairline(index)
|
||||
BuzzImportRow(
|
||||
groupId = groupId,
|
||||
isAdded = groupId.id in buzzAdded,
|
||||
onAdd = { buzzVm.add(groupId) },
|
||||
onRemove = { buzzVm.remove(groupId) },
|
||||
accountViewModel = accountViewModel,
|
||||
onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) },
|
||||
isStarred = groupId.id in starred,
|
||||
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- FORUMS --
|
||||
if (buzzForumChannels.isNotEmpty()) {
|
||||
// -- FORUMS -- Same treatment: an always-visible label with a "+" that starts the
|
||||
// create flow on a forum channel (threaded posts) instead of a chat one.
|
||||
run {
|
||||
val forumsCollapsed = "forums" in collapsedSections
|
||||
item(key = "sec-forums") {
|
||||
RelayGroupSectionHeader(
|
||||
title = stringRes(R.string.relay_group_section_forums),
|
||||
collapsed = forumsCollapsed,
|
||||
onToggle = { toggleSection("forums") },
|
||||
)
|
||||
onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null,
|
||||
) {
|
||||
SectionAddButton(stringRes(R.string.buzz_forum_create_title)) {
|
||||
nav.nav(Route.RelayGroupCreate(relay.url, isForum = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!forumsCollapsed) {
|
||||
if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) {
|
||||
itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId ->
|
||||
RowHairline(index)
|
||||
BuzzImportRow(
|
||||
groupId = groupId,
|
||||
isAdded = groupId.id in buzzAdded,
|
||||
onAdd = { buzzVm.add(groupId) },
|
||||
onRemove = { buzzVm.remove(groupId) },
|
||||
accountViewModel = accountViewModel,
|
||||
// A forum channel's primary content is its threads (kind-45001 posts), not a
|
||||
// kind-9 chat, so open the forum/threads view directly instead of the chat.
|
||||
onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) },
|
||||
isStarred = groupId.id in starred,
|
||||
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
|
||||
// Forum posts live in a separate thread store, not the chat notes the
|
||||
// activity preview reads — so don't warm a kind-9 sub that returns nothing.
|
||||
showActivityPreview = false,
|
||||
@@ -458,6 +562,38 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// -- ARCHIVED -- Channels the relay has archived (chat + forum), tucked into a
|
||||
// collapsed tail. Opening one and using the top-bar Unarchive brings it back.
|
||||
if (buzzArchivedChannels.isNotEmpty()) {
|
||||
val archivedCollapsed = "archived" in collapsedSections
|
||||
item(key = "sec-archived") {
|
||||
RelayGroupSectionHeader(
|
||||
title = stringRes(R.string.relay_group_section_archived),
|
||||
collapsed = archivedCollapsed,
|
||||
onToggle = { toggleSection("archived") },
|
||||
)
|
||||
}
|
||||
if (!archivedCollapsed) {
|
||||
itemsIndexed(buzzArchivedChannels, key = { _, it -> "archived-${it.id}" }) { index, groupId ->
|
||||
RowHairline(index)
|
||||
val isForum = buzzTypeOf(groupId) == BUZZ_CHANNEL_TYPE_FORUM
|
||||
BuzzImportRow(
|
||||
groupId = groupId,
|
||||
accountViewModel = accountViewModel,
|
||||
onOpen = {
|
||||
if (isForum) {
|
||||
nav.nav(Route.RelayGroupThreads(groupId.id, relay.url))
|
||||
} else {
|
||||
nav.nav(Route.RelayGroup(groupId.id, relay.url))
|
||||
}
|
||||
},
|
||||
isStarred = groupId.id in starred,
|
||||
showActivityPreview = !isForum,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- DIRECT MESSAGES -- (this community's private conversations, most recent first)
|
||||
item(key = "sec-dms") {
|
||||
RelayGroupSectionHeader(title = stringRes(R.string.buzz_dm_title)) {
|
||||
@@ -488,7 +624,6 @@ fun RelayGroupChannelListScreen(
|
||||
row = row,
|
||||
myPubkey = myPubkey,
|
||||
isHidden = false,
|
||||
onToggleMessages = { dmVm.removeFromMessages(row) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) {
|
||||
@@ -520,7 +655,6 @@ fun RelayGroupChannelListScreen(
|
||||
row = row,
|
||||
myPubkey = myPubkey,
|
||||
isHidden = true,
|
||||
onToggleMessages = { dmVm.addToMessages(row) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) {
|
||||
@@ -640,27 +774,44 @@ private fun RelayGroupSectionHeader(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing "+" for a section label (Channels / Forums), matching the Direct Messages header's
|
||||
* New-message icon: a primary-tinted Add glyph that creates a new item of that section's type.
|
||||
*/
|
||||
@Composable
|
||||
private fun SectionAddButton(
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = contentDescription,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One inline Direct-Message conversation row inside the community view: the counterpart's avatar +
|
||||
* name (or a "+N" cluster label for a group DM), a preview of the last message, a compact
|
||||
* last-activity time, and an overflow holding the Add/Remove-from-Messages toggle. The channel's
|
||||
* recent content is warmed while the row is visible so the preview fills in ahead of a tap. Tapping
|
||||
* opens the DM as its relay-group chat.
|
||||
* name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact
|
||||
* last-activity time. The channel's recent content is warmed while the row is visible so the preview
|
||||
* fills in ahead of a tap. Tapping opens the DM as its relay-group chat; the Add/Remove-from-Messages
|
||||
* (hide/unhide) action lives in that chat screen's top-bar overflow, not on this row.
|
||||
*
|
||||
* [isHidden] renders the row faded and flips the overflow to "Add to Messages" — a hidden DM is a
|
||||
* live conversation the viewer merely parked, so it stays openable and reversible.
|
||||
* [isHidden] renders the row faded — a hidden DM is a live conversation the viewer merely parked, so
|
||||
* it stays openable and reversible from the opened conversation.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuzzDmInlineRow(
|
||||
row: BuzzDmListViewModel.DmRow,
|
||||
myPubkey: HexKey,
|
||||
isHidden: Boolean,
|
||||
onToggleMessages: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val others = row.others.ifEmpty { listOf(myPubkey) }
|
||||
val leadHex = others.first()
|
||||
val leadUser = remember(leadHex) { LocalCache.getOrCreateUser(leadHex) }
|
||||
@@ -689,7 +840,7 @@ private fun BuzzDmInlineRow(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 10.dp)
|
||||
.alpha(if (isHidden) 0.55f else 1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -718,33 +869,6 @@ private fun BuzzDmInlineRow(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MoreVert,
|
||||
contentDescription = stringRes(R.string.more_options),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onToggleMessages()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-17
@@ -97,10 +97,15 @@ fun RelayGroupCreateScreen(
|
||||
relayUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
// Buzz only: pre-select the `forum` channel type (the Forums section's "+" routes here with true).
|
||||
isForum: Boolean = false,
|
||||
) {
|
||||
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
|
||||
val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl")
|
||||
LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) }
|
||||
LaunchedEffect(relay) {
|
||||
viewModel.initCreate(accountViewModel, relay)
|
||||
if (isForum) viewModel.isForum = true
|
||||
}
|
||||
|
||||
// A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as
|
||||
// ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation
|
||||
@@ -197,8 +202,15 @@ private fun RelayGroupMetadataScaffold(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (viewModel.isNewGroup) {
|
||||
// The type is fixed by the caller (the community's per-section "+"), so the title names
|
||||
// it — "New forum" vs "New channel" on Buzz — rather than offering a toggle to change it.
|
||||
CreatingTopBar(
|
||||
titleRes = if (viewModel.isBuzzRelay) R.string.buzz_channel_create_title else R.string.relay_group_create_title,
|
||||
titleRes =
|
||||
when {
|
||||
!viewModel.isBuzzRelay -> R.string.relay_group_create_title
|
||||
viewModel.isForum -> R.string.buzz_forum_create_title
|
||||
else -> R.string.buzz_channel_create_title
|
||||
},
|
||||
isActive = { viewModel.canPost && (nip29Support == true || viewModel.isBuzzRelay) },
|
||||
onCancel = nav::popBack,
|
||||
onPost = onSubmit,
|
||||
@@ -410,21 +422,11 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) {
|
||||
viewModel.markTouched()
|
||||
}
|
||||
|
||||
if (viewModel.isBuzzRelay) {
|
||||
// Buzz's `channel_type`. Only offered on create: the relay takes it on the 9007 and its
|
||||
// 9002 handler has no `channel_type` key, so an existing channel cannot be converted.
|
||||
if (viewModel.isNewGroup) {
|
||||
LabeledSwitchRow(
|
||||
label = stringRes(R.string.buzz_channel_flag_forum),
|
||||
description = stringRes(R.string.buzz_channel_flag_forum_desc),
|
||||
checked = viewModel.isForum,
|
||||
) {
|
||||
viewModel.isForum = it
|
||||
viewModel.markTouched()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// A Buzz channel's type (chat vs forum) is fixed by the caller — the community's per-section "+" —
|
||||
// and isn't editable (the relay's 9002 has no `channel_type` key), so there's no toggle here. The
|
||||
// remaining vanilla NIP-29 flags (invite-only / restricted below) don't apply to Buzz either, so
|
||||
// stop here for a Buzz relay.
|
||||
if (viewModel.isBuzzRelay) return
|
||||
|
||||
LabeledSwitchRow(
|
||||
label = stringRes(R.string.relay_group_flag_invite_only),
|
||||
|
||||
+37
@@ -34,15 +34,20 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -57,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -166,6 +172,37 @@ private fun RelayGroupThreads(
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// The forum's per-item actions, moved off the community-list row into this screen's
|
||||
// top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle, plus the
|
||||
// admin-only Archive/Unarchive (so an archived forum can be brought back from here).
|
||||
// Buzz-only, which every forum channel is.
|
||||
if (isBuzz) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val isAdmin = channel.membershipOf(accountViewModel.userProfile().pubkeyHex) == RelayGroupMembership.ADMIN
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MoreVert,
|
||||
contentDescription = stringRes(R.string.more_options),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
|
||||
RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false }
|
||||
if (isAdmin) {
|
||||
val archived = channel.isArchived()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.archiveRelayGroup(channel, !archived)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
|
||||
+49
-1
@@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
@@ -117,6 +118,9 @@ fun RelayGroupTopBar(
|
||||
// My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below.
|
||||
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
|
||||
.collectAsStateWithLifecycle()
|
||||
// A Buzz DM is hidden via its own per-viewer 30622 snapshot, not the kind-10009 list, so the
|
||||
// Messages toggle branches on this for DMs.
|
||||
val hiddenDms by BuzzDmRegistry.hidden.collectAsStateWithLifecycle()
|
||||
// Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu
|
||||
// callback — leaving a group shouldn't strand the user on the screen of a group they left.
|
||||
val canPop = nav.canPop()
|
||||
@@ -237,7 +241,9 @@ fun RelayGroupTopBar(
|
||||
// Buzz `t=stream` channel it is always empty (forum posts live in `t=forum` channels, which
|
||||
// the relay's channel list already surfaces in their own section), so it read as a broken
|
||||
// feature on every chat. Demoted to the overflow, where the frequency of use actually is.
|
||||
if (!isDm || naddr != null || showMembershipActions) {
|
||||
// A Buzz DM always gets the overflow too — its hide/unhide (below) is the DM row's old
|
||||
// action, and it must be reachable even where the membership actions aren't offered.
|
||||
if (!isDm || naddr != null || showMembershipActions || (isDm && isBuzzRelay)) {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MoreVert,
|
||||
@@ -246,6 +252,33 @@ fun RelayGroupTopBar(
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
// Pin/Unpin moved here off the community-list row. A local favorite, so it's offered
|
||||
// for any Buzz channel/forum regardless of membership; DMs are never pinned.
|
||||
if (isBuzzRelay && !isDm) {
|
||||
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
|
||||
}
|
||||
// A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer
|
||||
// 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is
|
||||
// shown regardless of the membership gate below.
|
||||
if (isBuzzRelay && isDm) {
|
||||
val dmHidden = channel.groupId.id in (hiddenDms[myPubkey] ?: emptySet())
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(if (dmHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
if (dmHidden) {
|
||||
val participants =
|
||||
channel.event
|
||||
?.buzzParticipants()
|
||||
?.filter { it != myPubkey }
|
||||
.orEmpty()
|
||||
accountViewModel.unhideBuzzDm(channel.groupId.relayUrl, participants.ifEmpty { listOf(myPubkey) })
|
||||
} else {
|
||||
accountViewModel.hideBuzzDm(channel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!isDm) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.relay_group_threads_title)) },
|
||||
@@ -308,6 +341,8 @@ fun RelayGroupTopBar(
|
||||
// Two distinct actions, never conflated: the Messages toggle adds/drops the group
|
||||
// on my kind-10009 list but keeps my relay membership either way; "Leave" sends
|
||||
// the kind-9022 that actually removes me. Same split as the channel-invite card.
|
||||
// (A DM's Messages toggle is the hide/unhide item above — it rides a different
|
||||
// mechanism and must show even when these membership actions don't.)
|
||||
//
|
||||
// Reads the live kind-10009 list rather than assuming the group is on it: this
|
||||
// bar also opens for channels reached from the workspace browse (a Buzz relay
|
||||
@@ -335,6 +370,19 @@ fun RelayGroupTopBar(
|
||||
if (canPop) nav.popBack()
|
||||
},
|
||||
)
|
||||
// Archive/Unarchive (kind-9002 `archived` tag) — a reversible hide-from-the-sidebar,
|
||||
// Buzz-only and admin-gated like Delete but NOT destructive, so no confirm dialog.
|
||||
// A DM is never archived (it has its own hide), so this is channels/forums only.
|
||||
if (isBuzzRelay && !isDm && displayMembership == RelayGroupMembership.ADMIN) {
|
||||
val archived = channel.isArchived()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.archiveRelayGroup(channel, !archived)
|
||||
},
|
||||
)
|
||||
}
|
||||
// Deleting the whole channel/group (kind-9008) is destructive for everyone, so it's
|
||||
// shown ONLY to an admin/owner — the same authorization gate as Edit above — and
|
||||
// routed through a confirmation dialog rather than firing on tap.
|
||||
|
||||
-24
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
@@ -112,29 +111,6 @@ fun RelayGroupChannel.newestTimelineNote(account: Account): Note? =
|
||||
.sortedByDefaultFeedOrder()
|
||||
.firstOrNull()
|
||||
|
||||
/**
|
||||
* The pubkeys of the [limit] most-recent distinct posters in this group, newest first — the facepile
|
||||
* shown on a channel row. One O(notes) pass keeps each author's latest post time, so a chatty author
|
||||
* counts once (at their newest message) rather than crowding out quieter voices.
|
||||
*/
|
||||
fun RelayGroupChannel.recentAuthorHexes(
|
||||
account: Account,
|
||||
limit: Int,
|
||||
): List<HexKey> {
|
||||
val latestByAuthor = HashMap<HexKey, Long>()
|
||||
for (note in notes.values()) {
|
||||
if (!isRelayGroupTimelineMessage(note, account)) continue
|
||||
val author = note.author?.pubkeyHex ?: continue
|
||||
val at = note.createdAt() ?: continue
|
||||
val prev = latestByAuthor[author]
|
||||
if (prev == null || at > prev) latestByAuthor[author] = at
|
||||
}
|
||||
return latestByAuthor.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { it.key }
|
||||
}
|
||||
|
||||
/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */
|
||||
private fun RelayGroupChannel.hasChatNewerThan(
|
||||
account: Account,
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatLabelMaxWidth
|
||||
|
||||
/**
|
||||
* A tappable chip naming the relay a NIP-29 / Buzz group lives on. A group id is only unique within
|
||||
* its host relay, so the relay is part of the room's identity — the same `#general` can exist on two
|
||||
* Buzz servers and only this chip tells them apart.
|
||||
*
|
||||
* Wears the same highlighted wash as [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill]
|
||||
* so every "which server / community does this room belong to" chip reads the same way. Unlike the
|
||||
* muted note-header [com.vitorpamplona.amethyst.commons.ui.note.HeaderPill] (PoW/OTS/location
|
||||
* markers), this one is a first-class navigation entry point, so it keeps the stronger
|
||||
* `secondaryContainer` highlight. Shared by the Messages row and the Notifications feed so a Buzz
|
||||
* message reads the same wherever it surfaces; the width is capped at [ChatLabelMaxWidth] with a
|
||||
* middle ellipsis so a long host is truncated instead of crowding the channel name out.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayNameChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(11.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-43
@@ -20,22 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -98,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayNameChip
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote
|
||||
@@ -746,43 +741,6 @@ private fun ConcordServerRoomCompose(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tappable chip naming the server/community a Messages row belongs to. Unlike the muted
|
||||
* note-header [HeaderPill] (PoW/OTS/location markers), this one is a first-class navigation entry
|
||||
* point, so it keeps the stronger `secondaryContainer` highlight.
|
||||
*/
|
||||
@Composable
|
||||
private fun RelayNameChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(11.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Messages row title as the channel name followed by a muted [HeaderPill] naming the room
|
||||
* type (Public Chat, Marmot Group, ...). The pill mirrors the Concord community chip so every group
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ fun MessagesPager(
|
||||
// same state holder, so the two surfaces cannot disagree.
|
||||
headerContent =
|
||||
if (tabs[page].resource == R.string.new_requests) {
|
||||
{ ChannelInvitesSection(accountViewModel) }
|
||||
{ ChannelInvitesSection(accountViewModel, nav) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
||||
+8
@@ -45,6 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.VideoPostKind
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
|
||||
@@ -63,6 +64,12 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The Longs feed's composer. Every *video* posted from here is a NIP-71 kind-21 video — recorded or
|
||||
* picked alike — so portrait footage lands in the Longs feed too instead of being routed to kind 22
|
||||
* by its orientation. Images are unaffected: the gallery picker accepts them and they still post as
|
||||
* NIP-68 kind-20 pictures, which the Longs feed does not read.
|
||||
*/
|
||||
@Composable
|
||||
fun NewLongVideoButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -106,6 +113,7 @@ fun NewLongVideoButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
videoKind = VideoPostKind.NORMAL,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+114
-31
@@ -21,29 +21,47 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
@@ -60,6 +78,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
@Composable
|
||||
fun ChannelInvitesSection(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val invites by accountViewModel.feedStates.channelInvites.flow
|
||||
@@ -69,48 +88,110 @@ fun ChannelInvitesSection(
|
||||
|
||||
Column(modifier) {
|
||||
invites.forEach { invite ->
|
||||
ChannelInviteCard(invite, accountViewModel)
|
||||
// Keyed by channel: the list is sorted newest-first, so an arriving invite shifts every row
|
||||
// below it. Without a key Compose matches children by position and each shifted row would
|
||||
// recompose against a different invite — re-resolving the actor and reloading their avatar.
|
||||
key(invite.channelId) {
|
||||
ChannelInviteCard(invite, accountViewModel, nav)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One pending add, drawn as a feed row instead of a floating Material card: the actor is the row's
|
||||
* author — picture, name and time in the usual note header — and "added you to X" is the row's content,
|
||||
* so the prompt reads like the reply/mention notifications it sits next to. The three choices take the
|
||||
* reactions slot, which spans the full width and therefore fits "Add to Messages" without wrapping.
|
||||
*/
|
||||
@Composable
|
||||
fun ChannelInviteCard(
|
||||
invite: BuzzChannelInvite,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val channel = remember(invite.channelId, invite.relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay)) }
|
||||
val baseChannel =
|
||||
remember(invite.channelId, invite.relay) {
|
||||
LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay))
|
||||
}
|
||||
|
||||
// The channel's own metadata flow, collected directly rather than through `observeChannel`. That
|
||||
// helper also registers a ChannelFinder query, and every assembler under it is gated on
|
||||
// `is PublicChatChannel` / `is LiveActivitiesChannel` — a RelayGroupChannel yields no filter at all,
|
||||
// so the registration buys nothing and only churns the app-wide key set on mount/unmount. The flow
|
||||
// still fills the name in when the group's kind-39000 lands from the directory subscription, and
|
||||
// nothing here opens the channel's *message* subscription — holding that back until the viewer
|
||||
// answers is the whole point of the prompt.
|
||||
val channelState by
|
||||
remember(baseChannel) { baseChannel.flow().metadata.stateFlow }
|
||||
.collectAsStateWithLifecycle()
|
||||
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } }
|
||||
val actorName = actorUser?.let { observeUserName(it, accountViewModel).value }
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
// A pending invite is by definition unanswered, so it always carries the new-item wash rather than
|
||||
// fading with a last-read marker: it is a standing question, not a dated event.
|
||||
val backgroundColor =
|
||||
MaterialTheme.colorScheme.newItemBackgroundColor
|
||||
.compositeOver(MaterialTheme.colorScheme.background)
|
||||
|
||||
NoteComposeLayout(
|
||||
modifier =
|
||||
remember(backgroundColor) {
|
||||
Modifier.drawBehind { drawRect(backgroundColor) }.fillMaxWidth()
|
||||
},
|
||||
authorPicture = {
|
||||
Box(Size55Modifier, contentAlignment = Alignment.BottomEnd) {
|
||||
if (actorUser != null) {
|
||||
UserPicture(actorUser, Size55dp, accountViewModel = accountViewModel, nav = nav)
|
||||
} else {
|
||||
DisplayBlankAuthor(Size55dp, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
},
|
||||
firstRow = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
modifier = UserNameRowHeight,
|
||||
) {
|
||||
// Who did it matters: the relay reports a self-join with the same event, so naming the
|
||||
// actor is what tells "I joined this" apart from "a stranger put me here".
|
||||
text =
|
||||
stringRes(
|
||||
R.string.channel_invite_body,
|
||||
actorName ?: stringRes(R.string.channel_invite_unknown_actor),
|
||||
invite.relay.displayUrl(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (actorUser != null) {
|
||||
UsernameDisplay(actorUser, Modifier.weight(1f), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_unknown_actor),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
// DottedTight, not Dotted: the row's `spacedBy` already supplies the gap, so the
|
||||
// dotted variant's own leading space would double it. Same choice the note header makes.
|
||||
TimeAgo(invite.createdAt, style = TimeAgoStyle.DottedTight)
|
||||
}
|
||||
},
|
||||
secondRow = {},
|
||||
noteContent = {
|
||||
Text(text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()))
|
||||
|
||||
Text(
|
||||
text = invite.relay.displayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
reactionsRow = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size10dp),
|
||||
) {
|
||||
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves
|
||||
// you in the roster, Leave is the kind-9022 that actually removes you from the channel.
|
||||
@@ -120,10 +201,12 @@ fun ChannelInviteCard(
|
||||
TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) {
|
||||
Text(stringRes(R.string.channel_invite_ignore))
|
||||
}
|
||||
// Accepting *is* `addRelayGroupToMessages`, the same call behind the channel top bar's
|
||||
// "Add to Messages", so it carries that label rather than a second word for one action.
|
||||
TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.channel_invite_accept), fontWeight = FontWeight.Bold)
|
||||
Text(stringRes(R.string.add_to_messages), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ internal fun SingleNotificationsBody(
|
||||
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
|
||||
// "X added you to #channel" prompts sit above the feed rather than inside it: they are a
|
||||
// standing decision, not a dated event, so they must not scroll away into history.
|
||||
ChannelInvitesSection(accountViewModel)
|
||||
ChannelInvitesSection(accountViewModel, nav)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+21
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -50,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -63,11 +66,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The picture feed's composer. Images post as NIP-68 kind-20 pictures and land in this feed; a
|
||||
* video picked from the gallery still posts as a NIP-71 video and shows up in the video feeds
|
||||
* instead, since the file's mime type — not the host feed — decides picture vs. video.
|
||||
*
|
||||
* @param sharedAttachments content URIs handed over by the "New Picture" share target. When
|
||||
* non-empty the composer opens on them right away, so the share lands as a picture post instead
|
||||
* of a text note with a link.
|
||||
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
|
||||
*/
|
||||
@Composable
|
||||
fun NewPictureButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
navScrollToTop: () -> Unit,
|
||||
sharedAttachments: List<String> = emptyList(),
|
||||
sharedCaption: String? = null,
|
||||
) {
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
var wantsToPostFromCamera by remember { mutableStateOf(false) }
|
||||
@@ -83,6 +98,11 @@ fun NewPictureButton(
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(sharedAttachments) {
|
||||
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
|
||||
}
|
||||
|
||||
if (wantsToPostFromCamera) {
|
||||
TakePicture { uri ->
|
||||
wantsToPostFromCamera = false
|
||||
@@ -106,6 +126,7 @@ fun NewPictureButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
initialCaption = sharedCaption.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.Picture
|
||||
fun PicturesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
PicturesScreen(
|
||||
picturesFeedContentState = accountViewModel.feedStates.picturesFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
attachments = attachments,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +59,8 @@ fun PicturesScreen(
|
||||
picturesFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(picturesFeedContentState)
|
||||
WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel)
|
||||
@@ -66,8 +72,8 @@ fun PicturesScreen(
|
||||
PicturesTopBar(accountViewModel, nav)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Pictures, nav, accountViewModel) { route ->
|
||||
if (route == Route.Pictures) {
|
||||
AppBottomBar(Route.Pictures(), nav, accountViewModel) { route ->
|
||||
if (route is Route.Pictures) {
|
||||
picturesFeedContentState.sendToTop()
|
||||
} else {
|
||||
nav.navBottomBar(route)
|
||||
@@ -76,7 +82,7 @@ fun PicturesScreen(
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop)
|
||||
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop, attachments, message)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+23
@@ -34,22 +34,26 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.VideoPostKind
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -63,11 +67,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The Shorts feed's composer. Every *video* posted from here is a NIP-71 kind-22 short — recorded,
|
||||
* picked or shared alike — so landscape footage lands in the Shorts feed too instead of being
|
||||
* routed to kind 21 by its orientation. Images are unaffected: the gallery picker accepts them and
|
||||
* they still post as NIP-68 kind-20 pictures, which the Shorts feed does not read.
|
||||
*
|
||||
* @param sharedAttachments content URIs handed over by the "New Short" share target. When non-empty
|
||||
* the composer opens on them right away.
|
||||
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
|
||||
*/
|
||||
@Composable
|
||||
fun NewShortVideoButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
navScrollToTop: () -> Unit,
|
||||
sharedAttachments: List<String> = emptyList(),
|
||||
sharedCaption: String? = null,
|
||||
) {
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
var wantsToRecordVideo by remember { mutableStateOf(false) }
|
||||
@@ -83,6 +99,11 @@ fun NewShortVideoButton(
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(sharedAttachments) {
|
||||
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
|
||||
}
|
||||
|
||||
if (wantsToRecordVideo) {
|
||||
TakeVideo { uri ->
|
||||
wantsToRecordVideo = false
|
||||
@@ -106,6 +127,8 @@ fun NewShortVideoButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
videoKind = VideoPostKind.SHORT,
|
||||
initialCaption = sharedCaption.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFil
|
||||
fun ShortsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
ShortsScreen(
|
||||
shortsFeedContentState = accountViewModel.feedStates.shortsFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
attachments = attachments,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +59,8 @@ fun ShortsScreen(
|
||||
shortsFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(shortsFeedContentState)
|
||||
WatchAccountForShortsScreen(videoFeedState = shortsFeedContentState, accountViewModel = accountViewModel)
|
||||
@@ -66,8 +72,8 @@ fun ShortsScreen(
|
||||
ShortsTopBar(accountViewModel, nav)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Shorts, nav, accountViewModel) { route ->
|
||||
if (route == Route.Shorts) {
|
||||
AppBottomBar(Route.Shorts(), nav, accountViewModel) { route ->
|
||||
if (route is Route.Shorts) {
|
||||
shortsFeedContentState.sendToTop()
|
||||
} else {
|
||||
nav.navBottomBar(route)
|
||||
@@ -76,7 +82,7 @@ fun ShortsScreen(
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop)
|
||||
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop, attachments, message)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
+24
@@ -133,6 +133,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
|
||||
import com.vitorpamplona.amethyst.ui.note.observeEdits
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.types.AudioHeader
|
||||
@@ -281,6 +282,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
@@ -331,6 +333,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.scope
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
@@ -1133,6 +1136,27 @@ private fun FullBleedNoteCompose(
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else if (noteEvent is CommentEvent) {
|
||||
// A comment scoped to external content (NIP-73 `I` tag, e.g. a URL) has no
|
||||
// in-cache parent note, so it surfaces here as the thread's own "master" note.
|
||||
// Show its external scope for context, same as the reply-context branch in
|
||||
// RenderTextEvent does for non-root occurrences of the same comment.
|
||||
val scope = remember(baseNote) { noteEvent.scope() }
|
||||
if (scope != null) {
|
||||
DisplayExternalId(scope, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
RenderTextEvent(
|
||||
baseNote,
|
||||
false,
|
||||
canPreview,
|
||||
quotesLeft = 3,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
} else {
|
||||
RenderTextEvent(
|
||||
baseNote,
|
||||
|
||||
+51
-7
@@ -23,19 +23,28 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.url
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard
|
||||
import com.vitorpamplona.amethyst.ui.components.rememberUrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal.UrlFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource.UrlFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
|
||||
|
||||
@Composable
|
||||
@@ -85,7 +94,7 @@ fun UrlScreen(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
DisplayUrlHeader(url, Modifier.weight(1f))
|
||||
Text(stringRes(R.string.external_content_title))
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
@@ -97,12 +106,47 @@ fun UrlScreen(
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableFeedView(
|
||||
feedViewModel,
|
||||
null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
CompositionLocalProvider(LocalCurrentExternalScope provides url) {
|
||||
RefresheableFeedView(
|
||||
feedViewModel,
|
||||
null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onLoaded = { state, listState ->
|
||||
FeedLoaded(
|
||||
state,
|
||||
listState,
|
||||
null,
|
||||
accountViewModel,
|
||||
nav,
|
||||
header = { UrlScreenPreview(url, accountViewModel) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UrlScreenPreview(
|
||||
url: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// Respect the data-saver/privacy gate: fetching the preview reaches out to the
|
||||
// third-party page, so when previews are off this stays the plain URL header.
|
||||
if (!accountViewModel.settings.showUrlPreview()) {
|
||||
DisplayUrlHeader(url, MaxWidthWithHorzPadding)
|
||||
return
|
||||
}
|
||||
|
||||
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
|
||||
else -> {
|
||||
DisplayUrlHeader(url, MaxWidthWithHorzPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -51,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -64,11 +67,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The Video feed's composer. That feed renders every media kind, so videos keep the automatic kind
|
||||
* choice (portrait -> NIP-71 kind 22, landscape -> kind 21) and pictures post as NIP-68 kind 20 —
|
||||
* all three show up here either way, which is why this is the one media composer that pins nothing.
|
||||
*
|
||||
* @param sharedAttachments content URIs handed over by the "New Video" share target. When non-empty
|
||||
* the composer opens on them right away, so the share lands as a NIP-71 video event instead of a
|
||||
* text note with a link.
|
||||
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
|
||||
*/
|
||||
@Composable
|
||||
fun NewImageButton(
|
||||
fun NewVideoFeedButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
navScrollToTop: () -> Unit,
|
||||
sharedAttachments: List<String> = emptyList(),
|
||||
sharedCaption: String? = null,
|
||||
) {
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -90,6 +105,11 @@ fun NewImageButton(
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(sharedAttachments) {
|
||||
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
|
||||
}
|
||||
|
||||
if (wantsToPostFromCamera) {
|
||||
TakePicture { uri ->
|
||||
wantsToPostFromCamera = false
|
||||
@@ -120,6 +140,7 @@ fun NewImageButton(
|
||||
postViewModel = postViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
initialCaption = sharedCaption.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
+9
-3
@@ -59,11 +59,15 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
fun VideoScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
VideoScreen(
|
||||
accountViewModel.feedStates.videoFeed,
|
||||
accountViewModel,
|
||||
nav,
|
||||
attachments,
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,6 +76,8 @@ fun VideoScreen(
|
||||
videoFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
attachments: List<String> = emptyList(),
|
||||
message: String? = null,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(videoFeedContentState)
|
||||
WatchAccountForVideoScreen(videoFeedContentState = videoFeedContentState, accountViewModel = accountViewModel)
|
||||
@@ -83,8 +89,8 @@ fun VideoScreen(
|
||||
StoriesTopBar(accountViewModel, nav)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Video, nav, accountViewModel) { route ->
|
||||
if (route == Route.Video) {
|
||||
AppBottomBar(Route.Video(), nav, accountViewModel) { route ->
|
||||
if (route is Route.Video) {
|
||||
videoFeedContentState.sendToTop()
|
||||
} else {
|
||||
nav.navBottomBar(route)
|
||||
@@ -93,7 +99,7 @@ fun VideoScreen(
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewImageButton(accountViewModel, nav, videoFeedContentState::sendToTop)
|
||||
NewVideoFeedButton(accountViewModel, nav, videoFeedContentState::sendToTop, attachments, message)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
@@ -98,6 +98,24 @@ class TorService(
|
||||
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
|
||||
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
|
||||
|
||||
/**
|
||||
* Every status change goes through here so the transition is logged exactly once, at INFO, with
|
||||
* the time since bootstrap started.
|
||||
*
|
||||
* Tor state is the single biggest determinant of whether a boot works — a stuck `Connecting`,
|
||||
* an `Active` that took 40s, and a silent drop back to `Off` are three completely different
|
||||
* bugs that used to look identical in the log, because the per-status detail lived only in
|
||||
* Arti's own DEBUG chatter. Self-transitions are not logged: the reset paths reassign `Off`
|
||||
* defensively and repeating it adds nothing.
|
||||
*/
|
||||
private fun setStatus(next: TorServiceStatus) {
|
||||
val prev = _status.value
|
||||
_status.value = next
|
||||
if (prev::class == next::class && prev.toString() == next.toString()) return
|
||||
val since = if (bootstrapStartedAtMs > 0) " after ${System.currentTimeMillis() - bootstrapStartedAtMs}ms" else ""
|
||||
Log.i("TorService") { "${prev::class.simpleName} -> ${next::class.simpleName}$since" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime detector for a rotten guard sample. Arti logs [ALL_GUARDS_DOWN_MARKER] every time it
|
||||
* fails to find a usable guard; [GUARDS_DOWN_THRESHOLD] of those inside [GUARDS_DOWN_WINDOW_MS]
|
||||
@@ -218,11 +236,11 @@ class TorService(
|
||||
lifecycleMutex.withLock {
|
||||
if (proxyRunning.get()) {
|
||||
if (_status.value is TorServiceStatus.Active) return@withLock
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
setStatus(TorServiceStatus.Connecting)
|
||||
return@withLock
|
||||
}
|
||||
|
||||
_status.value = TorServiceStatus.Connecting
|
||||
setStatus(TorServiceStatus.Connecting)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
// Initialize TorClient once — this bootstraps the Tor network.
|
||||
@@ -292,7 +310,7 @@ class TorService(
|
||||
if (initResult != 0) {
|
||||
Log.e("TorService") { "Failed to initialize Arti on retry: error $initResult" }
|
||||
initialized.set(false)
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
@@ -313,7 +331,7 @@ class TorService(
|
||||
|
||||
if (!started) {
|
||||
Log.e("TorService") { "Failed to start SOCKS proxy after $MAX_PORT_RETRIES attempts" }
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
@@ -332,7 +350,7 @@ class TorService(
|
||||
// reset/stop can't clobber it.
|
||||
val startedAt = bootstrapStartedAtMs
|
||||
val elapsed = if (startedAt > 0) System.currentTimeMillis() - startedAt else -1
|
||||
_status.value = TorServiceStatus.Active(socksPort)
|
||||
setStatus(TorServiceStatus.Active(socksPort))
|
||||
Log.d("TorService") { "Arti SOCKS proxy active on port $socksPort (bootstrap took ${elapsed}ms)" }
|
||||
}
|
||||
}
|
||||
@@ -350,7 +368,7 @@ class TorService(
|
||||
Log.d("TorService") { "SOCKS proxy stopped" }
|
||||
}
|
||||
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +383,7 @@ class TorService(
|
||||
lifecycleMutex.withLock {
|
||||
resetLocked()
|
||||
}
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,7 +399,7 @@ class TorService(
|
||||
clearAllArtiData()
|
||||
}
|
||||
}
|
||||
_status.value = TorServiceStatus.Off
|
||||
setStatus(TorServiceStatus.Off)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
|
||||
</vector>
|
||||
@@ -4,5 +4,16 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M19,3H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM14,17H7v-2h7v2zM17,13H7v-2h10v2zM17,9H7V7h10v2z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.9,2.5H18.1A2.3,2.3 0 0 1 20.4,4.8V19.2A2.3,2.3 0 0 1 18.1,21.5H5.9A2.3,2.3 0 0 1 3.6,19.2V4.8A2.3,2.3 0 0 1 5.9,2.5Z M6.7,4.6H17.3A1.1,1.1 0 0 1 18.4,5.7V18.3A1.1,1.1 0 0 1 17.3,19.4H6.7A1.1,1.1 0 0 1 5.6,18.3V5.7A1.1,1.1 0 0 1 6.7,4.6Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M11.99,7H16.75A0.65,0.65 0 0 1 17.4,7.65V7.65A0.65,0.65 0 0 1 16.75,8.3H11.99A0.65,0.65 0 0 1 11.34,7.65V7.65A0.65,0.65 0 0 1 11.99,7Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M11.99,9.9H16.75A0.65,0.65 0 0 1 17.4,10.55V10.55A0.65,0.65 0 0 1 16.75,11.2H11.99A0.65,0.65 0 0 1 11.34,10.55V10.55A0.65,0.65 0 0 1 11.99,9.9Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M7.25,12.8H16.75A0.65,0.65 0 0 1 17.4,13.45V13.45A0.65,0.65 0 0 1 16.75,14.1H7.25A0.65,0.65 0 0 1 6.6,13.45V13.45A0.65,0.65 0 0 1 7.25,12.8Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M7.25,15.7H16.75A0.65,0.65 0 0 1 17.4,16.35V16.35A0.65,0.65 0 0 1 16.75,17H7.25A0.65,0.65 0 0 1 6.6,16.35V16.35A0.65,0.65 0 0 1 7.25,15.7Z"/>
|
||||
<group
|
||||
android:scaleX="0.017"
|
||||
android:scaleY="0.017"
|
||||
android:translateX="4.216"
|
||||
android:translateY="4.718">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M19.8,9.8L19.733,10.818L19.534,11.819L19.206,12.785L18.755,13.7L18.188,14.548L17.515,15.315L16.748,15.988L15.9,16.555L14.985,17.006L14.019,17.334L13.018,17.533L12,17.6L10.982,17.533L9.981,17.334L9.015,17.006L8.1,16.555L7.252,15.988L6.485,15.315L5.812,14.548L5.245,13.7L4.794,12.785L4.466,11.819L4.267,10.818L4.2,9.8L4.267,8.782L4.466,7.781L4.794,6.815L5.245,5.9L5.812,5.052L6.485,4.285L7.252,3.612L8.1,3.045L9.015,2.594L9.981,2.266L10.982,2.067L12,2L13.018,2.067L14.019,2.266L14.985,2.594L15.9,3.045L16.748,3.612L17.515,4.285L18.188,5.052L18.755,5.9L19.206,6.815L19.534,7.781L19.733,8.782Z M 16.250896717373898,12.066845476381104 C 16.240844675740593,12.036317053642914 13.909887910328264,6.486473178542833 13.269163330664531,4.947393915132104 C 12.488827061649319,4.947393915132104 10.840664531625299,4.947393915132104 9.677978382706165,4.947393915132104 C 9.336581265012011,5.791393114491592 8.456096877502002,7.942157726180943 7.749475580464371,9.674831865492393 C 8.136665332265814,10.598502802241793 8.484391513210568,11.428354683747 8.752818254603682,12.068334667734188 L 10.215204163330664,12.068334667734188 A 0.09977582065652522,0.09977582065652522 135.0 0,0 10.308650920736588,11.951060848678944 C 10.295992794235387,11.655828662930345 9.492201761409126,10.582493995196158 9.731961569255404,9.421669335468376 C 9.79599679743795,9.171857485988792 9.8447678142514,8.917950360288232 9.914759807846275,8.66925540432346 A 8.781761409127302,8.781761409127302 135.0 0,1 10.586385108086468,6.93062449959968 C 10.630688550840672,6.8461128903122495 10.651164931945555,6.808138510808647 10.753919135308244,6.872173738991193 C 10.972085668534826,7.008062449959968 11.21594075260208,7.000244195356284 11.459051240992792,6.982746196957566 C 11.66828262610088,6.937325860688551 11.770292233787027,6.836805444355485 12.154131305044032,6.938815052041633 C 12.230080064051238,6.938815052041633 12.30416733386709,6.938815052041633 12.38160528422738,6.945888710968775 C 12.565892714171335,6.960408326661328 12.739011208967172,7.004339471577261 12.849583666933544,7.171128903122498 C 12.970580464371494,7.3535548438751 12.989939951961565,7.559807846277022 12.970580464371494,7.770528422738191 C 12.941168935148115,8.085864691753402 12.96201761409127,8.379607686148919 13.228955164131303,8.601869495596477 A 2.873767013610889,2.873767013610889 0.0 0,0 13.528654923939149,8.801793434747797 C 13.661192954363488,8.886677341873497 13.820908726981584,8.923534827862289 13.93445956765412,9.041553242594075 C 14.001473178542831,9.11117293835068 14.040936749399517,9.184887910328262 14.011897518014408,9.283919135308246 C 13.9828582866293,9.382950360288229 13.90542033626901,9.429487590072057 13.803410728582863,9.440284227381904 C 13.545780624499596,9.4682065652522 13.293734987990389,9.415712570056042 13.040572457966368,9.385184147317853 C 13.007437950360284,9.381088871096875 12.97504803843074,9.378855084067252 12.940796637309843,9.37662129703763 A 0.5729663730984789,0.5729663730984789 0.0 0,1 12.750552441953557,9.382950360288229 L 12.690984787830258,9.382950360288229 A 1.333943154523619,1.333943154523619 0.0 0,0 12.39612489991993,9.453314651721376 C 12.157481985588463,9.540432345876699 11.940060048038424,9.63611289031225 11.658230584467567,9.63127301841473 A 0.21593274619695757,0.21593274619695757 0.0 0,0 11.555848678943148,9.675576461168934 C 11.335076060848673,9.871777421937548 11.202165732586062,10.11972778222578 11.312365892714165,10.481973578863089 C 11.615044035228177,11.327834267413929 12.392029623698955,13.36839871897518 12.725608486789426,14.248883106485188 C 12.716301040832661,13.652461969575661 12.713694955964767,12.584339471577263 12.711461168935143,12.065728582866294 C 14.044287429943951,12.067590072057648 16.088202562049634,12.066845476381104 16.251269015212163,12.066845476381104 L 16.250896717373898,12.066845476381104"/>
|
||||
<path android:fillColor="#000000" android:pathData="M9.4,15L8.1,22.4L11.5,18.6Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M14.6,15L15.9,22.4L12.5,18.6Z"/>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,18 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M19,22H5v-2h14v2zM17.16,8.26C18.22,9.63 18.7,11.4 18.5,13.16C18.15,15.98 15.67,18 12.83,18H11.5C8.66,18 6.35,15.98 6,13.16c-0.2,-1.79 0.29,-3.56 1.34,-4.93C6.5,7.68 6,6.65 6,5.5 6,3.57 7.57,2 9.5,2c1.03,0 1.95,0.45 2.59,1.15C12.71,2.44 13.58,2 14.5,2 16.43,2 18,3.57 18,5.5c0,1.15 -0.5,2.18 -0.84,2.76z"/>
|
||||
<group
|
||||
android:scaleX="0.76"
|
||||
android:scaleY="0.76"
|
||||
android:translateX="-0.399"
|
||||
android:translateY="1.08">
|
||||
<path android:fillColor="#000000" android:pathData="M 5.0,22.0 L 5.0,18.0 Q 5.0,17.43 5.3,16.96 Q 5.6,16.5 6.1,16.23 L 11.0,13.75 L 11.0,12.0 L 7.53,13.72 Q 7.23,13.88 6.9,13.95 Q 6.58,14.02 6.25,14.02 Q 5.47,14.02 4.79,13.62 Q 4.1,13.22 3.73,12.47 Q 3.38,11.8 3.43,11.04 Q 3.48,10.28 3.9,9.62 L 7.0,5.0 L 5.0,2.0 L 11.0,2.0 Q 14.32,2.0 16.66,4.32 Q 19.0,6.65 19.0,10.0 L 19.0,22.0 L 5.0,22.0 M 7.0,20.0 L 17.0,20.0 L 17.0,10.0 Q 17.0,7.5 15.25,5.75 Q 13.5,4.0 11.0,4.0 L 8.75,4.0 L 9.4,5.0 L 5.58,10.75 Q 5.45,10.95 5.44,11.16 Q 5.42,11.38 5.53,11.57 Q 5.65,11.85 5.86,11.94 Q 6.08,12.03 6.28,12.02 Q 6.35,12.02 6.65,11.95 L 13.0,8.75 L 13.0,15.0 L 7.0,18.0 L 7.0,20.0"/>
|
||||
</group>
|
||||
<group
|
||||
android:scaleX="0.037"
|
||||
android:scaleY="0.037"
|
||||
android:translateX="8.092"
|
||||
android:translateY="6.423">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,12 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M9.4,16.6L4.8,12l4.6,-4.6L8,6l-6,6 6,6 1.4,-1.4zM14.6,16.6l4.6,-4.6 -4.6,-4.6L16,6l6,6 -6,6 -1.4,-1.4z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M 6.49,16.6 L 2.58,12.0 L 6.489999999999998,7.4 L 5.3,6.0 L 0.19999999999999996,12.0 L 5.3,18.0 L 6.49,16.6 M 17.51,16.6 L 21.42,12.000000000000002 L 17.51,7.400000000000002 L 18.700000000000003,6.0 L 23.8,12.0 L 18.700000000000003,18.0 L 17.51,16.6"/>
|
||||
<group
|
||||
android:scaleX="0.04"
|
||||
android:scaleY="0.04"
|
||||
android:translateX="1.752"
|
||||
android:translateY="1.568">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,13 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.4,3.4H18.6A3,3 0 0 1 21.6,6.4V17.6A3,3 0 0 1 18.6,20.6H5.4A3,3 0 0 1 2.4,17.6V6.4A3,3 0 0 1 5.4,3.4Z M6.2,6H17.8A1.2,1.2 0 0 1 19,7.2V16.8A1.2,1.2 0 0 1 17.8,18H6.2A1.2,1.2 0 0 1 5,16.8V7.2A1.2,1.2 0 0 1 6.2,6Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M4.7,18L9,10.8L13,18Z"/>
|
||||
<group
|
||||
android:scaleX="0.043"
|
||||
android:scaleY="0.043"
|
||||
android:translateX="4.846"
|
||||
android:translateY="4.03">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,12 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10c1.87,0 3.63,-0.52 5.13,-1.42l-0.9,-1.55C15,19.65 13.55,20 12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8v0.72c0,0.79 -0.71,1.78 -1.5,1.78s-1.5,-0.99 -1.5,-1.78V12c0,-2.76 -2.24,-5 -5,-5S7,9.24 7,12s2.24,5 5,5c1.38,0 2.64,-0.56 3.54,-1.47 0.65,0.89 1.77,1.47 2.96,1.47 1.97,0 3.5,-1.6 3.5,-3.78V12c0,-5.52 -4.48,-10 -10,-10zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M 12.0,2.0 C 6.48,2.0 2.0,6.48 2.0,12.0 C 2.0,17.52 6.48,22.0 12.0,22.0 C 13.870000000000001,22.0 15.629999999999999,21.48 17.13,20.58 L 16.23,19.029999999999998 C 15.0,19.65 13.55,20.0 12.0,20.0 C 7.58,20.0 4.0,16.42 4.0,12.0 C 4.0,7.579999999999998 7.58,4.0 12.0,4.0 C 16.42,4.0 20.0,7.58 20.0,12.0 L 20.0,12.72 C 20.0,13.510000000000002 19.29,14.5 18.5,14.5 C 17.71,14.5 17.0,13.51 16.5,12.72 C 14.3,12.5 14.0,15.0 17.7,17.0 C 20.47,17.0 22.0,15.4 22.0,13.22 L 22.0,12.0 C 22.0,6.48 17.52,2.0 12.0,2.0"/>
|
||||
<group
|
||||
android:scaleX="0.042"
|
||||
android:scaleY="0.042"
|
||||
android:translateX="1.822"
|
||||
android:translateY="1.15">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.7,1.5H18.3A4.2,4.2 0 0 1 22.5,5.7V13.3A4.2,4.2 0 0 1 18.3,17.5H5.7A4.2,4.2 0 0 1 1.5,13.3V5.7A4.2,4.2 0 0 1 5.7,1.5Z M 11.022353883106485,11.834219375500401 C 11.012842273819055,11.805332265812652 8.80720576461169,6.55386709367494 8.200928742994394,5.097534027221777 C 7.462546036829464,5.097534027221777 5.902994395516411,5.097534027221777 4.802818254603682,5.097534027221777 C 4.479775820656525,5.896156925540433 3.6466293034427535,7.9312890312249795 2.9779983987189746,9.570808646917534 C 3.3443714971977583,10.444819855884708 3.673402722177742,11.23005604483587 3.927397918334667,11.835628502802242 L 5.311160928742994,11.835628502802242 A 0.09441152922337871,0.09441152922337871 135.0 0,0 5.3995836669335455,11.724659727782228 C 5.387606084867893,11.445300240192156 4.627029623698958,10.429671737389915 4.853899119295435,9.331257005604485 C 4.914491593274619,9.094875900720577 4.960640512409927,8.854619695756606 5.0268694955964754,8.61929543634908 A 8.309623698959168,8.309623698959168 135.0 0,1 5.6623859087269794,6.974139311449161 C 5.704307445956763,6.894171337069657 5.7236829463570835,6.8582385908727 5.820912730184145,6.918831064851883 C 6.027349879903921,7.047413931144917 6.258094475580462,7.040016012810249 6.488134507606082,7.023458767013612 C 6.686116893514809,6.980480384307447 6.782642113690949,6.885364291433149 7.145844675740589,6.981889511609288 C 7.217710168134506,6.981889511609288 7.287814251401116,6.981889511609288 7.361088871096875,6.988582866293036 C 7.535468374699757,7.002321857485989 7.6992794235388295,7.043891112890313 7.803907125700558,7.201713370696559 C 7.918398718975178,7.37433146517214 7.936717373899116,7.569495596477183 7.918398718975178,7.768887109687752 C 7.890568454763809,8.067269815852683 7.9102962369895895,8.345220176140915 8.162882305844674,8.555532425940754 A 2.719263410728583,2.719263410728583 0.0 0,0 8.44646917534027,8.74470776621297 C 8.571881505204162,8.825028022417934 8.72301040832666,8.85990392313851 8.83045636509207,8.971577261809449 C 8.893867093674936,9.037453963170538 8.931208967173736,9.10720576461169 8.903730984787828,9.200912730184148 C 8.87625300240192,9.294619695756607 8.802978382706161,9.338654923939153 8.70645316253002,9.348871096877502 C 8.462674139311444,9.37529223378703 8.224179343474775,9.325620496397118 7.984627702161726,9.296733386709368 C 7.953274619695753,9.292858286629304 7.9226261008807,9.290744595676543 7.890216172938347,9.28863090472378 A 0.5421617293835068,0.5421617293835068 0.0 0,1 7.710200160128098,9.294619695756605 L 7.653835068054438,9.294619695756605 A 1.2622257806244996,1.2622257806244996 0.0 0,0 7.374827862289825,9.361200960768615 C 7.1490152121697275,9.44363490792634 6.943282626100874,9.534171337069656 6.676605284227376,9.52959167333867 A 0.2043234587670136,0.2043234587670136 0.0 0,0 6.579727782225774,9.571513210568455 C 6.370824659727775,9.757165732586069 6.2450600480384235,9.991785428342673 6.349335468374694,10.334555644515612 C 6.635740592473972,11.134939951961568 7.370952762209763,13.065796637309848 7.686597277822251,13.89894315452362 C 7.6777902321857425,13.334587670136111 7.675324259407519,12.323891112890314 7.673210568454758,11.83316253002402 C 8.934379503602877,11.834923939151324 10.868406725380298,11.834219375500401 11.02270616493194,11.834219375500401 L 11.022353883106485,11.834219375500401 M14.1,5.1H19.4A0.8,0.8 0 0 1 20.2,5.9V5.9A0.8,0.8 0 0 1 19.4,6.7H14.1A0.8,0.8 0 0 1 13.3,5.9V5.9A0.8,0.8 0 0 1 14.1,5.1Z M14.1,8.7H19.4A0.8,0.8 0 0 1 20.2,9.5V9.5A0.8,0.8 0 0 1 19.4,10.3H14.1A0.8,0.8 0 0 1 13.3,9.5V9.5A0.8,0.8 0 0 1 14.1,8.7Z M14.1,12.3H19.4A0.8,0.8 0 0 1 20.2,13.1V13.1A0.8,0.8 0 0 1 19.4,13.9H14.1A0.8,0.8 0 0 1 13.3,13.1V13.1A0.8,0.8 0 0 1 14.1,12.3Z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M6,16.5L6,22.3L11.6,16.5Z"/>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z M 16.525148118494798,14.52599679743795 C 16.514447558046438,14.49349879903923 14.033106485188151,8.585600480384304 13.351044835868693,6.947225780624498 C 12.520364291433145,6.947225780624498 10.765868694955962,6.947225780624498 9.528170536429144,6.947225780624498 C 9.16474779823859,7.845676541232986 8.227457966373098,10.1352001601281 7.475248198558846,11.979659727782225 C 7.887417934347478,12.962922337870296 8.257578062449959,13.846313050440353 8.543322658126499,14.527582065652522 L 10.100056044835867,14.527582065652522 A 0.10621297037630105,0.10621297037630105 135.0 0,0 10.199531625300239,14.402742193755005 C 10.18605684547638,14.088462770216173 9.330408326661328,12.945880704563653 9.585636509207365,11.710164131305046 C 9.653803042433946,11.44423538831065 9.705720576461168,11.173947157726182 9.780228182546036,10.909207365892716 A 9.348326661329063,9.348326661329063 135.0 0,1 10.495184147317852,9.058406725380305 C 10.54234587670136,8.968442754203362 10.56414331465172,8.928018414731785 10.673526821457164,8.996184947958367 C 10.905768614891914,9.140840672538031 11.165356285028022,9.13251801441153 11.424151321056843,9.113891112890313 C 11.646881505204162,9.065540432345877 11.755472377902318,8.958534827862291 12.164075260208163,9.06712570056045 C 12.244923939151318,9.06712570056045 12.323791032826257,9.06712570056045 12.406224979983985,9.074655724579664 C 12.602401921537227,9.090112089671738 12.786689351481185,9.136877502001601 12.904395516413128,9.314427542033627 C 13.033198558847076,9.508622898318656 13.053807045636507,9.72818254603683 13.033198558847076,9.95249799839872 C 13.001889511609285,10.288178542834268 13.024083266613289,10.600872698158527 13.308242594075258,10.837473979183347 A 3.059171337069656,3.059171337069656 0.0 0,0 13.627277822257804,11.050296236989592 C 13.768366693354682,11.140656525220177 13.938386709367492,11.179891913530826 14.05926341072858,11.305524419535628 C 14.130600480384304,11.379635708566854 14.172610088070453,11.45810648518815 14.141697357886306,11.563526821457165 C 14.11078462770216,11.66894715772618 14.028350680544433,11.718486789431546 13.919759807846273,11.729979983987189 C 13.645508406725375,11.759703763010407 13.377201761409124,11.703823058446757 13.10770616493194,11.671325060048037 C 13.072433947157721,11.666965572457965 13.037954363490789,11.664587670136108 13.00149319455564,11.66220976781425 A 0.6099319455564451,0.6099319455564451 0.0 0,1 12.79897518014411,11.66894715772618 L 12.735564451561242,11.66894715772618 A 1.420004003202562,1.420004003202562 0.0 0,0 12.421681345076054,11.743851080864692 C 12.167642113690945,11.836589271417132 11.936192954363484,11.938442754203365 11.636180944755798,11.933290632506003 A 0.2298638911128903,0.2298638911128903 0.0 0,0 11.527193755003996,11.98045236188951 C 11.292177742193747,12.189311449159325 11.150692554043227,12.453258606885507 11.26800240192153,12.838875100080063 C 11.59020816653322,13.739307445956763 12.417321857485982,15.91152121697358 12.772421937550034,16.84881104883907 C 12.76251401120896,16.21391112890312 12.75973979183346,15.076877502001603 12.757361889511603,14.524807846277023 C 14.176176941553237,14.526789431545238 16.35195756605284,14.52599679743795 16.525544435548433,14.52599679743795 L 16.525148118494798,14.52599679743795"/>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,18 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
|
||||
<group
|
||||
android:scaleX="0.9"
|
||||
android:scaleY="0.9"
|
||||
android:translateX="-1.5"
|
||||
android:translateY="4.6">
|
||||
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
|
||||
</group>
|
||||
<group
|
||||
android:scaleX="0.052"
|
||||
android:scaleY="0.052"
|
||||
android:translateX="3.65"
|
||||
android:translateY="-6.25">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,18 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
|
||||
<group
|
||||
android:scaleX="1.15"
|
||||
android:scaleY="1.15"
|
||||
android:translateX="-1.8"
|
||||
android:translateY="-1.8">
|
||||
<path android:fillColor="#000000" android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
|
||||
</group>
|
||||
<group
|
||||
android:scaleX="0.029"
|
||||
android:scaleY="0.029"
|
||||
android:translateX="4.621"
|
||||
android:translateY="4.489">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -4,5 +4,18 @@
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#000000">
|
||||
<path android:fillColor="#000000" android:pathData="M11,21h-1l1,-7H7.5c-0.58,0 -0.57,-0.32 -0.38,-0.66c0.19,-0.34 0.05,-0.08 0.07,-0.12C8.48,10.94 10.42,7.54 13,3h1l-1,7h3.5c0.49,0 0.56,0.33 0.47,0.51l-0.07,0.15C12.96,17.55 11,21 11,21z"/>
|
||||
<group
|
||||
android:scaleX="1.15"
|
||||
android:scaleY="1.15"
|
||||
android:translateX="-7.05"
|
||||
android:translateY="-1.95">
|
||||
<path android:fillColor="#000000" android:pathData="M11,21h-1l1,-7H7.5c-0.58,0 -0.57,-0.32 -0.38,-0.66c0.19,-0.34 0.05,-0.08 0.07,-0.12C8.48,10.94 10.42,7.54 13,3h1l-1,7h3.5c0.49,0 0.56,0.33 0.47,0.51l-0.07,0.15C12.96,17.55 11,21 11,21z"/>
|
||||
</group>
|
||||
<group
|
||||
android:scaleX="0.042"
|
||||
android:scaleY="0.042"
|
||||
android:translateX="7.35"
|
||||
android:translateY="-4.5">
|
||||
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -458,6 +458,8 @@
|
||||
<string name="refresh">Obnovit</string>
|
||||
<string name="changed_chat_profile_to">Nový chatový profil:</string>
|
||||
<string name="leave">Opustit</string>
|
||||
<string name="remove_from_messages">Odebrat ze Zpráv</string>
|
||||
<string name="add_to_messages">Přidat do Zpráv</string>
|
||||
<string name="unfollow">Přestat sledovat</string>
|
||||
<string name="channel_created">Kanál vytvořen</string>
|
||||
<string name="channel_information_changed_to">"Informace kanálu změněna na"</string>
|
||||
@@ -2216,6 +2218,30 @@
|
||||
<string name="settings_section_tor_routing">Směrovat přes Tor</string>
|
||||
<string name="settings_section_profile_sections">Sekce a kanály</string>
|
||||
<string name="settings_section_home_tabs">Viditelné karty</string>
|
||||
<string name="settings_section_home_content_types">Obsah v kanálu</string>
|
||||
<string name="home_content_type_text_notes">Textové poznámky</string>
|
||||
<string name="home_content_type_reposts">Sdílení</string>
|
||||
<string name="home_content_type_comments">Komentáře a odpovědi</string>
|
||||
<string name="home_content_type_pictures">Obrázky</string>
|
||||
<string name="home_content_type_videos">Videa</string>
|
||||
<string name="home_content_type_shorts">Krátká videa</string>
|
||||
<string name="home_content_type_articles">Články</string>
|
||||
<string name="home_content_type_wiki">Wiki stránky</string>
|
||||
<string name="home_content_type_highlights">Zvýraznění</string>
|
||||
<string name="home_content_type_polls">Ankety</string>
|
||||
<string name="home_content_type_classifieds">Inzeráty</string>
|
||||
<string name="home_content_type_torrents">Torrenty</string>
|
||||
<string name="home_content_type_voice">Hlasové zprávy</string>
|
||||
<string name="home_content_type_live_activities">Živé aktivity</string>
|
||||
<string name="home_content_type_ephemeral_chat">Dočasné chaty</string>
|
||||
<string name="home_content_type_interactive_stories">Interaktivní příběhy</string>
|
||||
<string name="home_content_type_chess">Šachové partie</string>
|
||||
<string name="home_content_type_birds">Pozorování ptáků</string>
|
||||
<string name="home_content_type_attestations">Atestace</string>
|
||||
<string name="home_content_type_nips">Návrhy NIP</string>
|
||||
<string name="home_content_type_music">Hudba a zvuk</string>
|
||||
<string name="home_content_type_podcasts">Podcasty</string>
|
||||
<string name="home_content_type_fundraisers">Sbírky</string>
|
||||
<string name="settings_section_reminders">Připomenutí</string>
|
||||
<string name="wallet_connect">Připojení peněženky</string>
|
||||
<string name="language">Jazyk</string>
|
||||
@@ -2452,6 +2478,10 @@
|
||||
<string name="relay_group_edit_confirm">Uložit</string>
|
||||
<string name="relay_group_menu_members">Členové</string>
|
||||
<string name="relay_group_menu_edit">Upravit skupinu</string>
|
||||
<string name="relay_group_delete">Smazat skupinu</string>
|
||||
<string name="relay_group_delete_confirm">Smazat „%1$s“? Odstraní to skupinu a její historii pro všechny a nelze to vrátit zpět.</string>
|
||||
<string name="buzz_channel_delete">Smazat kanál</string>
|
||||
<string name="buzz_channel_delete_confirm">Smazat „%1$s“? Odstraní to kanál a jeho zprávy pro všechny a nelze to vrátit zpět.</string>
|
||||
<string name="relay_group_threads_title">Vlákna</string>
|
||||
<string name="relay_group_pin_message">Připnout zprávu</string>
|
||||
<string name="relay_group_unpin_message">Odepnout zprávu</string>
|
||||
@@ -2533,9 +2563,7 @@
|
||||
<string name="relay_group_browse_popular">Oblíbené relaye</string>
|
||||
<string name="relay_group_no_messages_yet">Zatím žádné zprávy</string>
|
||||
<string name="channel_invite_title">Přidáno do %1$s</string>
|
||||
<string name="channel_invite_body">%1$s vás přidal do tohoto kanálu na %2$s. Zobrazit ho ve Zprávách?</string>
|
||||
<string name="channel_invite_unknown_actor">Někdo</string>
|
||||
<string name="channel_invite_accept">Zobrazit</string>
|
||||
<string name="channel_invite_ignore">Ignorovat</string>
|
||||
<string name="channel_invite_leave">Opustit</string>
|
||||
<string name="relay_group_join_to_post">Připojte se k této skupině pro odesílání zpráv.</string>
|
||||
@@ -2605,6 +2633,14 @@
|
||||
<string name="share_target_as_dm">Odeslat jako DM</string>
|
||||
<string name="share_to_dm_title">Odeslat…</string>
|
||||
<string name="share_to_dm_start_new">Nová zpráva</string>
|
||||
<string name="share_target_as_highlight">Nové zvýraznění</string>
|
||||
<string name="new_highlight_title">Nové zvýraznění</string>
|
||||
<string name="new_highlight_passage_label">Zvýrazněný text</string>
|
||||
<string name="new_highlight_passage_placeholder">Co vás zaujalo?</string>
|
||||
<string name="new_highlight_source_label">URL zdroje</string>
|
||||
<string name="new_highlight_note_label">Vaše poznámka (volitelné)</string>
|
||||
<string name="new_highlight_note_placeholder">Přidejte své myšlenky…</string>
|
||||
<string name="highlight_action">Zvýraznit</string>
|
||||
<string name="copy_url_to_clipboard">Kopírovat URL do schránky</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopírovat ID poznámky do schránky</string>
|
||||
<string name="add_media_to_gallery">Přidat média do galerie</string>
|
||||
@@ -3392,6 +3428,12 @@
|
||||
<string name="buzz_pin">Připnout kanál</string>
|
||||
<string name="buzz_unpin">Odepnout kanál</string>
|
||||
<string name="buzz_dm_section_empty">Zatím žádné konverzace</string>
|
||||
<plurals name="buzz_dm_hidden_count">
|
||||
<item quantity="one">%1$d skrytá konverzace</item>
|
||||
<item quantity="few">%1$d skryté konverzace</item>
|
||||
<item quantity="many">%1$d skryté konverzace</item>
|
||||
<item quantity="other">%1$d skrytých konverzací</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">Zobrazit další %1$d konverzaci</item>
|
||||
<item quantity="few">Zobrazit všechny %1$d konverzace</item>
|
||||
@@ -3572,6 +3614,7 @@
|
||||
<string name="git_repo_settings_topics">Témata (oddělená čárkami)</string>
|
||||
<string name="git_repo_settings_save">Uložit</string>
|
||||
<string name="git_repositories">Git repozitáře</string>
|
||||
<string name="highlights">Zvýraznění</string>
|
||||
<string name="nsite_title">Statický web: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Oprávnění:</string>
|
||||
@@ -4653,6 +4696,7 @@
|
||||
<string name="new_conversation_location_pro_2">Kompatibilní s Bitchatem; zasáhne blízké uživatele na stejných relayích.</string>
|
||||
<string name="new_conversation_location_con_1">Veřejné a dočasné — žádná historie, může to číst kdokoli v buňce.</string>
|
||||
<!-- Buzz workflow run board -->
|
||||
<string name="buzz_job_board_title">Fronta úloh</string>
|
||||
<string name="buzz_workflow_runs_title">Běhy workflow</string>
|
||||
<string name="buzz_agent_work_title">Práce agenta</string>
|
||||
<string name="buzz_workflow_new_run">Nový běh</string>
|
||||
|
||||
@@ -438,6 +438,8 @@
|
||||
<string name="refresh">Aktualisieren</string>
|
||||
<string name="changed_chat_profile_to">Neues Chat-Profil:</string>
|
||||
<string name="leave">Verlassen</string>
|
||||
<string name="remove_from_messages">Aus Nachrichten entfernen</string>
|
||||
<string name="add_to_messages">Zu Nachrichten hinzufügen</string>
|
||||
<string name="unfollow">Entfolgen</string>
|
||||
<string name="channel_created">Kanal erstellt</string>
|
||||
<string name="channel_information_changed_to">"Kanalinformationen geändert in"</string>
|
||||
@@ -2134,6 +2136,25 @@
|
||||
<string name="settings_section_tor_routing">Über Tor leiten</string>
|
||||
<string name="settings_section_profile_sections">Abschnitte & Feeds</string>
|
||||
<string name="settings_section_home_tabs">Sichtbare Tabs</string>
|
||||
<string name="settings_section_home_content_types">Inhalte im Feed</string>
|
||||
<string name="home_content_type_text_notes">Textnotizen</string>
|
||||
<string name="home_content_type_comments">Kommentare & Antworten</string>
|
||||
<string name="home_content_type_pictures">Bilder</string>
|
||||
<string name="home_content_type_shorts">Kurzvideos</string>
|
||||
<string name="home_content_type_articles">Artikel</string>
|
||||
<string name="home_content_type_wiki">Wiki-Seiten</string>
|
||||
<string name="home_content_type_polls">Umfragen</string>
|
||||
<string name="home_content_type_classifieds">Kleinanzeigen</string>
|
||||
<string name="home_content_type_voice">Sprachnachrichten</string>
|
||||
<string name="home_content_type_live_activities">Live-Aktivitäten</string>
|
||||
<string name="home_content_type_ephemeral_chat">Ephemere Chats</string>
|
||||
<string name="home_content_type_interactive_stories">Interaktive Geschichten</string>
|
||||
<string name="home_content_type_chess">Schachpartien</string>
|
||||
<string name="home_content_type_birds">Vogelbeobachtungen</string>
|
||||
<string name="home_content_type_attestations">Attestierungen</string>
|
||||
<string name="home_content_type_nips">NIP-Entwürfe</string>
|
||||
<string name="home_content_type_music">Musik & Audio</string>
|
||||
<string name="home_content_type_fundraisers">Spendenaktionen</string>
|
||||
<string name="settings_section_reminders">Erinnerungen</string>
|
||||
<string name="wallet_connect">Wallet Verbindung</string>
|
||||
<string name="language">Sprache</string>
|
||||
@@ -2366,6 +2387,10 @@
|
||||
<string name="relay_group_edit_confirm">Speichern</string>
|
||||
<string name="relay_group_menu_members">Mitglieder</string>
|
||||
<string name="relay_group_menu_edit">Gruppe bearbeiten</string>
|
||||
<string name="relay_group_delete">Gruppe löschen</string>
|
||||
<string name="relay_group_delete_confirm">„%1$s“ löschen? Damit werden die Gruppe und ihr Verlauf für alle entfernt, und das lässt sich nicht rückgängig machen.</string>
|
||||
<string name="buzz_channel_delete">Kanal löschen</string>
|
||||
<string name="buzz_channel_delete_confirm">„%1$s“ löschen? Damit werden der Kanal und seine Nachrichten für alle entfernt, und das lässt sich nicht rückgängig machen.</string>
|
||||
<string name="relay_group_threads_title">Threads</string>
|
||||
<string name="relay_group_pin_message">Nachricht anheften</string>
|
||||
<string name="relay_group_unpin_message">Nachricht loslösen</string>
|
||||
@@ -2447,9 +2472,7 @@
|
||||
<string name="relay_group_browse_popular">Beliebte Relays</string>
|
||||
<string name="relay_group_no_messages_yet">Noch keine Nachrichten</string>
|
||||
<string name="channel_invite_title">Zu %1$s hinzugefügt</string>
|
||||
<string name="channel_invite_body">%1$s hat dich auf %2$s zu diesem Kanal hinzugefügt. In Nachrichten anzeigen?</string>
|
||||
<string name="channel_invite_unknown_actor">Jemand</string>
|
||||
<string name="channel_invite_accept">Anzeigen</string>
|
||||
<string name="channel_invite_ignore">Ignorieren</string>
|
||||
<string name="channel_invite_leave">Verlassen</string>
|
||||
<string name="relay_group_join_to_post">Tritt dieser Gruppe bei, um Nachrichten zu senden.</string>
|
||||
@@ -2509,6 +2532,14 @@
|
||||
<string name="share_target_as_dm">Als DM senden</string>
|
||||
<string name="share_to_dm_title">Senden an…</string>
|
||||
<string name="share_to_dm_start_new">Neue Nachricht</string>
|
||||
<string name="share_target_as_highlight">Neues Highlight</string>
|
||||
<string name="new_highlight_title">Neues Highlight</string>
|
||||
<string name="new_highlight_passage_label">Hervorgehobener Text</string>
|
||||
<string name="new_highlight_passage_placeholder">Was ist dir aufgefallen?</string>
|
||||
<string name="new_highlight_source_label">Quell-URL</string>
|
||||
<string name="new_highlight_note_label">Deine Notiz (optional)</string>
|
||||
<string name="new_highlight_note_placeholder">Füge deine Gedanken hinzu…</string>
|
||||
<string name="highlight_action">Hervorheben</string>
|
||||
<string name="copy_url_to_clipboard">URL in die Zwischenablage kopieren</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Notiz-ID in die Zwischenablage kopieren</string>
|
||||
<string name="add_media_to_gallery">Medien zur Galerie hinzufügen</string>
|
||||
@@ -3276,6 +3307,10 @@
|
||||
<string name="buzz_pin">Kanal anheften</string>
|
||||
<string name="buzz_unpin">Kanal nicht mehr anheften</string>
|
||||
<string name="buzz_dm_section_empty">Noch keine Unterhaltungen</string>
|
||||
<plurals name="buzz_dm_hidden_count">
|
||||
<item quantity="one">%1$d ausgeblendete Unterhaltung</item>
|
||||
<item quantity="other">%1$d ausgeblendete Unterhaltungen</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">%1$d weitere Unterhaltung anzeigen</item>
|
||||
<item quantity="other">Alle %1$d Unterhaltungen anzeigen</item>
|
||||
@@ -4577,6 +4612,7 @@
|
||||
<string name="buzz_persona_system_prompt">System-Prompt</string>
|
||||
<string name="buzz_persona_model">Modell (optional)</string>
|
||||
<string name="buzz_persona_provider">Anbieter (optional)</string>
|
||||
<string name="buzz_persona_runtime">Laufzeitumgebung (optional)</string>
|
||||
<string name="buzz_persona_avatar">Avatar-URL (optional)</string>
|
||||
<string name="buzz_persona_publishing">Wird veröffentlicht…</string>
|
||||
<string name="buzz_persona_publish">Persona veröffentlichen</string>
|
||||
|
||||
@@ -2473,9 +2473,7 @@
|
||||
<string name="relay_group_browse_popular">लोकप्रिय पुनःप्रसारक</string>
|
||||
<string name="relay_group_no_messages_yet">कोई सन्देश नहीं अब तक</string>
|
||||
<string name="channel_invite_title">%1$s में जोडा गया</string>
|
||||
<string name="channel_invite_body">%1$s ने आपको इस प्रणाली में जोडा %2$s पर। क्या इसे सन्देशों में दिखाया जाए।</string>
|
||||
<string name="channel_invite_unknown_actor">कोई व्यक्ति</string>
|
||||
<string name="channel_invite_accept">दिखाएँ</string>
|
||||
<string name="channel_invite_ignore">उपेक्षा करें</string>
|
||||
<string name="channel_invite_leave">छोडें</string>
|
||||
<string name="relay_group_join_to_post">इस समूह से जुडें सन्देशों को भेजने के लिए।</string>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -341,6 +341,7 @@
|
||||
<string name="concord_leave_message">Chcesz opuścić %1$s? Zostaniesz usunięty z listy członków tej społeczności, a synchronizacja z Twoimi urządzeniami zostanie wstrzymana. Społeczność nie zostanie o tym powiadomiona, a Ty nie zostaniesz usunięty z listy jej członków. Wiadomości, których nie będziesz już mógł odszyfrować, mogą okazać się nie do odzyskania, a powrót do społeczności będzie możliwy wyłącznie po otrzymaniu nowego zaproszenia.</string>
|
||||
<string name="concord_leave_owner_warning">To Ty stworzyłeś tę społeczność. Odejście nie powoduje jej usunięcia ani przekazania komukolwiek innemu, ale powoduje usunięcie klucza właściciela przechowywanego na Twojej liście — nie będziesz mógł już nią zarządzać.</string>
|
||||
<string name="concord_edit_relays_desc">Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności.</string>
|
||||
<string name="concord_dissolved_read_only">Ta społeczność została rozwiązana i jest tylko do odczytu. Nadal możesz przeczytać jej historię, ale nie można opublikować żadnych nowych wiadomości.</string>
|
||||
<string name="concord_typing_one">%1$s pisze…</string>
|
||||
<string name="concord_typing_two">%1$s i %2$s piszą…</string>
|
||||
<string name="concord_typing_many">Kilka osób pisze…</string>
|
||||
@@ -457,6 +458,8 @@
|
||||
<string name="refresh">Odśwież</string>
|
||||
<string name="changed_chat_profile_to">Nowy profil czatu:</string>
|
||||
<string name="leave">Wyjdź</string>
|
||||
<string name="remove_from_messages">Usuń z wiadomości</string>
|
||||
<string name="add_to_messages">Dodaj do wiadomości</string>
|
||||
<string name="unfollow">Nie obserwuj</string>
|
||||
<string name="channel_created">Kanał utworzony</string>
|
||||
<string name="channel_information_changed_to">"Informacje o kanale zmienione na"</string>
|
||||
@@ -790,6 +793,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="workout_suggestion_connect_button">Połącz</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="workout_from_health_connect">Z Health Connect</string>
|
||||
<plurals name="workout_suggestion_combined_sessions">
|
||||
<item quantity="one">%1$d aktywność</item>
|
||||
<item quantity="few">%1$d aktywności</item>
|
||||
<item quantity="many">%1$d aktywności</item>
|
||||
<item quantity="other">%1$d aktywności</item>
|
||||
</plurals>
|
||||
<string name="exercise_running">Bieganie</string>
|
||||
<string name="exercise_walking">Spacer</string>
|
||||
<string name="exercise_cycling">Jazda na rowerze</string>
|
||||
@@ -2209,6 +2218,29 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="settings_section_tor_routing">Przejście przez Tor</string>
|
||||
<string name="settings_section_profile_sections">Sekcje & Wątki</string>
|
||||
<string name="settings_section_home_tabs">Widoczne karty</string>
|
||||
<string name="settings_section_home_content_types">Zawartość kanału</string>
|
||||
<string name="home_content_type_text_notes">Notatki tekstowe</string>
|
||||
<string name="home_content_type_reposts">Reposty</string>
|
||||
<string name="home_content_type_comments">Komentarze & odpowiedzi</string>
|
||||
<string name="home_content_type_pictures">Zdjęcia</string>
|
||||
<string name="home_content_type_videos">Filmy wideo</string>
|
||||
<string name="home_content_type_shorts">Filmiki</string>
|
||||
<string name="home_content_type_articles">Artykuły</string>
|
||||
<string name="home_content_type_wiki">Strony Wiki</string>
|
||||
<string name="home_content_type_highlights">Wyróżnienia</string>
|
||||
<string name="home_content_type_polls">Ankiety</string>
|
||||
<string name="home_content_type_classifieds">Ogłoszenia</string>
|
||||
<string name="home_content_type_torrents">Torrenty</string>
|
||||
<string name="home_content_type_voice">Wiadomości głosowe</string>
|
||||
<string name="home_content_type_live_activities">Bieżąca aktywność</string>
|
||||
<string name="home_content_type_ephemeral_chat">Czaty efemeryczne</string>
|
||||
<string name="home_content_type_interactive_stories">Interaktywne opowieści</string>
|
||||
<string name="home_content_type_chess">Szachy</string>
|
||||
<string name="home_content_type_attestations">Świadectwa</string>
|
||||
<string name="home_content_type_nips">Szkice NIP</string>
|
||||
<string name="home_content_type_music">Muzyka & audio</string>
|
||||
<string name="home_content_type_podcasts">Podcasty</string>
|
||||
<string name="home_content_type_fundraisers">Zbiórki funduszy</string>
|
||||
<string name="settings_section_reminders">Przypomnienia</string>
|
||||
<string name="wallet_connect">Podłącz portfel</string>
|
||||
<string name="language">Język</string>
|
||||
@@ -2406,6 +2438,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="chat_type_ephemeral_desc">Lekkie pokoje, które nie utrzymują się w historii.</string>
|
||||
<string name="relay_group_create_title">Utwórz grupę</string>
|
||||
<!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. -->
|
||||
<string name="buzz_channel_create_title">Nowy kanał</string>
|
||||
<string name="buzz_forum_create_title">Nowe forum</string>
|
||||
<string name="buzz_channel_flag_private">Kanał prywatny</string>
|
||||
<string name="buzz_channel_flag_private_desc">Ukryty na liście kanałów i dostępny wyłącznie na zaproszenie. Opcja „Wyłączone” oznacza, że każdy użytkownik tego transmitera może go znaleźć i dołączyć do niego.</string>
|
||||
<string name="buzz_channel_flag_forum">Kanał forum</string>
|
||||
<string name="buzz_channel_flag_forum_desc">Wpisy w wątku zamiast osi czasu czatu. Nie można tego później zmienić.</string>
|
||||
<string name="relay_group_relay_no_nip29">Ten transmiter nie obsługuje grup zgodnych ze standardem NIP-29. Grupa utworzona tutaj nie będzie działać — transmiter nie będzie zarządzał jej nazwą, członkami ani wiadomościami. Wybierz transmiter, który obsługuje grupy oparte na rekomendacjach.</string>
|
||||
<string name="relay_group_create_name">Nazwa grupy</string>
|
||||
<string name="relay_group_create_topic">Temat (opcjonalnie)</string>
|
||||
@@ -2440,6 +2478,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="relay_group_edit_confirm">Zapisz</string>
|
||||
<string name="relay_group_menu_members">Członkowie</string>
|
||||
<string name="relay_group_menu_edit">Edytuj grupę</string>
|
||||
<string name="relay_group_delete">Usuń grupę</string>
|
||||
<string name="relay_group_delete_confirm">Usunąć \"%1$s\"? To usuwa grupę i jej historię dla wszystkich i nie może być cofnięte.</string>
|
||||
<string name="buzz_channel_delete">Usuń kanał</string>
|
||||
<string name="buzz_channel_delete_confirm">Usunąć \"%1$s\"? To usuwa kanał i jego wiadomości dla wszystkich i nie może być cofnięte.</string>
|
||||
<string name="buzz_channel_archive">Archiwizuj kanał</string>
|
||||
<string name="buzz_channel_unarchive">Odarchiwizuj kanał</string>
|
||||
<string name="relay_group_threads_title">Wątki</string>
|
||||
<string name="relay_group_pin_message">Przypnij wiadomość</string>
|
||||
<string name="relay_group_unpin_message">Odepnij wiadomość</string>
|
||||
@@ -2502,6 +2546,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="relay_group_favorite_relay">Faworyzuj ten transmiter</string>
|
||||
<string name="relay_group_threads_empty">Brak wątków. Zacznij od przycisku +.</string>
|
||||
<!-- Same, where this viewer cannot start one: not a member, or a Buzz chat channel (forum posts belong to forum channels). -->
|
||||
<string name="relay_group_threads_empty_read_only">Brak wątków.</string>
|
||||
<string name="relay_group_threads_loading_older">Ładowanie starych wątków…</string>
|
||||
<string name="relay_group_threads_all_caught_up">Brak starszych wątków</string>
|
||||
<string name="relay_group_thread_new">Nowy wątek</string>
|
||||
@@ -2519,6 +2564,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="relay_group_browse_your_relays">Transmitery, których używasz</string>
|
||||
<string name="relay_group_browse_popular">Popularne transmitery</string>
|
||||
<string name="relay_group_no_messages_yet">Brak wiadomości</string>
|
||||
<string name="channel_invite_title">Dodano do %1$s</string>
|
||||
<string name="channel_invite_unknown_actor">Ktoś</string>
|
||||
<string name="channel_invite_ignore">Ignoruj</string>
|
||||
<string name="channel_invite_leave">Wyjdź</string>
|
||||
<string name="relay_group_join_to_post">Dołącz do tej grupy, aby wysyłać wiadomości.</string>
|
||||
<string name="relay_group_invite_only_to_post">Ta grupa jest dostępna wyłącznie dla zaproszonych osób — aby móc publikować posty, potrzebujesz zaproszenia.</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
@@ -2587,6 +2636,14 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="share_target_as_dm">Wyślij jako DM</string>
|
||||
<string name="share_to_dm_title">Wyślij do…</string>
|
||||
<string name="share_to_dm_start_new">Nowa wiadomość</string>
|
||||
<string name="share_target_as_highlight">Nowe wyróżnienie</string>
|
||||
<string name="new_highlight_title">Nowe wyróżnienie</string>
|
||||
<string name="new_highlight_passage_label">Wyróżniony tekst</string>
|
||||
<string name="new_highlight_passage_placeholder">Co szczególnie zwróciło twoją uwagę?</string>
|
||||
<string name="new_highlight_source_label">Adres źródła</string>
|
||||
<string name="new_highlight_note_label">Twoja notatka (opcjonalnie)</string>
|
||||
<string name="new_highlight_note_placeholder">Dodaj swoje przemyślenia…</string>
|
||||
<string name="highlight_action">Wyróżnienie</string>
|
||||
<string name="copy_url_to_clipboard">Skopiuj adres do schowka</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopiuj ID wpisu do schowka</string>
|
||||
<string name="add_media_to_gallery">Dodaj pliki do Galerii</string>
|
||||
@@ -3259,7 +3316,28 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="buzz_message_edited">(edytowane)</string>
|
||||
<string name="buzz_diff_truncated">(diff skrócony)</string>
|
||||
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
|
||||
<string name="buzz_system_member_joined">%1$s dołączył(a)</string>
|
||||
<string name="buzz_system_member_added">%1$s został(a) dodany(a) przez %2$s</string>
|
||||
<string name="buzz_system_member_left">%1$s zostało</string>
|
||||
<string name="buzz_system_member_removed">%1$s został usunięty przez %2$s</string>
|
||||
<string name="buzz_system_topic_changed">%1$s ustawił(a) temat na \"%2$s\"</string>
|
||||
<string name="buzz_system_topic_cleared">%1$s wyczyszczono temat</string>
|
||||
<string name="buzz_system_purpose_changed">%1$s ustawił cel na \"%2$s\"</string>
|
||||
<string name="buzz_system_purpose_cleared">%1$s doprecyzował cel</string>
|
||||
<string name="buzz_system_visibility_open">%1$s otworzył ten kanał — każdy może go znaleźć i dołączyć do niego</string>
|
||||
<string name="buzz_system_visibility_private">%1$s ustawił ten kanał jako prywatny — dostępny tylko dla zaproszonych osób</string>
|
||||
<string name="buzz_system_visibility_other">%1$s zmieniono widoczność na %2$s</string>
|
||||
<string name="buzz_system_ttl_set">%1$s ustawił wiadomości do zniknięcia po %2$s</string>
|
||||
<string name="buzz_system_ttl_cleared">%1$s wyłączył(a) funkcję znikających wiadomości</string>
|
||||
<string name="buzz_system_channel_archived">%1$s zarchiwizował ten kanał</string>
|
||||
<string name="buzz_system_channel_unarchived">%1$s przywrócił ten kanał</string>
|
||||
<string name="buzz_system_channel_created">%1$s utworzył ten kanał</string>
|
||||
<string name="buzz_system_channel_deleted">%1$s usunął ten kanał</string>
|
||||
<string name="buzz_system_message_deleted">%1$s usunął wiadomość</string>
|
||||
<string name="buzz_system_message_deleted_reason">%1$s usunął wiadomość: %2$s</string>
|
||||
<string name="buzz_system_dm_created">%1$s rozpoczął tę rozmowę</string>
|
||||
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
|
||||
<string name="buzz_system_unknown">%1$s: %2$s</string>
|
||||
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
|
||||
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
|
||||
<!-- Buzz forum votes (kind 45002). -->
|
||||
@@ -3519,6 +3597,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="git_repo_settings_topics">Tematy (oddzielone przecinkami)</string>
|
||||
<string name="git_repo_settings_save">Zapisz</string>
|
||||
<string name="git_repositories">Repozytoria Git</string>
|
||||
<string name="highlights">Wyróżnienia</string>
|
||||
<string name="nsite_title">Statyczna Witryna: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Uprawnienia:</string>
|
||||
@@ -3916,7 +3995,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="kind_git_pr_update">Aktualizacja PR</string>
|
||||
<string name="kind_zap_goals">Cele Zap-a</string>
|
||||
<string name="kind_hashtag_follows">Obserwowane hashtagi</string>
|
||||
<string name="kind_highlights">Najważniejsze informacje</string>
|
||||
<string name="kind_highlights">Wyróżnienia</string>
|
||||
<string name="kind_http_auth">Autoryzacja http</string>
|
||||
<string name="kind_index_relay_list">Indeks listy transmiterów</string>
|
||||
<string name="kind_adventure_prologue">Wstęp do przygody</string>
|
||||
@@ -4600,6 +4679,51 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="new_conversation_location_pro_2">Kompatybilny z Bitchatem; dociera do pobliskich użytkowników na tych samych transmiterach.</string>
|
||||
<string name="new_conversation_location_con_1">Publiczny i efemeryczny – brak historii, każdy uczestnik może go odczytać.</string>
|
||||
<!-- Buzz workflow run board -->
|
||||
<string name="buzz_workflow_section_active">Pracuje teraz</string>
|
||||
<string name="buzz_workflow_section_shipped">Wysłano</string>
|
||||
<string name="buzz_workflow_section_closed">Zamknięte</string>
|
||||
<string name="buzz_workflow_gate_needs_you">Wymaga zatwierdzenia</string>
|
||||
<string name="buzz_workflow_gate_awaiting">Oczekiwanie na zatwierdzenie</string>
|
||||
<string name="buzz_workflow_no_description">(brak opisu)</string>
|
||||
<string name="buzz_workflow_by">przez</string>
|
||||
<string name="buzz_workflow_cancel">Anuluj</string>
|
||||
<string name="buzz_workflow_trigger_failed_toast">Nie udało się uruchomić zadania — sprawdź, czy możesz publikować posty w tej przestrzeni roboczej</string>
|
||||
<string name="buzz_workflow_approved_toast">Zatwierdzono — autor tworzy pull request</string>
|
||||
<string name="buzz_workflow_denied_toast">Odmowa – prace zostały odrzucone</string>
|
||||
<string name="buzz_workflow_decision_failed_toast">Nie można opublikować Twojej decyzji — sprawdź, czy możesz publikować posty w tej przestrzeni roboczej</string>
|
||||
<string name="buzz_workflow_view_pr">Zobacz PR</string>
|
||||
<string name="buzz_workflow_closed_denied">Odmowa – odrzucona praca</string>
|
||||
<string name="buzz_workflow_closed_cancelled">Anulowano</string>
|
||||
<string name="buzz_workflow_closed_failed_prefix">Nieudane: %1$s</string>
|
||||
<string name="buzz_workflow_closed_failed">Niepowodzenie</string>
|
||||
<string name="buzz_workflow_pill_queued">W kolejce</string>
|
||||
<string name="buzz_workflow_pill_working">Pracuję</string>
|
||||
<string name="buzz_workflow_pill_needs_approval">Wymaga zatwierdzenia</string>
|
||||
<string name="buzz_workflow_pill_approved">Zatwierdzono</string>
|
||||
<string name="buzz_workflow_pill_shipped">Wysłano</string>
|
||||
<string name="buzz_workflow_pill_failed">Niepowodzenie</string>
|
||||
<string name="buzz_workflow_pill_cancelled">Anulowano</string>
|
||||
<string name="buzz_workflow_pill_denied">Odmowa</string>
|
||||
<string name="buzz_workflow_trigger_title">Uruchom proces</string>
|
||||
<string name="buzz_workflow_trigger_desc">Program wykonuje zadanie, a następnie wstrzymuje się, czekając na zatwierdzenie przez użytkownika, zanim utworzy zgłoszenie PR. Cały kanał ma wgląd w przebieg zadania.</string>
|
||||
<string name="buzz_workflow_no_defs_hint">Nie ma jeszcze żadnych przepływów pracy. Otwórz menu powyżej i wybierz opcję „Nowa definicja…”, aby utworzyć przepływ pracy, a następnie go uruchom.</string>
|
||||
<string name="buzz_workflow_task_label">Co należy zrobić?</string>
|
||||
<string name="buzz_workflow_trigger_run">Uruchom</string>
|
||||
<string name="buzz_workflow_picker_label">Proces</string>
|
||||
<string name="buzz_workflow_picker_empty">Nie zdefiniowano jeszcze procesów</string>
|
||||
<string name="buzz_workflow_picker_choose">Wybierz proces</string>
|
||||
<string name="buzz_workflow_new_definition">Nowa definicja…</string>
|
||||
<string name="buzz_workflow_def_title">Nowa definicja procesu</string>
|
||||
<string name="buzz_workflow_def_desc">Nadaje nazwę dla kanału i publikuje jego recepturę YAML (kind-30620). Rzeczywisty transmiter Buzz uruchamia YAML. W przypadku hostingu własnego moduł uruchamiający wykonuje skonfigurowane polecenie — w tym przypadku definicja służy jedynie do nazwania i skatalogowania uruchomienia.</string>
|
||||
<string name="buzz_workflow_def_name">Nazwa</string>
|
||||
<string name="buzz_workflow_def_name_hint">zbuduj i przetestuj</string>
|
||||
<string name="buzz_workflow_def_yaml">Receptura YAML</string>
|
||||
<string name="buzz_workflow_def_publish_failed">Nie można opublikować definicji — sprawdź czy możesz opublikować wpis w tym obszarze roboczym.</string>
|
||||
<string name="buzz_workflow_publishing">Publikowanie…</string>
|
||||
<string name="buzz_workflow_create_definition">Utwórz definicję</string>
|
||||
<string name="buzz_workflow_empty_title">Jeszcze nie uruchomiono</string>
|
||||
<string name="buzz_workflow_empty_desc">Naciśnij „Nowe uruchomienie” w celu uruchomienia procesu. Moduł wykonawczy realizuje zadanie i zatrzymuje się na etapie zatwierdzenia — przed wysłaniem jakichkolwiek danych zatwierdzenie musi udzielić osoba.</string>
|
||||
<!-- Buzz agent attestation -->
|
||||
<string name="buzz_attest_topbar">Świadectwa</string>
|
||||
<!-- Buzz agent persona editor -->
|
||||
</resources>
|
||||
|
||||
@@ -438,6 +438,8 @@
|
||||
<string name="refresh">Atualizar</string>
|
||||
<string name="changed_chat_profile_to">Novo perfil de chat:</string>
|
||||
<string name="leave">Sair</string>
|
||||
<string name="remove_from_messages">Remover das Mensagens</string>
|
||||
<string name="add_to_messages">Adicionar às Mensagens</string>
|
||||
<string name="unfollow">Desseguir</string>
|
||||
<string name="channel_created">Canal criado</string>
|
||||
<string name="channel_information_changed_to">"Informação do canal mudou para"</string>
|
||||
@@ -2132,6 +2134,28 @@
|
||||
<string name="settings_section_tor_routing">Rotear pelo Tor</string>
|
||||
<string name="settings_section_profile_sections">Seções e feeds</string>
|
||||
<string name="settings_section_home_tabs">Abas visíveis</string>
|
||||
<string name="settings_section_home_content_types">Conteúdo no feed</string>
|
||||
<string name="home_content_type_text_notes">Notas de texto</string>
|
||||
<string name="home_content_type_reposts">Repostagens</string>
|
||||
<string name="home_content_type_comments">Comentários e respostas</string>
|
||||
<string name="home_content_type_pictures">Imagens</string>
|
||||
<string name="home_content_type_videos">Vídeos</string>
|
||||
<string name="home_content_type_shorts">Curtas</string>
|
||||
<string name="home_content_type_articles">Artigos</string>
|
||||
<string name="home_content_type_wiki">Páginas wiki</string>
|
||||
<string name="home_content_type_highlights">Destaques</string>
|
||||
<string name="home_content_type_polls">Enquetes</string>
|
||||
<string name="home_content_type_classifieds">Classificados</string>
|
||||
<string name="home_content_type_voice">Mensagens de voz</string>
|
||||
<string name="home_content_type_live_activities">Atividades ao vivo</string>
|
||||
<string name="home_content_type_ephemeral_chat">Chats efêmeros</string>
|
||||
<string name="home_content_type_interactive_stories">Histórias interativas</string>
|
||||
<string name="home_content_type_chess">Partidas de xadrez</string>
|
||||
<string name="home_content_type_birds">Observações de aves</string>
|
||||
<string name="home_content_type_attestations">Atestações</string>
|
||||
<string name="home_content_type_nips">Rascunhos de NIP</string>
|
||||
<string name="home_content_type_music">Música e áudio</string>
|
||||
<string name="home_content_type_fundraisers">Arrecadações</string>
|
||||
<string name="settings_section_reminders">Lembretes</string>
|
||||
<string name="wallet_connect">Conectar Carteira</string>
|
||||
<string name="language">Linguagem</string>
|
||||
@@ -2364,6 +2388,10 @@
|
||||
<string name="relay_group_edit_confirm">Salvar</string>
|
||||
<string name="relay_group_menu_members">Membros</string>
|
||||
<string name="relay_group_menu_edit">Editar grupo</string>
|
||||
<string name="relay_group_delete">Excluir grupo</string>
|
||||
<string name="relay_group_delete_confirm">Excluir “%1$s”? Isso remove o grupo e seu histórico para todos e não pode ser desfeito.</string>
|
||||
<string name="buzz_channel_delete">Excluir canal</string>
|
||||
<string name="buzz_channel_delete_confirm">Excluir “%1$s”? Isso remove o canal e suas mensagens para todos e não pode ser desfeito.</string>
|
||||
<string name="relay_group_threads_title">Tópicos</string>
|
||||
<string name="relay_group_pin_message">Fixar mensagem</string>
|
||||
<string name="relay_group_unpin_message">Desafixar mensagem</string>
|
||||
@@ -2445,9 +2473,7 @@
|
||||
<string name="relay_group_browse_popular">Relays populares</string>
|
||||
<string name="relay_group_no_messages_yet">Nenhuma mensagem ainda</string>
|
||||
<string name="channel_invite_title">Adicionado a %1$s</string>
|
||||
<string name="channel_invite_body">%1$s adicionou você a este canal em %2$s. Mostrar em Mensagens?</string>
|
||||
<string name="channel_invite_unknown_actor">Alguém</string>
|
||||
<string name="channel_invite_accept">Mostrar</string>
|
||||
<string name="channel_invite_ignore">Ignorar</string>
|
||||
<string name="channel_invite_leave">Sair</string>
|
||||
<string name="relay_group_join_to_post">Entre neste grupo para enviar mensagens.</string>
|
||||
@@ -2507,6 +2533,14 @@
|
||||
<string name="share_target_as_dm">Enviar como DM</string>
|
||||
<string name="share_to_dm_title">Enviar para…</string>
|
||||
<string name="share_to_dm_start_new">Nova mensagem</string>
|
||||
<string name="share_target_as_highlight">Novo destaque</string>
|
||||
<string name="new_highlight_title">Novo destaque</string>
|
||||
<string name="new_highlight_passage_label">Texto destacado</string>
|
||||
<string name="new_highlight_passage_placeholder">O que chamou sua atenção?</string>
|
||||
<string name="new_highlight_source_label">URL de origem</string>
|
||||
<string name="new_highlight_note_label">Sua nota (opcional)</string>
|
||||
<string name="new_highlight_note_placeholder">Adicione seus comentários…</string>
|
||||
<string name="highlight_action">Destacar</string>
|
||||
<string name="copy_url_to_clipboard">Copiar URL para a área de transferência</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copiar ID da nota para a área de transferência</string>
|
||||
<string name="add_media_to_gallery">Adicionar Mídia à Galeria</string>
|
||||
@@ -3274,6 +3308,10 @@
|
||||
<string name="buzz_pin">Fixar canal</string>
|
||||
<string name="buzz_unpin">Desafixar canal</string>
|
||||
<string name="buzz_dm_section_empty">Nenhuma conversa ainda</string>
|
||||
<plurals name="buzz_dm_hidden_count">
|
||||
<item quantity="one">%1$d conversa oculta</item>
|
||||
<item quantity="other">%1$d conversas ocultas</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">Ver mais %1$d conversa</item>
|
||||
<item quantity="other">Ver todas as %1$d conversas</item>
|
||||
@@ -3450,6 +3488,7 @@
|
||||
<string name="git_repo_settings_topics">Tópicos (separados por vírgula)</string>
|
||||
<string name="git_repo_settings_save">Salvar</string>
|
||||
<string name="git_repositories">Repositórios Git</string>
|
||||
<string name="highlights">Destaques</string>
|
||||
<string name="nsite_title">Site Estático: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Permissões:</string>
|
||||
|
||||
@@ -2544,9 +2544,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="relay_group_browse_popular">Popularni releji</string>
|
||||
<string name="relay_group_no_messages_yet">Ni sporočil</string>
|
||||
<string name="channel_invite_title">Dodano v: %1$s</string>
|
||||
<string name="channel_invite_body">%1$s vas je dodal v ta kanal na %2$s. Želite to prikazati med sporočili?</string>
|
||||
<string name="channel_invite_unknown_actor">Nekdo</string>
|
||||
<string name="channel_invite_accept">Prikaži</string>
|
||||
<string name="channel_invite_ignore">Prezri</string>
|
||||
<string name="channel_invite_leave">Zapusti</string>
|
||||
<string name="relay_group_join_to_post">Pridružite se tej skupini, da boste lahko pošiljali sporočila.</string>
|
||||
|
||||
@@ -438,6 +438,8 @@
|
||||
<string name="refresh">Uppdatera</string>
|
||||
<string name="changed_chat_profile_to">Ny chattprofil:</string>
|
||||
<string name="leave">Lämna</string>
|
||||
<string name="remove_from_messages">Ta bort från Meddelanden</string>
|
||||
<string name="add_to_messages">Lägg till i Meddelanden</string>
|
||||
<string name="unfollow">Sluta följa</string>
|
||||
<string name="channel_created">Kanal skapad</string>
|
||||
<string name="channel_information_changed_to">"Kanalinformation ändrad till"</string>
|
||||
@@ -2132,6 +2134,30 @@
|
||||
<string name="settings_section_tor_routing">Dirigera via Tor</string>
|
||||
<string name="settings_section_profile_sections">Sektioner & flöden</string>
|
||||
<string name="settings_section_home_tabs">Synliga flikar</string>
|
||||
<string name="settings_section_home_content_types">Innehåll i flödet</string>
|
||||
<string name="home_content_type_text_notes">Textanteckningar</string>
|
||||
<string name="home_content_type_reposts">Återinlägg</string>
|
||||
<string name="home_content_type_comments">Kommentarer & svar</string>
|
||||
<string name="home_content_type_pictures">Bilder</string>
|
||||
<string name="home_content_type_videos">Videor</string>
|
||||
<string name="home_content_type_shorts">Kortfilmer</string>
|
||||
<string name="home_content_type_articles">Artiklar</string>
|
||||
<string name="home_content_type_wiki">Wiki-sidor</string>
|
||||
<string name="home_content_type_highlights">Höjdpunkter</string>
|
||||
<string name="home_content_type_polls">Omröstningar</string>
|
||||
<string name="home_content_type_classifieds">Annonser</string>
|
||||
<string name="home_content_type_torrents">Torrenter</string>
|
||||
<string name="home_content_type_voice">Röstmeddelanden</string>
|
||||
<string name="home_content_type_live_activities">Liveaktiviteter</string>
|
||||
<string name="home_content_type_ephemeral_chat">Tillfälliga chattar</string>
|
||||
<string name="home_content_type_interactive_stories">Interaktiva berättelser</string>
|
||||
<string name="home_content_type_chess">Schackpartier</string>
|
||||
<string name="home_content_type_birds">Fågelobservationer</string>
|
||||
<string name="home_content_type_attestations">Attesteringar</string>
|
||||
<string name="home_content_type_nips">NIP-utkast</string>
|
||||
<string name="home_content_type_music">Musik & ljud</string>
|
||||
<string name="home_content_type_podcasts">Poddar</string>
|
||||
<string name="home_content_type_fundraisers">Insamlingar</string>
|
||||
<string name="settings_section_reminders">Påminnelser</string>
|
||||
<string name="wallet_connect">Plånboksanslut</string>
|
||||
<string name="language">Språk</string>
|
||||
@@ -2364,6 +2390,10 @@
|
||||
<string name="relay_group_edit_confirm">Spara</string>
|
||||
<string name="relay_group_menu_members">Medlemmar</string>
|
||||
<string name="relay_group_menu_edit">Redigera grupp</string>
|
||||
<string name="relay_group_delete">Radera grupp</string>
|
||||
<string name="relay_group_delete_confirm">Radera ”%1$s”? Det tar bort gruppen och dess historik för alla, och kan inte ångras.</string>
|
||||
<string name="buzz_channel_delete">Radera kanal</string>
|
||||
<string name="buzz_channel_delete_confirm">Radera ”%1$s”? Det tar bort kanalen och dess meddelanden för alla, och kan inte ångras.</string>
|
||||
<string name="relay_group_threads_title">Trådar</string>
|
||||
<string name="relay_group_pin_message">Fäst meddelande</string>
|
||||
<string name="relay_group_unpin_message">Lossa meddelande</string>
|
||||
@@ -2445,9 +2475,7 @@
|
||||
<string name="relay_group_browse_popular">Populära reläer</string>
|
||||
<string name="relay_group_no_messages_yet">Inga meddelanden ännu</string>
|
||||
<string name="channel_invite_title">Tillagd i %1$s</string>
|
||||
<string name="channel_invite_body">%1$s lade till dig i den här kanalen på %2$s. Visa den i Meddelanden?</string>
|
||||
<string name="channel_invite_unknown_actor">Någon</string>
|
||||
<string name="channel_invite_accept">Visa</string>
|
||||
<string name="channel_invite_ignore">Ignorera</string>
|
||||
<string name="channel_invite_leave">Lämna</string>
|
||||
<string name="relay_group_join_to_post">Gå med i den här gruppen för att skicka meddelanden.</string>
|
||||
@@ -2507,6 +2535,14 @@
|
||||
<string name="share_target_as_dm">Skicka som DM</string>
|
||||
<string name="share_to_dm_title">Skicka till…</string>
|
||||
<string name="share_to_dm_start_new">Nytt meddelande</string>
|
||||
<string name="share_target_as_highlight">Ny höjdpunkt</string>
|
||||
<string name="new_highlight_title">Ny höjdpunkt</string>
|
||||
<string name="new_highlight_passage_label">Markerad text</string>
|
||||
<string name="new_highlight_passage_placeholder">Vad fastnade du för?</string>
|
||||
<string name="new_highlight_source_label">Käll-URL</string>
|
||||
<string name="new_highlight_note_label">Din anteckning (valfritt)</string>
|
||||
<string name="new_highlight_note_placeholder">Lägg till dina tankar…</string>
|
||||
<string name="highlight_action">Markera</string>
|
||||
<string name="copy_url_to_clipboard">Kopiera URL till urklipp</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopiera anteckningens ID till urklipp</string>
|
||||
<string name="add_media_to_gallery">Lägg till media i galleriet</string>
|
||||
@@ -3274,6 +3310,10 @@
|
||||
<string name="buzz_pin">Fäst kanal</string>
|
||||
<string name="buzz_unpin">Lossa kanal</string>
|
||||
<string name="buzz_dm_section_empty">Inga konversationer än</string>
|
||||
<plurals name="buzz_dm_hidden_count">
|
||||
<item quantity="one">%1$d dold konversation</item>
|
||||
<item quantity="other">%1$d dolda konversationer</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">Visa %1$d konversation till</item>
|
||||
<item quantity="other">Visa alla %1$d konversationer</item>
|
||||
@@ -3450,6 +3490,7 @@
|
||||
<string name="git_repo_settings_topics">Ämnen (kommaseparerade)</string>
|
||||
<string name="git_repo_settings_save">Spara</string>
|
||||
<string name="git_repositories">Git-repositories</string>
|
||||
<string name="highlights">Höjdpunkter</string>
|
||||
<string name="nsite_title">Statisk webbplats: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Behörigheter:</string>
|
||||
|
||||
@@ -2426,8 +2426,10 @@
|
||||
|
||||
<string name="hashtag_exclusive">Hashtag-exclusive Post</string>
|
||||
|
||||
<string name="external_content_title">External Content</string>
|
||||
<string name="external_url_scope">Comment on a website</string>
|
||||
<string name="external_id_scope">Comment on an external resource</string>
|
||||
<string name="url_preview_open_in_browser">Open in browser</string>
|
||||
|
||||
<string name="long_form_reading_minutes">%1$d min read</string>
|
||||
|
||||
@@ -2574,6 +2576,7 @@
|
||||
<string name="relay_group_create_title">Create a group</string>
|
||||
<!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. -->
|
||||
<string name="buzz_channel_create_title">New channel</string>
|
||||
<string name="buzz_forum_create_title">New forum</string>
|
||||
<string name="buzz_channel_flag_private">Private channel</string>
|
||||
<string name="buzz_channel_flag_private_desc">Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it.</string>
|
||||
<string name="buzz_channel_flag_forum">Forum channel</string>
|
||||
@@ -2616,6 +2619,8 @@
|
||||
<string name="relay_group_delete_confirm">Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone.</string>
|
||||
<string name="buzz_channel_delete">Delete channel</string>
|
||||
<string name="buzz_channel_delete_confirm">Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone.</string>
|
||||
<string name="buzz_channel_archive">Archive channel</string>
|
||||
<string name="buzz_channel_unarchive">Unarchive channel</string>
|
||||
<string name="relay_group_threads_title">Threads</string>
|
||||
<string name="relay_group_pin_message">Pin message</string>
|
||||
<string name="relay_group_unpin_message">Unpin message</string>
|
||||
@@ -2697,9 +2702,7 @@
|
||||
<string name="relay_group_browse_popular">Popular relays</string>
|
||||
<string name="relay_group_no_messages_yet">No messages yet</string>
|
||||
<string name="channel_invite_title">Added to %1$s</string>
|
||||
<string name="channel_invite_body">%1$s added you to this channel on %2$s. Show it in Messages?</string>
|
||||
<string name="channel_invite_unknown_actor">Someone</string>
|
||||
<string name="channel_invite_accept">Show</string>
|
||||
<string name="channel_invite_ignore">Ignore</string>
|
||||
<string name="channel_invite_leave">Leave</string>
|
||||
<string name="relay_group_join_to_post">Join this group to send messages.</string>
|
||||
@@ -2769,6 +2772,9 @@
|
||||
<string name="share_to_dm_title">Send to…</string>
|
||||
<string name="share_to_dm_start_new">New message</string>
|
||||
<string name="share_target_as_highlight">New Highlight</string>
|
||||
<string name="share_target_as_picture">New Picture</string>
|
||||
<string name="share_target_as_short_video">New Short</string>
|
||||
<string name="share_target_as_video">New Video</string>
|
||||
<string name="new_highlight_title">New Highlight</string>
|
||||
<string name="new_highlight_passage_label">Highlighted text</string>
|
||||
<string name="new_highlight_passage_placeholder">What stood out to you?</string>
|
||||
@@ -3607,6 +3613,7 @@
|
||||
|
||||
<string name="relay_group_section_channels">Channels</string>
|
||||
<string name="relay_group_section_forums">Forums</string>
|
||||
<string name="relay_group_section_archived">Archived</string>
|
||||
<string name="buzz_agent_working">Working…</string>
|
||||
<string name="relay_group_add_member">Add member</string>
|
||||
<string name="buzz_community_add_people">Add people to this workspace</string>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VideoPostKindTest {
|
||||
private val portrait = DimensionTag(1080, 1920)
|
||||
private val landscape = DimensionTag(1920, 1080)
|
||||
private val square = DimensionTag(1080, 1080)
|
||||
|
||||
@Test
|
||||
fun autoFollowsTheOrientation() {
|
||||
assertTrue(VideoPostKind.AUTO.isShort(portrait))
|
||||
assertFalse(VideoPostKind.AUTO.isShort(landscape))
|
||||
// A square video is not taller than it is wide, so it stays a normal video.
|
||||
assertFalse(VideoPostKind.AUTO.isShort(square))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortStaysShortEvenWhenTheFootageIsLandscape() {
|
||||
// The Shorts feed only reads kind 22. A landscape video shared to the "New Short" target
|
||||
// (or recorded from the Shorts composer) has to be a short or it never shows up there.
|
||||
assertTrue(VideoPostKind.SHORT.isShort(landscape))
|
||||
assertTrue(VideoPostKind.SHORT.isShort(portrait))
|
||||
assertTrue(VideoPostKind.SHORT.isShort(square))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalStaysNormalEvenWhenTheFootageIsPortrait() {
|
||||
assertFalse(VideoPostKind.NORMAL.isShort(portrait))
|
||||
assertFalse(VideoPostKind.NORMAL.isShort(landscape))
|
||||
assertFalse(VideoPostKind.NORMAL.isShort(square))
|
||||
}
|
||||
}
|
||||
+27
-9
@@ -21,34 +21,52 @@
|
||||
package com.vitorpamplona.amethyst.navigation
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.ShareIntentRouting
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import com.vitorpamplona.amethyst.ui.navigation.ShareTarget
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ShareIntentRoutingTest {
|
||||
@Test
|
||||
fun detectsAliasByExactClassName() {
|
||||
assertTrue(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.ShareAsDMAlias"))
|
||||
assertEquals(ShareTarget.DIRECT_MESSAGE, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsDMAlias"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detectsAliasRegardlessOfPackagePrefix() {
|
||||
// Flavors can change the resolved package prefix; match on the simple name.
|
||||
assertTrue(ShareIntentRouting.isShareAsDm("com.example.fork.ui.ShareAsDMAlias"))
|
||||
assertEquals(ShareTarget.DIRECT_MESSAGE, ShareIntentRouting.targetOf("com.example.fork.ui.ShareAsDMAlias"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMainActivity() {
|
||||
assertFalse(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.MainActivity"))
|
||||
fun detectsEveryAlias() {
|
||||
assertEquals(ShareTarget.HIGHLIGHT, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsHighlightAlias"))
|
||||
assertEquals(ShareTarget.PICTURE, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsPictureAlias"))
|
||||
assertEquals(ShareTarget.SHORT_VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsShortVideoAlias"))
|
||||
assertEquals(ShareTarget.VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsVideoAlias"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNull() {
|
||||
assertFalse(ShareIntentRouting.isShareAsDm(null))
|
||||
fun mainActivityIsTheDefaultNewPostTarget() {
|
||||
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.MainActivity"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullIsTheDefaultNewPostTarget() {
|
||||
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsSuffixThatIsNotASimpleName() {
|
||||
assertFalse(ShareIntentRouting.isShareAsDm("com.evil.XShareAsDMAlias"))
|
||||
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.evil.XShareAsDMAlias"))
|
||||
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.evil.XShareAsVideoAlias"))
|
||||
}
|
||||
|
||||
/**
|
||||
* "ShareAsShortVideoAlias" also ends with "VideoAlias", so a sloppier match would route every
|
||||
* short to the plain video composer (kind 21 instead of 22 — the wrong feed).
|
||||
*/
|
||||
@Test
|
||||
fun shortVideoAliasDoesNotCollideWithTheVideoAlias() {
|
||||
assertEquals(ShareTarget.SHORT_VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsShortVideoAlias"))
|
||||
}
|
||||
}
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.navigation
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.ShareIntentRouting
|
||||
import com.vitorpamplona.amethyst.ui.navigation.ShareTarget
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Guards the one link in the share-target chain the compiler cannot see: the `<activity-alias>`
|
||||
* names in AndroidManifest.xml against the `SHARE_AS_*_ALIAS_SIMPLE_NAME` constants that
|
||||
* [ShareIntentRouting] matches them by. Renaming an alias on one side only still builds and still
|
||||
* runs — the share just silently opens the wrong composer (a picture share falls through to the
|
||||
* default "New Post" target and becomes a text note).
|
||||
*
|
||||
* Checked in both directions, so neither a renamed constant nor a new alias without a constant
|
||||
* slips through.
|
||||
*/
|
||||
class ShareTargetManifestTest {
|
||||
private val aliasPattern = Regex("""<activity-alias[^>]*android:name="\.ui\.(\w+)"""", RegexOption.DOT_MATCHES_ALL)
|
||||
|
||||
private fun manifest(): String {
|
||||
// Gradle runs unit tests with the module directory as the working directory; fall back to
|
||||
// the repo root so the test also passes when run from an IDE configured that way.
|
||||
val candidates =
|
||||
listOf(
|
||||
File("src/main/AndroidManifest.xml"),
|
||||
File("amethyst/src/main/AndroidManifest.xml"),
|
||||
)
|
||||
val found = candidates.firstOrNull { it.isFile }
|
||||
assertTrue(
|
||||
"Could not locate AndroidManifest.xml from ${File(".").absolutePath}",
|
||||
found != null,
|
||||
)
|
||||
return found!!.readText()
|
||||
}
|
||||
|
||||
private fun declaredAliases(): Set<String> =
|
||||
aliasPattern
|
||||
.findAll(manifest())
|
||||
.map { it.groupValues[1] }
|
||||
.toSet()
|
||||
|
||||
@Test
|
||||
fun everyRoutedAliasIsDeclaredInTheManifest() {
|
||||
val declared = declaredAliases()
|
||||
|
||||
val expected =
|
||||
listOf(
|
||||
ShareIntentRouting.SHARE_AS_DM_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME,
|
||||
)
|
||||
|
||||
expected.forEach {
|
||||
assertTrue(
|
||||
"ShareIntentRouting routes \"$it\" but AndroidManifest.xml declares no such " +
|
||||
"<activity-alias android:name=\".ui.$it\">. Declared: $declared",
|
||||
it in declared,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyDeclaredAliasRoutesToItsOwnTarget() {
|
||||
declaredAliases().forEach { alias ->
|
||||
val target = ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.$alias")
|
||||
assertTrue(
|
||||
"AndroidManifest.xml declares <activity-alias android:name=\".ui.$alias\"> but " +
|
||||
"ShareIntentRouting has no constant for it, so shares to it fall through to " +
|
||||
"the default New Post composer.",
|
||||
target != ShareTarget.NEW_POST,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aliasesResolveToDistinctTargets() {
|
||||
val aliases = declaredAliases()
|
||||
val targets = aliases.map { ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.$it") }
|
||||
|
||||
assertEquals(
|
||||
"Two aliases resolve to the same ShareTarget — one of them is shadowed by a " +
|
||||
"suffix match. Aliases: $aliases, targets: $targets",
|
||||
targets.size,
|
||||
targets.toSet().size,
|
||||
)
|
||||
}
|
||||
|
||||
/** The media targets are the only ones that accept a multi-file selection. */
|
||||
@Test
|
||||
fun mediaAliasesAcceptSendMultiple() {
|
||||
val text = manifest()
|
||||
|
||||
listOf(
|
||||
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME,
|
||||
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME,
|
||||
).forEach { alias ->
|
||||
val block =
|
||||
text
|
||||
.substringAfter("android:name=\".ui.$alias\"")
|
||||
.substringBefore("</activity-alias>")
|
||||
|
||||
assertTrue(
|
||||
"The \".ui.$alias\" share target has no SEND_MULTIPLE intent-filter, so sharing " +
|
||||
"several files at once will not offer it.",
|
||||
block.contains("android.intent.action.SEND_MULTIPLE"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset
|
||||
import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate
|
||||
import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The "Can't reach this relay over Tor" banner used to be driven by a bare 6s timer, so it appeared on
|
||||
* a relay that was connected and delivering. These pin the gates that replaced it.
|
||||
*/
|
||||
class TorClearnetFallbackTest {
|
||||
private fun offer(
|
||||
usesTor: Boolean = true,
|
||||
torIsUp: Boolean = true,
|
||||
trustingMovesToClearnet: Boolean = true,
|
||||
disconnectedLongEnough: Boolean = true,
|
||||
) = shouldOfferTorClearnetFallback(usesTor, torIsUp, trustingMovesToClearnet, disconnectedLongEnough)
|
||||
|
||||
@Test
|
||||
fun offersWhenTorRoutedRelayStaysSilent() {
|
||||
assertTrue(offer())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentWhileTheRelayIsReachable() {
|
||||
// The regression: the socket is open and the relay is replying, so there is nothing to escape.
|
||||
// The screen models that as the debounce never completing.
|
||||
assertFalse(offer(disconnectedLongEnough = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentWhenTheRelayIsNotRoutedOverTor() {
|
||||
// Tor can be on globally while this relay is dialed over clearnet (onion/localhost/trusted
|
||||
// presets) — blaming Tor for that relay's silence would be wrong.
|
||||
assertFalse(offer(usesTor = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentWhileTorItselfIsStillBootstrapping() {
|
||||
// Nothing Tor-routed connects during bootstrap — that's Tor's failure, not the relay's.
|
||||
assertFalse(offer(torIsUp = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentWhenTrustingWouldNotChangeRouting() {
|
||||
assertFalse(offer(trustingMovesToClearnet = false))
|
||||
}
|
||||
|
||||
// --- the routing inputs the screen feeds the predicate, read off the real presets ---
|
||||
|
||||
private val relay = NormalizedRelayUrl("wss://buzz.example.com/")
|
||||
|
||||
private fun evaluation(preset: TorSettings) =
|
||||
TorRelayEvaluation(
|
||||
torSettings =
|
||||
TorRelaySettings(
|
||||
torType = preset.torType,
|
||||
onionRelaysViaTor = preset.onionRelaysViaTor,
|
||||
dmRelaysViaTor = preset.dmRelaysViaTor,
|
||||
newRelaysViaTor = preset.newRelaysViaTor,
|
||||
trustedRelaysViaTor = preset.trustedRelaysViaTor,
|
||||
moneyOperationsViaTor = preset.moneyOperationsViaTor,
|
||||
),
|
||||
trustedRelayList = emptySet(),
|
||||
dmRelayList = emptySet(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun defaultPresetRoutesANewRelayOverTorAndTrustingEscapesIt() {
|
||||
val eval = evaluation(torDefaultPreset)
|
||||
assertTrue(eval.useTor(relay))
|
||||
assertTrue(!eval.torSettings.trustedRelaysViaTor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun privacyPresetsKeepTrustedRelaysOnTorSoTheOfferIsWithheld() {
|
||||
// Both keep trusted relays on Tor: adding this relay to the Trusted list would move nothing,
|
||||
// so the banner must not offer it (the button would be inert).
|
||||
assertTrue(evaluation(torSmallPayloadsPreset).torSettings.trustedRelaysViaTor)
|
||||
assertTrue(evaluation(torFullyPrivate).torSettings.trustedRelaysViaTor)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Login & Auth -->
|
||||
<string name="login_title">Welkom bij Amethyst</string>
|
||||
<string name="login_subtitle">Meld je aan bij je Nostr-account</string>
|
||||
<string name="login_subtitle_desktop">Een Nostr-client voor desktop</string>
|
||||
<string name="login_card_title">Inloggen met je Nostr-sleutel</string>
|
||||
<string name="login_card_subtitle">nsec voor volledige toegang, bunker:// voor remote signer, of npub voor alleen-lezen</string>
|
||||
<string name="login_with_key">Inloggen met sleutel</string>
|
||||
<string name="login_button">Inloggen</string>
|
||||
<string name="login_generate_new">Nieuwe sleutel genereren</string>
|
||||
<string name="login_generate_button">Nieuwe genereren</string>
|
||||
<string name="login_key_hint">Voer je privésleutel (nsec) of openbare sleutel (npub) in</string>
|
||||
<string name="login_key_label">nsec, bunker:// of npub</string>
|
||||
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
|
||||
<string name="login_show_key">Sleutel tonen</string>
|
||||
<string name="login_hide_key">Sleutel verbergen</string>
|
||||
<!-- New Key Warning -->
|
||||
<string name="new_key_warning_title">BELANGRIJK: Bewaar je sleutels!</string>
|
||||
<string name="new_key_warning_message">Je geheime sleutel (nsec) is de ENIGE manier om toegang te krijgen tot je account. Als je hem kwijtraakt, is je account voorgoed weg. Bewaar hem op een veilige plek!</string>
|
||||
<string name="new_key_public_label">Openbare sleutel (deelbaar):</string>
|
||||
<string name="new_key_secret_label">Geheime sleutel (deel dit NOOIT!):</string>
|
||||
<string name="new_key_continue_button">Ik heb mijn sleutels bewaard, doorgaan</string>
|
||||
<!-- Common Actions -->
|
||||
<string name="action_copy">Kopiëren</string>
|
||||
<string name="action_paste">Plakken</string>
|
||||
<string name="action_cancel">Annuleren</string>
|
||||
<string name="action_ok">OK</string>
|
||||
<string name="action_save">Opslaan</string>
|
||||
<string name="action_delete">Verwijderen</string>
|
||||
<string name="action_share">Delen</string>
|
||||
<!-- Errors -->
|
||||
<string name="error_invalid_key">Ongeldig sleutelformaat. Controleer het en probeer opnieuw.</string>
|
||||
<string name="error_network">Netwerkfout. Controleer je verbinding.</string>
|
||||
<string name="error_generic">Er is een fout opgetreden. Probeer het opnieuw.</string>
|
||||
<!-- Loading & Empty States -->
|
||||
<string name="action_refresh">Vernieuwen</string>
|
||||
<string name="action_try_again">Opnieuw proberen</string>
|
||||
<string name="feed_empty">Feed is empty</string>
|
||||
<string name="error_loading_feed">Error loading feed: %s</string>
|
||||
<!-- Placeholder Screens -->
|
||||
<string name="screen_search_title">Zoeken</string>
|
||||
<string name="screen_search_description">Zoek naar gebruikers, notes en hashtags.</string>
|
||||
<string name="screen_messages_title">Berichten</string>
|
||||
<string name="screen_messages_description">Je versleutelde directe berichten verschijnen hier.</string>
|
||||
<string name="screen_notifications_title">Meldingen</string>
|
||||
<string name="screen_notifications_description">Vermeldingen, antwoorden en reacties verschijnen hier.</string>
|
||||
<!-- Accessibility -->
|
||||
<string name="accessibility_user_avatar">Avatar van gebruiker</string>
|
||||
<string name="accessibility_navigate">Navigeren</string>
|
||||
<!-- Relay history paging (shared feed markers + status card) -->
|
||||
<string name="chats_history_loading_label">Laden:</string>
|
||||
<string name="chats_history_fully_loaded_label">Volledig geladen:</string>
|
||||
<string name="chats_history_fully_loaded">(volledig geladen)</string>
|
||||
<string name="chats_history_by_relay">Geschiedenis per relay</string>
|
||||
<string name="chats_history_stalled_retry">Wordt opnieuw geprobeerd als je dit scherm opnieuw opent</string>
|
||||
<string name="chats_history_older">Oudere %1$s berichten</string>
|
||||
<string name="chats_history_all_caught_up">Helemaal bij</string>
|
||||
<string name="chats_history_reached_start">Begin van je %1$s berichten bereikt</string>
|
||||
<string name="chats_history_subtitle">%1$s · %2$s · geladen sinds %3$s</string>
|
||||
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
|
||||
<string name="chats_history_waiting">waiting on %1$s</string>
|
||||
<string name="chats_history_incomplete">Sommige relays reageerden niet</string>
|
||||
<string name="chats_history_incomplete_sub">%1$s onbereikbaar · tik om te zien welke</string>
|
||||
<string name="chats_history_relays_title">%1$s · geschiedenis per relay</string>
|
||||
<string name="chats_history_relay_since">sinds %1$s</string>
|
||||
<string name="action_dismiss">Negeren</string>
|
||||
<plurals name="chats_history_relays">
|
||||
<item quantity="one">%1$d relay</item>
|
||||
<item quantity="other">%1$d relays</item>
|
||||
</plurals>
|
||||
<!-- Notes & Replies -->
|
||||
<string name="replying_to">replying to </string>
|
||||
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
|
||||
<string name="nsite_title">nSite: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_kind">nApplet</string>
|
||||
<string name="nsite_website_kind">nSite</string>
|
||||
<string name="napplet_card_permissions">Waar het toegang toe heeft</string>
|
||||
<string name="nsite_root_site">Root Site</string>
|
||||
<string name="nsite_source">Bron:</string>
|
||||
<string name="nsite_servers">Servers:</string>
|
||||
<string name="nsite_open">Openen</string>
|
||||
<!-- Custom emoji suggestions (NIP-30) -->
|
||||
<string name="use_direct_url">Directe URL gebruiken</string>
|
||||
<!-- Nicknames (NIP-85 contact cards) -->
|
||||
<string name="nickname_dialog_title">Bijnaam</string>
|
||||
<string name="nickname_dialog_explainer">Wordt aan jou getoond in plaats van de naam van deze gebruiker, overal in de app. Het wordt versleuteld opgeslagen in je contactkaart: alleen jij kunt het lezen. Typ : om je eigen emoji\'s te gebruiken.</string>
|
||||
<string name="nickname_label">Bijnaam</string>
|
||||
<string name="nickname_summary_label">Privénote over deze gebruiker</string>
|
||||
<string name="nickname_save">Opslaan</string>
|
||||
<string name="nickname_cancel">Annuleren</string>
|
||||
<string name="git_status_open">Openen</string>
|
||||
<string name="git_status_merged">Samengevoegd</string>
|
||||
<string name="git_status_closed">Gesloten</string>
|
||||
@@ -53,11 +131,16 @@
|
||||
<string name="road_event_traffic_jam">File</string>
|
||||
<string name="road_event_unknown">Weggebeurtenis</string>
|
||||
<string name="podcast_value_zap_split_hint">Zaps hiervoor worden verdeeld over:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_for_value">Value-for-Value</string>
|
||||
<string name="relay_monitor_rtt_open">Openen</string>
|
||||
<string name="relay_monitor_rtt_read">Lezen</string>
|
||||
<string name="relay_monitor_rtt_write">Schrijven</string>
|
||||
<string name="relay_monitor_network">Netwerk</string>
|
||||
<string name="relay_monitor_relay_type">Type</string>
|
||||
<string name="relay_monitor_requirements">Vereisten</string>
|
||||
<string name="relay_monitor_supported_nips">Ondersteunde NIPs</string>
|
||||
<string name="relay_monitor_ms">%1$d ms</string>
|
||||
<string name="relay_discovery_accepted_kinds">Geaccepteerde kinds</string>
|
||||
<string name="relay_discovery_geohash">Locatie</string>
|
||||
<string name="calendar_rsvp_going">Ga</string>
|
||||
|
||||
+3
@@ -178,6 +178,9 @@ class RelayGroupChannel(
|
||||
|
||||
fun isPrivate(): Boolean = event?.isPrivate() ?: false
|
||||
|
||||
/** Buzz-only: the relay has archived this channel (hidden from the sidebar, but not deleted). */
|
||||
fun isArchived(): Boolean = event?.isArchived() ?: false
|
||||
|
||||
fun isRestricted(): Boolean = event?.isRestricted() ?: false
|
||||
|
||||
fun isClosed(): Boolean = event?.isClosed() ?: false
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.nip29RelayGroups
|
||||
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The set of NIP-29 relay-group channels this device has **deleted** (kind-9008), keyed by
|
||||
* [GroupId.toKey] (`id@relay`, so a group is scoped to its host relay — group ids are only
|
||||
* unique per relay).
|
||||
*
|
||||
* A delete is terminal and destroys the group for everyone: the relay drops it and stops serving
|
||||
* its kind-39000 metadata. But the client already holds that metadata in `LocalCache`, and a Buzz
|
||||
* relay may keep re-announcing a stale kind-44100 member-added notification that would re-surface
|
||||
* the channel in the community's browse list — after a restart too, since it's re-fetched from the
|
||||
* relay. So the deletion has to be remembered client-side and the channel filtered out everywhere
|
||||
* the list is built.
|
||||
*
|
||||
* Like [BuzzChannelStars], there is no personal Nostr event for "I deleted this from my view", so
|
||||
* this is a process-wide singleton mirrored to a device-global store by the platform
|
||||
* ([com.vitorpamplona.amethyst] `RelayGroupDeletionPreferences`) and restored at startup. Deleting
|
||||
* is authoritative and terminal, so an entry is only ever added, never removed.
|
||||
*/
|
||||
object RelayGroupDeletions {
|
||||
private val deleted = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** The deleted group keys ([GroupId.toKey]); the community view collects this to hide them. */
|
||||
val flow: StateFlow<Set<String>> = deleted
|
||||
|
||||
fun isDeleted(groupKey: String): Boolean = groupKey in deleted.value
|
||||
|
||||
fun isDeleted(groupId: GroupId): Boolean = isDeleted(groupId.toKey())
|
||||
|
||||
/** Record [groupId] as deleted (idempotent). */
|
||||
fun markDeleted(groupId: GroupId) = markDeleted(groupId.toKey())
|
||||
|
||||
/** Record [groupKey] ([GroupId.toKey]) as deleted (idempotent). */
|
||||
fun markDeleted(groupKey: String) {
|
||||
while (true) {
|
||||
val current = deleted.value
|
||||
if (groupKey in current) return
|
||||
if (deleted.compareAndSet(current, current + groupKey)) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the whole set — used to restore from disk at startup. */
|
||||
fun restore(keys: Set<String>) {
|
||||
deleted.value = keys
|
||||
}
|
||||
|
||||
/** Test-only: clears the set so unit tests don't leak state into each other. */
|
||||
fun clearForTesting() {
|
||||
deleted.value = emptySet()
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.nip29RelayGroups
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The device-global "deleted channels" bookkeeping: a delete is relay-scoped (keyed by
|
||||
* [GroupId.toKey]) and terminal (only ever added), so the same id on a different relay stays visible.
|
||||
*/
|
||||
class RelayGroupDeletionsTest {
|
||||
private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com")
|
||||
private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com")
|
||||
private val gid = "0123456789abcdef"
|
||||
|
||||
@BeforeTest
|
||||
fun reset() = RelayGroupDeletions.clearForTesting()
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() = RelayGroupDeletions.clearForTesting()
|
||||
|
||||
@Test
|
||||
fun marksAChannelDeletedAndReflectsInTheFlow() {
|
||||
val group = GroupId(gid, relayA)
|
||||
assertFalse(RelayGroupDeletions.isDeleted(group))
|
||||
|
||||
RelayGroupDeletions.markDeleted(group)
|
||||
|
||||
assertTrue(RelayGroupDeletions.isDeleted(group))
|
||||
assertTrue(RelayGroupDeletions.isDeleted(group.toKey()))
|
||||
assertEquals(setOf(group.toKey()), RelayGroupDeletions.flow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletionIsRelayScoped() {
|
||||
RelayGroupDeletions.markDeleted(GroupId(gid, relayA))
|
||||
|
||||
// The same group id on a different host relay is a different group, so it stays visible.
|
||||
assertTrue(RelayGroupDeletions.isDeleted(GroupId(gid, relayA)))
|
||||
assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayB)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markingIsIdempotent() {
|
||||
val group = GroupId(gid, relayA)
|
||||
RelayGroupDeletions.markDeleted(group)
|
||||
RelayGroupDeletions.markDeleted(group)
|
||||
|
||||
assertEquals(1, RelayGroupDeletions.flow.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreReplacesTheWholeSet() {
|
||||
RelayGroupDeletions.markDeleted(GroupId(gid, relayA))
|
||||
|
||||
val restored = setOf(GroupId("aaaa", relayB).toKey(), GroupId("bbbb", relayB).toKey())
|
||||
RelayGroupDeletions.restore(restored)
|
||||
|
||||
assertEquals(restored, RelayGroupDeletions.flow.value)
|
||||
assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayA)))
|
||||
}
|
||||
}
|
||||
+72
-24
@@ -31,6 +31,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
@@ -38,6 +39,9 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Regression tests for PR #3483 review findings on FeedMetadataCoordinator:
|
||||
@@ -73,34 +77,78 @@ class FeedMetadataCoordinatorTest {
|
||||
|
||||
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
|
||||
|
||||
/**
|
||||
* Suspends until every coroutine the coordinator launched into [scope] has finished.
|
||||
*
|
||||
* This is what makes the assertions deterministic. The coordinator does its work in
|
||||
* `scope.launch { subscribe(); gate.awaitAll(timeout); unsubscribe(); promote-or-roll-back }`,
|
||||
* so "has call 1 finished?" is a question about the job tree, not about the clock. The tests
|
||||
* used to answer it with `delay(timeoutMs + margin)` and assert straight after — which holds
|
||||
* only while the dispatcher is free to start that coroutine promptly. On a loaded runner
|
||||
* (macOS CI, 1431 tests in the same module) the launch itself can be queued past the margin,
|
||||
* the roll-back lands late, the next call short-circuits, and the assertion fails on a
|
||||
* perfectly healthy coordinator. Waiting on the jobs removes the margin entirely.
|
||||
*/
|
||||
private suspend fun awaitCoordinatorIdle(timeoutMs: Long = 30_000) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (scope.coroutineContext.job.children
|
||||
.any { it.isActive }
|
||||
) {
|
||||
check(System.currentTimeMillis() < deadline) { "coordinator work did not settle within ${timeoutMs}ms" }
|
||||
delay(2)
|
||||
}
|
||||
}
|
||||
|
||||
/** Polls [predicate] to a deadline. For preconditions we cannot express as job completion. */
|
||||
private suspend fun waitUntil(
|
||||
message: String,
|
||||
timeoutMs: Long = 30_000,
|
||||
predicate: () -> Boolean,
|
||||
) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (!predicate()) {
|
||||
check(System.currentTimeMillis() < deadline) { "timed out waiting for: $message" }
|
||||
delay(2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake client that captures subscribe/unsubscribe and lets the test
|
||||
* drive EOSE notifications on any dispatcher we choose.
|
||||
*
|
||||
* Every collection here is written from the coordinator's coroutines (`Dispatchers.Default`,
|
||||
* and `Dispatchers.IO` for the concurrent-EOSE test) and read from the test thread, so plain
|
||||
* `mutableMapOf`/`mutableListOf` were two more races: an unsynchronized `size` read can be
|
||||
* stale, and `fireEose` iterating `subscriptions.values` while a coordinator coroutine calls
|
||||
* `unsubscribe` can throw ConcurrentModificationException. Concurrency is the thing under
|
||||
* test here, so the fake must not be the weak link.
|
||||
*/
|
||||
private class ControllableClient(
|
||||
private val delegate: INostrClient = EmptyNostrClient(),
|
||||
) : INostrClient by delegate {
|
||||
val subscriptions = mutableMapOf<String, SubscriptionListener?>()
|
||||
val subscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
var unsubscribeCallCount = 0
|
||||
private set
|
||||
val subscriptions = ConcurrentHashMap<String, SubscriptionListener>()
|
||||
val subscribeCalls: MutableList<Map<NormalizedRelayUrl, List<Filter>>> =
|
||||
Collections.synchronizedList(mutableListOf())
|
||||
private val unsubscribes = AtomicInteger(0)
|
||||
val unsubscribeCallCount: Int get() = unsubscribes.get()
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
subscriptions[subId] = listener
|
||||
listener?.let { subscriptions[subId] = it }
|
||||
subscribeCalls.add(filters)
|
||||
}
|
||||
|
||||
override fun unsubscribe(subId: String) {
|
||||
subscriptions.remove(subId)
|
||||
unsubscribeCallCount++
|
||||
unsubscribes.incrementAndGet()
|
||||
}
|
||||
|
||||
fun fireEose(relay: NormalizedRelayUrl) {
|
||||
subscriptions.values.filterNotNull().forEach { it.onEose(relay, forFilters = null) }
|
||||
// Snapshot: a coordinator coroutine may unsubscribe concurrently.
|
||||
subscriptions.values.toList().forEach { it.onEose(relay, forFilters = null) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,13 +167,13 @@ class FeedMetadataCoordinatorTest {
|
||||
|
||||
// Call 1 — no relay EOSEs; must time out.
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(350) // exceed the timeout
|
||||
awaitCoordinatorIdle() // call 1 timed out and rolled back
|
||||
|
||||
// Call 2 — the same pubkeys must be re-subscribed since call 1
|
||||
// never got a successful EOSE. The old code would silently
|
||||
// short-circuit here.
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(50) // let the launcher run
|
||||
awaitCoordinatorIdle()
|
||||
|
||||
assertEquals(
|
||||
"Zero-EOSE timeout must not permanently dedup pubkeys",
|
||||
@@ -158,13 +206,13 @@ class FeedMetadataCoordinatorTest {
|
||||
val pubkeys = listOf(pubkey(1), pubkey(2))
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 1_000)
|
||||
// Give the launcher time to register the listener before we fire.
|
||||
delay(50)
|
||||
// The listener must be registered before we fire, or the EOSEs go nowhere.
|
||||
waitUntil("subscription registered") { client.subscriptions.isNotEmpty() }
|
||||
indexRelays.forEach(client::fireEose)
|
||||
delay(200) // let the coordinator finish + promote to queued
|
||||
awaitCoordinatorIdle() // coordinator finished + promoted to queued
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(50)
|
||||
awaitCoordinatorIdle()
|
||||
|
||||
assertEquals(
|
||||
"Successful call must dedup subsequent identical calls",
|
||||
@@ -187,13 +235,13 @@ class FeedMetadataCoordinatorTest {
|
||||
val pubkeys = listOf(pubkey(1))
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 300)
|
||||
delay(30)
|
||||
waitUntil("subscription registered") { client.subscriptions.isNotEmpty() }
|
||||
// Only 1 of 3 EOSEs — timeout still fires but we made progress.
|
||||
client.fireEose(relay1)
|
||||
delay(400)
|
||||
awaitCoordinatorIdle()
|
||||
|
||||
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
|
||||
delay(50)
|
||||
awaitCoordinatorIdle()
|
||||
|
||||
assertEquals(
|
||||
"≥1 EOSE = progress = promote to queued (avoid re-asking)",
|
||||
@@ -222,7 +270,7 @@ class FeedMetadataCoordinatorTest {
|
||||
)
|
||||
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 2_000)
|
||||
delay(50) // wait for subscription
|
||||
waitUntil("subscription registered") { client.subscriptions.isNotEmpty() }
|
||||
|
||||
// Fire EOSEs concurrently from many dispatchers.
|
||||
val jobs =
|
||||
@@ -235,9 +283,9 @@ class FeedMetadataCoordinatorTest {
|
||||
|
||||
// The 2nd call must short-circuit — every relay EOSE'd, so
|
||||
// pubkey(1) is now in queuedKind3Pubkeys.
|
||||
delay(100)
|
||||
awaitCoordinatorIdle()
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
|
||||
delay(50)
|
||||
awaitCoordinatorIdle()
|
||||
|
||||
assertEquals(
|
||||
"Under concurrent EOSE from all relays, aggregator must reach target",
|
||||
@@ -261,10 +309,10 @@ class FeedMetadataCoordinatorTest {
|
||||
|
||||
// Call 1 — zero EOSE, timeout.
|
||||
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
|
||||
delay(350)
|
||||
awaitCoordinatorIdle()
|
||||
// Call 2 — must re-subscribe.
|
||||
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
|
||||
delay(50)
|
||||
awaitCoordinatorIdle()
|
||||
|
||||
assertTrue(
|
||||
"Metadata batch also retries on zero-EOSE timeout",
|
||||
@@ -284,13 +332,13 @@ class FeedMetadataCoordinatorTest {
|
||||
)
|
||||
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
|
||||
delay(50)
|
||||
waitUntil("subscription registered") { client.subscriptions.isNotEmpty() }
|
||||
// clear() must drop the in-flight tracker even mid-request.
|
||||
coordinator.clear()
|
||||
delay(300) // let call 1 finish + roll back
|
||||
awaitCoordinatorIdle() // call 1 finished + rolled back
|
||||
|
||||
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
|
||||
delay(50)
|
||||
awaitCoordinatorIdle()
|
||||
|
||||
assertTrue(client.subscribeCalls.size >= 2)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,18 @@
|
||||
"tag": "v1.13.1",
|
||||
"since": "2026-07-28T23:17:02-04:00",
|
||||
"translators": [
|
||||
{
|
||||
"user": "Bardesss",
|
||||
"languages": [
|
||||
"Dutch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "maxblake2015",
|
||||
"languages": [
|
||||
"Polish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "vitorpamplona",
|
||||
"languages": [
|
||||
@@ -125,10 +137,6 @@
|
||||
"user": "vazw",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "Bardesss",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "anthony-robin",
|
||||
"languages": []
|
||||
@@ -281,10 +289,6 @@
|
||||
"user": "hypnotichemionus4",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "maxblake2015",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "eiie7",
|
||||
"languages": []
|
||||
|
||||
+17
-6
@@ -89,7 +89,13 @@ data class ConcordCommunityState(
|
||||
ownerPubKey: String,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
): Map<String, EntityFloor> {
|
||||
val pool = EditionFold.admissible(editions, floors)
|
||||
// This call folds exactly one epoch's editions, so they ARE the snapshot: an entity
|
||||
// appearing here has been re-wrapped into this epoch and must anchor on version, not
|
||||
// on a `prev` that necessarily dangles back into the epoch the floor came from. The
|
||||
// known heads `admissible` re-seats are not in the set, so an entity this epoch never
|
||||
// mentioned correctly keeps chain-walk semantics.
|
||||
val snapshot = editions.mapTo(HashSet(editions.size)) { it.rumorId }
|
||||
val pool = EditionFold.admissible(editions, floors, snapshot = snapshot)
|
||||
val authority = AuthorityResolver.resolve(pool, ownerPubKey)
|
||||
val out = HashMap<String, EntityFloor>(floors)
|
||||
for ((kind, list) in pool.groupBy { it.entityKind }) {
|
||||
@@ -97,7 +103,7 @@ data class ConcordCommunityState(
|
||||
// Gate the CANDIDATES, don't pre-filter the chain: a rejected edition mid-chain must
|
||||
// stay inert instead of orphaning the authorized editions above it (EditionFold.candidates).
|
||||
val heads =
|
||||
EditionFold.foldGated(list, floors) {
|
||||
EditionFold.foldGated(list, floors, snapshot = snapshot) {
|
||||
authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit))
|
||||
}
|
||||
for ((entity, head) in heads) {
|
||||
@@ -119,10 +125,15 @@ data class ConcordCommunityState(
|
||||
// authority chains, the per-kind gated folds), so the anti-rollback floor is applied
|
||||
// once, up front, on the shared pool: a rolled-back edition is never seen by any of
|
||||
// them, and the head we already folded is re-seated so the entity keeps its state.
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val editions = EditionFold.admissible(editions, floors)
|
||||
// The editions handed in are one epoch's — the current one — so they are the
|
||||
// snapshot that selects the compaction arm. Captured BEFORE `admissible` re-seats
|
||||
// known heads from older epochs, which must not be mistaken for this epoch's.
|
||||
val snapshot = editions.mapTo(HashSet(editions.size)) { it.rumorId }
|
||||
|
||||
val heads = EditionFold.fold(editions, floors).values
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val editions = EditionFold.admissible(editions, floors, snapshot = snapshot)
|
||||
|
||||
val heads = EditionFold.fold(editions, floors, snapshot = snapshot).values
|
||||
// Resolve authority from the FULL edition set (not the structural heads): the resolver
|
||||
// folds each role/grant chain through authorized editions only, so a rogue higher-version
|
||||
// edition can't supersede a legit one before authority is even judged.
|
||||
@@ -142,7 +153,7 @@ data class ConcordCommunityState(
|
||||
kind: ControlEntityKind,
|
||||
bit: Int,
|
||||
): Map<String, ControlEdition> =
|
||||
EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors) {
|
||||
EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors, snapshot = snapshot) {
|
||||
authority.isOwner(it.author) || authority.hasPermission(it.author, bit)
|
||||
}
|
||||
|
||||
|
||||
+121
-18
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentSet
|
||||
|
||||
/**
|
||||
* The anti-rollback floor for one Control Plane entity: the [version] and
|
||||
@@ -97,16 +98,61 @@ typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion
|
||||
object EditionFold {
|
||||
private const val TAG = "ConcordEditionFold"
|
||||
|
||||
/**
|
||||
* Rollback refusals already reported by [LOG_GAP], keyed by the exact refusal
|
||||
* (entity + floor + offered version) rather than by entity, so a *new* rollback
|
||||
* attempt against the same entity is still reported.
|
||||
*
|
||||
* Bounded in practice: a key is only ever added when an entity is offered a chain
|
||||
* it already refused at a version pair it has not seen, which happens once per real
|
||||
* rotation, not once per fold. Deliberately never cleared and deliberately
|
||||
* fail-open — see [LOG_GAP].
|
||||
*/
|
||||
private val reportedGaps = ConcurrentSet<String>()
|
||||
|
||||
/** Above this many distinct refusals, stop deduplicating and just warn. See [LOG_GAP]. */
|
||||
private const val MAX_TRACKED_GAPS = 4096
|
||||
|
||||
/**
|
||||
* The default [GapReporter]: a rollback refusal is security-relevant (a rotator
|
||||
* tried to revert an entity), so it is warned, never swallowed.
|
||||
*
|
||||
* Warned **once per distinct refusal**, though. Amethyst re-folds the whole buffer
|
||||
* from scratch on every control-plane change, so an entity stuck refusing a rollback
|
||||
* re-reports the identical refusal on every refold — 22 byte-identical warnings in an
|
||||
* 80s cold start, which reads as 22 attacks rather than one unchanged state. The
|
||||
* dedup is on the message's own contents, so nothing a reader could act on is lost.
|
||||
*
|
||||
* Fails **open**: past [MAX_TRACKED_GAPS] distinct refusals it reverts to warning
|
||||
* every time. A flood of distinct rollback attempts is exactly when the warnings
|
||||
* matter most, so the failure mode is a noisy log, never a silenced one.
|
||||
*/
|
||||
val LOG_GAP: GapReporter = { entityIdHex, floorVersion, offeredVersion ->
|
||||
Log.w(TAG) {
|
||||
"Control-plane rollback refused for entity $entityIdHex: already folded v$floorVersion, offered chain tops out at v$offeredVersion and does not connect to it"
|
||||
val firstReport =
|
||||
reportedGaps.size() >= MAX_TRACKED_GAPS ||
|
||||
reportedGaps.add("$entityIdHex@$floorVersion<-$offeredVersion")
|
||||
|
||||
if (firstReport) {
|
||||
Log.w(TAG) {
|
||||
"Control-plane rollback refused for entity $entityIdHex: already folded v$floorVersion, offered chain tops out at v$offeredVersion and does not connect to it"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The compaction-era head: the highest-version edition at or above [floorVersion], ties
|
||||
* broken by the lower rumor id — no `prev`, no hash, no contiguity. See the compaction arm
|
||||
* in [foldEntity] for why version is the right (and only sound) anchor behind a Refounding.
|
||||
* Null when nothing at or above the floor was offered, which is the only gap this arm has.
|
||||
*/
|
||||
private fun bootstrapHead(
|
||||
editions: List<ControlEdition>,
|
||||
floorVersion: Long,
|
||||
): ControlEdition? =
|
||||
editions
|
||||
.filter { it.version >= floorVersion }
|
||||
.minWithOrNull(compareByDescending<ControlEdition> { it.version }.thenBy { it.rumorId })
|
||||
|
||||
/**
|
||||
* Groups mixed [editions] by entity id and folds each to its head, honoring the
|
||||
* per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]).
|
||||
@@ -119,12 +165,13 @@ object EditionFold {
|
||||
fun fold(
|
||||
editions: Collection<ControlEdition>,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
snapshot: Set<String>? = null,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
): Map<String, ControlEdition> {
|
||||
val byEntity = editions.groupBy { it.entityIdHex }
|
||||
val out = HashMap<String, ControlEdition>(byEntity.size)
|
||||
for ((entity, list) in byEntity) {
|
||||
foldEntity(list, floors[entity], onGap)?.let { out[entity] = it }
|
||||
foldEntity(list, floors[entity], snapshot, onGap)?.let { out[entity] = it }
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -134,18 +181,44 @@ object EditionFold {
|
||||
*
|
||||
* With no [floor] this is the fresh-joiner fold: genesis-anchored, falling back
|
||||
* to the lowest-version edition present (the compaction bootstrap). With a
|
||||
* [floor] the walk is anchored at the exact edition already folded; if that
|
||||
* edition is not among [editions] the chain is **gapped** and nothing above the
|
||||
* floor is adopted — [EntityFloor.known] is kept instead (or null when we no
|
||||
* longer hold it). A head is therefore never below the floor version.
|
||||
* [floor] the walk is anchored at the edition already folded **or** at its
|
||||
* immediate successor when that successor cites the floor's hash; failing both
|
||||
* the chain is **gapped** and nothing above the floor is adopted —
|
||||
* [EntityFloor.known] is kept instead (or null when we no longer hold it). A head
|
||||
* is therefore never below the floor version.
|
||||
*
|
||||
* [snapshot] is the set of [ControlEdition.rumorId]s belonging to the epoch being
|
||||
* folded, supplied once a community has Refounded. An entity present in it takes
|
||||
* the version-anchored compaction arm instead of the chain walk — see the arm
|
||||
* itself for why. Null (the default) keeps the pure chain walk, which is right for
|
||||
* a single-epoch fold and for every caller that has no epoch to speak of.
|
||||
*/
|
||||
fun foldEntity(
|
||||
editions: List<ControlEdition>,
|
||||
floor: EntityFloor? = null,
|
||||
snapshot: Set<String>? = null,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
): ControlEdition? {
|
||||
if (editions.isEmpty()) return floor?.known
|
||||
|
||||
// Compaction arm (CORD-06 §3). Once this entity has been re-wrapped into the epoch
|
||||
// being folded, `prev` chaining across the epoch boundary is meaningless: a Refounding
|
||||
// trims history and re-wraps the head alone, so the predecessor our floor names stays
|
||||
// behind on the older epoch and every offered `prev` dangles by design. Anchor on
|
||||
// VERSION instead — a re-wrap preserves the original author's signature but cannot
|
||||
// raise the version inside the signed seal, so a re-served stale edition always loses
|
||||
// to the compacted head. Presence of the entity in the snapshot selects the ARM;
|
||||
// version selects the HEAD, over every edition we hold and not just the subset.
|
||||
if (floor != null && snapshot != null && editions.any { it.rumorId in snapshot }) {
|
||||
return bootstrapHead(editions, floor.version)
|
||||
?: run {
|
||||
// Nothing at or above the floor was served: the head we already accepted
|
||||
// vanished from the offered set — withheld, so fail closed.
|
||||
onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version })
|
||||
floor.known
|
||||
}
|
||||
}
|
||||
|
||||
// Index editions by version, keeping the tie-break winner where several
|
||||
// share a version (lower rumor id wins).
|
||||
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
|
||||
@@ -153,12 +226,25 @@ object EditionFold {
|
||||
|
||||
var head =
|
||||
if (floor != null) {
|
||||
// Anchored at what we already folded: the offered set MUST contain that exact
|
||||
// edition (same version AND same hash — a same-version sibling is a fork, not
|
||||
// our chain). Failing that, refuse to move at all rather than accept an
|
||||
// unverifiable jump; walking up from the floor also makes a head below the
|
||||
// floor version structurally impossible.
|
||||
editions.firstOrNull { it.version == floor.version && it.hashHex == floor.hashHex }
|
||||
// Anchored at what we already folded. Below the floor is history we absorbed, so
|
||||
// the anchor is the lowest offered version at or above it, and only two shapes
|
||||
// connect: that edition IS the floor (same version AND hash — a same-version
|
||||
// sibling is a fork, not our chain), or it is the floor's immediate successor and
|
||||
// cites the floor's hash. The latter is the ordinary cross-epoch shape and is a
|
||||
// STRONGER proof of connection than mere presence. Anything else is a jump we
|
||||
// refuse; walking up from the anchor also makes a head below the floor version
|
||||
// structurally impossible.
|
||||
val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull()
|
||||
val winner = lowest?.let { v -> byVersion[v]?.minByOrNull { it.rumorId } }
|
||||
val anchor =
|
||||
when {
|
||||
winner == null -> null
|
||||
lowest == floor.version -> winner.takeIf { it.hashHex == floor.hashHex }
|
||||
lowest == floor.version + 1 ->
|
||||
winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }
|
||||
else -> null
|
||||
}
|
||||
anchor
|
||||
?: run {
|
||||
onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version })
|
||||
return floor.known
|
||||
@@ -222,12 +308,21 @@ object EditionFold {
|
||||
fun candidates(
|
||||
editions: List<ControlEdition>,
|
||||
floor: EntityFloor? = null,
|
||||
snapshot: Set<String>? = null,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
): List<ControlEdition> {
|
||||
val head = foldEntity(editions, floor, onGap) ?: return emptyList()
|
||||
// Ask the fold whether it gapped rather than re-deriving the condition here: with the
|
||||
// compaction arm and the successor anchor there are three ways to connect, and a second
|
||||
// copy of that test is a bug waiting to drift out of sync with the first.
|
||||
var gapped = false
|
||||
val head =
|
||||
foldEntity(editions, floor, snapshot) { e, f, o ->
|
||||
gapped = true
|
||||
onGap(e, f, o)
|
||||
} ?: return emptyList()
|
||||
// A gap re-seated the known head: nothing from the offered set is admissible above
|
||||
// the floor, so the known edition is the only candidate.
|
||||
if (floor != null && editions.none { it.version == floor.version && it.hashHex == floor.hashHex }) {
|
||||
if (floor != null && gapped) {
|
||||
return listOf(head)
|
||||
}
|
||||
val out = ArrayList<ControlEdition>(editions.size)
|
||||
@@ -247,9 +342,10 @@ object EditionFold {
|
||||
fun foldEntityGated(
|
||||
editions: List<ControlEdition>,
|
||||
floor: EntityFloor? = null,
|
||||
snapshot: Set<String>? = null,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
gate: (ControlEdition) -> Boolean,
|
||||
): ControlEdition? = candidates(editions, floor, onGap).firstOrNull(gate)
|
||||
): ControlEdition? = candidates(editions, floor, snapshot, onGap).firstOrNull(gate)
|
||||
|
||||
/**
|
||||
* Groups mixed [editions] by entity id and folds each to the highest-priority
|
||||
@@ -258,13 +354,14 @@ object EditionFold {
|
||||
fun foldGated(
|
||||
editions: Collection<ControlEdition>,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
snapshot: Set<String>? = null,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
gate: (ControlEdition) -> Boolean,
|
||||
): Map<String, ControlEdition> {
|
||||
val byEntity = editions.groupBy { it.entityIdHex }
|
||||
val out = HashMap<String, ControlEdition>(byEntity.size)
|
||||
for ((entity, list) in byEntity) {
|
||||
foldEntityGated(list, floors[entity], onGap, gate)?.let { out[entity] = it }
|
||||
foldEntityGated(list, floors[entity], snapshot, onGap, gate)?.let { out[entity] = it }
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -285,6 +382,7 @@ object EditionFold {
|
||||
fun admissible(
|
||||
editions: Collection<ControlEdition>,
|
||||
floors: Map<String, EntityFloor>,
|
||||
snapshot: Set<String>? = null,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
): List<ControlEdition> {
|
||||
if (floors.isEmpty()) return editions.toList()
|
||||
@@ -298,7 +396,12 @@ object EditionFold {
|
||||
out.addAll(list)
|
||||
continue
|
||||
}
|
||||
if (list.any { it.version == floor.version && it.hashHex == floor.hashHex }) {
|
||||
// Same three-way connection test as the fold, asked of the fold itself so the two
|
||||
// can't drift apart: present at the floor, a successor citing it, or the compaction
|
||||
// arm's version anchor.
|
||||
var gapped = false
|
||||
foldEntity(list, floor, snapshot) { _, _, _ -> gapped = true }
|
||||
if (!gapped) {
|
||||
out.addAll(list)
|
||||
continue
|
||||
}
|
||||
|
||||
+26
-12
@@ -88,27 +88,41 @@ class MetadataEvent(
|
||||
} ?: ""
|
||||
|
||||
fun contactMetadataJson() =
|
||||
try {
|
||||
Json.parseToJsonElement(content) as JsonObject
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
|
||||
null
|
||||
if (content.isBlank()) {
|
||||
// An empty kind-0 is a valid, empty profile — not a parse failure.
|
||||
EMPTY_METADATA_JSON
|
||||
} else {
|
||||
try {
|
||||
Json.parseToJsonElement(content) as JsonObject
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun contactMetaData() =
|
||||
try {
|
||||
JsonMapper.fromJson<UserMetadata>(content)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
|
||||
null
|
||||
if (content.isBlank()) {
|
||||
// Users do publish kind 0s with an empty content to wipe their
|
||||
// profile. That must replace the previous metadata with a blank
|
||||
// one, not be discarded as an error.
|
||||
UserMetadata()
|
||||
} else {
|
||||
try {
|
||||
JsonMapper.fromJson<UserMetadata>(content)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 0
|
||||
const val FIXED_D_TAG = ""
|
||||
|
||||
private val EMPTY_METADATA_JSON = JsonObject(emptyMap())
|
||||
|
||||
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
|
||||
|
||||
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
|
||||
|
||||
+16
-2
@@ -29,6 +29,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.nullable
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -44,8 +45,10 @@ import kotlinx.serialization.json.booleanOrNull
|
||||
* profile (name, picture, about…).
|
||||
*
|
||||
* This serializer accepts any JSON primitive (matching the pre-existing lenient
|
||||
* behavior, where a bare number or boolean decodes into a string field) and
|
||||
* treats objects, arrays, and JSON null as absent rather than failing.
|
||||
* behavior, where a bare number or boolean decodes into a string field), unwraps
|
||||
* a single-element array wrapping a primitive (seen in the wild as
|
||||
* `"nip05":["name@domain"]`, where the intended value is unambiguous), and treats
|
||||
* objects, longer arrays, and JSON null as absent rather than failing.
|
||||
*
|
||||
* Same rationale as [BirthdayTolerantSerializer].
|
||||
*/
|
||||
@@ -60,6 +63,17 @@ object TolerantStringSerializer : KSerializer<String?> {
|
||||
return when {
|
||||
element is JsonNull -> null
|
||||
element is JsonPrimitive -> element.content
|
||||
// A one-element array around a primitive has only one possible reading,
|
||||
// so recover the value instead of dropping the field.
|
||||
element is JsonArray -> {
|
||||
val single = element.singleOrNull()
|
||||
if (single is JsonPrimitive && single !is JsonNull) {
|
||||
single.content
|
||||
} else {
|
||||
Log.w("TolerantStringSerializer") { "Ignoring array string field with ${element.size} elements" }
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Log the JSON kind only, not the raw (untrusted, network-sourced) value.
|
||||
Log.w("TolerantStringSerializer") { "Ignoring non-primitive string field (${element::class.simpleName})" }
|
||||
|
||||
+9
-1
@@ -101,12 +101,20 @@ class RelayLogger(
|
||||
Log.d(logTag(relay.url), "Disconnected")
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug, not error: under the outbox model a client dials hundreds of relays per boot,
|
||||
* and a large share of them are dead, unreachable, or refused by the local Tor proxy.
|
||||
* One line per failed socket is a few hundred lines that say the same four things
|
||||
* ("SOCKS: TTL expired", "SSLHandshakeException", "Host unreachable", …) and cannot be
|
||||
* acted on individually. The actionable form is the aggregate — failures bucketed by
|
||||
* cause, which the consumer's boot census already reports.
|
||||
*/
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
super.onCannotConnect(relay, errorMessage)
|
||||
Log.e(logTag(relay.url), errorMessage)
|
||||
Log.d(logTag(relay.url), errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -57,6 +57,14 @@ class GroupMetadataEvent(
|
||||
|
||||
fun picture() = tags.firstTagValue("picture")
|
||||
|
||||
/**
|
||||
* Buzz-only: whether the relay has marked this channel **archived** — a hide-from-the-sidebar
|
||||
* state, distinct from a delete (the channel and its history live on). The Buzz relay stamps an
|
||||
* `["archived","true"]` tag onto the 39000 for an archived channel and clients hide it; a plain
|
||||
* NIP-29 relay has no such concept, so this is false there.
|
||||
*/
|
||||
fun isArchived() = tags.firstTagValue("archived") == "true"
|
||||
|
||||
/**
|
||||
* Topic hashtags (`t` tags) the relay advertises for this group, used by the
|
||||
* discovery feed's hashtag filter. NIP-29 doesn't define these; a group only
|
||||
|
||||
+8
@@ -82,6 +82,12 @@ class EditMetadataEvent(
|
||||
parent: String? = null,
|
||||
children: List<String> = emptyList(),
|
||||
previousEvents: List<String> = emptyList(),
|
||||
// Buzz-only channel-settings tags (a Buzz relay reads these on 9002; a plain NIP-29 relay
|
||||
// ignores them). [visibility] is "open"/"private" — Buzz's own vocabulary, which it reads
|
||||
// instead of the NIP-29 `private` status flag, so editing visibility on a Buzz channel needs
|
||||
// this tag to take. [archived] toggles the channel's archived state ("true"/"false").
|
||||
visibility: String? = null,
|
||||
archived: Boolean? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<EditMetadataEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
@@ -90,6 +96,8 @@ class EditMetadataEvent(
|
||||
about?.let { add(arrayOf("about", it)) }
|
||||
picture?.let { add(arrayOf("picture", it)) }
|
||||
status.forEach { add(arrayOf(it.code)) }
|
||||
visibility?.let { add(arrayOf("visibility", it)) }
|
||||
archived?.let { add(arrayOf("archived", if (it) "true" else "false")) }
|
||||
addAll(HashtagTag.assemble(hashtags))
|
||||
// Mip-map each geohash into every prefix so a coarser followed geohash still matches.
|
||||
geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) }
|
||||
|
||||
+78
@@ -110,6 +110,84 @@ class EditionFoldFloorTest {
|
||||
assertEquals(v3.rumorId, head?.rumorId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The cross-epoch case, observed on device: a CORD-06 Refounding re-wraps ONE edition per
|
||||
* entity, so an entity edited *after* the refounding has its successor alone on the new epoch
|
||||
* while the floor edition stays behind on the old one. The offered set therefore never contains
|
||||
* the floor edition — but v3 cites its hash, which is a *stronger* proof the chain connects than
|
||||
* mere presence. Refusing here pins the entity at the floor forever and silently discards the
|
||||
* newer state (a role edit, a channel rename, a banlist entry) on every refold.
|
||||
*
|
||||
* Regression guard: reproduced on device 2026-07-29 (entity e83ee182 pinned at v1 across 21
|
||||
* consecutive refolds while epoch 1 offered v2 citing v1's hash). Mirrors Armada's third
|
||||
* anchor branch, `versions[0] === floor + 1n → bytesEq(lo.prevHash, floorHash)`.
|
||||
*/
|
||||
@Test
|
||||
fun advancesWhenOnlyTheConnectingSuccessorIsOffered() {
|
||||
val gaps = mutableListOf<Triple<String, Long, Long>>()
|
||||
val head = EditionFold.foldEntity(listOf(v3), floorAtV2) { e, f, o -> gaps += Triple(e, f, o) }
|
||||
|
||||
assertEquals(v3.rumorId, head?.rumorId, "v3.prev == floor.hash, so the chain connects")
|
||||
assertTrue(gaps.isEmpty(), "a successor citing the floor is not a rollback")
|
||||
}
|
||||
|
||||
/** But a successor that cites something else is still a fork, and still refused. */
|
||||
@Test
|
||||
fun stillRefusesASuccessorThatDoesNotCiteTheFloor() {
|
||||
val forked = edition(3, ByteArray(32) { 0x11 }, """{"member":"a","role_ids":["owner-ish"]}""", rumorId = "id-forked")
|
||||
val gaps = mutableListOf<Triple<String, Long, Long>>()
|
||||
val head = EditionFold.foldEntity(listOf(forked), floorAtV2) { e, f, o -> gaps += Triple(e, f, o) }
|
||||
|
||||
assertEquals(v2.rumorId, head?.rumorId, "an unconnected successor must not be adopted")
|
||||
assertEquals(listOf(Triple(v2.entityIdHex, 2L, 3L)), gaps, "and the refusal must still be reported")
|
||||
}
|
||||
|
||||
// ---- the compaction arm (CORD-06 §3) --------------------------------------
|
||||
|
||||
/**
|
||||
* Behind a Refounding the predecessor is trimmed, so a `prev` that reaches back to the floor
|
||||
* is NOT guaranteed — the compacted head can sit any distance above it. Once the entity is in
|
||||
* the epoch's snapshot the anchor is the version alone, so v3 is adopted over a floor of v1
|
||||
* even though nothing links them. This is the case part 1 alone cannot handle.
|
||||
*/
|
||||
@Test
|
||||
fun compactionArmAdoptsTheHighestVersionWithoutAChain() {
|
||||
val gaps = mutableListOf<Triple<String, Long, Long>>()
|
||||
val head =
|
||||
EditionFold.foldEntity(
|
||||
listOf(v3),
|
||||
v1.asFloor(),
|
||||
snapshot = setOf(v3.rumorId),
|
||||
) { e, f, o -> gaps += Triple(e, f, o) }
|
||||
|
||||
assertEquals(v3.rumorId, head?.rumorId, "version anchors the compaction arm; prev is ignored")
|
||||
assertTrue(gaps.isEmpty(), "a compacted head above the floor is not a gap")
|
||||
}
|
||||
|
||||
/** The floor still holds in the compaction arm: a re-served stale edition cannot downgrade us. */
|
||||
@Test
|
||||
fun compactionArmStillRefusesEverythingBelowTheFloor() {
|
||||
val gaps = mutableListOf<Triple<String, Long, Long>>()
|
||||
val head =
|
||||
EditionFold.foldEntity(
|
||||
listOf(v0, v1),
|
||||
floorAtV2,
|
||||
snapshot = setOf(v0.rumorId, v1.rumorId),
|
||||
) { e, f, o -> gaps += Triple(e, f, o) }
|
||||
|
||||
assertEquals(v2.rumorId, head?.rumorId, "nothing at or above the floor: keep what we knew")
|
||||
assertEquals(listOf(Triple(v2.entityIdHex, 2L, 1L)), gaps, "and report the refusal")
|
||||
}
|
||||
|
||||
/** An entity this epoch never re-wrapped is not in the snapshot, so it keeps chain-walk semantics. */
|
||||
@Test
|
||||
fun entityAbsentFromTheSnapshotStillChainWalks() {
|
||||
val forked = edition(3, ByteArray(32) { 0x11 }, """{"member":"a","role_ids":["owner-ish"]}""", rumorId = "id-forked")
|
||||
val head = EditionFold.foldEntity(listOf(forked), floorAtV2, snapshot = setOf("some-other-rumor")) { _, _, _ -> }
|
||||
|
||||
assertEquals(v2.rumorId, head?.rumorId, "no snapshot membership -> the chain walk still refuses the fork")
|
||||
}
|
||||
|
||||
/** A floor whose known edition we no longer hold still refuses to move down — it adopts nothing. */
|
||||
@Test
|
||||
fun refusesRollbackWithNoKnownEditionToFallBackOn() {
|
||||
|
||||
+52
-2
@@ -66,7 +66,7 @@ class TolerantPrimitiveSerializersTest {
|
||||
fun otherNonStringValuesAreIgnoredFieldByField() {
|
||||
val meta =
|
||||
metaWith(
|
||||
"""{"name":{"first":"A"},"display_name":["A"],"picture":null,"about":"bio","website":{}}""",
|
||||
"""{"name":{"first":"A"},"display_name":["A","B"],"picture":null,"about":"bio","website":{}}""",
|
||||
).contactMetaData()
|
||||
|
||||
assertIs<UserMetadata>(meta)
|
||||
@@ -77,6 +77,37 @@ class TolerantPrimitiveSerializersTest {
|
||||
assertEquals("bio", meta.about, "well-formed fields must survive the malformed ones")
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for profiles that wrap a string field in a one-element array
|
||||
* (seen in the wild as `"nip05":["shoreline@decentnewsroom.com"]`). There is
|
||||
* only one possible reading, so the value must be recovered rather than
|
||||
* silently dropped along with the user's NIP-05 verification.
|
||||
*/
|
||||
@Test
|
||||
fun singleElementArrayIsUnwrapped() {
|
||||
val meta = metaWith("""{"name":"A","nip05":["a@b.com"],"lud16":[123]}""").contactMetaData()
|
||||
|
||||
assertIs<UserMetadata>(meta)
|
||||
assertEquals("a@b.com", meta.nip05)
|
||||
assertEquals("123", meta.lud16)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun arraysThatAreNotASinglePrimitiveAreStillIgnored() {
|
||||
listOf(
|
||||
"""{"name":"A","nip05":[]}""",
|
||||
"""{"name":"A","nip05":["a@b.com","c@d.com"]}""",
|
||||
"""{"name":"A","nip05":[{}]}""",
|
||||
"""{"name":"A","nip05":[["a@b.com"]]}""",
|
||||
"""{"name":"A","nip05":[null]}""",
|
||||
).forEach { json ->
|
||||
val meta = metaWith(json).contactMetaData()
|
||||
assertIs<UserMetadata>(meta, "profile must survive nip05 in $json")
|
||||
assertEquals("A", meta.name)
|
||||
assertNull(meta.nip05, "ambiguous array nip05 must be dropped in $json")
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-existing lenient behavior: bare primitives still decode into string fields. */
|
||||
@Test
|
||||
fun numberAndBooleanPrimitivesStillDecodeIntoStringFields() {
|
||||
@@ -117,7 +148,26 @@ class TolerantPrimitiveSerializersTest {
|
||||
@Test
|
||||
fun nonJsonContentReturnsNull() {
|
||||
assertNull(metaWith("Relay initialized").contactMetaData())
|
||||
assertNull(metaWith("").contactMetaData())
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty content is a valid, empty profile — a user wiping their metadata —
|
||||
* not a parse error. It must decode to a blank [UserMetadata] so the new
|
||||
* (replaceable) event actually replaces the stale profile downstream.
|
||||
*/
|
||||
@Test
|
||||
fun blankContentIsAnEmptyProfileNotAnError() {
|
||||
listOf("", " ", "\n", "\t ").forEach { blank ->
|
||||
val meta = metaWith(blank).contactMetaData()
|
||||
assertIs<UserMetadata>(meta, "blank content must decode into an empty profile, not null")
|
||||
assertNull(meta.name)
|
||||
assertNull(meta.displayName)
|
||||
assertNull(meta.picture)
|
||||
assertNull(meta.about)
|
||||
assertNull(meta.nip05)
|
||||
|
||||
assertEquals(emptySet(), metaWith(blank).contactMetadataJson()?.keys, "blank content must decode into an empty json object")
|
||||
}
|
||||
}
|
||||
|
||||
/** Dropped fields must not leak back into the serialized profile as nulls. */
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip29RelayGroups
|
||||
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The Buzz-only channel-settings tags on kind-9002 edit-metadata (`visibility`, `archived`) and the
|
||||
* `archived` reflection on the relay-signed kind-39000. These ride the 9002 as tags — Buzz reads its
|
||||
* own `visibility` vocabulary rather than the NIP-29 `private` status flag, and stamps `archived` onto
|
||||
* the 39000 for an archived channel.
|
||||
*/
|
||||
class ChannelSettingsTagTest {
|
||||
private val relaySelf = "aa".repeat(32)
|
||||
private val sig = "bb".repeat(64)
|
||||
private val id = "00".repeat(32)
|
||||
private val gid = "0123456789abcdef"
|
||||
|
||||
private fun tagValue(
|
||||
tags: Array<Array<String>>,
|
||||
name: String,
|
||||
): String? = tags.firstOrNull { it.isNotEmpty() && it[0] == name }?.getOrNull(1)
|
||||
|
||||
@Test
|
||||
fun editEmitsVisibilityTagOnlyWhenSet() {
|
||||
val priv = EditMetadataEvent.build(gid, visibility = "private")
|
||||
assertEquals("private", tagValue(priv.tags, "visibility"))
|
||||
|
||||
val open = EditMetadataEvent.build(gid, visibility = "open")
|
||||
assertEquals("open", tagValue(open.tags, "visibility"))
|
||||
|
||||
// Absent by default so an ordinary metadata edit doesn't reclassify visibility.
|
||||
assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "visibility"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editEmitsArchivedTagAsTrueFalse() {
|
||||
assertEquals("true", tagValue(EditMetadataEvent.build(gid, archived = true).tags, "archived"))
|
||||
assertEquals("false", tagValue(EditMetadataEvent.build(gid, archived = false).tags, "archived"))
|
||||
// Null archived means "don't touch it" — no tag emitted.
|
||||
assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "archived"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metadataReflectsArchivedFlag() {
|
||||
val archivedTemplate = GroupMetadataEvent.build(gid, name = gid) { add(arrayOf("archived", "true")) }
|
||||
val archived = EventFactory.create(id, relaySelf, archivedTemplate.createdAt, GroupMetadataEvent.KIND, archivedTemplate.tags, "", sig) as GroupMetadataEvent
|
||||
assertTrue(archived.isArchived())
|
||||
|
||||
val plainTemplate = GroupMetadataEvent.build(gid, name = gid)
|
||||
val plain = EventFactory.create(id, relaySelf, plainTemplate.createdAt, GroupMetadataEvent.KIND, plainTemplate.tags, "", sig) as GroupMetadataEvent
|
||||
assertFalse(plain.isArchived())
|
||||
}
|
||||
}
|
||||
+154
@@ -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.quartz.nip01Core.metadata
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Real kind-0 events collected from relays that used to lose all or part of the
|
||||
* profile. Each is checked through **both** wire parsers — Jackson (the
|
||||
* JVM/Android [com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper]
|
||||
* actual) and kotlinx serialization (the native actual) — because a profile that
|
||||
* renders on one platform and not the other is the harder bug to spot.
|
||||
*/
|
||||
class MetadataEventWildProfilesTest {
|
||||
/** Runs [assertions] against the event as parsed by each of the two mappers. */
|
||||
private fun bothMappers(
|
||||
json: String,
|
||||
assertions: (label: String, event: MetadataEvent) -> Unit,
|
||||
) {
|
||||
assertions("jackson", JacksonMapper.fromJson(json) as MetadataEvent)
|
||||
assertions("kotlinx", KotlinSerializationMapper.fromJson(json) as MetadataEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty content is a valid, empty profile — someone wiping their metadata —
|
||||
* so it must decode into a blank [UserMetadata]. Returning null made this a
|
||||
* parse "error" that `LocalCache.consume` dropped, leaving the stale profile
|
||||
* in place forever even though a newer replaceable event had arrived.
|
||||
*/
|
||||
@Test
|
||||
fun emptyContentIsAnEmptyProfile() {
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"id": "3d6f3be9e56aaf0f6e7f013d886f123ff72863df6cc103f2a9d2398221a46a73",
|
||||
"pubkey": "bed07edf2bb9690324f7898213a7431586f12088e727c2b722ab7c5b2d80368f",
|
||||
"created_at": 1785356310,
|
||||
"kind": 0,
|
||||
"tags": [],
|
||||
"content": "",
|
||||
"sig": "2bddad095b8a7dc087cf1c030bf48f93280274d4cb01eb352d1540c0f2d2f4dad84fbd52d8a031c148df735a22e706e29c79d2c956b5e5d9edca49346bb2ba5a"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
bothMappers(json) { label, event ->
|
||||
val meta = event.contactMetaData()
|
||||
assertIs<UserMetadata>(meta, "$label: empty content must not be an error")
|
||||
assertNull(meta.name, label)
|
||||
assertNull(meta.displayName, label)
|
||||
assertNull(meta.picture, label)
|
||||
assertNull(meta.about, label)
|
||||
assertNull(meta.nip05, label)
|
||||
|
||||
assertEquals(emptySet(), event.contactMetadataJson()?.keys, "$label: empty content is an empty json object")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `"nip05":["shoreline@decentnewsroom.com"]` — a string field wrapped in a
|
||||
* one-element array. The intended value is unambiguous, so it is recovered
|
||||
* instead of dropping the user's NIP-05 verification.
|
||||
*/
|
||||
@Test
|
||||
fun singleElementArrayNip05IsRecovered() {
|
||||
val json =
|
||||
"""
|
||||
{"id":"642168789c75b50a7f67d0dab7bf4723433a70a7694c63c77eeca03a74af636c","pubkey":"689c44a2c362229489eb3fc5273920469ae3fa6ce01467e07f1c3383097a26b9","created_at":1782219566,"kind":0,"tags":[["display_name","Shoreline Poetry"],["name","shoreline"],["about","Poems and fragments. Inner weather. Ceasing, breaking, flowing, and becoming something new."],["picture","https://image.nostr.build/e98b3341636054b33ccf414390d75d02b721511d5e331ffcac63bacfa56fa1c4.jpg"],["banner","https://images.pexels.com/photos/18558371/pexels-photo-18558371.jpeg"],["lud16","purepug2@primal.net"],["website","https://shoreline.decentnewsroom.com"],["nip05","shoreline@decentnewsroom.com"]],"content":"{\"display_name\":\"Shoreline Poetry\",\"name\":\"shoreline\",\"about\":\"Poems and fragments. Inner weather. Ceasing, breaking, flowing, and becoming something new.\",\"banner\":\"https://images.pexels.com/photos/18558371/pexels-photo-18558371.jpeg\",\"website\":\"https://shoreline.decentnewsroom.com\",\"nip05\":[\"shoreline@decentnewsroom.com\"],\"lud16\":\"purepug2@primal.net\",\"picture\":\"https://image.nostr.build/e98b3341636054b33ccf414390d75d02b721511d5e331ffcac63bacfa56fa1c4.jpg\"}","sig":"fe2a682f4cc407e6de07ff7e68309f757b3a63ba41851c21199ea71067aafd0a27dc874fc575104654d908cb65aba25dd060086c31723f496efb9271ee256560"}
|
||||
""".trimIndent()
|
||||
|
||||
bothMappers(json) { label, event ->
|
||||
val meta = event.contactMetaData()
|
||||
assertIs<UserMetadata>(meta, label)
|
||||
assertEquals("shoreline", meta.name, label)
|
||||
assertEquals("Shoreline Poetry", meta.displayName, label)
|
||||
assertEquals("shoreline@decentnewsroom.com", meta.nip05, "$label: the one-element array must be unwrapped")
|
||||
assertEquals("purepug2@primal.net", meta.lud16, label)
|
||||
assertEquals("https://shoreline.decentnewsroom.com", meta.website, label)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile dumped straight out of some client's internal state: `"nip05":{}`
|
||||
* and `"custom_data":{}` plus keys that are not NIP-24 fields at all. The
|
||||
* malformed nip05 is dropped, everything else survives.
|
||||
*/
|
||||
@Test
|
||||
fun objectNip05AndForeignKeysDoNotDropTheProfile() {
|
||||
val json =
|
||||
"""
|
||||
{"id":"dde6cd9661490879de9d5c1db12e1025ef6e429e7f6efb1021f1fda7fa1bc99d","pubkey":"85faee02e65460a46a5e8dbb47d46f0b1c02030aba3da437257e5759a00e0885","created_at":1710229789,"kind":0,"tags":[],"content":"{\"name\":\"krit\",\"about\":\"\",\"display_name\":\"krit\",\"picture\":\"\",\"pubkey\":\"npub1shawuqhx23s2g6j73ka504r0pvwqyqc2hg76gde90et4ngqwpzzsua9kwr\",\"pubkeyHex\":\"85faee02e65460a46a5e8dbb47d46f0b1c02030aba3da437257e5759a00e0885\",\"lastUpdatedAt\":0,\"nip05\":{},\"custom_data\":{},\"isLogin\":false}","sig":"df4ef440b80843e53b9b99e0ebbf1d5f22fbeb4cc8ce8993b3df42a16d96e06b74036f10f3cf62348a4f5124b91afc2ff7d82482289879994b30bb73138a92d9"}
|
||||
""".trimIndent()
|
||||
|
||||
bothMappers(json) { label, event ->
|
||||
val meta = event.contactMetaData()
|
||||
assertIs<UserMetadata>(meta, label)
|
||||
assertEquals("krit", meta.name, label)
|
||||
assertEquals("krit", meta.displayName, label)
|
||||
assertNull(meta.nip05, "$label: an object nip05 has no readable value")
|
||||
|
||||
// The empty strings the client wrote are normalized away on consumption.
|
||||
meta.cleanBlankNames()
|
||||
assertNull(meta.picture, label)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Ditto profile: `"birthday":"10-24"` (NIP-24 defines an object), a `fields`
|
||||
* array of arrays, and assorted client-private keys. Everything the app reads
|
||||
* must survive; the birthday stays dropped because a bare `"10-24"` is
|
||||
* ambiguous (MM-DD vs DD-MM) — see [BirthdayTolerantSerializer].
|
||||
*/
|
||||
@Test
|
||||
fun stringBirthdayAndExtraFieldsDoNotDropTheProfile() {
|
||||
val json =
|
||||
"""
|
||||
{"id":"ed269c23907649461da4b0fe109eed689ed1a562d33873b97ed01496dd02b87c","pubkey":"932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d","created_at":1779416127,"kind":0,"tags":[["client","Ditto","31990:781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5:ditto"],["published_at","1779044138"]],"content":"{\"about\":\"Team Soapbox. Freedom advocate.\",\"banner\":\"https://blossom.ditto.pub/ee0d.jpeg\",\"bot\":false,\"display_name\":\"MK Fain\",\"lud16\":\"mkfain@cash.app\",\"name\":\"MK Fain\",\"nip05\":\"mk@ditto.pub\",\"picture\":\"https://blossom.ditto.pub/cf2b.jpeg\",\"displayName\":\"MK Fain\",\"pubkey\":\"932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d\",\"npub\":\"npub1jvnpg4c6ljadf5t6ry0w9q0rnm4mksde87kglkrc993z46c39axsgq89sc\",\"created_at\":1718637715,\"fields\":[[\"Soapbox\",\"https://soapbox.pub\"],[\"Ditto FAQ\",\"https://ditto.pub/help\"]],\"birthday\":\"10-24\",\"showBirthday\":true,\"client\":\"divine.video\",\"shape\":\"x\"}","sig":"a78ed7b638eb240dbc81ea6565d5de4fb67d970fa391ea69b9757e1c6dfb448c54ea7d41f16802027135c1b6bd729e3889eb27065b432d42660cf1fb186fc8a0"}
|
||||
""".trimIndent()
|
||||
|
||||
bothMappers(json) { label, event ->
|
||||
val meta = event.contactMetaData()
|
||||
assertIs<UserMetadata>(meta, label)
|
||||
assertEquals("MK Fain", meta.name, label)
|
||||
assertEquals("MK Fain", meta.displayName, label)
|
||||
assertEquals("mk@ditto.pub", meta.nip05, label)
|
||||
assertEquals("mkfain@cash.app", meta.lud16, label)
|
||||
assertEquals(false, meta.bot, label)
|
||||
assertEquals("Team Soapbox. Freedom advocate.", meta.about, label)
|
||||
assertNull(meta.birthday, "$label: an ambiguous string birthday is dropped, not fatal")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user