mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 06:44:37 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f1b8ec833 | ||
|
|
f4fc9917f8 | ||
|
|
0c4ea9031b | ||
|
|
72d05e2eb8 | ||
|
|
bcbf78d8ea | ||
|
|
32331ecd3d | ||
|
|
3353e591d0 | ||
|
|
221214e545 | ||
|
|
ff9855aad6 | ||
|
|
372964194d | ||
|
|
f3104f0a6c | ||
|
|
cd2ce05ee8 | ||
|
|
79d7d27198 | ||
|
|
a02066a700 | ||
|
|
fdbad9396f | ||
|
|
5c18cee6ad | ||
|
|
3565f75847 | ||
|
|
0cf1534861 | ||
|
|
64019dbd7c | ||
|
|
93fe3ac727 | ||
|
|
dda0f0d9e8 | ||
|
|
9373c0a22c | ||
|
|
a6f41072a9 | ||
|
|
d9ea3ef86a | ||
|
|
673184dd7b | ||
|
|
8b4220019a | ||
|
|
d6e78a7857 | ||
|
|
a3fac4fc08 | ||
|
|
9ac033effc | ||
|
|
f1d2bdee49 |
@@ -44,11 +44,26 @@ jobs:
|
||||
matrix:
|
||||
# Linux legs run on x64 and arm64 GitHub-hosted runners (the
|
||||
# ubuntu-24.04-arm label is a standard free public-repo runner as of
|
||||
# early 2025). jpackage / jlink / Compose Multiplatform 1.11 all
|
||||
# produce host-native artifacts — no cross-compilation needed.
|
||||
# early 2025). Windows arm64 uses windows-11-arm, added to the free
|
||||
# public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64).
|
||||
# jpackage / jlink / Compose Multiplatform 1.11 all produce
|
||||
# host-native artifacts — no cross-compilation needed.
|
||||
#
|
||||
# The arm64 Windows leg builds the portable .zip ONLY — no MSI.
|
||||
# jpackage --type msi shells out to WiX 3's heat/candle/light, and the
|
||||
# windows-11-arm runner image ships no WiX (the windows-latest image
|
||||
# has WiX 3.14 preinstalled, which is why the x64 leg can package an
|
||||
# MSI). Installing it here would mean pulling an archived, x86-only
|
||||
# toolchain (wixtoolset/wix3 was archived in Feb 2025; WiX 4+ dropped
|
||||
# the candle/light CLI that JDK 21's jpackage requires) into the job
|
||||
# that publishes signed release assets. The portable zip is the
|
||||
# documented Windows install path for amy/geode already, so arm64
|
||||
# Windows users get that until either the runner image gains WiX or
|
||||
# jpackage learns the WiX 4+ CLI.
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
@@ -221,6 +236,20 @@ jobs:
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
# jpackage --type deb only auto-generates Depends from dpkg-shlibdeps
|
||||
# against the bundled JRE under lib/runtime/, NOT the app payload under
|
||||
# lib/app/. libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (unlike
|
||||
# the x64 skiko which only links libGL.so.1), so a minimal aarch64
|
||||
# install without EGL crashes at startup with:
|
||||
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
|
||||
# Rewrite the arm64 .deb to add libegl1 to Depends. x64 .deb is untouched.
|
||||
- name: Add libegl1 dep to arm64 .deb
|
||||
if: matrix.family == 'linux' && matrix.arch == 'arm64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/add-deb-libegl-dep.sh
|
||||
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
- name: Build portable archives (windows + linux-portable)
|
||||
if: matrix.family == 'windows' || matrix.family == 'linux-portable'
|
||||
run: |
|
||||
@@ -355,9 +384,17 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types
|
||||
# and jpackageMsi for a CLI is deferred (the portable zip is the
|
||||
# documented Windows install path). The launcher script writes both
|
||||
# `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs
|
||||
# under bash on GH windows runners (git-bash is on PATH). collect_cli_assets
|
||||
# zips the image on Windows instead of tar.gz.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -605,9 +642,16 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is
|
||||
# deferred (portable zip covers the primary use — operators still
|
||||
# deploy geode via the Docker image or the tarball on Linux). The
|
||||
# image writes both `bin/geode` (sh) and `bin/geode.bat`, and the
|
||||
# smoke test below runs under bash on the windows runner.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -662,12 +706,23 @@ jobs:
|
||||
# module list is complete for the real relay path (Ktor CIO + SQLite +
|
||||
# NIP-11 serialization) — a too-tight module list links fine but fails
|
||||
# here with NoClassDefFound instead of on an operator's machine.
|
||||
#
|
||||
# On the Windows legs we invoke bin/geode.bat instead of bin/geode. The
|
||||
# tmp path also differs between git-bash on Windows (which resolves /tmp
|
||||
# to a mingw path that curl -o accepts) and POSIX runners; kept identical
|
||||
# because the workflow's `defaults.run.shell: bash` uses git-bash on
|
||||
# Windows and /tmp is a valid mingw path there.
|
||||
- name: Smoke-test the geode image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="geode/build/geode-image/geode"
|
||||
"$IMG/bin/geode" --version
|
||||
"$IMG/bin/geode" --port 17447 &
|
||||
if [[ "${{ matrix.family }}" == "windows" ]]; then
|
||||
LAUNCHER="$IMG/bin/geode.bat"
|
||||
else
|
||||
LAUNCHER="$IMG/bin/geode"
|
||||
fi
|
||||
"$LAUNCHER" --version
|
||||
"$LAUNCHER" --port 17447 &
|
||||
PID=$!
|
||||
ok=0
|
||||
for i in $(seq 1 20); do
|
||||
|
||||
+25
-7
@@ -35,7 +35,12 @@ All platforms:
|
||||
Platform-specific:
|
||||
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`.
|
||||
Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner
|
||||
and produce the portable `.zip` only — that image ships no WiX, so CI cannot
|
||||
package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with
|
||||
WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries
|
||||
themselves are x86 and run under emulation).
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
|
||||
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
|
||||
`flatpak-builder` for the Flatpak bundle (see
|
||||
@@ -328,14 +333,27 @@ Quartz library in one pipeline.
|
||||
|
||||
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
|
||||
|
||||
4. **Verify** — the GH Release should hold **31 assets**:
|
||||
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
|
||||
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
|
||||
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
|
||||
configured, so macOS ships arm64-only.
|
||||
4. **Verify** — the GH Release should hold **47 assets**:
|
||||
- **14 desktop**, one per matrix leg × format:
|
||||
- macOS arm64: `dmg` (1)
|
||||
- Windows x64: `msi` + portable `zip` (2)
|
||||
- Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below
|
||||
- Linux x64 / arm64: `deb` + `rpm` (4)
|
||||
- Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6)
|
||||
|
||||
There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile
|
||||
and no Intel runner leg is configured, so macOS ships arm64-only.
|
||||
There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to
|
||||
WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image
|
||||
ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why
|
||||
the x64 leg gets an MSI). Revisit if that image gains WiX, or if
|
||||
jpackage learns the WiX 4+ `wix build` CLI.
|
||||
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
|
||||
F-Droid `.apks` set built for Accrescent.
|
||||
- **5 amy** + **5 geode** bundles.
|
||||
- **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64),
|
||||
`deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the
|
||||
one arch-independent no-JRE `amy-<ver>-jvm.tar.gz` for Homebrew-core.
|
||||
- **10 geode** — same shape as amy.
|
||||
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
|
||||
- Android flow unchanged
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
@@ -955,6 +956,9 @@ class Account(
|
||||
*/
|
||||
fun applyBottomBarItems(items: List<BottomBarEntry>): Boolean = settings.changeBottomBarItems(items)
|
||||
|
||||
/** The drawer counterpart of [applyBottomBarItems] — same synchronous-apply, publish-after contract. */
|
||||
fun applyHiddenDrawerItems(items: Set<NavBarItem>): Boolean = settings.changeHiddenDrawerItems(items)
|
||||
|
||||
suspend fun toggleChatroomPin(room: ChatroomKey) {
|
||||
settings.toggleChatroomPin(room)
|
||||
sendNewAppSpecificData()
|
||||
|
||||
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
@@ -501,6 +503,18 @@ class AccountSettings(
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeHiddenDrawerItems(newItems: Set<NavBarItem>): Boolean {
|
||||
// Sanitize on the way in as well as on the way out: a caller must never be able to persist
|
||||
// Settings as hidden, which would leave no route back to the screen that hides rows.
|
||||
val sanitized = DrawerItemVisibility.sanitize(newItems)
|
||||
if (syncedSettings.navigation.hiddenDrawerItems.value != sanitized) {
|
||||
syncedSettings.navigation.hiddenDrawerItems.tryEmit(sanitized)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The selected default spend rail across both NWC wallets and CLINK debits. */
|
||||
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -83,6 +87,7 @@ class AccountSyncedSettings(
|
||||
val navigation =
|
||||
AccountNavigationPreferences(
|
||||
MutableStateFlow(internalSettings.navigation.bottomBarItems),
|
||||
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
@@ -124,7 +129,11 @@ class AccountSyncedSettings(
|
||||
.map { it.id }
|
||||
.sorted(),
|
||||
),
|
||||
navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value),
|
||||
navigation =
|
||||
AccountNavigationPreferencesInternal(
|
||||
navigation.bottomBarItems.value,
|
||||
navigation.hiddenDrawerItems.value.toNames(),
|
||||
),
|
||||
)
|
||||
|
||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||
@@ -221,6 +230,11 @@ class AccountSyncedSettings(
|
||||
if (navigation.bottomBarItems.value != newBottomBarItems) {
|
||||
navigation.bottomBarItems.tryEmit(newBottomBarItems)
|
||||
}
|
||||
|
||||
val newHiddenDrawerItems = DrawerItemVisibility.sanitize(navBarItemsFromNames(syncedSettingsInternal.navigation.hiddenDrawerItems))
|
||||
if (navigation.hiddenDrawerItems.value != newHiddenDrawerItems) {
|
||||
navigation.hiddenDrawerItems.tryEmit(newHiddenDrawerItems)
|
||||
}
|
||||
}
|
||||
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||
@@ -322,6 +336,8 @@ class AccountMediaPreferences(
|
||||
@Stable
|
||||
class AccountNavigationPreferences(
|
||||
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
|
||||
/** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */
|
||||
val hiddenDrawerItems: MutableStateFlow<Set<NavBarItem>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
|
||||
+9
@@ -170,6 +170,15 @@ class AccountNavigationPreferencesInternal(
|
||||
// favorite apps, and individual joined chats/groups). Defaulted so blobs
|
||||
// written before this field existed decode to the app's current defaults.
|
||||
var bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
|
||||
// The drawer (side menu) rows the user switched off, as NavBarItem *names*.
|
||||
// Empty by default, which is what makes a newly shipped destination visible
|
||||
// to everyone without a migration — see DrawerItemVisibility.
|
||||
//
|
||||
// Stored as strings rather than the enum on purpose: an id written by a
|
||||
// newer client would fail the enum decoder and take the whole synced-settings
|
||||
// blob down with it, so unknown names are dropped on read instead (the same
|
||||
// approach AccountPoWPreferencesInternal.enabledCategories takes).
|
||||
var hiddenDrawerItems: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ class NappletLiveSubscriptions {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -113,7 +113,7 @@ object ClinkDebitPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -85,7 +85,7 @@ object ClinkOfferPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -158,7 +158,7 @@ class BootRelayDiagnostics(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ class DmRelayDiagnosticsLogger(
|
||||
Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" }
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ abstract class PerUserEoseManager<T>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ class NotifyCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class AccountFollowsLoaderSubAssembler(
|
||||
newEose(TimeUtils.now(), relay, forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ class AccountNotificationsHistoryEoseManager(
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.notificationHistory
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ class NwcNotificationsEoseManager(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ class AccountGiftWrapsHistoryEoseManager(
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.chatroomList.giftWrapHistory
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ class UserWatcherSubAssembler(
|
||||
newEose(relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class RelaySpeedLogger(
|
||||
|
||||
private val clientListener =
|
||||
object : RelayConnectionListener {
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ class RelayUsageListener(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
|
||||
import com.vitorpamplona.amethyst.commons.util.prettyMime
|
||||
import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
|
||||
/**
|
||||
* The renderer for a declared file that none of the media viewers can display — a webxdc app,
|
||||
* an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia]
|
||||
* returns null for.
|
||||
*
|
||||
* It exists so those files have somewhere to land other than the video player: an unknown blob
|
||||
* used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a
|
||||
* zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the
|
||||
* card costs no network round-trip — unlike routing the URL through the OpenGraph previewer,
|
||||
* which would try to download the blob just to rediscover the type the event already declared.
|
||||
*/
|
||||
@Composable
|
||||
fun FileAttachmentCard(
|
||||
url: String,
|
||||
description: String?,
|
||||
mimeType: String?,
|
||||
sizeInBytes: Long?,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val filename = remember(url) { extractFilename(url) }
|
||||
val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.fillMaxWidth()
|
||||
.clickable { uriHandler.openUri(url) },
|
||||
) {
|
||||
FileAttachmentRow(
|
||||
symbol = MaterialSymbols.AttachFile,
|
||||
// The alt/content text names the file for a human ("Webxdc app: Quake");
|
||||
// the hashed URL basename is the fallback when the event omits it.
|
||||
title = description?.ifBlank { null } ?: filename,
|
||||
subtitle = subtitle,
|
||||
titleMaxLines = 2,
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon + title + subtitle row shared by every card that stands in for a file it can't
|
||||
* render inline: this one and the PDF placeholder/skeleton in
|
||||
* [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard].
|
||||
*/
|
||||
@Composable
|
||||
internal fun FileAttachmentRow(
|
||||
symbol: MaterialSymbol,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
titleMaxLines: Int = 1,
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "APK · 16 MB", dropping either half when the event doesn't declare it. */
|
||||
private fun fileSubtitle(
|
||||
mimeType: String?,
|
||||
sizeInBytes: Long?,
|
||||
): String? =
|
||||
listOfNotNull(
|
||||
mimeType?.ifBlank { null }?.let(::prettyMime),
|
||||
sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes),
|
||||
).joinToString(" · ").ifEmpty { null }
|
||||
+6
-39
@@ -26,38 +26,29 @@ import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -207,35 +198,11 @@ private fun PdfSkeletonCard(filename: String) {
|
||||
private fun FilenameRow(
|
||||
filename: String,
|
||||
subtitle: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PictureAsPdf,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = filename,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) = FileAttachmentRow(
|
||||
symbol = MaterialSymbols.PictureAsPdf,
|
||||
title = filename,
|
||||
subtitle = subtitle,
|
||||
)
|
||||
|
||||
private fun renderFirstPage(
|
||||
file: java.io.File,
|
||||
|
||||
@@ -263,6 +263,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen
|
||||
@@ -578,6 +579,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.MessagesSettings> { MessagesSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
|
||||
-28
@@ -20,13 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
@@ -58,29 +56,3 @@ fun keyboardAsState(): State<KeyboardState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [BackHandler] that steps aside while the soft keyboard is on screen.
|
||||
*
|
||||
* Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen.
|
||||
* When that pop happens while the keyboard is still up, it races the predictive-back window
|
||||
* animation against the IME's close animation. On release builds — fast enough that the window
|
||||
* animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat]
|
||||
* is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime`
|
||||
* holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard
|
||||
* height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the
|
||||
* screen).
|
||||
*
|
||||
* Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the
|
||||
* system dismisses the keyboard first with its own animation (which completes cleanly). The next
|
||||
* back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an
|
||||
* always-available exit, so this can never trap the user even if the inset reading were itself stale.
|
||||
*/
|
||||
@Composable
|
||||
fun KeyboardAwareBackHandler(
|
||||
enabled: Boolean = true,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val keyboardState by keyboardAsState()
|
||||
BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack)
|
||||
}
|
||||
|
||||
+22
-66
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -29,8 +28,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Stable identifiers for every drawer destination that the user can pin to the bottom bar.
|
||||
* Order in this enum has no semantic meaning — the user picks a subset and an order at runtime.
|
||||
* Stable identifiers for every destination the navigation surfaces can show — the bottom bar pins a
|
||||
* subset in a user-chosen order, the drawer lists them under fixed headings (see DrawerSections).
|
||||
* Order in this enum has no semantic meaning.
|
||||
*/
|
||||
@Serializable
|
||||
enum class NavBarItem {
|
||||
@@ -84,6 +84,18 @@ enum class NavBarItem {
|
||||
FAVORITE_ALGO_FEEDS,
|
||||
}
|
||||
|
||||
private val NavBarItemsByName = NavBarItem.entries.associateBy { it.name }
|
||||
|
||||
/**
|
||||
* Parses persisted [NavBarItem] names, silently dropping any this build doesn't know — a settings
|
||||
* blob synced from a newer client can name a destination that doesn't exist here yet, and that must
|
||||
* degrade to "ignore this one row" rather than failing the decode of the whole blob.
|
||||
*/
|
||||
fun navBarItemsFromNames(names: Collection<String>): Set<NavBarItem> = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] }
|
||||
|
||||
/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */
|
||||
fun Set<NavBarItem>.toNames(): List<String> = map { it.name }.sorted()
|
||||
|
||||
data class NavBarItemDef(
|
||||
val id: NavBarItem,
|
||||
val labelRes: Int,
|
||||
@@ -443,34 +455,6 @@ val DefaultBottomBarItems: List<NavBarItem> =
|
||||
/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */
|
||||
val DefaultBottomBarEntries: List<BottomBarEntry> = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) }
|
||||
|
||||
// Ordered membership lists for each drawer section. The drawer renders these by looking up
|
||||
// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the
|
||||
// matching section list below — not two separate files.
|
||||
val DrawerNavigateItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
NavBarItem.VIDEO,
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.DISCOVER,
|
||||
NavBarItem.NOTIFICATIONS,
|
||||
)
|
||||
|
||||
val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
)
|
||||
|
||||
/**
|
||||
* A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The
|
||||
* catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat
|
||||
@@ -479,6 +463,7 @@ val DrawerYouItems: List<NavBarItem> =
|
||||
*/
|
||||
data class NavBarCategory(
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val items: List<NavBarItem>,
|
||||
)
|
||||
|
||||
@@ -491,6 +476,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
listOf(
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_main,
|
||||
MaterialSymbols.Home,
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
@@ -501,6 +487,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_chats,
|
||||
MaterialSymbols.Group,
|
||||
listOf(
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
@@ -510,6 +497,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_you,
|
||||
MaterialSymbols.AccountCircle,
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
@@ -527,6 +515,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_feeds,
|
||||
MaterialSymbols.Subscriptions,
|
||||
listOf(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.LONGS,
|
||||
@@ -553,6 +542,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_apps,
|
||||
MaterialSymbols.Apps,
|
||||
listOf(
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.FAVORITE_APPS,
|
||||
@@ -563,43 +553,9 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_other,
|
||||
MaterialSymbols.Settings,
|
||||
listOf(
|
||||
NavBarItem.SETTINGS,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val DrawerFeedsItems: List<NavBarItem> =
|
||||
listOfNotNull(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.SHORTS,
|
||||
NavBarItem.LONGS,
|
||||
NavBarItem.PODCAST_EPISODES,
|
||||
NavBarItem.PODCASTS,
|
||||
NavBarItem.MUSIC_TRACKS,
|
||||
NavBarItem.MUSIC_PLAYLISTS,
|
||||
NavBarItem.POLLS,
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
// Favorites can be pinned as inline tabs that render on a cross-process surface
|
||||
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
|
||||
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
|
||||
NavBarItem.NAPPLETS,
|
||||
NavBarItem.NSITES,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.BADGES,
|
||||
NavBarItem.EMOJI_SETS,
|
||||
)
|
||||
|
||||
+59
-44
@@ -63,6 +63,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -105,9 +106,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
|
||||
@@ -584,42 +582,17 @@ fun ListContent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Per-account, synced through the NIP-78 app-specific data event, and edited on the
|
||||
// Side Menu settings screen. Empty (the default) means the full stock drawer.
|
||||
val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier) {
|
||||
CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav)
|
||||
CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav)
|
||||
CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav)
|
||||
|
||||
CollapsibleSection(title = R.string.drawer_section_create) {
|
||||
NavigationRow(
|
||||
title = R.string.share_hls_video,
|
||||
icon = MaterialSymbols.SettingsInputAntenna,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.NewHlsVideo,
|
||||
)
|
||||
|
||||
if (isDebug) {
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleSection(title = R.string.drawer_section_system) {
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
nav.nav(Route.EditRelays)
|
||||
},
|
||||
)
|
||||
|
||||
NavBarCatalog[NavBarItem.SETTINGS]?.let {
|
||||
CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav)
|
||||
DrawerSections.forEach { section ->
|
||||
// Keyed by section: hiding the last row of a section removes it from the drawer
|
||||
// entirely, and without a key the sections below would slide up into its slots and
|
||||
// inherit its CollapsibleSection expanded/collapsed state.
|
||||
key(section.id) {
|
||||
CatalogSection(section, hidden, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,22 +607,64 @@ fun ListContent(
|
||||
}
|
||||
}
|
||||
|
||||
/** The Create section's rows — composer entry points, none of which is a catalog destination. */
|
||||
@Composable
|
||||
private fun CreateRows(nav: INav) {
|
||||
NavigationRow(
|
||||
title = R.string.share_hls_video,
|
||||
icon = MaterialSymbols.SettingsInputAntenna,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.NewHlsVideo,
|
||||
)
|
||||
|
||||
if (isDebug) {
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog].
|
||||
* Profile gets the primary-colored tint; every other item uses onBackground.
|
||||
* Renders one drawer section: its fixed rows, if it has any, then the catalog rows the user hasn't
|
||||
* switched off. Profile gets the primary-colored tint; every other item uses onBackground.
|
||||
*
|
||||
* A section with nothing left to show renders nothing at all — an empty, permanently collapsed
|
||||
* heading is just noise. Two sections always have something: Create is entirely fixed rows, and
|
||||
* System carries the relay-status row (not a catalog destination — it shows a live counter).
|
||||
*/
|
||||
@Composable
|
||||
fun CatalogSection(
|
||||
titleRes: Int,
|
||||
ids: List<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val onBackground = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
CollapsibleSection(title = titleRes) {
|
||||
ids.forEach { id ->
|
||||
val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) }
|
||||
if (visible.isEmpty() && !section.hasFixedRows) return
|
||||
|
||||
CollapsibleSection(title = section.titleRes) {
|
||||
when (section.id) {
|
||||
DrawerSectionId.CREATE -> CreateRows(nav)
|
||||
DrawerSectionId.SYSTEM ->
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
nav.nav(Route.EditRelays)
|
||||
},
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
|
||||
visible.forEach { id ->
|
||||
NavBarCatalog[id]?.let { def ->
|
||||
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
|
||||
if (def.id == NavBarItem.SCHEDULED_POSTS) {
|
||||
|
||||
+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.navigation.drawer
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
|
||||
/**
|
||||
* Which drawer rows the user cannot hide.
|
||||
*
|
||||
* Settings is the only one, and it is mandatory for a specific reason: it is the route back to the
|
||||
* screen that hides rows in the first place. Hiding it would let a user lock themselves out of their
|
||||
* own configuration. Everything else the drawer always shows — the profile header, the relay-status
|
||||
* row, the account switcher and the version/QR footer — is fixed chrome rather than a catalog row,
|
||||
* so it is present by construction and never appears in the hidden set.
|
||||
*/
|
||||
val MandatoryDrawerItems: Set<NavBarItem> = setOf(NavBarItem.SETTINGS)
|
||||
|
||||
/**
|
||||
* Pure show/hide rules for the drawer's catalog rows, kept free of Compose and Android so they are
|
||||
* exercised directly by unit tests (DrawerItemVisibilityTest) rather than only through the UI.
|
||||
*
|
||||
* The per-account preference stores the **hidden** items rather than the visible ones. That choice is
|
||||
* what makes a newly added destination appear for everyone automatically: a row nobody has ever
|
||||
* hidden simply isn't in the set, so it renders. Storing the visible list instead would freeze each
|
||||
* account's drawer at the moment they first touched the setting, and every later release would have
|
||||
* to migrate saved lists to introduce a screen.
|
||||
*/
|
||||
object DrawerItemVisibility {
|
||||
fun isVisible(
|
||||
hidden: Set<NavBarItem>,
|
||||
item: NavBarItem,
|
||||
): Boolean = item in MandatoryDrawerItems || item !in hidden
|
||||
|
||||
/** Hides [item] if shown, shows it if hidden. Mandatory items never change (see [MandatoryDrawerItems]). */
|
||||
fun toggle(
|
||||
hidden: Set<NavBarItem>,
|
||||
item: NavBarItem,
|
||||
): Set<NavBarItem> =
|
||||
when {
|
||||
item in MandatoryDrawerItems -> hidden
|
||||
item in hidden -> hidden - item
|
||||
else -> hidden + item
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops mandatory rows from the set. The persistence layer is the single place this is enforced —
|
||||
* it runs on decode, on an external sync, and on every write — so a value synced from another
|
||||
* client (or from a build where the row wasn't mandatory yet) can't strand Settings as hidden.
|
||||
*
|
||||
* Ids that no section renders are deliberately *kept*: on a device where a row is gated off (see
|
||||
* DrawerFeedsItems' API-30 gate on Favorite Apps) it matches nothing and costs nothing, and
|
||||
* preserving it means editing the drawer on that device doesn't silently clear the choice the
|
||||
* user made on another one.
|
||||
*/
|
||||
fun sanitize(hidden: Set<NavBarItem>): Set<NavBarItem> = hidden - MandatoryDrawerItems
|
||||
|
||||
/** The rows of [section] to render, in the section's fixed order. */
|
||||
fun visibleItems(
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
): List<NavBarItem> = section.items.filter { isVisible(hidden, it) }
|
||||
|
||||
/** How many of [section]'s rows are currently hidden — shown on the collapsed section header. */
|
||||
fun hiddenCount(
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
): Int = section.items.count { !isVisible(hidden, it) }
|
||||
|
||||
/** Whether [section] has any row the user is allowed to switch off — gates its bulk actions. */
|
||||
fun hasHideableRows(section: DrawerSection): Boolean = section.items.any { it !in MandatoryDrawerItems }
|
||||
|
||||
/** Hides every row of [section] that can be hidden, leaving the mandatory ones. */
|
||||
fun hideAll(
|
||||
hidden: Set<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
): Set<NavBarItem> = hidden + section.items.filter { it !in MandatoryDrawerItems }
|
||||
|
||||
/** Shows every row of [section] again. */
|
||||
fun showAll(
|
||||
hidden: Set<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
): Set<NavBarItem> = hidden - section.items.toSet()
|
||||
|
||||
/** Total hidden rows across every section — the count the settings screen shows at the top. */
|
||||
fun totalHidden(hidden: Set<NavBarItem>): Int = DrawerSections.sumOf { hiddenCount(it, hidden) }
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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.navigation.drawer
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
|
||||
/**
|
||||
* The drawer's layout: which destinations it lists, under which heading, in which order.
|
||||
*
|
||||
* One list drives two screens — [ListContent] renders the visible rows of each section, and the Side
|
||||
* Menu settings screen renders the same sections as its show/hide catalog. Adding a destination to a
|
||||
* section's list therefore surfaces it in the drawer *and* in its configuration screen without
|
||||
* touching either, and DrawerSectionsTest fails the build if a newly added [NavBarCatalog] id isn't
|
||||
* filed into exactly one section.
|
||||
*
|
||||
* Section order and within-section order are fixed and not user-editable: the drawer is a menu, and a
|
||||
* menu whose headings move around is harder to learn, not easier. The only per-account choice is
|
||||
* which rows are visible — see [DrawerItemVisibility].
|
||||
*/
|
||||
@Immutable
|
||||
data class DrawerSection(
|
||||
val id: DrawerSectionId,
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val items: List<NavBarItem>,
|
||||
/**
|
||||
* True for a section that renders rows of its own on top of its catalog items (see [CatalogSection]).
|
||||
* Such a section stays in the drawer even with every catalog row switched off, and — since a fixed
|
||||
* row is not a catalog destination — it never appears in the Side Menu settings screen's counts.
|
||||
*/
|
||||
val hasFixedRows: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Identifies a section for the handful of rendering rules that are specific to one. Matching on this
|
||||
* rather than on a section's object identity keeps those rules working if the list is ever mapped or
|
||||
* copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no
|
||||
* compile error and nothing to fail a test.
|
||||
*/
|
||||
enum class DrawerSectionId {
|
||||
YOU,
|
||||
NAVIGATE,
|
||||
FEEDS,
|
||||
|
||||
/** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */
|
||||
CREATE,
|
||||
|
||||
/** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */
|
||||
SYSTEM,
|
||||
}
|
||||
|
||||
private val DrawerNavigateItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
NavBarItem.VIDEO,
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.DISCOVER,
|
||||
NavBarItem.NOTIFICATIONS,
|
||||
)
|
||||
|
||||
private val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.FAVORITE_ALGO_FEEDS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
)
|
||||
|
||||
private val DrawerFeedsItems: List<NavBarItem> =
|
||||
listOfNotNull(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.SHORTS,
|
||||
NavBarItem.LONGS,
|
||||
NavBarItem.PODCAST_EPISODES,
|
||||
NavBarItem.PODCASTS,
|
||||
NavBarItem.MUSIC_TRACKS,
|
||||
NavBarItem.MUSIC_PLAYLISTS,
|
||||
NavBarItem.POLLS,
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
// Favorites can be pinned as inline tabs that render on a cross-process surface
|
||||
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
|
||||
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
|
||||
NavBarItem.NAPPLETS,
|
||||
NavBarItem.NSITES,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.BADGES,
|
||||
NavBarItem.EMOJI_SETS,
|
||||
)
|
||||
|
||||
val DrawerSections: List<DrawerSection> =
|
||||
listOf(
|
||||
DrawerSection(DrawerSectionId.YOU, R.string.drawer_section_you, MaterialSymbols.AccountCircle, DrawerYouItems),
|
||||
DrawerSection(DrawerSectionId.NAVIGATE, R.string.drawer_section_navigate, MaterialSymbols.Home, DrawerNavigateItems),
|
||||
DrawerSection(DrawerSectionId.FEEDS, R.string.drawer_section_feeds, MaterialSymbols.Subscriptions, DrawerFeedsItems),
|
||||
DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList(), hasFixedRows = true),
|
||||
DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS), hasFixedRows = true),
|
||||
)
|
||||
|
||||
/**
|
||||
* Catalog ids deliberately absent from every [DrawerSections] list, with the reason. Only Favorite
|
||||
* Apps qualifies: [DrawerFeedsItems] gates it on API 30+ (its inline tabs need SurfaceControlViewHost),
|
||||
* so on older devices the row simply doesn't exist. DrawerSectionsTest allows exactly these to be
|
||||
* missing, and fails on anything else — that's what keeps a newly added destination from silently
|
||||
* skipping both the drawer and its settings screen.
|
||||
*/
|
||||
val SdkGatedDrawerItems: Set<NavBarItem> = setOf(NavBarItem.FAVORITE_APPS)
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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.navigation.navs
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** How long to wait for the IME inset to reach zero before navigating anyway. */
|
||||
const val IME_SETTLE_TIMEOUT_MS = 700L
|
||||
|
||||
/**
|
||||
* Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation
|
||||
* in the app serializes the IME and window animations instead of overlapping them.
|
||||
*
|
||||
* Navigating while the keyboard is up races the window animation against the IME's close animation.
|
||||
* On release builds — fast enough that the window animation wins — the IME
|
||||
* [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before
|
||||
* its terminal (zero) frame reaches Compose. `WindowInsets.ime` is a single app-wide holder, so it
|
||||
* stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left —
|
||||
* freezes at the keyboard height until some later inset pass happens to rebalance it.
|
||||
*
|
||||
* This is not a composer-screen problem, which is why it lives here rather than in the screens.
|
||||
* Any destination that can hold focus in a text field can strand the padding on the way out, by any
|
||||
* exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the
|
||||
* clearest case — it focuses its field on arrival, so the keyboard is already up before the user
|
||||
* has done anything, and every way out of it is a navigation.
|
||||
*/
|
||||
fun interface ImeSettler {
|
||||
suspend fun settle()
|
||||
|
||||
companion object {
|
||||
/** For [EmptyNav] and previews, where there is no window to read insets from. */
|
||||
val None = ImeSettler { }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler
|
||||
* and the padding can never disagree about whether the keyboard is gone.
|
||||
*
|
||||
* Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded
|
||||
* by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this
|
||||
* guards against, navigation still proceeds rather than stranding the user on the screen.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberImeSettler(): ImeSettler {
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
return remember(density, imeInsets, keyboard, focusManager) {
|
||||
ImeSettler {
|
||||
if (imeInsets.getBottom(density) > 0) {
|
||||
focusManager.clearFocus(true)
|
||||
keyboard?.hide()
|
||||
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
|
||||
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,13 @@ import kotlin.reflect.KClass
|
||||
class Nav(
|
||||
val controller: NavHostController,
|
||||
override val navigationScope: CoroutineScope,
|
||||
/**
|
||||
* Awaited before every transition below. Leaving a screen while the soft keyboard is still
|
||||
* animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes
|
||||
* through this class, so this is the one place that has to get it right — no screen, top bar
|
||||
* or back handler needs to think about the keyboard on its way out.
|
||||
*/
|
||||
private val ime: ImeSettler = ImeSettler.None,
|
||||
) : INav {
|
||||
override val drawerState = DrawerState(DrawerValue.Closed)
|
||||
|
||||
@@ -63,6 +70,7 @@ class Nav(
|
||||
|
||||
override fun nav(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
if (getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
}
|
||||
@@ -71,6 +79,7 @@ class Nav(
|
||||
|
||||
override fun nav(computeRoute: suspend () -> Route?) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
val route = computeRoute()
|
||||
if (route != null && getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
@@ -80,6 +89,7 @@ class Nav(
|
||||
|
||||
override fun newStack(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(route) {
|
||||
inclusive = true
|
||||
@@ -91,6 +101,7 @@ class Nav(
|
||||
|
||||
override fun navBottomBar(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
// Clear sibling bottom-nav entries but keep Home (the start
|
||||
// destination) below, so back-swipe from any tab returns to
|
||||
@@ -149,6 +160,7 @@ class Nav(
|
||||
|
||||
override fun popBack() {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigateUp()
|
||||
}
|
||||
}
|
||||
@@ -159,6 +171,7 @@ class Nav(
|
||||
klass: KClass<T>,
|
||||
) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(klass) { inclusive = true }
|
||||
}
|
||||
|
||||
+3
-2
@@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController
|
||||
fun rememberNav(): Nav {
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
val ime = rememberImeSettler()
|
||||
|
||||
return remember(navController, scope) {
|
||||
Nav(navController, scope)
|
||||
return remember(navController, scope, ime) {
|
||||
Nav(navController, scope, ime)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -457,6 +457,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object BottomBarSettings : Route()
|
||||
|
||||
@Serializable object DrawerSettings : Route()
|
||||
|
||||
@Serializable object HomeTabsSettings : Route()
|
||||
|
||||
@Serializable object ProfileUiSettings : Route()
|
||||
|
||||
@@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -43,50 +46,103 @@ fun FileHeaderDisplay(
|
||||
) {
|
||||
val event = (note.event as? FileHeaderEvent) ?: return
|
||||
val fullUrl = event.url() ?: return
|
||||
val mimeType = remember(note) { event.mimeType() }
|
||||
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
|
||||
|
||||
val content: BaseMediaContent =
|
||||
remember(note) {
|
||||
val blurHash = event.blurhash()
|
||||
val thumbHash = event.thumbhash()
|
||||
val hash = event.hash()
|
||||
val dimensions = event.dimensions()
|
||||
val description = event.content.ifEmpty { null } ?: event.alt()
|
||||
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
|
||||
val uri = note.toNostrUri()
|
||||
val mimeType = event.mimeType()
|
||||
|
||||
if (isImage) {
|
||||
MediaUrlImage(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The sensitivity gate wraps both branches: a content warning is about the file, not about
|
||||
// which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate.
|
||||
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
if (content == null) {
|
||||
FileHeaderAttachmentCard(event, fullUrl, mimeType)
|
||||
} else {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob.
|
||||
*
|
||||
* Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71
|
||||
* video event the kind itself asserts nothing about how to render the payload. A null here means
|
||||
* the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player.
|
||||
*/
|
||||
internal fun FileHeaderEvent.toMediaContent(
|
||||
note: Note,
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): BaseMediaContent? {
|
||||
val blurHash = blurhash()
|
||||
val thumbHash = thumbhash()
|
||||
val hash = hash()
|
||||
val dimensions = dimensions()
|
||||
val description = fileDescription()
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
return when (RichTextParser.classifyMedia(url, mimeType)) {
|
||||
MediaContentKind.IMAGE ->
|
||||
MediaUrlImage(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
MediaContentKind.VIDEO ->
|
||||
MediaUrlVideo(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
MediaContentKind.PDF ->
|
||||
MediaUrlPdf(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */
|
||||
@Composable
|
||||
internal fun FileHeaderAttachmentCard(
|
||||
event: FileHeaderEvent,
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
) {
|
||||
val description = remember(event) { event.fileDescription() }
|
||||
val sizeInBytes = remember(event) { event.size()?.toLong() }
|
||||
|
||||
FileAttachmentCard(
|
||||
url = url,
|
||||
description = description,
|
||||
mimeType = mimeType,
|
||||
sizeInBytes = sizeInBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */
|
||||
private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt()
|
||||
|
||||
@@ -65,6 +65,7 @@ import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
|
||||
import com.vitorpamplona.amethyst.commons.util.prettyMime
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -766,27 +767,6 @@ fun RenderSoftwareAsset(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun prettyMime(mime: String): String =
|
||||
when (mime) {
|
||||
"application/vnd.android.package-archive" -> "APK"
|
||||
"application/vnd.apple.ipa" -> "IPA"
|
||||
"application/x-apple-diskimage" -> "DMG"
|
||||
"application/vnd.apple.installer+xml" -> "PKG"
|
||||
"application/x-msi" -> "MSI"
|
||||
"application/vnd.appimage" -> "AppImage"
|
||||
"application/vnd.flatpak" -> "Flatpak"
|
||||
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
|
||||
"application/x-executable" -> "ELF"
|
||||
"application/x-mach-binary" -> "Mach-O"
|
||||
"application/vnd.microsoft.portable-executable" -> "EXE"
|
||||
"application/vsix" -> "VSIX"
|
||||
"application/x-chrome-extension" -> "CRX"
|
||||
"application/x-xpinstall" -> "XPI"
|
||||
"application/wasm" -> "WASM"
|
||||
"application/webbundle" -> "Web Bundle"
|
||||
else -> mime
|
||||
}
|
||||
|
||||
internal fun formatBytes(bytes: Long): String {
|
||||
if (bytes < 1024L) return "$bytes B"
|
||||
val kb = bytes / 1024.0
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -88,7 +89,9 @@ fun VideoDisplay(
|
||||
val content: BaseMediaContent =
|
||||
remember(note) {
|
||||
val description = videoEvent.content.ifBlank { null } ?: event.alt()
|
||||
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
if (isImage) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -55,7 +56,9 @@ fun JustVideoDisplay(
|
||||
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
|
||||
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
|
||||
val reasons = remember(note) { collectContentWarningReasons(event) }
|
||||
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
|
||||
|
||||
val content by
|
||||
remember(note) {
|
||||
|
||||
+10
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
|
||||
@@ -1986,6 +1987,15 @@ class AccountViewModel(
|
||||
|
||||
fun bottomBarItemsFlow(): StateFlow<List<BottomBarEntry>> = account.settings.syncedSettings.navigation.bottomBarItems
|
||||
|
||||
fun hiddenDrawerItemsFlow(): StateFlow<Set<NavBarItem>> = account.settings.syncedSettings.navigation.hiddenDrawerItems
|
||||
|
||||
/** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */
|
||||
fun changeHiddenDrawerItems(items: Set<NavBarItem>) {
|
||||
if (account.applyHiddenDrawerItems(items)) {
|
||||
launchSigner { account.sendNewAppSpecificData() }
|
||||
}
|
||||
}
|
||||
|
||||
fun changeBottomBarItems(items: List<BottomBarEntry>) {
|
||||
// Apply to the reactive flow synchronously on the caller (UI) thread so rapid edits stay
|
||||
// ordered — launchSigner dispatches on a multi-threaded pool, so wrapping the emit too would
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class ChatroomNip04HistorySubAssembler(
|
||||
// so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+2
-2
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
|
||||
@@ -169,7 +169,7 @@ fun NewGroupDMScreen(
|
||||
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
@@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow(
|
||||
onSendNewMessage: () -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
if (channelScreenModel.message.text.isNotBlank()) {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ class ConcordChannelHistorySubAssembler(
|
||||
// cursors so a late callback can't move another channel's cursors. newEose runs regardless.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ class RelayGroupOpenChatHistorySubAssembler(
|
||||
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ class RelayGroupOpenThreadsHistorySubAssembler(
|
||||
// cursors so a late callback can't move another group's cursors. newEose runs regardless.
|
||||
val myCursors = cursorsFor(key)
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -78,7 +78,7 @@ fun EditFieldRow(
|
||||
onSendNewMessage: suspend () -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
channelScreenModel.sendDraftSync()
|
||||
channelScreenModel.cancel()
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ class ChatroomListNip04HistorySubAssembler(
|
||||
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
|
||||
val myCursors = key.account.chatroomList.nip04History
|
||||
return object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ class ChessFeedFilterSubAssembler(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+210
-9
@@ -20,11 +20,36 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.search.GitRepositorySearchMatcher
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
@@ -34,8 +59,17 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
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.note.ClearTextIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
@Composable
|
||||
fun GitRepositoriesScreen(
|
||||
@@ -59,10 +93,31 @@ fun GitRepositoriesScreen(
|
||||
WatchAccountForGitRepositoriesScreen(gitRepositoriesFeedContentState = gitRepositoriesFeedContentState, accountViewModel = accountViewModel)
|
||||
GitRepositoriesFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
// Search UI state is remembered across configuration changes so the
|
||||
// user doesn't lose their query when rotating; scoped to this screen,
|
||||
// not persisted to disk (unlike the follow-list filter above).
|
||||
var isSearchOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
GitRepositoriesTopBar(accountViewModel, nav)
|
||||
GitRepositoriesTopBar(
|
||||
isSearchOpen = isSearchOpen,
|
||||
onToggleSearch = {
|
||||
// Closing collapses the field AND clears the query so
|
||||
// the feed is fully restored — the icon acts as a
|
||||
// one-tap "reset" once the user has narrowed the view.
|
||||
if (isSearchOpen) {
|
||||
searchQuery = ""
|
||||
isSearchOpen = false
|
||||
} else {
|
||||
isSearchOpen = true
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.GitRepositories, nav, accountViewModel) { route ->
|
||||
@@ -75,20 +130,166 @@ fun GitRepositoriesScreen(
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableBox(gitRepositoriesFeedContentState, true) {
|
||||
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = gitRepositoriesFeedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (isSearchOpen) {
|
||||
GitRepositorySearchField(
|
||||
query = searchQuery,
|
||||
onQueryChange = { searchQuery = it },
|
||||
onClearQuery = { searchQuery = "" },
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
RefresheableBox(gitRepositoriesFeedContentState, true) {
|
||||
SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState ->
|
||||
val query = searchQuery
|
||||
if (query.isBlank()) {
|
||||
RenderFeedContentState(
|
||||
feedContentState = gitRepositoriesFeedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
)
|
||||
} else {
|
||||
// When the filter is active we can't reuse the shared
|
||||
// scroll state because the filtered list has a different
|
||||
// set of item keys — using the same LazyListState would
|
||||
// make Compose try to restore an index that no longer
|
||||
// exists and jump the user to an unrelated repo. We
|
||||
// scope a fresh, per-query LazyListState so scrolling
|
||||
// stays inside the filtered view.
|
||||
RenderFilteredFeed(
|
||||
feedContentState = gitRepositoriesFeedContentState,
|
||||
query = query,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline text field that drives the client-side ngit-repository search. Sits
|
||||
* directly under the top bar and above the feed so the user can see the
|
||||
* result of every keystroke narrow the list beneath it.
|
||||
*/
|
||||
@Composable
|
||||
private fun GitRepositorySearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClearQuery: () -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
// Focus on first appearance so the keyboard opens without a second
|
||||
// tap. Subsequent recompositions inside the same session don't re-
|
||||
// request focus, which would fight with the user pressing "back to
|
||||
// the feed" via the field's clear-text icon.
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.git_repositories_search_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = onClearQuery) {
|
||||
ClearTextIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the ngit repositories the user is already subscribed to, filtered
|
||||
* by [query]. Loading and error states are delegated to the shared
|
||||
* [RenderFeedContentState] via the appropriate branches; the loaded branch
|
||||
* is intercepted so we can filter the notes without touching the shared
|
||||
* feed model (which other screens also observe).
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RenderFilteredFeed(
|
||||
feedContentState: FeedContentState,
|
||||
query: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val filteredListState = rememberLazyListState()
|
||||
|
||||
RenderFeedContentState(
|
||||
feedContentState = feedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = filteredListState,
|
||||
nav = nav,
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
onLoaded = { loaded ->
|
||||
val loadedItems by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
val filtered =
|
||||
remember(loadedItems, query) {
|
||||
loadedItems.list.filter { note ->
|
||||
val event = note.event as? GitRepositoryEvent ?: return@filter false
|
||||
GitRepositorySearchMatcher.matches(event, query)
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.git_repositories_search_no_results),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = filteredListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
filtered,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, item -> item.event?.kind ?: -1 },
|
||||
) { _, item ->
|
||||
Row(Modifier.fillMaxWidth().animateItem()) {
|
||||
NoteCompose(
|
||||
item,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
routeForLastRead = "GitRepositoriesFeed",
|
||||
isBoostedNote = false,
|
||||
isHiddenFeed = loadedItems.showHidden,
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForGitRepositoriesScreen(
|
||||
gitRepositoriesFeedContentState: FeedContentState,
|
||||
|
||||
+81
-11
@@ -20,35 +20,105 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
/**
|
||||
* Top bar for the ngit repositories discovery screen.
|
||||
*
|
||||
* Two search affordances live side-by-side in the actions row:
|
||||
*
|
||||
* 1. A **repository filter** (magnifier-with-a-plus icon) that toggles
|
||||
* an inline text field over the loaded feed. This is the ngit-specific
|
||||
* search — it matches the fields NIP-34 announcements carry: name,
|
||||
* identifier, description, hashtags, clone/web/relay URLs, and
|
||||
* maintainer pubkeys. It filters what the user is already looking
|
||||
* at without touching relays.
|
||||
*
|
||||
* 2. The **generic Nostr search** (plain magnifier) that navigates to
|
||||
* the global [Route.Search] screen, matching the affordance on
|
||||
* every other top-level screen.
|
||||
*
|
||||
* Splitting them this way makes it obvious which magnifier does what: the
|
||||
* inline one narrows the current list, the outbound one opens the fleet-
|
||||
* wide search that also queries people, notes, hashtags, etc.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GitRepositoriesTopBar(
|
||||
isSearchOpen: Boolean,
|
||||
onToggleSearch: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
ShorterTopAppBar(
|
||||
title = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
GitRepositoriesTopNavFilterBar(
|
||||
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||
listName = list,
|
||||
accountViewModel = accountViewModel,
|
||||
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
|
||||
)
|
||||
}
|
||||
GitRepositoriesTopNavFilterBar(
|
||||
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||
listName = list,
|
||||
accountViewModel = accountViewModel,
|
||||
onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) },
|
||||
actions = {
|
||||
IconButton(onClick = onToggleSearch) {
|
||||
Icon(
|
||||
symbol =
|
||||
if (isSearchOpen) {
|
||||
MaterialSymbols.Close
|
||||
} else {
|
||||
MaterialSymbols.FilterAlt
|
||||
},
|
||||
contentDescription =
|
||||
stringRes(
|
||||
if (isSearchOpen) {
|
||||
R.string.git_repositories_search_close
|
||||
} else {
|
||||
R.string.git_repositories_search_open
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { nav.nav(Route.Search) }) {
|
||||
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -456,7 +456,7 @@ class EventSync(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+89
-260
@@ -22,11 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
@@ -50,7 +45,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -60,7 +54,6 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -97,7 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
@@ -116,11 +109,6 @@ private val ExpandableItems =
|
||||
/** Soft guidance, not a hard cap: a Material bottom bar reads best at ~5 tabs. */
|
||||
private const val RECOMMENDED_SLOTS = 5
|
||||
|
||||
// Reveal expandable sections by unrolling straight down from the top edge (the default AnimatedVisibility
|
||||
// enter also expands horizontally from the bottom-end, which reads as a diagonal slide from the top-left).
|
||||
private val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn()
|
||||
private val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
|
||||
fun BottomBarSettingsScreenPreview() {
|
||||
@@ -157,14 +145,21 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
|
||||
// All pin/unpin/reorder logic lives in the holder (unit-tested); the composable only renders and
|
||||
// forwards events. Each persist republishes the account's NIP-78 settings event. syncFrom re-seeds
|
||||
// when the saved list changes elsewhere without clobbering a drag.
|
||||
//
|
||||
// Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a
|
||||
// holder that outlived an account switch would write account A's edits to account B. It cannot:
|
||||
// SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`,
|
||||
// so a switch disposes this composable (and the NavController with it) and re-runs this remember
|
||||
// against the new account's ViewModel. Keying on accountViewModel here would be a no-op that
|
||||
// implies the subtree survives a switch — if that ever becomes true, this comment is the bug.
|
||||
val state = remember { BottomBarSettingsState(savedItems) { accountViewModel.changeBottomBarItems(it) } }
|
||||
LaunchedEffect(savedItems) { state.syncFrom(savedItems) }
|
||||
|
||||
val pinned = state.pinned
|
||||
val pinnedKeys = remember(pinned) { state.pinnedKeys() }
|
||||
|
||||
val expandedCategories = remember { mutableStateMapOf<Int, Boolean>() }
|
||||
val expandedItems = remember { mutableStateMapOf<NavBarItem, Boolean>() }
|
||||
val expandedCategories = rememberExpandedKeys<Int>()
|
||||
val expandedItems = rememberExpandedKeys<NavBarItem>()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
@@ -177,26 +172,19 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
|
||||
// --- The editable bar: a real preview you drag to reorder and tap ✕ to remove from. ---
|
||||
EditableBarCard(state, pinned, accountViewModel)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = { state.restoreDefault() }) {
|
||||
Text(stringRes(R.string.bottom_bar_settings_restore_default))
|
||||
}
|
||||
}
|
||||
RestoreDefaultRow(onClick = { state.restoreDefault() })
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// --- Available catalogue, grouped into collapsible category cards. ---
|
||||
SectionHeader(title = stringRes(R.string.bottom_bar_settings_available))
|
||||
PickerSectionHeader(title = stringRes(R.string.bottom_bar_settings_available))
|
||||
|
||||
BottomBarCategories.forEach { category ->
|
||||
CategoryCard(
|
||||
category = category,
|
||||
pinnedKeys = pinnedKeys,
|
||||
expanded = expandedCategories[category.titleRes] ?: false,
|
||||
onToggleExpand = { expandedCategories[category.titleRes] = !(expandedCategories[category.titleRes] ?: false) },
|
||||
expanded = expandedCategories.isExpanded(category.titleRes),
|
||||
onToggleExpand = { expandedCategories.toggle(category.titleRes) },
|
||||
expandedItems = expandedItems,
|
||||
accountViewModel = accountViewModel,
|
||||
onTogglePin = state::togglePin,
|
||||
@@ -217,60 +205,43 @@ private fun EditableBarCard(
|
||||
pinned: List<BottomBarEntry>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = accent.copy(alpha = 0.07f),
|
||||
border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.bottom_bar_settings_pinned),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "${pinned.size} / $RECOMMENDED_SLOTS",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (pinned.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringRes(R.string.bottom_bar_settings_pinned_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
EditableBar(state, pinned, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
PickerHeroCard(
|
||||
title = stringRes(R.string.bottom_bar_settings_pinned),
|
||||
trailing = {
|
||||
Text(
|
||||
text = stringRes(R.string.bottom_bar_settings_reorder_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
text = "${pinned.size} / $RECOMMENDED_SLOTS",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (pinned.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringRes(R.string.bottom_bar_settings_pinned_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
EditableBar(state, pinned, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.bottom_bar_settings_reorder_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,76 +437,37 @@ private fun CategoryCard(
|
||||
pinnedKeys: Set<String>,
|
||||
expanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
expandedItems: SnapshotStateMap<NavBarItem, Boolean>,
|
||||
expandedItems: ExpandedKeys<NavBarItem>,
|
||||
accountViewModel: AccountViewModel,
|
||||
onTogglePin: (BottomBarEntry) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp),
|
||||
CatalogCard(
|
||||
icon = category.icon,
|
||||
title = stringRes(category.titleRes),
|
||||
expanded = expanded,
|
||||
onToggleExpand = onToggleExpand,
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(34.dp)
|
||||
.clip(RoundedCornerShape(11.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
category.items.forEach { item ->
|
||||
val def = NavBarCatalog[item] ?: return@forEach
|
||||
val entry = BottomBarEntry.BuiltIn(item)
|
||||
if (item in ExpandableItems) {
|
||||
ExpandableAvailableRow(
|
||||
icon = def.icon,
|
||||
label = stringRes(def.labelRes),
|
||||
pinned = entry.stableKey in pinnedKeys,
|
||||
expanded = expandedItems.isExpanded(item),
|
||||
onTogglePin = { onTogglePin(entry) },
|
||||
onToggleExpand = { expandedItems.toggle(item) },
|
||||
) {
|
||||
Icon(
|
||||
symbol = categoryIcon(category.titleRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(category.titleRes),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
} else {
|
||||
AvailableRow(
|
||||
leading = { LeadingGlyph(def.icon) },
|
||||
label = stringRes(def.labelRes),
|
||||
pinned = entry.stableKey in pinnedKeys,
|
||||
onToggle = { onTogglePin(entry) },
|
||||
)
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) {
|
||||
Column(Modifier.padding(bottom = 6.dp)) {
|
||||
category.items.forEach { item ->
|
||||
val def = NavBarCatalog[item] ?: return@forEach
|
||||
val entry = BottomBarEntry.BuiltIn(item)
|
||||
if (item in ExpandableItems) {
|
||||
ExpandableAvailableRow(
|
||||
icon = def.icon,
|
||||
label = stringRes(def.labelRes),
|
||||
pinned = entry.stableKey in pinnedKeys,
|
||||
expanded = expandedItems[item] ?: false,
|
||||
onTogglePin = { onTogglePin(entry) },
|
||||
onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) },
|
||||
) {
|
||||
PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin)
|
||||
}
|
||||
} else {
|
||||
AvailableRow(
|
||||
leading = { LeadingGlyph(def.icon) },
|
||||
label = stringRes(def.labelRes),
|
||||
pinned = entry.stableKey in pinnedKeys,
|
||||
onToggle = { onTogglePin(entry) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -757,18 +689,6 @@ private fun ConcordServerPickerGroup(
|
||||
// Rows & shared bits
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable
|
||||
* category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a
|
||||
* NIP-29 group under its relay, or a Concord channel under its community).
|
||||
*/
|
||||
private fun indentPadding(level: Int) =
|
||||
when (level) {
|
||||
0 -> 13.dp
|
||||
1 -> 24.dp
|
||||
else -> 40.dp
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvailableRow(
|
||||
leading: @Composable () -> Unit,
|
||||
@@ -777,23 +697,12 @@ private fun AvailableRow(
|
||||
onToggle: () -> Unit,
|
||||
indentLevel: Int = 0,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(start = indentPadding(indentLevel), end = 13.dp, top = 7.dp, bottom = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
CatalogRow(
|
||||
leading = leading,
|
||||
label = label,
|
||||
onToggle = onToggle,
|
||||
indentLevel = indentLevel,
|
||||
) {
|
||||
leading()
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AddPill(added = pinned, onClick = onToggle)
|
||||
}
|
||||
}
|
||||
@@ -808,27 +717,15 @@ private fun ExpandableAvailableRow(
|
||||
onToggleExpand: () -> Unit,
|
||||
children: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggleExpand)
|
||||
.padding(start = 13.dp, end = 13.dp, top = 7.dp, bottom = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
CatalogRow(
|
||||
leading = { LeadingGlyph(icon) },
|
||||
label = label,
|
||||
onToggle = onToggleExpand,
|
||||
) {
|
||||
LeadingGlyph(icon)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
contentDescription = stringRes(R.string.bottom_bar_settings_expand),
|
||||
modifier = Modifier.size(22.dp),
|
||||
modifier = Size22Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
AddPill(added = pinned, onClick = onTogglePin)
|
||||
@@ -838,52 +735,18 @@ private fun ExpandableAvailableRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outlined "Add" that fills to "Added" once pinned — states the action and its result. Both states
|
||||
* share one Row body (only color/border/tint differ) so the pill keeps a constant height and the rows
|
||||
* stay aligned whether an item is added or not.
|
||||
*/
|
||||
/** Outlined "Add" that fills to "Added" once pinned — states the action and its result. */
|
||||
@Composable
|
||||
private fun AddPill(
|
||||
added: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val content = if (added) MaterialTheme.colorScheme.onPrimary else accent
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (added) accent else Color.Transparent,
|
||||
border = if (added) null else BorderStroke(1.dp, accent),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (added) MaterialSymbols.Check else MaterialSymbols.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = content,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A category/destination glyph in a soft accent-tinted circle. */
|
||||
@Composable
|
||||
private fun LeadingGlyph(icon: MaterialSymbol) {
|
||||
Box(
|
||||
modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(symbol = icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
TogglePill(
|
||||
on = added,
|
||||
label = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add),
|
||||
icon = if (added) MaterialSymbols.Check else MaterialSymbols.Add,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** A favorite web-app / nsite / napplet's real favicon in a tinted circle (glyph fallback). */
|
||||
@@ -902,40 +765,6 @@ private fun FavoriteLeading(app: FavoriteApp) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = 18.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyChildHint(
|
||||
textRes: Int,
|
||||
indentLevel: Int = 1,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(textRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = indentPadding(indentLevel), end = 13.dp, top = 6.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun categoryIcon(titleRes: Int): MaterialSymbol =
|
||||
when (titleRes) {
|
||||
R.string.bottom_bar_category_main -> MaterialSymbols.Home
|
||||
R.string.bottom_bar_category_chats -> MaterialSymbols.Group
|
||||
R.string.bottom_bar_category_you -> MaterialSymbols.AccountCircle
|
||||
R.string.bottom_bar_category_feeds -> MaterialSymbols.Subscriptions
|
||||
R.string.bottom_bar_category_apps -> MaterialSymbols.Apps
|
||||
else -> MaterialSymbols.Settings
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Leading/label resolution for a pinned entry (built-in glyph, favorite icon, or group avatar).
|
||||
// Computed once so a group's channel is subscribed at most once per row.
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* 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.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
|
||||
@Composable
|
||||
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
|
||||
fun DrawerSettingsScreenPreview() {
|
||||
ThemeComparisonRow {
|
||||
DrawerSettingsScreen(
|
||||
mockAccountViewModel(),
|
||||
EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerSettingsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(stringRes(id = R.string.drawer_settings), nav)
|
||||
},
|
||||
) { padding ->
|
||||
Column(Modifier.padding(padding)) {
|
||||
DrawerSettingsContent(accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide editor for the side menu's rows. It renders [DrawerSections] directly — the very list the
|
||||
* drawer renders — so a destination added to a section shows up here with no work, and a row that
|
||||
* exists here always exists there.
|
||||
*/
|
||||
@Composable
|
||||
fun DrawerSettingsContent(accountViewModel: AccountViewModel) {
|
||||
// Per-account, synced through the NIP-78 app-specific data event.
|
||||
val savedHidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
|
||||
|
||||
// All show/hide logic lives in the holder (unit-tested); the composable only renders and forwards
|
||||
// events. Each edit republishes the account's NIP-78 settings event. syncFrom re-seeds when the
|
||||
// saved set changes elsewhere.
|
||||
//
|
||||
// Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a
|
||||
// holder that outlived an account switch would write account A's edits to account B. It cannot:
|
||||
// SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`,
|
||||
// so a switch disposes this composable (and the NavController with it) and re-runs this remember
|
||||
// against the new account's ViewModel. Keying on accountViewModel here would be a no-op that
|
||||
// implies the subtree survives a switch — if that ever becomes true, this comment is the bug.
|
||||
val state = remember { DrawerSettingsState(savedHidden) { accountViewModel.changeHiddenDrawerItems(it) } }
|
||||
LaunchedEffect(savedHidden) { state.syncFrom(savedHidden) }
|
||||
|
||||
// Sections start collapsed: expanded, they are ~50 rows of scrolling. The header's hidden
|
||||
// counter is what tells the user which one to open.
|
||||
val expandedSections = rememberExpandedKeys<DrawerSectionId>()
|
||||
|
||||
val totalHidden = state.totalHidden()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
SummaryCard(totalHidden)
|
||||
|
||||
RestoreDefaultRow(onClick = { state.restoreDefault() })
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
PickerSectionHeader(title = stringRes(R.string.drawer_settings_sections))
|
||||
|
||||
// A section with no catalog rows has nothing to configure (Create is composer entry points),
|
||||
// so it isn't listed here even though the drawer renders it.
|
||||
DrawerSections.forEach { section ->
|
||||
if (section.items.isEmpty()) return@forEach
|
||||
SectionCard(
|
||||
section = section,
|
||||
state = state,
|
||||
expanded = expandedSections.isExpanded(section.id),
|
||||
onToggleExpand = { expandedSections.toggle(section.id) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** What the setting does and how far from stock the menu currently is. */
|
||||
@Composable
|
||||
private fun SummaryCard(totalHidden: Int) {
|
||||
PickerHeroCard(
|
||||
title = stringRes(R.string.drawer_settings_title),
|
||||
trailing = {
|
||||
Text(
|
||||
text = stringRes(R.string.drawer_settings_hidden_count, totalHidden),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.drawer_settings_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(
|
||||
section: DrawerSection,
|
||||
state: DrawerSettingsState,
|
||||
expanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
) {
|
||||
// Each card reads the same coarse `hidden` state, so without derivedStateOf a toggle in one
|
||||
// section would recompose (and re-count) all of them.
|
||||
val hiddenHere by remember(section) { derivedStateOf { state.hiddenCount(section) } }
|
||||
|
||||
CatalogCard(
|
||||
icon = section.icon,
|
||||
title = stringRes(section.titleRes),
|
||||
expanded = expanded,
|
||||
onToggleExpand = onToggleExpand,
|
||||
trailing = {
|
||||
if (hiddenHere > 0) {
|
||||
Text(
|
||||
text = stringRes(R.string.drawer_settings_hidden_count, hiddenHere),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Bulk actions: turning ~29 feed rows off one at a time is the kind of chore that makes
|
||||
// people give up halfway and leave the menu in a worse state than they found it.
|
||||
if (DrawerItemVisibility.hasHideableRows(section)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 6.dp, end = 6.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = { state.showAll(section) }) {
|
||||
Text(stringRes(R.string.drawer_settings_show_all))
|
||||
}
|
||||
TextButton(onClick = { state.hideAll(section) }) {
|
||||
Text(stringRes(R.string.drawer_settings_hide_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section.items.forEach { item ->
|
||||
val def = NavBarCatalog[item] ?: return@forEach
|
||||
val mandatory = item in MandatoryDrawerItems
|
||||
val visible = state.isVisible(item)
|
||||
CatalogRow(
|
||||
leading = { LeadingGlyph(def.icon) },
|
||||
label = stringRes(def.labelRes),
|
||||
onToggle = if (mandatory) null else ({ state.toggle(item) }),
|
||||
) {
|
||||
VisibilityPill(visible = visible, mandatory = mandatory, onClick = { state.toggle(item) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled "Visible" / outlined "Hidden" — the bottom bar's Add/Added pill, saying what this screen
|
||||
* says instead. A mandatory row gets a locked "Always on" badge: it reads as deliberately fixed
|
||||
* rather than as a control that ignores taps.
|
||||
*/
|
||||
@Composable
|
||||
private fun VisibilityPill(
|
||||
visible: Boolean,
|
||||
mandatory: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// One branch decides both halves of the pill, so a label can't drift away from its glyph.
|
||||
val (labelRes, icon) =
|
||||
when {
|
||||
mandatory -> R.string.drawer_settings_always_on to MaterialSymbols.Lock
|
||||
visible -> R.string.drawer_settings_visible to MaterialSymbols.Visibility
|
||||
else -> R.string.drawer_settings_hidden to MaterialSymbols.VisibilityOff
|
||||
}
|
||||
|
||||
TogglePill(
|
||||
on = visible,
|
||||
label = stringRes(labelRes),
|
||||
icon = icon,
|
||||
enabled = !mandatory,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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.settings
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection
|
||||
|
||||
/**
|
||||
* State holder for the Side Menu settings screen: owns the set of switched-off drawer rows and the
|
||||
* show / hide / restore-default operations, so the composable only renders and forwards events.
|
||||
*
|
||||
* The rules themselves live in [DrawerItemVisibility] (pure, unit-tested); this adds only the Compose
|
||||
* state and the write-through to the account's synced settings. Unlike the bottom bar there is no
|
||||
* transient/commit split — a toggle is a single discrete edit, not a drag, so every change persists
|
||||
* immediately.
|
||||
*
|
||||
* No sanitizing here: every value in is either already sanitized by the persistence layer or produced
|
||||
* by a [DrawerItemVisibility] operation that can't introduce a mandatory row, and the write side
|
||||
* sanitizes again anyway. One authority, not three.
|
||||
*/
|
||||
@Stable
|
||||
class DrawerSettingsState(
|
||||
initial: Set<NavBarItem>,
|
||||
private val persist: (Set<NavBarItem>) -> Unit,
|
||||
) {
|
||||
var hidden by mutableStateOf(initial)
|
||||
private set
|
||||
|
||||
fun isVisible(item: NavBarItem): Boolean = DrawerItemVisibility.isVisible(hidden, item)
|
||||
|
||||
fun toggle(item: NavBarItem) = update(DrawerItemVisibility.toggle(hidden, item))
|
||||
|
||||
fun hiddenCount(section: DrawerSection): Int = DrawerItemVisibility.hiddenCount(section, hidden)
|
||||
|
||||
fun totalHidden(): Int = DrawerItemVisibility.totalHidden(hidden)
|
||||
|
||||
fun showAll(section: DrawerSection) = update(DrawerItemVisibility.showAll(hidden, section))
|
||||
|
||||
fun hideAll(section: DrawerSection) = update(DrawerItemVisibility.hideAll(hidden, section))
|
||||
|
||||
/** Back to the stock drawer: nothing hidden. */
|
||||
fun restoreDefault() = update(emptySet())
|
||||
|
||||
/**
|
||||
* Re-seed from an external change (the saved settings flow emitted) without re-persisting. A no-op
|
||||
* when equal, so the echo of our own [persist] doesn't fight an in-progress edit.
|
||||
*/
|
||||
fun syncFrom(items: Set<NavBarItem>) {
|
||||
if (items != hidden) hidden = items
|
||||
}
|
||||
|
||||
private fun update(newHidden: Set<NavBarItem>) {
|
||||
if (newHidden == hidden) return
|
||||
hidden = newHidden
|
||||
persist(newHidden)
|
||||
}
|
||||
}
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* 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.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size12dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size13dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size14dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size6dp
|
||||
|
||||
/**
|
||||
* The shared visual language of the navigation-configuration screens — the Bottom Navigation Bar
|
||||
* picker and the Side Menu picker. Both present the same shape (collapsible cards of catalog rows,
|
||||
* each row a glyph + label + a pill stating its current state), so the pieces live here once and
|
||||
* each screen supplies only its own semantics: the bottom bar pins and reorders entries, the side
|
||||
* menu switches rows on and off.
|
||||
*
|
||||
* [SectionExpand]/[SectionCollapse] reveal expandable sections by unrolling straight down from the
|
||||
* top edge (the default AnimatedVisibility enter also expands horizontally from the bottom-end,
|
||||
* which reads as a diagonal slide from the top-left).
|
||||
*/
|
||||
val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn()
|
||||
val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
|
||||
|
||||
/**
|
||||
* Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable
|
||||
* category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a
|
||||
* NIP-29 group under its relay, or a Concord channel under its community).
|
||||
*/
|
||||
private fun indentPadding(level: Int) =
|
||||
when (level) {
|
||||
0 -> Size13dp
|
||||
1 -> Size24dp
|
||||
else -> Size40dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Which collapsible rows of a picker are currently open, keyed by whatever identifies a row (a
|
||||
* section id, a string-resource id, a catalog item). Absent means collapsed, so the initial state
|
||||
* costs nothing and no list has to be seeded.
|
||||
*/
|
||||
@Stable
|
||||
class ExpandedKeys<K> {
|
||||
private val open = mutableStateMapOf<K, Boolean>()
|
||||
|
||||
fun isExpanded(key: K): Boolean = open[key] == true
|
||||
|
||||
fun toggle(key: K) {
|
||||
open[key] = !isExpanded(key)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <K> rememberExpandedKeys(): ExpandedKeys<K> = remember { ExpandedKeys() }
|
||||
|
||||
@Composable
|
||||
fun PickerSectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = Size18dp, bottom = Size6dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A category/destination glyph in a soft accent-tinted circle. */
|
||||
@Composable
|
||||
fun LeadingGlyph(icon: MaterialSymbol) {
|
||||
Box(
|
||||
modifier = SimpleImage35Modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(symbol = icon, contentDescription = null, modifier = Size19Modifier, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyChildHint(
|
||||
textRes: Int,
|
||||
indentLevel: Int = 1,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(textRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = indentPadding(indentLevel), end = Size13dp, top = Size6dp, bottom = Size6dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One catalog row: leading visual, label, and a caller-supplied [trailing] state control. Tapping
|
||||
* anywhere on the row runs [onToggle]; pass null for a row whose state can't change (a mandatory
|
||||
* side-menu item), which also drops the ripple so the row doesn't advertise an action it won't take.
|
||||
*/
|
||||
@Composable
|
||||
fun CatalogRow(
|
||||
leading: @Composable () -> Unit,
|
||||
label: String,
|
||||
onToggle: (() -> Unit)?,
|
||||
indentLevel: Int = 0,
|
||||
trailing: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (onToggle != null) it.clickable(onClick = onToggle) else it }
|
||||
.padding(start = indentPadding(indentLevel), end = Size13dp, top = 7.dp, bottom = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size12dp),
|
||||
) {
|
||||
leading()
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state pill at the end of a catalog row: outlined in the "off" state, filled in the "on" state —
|
||||
* so it states both the current state and, by contrast, that it can be changed. Both states share one
|
||||
* Row body (only color/border/tint differ) so the pill keeps a constant height and rows stay aligned.
|
||||
*
|
||||
* [enabled] false renders the pill as a locked, non-interactive badge — used for a row the user isn't
|
||||
* allowed to switch off.
|
||||
*/
|
||||
@Composable
|
||||
fun TogglePill(
|
||||
on: Boolean,
|
||||
label: String,
|
||||
icon: MaterialSymbol,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val container = if (enabled) accent else MaterialTheme.colorScheme.surfaceVariant
|
||||
val content =
|
||||
when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
on -> MaterialTheme.colorScheme.onPrimary
|
||||
else -> accent
|
||||
}
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (on) container else Color.Transparent,
|
||||
border = if (on) null else BorderStroke(1.dp, content),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.let { if (enabled) it.clickable(onClick = onClick) else it }
|
||||
.padding(horizontal = Size14dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = icon,
|
||||
contentDescription = null,
|
||||
modifier = Size15Modifier,
|
||||
tint = content,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsible card holding catalog rows. [trailing] renders between the title and the chevron —
|
||||
* the side menu puts its "n hidden" counter there; the bottom bar leaves it empty.
|
||||
*/
|
||||
@Composable
|
||||
fun CatalogCard(
|
||||
icon: MaterialSymbol,
|
||||
title: String,
|
||||
expanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
trailing: @Composable () -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(Size13dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size12dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Size34dp)
|
||||
.clip(RoundedCornerShape(11.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = icon,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing()
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Size24Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) {
|
||||
Column(Modifier.padding(bottom = Size6dp)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The accent-tinted card each picker opens with: a bold title, an optional [trailing] status, and a
|
||||
* body. The bottom bar puts its editable preview bar in the body; the side menu puts its description.
|
||||
*/
|
||||
@Composable
|
||||
fun PickerHeroCard(
|
||||
title: String,
|
||||
trailing: @Composable () -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
Surface(
|
||||
shape = RoundedCornerShape(Size22dp),
|
||||
color = accent.copy(alpha = 0.07f),
|
||||
border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp),
|
||||
) {
|
||||
Column(Modifier.padding(Size14dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = Size10dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
trailing()
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The end-aligned "Restore Default" action both pickers put under their hero card. */
|
||||
@Composable
|
||||
fun RestoreDefaultRow(onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onClick) {
|
||||
Text(stringRes(R.string.bottom_bar_settings_restore_default))
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -72,6 +72,7 @@ fun buildSettingsCatalog(
|
||||
symEntry(R.string.reactions_settings, MaterialSymbols.ThumbUp, R.string.reactions_settings_search_keywords, Route.ReactionsSettings),
|
||||
symEntry(R.string.messages_settings, MaterialSymbols.Mail, R.string.messages_settings_search_keywords, Route.MessagesSettings),
|
||||
symEntry(R.string.bottom_bar_settings, MaterialSymbols.Dashboard, R.string.bottom_bar_search_keywords, Route.BottomBarSettings),
|
||||
symEntry(R.string.drawer_settings, MaterialSymbols.AutoMirrored.ViewList, R.string.drawer_search_keywords, Route.DrawerSettings),
|
||||
symEntry(R.string.video_player_settings, MaterialSymbols.VideoSettings, R.string.video_player_search_keywords, Route.VideoPlayerSettings),
|
||||
symEntry(R.string.audio_visualizer_settings, MaterialSymbols.MusicNote, R.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings),
|
||||
symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds),
|
||||
|
||||
+4
-1
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -104,7 +105,9 @@ private fun VideoCardImage(
|
||||
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
|
||||
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
|
||||
val reasons = remember(note) { collectContentWarningReasons(event) }
|
||||
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
|
||||
|
||||
val content by
|
||||
remember(note) {
|
||||
|
||||
+22
-37
@@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -38,10 +37,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop
|
||||
@@ -51,9 +47,10 @@ import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
|
||||
import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderAttachmentCard
|
||||
import com.vitorpamplona.amethyst.ui.note.types.toMediaContent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
|
||||
@@ -101,45 +98,33 @@ private fun FileHeaderCardImage(
|
||||
|
||||
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
|
||||
val reasons = remember(note) { collectContentWarningReasons(event) }
|
||||
val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) }
|
||||
val mimeType = remember(note) { event.mimeType() }
|
||||
val blurHash = remember(note) { event.blurhash() }
|
||||
val thumbHash = remember(note) { event.thumbhash() }
|
||||
val dimensions = remember(note) { event.dimensions() }
|
||||
|
||||
val content by remember(note) {
|
||||
val hash = event.hash()
|
||||
val description = event.content.ifEmpty { null } ?: event.alt()
|
||||
val uri = note.toNostrUri()
|
||||
val mimeType = event.mimeType()
|
||||
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
|
||||
|
||||
mutableStateOf<BaseMediaContent>(
|
||||
if (isImage) {
|
||||
MediaUrlImage(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
},
|
||||
)
|
||||
// Reachable despite VideoFeedFilter admitting only image/video types: the filter accepts on
|
||||
// `urls().any { … }` while this card renders `url()`, the first tag — so a multi-mirror event
|
||||
// whose first URL is unrenderable lands here. The gate wraps it for the same reason it wraps
|
||||
// the viewer in FileHeaderDisplay: a content warning is about the file, and the card still
|
||||
// spells out its filename, alt text, MIME and size. Sizing stays on the gate's defaults
|
||||
// (fillMaxWidth, no backdrop) — a link card has no aspect ratio to reserve and no blurhash
|
||||
// to show behind it.
|
||||
if (content == null) {
|
||||
ContentWarningGate(
|
||||
isSensitive = isSensitive,
|
||||
reasons = reasons,
|
||||
preloadUrls = emptyList(),
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
FileHeaderAttachmentCard(event, fullUrl, mimeType)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val isImage = content is MediaUrlImage
|
||||
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl)
|
||||
|
||||
ContentWarningGate(
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
<string name="connection_success_rate_description">Procento úspěšných připojení k relé</string>
|
||||
<string name="search_and_add_a_user">Vyhledat a přidat uživatele</string>
|
||||
<string name="add_a_relay">Přidat přeposílání</string>
|
||||
<string name="relay_url_not_valid">Neplatná adresa relaye. Použijte název hostitele nebo IP adresu v hranatých závorkách (například [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Moje @tag jméno</string>
|
||||
<string name="display_name">Zobrazované jméno</string>
|
||||
<string name="my_display_name">Moje zobrazované jméno</string>
|
||||
@@ -2024,14 +2025,105 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d relay</item>
|
||||
<item quantity="few">%1$s \u00b7 %2$d relaye</item>
|
||||
<item quantity="many">%1$s \u00b7 %2$d relaye</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d relayů</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Procházení</string>
|
||||
<string name="relay_purpose_media">Média</string>
|
||||
<string name="relay_purpose_tags">Hashtagy</string>
|
||||
<string name="relay_purpose_topics">Témata</string>
|
||||
<string name="relay_purpose_thread">Konverzace</string>
|
||||
<string name="relay_purpose_search">Hledání</string>
|
||||
<string name="relay_purpose_referenced">Hledání chybějících událostí</string>
|
||||
<string name="relay_purpose_engagement">Sledování událostí</string>
|
||||
<string name="relay_explain_referenced">Načítá podle ID události, na které se něco na obrazovce odkazuje, ale zatím je nemáte — citaci, rodiče odpovědi, kořen vlákna.</string>
|
||||
<string name="relay_explain_engagement">Sleduje právě zobrazené události kvůli novým odpovědím, reakcím, sdílením, zapům a nahlášením, takže se počty aktualizují během čtení.</string>
|
||||
<string name="relay_purpose_add_ons">Doplňky</string>
|
||||
<string name="relay_purpose_relay_info">Informace o relayi</string>
|
||||
<string name="relay_purpose_other">Ostatní</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Vyhledávač seznamů relayů</string>
|
||||
<string name="relay_purpose_observing_profiles">Sledování profilů</string>
|
||||
<string name="relay_purpose_your_account">Data účtu</string>
|
||||
<string name="relay_purpose_home_feed">Domovský zdroj</string>
|
||||
<string name="relay_purpose_relay_groups">Relay skupiny</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d skupina</item>
|
||||
<item quantity="few">%1$d skupiny</item>
|
||||
<item quantity="many">%1$d skupiny</item>
|
||||
<item quantity="other">%1$d skupin</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Mizící chaty</string>
|
||||
<string name="relay_purpose_geohash_chats">Chaty podle místa</string>
|
||||
<string name="relay_purpose_live_chat">Chat živého vysílání</string>
|
||||
<string name="relay_explain_relay_groups">Skupiny NIP-29, do kterých jste vstoupili. Každá skupina žije na jednom hostitelském relayi, takže se aplikace připojí ke každému relayi, který hostí některou vaši skupinu.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Chatovací místnosti bez historie — zprávy existují jen po dobu vašeho připojení, proto zůstávají odebírané, aby vůbec něco přišlo.</string>
|
||||
<string name="relay_explain_geohash_chats">Místnosti podle polohy pro oblasti, které sledujete, dotazované na relayích, které je nesou.</string>
|
||||
<string name="relay_explain_live_chat">Chat a zapovací cíle připojené k živým vysíláním, která máte otevřená nebo sledujete.</string>
|
||||
<string name="relay_purpose_dm_inbox">Schránka DM</string>
|
||||
<string name="relay_purpose_your_wallet">Peněženka</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Schránka nutzapů</string>
|
||||
<string name="relay_purpose_mint_directory">Adresář mintů</string>
|
||||
<string name="relay_purpose_nwc">Wallet Connect</string>
|
||||
<string name="relay_purpose_community_chats">Chaty komunit</string>
|
||||
<string name="relay_purpose_community_feeds">Zdroje komunit</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Vaše relaye pro příjem a k tomu malý rotující vzorek relayů, kam publikují vaši sledovaní, pro případ, že by zmínka byla doručena jinam.</string>
|
||||
<string name="relay_explain_direct_messages">Vaše relaye pro schránku DM, kam se doručují zprávy zabalené v gift-wrapu.</string>
|
||||
<string name="relay_explain_public_chats">Domovský relay každého chatu, který máte otevřený nebo do kterého jste se připojili.</string>
|
||||
<string name="relay_explain_community_chats">Relaye, na které každá komunita publikuje své roviny.</string>
|
||||
<string name="relay_explain_encrypted_groups">Skupinové zprávy a balíčky klíčů na relayích každé skupiny.</string>
|
||||
<string name="relay_explain_live_rooms">Relaye místnosti, dokud je otevřená.</string>
|
||||
<string name="relay_explain_account_data">Váš vlastní profil, nastavení a koncepty na vašich domovských relayích.</string>
|
||||
<string name="relay_explain_profiles">Profily lidí právě na obrazovce.</string>
|
||||
<string name="relay_explain_relay_lists">Zjišťuje, na které relaye každý člověk publikuje, aby se jeho příspěvky daly načíst na správném místě.</string>
|
||||
<string name="relay_explain_follows">Seznamy sledovaných, ze kterých se sestavuje váš zdroj a vaše síť důvěry.</string>
|
||||
<string name="relay_explain_moderation">Nahlášení, která vaši sledovaní napsali o profilech právě na obrazovce, dotazovaná na každém relayi, kam tito sledovaní publikují.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Nahlášení od sledovaných</string>
|
||||
<string name="relay_explain_wallet">Události vaší vlastní peněženky, čtené zpět z relayů, na které jste je publikovali.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Naslouchá na vašich nutzap relayích a také na relayích pro příjem a DM, aby vám neunikla žádná platba.</string>
|
||||
<string name="relay_explain_mint_directory">Prohledává relaye, které minty existují a které lidé doporučují.</string>
|
||||
<string name="relay_explain_nwc">Upozornění z vaší připojené peněženky.</string>
|
||||
<string name="active_subs_title">Aktivní odběry relayů</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d filtr</item>
|
||||
<item quantity="few">%1$d filtry</item>
|
||||
<item quantity="many">%1$d filtru</item>
|
||||
<item quantity="other">%1$d filtrů</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d relay</item>
|
||||
<item quantity="few">%1$d relaye</item>
|
||||
<item quantity="many">%1$d relaye</item>
|
||||
<item quantity="other">%1$d relayů</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filtr zatím není přiřazen</item>
|
||||
<item quantity="few">%1$d filtry zatím nejsou přiřazeny</item>
|
||||
<item quantity="many">%1$d filtru zatím není přiřazeno</item>
|
||||
<item quantity="other">%1$d filtrů zatím není přiřazeno</item>
|
||||
</plurals>
|
||||
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
|
||||
<string name="active_subs_unattributed">Nepřiřazeno k žádnému účtu</string>
|
||||
<string name="active_subs_no_entity">Vše</string>
|
||||
<string name="active_subs_scope_global">Všichni</string>
|
||||
<string name="active_subs_scope_follows">Lidé, které sledujete</string>
|
||||
<string name="active_subs_scope_authors">Vybraný seznam lidí</string>
|
||||
<string name="active_subs_scope_muted">Ztlumení lidé</string>
|
||||
<string name="active_subs_scope_all_communities">Vaše komunity</string>
|
||||
<string name="active_subs_scope_algo">Oblíbený algoritmický zdroj</string>
|
||||
<string name="active_subs_share">%1$d %% ze všech</string>
|
||||
<string name="active_subs_search_keywords">odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika</string>
|
||||
<string name="relay_explain_home">Příspěvky lidí, které sledujete, čtené z relayů, na které každý z nich publikuje.</string>
|
||||
<string name="always_on_notif_connecting">Připojování k inbox relayím\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Služba trvalých oznámení</string>
|
||||
<string name="always_on_notif_setting_description">Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu.</string>
|
||||
@@ -4726,6 +4818,7 @@
|
||||
<string name="buzz_workflow_gate_needs_you">Potřebuje vaše schválení</string>
|
||||
<string name="buzz_workflow_gate_awaiting">Čeká na schválení</string>
|
||||
<string name="buzz_workflow_no_description">(bez popisu)</string>
|
||||
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
|
||||
<string name="buzz_workflow_by">od</string>
|
||||
<string name="buzz_workflow_waiting_on">čeká na</string>
|
||||
<string name="buzz_workflow_readonly_approver">Jste schvalovatel, ale toto přihlášení nemůže rozhodnutí podepsat.</string>
|
||||
@@ -4761,6 +4854,7 @@
|
||||
<string name="buzz_workflow_no_defs_hint">Zatím žádná workflow. Otevřete nabídku výše, zvolte „Nová definice…“, vytvořte ho a pak spusťte.</string>
|
||||
<string name="buzz_workflow_task_label">Co má udělat?</string>
|
||||
<string name="buzz_workflow_trigger_run">Spustit běh</string>
|
||||
<string name="buzz_workflow_picker_label">Workflow</string>
|
||||
<string name="buzz_workflow_picker_empty">Zatím nejsou definována žádná workflow</string>
|
||||
<string name="buzz_workflow_picker_choose">Vyberte workflow</string>
|
||||
<string name="buzz_workflow_new_definition">Nová definice…</string>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">Prozentsatz erfolgreicher Verbindungen zum Relay</string>
|
||||
<string name="search_and_add_a_user">Benutzer suchen und hinzufügen</string>
|
||||
<string name="add_a_relay">Relay hinzufügen</string>
|
||||
<string name="relay_url_not_valid">Keine gültige Relay-Adresse. Verwende einen Hostnamen oder eine IP-Adresse in Klammern (zum Beispiel [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Mein @tag-Name</string>
|
||||
<string name="display_name">Anzeigename</string>
|
||||
<string name="my_display_name">Mein Anzeigename</string>
|
||||
@@ -1942,14 +1943,91 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d Relay</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d Relays</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Stöbern</string>
|
||||
<string name="relay_purpose_media">Medien</string>
|
||||
<string name="relay_purpose_topics">Themen</string>
|
||||
<string name="relay_purpose_thread">Unterhaltung</string>
|
||||
<string name="relay_purpose_search">Suche</string>
|
||||
<string name="relay_purpose_referenced">Fehlende Events finden</string>
|
||||
<string name="relay_purpose_engagement">Events beobachten</string>
|
||||
<string name="relay_explain_referenced">Holt Events per ID, auf die etwas auf deinem Bildschirm verweist, die du aber noch nicht hast — ein Zitat, die übergeordnete Antwort, eine Thread-Wurzel.</string>
|
||||
<string name="relay_explain_engagement">Beobachtet die gerade angezeigten Events auf neue Antworten, Reaktionen, Reposts, Zaps und Meldungen, damit die Zähler beim Lesen aktuell bleiben.</string>
|
||||
<string name="relay_purpose_add_ons">Erweiterungen</string>
|
||||
<string name="relay_purpose_relay_info">Relay-Info</string>
|
||||
<string name="relay_purpose_other">Sonstiges</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Relay-Listen-Finder</string>
|
||||
<string name="relay_purpose_observing_profiles">Profile beobachten</string>
|
||||
<string name="relay_purpose_your_account">Kontodaten</string>
|
||||
<string name="relay_purpose_home_feed">Startseiten-Feed</string>
|
||||
<string name="relay_purpose_relay_groups">Relay-Gruppen</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d Gruppe</item>
|
||||
<item quantity="other">%1$d Gruppen</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Verschwindende Chats</string>
|
||||
<string name="relay_purpose_geohash_chats">Standort-Chats</string>
|
||||
<string name="relay_purpose_live_chat">Live-Stream-Chat</string>
|
||||
<string name="relay_explain_relay_groups">NIP-29-Gruppen, denen du beigetreten bist. Jede Gruppe liegt auf einem Host-Relay, daher verbindet sich die App mit jedem Relay, das eine deiner Gruppen beherbergt.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Chaträume ohne Verlauf — Nachrichten existieren nur, solange du verbunden bist, deshalb bleiben sie abonniert, damit überhaupt etwas ankommt.</string>
|
||||
<string name="relay_explain_geohash_chats">Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen.</string>
|
||||
<string name="relay_explain_live_chat">Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst.</string>
|
||||
<string name="relay_purpose_dm_inbox">DM-Posteingang</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Nutzap-Posteingang</string>
|
||||
<string name="relay_purpose_mint_directory">Mint-Verzeichnis</string>
|
||||
<string name="relay_purpose_community_chats">Community-Chats</string>
|
||||
<string name="relay_purpose_community_feeds">Community-Feeds</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Deine Posteingangs-Relays plus eine kleine, rotierende Stichprobe der Relays, auf denen deine Gefolgten veröffentlichen, falls eine Erwähnung woanders zugestellt wurde.</string>
|
||||
<string name="relay_explain_direct_messages">Deine DM-Posteingangs-Relays, an die Gift-Wrap-Nachrichten zugestellt werden.</string>
|
||||
<string name="relay_explain_public_chats">Das Heim-Relay jedes Chats, den du geöffnet hast oder dem du beigetreten bist.</string>
|
||||
<string name="relay_explain_community_chats">Die Relays, auf denen jede Community ihre Planes veröffentlicht.</string>
|
||||
<string name="relay_explain_encrypted_groups">Gruppennachrichten und Schlüsselpakete auf den Relays der jeweiligen Gruppe.</string>
|
||||
<string name="relay_explain_live_rooms">Die Relays des Raums, solange er geöffnet ist.</string>
|
||||
<string name="relay_explain_account_data">Dein eigenes Profil, deine Einstellungen und Entwürfe auf deinen Heim-Relays.</string>
|
||||
<string name="relay_explain_profiles">Profile der gerade angezeigten Personen.</string>
|
||||
<string name="relay_explain_relay_lists">Findet heraus, auf welchen Relays jede Person veröffentlicht, damit ihre Beiträge an der richtigen Stelle abgerufen werden können.</string>
|
||||
<string name="relay_explain_follows">Folgelisten, aus denen dein Feed und dein Web of Trust aufgebaut werden.</string>
|
||||
<string name="relay_explain_moderation">Meldungen, die deine Gefolgten über die gerade angezeigten Profile geschrieben haben, abgefragt bei jedem Relay, auf dem diese Gefolgten veröffentlichen.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Meldungen von Gefolgten</string>
|
||||
<string name="relay_explain_wallet">Deine eigenen Wallet-Events, zurückgelesen von den Relays, auf denen du sie veröffentlicht hast.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Lauscht auf deinen Nutzap-Relays sowie deinen Posteingangs- und DM-Relays, damit keine Zahlung durchrutscht.</string>
|
||||
<string name="relay_explain_mint_directory">Sucht über Relays hinweg, welche Mints existieren und welche empfohlen werden.</string>
|
||||
<string name="relay_explain_nwc">Benachrichtigungen von deiner verbundenen Wallet.</string>
|
||||
<string name="active_subs_title">Aktive Relay-Abonnements</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d Filter</item>
|
||||
<item quantity="other">%1$d Filter</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d Relay</item>
|
||||
<item quantity="other">%1$d Relays</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d Filter ist noch nicht zugeordnet</item>
|
||||
<item quantity="other">%1$d Filter sind noch nicht zugeordnet</item>
|
||||
</plurals>
|
||||
<string name="active_subs_unattributed">Keinem Konto zugeordnet</string>
|
||||
<string name="active_subs_no_entity">Alle</string>
|
||||
<string name="active_subs_scope_global">Jeder</string>
|
||||
<string name="active_subs_scope_follows">Personen, denen du folgst</string>
|
||||
<string name="active_subs_scope_authors">Eine ausgewählte Liste von Personen</string>
|
||||
<string name="active_subs_scope_muted">Stummgeschaltete Personen</string>
|
||||
<string name="active_subs_scope_all_communities">Deine Communitys</string>
|
||||
<string name="active_subs_scope_algo">Ein bevorzugter Feed-Algorithmus</string>
|
||||
<string name="active_subs_share">%1$d %% von allen</string>
|
||||
<string name="active_subs_search_keywords">abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose</string>
|
||||
<string name="relay_explain_home">Beiträge von Personen, denen du folgst, gelesen von den Relays, auf denen jede von ihnen veröffentlicht.</string>
|
||||
<string name="always_on_notif_connecting">Verbinde mit Inbox-Relays\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Dauerhafter Benachrichtigungsdienst</string>
|
||||
<string name="always_on_notif_setting_description">Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst.</string>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">सफल संयोजनों का प्रतिशत पुनःप्रसारक के साथ</string>
|
||||
<string name="search_and_add_a_user">ढूँढें तथा प्रयेक्ता जोडें</string>
|
||||
<string name="add_a_relay">पुनःप्रसारक जोडें</string>
|
||||
<string name="relay_url_not_valid">मान्य पुनःप्रसारक पता नहीं। एक जालावास नाम का उपयोग करें। अथवा कोष्ठकों में एक अंकीय जालपता उदाहरण [201:d0e:9ba5:8bbc::1]:8080 के जैसे।</string>
|
||||
<string name="my_name">मेरा @सूचक नाम</string>
|
||||
<string name="display_name">प्रदर्शन नाम</string>
|
||||
<string name="my_display_name">मेरा प्रदर्शन नाम</string>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">Percentage succesvolle verbindingen met deze relay</string>
|
||||
<string name="search_and_add_a_user">Zoek en voeg gebruiker toe</string>
|
||||
<string name="add_a_relay">Relay toevoegen</string>
|
||||
<string name="relay_url_not_valid">Geen geldig relay-adres. Gebruik een hostnaam, of een IP-adres tussen blokhaken (bijvoorbeeld [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Mijn @naam</string>
|
||||
<string name="display_name">Weergavenaam</string>
|
||||
<string name="my_display_name">Mijn weergavenaam</string>
|
||||
@@ -1929,14 +1930,95 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d relay</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d relays</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Bladeren</string>
|
||||
<string name="relay_purpose_media">Media</string>
|
||||
<string name="relay_purpose_tags">Hashtags</string>
|
||||
<string name="relay_purpose_topics">Onderwerpen</string>
|
||||
<string name="relay_purpose_thread">Gesprek</string>
|
||||
<string name="relay_purpose_search">Zoeken</string>
|
||||
<string name="relay_purpose_referenced">Ontbrekende events zoeken</string>
|
||||
<string name="relay_purpose_engagement">Events observeren</string>
|
||||
<string name="relay_explain_referenced">Haalt events op via hun id waar iets op je scherm naar verwijst maar die je nog niet hebt — een quote, de note waarop een reactie antwoordt, de start van een discussie.</string>
|
||||
<string name="relay_explain_engagement">Houdt de events die nu in beeld staan in de gaten voor nieuwe antwoorden, reacties, reposts, zaps en rapportages, zodat de tellers bijwerken terwijl je leest.</string>
|
||||
<string name="relay_purpose_add_ons">Add-ons</string>
|
||||
<string name="relay_purpose_relay_info">Relay-info</string>
|
||||
<string name="relay_purpose_other">Overig</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Relaylijsten zoeken</string>
|
||||
<string name="relay_purpose_observing_profiles">Profielen observeren</string>
|
||||
<string name="relay_purpose_your_account">Accountgegevens</string>
|
||||
<string name="relay_purpose_home_feed">Startfeed</string>
|
||||
<string name="relay_purpose_relay_groups">Relay-groepen</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d groep</item>
|
||||
<item quantity="other">%1$d groepen</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Vluchtige chats</string>
|
||||
<string name="relay_purpose_geohash_chats">Locatiechats</string>
|
||||
<string name="relay_purpose_live_chat">Livestream-chat</string>
|
||||
<string name="relay_explain_relay_groups">NIP-29-groepen waar je lid van bent. Elke groep staat op één host-relay, dus de app verbindt met elke relay die een groep van jou host.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Chatruimtes die geen geschiedenis bewaren — berichten bestaan alleen zolang je verbonden bent, dus deze blijven geabonneerd om überhaupt iets te ontvangen.</string>
|
||||
<string name="relay_explain_geohash_chats">Locatiegebonden ruimtes voor de gebieden die je volgt, opgevraagd bij de relays die ze aanbieden.</string>
|
||||
<string name="relay_explain_live_chat">Chat en zap-doelen die horen bij livestreams die je open hebt staan of volgt.</string>
|
||||
<string name="relay_purpose_dm_inbox">DM-inbox</string>
|
||||
<string name="relay_purpose_your_wallet">Wallet</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Nutzap-inbox</string>
|
||||
<string name="relay_purpose_mint_directory">Mintoverzicht</string>
|
||||
<string name="relay_purpose_nwc">Wallet Connect</string>
|
||||
<string name="relay_purpose_community_chats">Community-chats</string>
|
||||
<string name="relay_purpose_community_feeds">Community-feeds</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Je inbox-relays, plus een kleine wisselende steekproef van de relays waarop de mensen die je volgt plaatsen, voor het geval een vermelding ergens anders is afgeleverd.</string>
|
||||
<string name="relay_explain_direct_messages">Je DM-inbox-relays, waar gift-wrapped berichten worden afgeleverd.</string>
|
||||
<string name="relay_explain_public_chats">De thuisrelay van elke chat die je open hebt staan of waar je lid van bent.</string>
|
||||
<string name="relay_explain_community_chats">De relays waarop elke community zijn planes publiceert.</string>
|
||||
<string name="relay_explain_encrypted_groups">Groepsberichten en sleutelpakketten, op de relays van elke groep.</string>
|
||||
<string name="relay_explain_live_rooms">De relays van de ruimte, zolang die open is.</string>
|
||||
<string name="relay_explain_account_data">Je eigen profiel, instellingen en concepten, op je eigen relays.</string>
|
||||
<string name="relay_explain_profiles">Profielen van de mensen die nu in beeld zijn.</string>
|
||||
<string name="relay_explain_relay_lists">Zoekt uit naar welke relays iemand publiceert, zodat hun posts van de juiste plek kunnen worden opgehaald.</string>
|
||||
<string name="relay_explain_follows">Volglijsten, gebruikt om je feed en je web-of-trust op te bouwen.</string>
|
||||
<string name="relay_explain_moderation">Rapportages die de mensen die je volgt schreven over de profielen die nu in beeld zijn, opgevraagd bij elke relay waarop die mensen plaatsen.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Rapportages van wie je volgt</string>
|
||||
<string name="relay_explain_wallet">Je eigen wallet-events, teruggelezen van de relays waarop je ze hebt gepubliceerd.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Luistert op je nutzap-relays plus je inbox- en DM-relays, zodat een betaling er niet langs kan glippen.</string>
|
||||
<string name="relay_explain_mint_directory">Kijkt op alle relays welke mints er bestaan en welke door mensen worden aanbevolen.</string>
|
||||
<string name="relay_explain_nwc">Meldingen van je verbonden wallet.</string>
|
||||
<string name="active_subs_title">Actieve relay-abonnementen</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d filter</item>
|
||||
<item quantity="other">%1$d filters</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d relay</item>
|
||||
<item quantity="other">%1$d relays</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filter is nog niet toegewezen</item>
|
||||
<item quantity="other">%1$d filters zijn nog niet toegewezen</item>
|
||||
</plurals>
|
||||
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
|
||||
<string name="active_subs_unattributed">Niet toegewezen aan een account</string>
|
||||
<string name="active_subs_no_entity">Alles</string>
|
||||
<string name="active_subs_scope_global">Iedereen</string>
|
||||
<string name="active_subs_scope_follows">Mensen die je volgt</string>
|
||||
<string name="active_subs_scope_authors">Een gekozen lijst mensen</string>
|
||||
<string name="active_subs_scope_muted">Gedempte mensen</string>
|
||||
<string name="active_subs_scope_all_communities">Jouw communities</string>
|
||||
<string name="active_subs_scope_algo">Een favoriete algo-feed</string>
|
||||
<string name="active_subs_share">%1$d%% van alles</string>
|
||||
<string name="active_subs_search_keywords">abonnementen filters relays verzoeken reqs verbindingen waarom diagnostiek</string>
|
||||
<string name="relay_explain_home">Posts van de mensen die je volgt, gelezen van de relays waarop ieder van hen publiceert.</string>
|
||||
<string name="always_on_notif_connecting">Verbinden met inbox-relays…</string>
|
||||
<string name="always_on_notif_setting_title">Altijd-aan meldingsdienst</string>
|
||||
<string name="always_on_notif_setting_description">Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist.</string>
|
||||
@@ -2218,8 +2300,10 @@
|
||||
<string name="geohash_exclusive">Alleen locatie-exclusief bericht</string>
|
||||
<string name="geohash_exclusive_explainer">Alleen volgers van de locatie zien dit bericht.</string>
|
||||
<string name="hashtag_exclusive">Alleen hashtag-exclusief bericht</string>
|
||||
<string name="external_content_title">Externe inhoud</string>
|
||||
<string name="external_url_scope">Reageer op een website</string>
|
||||
<string name="external_id_scope">Reageer op een externe bron</string>
|
||||
<string name="url_preview_open_in_browser">Openen in browser</string>
|
||||
<string name="long_form_reading_minutes">%1$d min lezen</string>
|
||||
<plurals name="music_playlist_track_count">
|
||||
<item quantity="one">%1$d nummer</item>
|
||||
@@ -2531,6 +2615,9 @@
|
||||
<string name="share_to_dm_title">Verzenden naar…</string>
|
||||
<string name="share_to_dm_start_new">Nieuw bericht</string>
|
||||
<string name="share_target_as_highlight">Nieuwe Highlight</string>
|
||||
<string name="share_target_as_picture">Nieuwe afbeelding</string>
|
||||
<string name="share_target_as_short_video">Nieuwe Short</string>
|
||||
<string name="share_target_as_video">Nieuwe video</string>
|
||||
<string name="new_highlight_title">Nieuwe Highlight</string>
|
||||
<string name="new_highlight_passage_label">Gemarkeerde tekst</string>
|
||||
<string name="new_highlight_passage_placeholder">Wat viel je op?</string>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">Porcentagem de conexões bem-sucedidas ao relay</string>
|
||||
<string name="search_and_add_a_user">Pesquisar e adicionar usuário</string>
|
||||
<string name="add_a_relay">Adicionar um Relay</string>
|
||||
<string name="relay_url_not_valid">Endereço de relay inválido. Use um nome de host ou um endereço IP entre colchetes (por exemplo [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Meu nome de @tag</string>
|
||||
<string name="display_name">Nome de Exibição</string>
|
||||
<string name="my_display_name">Meu nome de exibição</string>
|
||||
@@ -1940,14 +1941,84 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<string name="relay_purpose_browsing">Navegação</string>
|
||||
<string name="relay_purpose_media">Mídia</string>
|
||||
<string name="relay_purpose_topics">Tópicos</string>
|
||||
<string name="relay_purpose_thread">Conversa</string>
|
||||
<string name="relay_purpose_search">Pesquisa</string>
|
||||
<string name="relay_purpose_referenced">Encontrando eventos faltantes</string>
|
||||
<string name="relay_purpose_engagement">Observando eventos</string>
|
||||
<string name="relay_explain_referenced">Busca por id os eventos a que algo na sua tela se refere, mas que você ainda não tem — uma citação, o pai de uma resposta, a raiz de uma conversa.</string>
|
||||
<string name="relay_explain_engagement">Observa os eventos exibidos no momento em busca de novas respostas, reações, repostagens, zaps e denúncias, para que as contagens sejam atualizadas enquanto você lê.</string>
|
||||
<string name="relay_purpose_add_ons">Complementos</string>
|
||||
<string name="relay_purpose_relay_info">Informações do relay</string>
|
||||
<string name="relay_purpose_other">Outros</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Localizador de listas de relays</string>
|
||||
<string name="relay_purpose_observing_profiles">Observando perfis</string>
|
||||
<string name="relay_purpose_your_account">Dados da conta</string>
|
||||
<string name="relay_purpose_home_feed">Feed inicial</string>
|
||||
<string name="relay_purpose_relay_groups">Grupos de relay</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d grupo</item>
|
||||
<item quantity="other">%1$d grupos</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Chats efêmeros</string>
|
||||
<string name="relay_purpose_geohash_chats">Chats por localização</string>
|
||||
<string name="relay_purpose_live_chat">Chat de transmissão ao vivo</string>
|
||||
<string name="relay_explain_relay_groups">Grupos NIP-29 dos quais você participa. Cada grupo vive em um relay hospedeiro, então o app se conecta a todo relay que hospeda um grupo seu.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Salas de chat que não guardam histórico — as mensagens existem apenas enquanto você está conectado, então elas continuam assinadas para que algo chegue.</string>
|
||||
<string name="relay_explain_geohash_chats">Salas baseadas em localização para as áreas que você segue, consultadas nos relays que as hospedam.</string>
|
||||
<string name="relay_explain_live_chat">Chat e metas de zap ligados às transmissões ao vivo que você tem abertas ou segue.</string>
|
||||
<string name="relay_purpose_dm_inbox">Caixa de entrada de DM</string>
|
||||
<string name="relay_purpose_your_wallet">Carteira</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Caixa de entrada de nutzaps</string>
|
||||
<string name="relay_purpose_mint_directory">Diretório de mints</string>
|
||||
<string name="relay_purpose_community_chats">Chats de comunidades</string>
|
||||
<string name="relay_purpose_community_feeds">Feeds de comunidades</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Os relays da sua caixa de entrada, mais uma pequena amostra rotativa dos relays em que quem você segue publica, caso uma menção tenha sido entregue em outro lugar.</string>
|
||||
<string name="relay_explain_direct_messages">Os relays da sua caixa de entrada de DM, para onde as mensagens em gift wrap são entregues.</string>
|
||||
<string name="relay_explain_public_chats">O relay de origem de cada chat que você abriu ou do qual participa.</string>
|
||||
<string name="relay_explain_community_chats">Os relays em que cada comunidade publica seus planos.</string>
|
||||
<string name="relay_explain_encrypted_groups">Mensagens de grupo e pacotes de chaves, nos relays de cada grupo.</string>
|
||||
<string name="relay_explain_live_rooms">Os relays da sala, enquanto ela estiver aberta.</string>
|
||||
<string name="relay_explain_account_data">Seu próprio perfil, configurações e rascunhos, nos seus relays de origem.</string>
|
||||
<string name="relay_explain_profiles">Perfis das pessoas atualmente na tela.</string>
|
||||
<string name="relay_explain_relay_lists">Descobre em quais relays cada pessoa publica, para que as publicações dela possam ser buscadas no lugar certo.</string>
|
||||
<string name="relay_explain_follows">Listas de seguindo, usadas para montar seu feed e sua rede de confiança.</string>
|
||||
<string name="relay_explain_moderation">Denúncias que as pessoas que você segue escreveram sobre os perfis atualmente na sua tela, consultadas em cada relay em que essas pessoas publicam.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Denúncias de quem você segue</string>
|
||||
<string name="relay_explain_wallet">Os eventos da sua própria carteira, lidos de volta dos relays em que você os publicou.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Escuta nos seus relays de nutzap, além dos relays da caixa de entrada e de DM, para que nenhum pagamento passe despercebido.</string>
|
||||
<string name="relay_explain_mint_directory">Procura pelos relays quais mints existem e quais as pessoas recomendam.</string>
|
||||
<string name="relay_explain_nwc">Notificações da sua carteira conectada.</string>
|
||||
<string name="active_subs_title">Assinaturas de relay ativas</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d filtro</item>
|
||||
<item quantity="other">%1$d filtros</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filtro ainda não foi atribuído</item>
|
||||
<item quantity="other">%1$d filtros ainda não foram atribuídos</item>
|
||||
</plurals>
|
||||
<string name="active_subs_unattributed">Não atribuído a nenhuma conta</string>
|
||||
<string name="active_subs_no_entity">Tudo</string>
|
||||
<string name="active_subs_scope_global">Todos</string>
|
||||
<string name="active_subs_scope_follows">Pessoas que você segue</string>
|
||||
<string name="active_subs_scope_authors">Uma lista escolhida de pessoas</string>
|
||||
<string name="active_subs_scope_muted">Pessoas silenciadas</string>
|
||||
<string name="active_subs_scope_all_communities">Suas comunidades</string>
|
||||
<string name="active_subs_scope_algo">Um algoritmo de feed favorito</string>
|
||||
<string name="active_subs_share">%1$d%% de todos</string>
|
||||
<string name="active_subs_search_keywords">assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico</string>
|
||||
<string name="relay_explain_home">Publicações de pessoas que você segue, lidas dos relays em que cada uma delas publica.</string>
|
||||
<string name="always_on_notif_connecting">Conectando aos relays de caixa de entrada\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Serviço de notificações sempre ativo</string>
|
||||
<string name="always_on_notif_setting_description">Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem.</string>
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<string name="connection_success_rate_description">Andel lyckade anslutningar till reläet</string>
|
||||
<string name="search_and_add_a_user">Sök och lägg till användare</string>
|
||||
<string name="add_a_relay">Lägg till Relä</string>
|
||||
<string name="relay_url_not_valid">Inte en giltig reläadress. Använd ett värdnamn eller en IP-adress inom hakparenteser (till exempel [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||
<string name="my_name">Mitt @tag-namn</string>
|
||||
<string name="display_name">Visningsnamn</string>
|
||||
<string name="my_display_name">Mitt visningsnamn</string>
|
||||
@@ -1940,14 +1941,95 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d relä</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d reläer</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">Bläddring</string>
|
||||
<string name="relay_purpose_media">Media</string>
|
||||
<string name="relay_purpose_tags">Hashtaggar</string>
|
||||
<string name="relay_purpose_topics">Ämnen</string>
|
||||
<string name="relay_purpose_thread">Konversation</string>
|
||||
<string name="relay_purpose_search">Sök</string>
|
||||
<string name="relay_purpose_referenced">Hittar saknade händelser</string>
|
||||
<string name="relay_purpose_engagement">Observerar händelser</string>
|
||||
<string name="relay_explain_referenced">Hämtar händelser via id som något på din skärm hänvisar till men som du inte har ännu — ett citat, ett svars förälder, en trådrot.</string>
|
||||
<string name="relay_explain_engagement">Bevakar de händelser som visas just nu efter nya svar, reaktioner, återinlägg, zaps och rapporter, så att räknarna uppdateras medan du läser.</string>
|
||||
<string name="relay_purpose_add_ons">Tillägg</string>
|
||||
<string name="relay_purpose_relay_info">Reläinfo</string>
|
||||
<string name="relay_purpose_other">Övrigt</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">Relälistsökare</string>
|
||||
<string name="relay_purpose_observing_profiles">Observerar profiler</string>
|
||||
<string name="relay_purpose_your_account">Kontots data</string>
|
||||
<string name="relay_purpose_home_feed">Hemflöde</string>
|
||||
<string name="relay_purpose_relay_groups">Relägrupper</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d grupp</item>
|
||||
<item quantity="other">%1$d grupper</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">Försvinnande chattar</string>
|
||||
<string name="relay_purpose_geohash_chats">Platschattar</string>
|
||||
<string name="relay_purpose_live_chat">Livesändningschatt</string>
|
||||
<string name="relay_explain_relay_groups">NIP-29-grupper du gått med i. Varje grupp bor på ett värdrelä, så appen ansluter till varje relä som är värd för en av dina grupper.</string>
|
||||
<string name="relay_explain_ephemeral_chats">Chattrum som inte sparar någon historik — meddelanden finns bara medan du är ansluten, så dessa förblir prenumererade för att något alls ska komma fram.</string>
|
||||
<string name="relay_explain_geohash_chats">Platsbaserade rum för de områden du följer, efterfrågade från de reläer som bär dem.</string>
|
||||
<string name="relay_explain_live_chat">Chatt och zap-mål kopplade till livesändningar du har öppna eller följer.</string>
|
||||
<string name="relay_purpose_dm_inbox">DM-inkorg</string>
|
||||
<string name="relay_purpose_your_wallet">Plånbok</string>
|
||||
<string name="relay_purpose_nutzap_inbox">Nutzap-inkorg</string>
|
||||
<string name="relay_purpose_mint_directory">Mint-katalog</string>
|
||||
<string name="relay_purpose_nwc">Wallet Connect</string>
|
||||
<string name="relay_purpose_community_chats">Gemenskapschattar</string>
|
||||
<string name="relay_purpose_community_feeds">Gemenskapsflöden</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">Dina inkorgsreläer, plus ett litet roterande urval av de reläer personer du följer publicerar till, ifall ett omnämnande levererades någon annanstans.</string>
|
||||
<string name="relay_explain_direct_messages">Dina DM-inkorgsreläer, dit gift-wrap-meddelanden levereras.</string>
|
||||
<string name="relay_explain_public_chats">Hemrelät för varje chatt du har öppen eller har gått med i.</string>
|
||||
<string name="relay_explain_community_chats">Reläerna som varje gemenskap publicerar sina plan till.</string>
|
||||
<string name="relay_explain_encrypted_groups">Gruppmeddelanden och nyckelpaket, på varje grupps reläer.</string>
|
||||
<string name="relay_explain_live_rooms">Rummets reläer, medan det är öppet.</string>
|
||||
<string name="relay_explain_account_data">Din egen profil, dina inställningar och utkast, på dina hemreläer.</string>
|
||||
<string name="relay_explain_profiles">Profiler för personerna som just nu visas på skärmen.</string>
|
||||
<string name="relay_explain_relay_lists">Hittar vilka reläer varje person publicerar till, så att deras inlägg kan hämtas från rätt ställe.</string>
|
||||
<string name="relay_explain_follows">Följerlistor, som används för att bygga ditt flöde och ditt förtroendenät.</string>
|
||||
<string name="relay_explain_moderation">Rapporter som personer du följer har skrivit om profilerna som just nu visas på skärmen, efterfrågade från varje relä dessa personer publicerar till.</string>
|
||||
<string name="relay_purpose_reports_from_follows">Rapporter från personer du följer</string>
|
||||
<string name="relay_explain_wallet">Dina egna plånbokshändelser, lästa tillbaka från de reläer du publicerade dem till.</string>
|
||||
<string name="relay_explain_nutzap_inbox">Lyssnar på dina nutzap-reläer plus dina inkorgs- och DM-reläer, så att en betalning inte kan slinka förbi.</string>
|
||||
<string name="relay_explain_mint_directory">Söker över reläer efter vilka mints som finns och vilka folk rekommenderar.</string>
|
||||
<string name="relay_explain_nwc">Aviseringar från din anslutna plånbok.</string>
|
||||
<string name="active_subs_title">Aktiva reläprenumerationer</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d filter</item>
|
||||
<item quantity="other">%1$d filter</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d relä</item>
|
||||
<item quantity="other">%1$d reläer</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filter är inte kopplat ännu</item>
|
||||
<item quantity="other">%1$d filter är inte kopplade ännu</item>
|
||||
</plurals>
|
||||
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
|
||||
<string name="active_subs_unattributed">Inte kopplat till något konto</string>
|
||||
<string name="active_subs_no_entity">Allt</string>
|
||||
<string name="active_subs_scope_global">Alla</string>
|
||||
<string name="active_subs_scope_follows">Personer du följer</string>
|
||||
<string name="active_subs_scope_authors">En vald lista med personer</string>
|
||||
<string name="active_subs_scope_muted">Tystade personer</string>
|
||||
<string name="active_subs_scope_all_communities">Dina gemenskaper</string>
|
||||
<string name="active_subs_scope_algo">En favorit-flödesalgoritm</string>
|
||||
<string name="active_subs_share">%1$d %% av alla</string>
|
||||
<string name="active_subs_search_keywords">prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik</string>
|
||||
<string name="relay_explain_home">Inlägg från personer du följer, lästa från de reläer var och en av dem publicerar till.</string>
|
||||
<string name="always_on_notif_connecting">Ansluter till inbox-relän\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Alltid på-notifieringstjänst</string>
|
||||
<string name="always_on_notif_setting_description">Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande.</string>
|
||||
|
||||
@@ -2378,6 +2378,7 @@
|
||||
<string name="compose_search_keywords" translatable="false">draft, posting, editor, auto-save, signature, proof of work, pow, mining, nip-13</string>
|
||||
<string name="reactions_settings_search_keywords" translatable="false">emoji, reactions, like</string>
|
||||
<string name="bottom_bar_search_keywords" translatable="false">navigation, tabs, nav bar</string>
|
||||
<string name="drawer_search_keywords" translatable="false">side menu, drawer, hamburger, sections, hide, show</string>
|
||||
<string name="home_tabs_search_keywords" translatable="false">tabs, feeds, threads, conversations</string>
|
||||
<string name="profile_ui_search_keywords" translatable="false">profile, layout</string>
|
||||
<string name="backup_keys_search_keywords" translatable="false">nsec, private key, seed, mnemonic, export</string>
|
||||
@@ -3512,6 +3513,16 @@
|
||||
<string name="bottom_bar_category_feeds">Feeds</string>
|
||||
<string name="bottom_bar_category_apps">Apps & Web</string>
|
||||
<string name="bottom_bar_category_other">Other</string>
|
||||
<string name="drawer_settings">Side Menu</string>
|
||||
<string name="drawer_settings_title">Your side menu</string>
|
||||
<string name="drawer_settings_description">Open a section and switch off the rows you never use. Settings always stays visible, so you can always get back here. Section order is fixed.</string>
|
||||
<string name="drawer_settings_sections">Sections</string>
|
||||
<string name="drawer_settings_hidden_count">%1$d hidden</string>
|
||||
<string name="drawer_settings_visible">Visible</string>
|
||||
<string name="drawer_settings_hidden">Hidden</string>
|
||||
<string name="drawer_settings_always_on">Always on</string>
|
||||
<string name="drawer_settings_show_all">Show all</string>
|
||||
<string name="drawer_settings_hide_all">Hide all</string>
|
||||
<string name="home_tabs_settings">Home Tabs</string>
|
||||
<string name="home_tabs_settings_description">Pick which tabs appear on the Home screen. When only one tab is active the tab bar is hidden.</string>
|
||||
<string name="home_tab_everything">Everything</string>
|
||||
@@ -3919,6 +3930,10 @@
|
||||
<string name="git_repo_settings_save">Save</string>
|
||||
<string name="git_repositories">Git Repositories</string>
|
||||
<string name="highlights">Highlights</string>
|
||||
<string name="git_repositories_search_open">Filter repositories</string>
|
||||
<string name="git_repositories_search_close">Close filter</string>
|
||||
<string name="git_repositories_search_placeholder">Filter by name, topic, host, maintainer…</string>
|
||||
<string name="git_repositories_search_no_results">No repositories in the current feed match this search.</string>
|
||||
<string name="nsite_title">nSite: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Permissions:</string>
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.preferences
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountNavigationPreferencesInternal
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Locks the per-account side-menu persistence. The hidden rows ride along in the same NIP-78
|
||||
* app-specific data blob as the bottom bar, so every account keeps its own menu and it syncs across
|
||||
* the user's devices.
|
||||
*/
|
||||
class DrawerPersistenceTest {
|
||||
@Test
|
||||
fun defaultIsAnUntouchedMenu() {
|
||||
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(JsonMapper.toJson(AccountNavigationPreferencesInternal()))
|
||||
|
||||
assertEquals(emptyList<String>(), decoded.hiddenDrawerItems)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blobWrittenBeforeTheFieldExistedDecodesToAnUntouchedMenu() {
|
||||
// Every existing account is in this state, and so is every account that never opens the
|
||||
// screen — which is exactly why the preference stores hidden rows rather than visible ones.
|
||||
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>("{}")
|
||||
|
||||
assertEquals(emptyList<String>(), decoded.hiddenDrawerItems)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenRowsRoundTripThroughTheSyncedSettingsBlob() {
|
||||
val hidden = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES)
|
||||
|
||||
val json = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = hidden.toNames()))
|
||||
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(json)
|
||||
|
||||
assertEquals(hidden, navBarItemsFromNames(decoded.hiddenDrawerItems))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serializedFormIsDeterministic() {
|
||||
// Two equal sets must produce byte-identical JSON, or a republish that changed nothing would
|
||||
// still look like a change and churn the account's NIP-78 event.
|
||||
val a = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES).toNames()))
|
||||
val b = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.BADGES, NavBarItem.DRAFTS).toNames()))
|
||||
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIdFromANewerClientIsDroppedInsteadOfFailingTheWholeBlob() {
|
||||
// The names are stored as strings precisely for this: decoding them as the enum would throw
|
||||
// and take every other synced setting down with it.
|
||||
val json = """{"hiddenDrawerItems":["DRAFTS","SOME_SCREEN_FROM_THE_FUTURE"]}"""
|
||||
|
||||
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(json)
|
||||
|
||||
assertEquals(setOf(NavBarItem.DRAFTS), navBarItemsFromNames(decoded.hiddenDrawerItems))
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The show/hide rules behind the Side Menu settings screen, exercised without the UI.
|
||||
*
|
||||
* The preference stores what is *hidden*, so the interesting cases are the empty set (a stock drawer,
|
||||
* and the state every new install and every newly shipped destination starts in) and the mandatory
|
||||
* rows, which no code path may switch off.
|
||||
*/
|
||||
class DrawerItemVisibilityTest {
|
||||
private val you = DrawerSections.first { it.id == DrawerSectionId.YOU }
|
||||
private val system = DrawerSections.first { it.id == DrawerSectionId.SYSTEM }
|
||||
|
||||
@Test
|
||||
fun nothingHiddenMeansEverythingVisible() {
|
||||
val visible = DrawerItemVisibility.visibleItems(you, emptySet())
|
||||
|
||||
assertEquals(you.items, visible)
|
||||
assertEquals(0, DrawerItemVisibility.hiddenCount(you, emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toggleHidesThenShows() {
|
||||
val once = DrawerItemVisibility.toggle(emptySet(), NavBarItem.DRAFTS)
|
||||
assertEquals(setOf(NavBarItem.DRAFTS), once)
|
||||
assertFalse(DrawerItemVisibility.isVisible(once, NavBarItem.DRAFTS))
|
||||
|
||||
val twice = DrawerItemVisibility.toggle(once, NavBarItem.DRAFTS)
|
||||
assertEquals(emptySet<NavBarItem>(), twice)
|
||||
assertTrue(DrawerItemVisibility.isVisible(twice, NavBarItem.DRAFTS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aHiddenRowDropsOutOfItsSectionKeepingTheOrderOfTheRest() {
|
||||
val hidden = setOf(NavBarItem.DRAFTS)
|
||||
val visible = DrawerItemVisibility.visibleItems(you, hidden)
|
||||
|
||||
assertEquals(you.items.filter { it != NavBarItem.DRAFTS }, visible)
|
||||
assertEquals(1, DrawerItemVisibility.hiddenCount(you, hidden))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsCannotBeHidden() {
|
||||
// The escape hatch: hiding Settings would leave no route back to the screen that hides rows.
|
||||
assertEquals(emptySet<NavBarItem>(), DrawerItemVisibility.toggle(emptySet(), NavBarItem.SETTINGS))
|
||||
assertTrue(DrawerItemVisibility.isVisible(setOf(NavBarItem.SETTINGS), NavBarItem.SETTINGS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeStripsMandatoryItemsSyncedFromElsewhere() {
|
||||
// Another client (or an older build) could put Settings in the set; reading it back must not
|
||||
// strand the row as hidden-yet-unhideable.
|
||||
val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.SETTINGS, NavBarItem.DRAFTS))
|
||||
|
||||
assertEquals(setOf(NavBarItem.DRAFTS), sanitized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeKeepsIdsThisDeviceDoesNotRender() {
|
||||
// Favorite Apps is gated off below API 30. Editing the menu on such a device must not clear
|
||||
// the choice the same account made on a newer one.
|
||||
val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.FAVORITE_APPS))
|
||||
|
||||
assertEquals(setOf(NavBarItem.FAVORITE_APPS), sanitized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hideAllLeavesTheMandatoryRowsOfASection() {
|
||||
val hidden = DrawerItemVisibility.hideAll(emptySet(), system)
|
||||
|
||||
assertTrue(DrawerItemVisibility.isVisible(hidden, NavBarItem.SETTINGS))
|
||||
assertEquals(0, DrawerItemVisibility.hiddenCount(system, hidden))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSectionOfOnlyMandatoryRowsHasNothingToHide() {
|
||||
// What gates the section's bulk Show all / Hide all actions.
|
||||
assertFalse(DrawerItemVisibility.hasHideableRows(system))
|
||||
assertTrue(DrawerItemVisibility.hasHideableRows(you))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hideAllThenShowAllRoundTripsASection() {
|
||||
val hidden = DrawerItemVisibility.hideAll(emptySet(), you)
|
||||
assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, hidden))
|
||||
|
||||
val shown = DrawerItemVisibility.showAll(hidden, you)
|
||||
assertEquals(emptySet<NavBarItem>(), shown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showAllOnlyTouchesItsOwnSection() {
|
||||
val hidden = DrawerItemVisibility.hideAll(DrawerItemVisibility.hideAll(emptySet(), you), system)
|
||||
|
||||
val shown = DrawerItemVisibility.showAll(hidden, system)
|
||||
|
||||
assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, shown))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateHolderPersistsEveryEditAndRestoresDefaults() {
|
||||
val saved = mutableListOf<Set<NavBarItem>>()
|
||||
val state = DrawerSettingsState(emptySet()) { saved.add(it) }
|
||||
|
||||
state.toggle(NavBarItem.DRAFTS)
|
||||
state.toggle(NavBarItem.BOOKMARKS)
|
||||
|
||||
assertEquals(listOf(setOf(NavBarItem.DRAFTS), setOf(NavBarItem.DRAFTS, NavBarItem.BOOKMARKS)), saved)
|
||||
assertEquals(2, state.totalHidden())
|
||||
|
||||
state.restoreDefault()
|
||||
|
||||
assertEquals(emptySet<NavBarItem>(), state.hidden)
|
||||
assertEquals(0, state.totalHidden())
|
||||
assertEquals(emptySet<NavBarItem>(), saved.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateHolderDoesNotRepublishANoOpEdit() {
|
||||
// Tapping a mandatory row must not republish the account's NIP-78 settings event.
|
||||
val saved = mutableListOf<Set<NavBarItem>>()
|
||||
val state = DrawerSettingsState(emptySet()) { saved.add(it) }
|
||||
|
||||
state.toggle(NavBarItem.SETTINGS)
|
||||
state.restoreDefault()
|
||||
|
||||
assertTrue(saved.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.SdkGatedDrawerItems
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The drawer and its settings screen both render [DrawerSections], so a destination missing from
|
||||
* every section is invisible in both places at once — no compiler error, no crash, just a screen
|
||||
* nobody can reach from the menu. These pin the invariants that keep that from happening quietly.
|
||||
*/
|
||||
class DrawerSectionsTest {
|
||||
@Test
|
||||
fun everyCatalogItemAppearsInADrawerSection() {
|
||||
val sectioned = DrawerSections.flatMap { it.items }.toSet()
|
||||
val missing = NavBarCatalog.keys - sectioned
|
||||
|
||||
assertEquals(
|
||||
"a catalog destination is in no drawer section — add it to one in NavBarItem.kt, " +
|
||||
"or to SdkGatedDrawerItems with the reason it can't be there",
|
||||
emptySet<Any>(),
|
||||
missing - SdkGatedDrawerItems,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noItemIsListedInTwoSections() {
|
||||
val sectioned = DrawerSections.flatMap { it.items }
|
||||
|
||||
assertEquals("an item is listed in more than one drawer section", sectioned.size, sectioned.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everySectionedItemResolvesInTheCatalog() {
|
||||
// The drawer looks each id up in NavBarCatalog and skips misses, so an id with no catalog
|
||||
// entry would silently render nothing while still occupying a row in the settings screen.
|
||||
DrawerSections.forEach { section ->
|
||||
section.items.forEach { item ->
|
||||
assertTrue("$item has no NavBarCatalog entry", NavBarCatalog.containsKey(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sectionsAreOrderedWithCreateBetweenFeedsAndSystem() {
|
||||
// The drawer renders DrawerSections in order, so this list *is* the menu's layout. Create sits
|
||||
// between the feeds and System, and is the one section with nothing configurable in it.
|
||||
assertEquals(
|
||||
listOf(
|
||||
DrawerSectionId.YOU,
|
||||
DrawerSectionId.NAVIGATE,
|
||||
DrawerSectionId.FEEDS,
|
||||
DrawerSectionId.CREATE,
|
||||
DrawerSectionId.SYSTEM,
|
||||
),
|
||||
DrawerSections.map { it.id },
|
||||
)
|
||||
assertEquals(emptyList<Any>(), DrawerSections.first { it.id == DrawerSectionId.CREATE }.items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everySectionHasItsOwnId() {
|
||||
val ids = DrawerSections.map { it.id }
|
||||
|
||||
assertEquals("two sections share a DrawerSectionId", ids.size, ids.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSectionWithNoCatalogItemsRendersFixedRowsOrNothingAtAll() {
|
||||
// hasFixedRows is declared on the section but consumed by CatalogSection's `when (section.id)`,
|
||||
// in another file — so the flag and the branch that honours it can drift apart with no compile
|
||||
// error. A section that carries neither is unreachable in both directions at once: the settings
|
||||
// screen skips it on items.isEmpty(), and CatalogSection returns before rendering a heading.
|
||||
val unreachable = DrawerSections.filter { it.items.isEmpty() && !it.hasFixedRows }
|
||||
|
||||
assertEquals(
|
||||
"a drawer section has no catalog items and no fixed rows, so it renders nowhere — " +
|
||||
"give it items, set hasFixedRows and a branch in CatalogSection, or delete it",
|
||||
emptyList<Any>(),
|
||||
unreachable.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mandatoryItemsAreActuallyRenderedByASection() {
|
||||
// A mandatory item that no section renders would be unhideable *and* invisible — the worst
|
||||
// of both. Settings is mandatory precisely because it is the way back to this configuration.
|
||||
val sectioned = DrawerSections.flatMap { it.items }.toSet()
|
||||
|
||||
assertTrue("a mandatory drawer item is in no section", sectioned.containsAll(MandatoryDrawerItems))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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.navigation
|
||||
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavOptionsBuilder
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.ImeSettler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Leaving a screen while the soft keyboard is still animating strands `imePadding()` at keyboard
|
||||
* height for the whole app, because `WindowInsets.ime` is a single shared holder. The fix is that
|
||||
* [Nav] waits for the IME to be gone before it moves, so these assert the ordering rather than any
|
||||
* visual result: every transition must settle the keyboard *first*.
|
||||
*
|
||||
* Without the settle calls in [Nav] each of these records only "navigate" and fails.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NavImeSettleTest {
|
||||
private fun controllerRecording(order: MutableList<String>): NavHostController =
|
||||
mockk<NavHostController>(relaxed = true) {
|
||||
every { navigate(any<Route>(), any<NavOptionsBuilder.() -> Unit>()) } answers
|
||||
{ order.add("navigate") }
|
||||
every { navigate(any<Route>()) } answers { order.add("navigate") }
|
||||
every { navigateUp() } answers {
|
||||
order.add("navigate")
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun popBackSettlesTheKeyboardBeforeNavigating() =
|
||||
runTest {
|
||||
val order = mutableListOf<String>()
|
||||
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
|
||||
|
||||
nav.popBack()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bottomBarSettlesTheKeyboardBeforeNavigating() =
|
||||
runTest {
|
||||
// The search tab focuses its field on arrival, so the keyboard is already up when the
|
||||
// user taps another tab — the exit that has no BackHandler and no top bar to guard it.
|
||||
val order = mutableListOf<String>()
|
||||
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
|
||||
|
||||
nav.navBottomBar(Route.Home)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newStackSettlesTheKeyboardBeforeNavigating() =
|
||||
runTest {
|
||||
val order = mutableListOf<String>()
|
||||
val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") })
|
||||
|
||||
nav.newStack(Route.Home)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSlowKeyboardStillHoldsTheNavigationBack() =
|
||||
runTest {
|
||||
// The real settler suspends for the length of the IME close animation. Navigation must
|
||||
// wait for it, not fire alongside it — that overlap is the bug.
|
||||
val order = mutableListOf<String>()
|
||||
val nav =
|
||||
Nav(
|
||||
controllerRecording(order),
|
||||
this,
|
||||
ImeSettler {
|
||||
delay(250)
|
||||
order.add("settle")
|
||||
},
|
||||
)
|
||||
|
||||
nav.popBack()
|
||||
assertEquals(emptyList<String>(), order)
|
||||
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf("settle", "navigate"), order)
|
||||
}
|
||||
}
|
||||
+33
-4
@@ -254,7 +254,14 @@ val jlinkRuntime =
|
||||
}
|
||||
|
||||
// Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE).
|
||||
// Cross-platform — the release workflow tars this up on every OS.
|
||||
// Cross-platform — the release workflow archives this on every OS (tar.gz on
|
||||
// unix, zip on Windows). We write BOTH a POSIX `amy` shell launcher AND a
|
||||
// Windows `amy.bat` launcher into `bin/` unconditionally so the same tree is
|
||||
// runnable on any target after extraction, regardless of which OS built it.
|
||||
// (The bundled jlink runtime is host-native — you still need to unzip a
|
||||
// Windows-built image on Windows to actually launch it — but the launcher
|
||||
// scripts themselves are host-agnostic, which keeps the layout uniform and
|
||||
// makes ad-hoc cross-machine inspection painless.)
|
||||
val amyImage =
|
||||
tasks.register<Sync>("amyImage") {
|
||||
group = "distribution"
|
||||
@@ -282,13 +289,35 @@ val amyImage =
|
||||
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
|
||||
exec "${'$'}DIR/runtime/bin/java" -Djava.awt.headless=true -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
|
||||
""".trimIndent() + "\n"
|
||||
// Windows launcher. Uses %~dp0 (drive+path of this .bat, always ending in
|
||||
// a backslash) so it resolves the app root without depending on CWD, then
|
||||
// execs the bundled JRE against lib\*. `chcp 65001` pins the console to
|
||||
// UTF-8 so `sun.jnu.encoding` isn't the OS OEM code page — same rationale
|
||||
// as the installDist launcher patch above. CRLF line endings so cmd.exe
|
||||
// parses it correctly.
|
||||
//
|
||||
// The `for %%i in (...) do set DIR=%%~fi` trick canonicalises `\bin\..`
|
||||
// out of DIR to the parent directory — same idiom Gradle's own
|
||||
// installDist .bat uses to resolve APP_HOME. Java tolerates the `..`
|
||||
// segment but canonicalising once here keeps every classpath entry and
|
||||
// error message clean (and matches the loose-directory layout users see
|
||||
// after unzipping the release archive).
|
||||
val windowsLauncher =
|
||||
"@echo off\r\n" +
|
||||
"chcp 65001 > NUL 2>&1\r\n" +
|
||||
"setlocal\r\n" +
|
||||
"set \"DIR=%~dp0..\"\r\n" +
|
||||
"for %%i in (\"%DIR%\") do set \"DIR=%%~fi\"\r\n" +
|
||||
"\"%DIR%\\runtime\\bin\\java.exe\" -Djava.awt.headless=true -cp \"%DIR%\\lib\\*\" $mainClass %*\r\n"
|
||||
|
||||
doLast {
|
||||
val binDir = amyImageDir.get().asFile.resolve("bin")
|
||||
binDir.mkdirs()
|
||||
val launcher = binDir.resolve("amy")
|
||||
launcher.writeText(unixLauncher)
|
||||
launcher.setExecutable(true, false)
|
||||
val unix = binDir.resolve("amy")
|
||||
unix.writeText(unixLauncher)
|
||||
unix.setExecutable(true, false)
|
||||
val windows = binDir.resolve("amy.bat")
|
||||
windows.writeText(windowsLauncher)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -668,7 +668,7 @@ class Context(
|
||||
val filters = relays.associateWith { listOf(responseFilter) }
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -133,7 +133,7 @@ object GeochatCommands {
|
||||
val subId = newSubId()
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -167,7 +167,7 @@ object NipCommand {
|
||||
val remaining = SEARCH_RELAYS.toMutableSet()
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -118,7 +118,7 @@ object NostrConnect {
|
||||
val subId = newSubId()
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -81,7 +81,7 @@ object SubscribeCommand {
|
||||
val subId = newSubId()
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ class ChessRelayFetchHelper(
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+3
-3
@@ -99,7 +99,7 @@ class FeedMetadataCoordinator(
|
||||
val listener =
|
||||
if (onEvent != null) {
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -295,7 +295,7 @@ class FeedMetadataCoordinator(
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -371,7 +371,7 @@ class FeedMetadataCoordinator(
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ abstract class PerKeyEoseManager<T, K : Any>(
|
||||
newEose(queryState, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ abstract class SingleSubEoseManager<T>(
|
||||
newEose(relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ class RelayHealthListener(
|
||||
store.recordConnect(relay.url, TimeUtils.now())
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.richtext
|
||||
|
||||
/**
|
||||
* Which player/viewer can render a declared blob, as resolved by
|
||||
* [RichTextParser.classifyMedia].
|
||||
*
|
||||
* The set is deliberately closed: it enumerates the renderers [BaseMediaContent] actually has
|
||||
* (`MediaUrlImage`, `MediaUrlVideo`, `MediaUrlPdf`), so "no constant fits" — a `null`
|
||||
* classification — is the honest answer for every other file type rather than a bucket some
|
||||
* caller has to invent a default for. Audio folds into [VIDEO] because both play through the
|
||||
* same pipeline; see `RichTextParser.videoExt`.
|
||||
*/
|
||||
enum class MediaContentKind {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
PDF,
|
||||
}
|
||||
+47
-36
@@ -61,41 +61,12 @@ class RichTextParser {
|
||||
|
||||
val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull()
|
||||
|
||||
var isImage = false
|
||||
var isVideo = false
|
||||
var isPdf = false
|
||||
// Returning null here drops the URL to a plain link, discarding the imeta's `dim`/blurhash
|
||||
// and forcing a URL-preview round-trip to rediscover a type the imeta already declared —
|
||||
// which is why classifyMedia falls back to the extension before giving up.
|
||||
val kind = classifyMedia(fullUrl, contentType)
|
||||
|
||||
if (contentType != null) {
|
||||
isImage = contentType.startsWith("image/")
|
||||
// HLS playlists are advertised with a non-`video/*` MIME (`application/vnd.apple.mpegurl`
|
||||
// and three legacy aliases). Without these, an imeta-described `.m3u8` falls into the
|
||||
// null bucket below and the renderer drops back to a plain hyperlink — even though
|
||||
// the matching extension would have routed it to MediaUrlVideo. Mirror the canonical
|
||||
// list used by MediaItemCache.toExoPlayerMimeType / GalleryThumb.isHlsMimeType.
|
||||
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") || isHlsMimeType(contentType)
|
||||
isPdf = contentType.startsWith("application/pdf")
|
||||
} else if (fullUrl.startsWith("data:")) {
|
||||
isImage = fullUrl.startsWith("data:image/")
|
||||
isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/")
|
||||
isPdf = fullUrl.startsWith("data:application/pdf")
|
||||
}
|
||||
|
||||
// Fall back to file-extension detection when the type is still unknown. This covers both
|
||||
// the no-MIME case and a *malformed* imeta MIME — e.g. Primal iOS emits `m jpeg` instead
|
||||
// of `m image/jpeg`, which matches none of the `startsWith` prefixes above. Without this
|
||||
// fallback such a URL returns null and drops to a plain link: that discards the imeta
|
||||
// `dim`/blurhash (so the loading placeholder can't reserve the image's height and the
|
||||
// feed jumps once the bitmap arrives) and forces a needless URL-preview network
|
||||
// round-trip just to rediscover the type the imeta already declared. `data:` URIs carry
|
||||
// their type in the prefix, so a miss there is genuine — don't extension-probe them.
|
||||
if (!isImage && !isVideo && !isPdf && !fullUrl.startsWith("data:")) {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
return if (isImage) {
|
||||
return if (kind == MediaContentKind.IMAGE) {
|
||||
MediaUrlImage(
|
||||
url = fullUrl,
|
||||
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
|
||||
@@ -108,7 +79,7 @@ class RichTextParser {
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
authorPubKey = authorPubKey,
|
||||
)
|
||||
} else if (isVideo) {
|
||||
} else if (kind == MediaContentKind.VIDEO) {
|
||||
MediaUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
|
||||
@@ -125,7 +96,7 @@ class RichTextParser {
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
authorPubKey = authorPubKey,
|
||||
)
|
||||
} else if (isPdf) {
|
||||
} else if (kind == MediaContentKind.PDF) {
|
||||
MediaUrlPdf(
|
||||
url = fullUrl,
|
||||
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
|
||||
@@ -582,6 +553,46 @@ class RichTextParser {
|
||||
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which renderer can display a declared blob — the single decision every media
|
||||
* renderer must make, from a NIP-94 `m` tag, a NIP-92 imeta, or a bare URL.
|
||||
*
|
||||
* A declared MIME type wins; the URL extension is the fallback both for the no-MIME case
|
||||
* and for a *malformed* MIME (Primal iOS emits `m jpeg` rather than `m image/jpeg`, which
|
||||
* matches no prefix below). `data:` URIs carry their type in the prefix, so a miss there is
|
||||
* genuine and the base64 payload is never extension-probed.
|
||||
*
|
||||
* Returns **null** when nothing can render the file. Callers must not substitute a media
|
||||
* kind for that null: handing an arbitrary blob — a webxdc app, a zip, an APK — to the
|
||||
* video player yields a permanently-buffering ExoPlayer where a plain link belongs. The one
|
||||
* defensible default is on kinds whose *event* already asserts the type (a NIP-71 video
|
||||
* event is a video however odd its imeta), and those call sites say so explicitly.
|
||||
*/
|
||||
fun classifyMedia(
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): MediaContentKind? {
|
||||
if (mimeType != null) {
|
||||
if (mimeType.startsWith("image/")) return MediaContentKind.IMAGE
|
||||
// HLS playlists are advertised with a non-`video/*` MIME; see [isHlsMimeType].
|
||||
if (mimeType.startsWith("video/") || mimeType.startsWith("audio/") || isHlsMimeType(mimeType)) return MediaContentKind.VIDEO
|
||||
if (mimeType.startsWith("application/pdf")) return MediaContentKind.PDF
|
||||
} else if (url.startsWith("data:")) {
|
||||
if (url.startsWith("data:image/")) return MediaContentKind.IMAGE
|
||||
if (url.startsWith("data:video/") || url.startsWith("data:audio/")) return MediaContentKind.VIDEO
|
||||
if (url.startsWith("data:application/pdf")) return MediaContentKind.PDF
|
||||
}
|
||||
|
||||
if (url.startsWith("data:")) return null
|
||||
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.IMAGE
|
||||
if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.VIDEO
|
||||
if (pdfExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.PDF
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun isValidURL(url: String?): Boolean = isValidUrl(url)
|
||||
|
||||
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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.search
|
||||
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
/**
|
||||
* Local, in-memory matcher for the Git Repositories screen search box.
|
||||
*
|
||||
* The screen already loads the full set of ngit repository announcements
|
||||
* (`kind:30617` `GitRepositoryEvent`) the user has subscribed to via their
|
||||
* follow lists / follow set, so a client-side filter avoids issuing an
|
||||
* extra NIP-50 relay query for the common "I know its name/topic/host"
|
||||
* lookup. It also matches on fields the generic NIP-50 search would
|
||||
* ignore — clone/web URLs, maintainer npubs, the ngit `d` identifier
|
||||
* — which are exactly what someone browsing repos on gitworkshop /
|
||||
* ngit tends to remember.
|
||||
*
|
||||
* The query is split on whitespace so `"amethyst nostr"` requires each
|
||||
* term to appear in at least one indexed field of the same repository.
|
||||
* Every term match is case-insensitive.
|
||||
*
|
||||
* Indexed fields:
|
||||
* - repo name (`name` tag)
|
||||
* - repo identifier (`d` tag; what appears in the ngit URL path)
|
||||
* - description
|
||||
* - hashtags/topics (`t` tags)
|
||||
* - clone URLs (`clone` tag values)
|
||||
* - web URLs (`web` tag values)
|
||||
* - relay URLs the maintainers listen on (`relays` tag values)
|
||||
* - maintainer pubkeys (both hex and NIP-19 `npub…` form)
|
||||
* - repo author pubkey (hex and npub)
|
||||
* - earliest-unique-commit hash (`r … euc`) — lets you paste a commit
|
||||
* hash from a nostr:naddr and land on the repo
|
||||
*/
|
||||
object GitRepositorySearchMatcher {
|
||||
/**
|
||||
* @return `true` when [event] matches every whitespace-separated term
|
||||
* in [query]. An empty query matches nothing (callers should skip the
|
||||
* filter path in that case).
|
||||
*/
|
||||
fun matches(
|
||||
event: GitRepositoryEvent,
|
||||
query: String,
|
||||
): Boolean {
|
||||
val terms = query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
if (terms.isEmpty()) return false
|
||||
|
||||
val haystack = buildHaystack(event)
|
||||
return terms.all { term ->
|
||||
val needle = term.lowercase()
|
||||
// Support "npub1…" queries by resolving them to hex; the hex
|
||||
// form is already in the haystack via authorNpubs / dTag /
|
||||
// maintainers.
|
||||
val hexFromBech32 = tryDecodeNpubToHex(needle)
|
||||
haystack.any { field -> field.contains(needle) } ||
|
||||
(hexFromBech32 != null && haystack.any { field -> field.contains(hexFromBech32) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildHaystack(event: GitRepositoryEvent): List<String> {
|
||||
val out = ArrayList<String>(16)
|
||||
event.name()?.lowercase()?.let(out::add)
|
||||
event
|
||||
.dTag()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.lowercase()
|
||||
?.let(out::add)
|
||||
event.description()?.lowercase()?.let(out::add)
|
||||
event.hashtags().forEach { out.add(it.lowercase()) }
|
||||
event.clones().forEach { out.add(it.lowercase()) }
|
||||
event.webs().forEach { out.add(it.lowercase()) }
|
||||
event.relays().forEach { out.add(it.lowercase()) }
|
||||
// Maintainers as hex + npub. Author is an implicit maintainer per
|
||||
// NIP-34, so include it in both forms too.
|
||||
val authors = HashSet<String>()
|
||||
authors.add(event.pubKey)
|
||||
authors.addAll(event.maintainers())
|
||||
authors.forEach { hex ->
|
||||
out.add(hex.lowercase())
|
||||
hexToNpub(hex)?.let { out.add(it.lowercase()) }
|
||||
}
|
||||
event.earliestUniqueCommit()?.lowercase()?.let(out::add)
|
||||
return out
|
||||
}
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
private fun tryDecodeNpubToHex(candidate: String): String? {
|
||||
if (!candidate.startsWith("npub1")) return null
|
||||
return runCatching {
|
||||
when (val parsed = Nip19Parser.uriToRoute(candidate)?.entity) {
|
||||
is NPub -> parsed.hex
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun hexToNpub(hex: String): String? = runCatching { NPub.create(hex) }.getOrNull()
|
||||
}
|
||||
+2
-2
@@ -118,7 +118,7 @@ class BroadcastTracker {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
@@ -294,7 +294,7 @@ class BroadcastTracker {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
/**
|
||||
* The short label a user recognises for a distributable file type — "APK", not
|
||||
* "application/vnd.android.package-archive".
|
||||
*
|
||||
* Unmapped types return the raw MIME unchanged, which is the honest fallback: a bare
|
||||
* `application/x-webxdc` still tells the reader more than an invented label would.
|
||||
*
|
||||
* Used by the NIP-82 software-app chips and by the file-attachment card that stands in for any
|
||||
* blob no viewer can render.
|
||||
*/
|
||||
fun prettyMime(mime: String): String =
|
||||
when (mime) {
|
||||
"application/vnd.android.package-archive" -> "APK"
|
||||
"application/vnd.apple.ipa" -> "IPA"
|
||||
"application/x-apple-diskimage" -> "DMG"
|
||||
"application/vnd.apple.installer+xml" -> "PKG"
|
||||
"application/x-msi" -> "MSI"
|
||||
"application/vnd.appimage" -> "AppImage"
|
||||
"application/vnd.flatpak" -> "Flatpak"
|
||||
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
|
||||
"application/x-executable" -> "ELF"
|
||||
"application/x-mach-binary" -> "Mach-O"
|
||||
"application/vnd.microsoft.portable-executable" -> "EXE"
|
||||
"application/vsix" -> "VSIX"
|
||||
"application/x-chrome-extension" -> "CRX"
|
||||
"application/x-xpinstall" -> "XPI"
|
||||
"application/wasm" -> "WASM"
|
||||
"application/webbundle" -> "Web Bundle"
|
||||
else -> mime
|
||||
}
|
||||
+2
-2
@@ -371,7 +371,7 @@ class OutboxDispatcher(
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -415,7 +415,7 @@ class OutboxDispatcher(
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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.richtext
|
||||
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ClassifyMediaTest {
|
||||
@Test
|
||||
fun webxdcAppIsNotMedia() {
|
||||
// Regression: a NIP-94 header for a webxdc app (a zip bundle) used to reach the
|
||||
// ExoPlayer branch, because the only test was `isImage` and everything else fell
|
||||
// through to video. https://blossom.ditto.pub/<sha256>.xdc, m=application/x-webxdc
|
||||
assertNull(
|
||||
RichTextParser.classifyMedia(
|
||||
"https://blossom.ditto.pub/d810ba7873d710b197fc402c0573cd95ce7d44fff7f904e8f58e48af3a47c107.xdc",
|
||||
"application/x-webxdc",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownTypesAreNotMedia() {
|
||||
assertNull(RichTextParser.classifyMedia("https://x.com/app.apk", "application/vnd.android.package-archive"))
|
||||
assertNull(RichTextParser.classifyMedia("https://x.com/archive.zip", "application/zip"))
|
||||
assertNull(RichTextParser.classifyMedia("https://x.com/notes.txt", "text/plain"))
|
||||
// No mime at all and an extension we don't render.
|
||||
assertNull(RichTextParser.classifyMedia("https://x.com/file.xdc", null))
|
||||
assertNull(RichTextParser.classifyMedia("https://x.com/no-extension-at-all", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun declaredMimeTypesClassify() {
|
||||
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a", "image/png"))
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "video/mp4"))
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "audio/mpeg"))
|
||||
assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/a", "application/pdf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hlsPlaylistMimesAreVideo() {
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "application/vnd.apple.mpegurl"))
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "application/x-mpegURL"))
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "audio/mpegurl"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extensionIsUsedWhenMimeIsAbsent() {
|
||||
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", null))
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.mp4", null))
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.m3u8", null))
|
||||
assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/a.pdf", null))
|
||||
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.PNG", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeclaredMimeBeatsAContradictingExtension() {
|
||||
// The check this replaced was an OR — `mime.startsWith("image/") || isImageUrl(url)` —
|
||||
// so a poster-named video URL classified as an image. A declared MIME is the publisher
|
||||
// stating the type; the extension is only a guess for when they didn't. Pins the
|
||||
// precedence against a future "simplification" back to OR-semantics.
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/thumb.jpg", "video/mp4"))
|
||||
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/clip.mp4", "image/png"))
|
||||
assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/scan.png", "application/pdf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnrecognisedMimeDefersToTheExtensionRatherThanVetoingIt() {
|
||||
// Precedence applies only to MIMEs we recognise. An unrecognised one means "no usable
|
||||
// declaration", not "declared unrenderable" — the two are indistinguishable here, and
|
||||
// treating them alike is what lets [extensionRescuesAMalformedMime] work. So a real
|
||||
// video mislabelled `application/x-webxdc` still plays…
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/bundle.mp4", "application/x-webxdc"))
|
||||
// …while the webxdc app that motivated this class stays unrenderable, because nothing
|
||||
// rescues it: `.xdc` is in no extension list either.
|
||||
assertNull(RichTextParser.classifyMedia("https://x.com/bundle.xdc", "application/x-webxdc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extensionRescuesAMalformedMime() {
|
||||
// Primal iOS emits `m jpeg` instead of `m image/jpeg`; the extension must still win
|
||||
// over "unknown". Preserves the behaviour createMediaContent already documented.
|
||||
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", "jpeg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queryStringsAndFragmentsAreStripped() {
|
||||
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg?token=1", null))
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.mp4#t=10", null))
|
||||
assertNull(RichTextParser.classifyMedia("https://x.com/a.xdc?token=1", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dataUrisAreClassifiedByTheirPrefixOnly() {
|
||||
assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("data:image/png;base64,AAAA", null))
|
||||
assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("data:video/mp4;base64,AAAA", null))
|
||||
assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("data:application/pdf;base64,AAAA", null))
|
||||
// A data: URI carries its type in the prefix, so a miss there is genuine — the
|
||||
// payload must never be extension-probed (base64 can end in any letters).
|
||||
assertNull(RichTextParser.classifyMedia("data:application/zip;base64,AAAAmp4", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifyMediaAgreesWithCreateMediaContent() {
|
||||
// createMediaContent is the long-standing reference for this decision; the two must
|
||||
// not drift, since half the renderers call one and half the other.
|
||||
val cases =
|
||||
listOf(
|
||||
"https://x.com/a.jpg" to null,
|
||||
"https://x.com/a" to "image/png",
|
||||
"https://x.com/a" to "video/mp4",
|
||||
"https://x.com/a" to "audio/mpeg",
|
||||
"https://x.com/a" to "application/pdf",
|
||||
"https://x.com/a" to "application/vnd.apple.mpegurl",
|
||||
"https://x.com/a.xdc" to "application/x-webxdc",
|
||||
"https://x.com/a.zip" to "application/zip",
|
||||
"https://x.com/a.jpg" to "jpeg",
|
||||
"data:image/png;base64,AAAA" to null,
|
||||
"data:application/zip;base64,AAAAmp4" to null,
|
||||
)
|
||||
|
||||
cases.forEach { (url, mime) ->
|
||||
val tags = mime?.let { mapOf(url to imeta(url, it)) } ?: emptyMap()
|
||||
val expected =
|
||||
when (RichTextParser().createMediaContent(url, tags, null)) {
|
||||
is MediaUrlImage -> MediaContentKind.IMAGE
|
||||
is MediaUrlVideo -> MediaContentKind.VIDEO
|
||||
is MediaUrlPdf -> MediaContentKind.PDF
|
||||
null -> null
|
||||
else -> error("unexpected content type for $url / $mime")
|
||||
}
|
||||
|
||||
assertEquals<MediaContentKind?>(expected, RichTextParser.classifyMedia(url, mime), "disagreement on $url / $mime")
|
||||
}
|
||||
}
|
||||
|
||||
private fun imeta(
|
||||
url: String,
|
||||
mimeType: String,
|
||||
) = IMetaTag(url = url, properties = mapOf("m" to listOf(mimeType)))
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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.search
|
||||
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Pins the ngit-repository-relevant search matcher used by
|
||||
* `GitRepositoriesScreen`. Every field the matcher promises to index is
|
||||
* exercised once here so a future refactor that drops one (e.g. relays)
|
||||
* shows up as a red test.
|
||||
*/
|
||||
class GitRepositorySearchMatcherTest {
|
||||
private val ownerHex = "aa".repeat(32)
|
||||
private val maintainerHex = "bb".repeat(32)
|
||||
private val ownerNpub = NPub.create(ownerHex)
|
||||
private val maintainerNpub = NPub.create(maintainerHex)
|
||||
|
||||
private fun repo(
|
||||
name: String = "amethyst",
|
||||
dTag: String = "amethyst",
|
||||
description: String? = "A Nostr client for Android",
|
||||
clones: List<String> = listOf("https://github.com/vitorpamplona/amethyst.git"),
|
||||
webs: List<String> = listOf("https://amethyst.social"),
|
||||
relays: List<String> = listOf("wss://relay.ngit.dev"),
|
||||
maintainers: List<String> = listOf(maintainerHex),
|
||||
hashtags: List<String> = listOf("nostr", "android"),
|
||||
euc: String? = "99614f07e4ffa99dff4143d7457be8923690bbba",
|
||||
pubKey: String = ownerHex,
|
||||
): GitRepositoryEvent {
|
||||
val tags = mutableListOf<Array<String>>()
|
||||
tags += arrayOf("d", dTag)
|
||||
tags += arrayOf("name", name)
|
||||
description?.let { tags += arrayOf("description", it) }
|
||||
clones.forEach { tags += arrayOf("clone", it) }
|
||||
webs.forEach { tags += arrayOf("web", it) }
|
||||
if (relays.isNotEmpty()) tags += arrayOf("relays", *relays.toTypedArray())
|
||||
if (maintainers.isNotEmpty()) tags += arrayOf("maintainers", *maintainers.toTypedArray())
|
||||
hashtags.forEach { tags += arrayOf("t", it) }
|
||||
euc?.let { tags += arrayOf("r", it, "euc") }
|
||||
return GitRepositoryEvent(
|
||||
id = "00".repeat(32),
|
||||
pubKey = pubKey,
|
||||
createdAt = 0L,
|
||||
tags = tags.toTypedArray(),
|
||||
content = "",
|
||||
sig = "00",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyQueryMatchesNothing() {
|
||||
// Callers must skip the filter path themselves — the matcher is
|
||||
// conservative and refuses to accept an empty term list.
|
||||
assertFalse(GitRepositorySearchMatcher.matches(repo(), ""))
|
||||
assertFalse(GitRepositorySearchMatcher.matches(repo(), " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesRepoNameCaseInsensitive() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(name = "Amethyst"), "amethyst"))
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(name = "Amethyst"), "AMET"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesRepoIdentifierDTag() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(dTag = "ngit-cli"), "ngit-cli"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesDescription() {
|
||||
assertTrue(
|
||||
GitRepositorySearchMatcher.matches(
|
||||
repo(description = "A private Nostr messenger"),
|
||||
"messenger",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesHashtag() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(hashtags = listOf("kotlin", "mobile")), "kotlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesCloneUrl() {
|
||||
assertTrue(
|
||||
GitRepositorySearchMatcher.matches(
|
||||
repo(clones = listOf("https://relay.ngit.dev/npub1abc/foo.git")),
|
||||
"relay.ngit.dev",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesWebUrl() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(webs = listOf("https://gitworkshop.dev/x")), "gitworkshop"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesRelayHost() {
|
||||
assertTrue(
|
||||
GitRepositorySearchMatcher.matches(
|
||||
repo(relays = listOf("wss://relay.damus.io")),
|
||||
"damus.io",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesMaintainerHex() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(), maintainerHex))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesMaintainerNpub() {
|
||||
// The `bb…` npub is a valid bech32 pubkey; supplying it as a
|
||||
// query must resolve to the same hex the tag carries.
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(), maintainerNpub))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesAuthorHex() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(), ownerHex))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesAuthorNpub() {
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(), ownerNpub))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesEarliestUniqueCommit() {
|
||||
// Full euc must match; a prefix that lives inside it should too.
|
||||
assertTrue(GitRepositorySearchMatcher.matches(repo(euc = "99614f07e4ff"), "99614f07"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleTermsMustAllMatch() {
|
||||
val target = repo(name = "amethyst", hashtags = listOf("nostr", "android"))
|
||||
assertTrue(GitRepositorySearchMatcher.matches(target, "amethyst android"))
|
||||
// "kotlin" isn't in this repo's fields, so the AND fails.
|
||||
assertFalse(GitRepositorySearchMatcher.matches(target, "amethyst kotlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidNpubTreatedAsRawText() {
|
||||
// "npub1notreallybech32" is not a decodable npub; the matcher
|
||||
// should still let it match as a raw substring of e.g. the
|
||||
// description, without throwing.
|
||||
val target = repo(description = "npub1notreallybech32 is a placeholder")
|
||||
assertTrue(GitRepositorySearchMatcher.matches(target, "npub1notreallybech32"))
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -90,7 +90,7 @@ class ChessEventBroadcaster(
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List<Filter
|
||||
forward(relay, forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ class RelayLatencyListener(
|
||||
tracker.recordSent(relay.url, cmd, success)
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ class DmInboxRelayResolverOutboxTest {
|
||||
filterList.forEach { filter ->
|
||||
filter.kinds?.forEach { kind ->
|
||||
script[kind to relay]?.forEach { event ->
|
||||
listener?.onEvent(event, isLive = false, relay = relay, forFilters = null)
|
||||
kotlinx.coroutines.runBlocking { listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -171,7 +171,7 @@ class OutboxDispatcherTest {
|
||||
filterList.forEach { filter ->
|
||||
filter.kinds?.forEach { kind ->
|
||||
script[kind to relay]?.forEach { event ->
|
||||
listener?.onEvent(event, isLive = false, relay = relay, forFilters = null)
|
||||
kotlinx.coroutines.runBlocking { listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1786,7 +1786,7 @@ fun MainContent(
|
||||
filters = listOf(filter),
|
||||
listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -1845,7 +1845,7 @@ fun MainContent(
|
||||
relays = outbox,
|
||||
listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -249,7 +249,7 @@ class AccountManager internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ class FollowPacksState(
|
||||
private fun subscribeToDiscovery() {
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ fun RelayConnectionManager.subscribeMetadataFor(
|
||||
val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = pubkeys)
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ fun FromThePackFeed(
|
||||
listOf(filter),
|
||||
listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ fun RenderFollowPackCard(
|
||||
listOf(filter),
|
||||
listener =
|
||||
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
isLive: Boolean,
|
||||
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user