1 Commits

Author SHA1 Message Date
0e87c22e55 WIP proof of concept to add localization using fluent-rs 2024-12-05 11:48:03 -05:00
351 changed files with 15671 additions and 46838 deletions

9
.envrc
View File

@@ -1,7 +1,6 @@
# set to false if you don't care to include android stuff
export use_android=true
export android_emulator=false
export ANDROID_DIR=crates/notedeck_chrome/android
use nix --arg use_android $use_android --arg android_emulator $android_emulator
@@ -14,8 +13,6 @@ source scripts/macos_build_secrets.sh || :
export PATH=$PATH:$HOME/.cargo/bin
export JB55=32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
export OLLAMA_HOST=http://ollama.jb55.com
# simple todo reminders
export TODO_FILE=TODO
2>/dev/null todo.sh ls || :
export JACK=npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m
export VROD=npub1h50pnxqw9jg7dhr906fvy4mze2yzawf895jhnc3p7qmljdugm6gsrurqev
export JEFFG=npub1zuuajd7u3sx8xu92yav9jwxpr839cs0kc3q6t56vd5u9q033xmhsk6c2uc

View File

@@ -22,5 +22,8 @@ jobs:
if: ${{ inputs.additional-setup != '' }}
run: ${{ inputs.additional-setup }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Run Tests (Native Only)
run: cargo test

View File

@@ -1,33 +0,0 @@
name: Crowdin Download Translations
on:
repository_dispatch:
types: [ crowdin-translation-complete ]
permissions:
contents: write
pull-requests: write
jobs:
crowdin-download:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Crowdin download translations
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
localization_branch_name: crowdin-translations
create_pull_request: true
pull_request_title: 'Crowdin Translations'
pull_request_body: 'Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'master'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -1,67 +0,0 @@
name: Crowdin Upload & Sync
on:
push:
branches: [ master ]
permissions:
contents: write
pull-requests: write
jobs:
crowdin-upload:
runs-on: ubuntu-latest
steps:
- name: Fetch crowdin-translations branch
run: |
git fetch origin crowdin-translations:crowdin-translations || true
- name: Checkout crowdin-translations branch
run: git checkout crowdin-translations || git checkout -b crowdin-translations
- name: Rebase master onto crowdin-translations
run: git rebase master
- name: Fail if rebase conflicts occurred
run: |
if [ -d .git/rebase-merge ] || [ -d .git/rebase-apply ]; then
echo "❌ Rebase conflict detected! Please resolve conflicts in the crowdin-translations branch manually."
exit 1
fi
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Run export_source_strings.py
run: python3 scripts/export_source_strings.py
- name: Check for changes in main.ftl
id: check_diff
run: |
git diff --quiet assets/translations/en-US/main.ftl assets/translations/en-XA/main.ftl || echo "changed=true" >> $GITHUB_OUTPUT
- name: Commit changes to crowdin-translations
if: steps.check_diff.outputs.changed == 'true'
run: |
git add assets/translations/en-US/main.ftl assets/translations/en-XA/main.ftl
git commit -m "Update source strings from export_source_strings.py" || true
git push --force origin crowdin-translations
- name: Crowdin upload sources
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
localization_branch_name: crowdin-translations
create_pull_request: true
pull_request_title: 'Crowdin Translations'
pull_request_body: 'Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'master'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -10,45 +10,31 @@ on:
- "*"
jobs:
lint:
name: Rustfmt + Clippy
runs-on: ubuntu-22.04
fmt:
name: Rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt,clippy
- run: |
cargo fmt --all -- --check
cargo clippy
components: rustfmt
- run: cargo fmt --all -- --check
android:
name: Check (android)
runs-on: ubuntu-22.04
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt,clippy
- name: Setup Java JDK
uses: actions/setup-java@v4.5.0
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Add android rust target
run: rustup target add aarch64-linux-android
- name: Install Cargo NDK
run: cargo install cargo-ndk
- name: Run tests
run: make jni-check
components: clippy
- run: cargo clippy -- -D warnings
linux-test:
name: Test (Linux)
uses: ./.github/workflows/build-and-test.yml
with:
os: ubuntu-22.04
os: ubuntu-latest
additional-setup: |
sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libspeechd-dev libxkbcommon-dev libssl-dev
@@ -66,7 +52,7 @@ jobs:
packaging:
name: rpm/deb
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
needs: linux-test
if: github.ref_name == 'master' || github.ref_name == 'ci'
@@ -90,6 +76,9 @@ jobs:
fi
cargo install cargo-generate-rpm cargo-deb
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Build Cross (${{ matrix.arch }})
if: matrix.arch != runner.arch
run: cargo build --release --target=${{ matrix.arch }}-unknown-linux-gnu
@@ -100,19 +89,19 @@ jobs:
- name: Build RPM (Cross)
if: matrix.arch != runner.arch
run: cargo generate-rpm -p crates/notedeck_chrome --target=${{ matrix.arch }}-unknown-linux-gnu
run: cargo generate-rpm --target=${{ matrix.arch }}-unknown-linux-gnu
- name: Build RPM
if: matrix.arch == runner.arch
run: cargo generate-rpm -p crates/notedeck_chrome
run: cargo generate-rpm
- name: Build deb (Cross)
if: matrix.arch != runner.arch
run: cargo deb -p notedeck_chrome --target=${{ matrix.arch }}-unknown-linux-gnu
run: cargo deb --target=${{ matrix.arch }}-unknown-linux-gnu
- name: Build deb
if: matrix.arch == runner.arch
run: cargo deb -p notedeck_chrome
run: cargo deb
- name: Upload RPM
uses: actions/upload-artifact@v4
@@ -189,15 +178,10 @@ jobs:
path: packages/notedeck-${{ matrix.arch }}.dmg
windows-installer:
name: Windows Installer
name: Build Windows Installer (x86_64)
runs-on: windows-latest
needs: windows-test
if: github.ref_name == 'master' || github.ref_name == 'ci'
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
steps:
# Checkout the repository
- name: Checkout Code
@@ -219,91 +203,19 @@ jobs:
- name: Install Inno Setup
run: choco install innosetup --no-progress --yes
# Set up Rust toolchain
- name: Install Rust toolchain
run: rustup target add ${{ matrix.arch }}-pc-windows-msvc
# Build
- name: Build
shell: pwsh
run: |
$target = "${{ matrix.arch }}-pc-windows-msvc"
Write-Output "Building for target: $target"
cargo build --release --target=$target
# Generate ISS Script
- name: Generate Inno Setup Script
shell: pwsh
run: |
$arch = "${{ matrix.arch }}"
$issContent = @"
[Setup]
AppName=Damus Notedeck
AppVersion=0.1
DefaultDirName={pf}\Notedeck
DefaultGroupName=Damus Notedeck
OutputDir=..\packages\$arch
OutputBaseFilename=DamusNotedeckInstaller
Compression=lzma
SolidCompression=yes
[Files]
Source: "..\target\$arch-pc-windows-msvc\release\notedeck.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\Damus Notedeck"; Filename: "{app}\notedeck.exe"
[Run]
Filename: "{app}\notedeck.exe"; Description: "Launch Damus Notedeck"; Flags: nowait postinstall skipifsilent
"@
Set-Content -Path "scripts/windows-installer-$arch.iss" -Value $issContent
# Build Installer
- name: Run Inno Setup Script
run: |
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "scripts\windows-installer-${{ matrix.arch }}.iss"
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "scripts\windows-installer.iss"
# List outputs
- name: List Inno Script outputs
run: dir packages
# Move output
- name: Move Inno Script outputs to architecture-specific folder
run: |
New-Item -ItemType Directory -Force -Path packages\${{ matrix.arch }}
Move-Item -Path packages\${{ matrix.arch }}\DamusNotedeckInstaller.exe -Destination packages\${{ matrix.arch }}\DamusNotedeckInstaller.exe
# Upload the installer as an artifact
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
name: DamusNotedeckInstaller-${{ matrix.arch }}.exe
path: packages\${{ matrix.arch }}\DamusNotedeckInstaller.exe
upload-artifacts:
name: Upload Artifacts to Server
runs-on: ubuntu-22.04
needs: [packaging, macos-dmg, windows-installer]
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/ci'
steps:
- name: Download all Artifacts
uses: actions/download-artifact@v4
- name: Setup SSH and Upload
run: |
eval "$(ssh-agent -s)"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "${{ secrets.DEPLOY_SFTP_KEY }}" | tr -d '\r' | ssh-add -
echo "${{ secrets.DEPLOY_IP }} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN65pj1cNMqlf96jZLr1i9+mnHIN4jjRPPTDix6sRnt" >> ~/.ssh/known_hosts
ls -la /home/runner/work/notedeck/notedeck/notedeck-x86_64.rpm
export ARTIFACTS=/home/runner/work/notedeck/notedeck
sftp ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_IP }} <<EOF
cd upload/artifacts
put $ARTIFACTS/notedeck-x86_64.rpm/*
put $ARTIFACTS/notedeck-x86_64.deb/*
put $ARTIFACTS/notedeck-x86_64.dmg/*
put $ARTIFACTS/notedeck-aarch64.rpm/*
put $ARTIFACTS/notedeck-aarch64.deb/*
put $ARTIFACTS/notedeck-aarch64.dmg/*
put $ARTIFACTS/DamusNotedeckInstaller-x86_64.exe/*
put $ARTIFACTS/DamusNotedeckInstaller-aarch64.exe/*
bye
EOF
name: DamusNotedeckInstaller.exe
path: packages\DamusNotedeckInstaller.exe

20
.gitignore vendored
View File

@@ -1,21 +1,17 @@
.DS_Store
.build-result
.buildcmd
TODO.bak
android-config.json
logcat.txt
build.log
rusty-tags.vi
crates/notedeck_chrome/android/app/build
perf.data
perf.data.old
.privenv
*.so
*.swp
*.jar
target
.gradle
queries/damus-notifs.json
.git
cache
/dist
/packages
.direnv/
src/camera.rs
scripts/macos_build_secrets.sh
*.patch
*.txt
/tags
*.mdb

View File

@@ -1,68 +0,0 @@
# Notedeck Beta - v0.4 - 2025-05-05
# Added
- Dave nostr ai assistant app
- GIFs!
- Fulltext note search
- Add full screen images, add zoom & pan
- Zaps! NWC/ Wallet ui
- Introduce last note per pubkey feed (experimental)
- Allow multiple media uploads per selection
- Major Android improvements (still wip)
- Added notedeck app sidebar
- User Tagging
- Note truncation
- Local network note broadcast, broadcast notes to other notedeck notes while you're offline
- Mute list support (reading)
- Relay list support
- Ctrl-enter to send notes
- Added relay indexing (relay columns soon)
- Click hashtags to open hashtag timeline
# Fixed
- Fix timelines sometimes not updating (stale feeds)
- Fix ui bounciness when loading profile pictures
- Fix unselectable post replies
# Notedeck Alpha 2 - v0.3 - 2025-01-31
## Added
- Clicking a mention now opens profile page (William Casarin)
- Note previews when hovering reply descriptions (William Casarin)
- Media uploads (kernelkind)
- Profile editing (kernelkind)
- Add hashtags to posts (Daniel Saxton)
- Enhanced command-line interface for user interactions (Ken Sedgwick)
- Various Android updates and compatibility improvements (Ken Sedgwick, William Casarin)
- Debug features for user relay-list and mute list synchronization (Ken Sedgwick)
## Changed
- Add confirmation when deleting columns (kernelkind)
- Enhance Android build and performance (Ken Sedgwick)
- Image cache handling using sha256 hash (kieran)
- Introduction of decks_cache and improvements (kernelkind)
- Migrated to egui v0.29.1 (William Casarin)
- Only show column delete button when not navigating (William Casarin)
- Show profile pictures in column headers (William Casarin)
- Show usernames in user columns (William Casarin)
- Switch to only notes & replies on some tabs (William Casarin)
- Tombstone muted notes (Ken)
- Pointer interactions enhancements in UI (William Casarin)
- Persistent theme setup across sessions (kernelkind)
- Increased ping intervals for network performance (William Casarin)
- Nostrdb update for async support (Ken Sedgwick)
## Fixed
- Fix GIT_COMMIT_HASH compilation issue (William Casarin)
- Fix avatar alignment in profile previews (William Casarin)
- Fix broken quote repost hitbox (William Casarin)
- Fix crash when navigating in debug mode (William Casarin)
- Fix long delays when reconnecting (William Casarin)
- Fix repost button size (William Casarin)
- Fixed since kind filters (kernelkind)
- Clippy warnings resolved (Dimitris Apostolou)
## Refactoring & Improvements
- Numerous internal structural improvements and modularization (William Casarin, Ken Sedgwick)

4655
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +1,78 @@
[workspace]
resolver = "2"
package.version = "0.5.6"
members = [
"crates/notedeck",
"crates/notedeck_chrome",
"crates/notedeck_columns",
"crates/notedeck_dave",
"crates/notedeck_ui",
[package]
name = "notedeck"
version = "0.2.0"
authors = ["William Casarin <jb55@jb55.com>"]
edition = "2021"
default-run = "notedeck"
#rust-version = "1.60"
license = "GPLv3"
description = "A multiplatform nostr client"
"crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui",
]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["lib", "cdylib"]
[workspace.dependencies]
base32 = "0.4.0"
base64 = "0.22.1"
rmpv = "1.3.0"
bech32 = { version = "0.11", default-features = false }
bitflags = "2.5.0"
dirs = "5.0.1"
eframe = { version = "0.31.1", default-features = false, features = [ "wgpu", "wayland", "x11", "android-game-activity" ] }
egui = { version = "0.31.1", features = ["serde"] }
egui-wgpu = "0.31.1"
egui_extras = { version = "0.31.1", features = ["all_loaders"] }
egui-winit = { version = "0.31.1", features = ["android-game-activity", "clipboard"] }
egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "111de8ac40b5d18df53e9691eb18a50d49cb31d8" }
egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "6eb91740577b374a8a6658c09c9a4181299734d0" }
#egui_virtual_list = "0.6.0"
egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" }
ehttp = "0.5.0"
enostr = { path = "crates/enostr" }
ewebsock = { version = "0.2.0", features = ["tls"] }
fluent = "0.17.0"
fluent-resmgr = "0.0.8"
fluent-langneg = "0.13"
hex = "0.4.3"
egui = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", features = ["serde"] }
eframe = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "eframe", default-features = false, features = [ "wgpu", "wayland", "x11", "android-native-activity" ] }
egui_extras = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "egui_extras", features = ["all_loaders"] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "71154e4100775f6932ee517da4350c433ba14ec7" }
[dependencies]
#egui-android = { git = "https://github.com/jb55/egui-android.git" }
egui = { workspace = true }
eframe = { workspace = true }
egui_extras = { workspace = true }
ehttp = "0.2.0"
egui_tabs = { git = "https://github.com/damus-io/egui-tabs", branch = "egui-0.28" }
egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "fd0900bdff4be35709372e921f2b49f68b261469" }
egui_virtual_list = { git = "https://github.com/jb55/hello_egui", branch = "egui-0.28", package = "egui_virtual_list" }
reqwest = { version = "0.12.4", default-features = false, features = [ "rustls-tls-native-roots" ] }
image = { version = "0.25", features = ["jpeg", "png", "webp"] }
indexmap = "2.6.0"
log = "0.4.17"
md5 = "0.7.0"
nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49"] }
nwc = "0.39.0"
mio = { version = "1.0.3", features = ["os-poll", "net"] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "a307f5d3863b5319c728b2782959839b8df544cb" }
#nostrdb = "0.6.1"
notedeck = { path = "crates/notedeck" }
notedeck_chrome = { path = "crates/notedeck_chrome" }
notedeck_columns = { path = "crates/notedeck_columns" }
notedeck_dave = { path = "crates/notedeck_dave" }
notedeck_ui = { path = "crates/notedeck_ui" }
tokenator = { path = "crates/tokenator" }
once_cell = "1.19.0"
open = "5.3.0"
poll-promise = { version = "0.3.0", features = ["tokio"] }
puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
puffin_egui = { git = "https://github.com/jb55/puffin", package = "puffin_egui", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
serde = { version = "1", features = ["derive"] } # You only need this if you want app persistence
serde_derive = "1"
serde = { version = "1", features = ["derive"] } # You only need this if you want app persistence
tracing = "0.1.40"
#wasm-bindgen = "0.2.83"
nostrdb = { workspace = true }
#nostrdb = { path = "/Users/jb55/dev/github/damus-io/nostrdb-rs" }
#nostrdb = "0.3.4"
enostr = { path = "enostr" }
serde_json = "1.0.89"
env_logger = "0.10.0"
puffin_egui = { version = "0.27.0", optional = true }
puffin = { version = "0.19.0", optional = true }
hex = "0.4.3"
base32 = "0.4.0"
strum = "0.26"
strum_macros = "0.26"
thiserror = "2.0.7"
tokio = { version = "1.16", features = ["macros", "rt-multi-thread", "fs"] }
tracing = { version = "0.1.40", features = ["log"] }
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tempfile = "3.13.0"
unic-langid = { version = "0.9.6", features = ["macros"] }
url = "2.5.2"
urlencoding = "2.1.3"
bitflags = "2.5.0"
uuid = { version = "1.10.0", features = ["v4"] }
sha2 = "0.10.8"
bincode = "1.3.3"
mime_guess = "2.0.5"
pretty_assertions = "1.4.1"
jni = "0.21.1"
profiling = "1.0"
lightning-invoice = "0.33.1"
secp256k1 = "0.30.0"
hashbrown = "0.15.2"
openai-api-rs = "6.0.3"
re_memory = "0.23.4"
oot_bitset = "0.1.1"
indexmap = "2.6.0"
dirs = "5.0.1"
tracing-appender = "0.2.3"
urlencoding = "2.1.3"
open = "5.3.0"
url = "2.5"
fluent-resmgr = "0.0.7"
fluent-fallback = "0.7.1"
fluent = "0.16.1"
unic-langid = "0.9.5"
[dev-dependencies]
tempfile = "3.13.0"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "2.11.0"
[features]
default = []
profiling = ["puffin", "puffin_egui", "eframe/puffin"]
debug-widget-callstack = ["egui/callstack"]
debug-interactive-widgets = []
[profile.small]
inherits = 'release'
@@ -90,23 +82,72 @@ codegen-units = 1 # Reduce number of codegen units to increase optimizations
panic = 'abort' # Abort on panic
strip = true # Strip symbols from binary*
# web:
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1.6"
tracing-wasm = "0.2"
wasm-bindgen-futures = "0.4"
# native:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1.16", features = ["macros", "rt-multi-thread", "fs"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.11.1"
android-activity = { version = "0.4", features = [ "native-activity" ] }
#winit = "0.28.6"
winit = { version = "0.29", features = [ "android-native-activity" ] }
#winit = { git="https://github.com/rust-windowing/winit.git", rev = "2a58b785fed2a3746f7c7eebce95bce67ddfd27c", features = ["android-native-activity"] }
[package.metadata.bundle]
identifier = "com.damus.notedeck"
icon = ["assets/app_icon.icns"]
[package.metadata.android]
package = "com.damus.app"
apk_name = "damus"
#assets = "assets"
[[package.metadata.android.uses_feature]]
name = "android.hardware.vulkan.level"
required = true
version = 1
[[package.metadata.android.uses_permission]]
name = "android.permission.WRITE_EXTERNAL_STORAGE"
max_sdk_version = 18
[[package.metadata.android.uses_permission]]
name = "android.permission.READ_EXTERNAL_STORAGE"
max_sdk_version = 18
[package.metadata.android.signing.release]
path = "damus.keystore"
keystore_password = "damuskeystore"
[[package.metadata.android.uses_permission]]
name = "android.permission.INTERNET"
[package.metadata.android.application]
label = "Damus"
[package.metadata.generate-rpm]
assets = [
{ source = "target/release/notedeck", dest = "/usr/bin/notedeck", mode = "755" },
]
[[bin]]
name = "notedeck"
path = "src/bin/notedeck.rs"
[[bin]]
name = "ui_preview"
path = "src/ui_preview/main.rs"
[patch.crates-io]
#egui = { path = "/home/jb55/dev/github/emilk/egui/crates/egui" }
#eframe = { path = "/home/jb55/dev/github/emilk/egui/crates/eframe" }
#egui-winit = { path = "/home/jb55/dev/github/emilk/egui/crates/egui-winit" }
#egui_extras = { path = "/home/jb55/dev/github/emilk/egui/crates/egui_extras" }
#epaint = { path = "/home/jb55/dev/github/emilk/egui/crates/epaint" }
egui = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" }
eframe = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" }
egui-winit = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" }
egui-wgpu = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" }
egui_extras = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" }
epaint = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" }
puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
puffin_egui = { git = "https://github.com/jb55/puffin", package = "puffin_egui", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
#winit = { git = "https://github.com/damus-io/winit", rev = "14d61a74bee0c9863abe7ef28efae2c4d8bd3743" }
#winit = { path = "/home/jb55/dev/github/rust-windowing/winit" }
android-activity = { git = "https://github.com/damus-io/android-activity", rev = "a8948332c7c551303d32eb26a59d0abd676e47a5" }
#android-activity = { path = "/home/jb55/dev/github/rust-mobile/android-activity/android-activity" }
egui = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb" }
eframe = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "eframe" }
emath = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "emath" }
egui_extras = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "egui_extras" }

View File

@@ -1,30 +1,8 @@
.DEFAULT_GOAL := check
.PHONY: fake
ANDROID_DIR := crates/notedeck_chrome/android
check:
all:
cargo check
tags: fake
rusty-tags vi
find . -type d -name target -prune -o -type f -name '*.rs' -print | xargs ctags
jni: fake
cargo ndk --target arm64-v8a -o $(ANDROID_DIR)/app/src/main/jniLibs/ build --profile release
jni-check: fake
cargo ndk --target arm64-v8a check
apk: jni
cd $(ANDROID_DIR) && ./gradlew build
gradle:
cd $(ANDROID_DIR) && ./gradlew build
push-android-config:
adb push android-config.json /sdcard/Android/data/com.damus.notedeck/files/android-config.json
android: jni
cd $(ANDROID_DIR) && ./gradlew installDebug
adb shell am start -n com.damus.notedeck/.MainActivity
adb logcat -v color -s RustStdoutStderr -s threaded_app | tee logcat.txt
.PHONY: fake

186
README.md
View File

@@ -1,148 +1,96 @@
# Notedeck
# Damus Notedeck
[![CI](https://github.com/damus-io/notedeck/actions/workflows/rust.yml/badge.svg)](https://github.com/damus-io/notedeck/actions/workflows/rust.yml)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/damus-io/notedeck)
[![CI](https://github.com/damus-io/notedeck/actions/workflows/rust.yml/badge.svg)](https://github.com/damus-io/notedeck/actions/workflows/rust.yml)
A modern, multiplatform Nostr client built with Rust. Notedeck provides a feature-rich experience for interacting with the Nostr protocol on both desktop and Android platforms.
A multiplatform nostr client. Works on android and desktop
<p align="center">
<img src="https://cdn.jb55.com/s/6130555f03db55b2.png" alt="Notedeck Desktop Screenshot" width="700">
</p>
The desktop client is called notedeck:
## ✨ Features
![notedeck](https://cdn.jb55.com/s/6130555f03db55b2.png)
- **Multi-column Layout**: TweetDeck-style interface for viewing different Nostr content
- **Dave AI Assistant**: AI-powered assistant that can search and analyze Nostr content
- **Profile Management**: View and edit Nostr profiles
- **Media Support**: View and upload images with GIF support
- **Lightning Integration**: Zap (tip) content creators with Bitcoin Lightning
- **Cross-platform**: Works on desktop (Linux, macOS, Windows) and Android
## Android
## 📱 Mobile Support
Look it actually runs on android!
Notedeck runs smoothly on Android devices with a responsive interface:
<img src="https://cdn.jb55.com/s/bebeeadf7001fae1.png" height="500px" />
<p align="center">
<img src="https://cdn.jb55.com/s/bebeeadf7001fae1.png" alt="Notedeck Android Screenshot" height="500px">
</p>
## 🏗️ Project Structure
```
notedeck
├── crates
│ ├── notedeck - Core library with shared functionality
│ ├── notedeck_chrome - UI container and navigation framework
│ ├── notedeck_columns - TweetDeck-style column interface
│ ├── notedeck_dave - AI assistant for Nostr
│ ├── notedeck_ui - Shared UI components
│ └── tokenator - String token parsing library
```
## 🚀 Getting Started
### Desktop
To run on desktop platforms:
## Usage
```bash
# Development build
cargo run -- --debug
# Release build
cargo run --release
$ ./target/release/notedeck
```
### Android
# Developer Setup
For Android devices:
## Desktop (Linux/MacOS, Windows?)
If you're running debian-based machine like Ubuntu or ElementaryOS, all you need is to install [rustup] and run `sudo apt install build-essential`.
```bash
# Install required target
rustup target add aarch64-linux-android
# Build and install on connected device
cargo apk run --release -p notedeck_chrome
$ cargo run --release
```
### Android Emulator
## Android
1. Install [Android Studio](https://developer.android.com/studio)
2. Open 'Device Manager' and create a device with API level `34` and ABI `arm64-v8a`
3. Start the emulator
4. Run: `cargo apk run --release -p notedeck_chrome`
The dev shell should also have all of the android-sdk dependencies needed for development, but you still need the `aarch64-linux-android` rustup target installed:
## 🧪 Development
```
$ rustup target add aarch64-linux-android
```
### Android Configuration
To run on a real device, just type:
Customize Android views for testing:
```bash
$ cargo apk run --release
```
1. Copy `example-android-config.json` to `android-config.json`
2. Run `make push-android-config` to deploy to your device
## Android Emulator
### Setting Up Developer Environment
- Install [Android Studio](https://developer.android.com/studio)
- Open 'Device Manager' in Android Studio
- Add a new device with API level `34` and ABI `arm64-v8a` (even though the app uses 30, the 30 emulator can't find the vulkan adapter, but 34 works fine)
- Start up the emulator
while the emulator is running, run:
```bash
cargo apk run --release
```
The app should appear on the emulator
[direnv]: https://direnv.net/
## Previews
You can preview individual widgets and views by running the preview script:
```bash
./preview RelayView
./preview ProfilePreview
# ... etc
```
When adding new previews you need to implement the Preview trait for your
view/widget and then add it to the `src/ui_preview/main.rs` bin:
```rust
previews!(runner, name,
RelayView,
AccountLoginView,
ProfilePreview,
);
```
## Contributing
Configure the developer environment:
```bash
./scripts/dev_setup.sh
```
This adds pre-commit hooks for proper code formatting.
This will add the pre-commit hook to your local repository to suggest proper formatting before commits.
## 📚 Documentation
Detailed developer documentation is available in each crate:
- [Notedeck Core](./crates/notedeck/DEVELOPER.md)
- [Notedeck Chrome](./crates/notedeck_chrome/DEVELOPER.md)
- [Notedeck Columns](./crates/notedeck_columns/DEVELOPER.md)
- [Dave AI Assistant](./crates/notedeck_dave/docs/README.md)
- [UI Components](./crates/notedeck_ui/docs/components.md)
## 🔄 Release Status
Notedeck is currently in **BETA** status. For the latest changes, see the [CHANGELOG](./CHANGELOG.md).
## Future
Notedeck allows for app development built on top of the performant, built specifically for nostr database [nostrdb][nostrdb]. An example app written on notedeck is [Dave](./crates/notedeck_dave)
Building on notedeck dev documentation is also on the roadmap.
## 🤝 Contributing
### Developers
Contributions are welcome! Please check the developer documentation and follow these guidelines:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Translators
Help us bring Notedeck to non-English speakers!
Request to join the Notedeck translations team through [Crowdin](https://crowdin.com/project/notedeck).
If you do not have a Crowdin account, sign up for one.
If you do not see your language, please request it in Crowdin.
## 🔒 Security
For security issues, please refer to our [Security Policy](./SECURITY.md).
## 📄 License
This project is licensed under the GPL - see license information in individual crates.
## 👥 Authors
- William Casarin <jb55@jb55.com>
- kernelkind <kernelkind@gmail.com>
- And [contributors](https://github.com/damus-io/notedeck/graphs/contributors)
[nostrdb]: https://github.com/damus-io/nostrdb
[rustup]: https://rustup.rs/

0
TODO
View File

View File

@@ -1,186 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="256mm"
height="256mm"
viewBox="0 0 256 256"
version="1.1"
id="svg5"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="damus-bg.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:blackoutopacity="0.0"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.5946522"
inkscape:cx="407.8014"
inkscape:cy="491.88416"
inkscape:window-width="1296"
inkscape:window-height="916"
inkscape:window-x="222"
inkscape:window-y="38"
inkscape:window-maximized="0"
inkscape:current-layer="svg5"
inkscape:showpageshadow="2"
inkscape:deskcolor="#d1d1d1" />
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient39361">
<stop
style="stop-color:#0de8ff;stop-opacity:0.78082192;"
offset="0"
id="stop39357" />
<stop
style="stop-color:#d600fc;stop-opacity:0.95433789;"
offset="1"
id="stop39359" />
</linearGradient>
<inkscape:path-effect
effect="bspline"
id="path-effect255"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<linearGradient
inkscape:collect="always"
id="linearGradient2119">
<stop
style="stop-color:#1c55ff;stop-opacity:1;"
offset="0"
id="stop2115" />
<stop
style="stop-color:#7f35ab;stop-opacity:1;"
offset="0.5"
id="stop2123" />
<stop
style="stop-color:#ff0bd6;stop-opacity:1;"
offset="1"
id="stop2117" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2119"
id="linearGradient2121"
x1="10.067794"
y1="248.81357"
x2="246.56145"
y2="7.1864405"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient39361"
id="linearGradient39367"
x1="62.104473"
y1="128.78963"
x2="208.25758"
y2="128.78963"
gradientUnits="userSpaceOnUse" />
</defs>
<g
inkscape:label="Background"
inkscape:groupmode="layer"
id="layer1"
sodipodi:insensitive="true"
style="display:inline">
<rect
style="fill:url(#linearGradient2121);fill-opacity:1;stroke-width:0.264583"
id="rect61"
width="256"
height="256"
x="-5.3875166e-08"
y="-1.0775033e-07"
ry="0"
inkscape:label="Gradient"
sodipodi:insensitive="true" />
</g>
<g
id="g407"
inkscape:label="Logo"
style="display:none">
<g
id="layer2"
inkscape:label="LogoStroke"
style="display:inline">
<path
style="fill:url(#linearGradient39367);fill-opacity:1;stroke:#ffffff;stroke-width:10;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 101.1429,213.87373 C 67.104473,239.1681 67.104473,42.67112 67.104473,42.67112 135.18122,57.58146 203.25844,72.491904 203.25758,105.24181 c -8.6e-4,32.74991 -68.07625,83.33755 -102.11468,108.63192 z"
id="path253" />
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Poly">
<path
style="fill:#ffffff;fill-opacity:0.325424;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 67.32839,76.766948 112.00424,99.41949 100.04873,52.226693 Z"
id="path4648" />
<path
style="fill:#ffffff;fill-opacity:0.274576;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 111.45696,98.998695 107.00758,142.60261 70.077729,105.67276 Z"
id="path9299" />
<path
style="fill:#ffffff;fill-opacity:0.379661;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 111.01202,99.221164 29.14343,-37.15232 25.80641,39.377006 z"
id="path9301" />
<path
style="fill:#ffffff;fill-opacity:0.447458;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 111.45696,99.443631 57.17452,55.172309 -2.89209,-53.17009 z"
id="path9368" />
<path
style="fill:#ffffff;fill-opacity:0.20678;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 106.78511,142.38015 62.06884,12.68073 -57.17452,-55.617249 z"
id="path9370" />
<path
style="fill:#ffffff;fill-opacity:0.244068;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 106.78511,142.38015 -28.47603,32.9254 62.51378,7.56395 z"
id="path9372" />
<path
style="fill:#ffffff;fill-opacity:0.216949;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 165.96186,101.44585 195.7727,125.02756 182.64703,78.754017 Z"
id="path9374" />
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="Vertices">
<circle
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path27764"
cx="106.86934"
cy="142.38014"
r="2.0022209" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle28773"
cx="111.54119"
cy="99.221161"
r="2.0022209" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle29091"
cx="165.90784"
cy="101.36163"
r="2.0022209" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -1,186 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="256mm"
height="256mm"
viewBox="0 0 256 256"
version="1.1"
id="svg5"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="damusfg.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:blackoutopacity="0.0"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.5946522"
inkscape:cx="407.8014"
inkscape:cy="491.88416"
inkscape:window-width="1296"
inkscape:window-height="916"
inkscape:window-x="222"
inkscape:window-y="38"
inkscape:window-maximized="0"
inkscape:current-layer="svg5"
inkscape:showpageshadow="2"
inkscape:deskcolor="#d1d1d1" />
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient39361">
<stop
style="stop-color:#0de8ff;stop-opacity:0.78082192;"
offset="0"
id="stop39357" />
<stop
style="stop-color:#d600fc;stop-opacity:0.95433789;"
offset="1"
id="stop39359" />
</linearGradient>
<inkscape:path-effect
effect="bspline"
id="path-effect255"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<linearGradient
inkscape:collect="always"
id="linearGradient2119">
<stop
style="stop-color:#1c55ff;stop-opacity:1;"
offset="0"
id="stop2115" />
<stop
style="stop-color:#7f35ab;stop-opacity:1;"
offset="0.5"
id="stop2123" />
<stop
style="stop-color:#ff0bd6;stop-opacity:1;"
offset="1"
id="stop2117" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2119"
id="linearGradient2121"
x1="10.067794"
y1="248.81357"
x2="246.56145"
y2="7.1864405"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient39361"
id="linearGradient39367"
x1="62.104473"
y1="128.78963"
x2="208.25758"
y2="128.78963"
gradientUnits="userSpaceOnUse" />
</defs>
<g
inkscape:label="Background"
inkscape:groupmode="layer"
id="layer1"
sodipodi:insensitive="true"
style="display:none">
<rect
style="fill:url(#linearGradient2121);fill-opacity:1;stroke-width:0.264583"
id="rect61"
width="256"
height="256"
x="-5.3875166e-08"
y="-1.0775033e-07"
ry="0"
inkscape:label="Gradient"
sodipodi:insensitive="true" />
</g>
<g
id="g407"
inkscape:label="Logo"
transform="matrix(0.61641471,0,0,0.61641471,51.853453,49.401806)">
<g
id="layer2"
inkscape:label="LogoStroke"
style="display:inline">
<path
style="fill:url(#linearGradient39367);fill-opacity:1;stroke:#ffffff;stroke-width:10;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 101.1429,213.87373 C 67.104473,239.1681 67.104473,42.67112 67.104473,42.67112 135.18122,57.58146 203.25844,72.491904 203.25758,105.24181 c -8.6e-4,32.74991 -68.07625,83.33755 -102.11468,108.63192 z"
id="path253" />
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Poly">
<path
style="fill:#ffffff;fill-opacity:0.325424;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 67.32839,76.766948 112.00424,99.41949 100.04873,52.226693 Z"
id="path4648" />
<path
style="fill:#ffffff;fill-opacity:0.274576;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 111.45696,98.998695 107.00758,142.60261 70.077729,105.67276 Z"
id="path9299" />
<path
style="fill:#ffffff;fill-opacity:0.379661;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 111.01202,99.221164 29.14343,-37.15232 25.80641,39.377006 z"
id="path9301" />
<path
style="fill:#ffffff;fill-opacity:0.447458;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 111.45696,99.443631 57.17452,55.172309 -2.89209,-53.17009 z"
id="path9368" />
<path
style="fill:#ffffff;fill-opacity:0.20678;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 106.78511,142.38015 62.06884,12.68073 -57.17452,-55.617249 z"
id="path9370" />
<path
style="fill:#ffffff;fill-opacity:0.244068;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 106.78511,142.38015 -28.47603,32.9254 62.51378,7.56395 z"
id="path9372" />
<path
style="fill:#ffffff;fill-opacity:0.216949;stroke:#ffffff;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 165.96186,101.44585 195.7727,125.02756 182.64703,78.754017 Z"
id="path9374" />
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="Vertices">
<circle
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path27764"
cx="106.86934"
cy="142.38014"
r="2.0022209" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle28773"
cx="111.54119"
cy="99.221161"
r="2.0022209" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle29091"
cx="165.90784"
cy="101.36163"
r="2.0022209" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -1,12 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="16" fill="#2C2C2C"/>
<g clip-path="url(#clip0_3568_3937)">
<path opacity="0.12" d="M15.9998 21.3334C18.9454 21.3334 21.3332 18.9456 21.3332 16.0001C21.3332 13.0546 18.9454 10.6667 15.9998 10.6667C13.0543 10.6667 10.6665 13.0546 10.6665 16.0001C10.6665 18.9456 13.0543 21.3334 15.9998 21.3334Z" fill="white"/>
<path d="M21.3335 15.9999C21.3335 18.9455 18.9457 21.3333 16.0002 21.3333M21.3335 15.9999C21.3335 13.0544 18.9457 10.6666 16.0002 10.6666M21.3335 15.9999H10.6668M16.0002 21.3333C13.0546 21.3333 10.6668 18.9455 10.6668 15.9999M16.0002 21.3333C17.3342 19.8728 18.0927 17.9775 18.1339 15.9999C18.0927 14.0223 17.3342 12.127 16.0002 10.6666M16.0002 21.3333C14.6661 19.8728 13.9084 17.9775 13.8672 15.9999C13.9084 14.0223 14.6661 12.127 16.0002 10.6666M16.0002 10.6666C13.0546 10.6666 10.6668 13.0544 10.6668 15.9999M12.0002 21.3333C12.0002 22.0697 11.4032 22.6666 10.6668 22.6666C9.93045 22.6666 9.3335 22.0697 9.3335 21.3333C9.3335 20.5969 9.93045 19.9999 10.6668 19.9999C11.4032 19.9999 12.0002 20.5969 12.0002 21.3333ZM22.6668 21.3333C22.6668 22.0697 22.0699 22.6666 21.3335 22.6666C20.5971 22.6666 20.0002 22.0697 20.0002 21.3333C20.0002 20.5969 20.5971 19.9999 21.3335 19.9999C22.0699 19.9999 22.6668 20.5969 22.6668 21.3333ZM12.0002 10.6666C12.0002 11.403 11.4032 11.9999 10.6668 11.9999C9.93045 11.9999 9.3335 11.403 9.3335 10.6666C9.3335 9.93021 9.93045 9.33325 10.6668 9.33325C11.4032 9.33325 12.0002 9.93021 12.0002 10.6666ZM22.6668 10.6666C22.6668 11.403 22.0699 11.9999 21.3335 11.9999C20.5971 11.9999 20.0002 11.403 20.0002 10.6666C20.0002 9.93021 20.5971 9.33325 21.3335 9.33325C22.0699 9.33325 22.6668 9.93021 22.6668 10.6666Z" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_3568_3937">
<rect width="16" height="16" fill="white" transform="translate(8 8)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,9 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M68 144.8C68 117.917 68 104.476 73.2317 94.2085C77.8336 85.1767 85.1767 77.8336 94.2085 73.2317C104.476 68 117.917 68 144.8 68H367.2C394.083 68 407.524 68 417.792 73.2317C426.823 77.8336 434.166 85.1767 438.768 94.2085C444 104.476 444 117.917 444 144.8V367.2C444 394.083 444 407.524 438.768 417.792C434.166 426.823 426.823 434.166 417.792 438.768C407.524 444 394.083 444 367.2 444H144.8C117.917 444 104.476 444 94.2085 438.768C85.1767 434.166 77.8336 426.823 73.2317 417.792C68 407.524 68 394.083 68 367.2V144.8ZM88 139.2C88 121.278 88 112.317 91.4878 105.472C94.5557 99.4511 99.4511 94.5557 105.472 91.4878C112.317 88 121.278 88 139.2 88H188C199.201 88 204.802 88 209.08 90.1799C212.843 92.0973 215.903 95.1569 217.82 98.9202C220 103.198 220 108.799 220 120V392C220 403.201 220 408.802 217.82 413.08C215.903 416.843 212.843 419.903 209.08 421.82C204.802 424 199.201 424 188 424H139.2C121.278 424 112.317 424 105.472 420.512C99.4511 417.444 94.5557 412.549 91.4878 406.528C88 399.683 88 390.722 88 372.8V139.2ZM242.18 98.9202C240 103.198 240 108.799 240 120V392C240 403.201 240 408.802 242.18 413.08C244.097 416.843 247.157 419.903 250.92 421.82C255.198 424 260.799 424 272 424H295C306.201 424 311.802 424 316.08 421.82C319.843 419.903 322.903 416.843 324.82 413.08C327 408.802 327 403.201 327 392V120C327 108.799 327 103.198 324.82 98.9202C322.903 95.1569 319.843 92.0973 316.08 90.1799C311.802 88 306.201 88 295 88H272C260.799 88 255.198 88 250.92 90.1799C247.157 92.0973 244.097 95.1569 242.18 98.9202Z" fill="url(#paint0_linear_19_1273)"/>
<defs>
<linearGradient id="paint0_linear_19_1273" x1="444" y1="444" x2="-5.21356" y2="206.447" gradientUnits="userSpaceOnUse">
<stop stop-color="#DACAA0"/>
<stop offset="1" stop-color="#8C93D7"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,3 +0,0 @@
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.32844 1.4159C8.36511 1.12223 8.20384 0.839518 7.93237 0.721671C7.66091 0.603828 7.34424 0.679064 7.15478 0.906418L1.20178 8.04995C1.0989 8.17335 0.994695 8.29835 0.918828 8.40822C0.847082 8.51208 0.716075 8.71635 0.712028 8.98475C0.707388 9.29208 0.844335 9.58449 1.08338 9.77762C1.2922 9.94635 1.53297 9.97649 1.65871 9.98788C1.79166 9.99995 1.95438 9.99988 2.11504 9.99988H6.24504L5.67204 14.5838C5.63533 14.8775 5.79664 15.1602 6.06811 15.2781C6.33958 15.3959 6.65624 15.3207 6.84571 15.0933L12.7987 7.94975C12.9016 7.82635 13.0058 7.70135 13.0816 7.59149C13.1534 7.48762 13.2844 7.28335 13.2884 7.01495C13.293 6.70762 13.1561 6.41525 12.9171 6.22207C12.7082 6.05333 12.4675 6.02321 12.3418 6.01183C12.2088 5.99979 12.046 5.99982 11.8854 5.99985L7.75544 5.99986L8.32844 1.41588V1.4159Z" fill="#FFB757"/>
</svg>

Before

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,4 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.12" d="M6 14V8H10V14" fill="white"/>
<path d="M5.99992 14V9.06667C5.99992 8.69327 5.99992 8.5066 6.07259 8.364C6.1365 8.23853 6.23849 8.1366 6.36392 8.07267C6.50654 8 6.69325 8 7.06659 8H8.93325C9.30665 8 9.49332 8 9.63592 8.07267C9.76139 8.1366 9.86332 8.23853 9.92725 8.364C9.99992 8.5066 9.99992 8.69327 9.99992 9.06667V14M1.33325 6.33333L7.35992 1.81333C7.58945 1.64121 7.70419 1.55514 7.83019 1.52196C7.94145 1.49268 8.05838 1.49268 8.16965 1.52197C8.29565 1.55514 8.41038 1.64121 8.63992 1.81333L14.6666 6.33333M2.66659 5.33333V11.8667C2.66659 12.6134 2.66659 12.9868 2.81191 13.272C2.93975 13.5229 3.14372 13.7269 3.3946 13.8547C3.67982 14 4.05318 14 4.79992 14H11.1999C11.9467 14 12.3201 14 12.6053 13.8547C12.8561 13.7269 13.0601 13.5229 13.1879 13.272C13.3333 12.9868 13.3333 12.6134 13.3333 11.8667V5.33333L9.27992 2.29333C8.82092 1.94907 8.59138 1.77695 8.33932 1.71059C8.11685 1.65203 7.88298 1.65203 7.66052 1.71059C7.40845 1.77695 7.17892 1.94907 6.71992 2.29333L2.66659 5.33333Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="#8a8a8a" xmlns="http://www.w3.org/2000/svg" class="icon-xl-heavy"><path d="M15.6729 3.91287C16.8918 2.69392 18.8682 2.69392 20.0871 3.91287C21.3061 5.13182 21.3061 7.10813 20.0871 8.32708L14.1499 14.2643C13.3849 15.0293 12.3925 15.5255 11.3215 15.6785L9.14142 15.9899C8.82983 16.0344 8.51546 15.9297 8.29289 15.7071C8.07033 15.4845 7.96554 15.1701 8.01005 14.8586L8.32149 12.6785C8.47449 11.6075 8.97072 10.615 9.7357 9.85006L15.6729 3.91287ZM18.6729 5.32708C18.235 4.88918 17.525 4.88918 17.0871 5.32708L11.1499 11.2643C10.6909 11.7233 10.3932 12.3187 10.3014 12.9613L10.1785 13.8215L11.0386 13.6986C11.6812 13.6068 12.2767 13.3091 12.7357 12.8501L18.6729 6.91287C19.1108 6.47497 19.1108 5.76499 18.6729 5.32708ZM11 3.99929C11.0004 4.55157 10.5531 4.99963 10.0008 5.00007C9.00227 5.00084 8.29769 5.00827 7.74651 5.06064C7.20685 5.11191 6.88488 5.20117 6.63803 5.32695C6.07354 5.61457 5.6146 6.07351 5.32698 6.63799C5.19279 6.90135 5.10062 7.24904 5.05118 7.8542C5.00078 8.47105 5 9.26336 5 10.4V13.6C5 14.7366 5.00078 15.5289 5.05118 16.1457C5.10062 16.7509 5.19279 17.0986 5.32698 17.3619C5.6146 17.9264 6.07354 18.3854 6.63803 18.673C6.90138 18.8072 7.24907 18.8993 7.85424 18.9488C8.47108 18.9992 9.26339 19 10.4 19H13.6C14.7366 19 15.5289 18.9992 16.1458 18.9488C16.7509 18.8993 17.0986 18.8072 17.362 18.673C17.9265 18.3854 18.3854 17.9264 18.673 17.3619C18.7988 17.1151 18.8881 16.7931 18.9393 16.2535C18.9917 15.7023 18.9991 14.9977 18.9999 13.9992C19.0003 13.4469 19.4484 12.9995 20.0007 13C20.553 13.0004 21.0003 13.4485 20.9999 14.0007C20.9991 14.9789 20.9932 15.7808 20.9304 16.4426C20.8664 17.116 20.7385 17.7136 20.455 18.2699C19.9757 19.2107 19.2108 19.9756 18.27 20.455C17.6777 20.7568 17.0375 20.8826 16.3086 20.9421C15.6008 21 14.7266 21 13.6428 21H10.3572C9.27339 21 8.39925 21 7.69138 20.9421C6.96253 20.8826 6.32234 20.7568 5.73005 20.455C4.78924 19.9756 4.02433 19.2107 3.54497 18.2699C3.24318 17.6776 3.11737 17.0374 3.05782 16.3086C2.99998 15.6007 2.99999 14.7266 3 13.6428V10.3572C2.99999 9.27337 2.99998 8.39922 3.05782 7.69134C3.11737 6.96249 3.24318 6.3223 3.54497 5.73001C4.02433 4.7892 4.78924 4.0243 5.73005 3.54493C6.28633 3.26149 6.88399 3.13358 7.55735 3.06961C8.21919 3.00673 9.02103 3.00083 9.99922 3.00007C10.5515 2.99964 10.9996 3.447 11 3.99929Z" fill="#8a8a8a"></path></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,4 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.12" d="M12.0001 5.33337C12.0001 4.27251 11.5787 3.25509 10.8286 2.50495C10.0784 1.7548 9.06095 1.33337 8.00008 1.33337C6.93922 1.33337 5.92182 1.7548 5.17167 2.50495C4.42152 3.25509 4.0001 4.27251 4.0001 5.33337C4.0001 7.39351 3.48041 8.80404 2.89987 9.73697C2.41018 10.524 2.16534 10.9174 2.17431 11.0272C2.18426 11.1488 2.21 11.1951 2.30794 11.2678C2.3964 11.3334 2.79516 11.3334 3.59266 11.3334H12.4075C13.205 11.3334 13.6038 11.3334 13.6922 11.2678C13.7902 11.1951 13.8159 11.1488 13.8259 11.0272C13.8349 10.9174 13.59 10.524 13.1003 9.73697C12.5197 8.80404 12.0001 7.39351 12.0001 5.33337Z" fill="white"/>
<path d="M9.33342 14H6.66675M12.0001 5.33337C12.0001 4.27251 11.5787 3.25509 10.8286 2.50495C10.0784 1.7548 9.06095 1.33337 8.00008 1.33337C6.93922 1.33337 5.92182 1.7548 5.17167 2.50495C4.42152 3.25509 4.0001 4.27251 4.0001 5.33337C4.0001 7.39351 3.48041 8.80404 2.89987 9.73697C2.41018 10.524 2.16534 10.9174 2.17431 11.0272C2.18426 11.1488 2.21 11.1951 2.30794 11.2678C2.3964 11.3334 2.79516 11.3334 3.59266 11.3334H12.4075C13.205 11.3334 13.6038 11.3334 13.6922 11.2678C13.7902 11.1951 13.8159 11.1488 13.8259 11.0272C13.8349 10.9174 13.59 10.524 13.1003 9.73697C12.5197 8.80404 12.0001 7.39351 12.0001 5.33337Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,11 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_11_352)">
<path opacity="1.0" d="M8.66663 2L9.82276 5.00591C10.0108 5.49473 10.1048 5.73914 10.251 5.94473C10.3805 6.12693 10.5397 6.28613 10.7219 6.41569C10.9275 6.56187 11.1719 6.65587 11.6607 6.84387L14.6666 8L11.6607 9.15613C11.1719 9.34413 10.9275 9.43813 10.7219 9.58433C10.5397 9.71387 10.3805 9.87307 10.251 10.0553C10.1048 10.2609 10.0108 10.5053 9.82276 10.9941L8.66663 14L7.51049 10.9941C7.32249 10.5053 7.22849 10.2609 7.08229 10.0553C6.95276 9.87307 6.79356 9.71387 6.61135 9.58433C6.40577 9.43813 6.16135 9.34413 5.67253 9.15613L2.66663 8L5.67253 6.84387C6.16135 6.65587 6.40577 6.56187 6.61135 6.41569C6.79356 6.28613 6.95276 6.12693 7.08229 5.94473C7.22849 5.73914 7.32249 5.49473 7.51049 5.00591L8.66663 2Z" fill="white"/>
<path d="M3.00004 14.6667V11.3334M3.00004 4.66671V1.33337M1.33337 3.00004H4.66671M1.33337 13H4.66671M8.66671 2.00004L7.51057 5.00595C7.32257 5.49477 7.22857 5.73918 7.08237 5.94477C6.95284 6.12697 6.79364 6.28617 6.61143 6.41573C6.40585 6.56191 6.16143 6.65591 5.67261 6.84391L2.66671 8.00004L5.67261 9.15617C6.16143 9.34417 6.40585 9.43817 6.61143 9.58437C6.79364 9.71391 6.95284 9.87311 7.08237 10.0553C7.22857 10.2609 7.32257 10.5053 7.51057 10.9941L8.66671 14L9.82284 10.9941C10.0108 10.5053 10.1048 10.2609 10.251 10.0553C10.3806 9.87311 10.5398 9.71391 10.722 9.58437C10.9276 9.43817 11.172 9.34417 11.6608 9.15617L14.6667 8.00004L11.6608 6.84391C11.172 6.65591 10.9276 6.56191 10.722 6.41573C10.5398 6.28617 10.3806 6.12697 10.251 5.94477C10.1048 5.73918 10.0108 5.49477 9.82284 5.00595L8.66671 2.00004Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_11_352">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,4 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.12" d="M11.8667 14C12.6134 14 12.9868 14 13.272 13.8547C13.5229 13.7269 13.7269 13.5229 13.8547 13.272C14 12.9868 14 12.6134 14 11.8667V7.46671C14 6.71997 14 6.3466 13.8547 6.06139C13.7269 5.81051 13.5229 5.60653 13.272 5.4787C12.9868 5.33337 12.6134 5.33337 11.8667 5.33337H4.13333C3.3866 5.33337 3.01323 5.33337 2.72801 5.4787C2.47713 5.60653 2.27315 5.8105 2.14533 6.06139C2 6.3466 2 6.71997 2 7.46671V11.8667C2 12.6134 2 12.9868 2.14533 13.272C2.27315 13.5229 2.47713 13.7269 2.72801 13.8547C3.01323 14 3.38659 14 4.13333 14H11.8667Z" fill="white"/>
<path d="M10.6667 5.33322V3.00032C10.6667 2.44583 10.6667 2.16858 10.5499 1.9982C10.4478 1.84934 10.2897 1.74821 10.1119 1.71794C9.9082 1.68329 9.65647 1.79947 9.153 2.03183L3.23934 4.76121C2.79034 4.96845 2.56583 5.07207 2.40141 5.23277C2.25604 5.37483 2.14508 5.54825 2.077 5.73977C2 5.95641 2 6.20367 2 6.6982V9.99987M11 9.66653H11.0067M2 7.46653V11.8665C2 12.6133 2 12.9867 2.14533 13.2719C2.27315 13.5227 2.47713 13.7267 2.72801 13.8545C3.01323 13.9999 3.38659 13.9999 4.13333 13.9999H11.8667C12.6134 13.9999 12.9868 13.9999 13.272 13.8545C13.5229 13.7267 13.7269 13.5227 13.8547 13.2719C14 12.9867 14 12.6133 14 11.8665V7.46653C14 6.7198 14 6.34645 13.8547 6.06123C13.7269 5.81035 13.5229 5.60637 13.272 5.47855C12.9868 5.33322 12.6134 5.33322 11.8667 5.33322H4.13333C3.3866 5.33322 3.01323 5.33322 2.72801 5.47854C2.47713 5.60637 2.27315 5.81035 2.14533 6.06123C2 6.34645 2 6.7198 2 7.46653ZM11.3333 9.66653C11.3333 9.85067 11.1841 9.99987 11 9.99987C10.8159 9.99987 10.6667 9.85067 10.6667 9.66653C10.6667 9.48247 10.8159 9.3332 11 9.3332C11.1841 9.3332 11.3333 9.48247 11.3333 9.66653Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 B

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env bash
MIPMAP="../crates/notedeck_chrome/android/app/src/main/res/mipmap-"
function mkicon() {
local name="$1"
echo "making icon $name"
mkdir -p "${MIPMAP}/{l,m,h,xh,xxh,xxxh}dpi"
inkscape "$name".svg -w 36 -h 36 -o ${MIPMAP}ldpi/"$name".png &
inkscape "$name".svg -w 48 -h 48 -o ${MIPMAP}mdpi/"$name".png &
inkscape "$name".svg -w 72 -h 72 -o ${MIPMAP}hdpi/"$name".png &
inkscape "$name".svg -w 96 -h 96 -o ${MIPMAP}xhdpi/"$name".png &
inkscape "$name".svg -w 144 -h 144 -o ${MIPMAP}xxhdpi/"$name".png &
inkscape "$name".svg -w 192 -h 192 -o ${MIPMAP}xxxhdpi/"$name".png &
wait
}
mkicon "damusfg"
mkicon "damusbg"

View File

@@ -1,432 +0,0 @@
# Main translation file for Notedeck
# This file contains common UI strings used throughout the application
# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
# Regular strings
# Profile about/bio field label
About_00c0 = Über
# Display name for account management
Accounts_e233 = Konten
# Column title for account management
Accounts_f018 = Konten
# Button label to add a relay
Add_269d = Hinzufügen
# Label for add column button
Add_47df = Hinzufügen
# Button label to add a different wallet
Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = Eine andere Wallet hinzufügen, die nur für dieses Konto verwendet wird
# Error message for missing wallet
Add_a_wallet_to_continue_d170 = Wallet hinzufügen um fortzufahren
# Button label to add a new account
Add_account_1cfc = Konto hinzufügen
# Column title for adding new account
Add_Account_d06c = Konto hinzufügen
# Display name for adding account
Add_Account_d715 = Konto hinzufügen
# Column title for adding algorithm column
Add_Algo_Column_0d75 = Algorithmus-Spalte hinzufügen
# Display name for adding column
Add_Column_c6ff = Spalte hinzufügen
# Column title for adding new column
Add_Column_c764 = Spalte hinzufügen
# Display name for adding deck
Add_Deck_6e5f = Deck hinzufügen
# Column title for adding new deck
Add_Deck_fabf = Deck hinzufügen
# Column title for adding external notifications column
Add_External_Notifications_Column_41ae = Externe Benachrichtigungsspalte hinzufügen
# Column title for adding hashtag column
Add_Hashtag_Column_ebf4 = Hashtag-Spalte hinzufügen
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Letzte Notizen-Spalte hinzufügen
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Benachrichtigungs-Spalte hinzufügen
# Button label to add a relay
Add_relay_269d = Relay hinzufügen
# Button label to add a wallet
Add_Wallet_d1be = Wallet hinzufügen
# Title for algorithmic feeds column
Algo_2452 = Algorithmus
# Description for algorithmic feeds column
Algorithmic_feeds_to_aid_in_note_discovery_d344 = Algorithmische Feeds zur Hilfe bei der Entdeckung von Notizen
# Label for zap amount input field
Amount_70f0 = Menge
# Button to send message to Dave AI assistant
Ask_b7f4 = Fragen
# Placeholder text for Dave AI input field
Ask_dave_anything_33d1 = Frage Dave etwas...
# Profile banner URL field label
Banner_52ef = Banner
# Beta version label
BETA_8e5d = BETA
# Broadcast the note to all connected relays
Broadcast_fe43 = Senden
# Broadcast the note only to local network relays
Broadcast_Local_7e50 = Lokal senden
# Button label to cancel an action
Cancel_ed3b = Abbrechen
# Hover text for editable zap amount
Click_to_edit_0414 = Zum Bearbeiten anklicken
# Display name for note composition
Compose_Note_ad11 = Notiz erstellen
# Column title for note composition
Compose_Note_c094 = Notiz erstellen
# Button label to confirm an action
Confirm_f8a6 = Bestätigen
# Status label for connected relay
Connected_f8cc = Verbunden
# Status label for connecting relay
Connecting_6b7e = Verbinde...
# Title for contact list column
Contact_List_f85a = Kontaktliste
# Column title for contact lists
Contacts_7533 = Kontakte
# Timeline kind label for contact lists
Contacts_8b98 = Kontakte
# Column title for last notes per contact
Contacts__last_notes_3f84 = Kontakte (letzte Notizen)
# Button label to copy logs
Copy_a688 = Kopieren
# Button to copy media link to clipboard
Copy_Link_dc7c = Link kopieren
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = Notiz-ID kopieren
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Notiz-JSON kopieren
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Pubkey kopieren
# Copy the text content of the note to clipboard
Copy_Text_f81c = Text kopieren
# Relative time in days
count_d_b9be = { $count }T.
# Relative time in hours
count_h_3ecb = { $count }Std.
# Relative time in minutes
count_m_b41e = { $count }Min.
# Relative time in months
count_mo_7aba = { $count }Mon.
# Relative time in seconds
count_s_aa26 = { $count }Sek.
# Relative time in weeks
count_w_7468 = { $count }Wo.
# Relative time in years
count_y_9408 = { $count }J.
# Button to create a new account
Create_Account_6994 = Konto erstellen
# Button label to create a new deck
Create_Deck_16b7 = Deck erstellen
# Column title for custom timelines
Custom_a69e = Benutzerdefiniert
# Display name for custom timelines
Custom_cb4f = Benutzerdefiniert
# Column title for zap amount customization
Customize_Zap_Amount_cfc4 = Zap-Betrag anpassen
# Display name for zap customization
Customize_Zap_Amount_ed29 = Zap-Betrag anpassen
# Column title for support page
Damus_Support_27c0 = Damus Support
# Label for deck name input field
Deck_name_cd32 = Deck-Name
# Label for decks section in side panel
DECKS_1fad = DECKS
# Label for default zap amount input
Default_amount_per_zap_399d = Standardbetrag pro Zap:
# Name of the default deck feed
Default_Deck_fcca = Standard-Deck
# Button label to delete a deck
Delete_Deck_bb29 = Deck löschen
# Tooltip for deleting a column
Delete_this_column_8d5a = Diese Spalte löschen
# Button label to delete a wallet
Delete_Wallet_d1d4 = Wallet löschen
# Profile display name field label
Display_name_f9d9 = Anzeigename
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" wird zur Identifikation verwendet
# Column title for editing deck
Edit_Deck_4018 = Deck bearbeiten
# Display name for editing deck
Edit_Deck_c9ba = Deck bearbeiten
# Button label to edit a deck
Edit_Deck_fd93 = Deck bearbeiten
# Button label to edit user profile
Edit_Profile_49e6 = Profil bearbeiten
# Display name for profile editing
Edit_Profile_6699 = Profil bearbeiten
# Column title for profile editing
Edit_Profile_8ad4 = Profil bearbeiten
# Placeholder for hashtag input field
Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = Gewünschte Hashtags hier eingeben (für mehrere, durch Leerzeichen trennen)
# Placeholder for relay input field
Enter_the_relay_here_1c8b = Relay hier eingeben
# Hint text to prompt entering the user's public key.
Enter_the_user_s_key__npub__hex__nip05__here_650c = Hier den Benutzerschlüssel (npub, hex, nip05) eingeben...
# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
Enter_your_key_0fca = Gib deinen Schlüssel ein
# Instructions for entering Nostr credentials
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 =
Gib deinen öffentlichen Schlüssel (npub), eine Nostr-Adresse (z.B. {$address}) oder deinen privaten Schlüssel (nsec) ein.
Für das Veröffentlichen von Beiträgen und andere Aktionen ist dein privater Schlüssel erforderlich.
# Label for find user button
Find_User_bd12 = Profil finden
# Timeline kind label for hashtag feeds
Hashtag_a0ab = Hashtag
# Display name for hashtag feeds
Hashtags_617e = Hashtags
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Display name for home feed
Home_3efc = Startseite
# Title for Home column
Home_8c19 = Startseite
# Label for deck icon selection
Icon_b0ab = Symbol
# Title for individual user column
Individual_b776 = Individuell
# Error message for invalid zap amount
Invalid_amount_6630 = Ungültiger Betrag
# Error message for invalid key input
Invalid_key_4726 = Ungültiger Schlüssel
# Error message for invalid Nostr Wallet Connect URI
Invalid_NWC_URI_031b = Ungültige NWC URI
# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
k_100K_686c = 100K
# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
k_10K_f7e6 = 10K
# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
k_20K_4977 = 20K
# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
k_50K_c2dc = 50K
# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Behalte den Überblick über deine Notizen & Antworten
# Title for last note per user column
Last_Note_per_User_17ad = Letzte Notiz pro Profil
# Timeline kind label for last notes per pubkey
Last_Notes_aefe = Letzte Notizen
# Display name for last notes per contact
Last_Per_Pubkey__Contact_33ce = Zuletzt pro Pubkey (Kontakt)
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = Lightning-Netzwerkadresse (lud16)
# Login page title
Login_9eef = Anmelden
# Login button text
Login_now___let_s_do_this_5630 = Jetzt anmelden — auf geht's!
# Text shown on blurred media from unfollowed users
Media_from_someone_you_don_t_follow_5611 = Medien von einem Profil, dem du nicht folgst
# Tooltip for moving a column
Moves_this_column_to_another_position_0d4b = Verschiebt diese Spalte an eine andere Position
# Title for the user's deck
My_Deck_4ac5 = Mein Deck
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = Neu bei Nostr?
# NIP-05 identity field label
Nostr_address__NIP-05_identity_74a2 = Nostr-Adresse (NIP-05-Identität)
# Default username when profile is not available
nostrich_df29 = Nostrich
# Status label for disconnected relay
Not_Connected_6292 = Nicht verbunden
# Link text for note references
note_cad6 = Notiz
# Beta product warning message
Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck ist ein Beta-Produkt. Erwarte Fehler und kontaktiere uns, wenn Probleme oder Fehler auftreten.
# Filter label for notes only view
Notes_03fb = Notizen
# Label for notes-only filter
Notes_60d2 = Notizen
# Filter label for notes and replies view
Notes___Replies_1ec2 = Notizen & Antworten
# Label for notes and replies filter
Notes___Replies_6e3b = Notizen & Antworten
# Timeline kind label for notifications
Notifications_6228 = Benachrichtigungen
# Display name for notifications
Notifications_8029 = Benachrichtigungen
# Column title for notifications
Notifications_d673 = Benachrichtigungen
# Title for notifications column
Notifications_ef56 = Benachrichtigungen
# Relative time for very recent events (less than 3 seconds)
now_2181 = Jetzt
# Button label to open email client
Open_Email_25e9 = E-Mail öffnen
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Öffne deinen Standard-E-Mail-Client, um Hilfe vom Damus-Team zu erhalten
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = Füge hier deine NWC-URI ein...
# Error message for missing deck name
Please_create_a_name_for_the_deck_38e7 = Bitte erstelle einen Namen für das Deck.
# Error message for missing deck name and icon
Please_create_a_name_for_the_deck_and_select_an_icon_0add = Bitte erstelle einen Namen für das Deck und wähle ein Symbol aus.
# Error message for missing deck icon
Please_select_an_icon_655b = Bitte wählen ein Symbol aus.
# Button label to post a note
Post_now_8a49 = Jetzt veröffentlichen
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Drücke die Schaltfläche unten, um deine neuesten Protokolle in die Zwischenablage deines Systems zu kopieren. Dann füge sie in deine E-Mail ein.
# Display name for user profiles
Profile_2478 = Profil
# Timeline kind label for user profiles
Profile_9027 = Profil
# Profile picture URL field label
Profile_picture_81ff = Profilbild
# Column title for quote composition
Quote_475c = Zitat
# Display name for quote composition
Quote_a38e = Zitat
# Error message when quote note cannot be found
Quote_of_unknown_note_e4f0 = Zitat von unbekannter Notiz
# Label for read-only profile mode
Read_only_82ff = Nur Lesezugriff
# Display name for relay management
Relays_7335 = Relays
# Column title for relay management
Relays_9d89 = Relays
# Label for relay list section
Relays_ad5e = Relays
# Column title for reply composition
Reply_3bf1 = Antwort
# Display name for reply composition
Reply_b40f = Antworten
# Hover text for reply button
Reply_to_this_note_f5de = Auf diese Notiz antworten
# Error message when reply note cannot be found
Reply_to_unknown_note_4401 = Antwort auf unbekannte Notiz
# Fallback template for replying to user
replying_to__user_15ab = Antwort an { $user }
# Template for replying to user in unknown thread
replying_to__user__in_someone_s_thread_e148 = Antwort an { $user } im Beitrag von jemandem
# Template for replying to note in different user's thread
replying_to__user__s__note__in__thread_user__s__thread_daa8 = Antwort auf { $user }'s { $note } in { $thread_user }'s { $thread }
# Template for replying to user's note
replying_to__user__s__note_ccba = Antwort auf { $user }'s { $note }
# Template for replying to root thread
replying_to__user__s__thread_444d = Antwort auf { $user }'s { $thread }
# Fallback text when reply note is not found
replying_to_a_note_e0bc = Antwort auf eine Notiz
# Hover text for repost button
Repost_this_note_8e56 = Diese Notiz teilen
# Label for reposted notes
Reposted_61c8 = Teilen
# Heading for support section
Running_into_a_bug_1796 = Ein Fehler aufgetreten?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
SATS_45d7 = SATS
# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
sats_e5ec = Sats
# Button to save default zap amount
Save_6f7c = Speichern
# Button label to save profile changes
Save_changes_00db = Änderungen speichern
# Display name for search results
Search_0aa0 = Suche
# Display name for search page
Search_4503 = Suche
# Timeline kind label for search results
Search_a0b8 = Suche
# Column title for search page
Search_c573 = Suche
# Placeholder for search notes input field
Search_notes_42a6 = Notizen suchen...
# Search in progress message
Searching_for___query_5d18 = Suche nach '{ $query }'
# Description for Home column
See_notes_from_your_contacts_ac16 = Notizen von deinen Kontakten ansehen
# Description for universe column
See_the_whole_nostr_universe_7694 = Sieh dir das ganze Nostr-Universum an
# Button label to send a zap
Send_1ea4 = Senden
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Zeige die letzte Notiz für jedes Profil aus einer Liste
# Button label to sign out of account
Sign_out_337b = Abmelden
# Title for someone else's notes column
Someone_else_s_Notes_7e5f = Notizen anderer Profile
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Mitteilungen anderer Profile
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Die letzte Notiz für jedes Profil aus deiner Kontaktliste anzeigen
# Description for hashtags column
Stay_up_to_date_with_a_certain_hashtag_88e3 = Mit einem bestimmten Hashtag auf dem Laufenden bleiben
# Description for notifications column
Stay_up_to_date_with_notifications_and_mentions_6f4e = Bleibe auf dem Laufenden mit Benachrichtigungen und Erwähnungen
# Description for someone else's notes column
Stay_up_to_date_with_someone_else_s_notes___replies_464c = Bleib auf dem Laufenden bei den Notizen & Antworten anderer
# Description for someone else's notifications column
Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = Bleib bei den Benachrichtigungen und Erwähnungen anderer auf dem Laufenden
# Description for individual user column
Stay_up_to_date_with_someone_s_notes___replies_aa78 = Bleib bei den Notizen & Antworten eines anderen auf dem Laufenden
# Description for your notifications column
Stay_up_to_date_with_your_notifications_and_mentions_e73e = Bleib bei deinen Benachrichtigungen und Erwähnungen auf dem Laufenden
# Step 1 label in support instructions
Step_1_8656 = Schritt 1
# Step 2 label in support instructions
Step_2_d08d = Schritt 2
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = Abonniere die Notizen eines anderen
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Abonniere die Notizen von jemandem
# Display name for support page
Support_a4b4 = Support
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Zum Dunkelmodus wechseln
# Hover text for light mode toggle button
Switch_to_light_mode_72ce = Zum Hellmodus wechseln
# Button text to load blurred media
Tap_to_Load_4b05 = Zum Laden antippen
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = Die Testphase des Dave Nostr KI-Assistenten ist beendet :(. Vielen Dank fürs Ausprobieren! Zap-fähiger Dave kommt bald!
# Column title for note thread view
Thread_0f20 = Unterhaltungen
# Display name for thread view
Thread_9957 = Unterhaltungen
# Link text for thread references
thread_ad1f = Unterhaltungen
# Generic timeline kind label
Timeline_b0fc = Timeline
# Timeline kind label for universe feed
Universe_0a3e = Weltraum
# Display name for universe feed
Universe_d47e = Weltraum
# Title for universe column
Universe_e01e = Weltraum
# Column title for universe feed
Universe_ffaa = Weltraum
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = Diese Wallet nur für das aktuelle Konto verwenden
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" bei "{ $domain }" wird für die Identifikation verwendet werden
# Profile username field label
Username_daa7 = Benutzername
# Column title for wallet management
Wallet_5e50 = Wallet
# Display name for wallet management
Wallet_cdca = Wallet
# Hint for deck name input field
We_recommend_short_names_083e = Wir empfehlen kurze Namen
# Profile website field label
Website_7980 = Website
# Placeholder for note input field
Write_a_banger_note_here_bad2 = Schreib hier eine richtig coole Notiz...
# Placeholder text for key input field
Your_key_here_81bd = Dein Schlüssel hier...
# Title for your notes column
Your_Notes_f6db = Deine Notizen
# Title for your notifications column
Your_Notifications_080d = Deine Benachrichtigungen
# Heading for zap (tip) action
Zap_16b4 = Zap
# Hover text for zap button
Zap_this_note_42b2 = Zappe diese Notiz
# Pluralized strings
# Search results count
Got__count__results_for___query_85fb =
{ $count ->
[one] { $count } Ergebnis für '{ $query } gefunden'
*[other] { $count } Ergebnisse für '{ $query } gefunden'
}

View File

@@ -1,542 +0,0 @@
# Main translation file for Notedeck
# This file contains common UI strings used throughout the application
# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
# Regular strings
# Profile about/bio field label
About_00c0 = About
# Column title for account management
Accounts_f018 = Accounts
# Button label to add a relay
Add_269d = Add
# Label for add column button
Add_47df = Add
# Button label to add a different wallet
Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = Add a different wallet that will only be used for this account
# Error message for missing wallet
Add_a_wallet_to_continue_d170 = Add a wallet to continue
# Button label to add a new account
Add_account_1cfc = Add account
# Column title for adding new account
Add_Account_d06c = Add Account
# Column title for adding algorithm column
Add_Algo_Column_0d75 = Add Algo Column
# Column title for adding new column
Add_Column_c764 = Add Column
# Column title for adding new deck
Add_Deck_fabf = Add Deck
# Column title for adding external notifications column
Add_External_Notifications_Column_41ae = Add External Notifications Column
# Column title for adding hashtag column
Add_Hashtag_Column_ebf4 = Add Hashtag Column
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Add Last Notes Column
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Add Notifications Column
# Button label to add a relay
Add_relay_269d = Add relay
# Button label to add a wallet
Add_Wallet_d1be = Add Wallet
# Title for algorithmic feeds column
Algo_2452 = Algo
# Description for algorithmic feeds column
Algorithmic_feeds_to_aid_in_note_discovery_d344 = Algorithmic feeds to aid in note discovery
# Label for zap amount input field
Amount_70f0 = Amount
# Button to send message to Dave AI assistant
Ask_b7f4 = Ask
# Placeholder text for Dave AI input field
Ask_dave_anything_33d1 = Ask dave anything...
# Profile banner URL field label
Banner_52ef = Banner
# Beta version label
BETA_8e5d = BETA
# Broadcast the note to all connected relays
Broadcast_fe43 = Broadcast
# Broadcast the note only to local network relays
Broadcast_Local_7e50 = Broadcast Local
# Button label to cancel an action
Cancel_ed3b = Cancel
# Hover text for editable zap amount
Click_to_edit_0414 = Click to edit
# Column title for note composition
Compose_Note_c094 = Compose Note
# Button label to confirm an action
Confirm_f8a6 = Confirm
# Status label for connected relay
Connected_f8cc = Connected
# Status label for connecting relay
Connecting_6b7e = Connecting...
# Title for contact list column
Contact_List_f85a = Contact List
# Column title for contact lists
Contacts_7533 = Contacts
# Column title for last notes per contact
Contacts__last_notes_3f84 = Contacts (last notes)
# Button label to copy logs
Copy_a688 = Copy
# Button to copy media link to clipboard
Copy_Link_dc7c = Copy Link
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = Copy Note ID
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Copy Note JSON
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Copy Pubkey
# Copy the text content of the note to clipboard
Copy_Text_f81c = Copy Text
# Relative time in days
count_d_b9be = {$count}d
# Relative time in hours
count_h_3ecb = {$count}h
# Relative time in minutes
count_m_b41e = {$count}m
# Relative time in months
count_mo_7aba = {$count}mo
# Relative time in seconds
count_s_aa26 = {$count}s
# Relative time in weeks
count_w_7468 = {$count}w
# Relative time in years
count_y_9408 = {$count}y
# Button to create a new account
Create_Account_6994 = Create Account
# Button label to create a new deck
Create_Deck_16b7 = Create Deck
# Column title for custom timelines
Custom_a69e = Custom
# Column title for zap amount customization
Customize_Zap_Amount_cfc4 = Customize Zap Amount
# Column title for support page
Damus_Support_27c0 = Damus Support
# Label for deck name input field
Deck_name_cd32 = Deck name
# Label for decks section in side panel
DECKS_1fad = DECKS
# Label for default zap amount input
Default_amount_per_zap_399d = Default amount per zap:
# Name of the default deck feed
Default_Deck_fcca = Default Deck
# Button label to delete a deck
Delete_Deck_bb29 = Delete Deck
# Tooltip for deleting a column
Delete_this_column_8d5a = Delete this column
# Button label to delete a wallet
Delete_Wallet_d1d4 = Delete Wallet
# Profile display name field label
Display_name_f9d9 = Display name
# Domain identification message
domain___will_be_used_for_identification_b67e = "{$domain}" will be used for identification
# Column title for editing deck
Edit_Deck_4018 = Edit Deck
# Button label to edit a deck
Edit_Deck_fd93 = Edit Deck
# Button label to edit user profile
Edit_Profile_49e6 = Edit Profile
# Column title for profile editing
Edit_Profile_8ad4 = Edit Profile
# Placeholder for hashtag input field
Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = Enter the desired hashtags here (for multiple space-separated)
# Placeholder for relay input field
Enter_the_relay_here_1c8b = Enter the relay here
# Hint text to prompt entering the user's public key.
Enter_the_user_s_key__npub__hex__nip05__here_650c = Enter the user's key (npub, hex, nip05) here...
# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
Enter_your_key_0fca = Enter your key
# Instructions for entering Nostr credentials
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = Enter your public key (npub), nostr address (e.g. {$address}), or private key (nsec). You must enter your private key to be able to post, reply, etc.
# Label for find user button
Find_User_bd12 = Find User
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Title for Home column
Home_8c19 = Home
# Label for deck icon selection
Icon_b0ab = Icon
# Title for individual user column
Individual_b776 = Individual
# Error message for invalid zap amount
Invalid_amount_6630 = Invalid amount
# Error message for invalid key input
Invalid_key_4726 = Invalid key.
# Error message for invalid Nostr Wallet Connect URI
Invalid_NWC_URI_031b = Invalid NWC URI
# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
k_100K_686c = 100K
# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
k_10K_f7e6 = 10K
# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
k_20K_4977 = 20K
# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
k_50K_c2dc = 50K
# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Keep track of your notes & replies
# Title for last note per user column
Last_Note_per_User_17ad = Last Note per User
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = Lightning network address (lud16)
# Login page title
Login_9eef = Login
# Login button text
Login_now___let_s_do_this_5630 = Login now — let's do this!
# Text shown on blurred media from unfollowed users
Media_from_someone_you_don_t_follow_5611 = Media from someone you don't follow
# Tooltip for moving a column
Moves_this_column_to_another_position_0d4b = Moves this column to another position
# Title for the user's deck
My_Deck_4ac5 = My Deck
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = New to Nostr?
# NIP-05 identity field label
Nostr_address__NIP-05_identity_74a2 = Nostr address (NIP-05 identity)
# Default username when profile is not available
nostrich_df29 = nostrich
# Status label for disconnected relay
Not_Connected_6292 = Not Connected
# Link text for note references
note_cad6 = note
# Beta product warning message
Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck is a beta product. Expect bugs and contact us when you run into issues.
# Filter label for notes only view
Notes_03fb = Notes
# Label for notes-only filter
Notes_60d2 = Notes
# Filter label for notes and replies view
Notes___Replies_1ec2 = Notes & Replies
# Label for notes and replies filter
Notes___Replies_6e3b = Notes & Replies
# Column title for notifications
Notifications_d673 = Notifications
# Title for notifications column
Notifications_ef56 = Notifications
# Relative time for very recent events (less than 3 seconds)
now_2181 = now
# Button label to open email client
Open_Email_25e9 = Open Email
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Open your default email client to get help from the Damus team
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = Paste your NWC URI here...
# Error message for missing deck name
Please_create_a_name_for_the_deck_38e7 = Please create a name for the deck.
# Error message for missing deck name and icon
Please_create_a_name_for_the_deck_and_select_an_icon_0add = Please create a name for the deck and select an icon.
# Error message for missing deck icon
Please_select_an_icon_655b = Please select an icon.
# Button label to post a note
Post_now_8a49 = Post now
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Press the button below to copy your most recent logs to your system's clipboard. Then paste it into your email.
# Profile picture URL field label
Profile_picture_81ff = Profile picture
# Column title for quote composition
Quote_475c = Quote
# Error message when quote note cannot be found
Quote_of_unknown_note_e4f0 = Quote of unknown note
# Label for read-only profile mode
Read_only_82ff = Read only
# Column title for relay management
Relays_9d89 = Relays
# Label for relay list section
Relays_ad5e = Relays
# Column title for reply composition
Reply_3bf1 = Reply
# Hover text for reply button
Reply_to_this_note_f5de = Reply to this note
# Error message when reply note cannot be found
Reply_to_unknown_note_4401 = Reply to unknown note
# Fallback template for replying to user
replying_to__user_15ab = replying to {$user}
# Template for replying to user in unknown thread
replying_to__user__in_someone_s_thread_e148 = replying to {$user} in someone's thread
# Template for replying to note in different user's thread
replying_to__user__s__note__in__thread_user__s__thread_daa8 = replying to {$user}'s {$note} in {$thread_user}'s {$thread}
# Template for replying to user's note
replying_to__user__s__note_ccba = replying to {$user}'s {$note}
# Template for replying to root thread
replying_to__user__s__thread_444d = replying to {$user}'s {$thread}
# Fallback text when reply note is not found
replying_to_a_note_e0bc = replying to a note
# Hover text for repost button
Repost_this_note_8e56 = Repost this note
# Label for reposted notes
Reposted_61c8 = Reposted
# Heading for support section
Running_into_a_bug_1796 = Running into a bug?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
SATS_45d7 = SATS
# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
sats_e5ec = sats
# Button to save default zap amount
Save_6f7c = Save
# Button label to save profile changes
Save_changes_00db = Save changes
# Column title for search page
Search_c573 = Search
# Placeholder for search notes input field
Search_notes_42a6 = Search notes...
# Search in progress message
Searching_for___query_5d18 = Searching for '{$query}'
# Description for Home column
See_notes_from_your_contacts_ac16 = See notes from your contacts
# Description for universe column
See_the_whole_nostr_universe_7694 = See the whole nostr universe
# Button label to send a zap
Send_1ea4 = Send
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Show the last note for each user from a list
# Button label to sign out of account
Sign_out_337b = Sign out
# Title for someone else's notes column
Someone_else_s_Notes_7e5f = Someone else's Notes
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Someone else's Notifications
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Source the last note for each user in your contact list
# Description for hashtags column
Stay_up_to_date_with_a_certain_hashtag_88e3 = Stay up to date with a certain hashtag
# Description for notifications column
Stay_up_to_date_with_notifications_and_mentions_6f4e = Stay up to date with notifications and mentions
# Description for someone else's notes column
Stay_up_to_date_with_someone_else_s_notes___replies_464c = Stay up to date with someone else's notes & replies
# Description for someone else's notifications column
Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = Stay up to date with someone else's notifications and mentions
# Description for individual user column
Stay_up_to_date_with_someone_s_notes___replies_aa78 = Stay up to date with someone's notes & replies
# Description for your notifications column
Stay_up_to_date_with_your_notifications_and_mentions_e73e = Stay up to date with your notifications and mentions
# Step 1 label in support instructions
Step_1_8656 = Step 1
# Step 2 label in support instructions
Step_2_d08d = Step 2
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = Subscribe to someone else's notes
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = Subscribe to someone's notes
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Switch to dark mode
# Hover text for light mode toggle button
Switch_to_light_mode_72ce = Switch to light mode
# Button text to load blurred media
Tap_to_Load_4b05 = Tap to Load
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = The Dave Nostr AI assistant trial has ended :(. Thanks for testing! Zap-enabled Dave coming soon!
# Column title for note thread view
Thread_0f20 = Thread
# Link text for thread references
thread_ad1f = thread
# Title for universe column
Universe_e01e = Universe
# Column title for universe feed
Universe_ffaa = Universe
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = Use this wallet for the current account only
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = "{$username}" at "{$domain}" will be used for identification
# Profile username field label
Username_daa7 = Username
# Column title for wallet management
Wallet_5e50 = Wallet
# Hint for deck name input field
We_recommend_short_names_083e = We recommend short names
# Profile website field label
Website_7980 = Website
# Placeholder for note input field
Write_a_banger_note_here_bad2 = Write a banger note here...
# Placeholder text for key input field
Your_key_here_81bd = Your key here...
# Title for your notes column
Your_Notes_f6db = Your Notes
# Title for your notifications column
Your_Notifications_080d = Your Notifications
# Heading for zap (tip) action
Zap_16b4 = Zap
# Hover text for zap button
Zap_this_note_42b2 = Zap this note
# Pluralized strings
# Search results count
Got__count__results_for___query_85fb =
{ $count ->
[one] Got {$count} result for '{$query}'
*[other] Got {$count} results for '{$query}'
}

View File

@@ -0,0 +1 @@
universe-title = Universe with Fluent Translation

View File

@@ -1,542 +0,0 @@
# Main translation file for Notedeck
# This file contains common UI strings used throughout the application
# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
# Regular strings
# Profile about/bio field label
About_00c0 = {"["}Àbóút{"]"}
# Column title for account management
Accounts_f018 = {"["}Àççóúñts{"]"}
# Button label to add a relay
Add_269d = {"["}Àdd{"]"}
# Label for add column button
Add_47df = {"["}Àdd{"]"}
# Button label to add a different wallet
Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = {"["}Àdd à dífféréñt wàllét thàt wíll óñly bé úséd fór thís àççóúñt{"]"}
# Error message for missing wallet
Add_a_wallet_to_continue_d170 = {"["}Àdd à wàllét tó çóñtíñúé{"]"}
# Button label to add a new account
Add_account_1cfc = {"["}Àdd àççóúñt{"]"}
# Column title for adding new account
Add_Account_d06c = {"["}Àdd Àççóúñt{"]"}
# Column title for adding algorithm column
Add_Algo_Column_0d75 = {"["}Àdd Àlgó Çólúmñ{"]"}
# Column title for adding new column
Add_Column_c764 = {"["}Àdd Çólúmñ{"]"}
# Column title for adding new deck
Add_Deck_fabf = {"["}Àdd Déçk{"]"}
# Column title for adding external notifications column
Add_External_Notifications_Column_41ae = {"["}Àdd Éxtérñàl Ñótífíçàtíóñs Çólúmñ{"]"}
# Column title for adding hashtag column
Add_Hashtag_Column_ebf4 = {"["}Àdd Hàshtàg Çólúmñ{"]"}
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = {"["}Àdd Làst Ñótés Çólúmñ{"]"}
# Column title for adding notifications column
Add_Notifications_Column_79f8 = {"["}Àdd Ñótífíçàtíóñs Çólúmñ{"]"}
# Button label to add a relay
Add_relay_269d = {"["}Àdd rélày{"]"}
# Button label to add a wallet
Add_Wallet_d1be = {"["}Àdd Wàllét{"]"}
# Title for algorithmic feeds column
Algo_2452 = {"["}Àlgó{"]"}
# Description for algorithmic feeds column
Algorithmic_feeds_to_aid_in_note_discovery_d344 = {"["}Àlgóríthmíç fééds tó àíd íñ ñóté dísçóvéry{"]"}
# Label for zap amount input field
Amount_70f0 = {"["}Àmóúñt{"]"}
# Button to send message to Dave AI assistant
Ask_b7f4 = {"["}Àsk{"]"}
# Placeholder text for Dave AI input field
Ask_dave_anything_33d1 = {"["}Àsk dàvé àñythíñg...{"]"}
# Profile banner URL field label
Banner_52ef = {"["}Bàññér{"]"}
# Beta version label
BETA_8e5d = {"["}BÉTÀ{"]"}
# Broadcast the note to all connected relays
Broadcast_fe43 = {"["}Bróàdçàst{"]"}
# Broadcast the note only to local network relays
Broadcast_Local_7e50 = {"["}Bróàdçàst Lóçàl{"]"}
# Button label to cancel an action
Cancel_ed3b = {"["}Çàñçél{"]"}
# Hover text for editable zap amount
Click_to_edit_0414 = {"["}Çlíçk tó édít{"]"}
# Column title for note composition
Compose_Note_c094 = {"["}Çómpósé Ñóté{"]"}
# Button label to confirm an action
Confirm_f8a6 = {"["}Çóñfírm{"]"}
# Status label for connected relay
Connected_f8cc = {"["}Çóññéçtéd{"]"}
# Status label for connecting relay
Connecting_6b7e = {"["}Çóññéçtíñg...{"]"}
# Title for contact list column
Contact_List_f85a = {"["}Çóñtàçt Líst{"]"}
# Column title for contact lists
Contacts_7533 = {"["}Çóñtàçts{"]"}
# Column title for last notes per contact
Contacts__last_notes_3f84 = {"["}Çóñtàçts (làst ñótés){"]"}
# Button label to copy logs
Copy_a688 = {"["}Çópy{"]"}
# Button to copy media link to clipboard
Copy_Link_dc7c = {"["}Çópy Líñk{"]"}
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = {"["}Çópy Ñóté ÍD{"]"}
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = {"["}Çópy Ñóté JSÓÑ{"]"}
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = {"["}Çópy Púbkéy{"]"}
# Copy the text content of the note to clipboard
Copy_Text_f81c = {"["}Çópy Téxt{"]"}
# Relative time in days
count_d_b9be = {"["}{$count}d{"]"}
# Relative time in hours
count_h_3ecb = {"["}{$count}h{"]"}
# Relative time in minutes
count_m_b41e = {"["}{$count}m{"]"}
# Relative time in months
count_mo_7aba = {"["}{$count}mó{"]"}
# Relative time in seconds
count_s_aa26 = {"["}{$count}s{"]"}
# Relative time in weeks
count_w_7468 = {"["}{$count}w{"]"}
# Relative time in years
count_y_9408 = {"["}{$count}y{"]"}
# Button to create a new account
Create_Account_6994 = {"["}Çréàté Àççóúñt{"]"}
# Button label to create a new deck
Create_Deck_16b7 = {"["}Çréàté Déçk{"]"}
# Column title for custom timelines
Custom_a69e = {"["}Çústóm{"]"}
# Column title for zap amount customization
Customize_Zap_Amount_cfc4 = {"["}Çústómízé Zàp Àmóúñt{"]"}
# Column title for support page
Damus_Support_27c0 = {"["}Dàmús Súppórt{"]"}
# Label for deck name input field
Deck_name_cd32 = {"["}Déçk ñàmé{"]"}
# Label for decks section in side panel
DECKS_1fad = {"["}DÉÇKS{"]"}
# Label for default zap amount input
Default_amount_per_zap_399d = {"["}Défàúlt àmóúñt pér zàp:{"]"}
# Name of the default deck feed
Default_Deck_fcca = {"["}Défàúlt Déçk{"]"}
# Button label to delete a deck
Delete_Deck_bb29 = {"["}Délété Déçk{"]"}
# Tooltip for deleting a column
Delete_this_column_8d5a = {"["}Délété thís çólúmñ{"]"}
# Button label to delete a wallet
Delete_Wallet_d1d4 = {"["}Délété Wàllét{"]"}
# Profile display name field label
Display_name_f9d9 = {"["}Dísplày ñàmé{"]"}
# Domain identification message
domain___will_be_used_for_identification_b67e = {"["}"{$domain}" wíll bé úséd fór ídéñtífíçàtíóñ{"]"}
# Column title for editing deck
Edit_Deck_4018 = {"["}Édít Déçk{"]"}
# Button label to edit a deck
Edit_Deck_fd93 = {"["}Édít Déçk{"]"}
# Button label to edit user profile
Edit_Profile_49e6 = {"["}Édít Prófílé{"]"}
# Column title for profile editing
Edit_Profile_8ad4 = {"["}Édít Prófílé{"]"}
# Placeholder for hashtag input field
Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = {"["}Éñtér thé désíréd hàshtàgs héré (fór múltíplé spàçé-sépàràtéd){"]"}
# Placeholder for relay input field
Enter_the_relay_here_1c8b = {"["}Éñtér thé rélày héré{"]"}
# Hint text to prompt entering the user's public key.
Enter_the_user_s_key__npub__hex__nip05__here_650c = {"["}Éñtér thé úsér's kéy (ñpúb, héx, ñíp05) héré...{"]"}
# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
Enter_your_key_0fca = {"["}Éñtér yóúr kéy{"]"}
# Instructions for entering Nostr credentials
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = {"["}Éñtér yóúr públíç kéy (ñpúb), ñóstr àddréss (é.g. {$address}), ór prívàté kéy (ñséç). Yóú múst éñtér yóúr prívàté kéy tó bé àblé tó póst, réply, étç.{"]"}
# Label for find user button
Find_User_bd12 = {"["}Fíñd Úsér{"]"}
# Title for hashtags column
Hashtags_f8e0 = {"["}Hàshtàgs{"]"}
# Title for Home column
Home_8c19 = {"["}Hómé{"]"}
# Label for deck icon selection
Icon_b0ab = {"["}Íçóñ{"]"}
# Title for individual user column
Individual_b776 = {"["}Íñdívídúàl{"]"}
# Error message for invalid zap amount
Invalid_amount_6630 = {"["}Íñvàlíd àmóúñt{"]"}
# Error message for invalid key input
Invalid_key_4726 = {"["}Íñvàlíd kéy.{"]"}
# Error message for invalid Nostr Wallet Connect URI
Invalid_NWC_URI_031b = {"["}Íñvàlíd ÑWÇ ÚRÍ{"]"}
# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
k_100K_686c = {"["}100K{"]"}
# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
k_10K_f7e6 = {"["}10K{"]"}
# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
k_20K_4977 = {"["}20K{"]"}
# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
k_50K_c2dc = {"["}50K{"]"}
# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
k_5K_f7e6 = {"["}5K{"]"}
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = {"["}Kéép tràçk óf yóúr ñótés & réplíés{"]"}
# Title for last note per user column
Last_Note_per_User_17ad = {"["}Làst Ñóté pér Úsér{"]"}
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = {"["}Líghtñíñg ñétwórk àddréss (lúd16){"]"}
# Login page title
Login_9eef = {"["}Lógíñ{"]"}
# Login button text
Login_now___let_s_do_this_5630 = {"["}Lógíñ ñów — lét's dó thís!{"]"}
# Text shown on blurred media from unfollowed users
Media_from_someone_you_don_t_follow_5611 = {"["}Médíà fróm sóméóñé yóú dóñ't fóllów{"]"}
# Tooltip for moving a column
Moves_this_column_to_another_position_0d4b = {"["}Móvés thís çólúmñ tó àñóthér pósítíóñ{"]"}
# Title for the user's deck
My_Deck_4ac5 = {"["}My Déçk{"]"}
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = {"["}Ñéw tó Ñóstr?{"]"}
# NIP-05 identity field label
Nostr_address__NIP-05_identity_74a2 = {"["}Ñóstr àddréss (ÑÍP-05 ídéñtíty){"]"}
# Default username when profile is not available
nostrich_df29 = {"["}ñóstríçh{"]"}
# Status label for disconnected relay
Not_Connected_6292 = {"["}Ñót Çóññéçtéd{"]"}
# Link text for note references
note_cad6 = {"["}ñóté{"]"}
# Beta product warning message
Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = {"["}Ñótédéçk ís à bétà pródúçt. Éxpéçt búgs àñd çóñtàçt ús whéñ yóú rúñ íñtó íssúés.{"]"}
# Filter label for notes only view
Notes_03fb = {"["}Ñótés{"]"}
# Label for notes-only filter
Notes_60d2 = {"["}Ñótés{"]"}
# Filter label for notes and replies view
Notes___Replies_1ec2 = {"["}Ñótés & Réplíés{"]"}
# Label for notes and replies filter
Notes___Replies_6e3b = {"["}Ñótés & Réplíés{"]"}
# Column title for notifications
Notifications_d673 = {"["}Ñótífíçàtíóñs{"]"}
# Title for notifications column
Notifications_ef56 = {"["}Ñótífíçàtíóñs{"]"}
# Relative time for very recent events (less than 3 seconds)
now_2181 = {"["}ñów{"]"}
# Button label to open email client
Open_Email_25e9 = {"["}Ópéñ Émàíl{"]"}
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = {"["}Ópéñ yóúr défàúlt émàíl çlíéñt tó gét hélp fróm thé Dàmús téàm{"]"}
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = {"["}Pàsté yóúr ÑWÇ ÚRÍ héré...{"]"}
# Error message for missing deck name
Please_create_a_name_for_the_deck_38e7 = {"["}Pléàsé çréàté à ñàmé fór thé déçk.{"]"}
# Error message for missing deck name and icon
Please_create_a_name_for_the_deck_and_select_an_icon_0add = {"["}Pléàsé çréàté à ñàmé fór thé déçk àñd séléçt àñ íçóñ.{"]"}
# Error message for missing deck icon
Please_select_an_icon_655b = {"["}Pléàsé séléçt àñ íçóñ.{"]"}
# Button label to post a note
Post_now_8a49 = {"["}Póst ñów{"]"}
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = {"["}Préss thé búttóñ bélów tó çópy yóúr móst réçéñt lógs tó yóúr systém's çlípbóàrd. Théñ pàsté ít íñtó yóúr émàíl.{"]"}
# Profile picture URL field label
Profile_picture_81ff = {"["}Prófílé píçtúré{"]"}
# Column title for quote composition
Quote_475c = {"["}Qúóté{"]"}
# Error message when quote note cannot be found
Quote_of_unknown_note_e4f0 = {"["}Qúóté óf úñkñówñ ñóté{"]"}
# Label for read-only profile mode
Read_only_82ff = {"["}Réàd óñly{"]"}
# Column title for relay management
Relays_9d89 = {"["}Rélàys{"]"}
# Label for relay list section
Relays_ad5e = {"["}Rélàys{"]"}
# Column title for reply composition
Reply_3bf1 = {"["}Réply{"]"}
# Hover text for reply button
Reply_to_this_note_f5de = {"["}Réply tó thís ñóté{"]"}
# Error message when reply note cannot be found
Reply_to_unknown_note_4401 = {"["}Réply tó úñkñówñ ñóté{"]"}
# Fallback template for replying to user
replying_to__user_15ab = {"["}réplyíñg tó {$user}{"]"}
# Template for replying to user in unknown thread
replying_to__user__in_someone_s_thread_e148 = {"["}réplyíñg tó {$user} íñ sóméóñé's thréàd{"]"}
# Template for replying to note in different user's thread
replying_to__user__s__note__in__thread_user__s__thread_daa8 = {"["}réplyíñg tó {$user}'s {$note} íñ {$thread_user}'s {$thread}{"]"}
# Template for replying to user's note
replying_to__user__s__note_ccba = {"["}réplyíñg tó {$user}'s {$note}{"]"}
# Template for replying to root thread
replying_to__user__s__thread_444d = {"["}réplyíñg tó {$user}'s {$thread}{"]"}
# Fallback text when reply note is not found
replying_to_a_note_e0bc = {"["}réplyíñg tó à ñóté{"]"}
# Hover text for repost button
Repost_this_note_8e56 = {"["}Répóst thís ñóté{"]"}
# Label for reposted notes
Reposted_61c8 = {"["}Répóstéd{"]"}
# Heading for support section
Running_into_a_bug_1796 = {"["}Rúññíñg íñtó à búg?{"]"}
# Label for satoshis (Bitcoin unit) for custom zap amount input field
SATS_45d7 = {"["}SÀTS{"]"}
# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
sats_e5ec = {"["}sàts{"]"}
# Button to save default zap amount
Save_6f7c = {"["}Sàvé{"]"}
# Button label to save profile changes
Save_changes_00db = {"["}Sàvé çhàñgés{"]"}
# Column title for search page
Search_c573 = {"["}Séàrçh{"]"}
# Placeholder for search notes input field
Search_notes_42a6 = {"["}Séàrçh ñótés...{"]"}
# Search in progress message
Searching_for___query_5d18 = {"["}Séàrçhíñg fór '{$query}'{"]"}
# Description for Home column
See_notes_from_your_contacts_ac16 = {"["}Séé ñótés fróm yóúr çóñtàçts{"]"}
# Description for universe column
See_the_whole_nostr_universe_7694 = {"["}Séé thé whólé ñóstr úñívérsé{"]"}
# Button label to send a zap
Send_1ea4 = {"["}Séñd{"]"}
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = {"["}Shów thé làst ñóté fór éàçh úsér fróm à líst{"]"}
# Button label to sign out of account
Sign_out_337b = {"["}Sígñ óút{"]"}
# Title for someone else's notes column
Someone_else_s_Notes_7e5f = {"["}Sóméóñé élsé's Ñótés{"]"}
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = {"["}Sóméóñé élsé's Ñótífíçàtíóñs{"]"}
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = {"["}Sóúrçé thé làst ñóté fór éàçh úsér íñ yóúr çóñtàçt líst{"]"}
# Description for hashtags column
Stay_up_to_date_with_a_certain_hashtag_88e3 = {"["}Stày úp tó dàté wíth à çértàíñ hàshtàg{"]"}
# Description for notifications column
Stay_up_to_date_with_notifications_and_mentions_6f4e = {"["}Stày úp tó dàté wíth ñótífíçàtíóñs àñd méñtíóñs{"]"}
# Description for someone else's notes column
Stay_up_to_date_with_someone_else_s_notes___replies_464c = {"["}Stày úp tó dàté wíth sóméóñé élsé's ñótés & réplíés{"]"}
# Description for someone else's notifications column
Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = {"["}Stày úp tó dàté wíth sóméóñé élsé's ñótífíçàtíóñs àñd méñtíóñs{"]"}
# Description for individual user column
Stay_up_to_date_with_someone_s_notes___replies_aa78 = {"["}Stày úp tó dàté wíth sóméóñé's ñótés & réplíés{"]"}
# Description for your notifications column
Stay_up_to_date_with_your_notifications_and_mentions_e73e = {"["}Stày úp tó dàté wíth yóúr ñótífíçàtíóñs àñd méñtíóñs{"]"}
# Step 1 label in support instructions
Step_1_8656 = {"["}Stép 1{"]"}
# Step 2 label in support instructions
Step_2_d08d = {"["}Stép 2{"]"}
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = {"["}Súbsçríbé tó sóméóñé élsé's ñótés{"]"}
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = {"["}Súbsçríbé tó sóméóñé's ñótés{"]"}
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = {"["}Swítçh tó dàrk módé{"]"}
# Hover text for light mode toggle button
Switch_to_light_mode_72ce = {"["}Swítçh tó líght módé{"]"}
# Button text to load blurred media
Tap_to_Load_4b05 = {"["}Tàp tó Lóàd{"]"}
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = {"["}Thé Dàvé Ñóstr ÀÍ àssístàñt tríàl hàs éñdéd :(. Thàñks fór téstíñg! Zàp-éñàbléd Dàvé çómíñg sóóñ!{"]"}
# Column title for note thread view
Thread_0f20 = {"["}Thréàd{"]"}
# Link text for thread references
thread_ad1f = {"["}thréàd{"]"}
# Title for universe column
Universe_e01e = {"["}Úñívérsé{"]"}
# Column title for universe feed
Universe_ffaa = {"["}Úñívérsé{"]"}
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = {"["}Úsé thís wàllét fór thé çúrréñt àççóúñt óñly{"]"}
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = {"["}"{$username}" àt "{$domain}" wíll bé úséd fór ídéñtífíçàtíóñ{"]"}
# Profile username field label
Username_daa7 = {"["}Úsérñàmé{"]"}
# Column title for wallet management
Wallet_5e50 = {"["}Wàllét{"]"}
# Hint for deck name input field
We_recommend_short_names_083e = {"["}Wé réçómméñd shórt ñàmés{"]"}
# Profile website field label
Website_7980 = {"["}Wébsíté{"]"}
# Placeholder for note input field
Write_a_banger_note_here_bad2 = {"["}Wríté à bàñgér ñóté héré...{"]"}
# Placeholder text for key input field
Your_key_here_81bd = {"["}Yóúr kéy héré...{"]"}
# Title for your notes column
Your_Notes_f6db = {"["}Yóúr Ñótés{"]"}
# Title for your notifications column
Your_Notifications_080d = {"["}Yóúr Ñótífíçàtíóñs{"]"}
# Heading for zap (tip) action
Zap_16b4 = {"["}Zàp{"]"}
# Hover text for zap button
Zap_this_note_42b2 = {"["}Zàp thís ñóté{"]"}
# Pluralized strings
# Search results count
Got__count__results_for___query_85fb =
{ $count ->
[one] {"["}Gót {$count} résúlt fór '{$query}'{"]"}
*[other] {"["}Gót {$count} résúlts fór '{$query}'{"]"}
}

View File

@@ -1,430 +0,0 @@
# Main translation file for Notedeck
# This file contains common UI strings used throughout the application
# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
# Regular strings
# Profile about/bio field label
About_00c0 = A propos
# Display name for account management
Accounts_e233 = Comptes
# Column title for account management
Accounts_f018 = Comptes
# Button label to add a relay
Add_269d = Ajouter
# Label for add column button
Add_47df = Ajouter
# Button label to add a different wallet
Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = Ajouter un portefeuille différent qui ne sera utilisé que pour ce compte
# Error message for missing wallet
Add_a_wallet_to_continue_d170 = Ajouter un portefeuille pour continuer
# Button label to add a new account
Add_account_1cfc = Ajouter un compte
# Column title for adding new account
Add_Account_d06c = Ajouter un compte
# Display name for adding account
Add_Account_d715 = Ajouter un compte
# Column title for adding algorithm column
Add_Algo_Column_0d75 = Ajouter une colonne Algo
# Display name for adding column
Add_Column_c6ff = Ajouter une colonne
# Column title for adding new column
Add_Column_c764 = Ajouter une colonne
# Display name for adding deck
Add_Deck_6e5f = Ajouter un deck
# Column title for adding new deck
Add_Deck_fabf = Ajouter un deck
# Column title for adding external notifications column
Add_External_Notifications_Column_41ae = Ajouter une colonne pour les notifications externes
# Column title for adding hashtag column
Add_Hashtag_Column_ebf4 = Ajouter une colonne Hashtag
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = Ajouter une colonne pour les dernières notes
# Column title for adding notifications column
Add_Notifications_Column_79f8 = Ajouter une colonne pour les notifications
# Button label to add a relay
Add_relay_269d = Ajouter un relai
# Button label to add a wallet
Add_Wallet_d1be = Ajouter un portefeuille
# Title for algorithmic feeds column
Algo_2452 = Algo
# Description for algorithmic feeds column
Algorithmic_feeds_to_aid_in_note_discovery_d344 = Des fils algorithmiques pour faciliter la découverte de notes
# Label for zap amount input field
Amount_70f0 = Montant
# Button to send message to Dave AI assistant
Ask_b7f4 = Demander
# Placeholder text for Dave AI input field
Ask_dave_anything_33d1 = Demandez à Dave n'importe quoi...
# Profile banner URL field label
Banner_52ef = Bannière
# Beta version label
BETA_8e5d = BETA
# Broadcast the note to all connected relays
Broadcast_fe43 = Diffusion
# Broadcast the note only to local network relays
Broadcast_Local_7e50 = Diffusion locale
# Button label to cancel an action
Cancel_ed3b = Annuler
# Hover text for editable zap amount
Click_to_edit_0414 = Cliquer pour modifier
# Display name for note composition
Compose_Note_ad11 = Ecrire une note
# Column title for note composition
Compose_Note_c094 = Ecrire une note
# Button label to confirm an action
Confirm_f8a6 = Confirmer
# Status label for connected relay
Connected_f8cc = Connecté
# Status label for connecting relay
Connecting_6b7e = Connexion...
# Title for contact list column
Contact_List_f85a = Liste de contacts
# Column title for contact lists
Contacts_7533 = Contacts
# Timeline kind label for contact lists
Contacts_8b98 = Contacts
# Column title for last notes per contact
Contacts__last_notes_3f84 = Contacts (dernières notes)
# Button label to copy logs
Copy_a688 = Copier
# Button to copy media link to clipboard
Copy_Link_dc7c = Copier le lien
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = Copier l'ID de la note
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = Copier le JSON de la note
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = Copier la Pubkey
# Copy the text content of the note to clipboard
Copy_Text_f81c = Copier le texte
# Relative time in days
count_d_b9be = { $count }j
# Relative time in hours
count_h_3ecb = { $count }h
# Relative time in minutes
count_m_b41e = { $count }min
# Relative time in months
count_mo_7aba = { $count }m
# Relative time in seconds
count_s_aa26 = { $count }s
# Relative time in weeks
count_w_7468 = { $count }sem
# Relative time in years
count_y_9408 = { $count }a
# Button to create a new account
Create_Account_6994 = Créer un compte
# Button label to create a new deck
Create_Deck_16b7 = Créer un deck
# Column title for custom timelines
Custom_a69e = Personnaliser
# Display name for custom timelines
Custom_cb4f = Personnaliser
# Column title for zap amount customization
Customize_Zap_Amount_cfc4 = Personnaliser le montant du Zap
# Display name for zap customization
Customize_Zap_Amount_ed29 = Personnaliser le montant du Zap
# Column title for support page
Damus_Support_27c0 = Assistance Damus
# Label for deck name input field
Deck_name_cd32 = Nom du deck
# Label for decks section in side panel
DECKS_1fad = DECKS
# Label for default zap amount input
Default_amount_per_zap_399d = Montant par défaut pour un Zap :
# Name of the default deck feed
Default_Deck_fcca = Deck par défaut
# Button label to delete a deck
Delete_Deck_bb29 = Supprimer le deck
# Tooltip for deleting a column
Delete_this_column_8d5a = Supprimer cette colonne
# Button label to delete a wallet
Delete_Wallet_d1d4 = Supprimer le portefeuille
# Profile display name field label
Display_name_f9d9 = Nom d'utilisateur
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" sera utilisé pour l'identification
# Column title for editing deck
Edit_Deck_4018 = Modifier le deck
# Display name for editing deck
Edit_Deck_c9ba = Modifier le deck
# Button label to edit a deck
Edit_Deck_fd93 = Modifier le deck
# Button label to edit user profile
Edit_Profile_49e6 = Modifier le profil
# Display name for profile editing
Edit_Profile_6699 = Modifier le profil
# Column title for profile editing
Edit_Profile_8ad4 = Modifier le profil
# Placeholder for hashtag input field
Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = Entrez les hashtags souhaités ici (séparez-les avec un espace)
# Placeholder for relay input field
Enter_the_relay_here_1c8b = Entrer un relai ici
# Hint text to prompt entering the user's public key.
Enter_the_user_s_key__npub__hex__nip05__here_650c = Entrer ici la clé de l'utilisateur (npub, hex, nip05)...
# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
Enter_your_key_0fca = Entrez votre clé
# Instructions for entering Nostr credentials
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = Entrez votre clé publique (npub), votre adresse nostr (par exemple { $address }), ou votre clé privée (nsec). Vous devez entrer votre clé privée pour pouvoir poster, répondre, etc.
# Label for find user button
Find_User_bd12 = Trouver un utilisateur
# Timeline kind label for hashtag feeds
Hashtag_a0ab = Hashtag
# Display name for hashtag feeds
Hashtags_617e = Hashtags
# Title for hashtags column
Hashtags_f8e0 = Hashtags
# Display name for home feed
Home_3efc = Accueil
# Title for Home column
Home_8c19 = Accueil
# Label for deck icon selection
Icon_b0ab = Icone
# Title for individual user column
Individual_b776 = Individuel
# Error message for invalid zap amount
Invalid_amount_6630 = Montant invalide
# Error message for invalid key input
Invalid_key_4726 = Clé non valide.
# Error message for invalid Nostr Wallet Connect URI
Invalid_NWC_URI_031b = Invalide NWC URI
# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
k_100K_686c = 100K
# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
k_10K_f7e6 = 10K
# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
k_20K_4977 = 20K
# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
k_50K_c2dc = 50K
# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
k_5K_f7e6 = 5K
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = Gardez une trace de vos notes & réponses
# Title for last note per user column
Last_Note_per_User_17ad = Dernière note par utilisateur
# Timeline kind label for last notes per pubkey
Last_Notes_aefe = Dernières notes
# Display name for last notes per contact
Last_Per_Pubkey__Contact_33ce = Dernière par Pubkey (Contact)
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = Adresse réseau Lightning (lud16)
# Login page title
Login_9eef = Se connecter
# Login button text
Login_now___let_s_do_this_5630 = Se connecter maintenant - c'est parti !
# Text shown on blurred media from unfollowed users
Media_from_someone_you_don_t_follow_5611 = Média d'une personne que vous ne suivez pas
# Tooltip for moving a column
Moves_this_column_to_another_position_0d4b = Déplace cette colonne vers une autre position
# Title for the user's deck
My_Deck_4ac5 = Mon deck
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = Nouveau sur Nostr ?
# NIP-05 identity field label
Nostr_address__NIP-05_identity_74a2 = Adresse Nostr (NIP-05 identité)
# Default username when profile is not available
nostrich_df29 = nostrich
# Status label for disconnected relay
Not_Connected_6292 = Non connecté
# Link text for note references
note_cad6 = note
# Beta product warning message
Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck est un produit en phase beta. Attendez-vous à des bugs et contactez-nous si vous rencontrez des problèmes.
# Filter label for notes only view
Notes_03fb = Notes
# Label for notes-only filter
Notes_60d2 = Notes
# Filter label for notes and replies view
Notes___Replies_1ec2 = Notes & Réponses
# Label for notes and replies filter
Notes___Replies_6e3b = Notes & Réponses
# Timeline kind label for notifications
Notifications_6228 = Notifications
# Display name for notifications
Notifications_8029 = Notifications
# Column title for notifications
Notifications_d673 = Notifications
# Title for notifications column
Notifications_ef56 = Notifications
# Relative time for very recent events (less than 3 seconds)
now_2181 = maintenant
# Button label to open email client
Open_Email_25e9 = Ouvrir Email
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Ouvrez votre service d'email par défaut pour obtenir de l'aide de l'équipe Damus
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = Collez ici votre NWC URI...
# Error message for missing deck name
Please_create_a_name_for_the_deck_38e7 = Veuillez créer un nom pour le deck.
# Error message for missing deck name and icon
Please_create_a_name_for_the_deck_and_select_an_icon_0add = Veuillez créer un nom pour le deck et sélectionner une icône.
# Error message for missing deck icon
Please_select_an_icon_655b = Veuillez choisir une icône.
# Button label to post a note
Post_now_8a49 = Publier maintenant
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Cliquez sur le bouton ci-dessous pour copier vos données les plus récentes dans le presse-papiers de votre système. Collez-les ensuite dans votre courrier électronique.
# Display name for user profiles
Profile_2478 = Profil
# Timeline kind label for user profiles
Profile_9027 = Profil
# Profile picture URL field label
Profile_picture_81ff = Photo de profil
# Column title for quote composition
Quote_475c = Citation
# Display name for quote composition
Quote_a38e = Citation
# Error message when quote note cannot be found
Quote_of_unknown_note_e4f0 = Citation d'une note inconnue
# Label for read-only profile mode
Read_only_82ff = En lecture seule
# Display name for relay management
Relays_7335 = Relais
# Column title for relay management
Relays_9d89 = Relais
# Label for relay list section
Relays_ad5e = Relais
# Column title for reply composition
Reply_3bf1 = Répondre
# Display name for reply composition
Reply_b40f = Répondre
# Hover text for reply button
Reply_to_this_note_f5de = Répondre à cette note
# Error message when reply note cannot be found
Reply_to_unknown_note_4401 = Répondre à la note inconnue
# Fallback template for replying to user
replying_to__user_15ab = répondre à { $user }
# Template for replying to user in unknown thread
replying_to__user__in_someone_s_thread_e148 = répondre à { $user } dans le fil de discussion
# Template for replying to note in different user's thread
replying_to__user__s__note__in__thread_user__s__thread_daa8 = répondre à la { $note } de { $user } dans le { $thread } sur le { $thread_user }
# Template for replying to user's note
replying_to__user__s__note_ccba = répondre à la { $note } de { $user }
# Template for replying to root thread
replying_to__user__s__thread_444d = répondre dans le { $thread } de { $user }
# Fallback text when reply note is not found
replying_to_a_note_e0bc = répondre à une note
# Hover text for repost button
Repost_this_note_8e56 = Republier cette note
# Label for reposted notes
Reposted_61c8 = Republier
# Heading for support section
Running_into_a_bug_1796 = Vous rencontrez un problème ?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
SATS_45d7 = SATS
# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
sats_e5ec = sats
# Button to save default zap amount
Save_6f7c = Enregistrer
# Button label to save profile changes
Save_changes_00db = Enregistrer les modifications
# Display name for search results
Search_0aa0 = Recherche
# Display name for search page
Search_4503 = Rechercher
# Timeline kind label for search results
Search_a0b8 = Recherche
# Column title for search page
Search_c573 = Rechercher
# Placeholder for search notes input field
Search_notes_42a6 = Rechercher des notes...
# Search in progress message
Searching_for___query_5d18 = Recherche par '{ $query }'
# Description for Home column
See_notes_from_your_contacts_ac16 = Afficher les notes de vos contacts
# Description for universe column
See_the_whole_nostr_universe_7694 = Voir l'ensemble de l'univers nostr
# Button label to send a zap
Send_1ea4 = Envoyer
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = Afficher la dernière note de chaque utilisateur à partir d'une liste
# Button label to sign out of account
Sign_out_337b = Se déconnecter
# Title for someone else's notes column
Someone_else_s_Notes_7e5f = Notes de quelqu'un d'autre
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = Notifications de quelqu'un d'autre
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = Source de la dernière note pour chaque utilisateur de votre liste de contacts
# Description for hashtags column
Stay_up_to_date_with_a_certain_hashtag_88e3 = Restez informé sur un hashtag
# Description for notifications column
Stay_up_to_date_with_notifications_and_mentions_6f4e = Restez informé avec les notifications et les mentions
# Description for someone else's notes column
Stay_up_to_date_with_someone_else_s_notes___replies_464c = Restez informé des notes et des réponses de quelqu'un d'autre
# Description for someone else's notifications column
Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = Restez informé des notifications et mentions de quelqu'un d'autre
# Description for individual user column
Stay_up_to_date_with_someone_s_notes___replies_aa78 = Restez informé des notes et réponses de quelqu'un
# Description for your notifications column
Stay_up_to_date_with_your_notifications_and_mentions_e73e = Restez informé pour vos notifications et mentions
# Step 1 label in support instructions
Step_1_8656 = Etape 1
# Step 2 label in support instructions
Step_2_d08d = Etape 2
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = S'abonner aux notes de quelqu'un d'autre
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = S'abonner aux notes de quelqu'un
# Display name for support page
Support_a4b4 = Assistance
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = Passer en mode sombre
# Hover text for light mode toggle button
Switch_to_light_mode_72ce = Passer en mode clair
# Button text to load blurred media
Tap_to_Load_4b05 = Appuyer pour charger
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = La période d'essai de l'assistant IA Dave Nostr est terminée :(. Merci de l'avoir testé ! Un Dave compatible-Zap sera bientôt disponible !
# Column title for note thread view
Thread_0f20 = Fil
# Display name for thread view
Thread_9957 = Fil
# Link text for thread references
thread_ad1f = fil
# Generic timeline kind label
Timeline_b0fc = Chronologie
# Timeline kind label for universe feed
Universe_0a3e = Universel
# Display name for universe feed
Universe_d47e = Universel
# Title for universe column
Universe_e01e = Universel
# Column title for universe feed
Universe_ffaa = Universel
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = Utiliser ce portefeuille pour le compte actuel
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" à "{ $domain }" sera utilisé pour l'identification
# Profile username field label
Username_daa7 = Nom d'utilisateur
# Column title for wallet management
Wallet_5e50 = Portefeuille
# Display name for wallet management
Wallet_cdca = Portefeuille
# Hint for deck name input field
We_recommend_short_names_083e = Nous recommandons des noms courts
# Profile website field label
Website_7980 = Site web
# Placeholder for note input field
Write_a_banger_note_here_bad2 = Écrivez une note banger ici...
# Placeholder text for key input field
Your_key_here_81bd = Votre clé ici...
# Title for your notes column
Your_Notes_f6db = Vos Notes
# Title for your notifications column
Your_Notifications_080d = Vos notifications
# Heading for zap (tip) action
Zap_16b4 = Zap
# Hover text for zap button
Zap_this_note_42b2 = Zap cette note
# Pluralized strings
# Search results count
Got__count__results_for___query_85fb =
{ $count ->
[one] A obtenu { $count } pour '{ $query }'
*[other] A obtenu { $count } pour '{ $query }'
}

View File

@@ -1,431 +0,0 @@
# Main translation file for Notedeck
# This file contains common UI strings used throughout the application
# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
# Regular strings
# Profile about/bio field label
About_00c0 = 关于
# Display name for account management
Accounts_e233 = 帐户
# Column title for account management
Accounts_f018 = 帐户
# Button label to add a relay
Add_269d = 添加
# Label for add column button
Add_47df = 添加
# Button label to add a different wallet
Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = 添加一个仅用于此帐户的不同钱包
# Error message for missing wallet
Add_a_wallet_to_continue_d170 = 添加钱包以继续
# Button label to add a new account
Add_account_1cfc = 添加帐户
# Column title for adding new account
Add_Account_d06c = 添加帐户
# Display name for adding account
Add_Account_d715 = 添加帐户
# Column title for adding algorithm column
Add_Algo_Column_0d75 = 添加算法列
# Display name for adding column
Add_Column_c6ff = 添加列
# Column title for adding new column
Add_Column_c764 = 添加列
# Display name for adding deck
Add_Deck_6e5f = 添加仪表板
# Column title for adding new deck
Add_Deck_fabf = 添加仪表板
# Column title for adding external notifications column
Add_External_Notifications_Column_41ae = 添加外部通知列
# Column title for adding hashtag column
Add_Hashtag_Column_ebf4 = 添加标签列
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = 添加最新笔记列
# Column title for adding notifications column
Add_Notifications_Column_79f8 = 添加通知列
# Button label to add a relay
Add_relay_269d = 添加中继器
# Button label to add a wallet
Add_Wallet_d1be = 添加钱包
# Title for algorithmic feeds column
Algo_2452 = 算法
# Description for algorithmic feeds column
Algorithmic_feeds_to_aid_in_note_discovery_d344 = 用于帮助发现笔记的算法源
# Label for zap amount input field
Amount_70f0 = 金额
# Button to send message to Dave AI assistant
Ask_b7f4 = 询问
# Placeholder text for Dave AI input field
Ask_dave_anything_33d1 = 向 Dave 提问任何问题…
# Profile banner URL field label
Banner_52ef = 横幅
# Beta version label
BETA_8e5d = BETA
# Broadcast the note to all connected relays
Broadcast_fe43 = 广播
# Broadcast the note only to local network relays
Broadcast_Local_7e50 = 仅广播至本地中继
# Button label to cancel an action
Cancel_ed3b = 取消
# Hover text for editable zap amount
Click_to_edit_0414 = 点击以编辑
# Display name for note composition
Compose_Note_ad11 = 撰写笔记
# Column title for note composition
Compose_Note_c094 = 撰写笔记
# Button label to confirm an action
Confirm_f8a6 = 确认
# Status label for connected relay
Connected_f8cc = 已连接
# Status label for connecting relay
Connecting_6b7e = 正在连接...
# Title for contact list column
Contact_List_f85a = 联系人列表
# Column title for contact lists
Contacts_7533 = 联系人
# Timeline kind label for contact lists
Contacts_8b98 = 联系人
# Column title for last notes per contact
Contacts__last_notes_3f84 = 联系人(最新笔记)
# Button label to copy logs
Copy_a688 = 复制
# Button to copy media link to clipboard
Copy_Link_dc7c = 复制链接
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = 复制笔记 ID
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = 复制笔记 JSON
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = 复制公钥
# Copy the text content of the note to clipboard
Copy_Text_f81c = 复制文本
# Relative time in days
count_d_b9be = { $count }天
# Relative time in hours
count_h_3ecb = { $count }小时
# Relative time in minutes
count_m_b41e = { $count }分钟
# Relative time in months
count_mo_7aba = { $count }月
# Relative time in seconds
count_s_aa26 = { $count }秒
# Relative time in weeks
count_w_7468 = { $count }周
# Relative time in years
count_y_9408 = { $count }年
# Button to create a new account
Create_Account_6994 = 创建帐户
# Button label to create a new deck
Create_Deck_16b7 = 创建仪表板
# Column title for custom timelines
Custom_a69e = 自定义
# Display name for custom timelines
Custom_cb4f = 自定义
# Column title for zap amount customization
Customize_Zap_Amount_cfc4 = 自定义打闪金额
# Display name for zap customization
Customize_Zap_Amount_ed29 = 自定义打闪金额
# Column title for support page
Damus_Support_27c0 = 达摩支持
# Label for deck name input field
Deck_name_cd32 = 仪表板名称
# Label for decks section in side panel
DECKS_1fad = 仪表板
# Label for default zap amount input
Default_amount_per_zap_399d = 打闪默认金额:
# Name of the default deck feed
Default_Deck_fcca = 默认仪表板
# Button label to delete a deck
Delete_Deck_bb29 = 删除仪表板
# Tooltip for deleting a column
Delete_this_column_8d5a = 删除此列
# Button label to delete a wallet
Delete_Wallet_d1d4 = 删除钱包
# Profile display name field label
Display_name_f9d9 = 显示名称
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" 将用于身份识别
# Column title for editing deck
Edit_Deck_4018 = 编辑仪表板
# Display name for editing deck
Edit_Deck_c9ba = 编辑仪表板
# Button label to edit a deck
Edit_Deck_fd93 = 编辑仪表板
# Button label to edit user profile
Edit_Profile_49e6 = 编辑个人档案
# Display name for profile editing
Edit_Profile_6699 = 编辑个人档案
# Column title for profile editing
Edit_Profile_8ad4 = 编辑个人档案
# Placeholder for hashtag input field
Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = 在此输入所需的标签 (用于多个时以空格分隔)
# Placeholder for relay input field
Enter_the_relay_here_1c8b = 在此输入中继器
# Hint text to prompt entering the user's public key.
Enter_the_user_s_key__npub__hex__nip05__here_650c = 在此输入用户的密钥npub、hex、nip05...
# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
Enter_your_key_0fca = 请输入你的密钥
# Instructions for entering Nostr credentials
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = 请输入你的公钥npub、nostr 地址(如 { $address })、或私钥(nsec)。 你必须输入你的私钥才能发帖、回复等等。
# Label for find user button
Find_User_bd12 = 查找用户
# Timeline kind label for hashtag feeds
Hashtag_a0ab = 标签
# Display name for hashtag feeds
Hashtags_617e = 标签
# Title for hashtags column
Hashtags_f8e0 = 标签
# Display name for home feed
Home_3efc = 主页
# Title for Home column
Home_8c19 = 主页
# Label for deck icon selection
Icon_b0ab = 图标
# Title for individual user column
Individual_b776 = 个人
# Error message for invalid zap amount
Invalid_amount_6630 = 无效金额
# Error message for invalid key input
Invalid_key_4726 = 无效密钥。
# Error message for invalid Nostr Wallet Connect URI
Invalid_NWC_URI_031b = 无效 NWC URI
# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
k_100K_686c = 10万
# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
k_10K_f7e6 = 1万
# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
k_20K_4977 = 2万
# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
k_50K_c2dc = 5万
# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
k_5K_f7e6 = 5千
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = 随时查看你的笔记和回复
# Title for last note per user column
Last_Note_per_User_17ad = 每个用户的最新笔记
# Timeline kind label for last notes per pubkey
Last_Notes_aefe = 最新笔记
# Display name for last notes per contact
Last_Per_Pubkey__Contact_33ce = 每个公钥(联系人)的最新笔记
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = 闪电网络地址lud16
# Login page title
Login_9eef = 登录
# Login button text
Login_now___let_s_do_this_5630 = 立即登录——让我们开始吧!
# Text shown on blurred media from unfollowed users
Media_from_someone_you_don_t_follow_5611 = 来自你不关注的用户的媒体
# Tooltip for moving a column
Moves_this_column_to_another_position_0d4b = 将此列移动到其他位置
# Title for the user's deck
My_Deck_4ac5 = 我的仪表板
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = 第一次使用 Nostr
# NIP-05 identity field label
Nostr_address__NIP-05_identity_74a2 = Nostr 地址 (NIP-05 标识符)
# Default username when profile is not available
nostrich_df29 = nostr 用户
# Status label for disconnected relay
Not_Connected_6292 = 未连接
# Link text for note references
note_cad6 = 笔记
# Beta product warning message
Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck目前是测试版产品。可能会出现故障如果遇到问题请及时联系我们。
# Filter label for notes only view
Notes_03fb = 笔记
# Label for notes-only filter
Notes_60d2 = 笔记
# Filter label for notes and replies view
Notes___Replies_1ec2 = 笔记和回复
# Label for notes and replies filter
Notes___Replies_6e3b = 笔记和回复
# Timeline kind label for notifications
Notifications_6228 = 通知
# Display name for notifications
Notifications_8029 = 通知
# Column title for notifications
Notifications_d673 = 通知
# Title for notifications column
Notifications_ef56 = 通知
# Relative time for very recent events (less than 3 seconds)
now_2181 = 刚刚
# Button label to open email client
Open_Email_25e9 = 打开电子邮箱
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = 打开你的默认电子邮件客户端以获得达摩团队的帮助
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = 在此粘贴你的 NWC URI...
# Error message for missing deck name
Please_create_a_name_for_the_deck_38e7 = 请为仪表板创建一个名称。
# Error message for missing deck name and icon
Please_create_a_name_for_the_deck_and_select_an_icon_0add = 请为仪表板创建一个名称并选择一个图标。
# Error message for missing deck icon
Please_select_an_icon_655b = 请选择一个图标。
# Button label to post a note
Post_now_8a49 = 立即发布
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = 请按下面的按钮将你最近的日志复制到系统剪贴板,然后将其粘贴到你的电子邮件。
# Display name for user profiles
Profile_2478 = 个人资料
# Timeline kind label for user profiles
Profile_9027 = 个人资料
# Profile picture URL field label
Profile_picture_81ff = 头像图片
# Column title for quote composition
Quote_475c = 引用
# Display name for quote composition
Quote_a38e = 引用
# Error message when quote note cannot be found
Quote_of_unknown_note_e4f0 = 引用未知笔记
# Label for read-only profile mode
Read_only_82ff = 只读
# Display name for relay management
Relays_7335 = 中继器
# Column title for relay management
Relays_9d89 = 中继器
# Label for relay list section
Relays_ad5e = 中继器
# Column title for reply composition
Reply_3bf1 = 回复
# Display name for reply composition
Reply_b40f = 回复
# Hover text for reply button
Reply_to_this_note_f5de = 回复此笔记
# Error message when reply note cannot be found
Reply_to_unknown_note_4401 = 回复未知笔记
# Fallback template for replying to user
replying_to__user_15ab = 正在回复{ $user }
# Template for replying to user in unknown thread
replying_to__user__in_someone_s_thread_e148 = 正在回复某人帖子中的{ $user }
# Template for replying to note in different user's thread
replying_to__user__s__note__in__thread_user__s__thread_daa8 = 正在回复在{ $thread_user }的{ $thread }中的{ $user }的{ $note }
# Template for replying to user's note
replying_to__user__s__note_ccba = 正在回复{ $user }的{ $note }
# Template for replying to root thread
replying_to__user__s__thread_444d = 正在回复{ $user }的{ $thread }
# Fallback text when reply note is not found
replying_to_a_note_e0bc = 正在回复笔记
# Hover text for repost button
Repost_this_note_8e56 = 转发此笔记
# Label for reposted notes
Reposted_61c8 = 已转发
# Heading for support section
Running_into_a_bug_1796 = 遇到故障了吗?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
SATS_45d7 = 聪
# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
sats_e5ec = 聪
# Button to save default zap amount
Save_6f7c = 保存
# Button label to save profile changes
Save_changes_00db = 保存变更
# Display name for search results
Search_0aa0 = 搜索
# Display name for search page
Search_4503 = 搜索
# Timeline kind label for search results
Search_a0b8 = 搜索
# Column title for search page
Search_c573 = 搜索
# Placeholder for search notes input field
Search_notes_42a6 = 搜索笔记...
# Search in progress message
Searching_for___query_5d18 = 正在搜索'{ $query }'
# Description for Home column
See_notes_from_your_contacts_ac16 = 查看来自你的联系人的笔记
# Description for universe column
See_the_whole_nostr_universe_7694 = 查看整个 nostr 宇宙
# Button label to send a zap
Send_1ea4 = 发送
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = 显示列表中每个用户的最新一条笔记
# Button label to sign out of account
Sign_out_337b = 登出
# Title for someone else's notes column
Someone_else_s_Notes_7e5f = 其他人的笔记
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = 其他人的通知
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = 获取你的联系人列表中每个用户的最新一条笔记
# Description for hashtags column
Stay_up_to_date_with_a_certain_hashtag_88e3 = 获取某个标签的最新动态
# Description for notifications column
Stay_up_to_date_with_notifications_and_mentions_6f4e = 获取通知和提及的最新动态
# Description for someone else's notes column
Stay_up_to_date_with_someone_else_s_notes___replies_464c = 获取其他用户的笔记和回复的最新动态
# Description for someone else's notifications column
Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = 获取其他用户的通知和提及的最新动态
# Description for individual user column
Stay_up_to_date_with_someone_s_notes___replies_aa78 = 获取某人的笔记和回复的最新动态
# Description for your notifications column
Stay_up_to_date_with_your_notifications_and_mentions_e73e = 获取你的通知和提及的最新动态
# Step 1 label in support instructions
Step_1_8656 = 第一步
# Step 2 label in support instructions
Step_2_d08d = 第二步
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = 订阅他人的笔记
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = 订阅某人的笔记
# Display name for support page
Support_a4b4 = 获取帮助
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = 切换到暗色模式
# Hover text for light mode toggle button
Switch_to_light_mode_72ce = 切换到亮色模式
# Button text to load blurred media
Tap_to_Load_4b05 = 点击加载
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = Dave Nostr AI 助手试用期已经结束 :(。感谢测试!可打闪付款的 Dave 即将来临!
# Column title for note thread view
Thread_0f20 = 帖子
# Display name for thread view
Thread_9957 = 帖子
# Link text for thread references
thread_ad1f = 帖子
# Generic timeline kind label
Timeline_b0fc = 时间线
# Timeline kind label for universe feed
Universe_0a3e = 宇宙
# Display name for universe feed
Universe_d47e = 宇宙
# Title for universe column
Universe_e01e = 宇宙
# Column title for universe feed
Universe_ffaa = 宇宙
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = 此钱包仅限用于当前帐户
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" 于 "{ $domain }" 将被用于身份识别
# Profile username field label
Username_daa7 = 用户名
# Column title for wallet management
Wallet_5e50 = 钱包
# Display name for wallet management
Wallet_cdca = 钱包
# Hint for deck name input field
We_recommend_short_names_083e = 我们推荐使用简短的名称
# Profile website field label
Website_7980 = 网站
# Placeholder for note input field
Write_a_banger_note_here_bad2 = 在这里写条超赞的笔记...
# Placeholder text for key input field
Your_key_here_81bd = 在此输入你的密钥...
# Title for your notes column
Your_Notes_f6db = 你的笔记
# Title for your notifications column
Your_Notifications_080d = 你的通知
# Heading for zap (tip) action
Zap_16b4 = 打闪
# Hover text for zap button
Zap_this_note_42b2 = 打闪此笔记
# Pluralized strings
# Search results count
Got__count__results_for___query_85fb =
{
$count ->
[one] 查询"{ $query }"得到{ $count }条结果
*[other] 查询"{ $query }"得到{ $count }条结果
}

View File

@@ -1,431 +0,0 @@
# Main translation file for Notedeck
# This file contains common UI strings used throughout the application
# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
# Regular strings
# Profile about/bio field label
About_00c0 = 關於
# Display name for account management
Accounts_e233 = 帳戶
# Column title for account management
Accounts_f018 = 帳戶
# Button label to add a relay
Add_269d = 添加
# Label for add column button
Add_47df = 添加
# Button label to add a different wallet
Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = 添加一個僅用於此帳戶的不同錢包
# Error message for missing wallet
Add_a_wallet_to_continue_d170 = 添加錢包以繼續
# Button label to add a new account
Add_account_1cfc = 新增帳戶
# Column title for adding new account
Add_Account_d06c = 新增帳戶
# Display name for adding account
Add_Account_d715 = 新增帳戶
# Column title for adding algorithm column
Add_Algo_Column_0d75 = 添加算法列
# Display name for adding column
Add_Column_c6ff = 添加列
# Column title for adding new column
Add_Column_c764 = 添加列
# Display name for adding deck
Add_Deck_6e5f = 添加儀表板
# Column title for adding new deck
Add_Deck_fabf = 添加儀表板
# Column title for adding external notifications column
Add_External_Notifications_Column_41ae = 添加外部通知列
# Column title for adding hashtag column
Add_Hashtag_Column_ebf4 = 添加標籤列
# Column title for adding last notes column
Add_Last_Notes_Column_bbad = 添加最新筆記列
# Column title for adding notifications column
Add_Notifications_Column_79f8 = 添加通知列
# Button label to add a relay
Add_relay_269d = 新增中繼器
# Button label to add a wallet
Add_Wallet_d1be = 新增錢包
# Title for algorithmic feeds column
Algo_2452 = 算法
# Description for algorithmic feeds column
Algorithmic_feeds_to_aid_in_note_discovery_d344 = 用於幫助發現筆記的算法源
# Label for zap amount input field
Amount_70f0 = 金額
# Button to send message to Dave AI assistant
Ask_b7f4 = 詢問
# Placeholder text for Dave AI input field
Ask_dave_anything_33d1 = 向 Dave 提問任何問題...
# Profile banner URL field label
Banner_52ef = 橫幅
# Beta version label
BETA_8e5d = 測試版
# Broadcast the note to all connected relays
Broadcast_fe43 = 廣播
# Broadcast the note only to local network relays
Broadcast_Local_7e50 = 僅廣播至本地中繼
# Button label to cancel an action
Cancel_ed3b = 取消
# Hover text for editable zap amount
Click_to_edit_0414 = 點擊編輯
# Display name for note composition
Compose_Note_ad11 = 撰寫筆記
# Column title for note composition
Compose_Note_c094 = 撰寫筆記
# Button label to confirm an action
Confirm_f8a6 = 確認
# Status label for connected relay
Connected_f8cc = 已連接
# Status label for connecting relay
Connecting_6b7e = 正在連接 ...
# Title for contact list column
Contact_List_f85a = 聯絡人列表
# Column title for contact lists
Contacts_7533 = 聯絡人
# Timeline kind label for contact lists
Contacts_8b98 = 聯絡人
# Column title for last notes per contact
Contacts__last_notes_3f84 = 聯絡人(最新筆記)
# Button label to copy logs
Copy_a688 = 複製
# Button to copy media link to clipboard
Copy_Link_dc7c = 複製鏈接
# Copy the unique note identifier to clipboard
Copy_Note_ID_6b45 = 複製筆記 ID
# Copy the raw note data in JSON format to clipboard
Copy_Note_JSON_9e4e = 複製筆記 JSON
# Copy the author's public key to clipboard
Copy_Pubkey_9cc4 = 複製公鑰
# Copy the text content of the note to clipboard
Copy_Text_f81c = 複製文字
# Relative time in days
count_d_b9be = { $count }天
# Relative time in hours
count_h_3ecb = { $count }小時
# Relative time in minutes
count_m_b41e = { $count }分鐘
# Relative time in months
count_mo_7aba = { $count }月
# Relative time in seconds
count_s_aa26 = { $count }秒
# Relative time in weeks
count_w_7468 = { $count }週
# Relative time in years
count_y_9408 = { $count }年
# Button to create a new account
Create_Account_6994 = 創建帳戶
# Button label to create a new deck
Create_Deck_16b7 = 創建儀表板
# Column title for custom timelines
Custom_a69e = 自訂
# Display name for custom timelines
Custom_cb4f = 自訂
# Column title for zap amount customization
Customize_Zap_Amount_cfc4 = 自訂打閃金額
# Display name for zap customization
Customize_Zap_Amount_ed29 = 自訂打閃金額
# Column title for support page
Damus_Support_27c0 = 達摩支持
# Label for deck name input field
Deck_name_cd32 = 儀表板名稱
# Label for decks section in side panel
DECKS_1fad = 儀表板
# Label for default zap amount input
Default_amount_per_zap_399d = 默認打閃金額:
# Name of the default deck feed
Default_Deck_fcca = 默認儀表板
# Button label to delete a deck
Delete_Deck_bb29 = 刪除儀表板
# Tooltip for deleting a column
Delete_this_column_8d5a = 刪除此列
# Button label to delete a wallet
Delete_Wallet_d1d4 = 刪除錢包
# Profile display name field label
Display_name_f9d9 = 顯示名稱
# Domain identification message
domain___will_be_used_for_identification_b67e = "{ $domain }" 將用於身份識別
# Column title for editing deck
Edit_Deck_4018 = 編輯儀表板
# Display name for editing deck
Edit_Deck_c9ba = 編輯儀表板
# Button label to edit a deck
Edit_Deck_fd93 = 編輯儀表板
# Button label to edit user profile
Edit_Profile_49e6 = 編輯個人檔案
# Display name for profile editing
Edit_Profile_6699 = 編輯個人檔案
# Column title for profile editing
Edit_Profile_8ad4 = 編輯個人檔案
# Placeholder for hashtag input field
Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = 在此輸入所需的標籤(用於多個時以空格分隔)
# Placeholder for relay input field
Enter_the_relay_here_1c8b = 在此輸入中繼器
# Hint text to prompt entering the user's public key.
Enter_the_user_s_key__npub__hex__nip05__here_650c = 請輸入用戶的密鑰npub、hex、nip05...
# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
Enter_your_key_0fca = 請輸入你的密鑰
# Instructions for entering Nostr credentials
Enter_your_public_key__npub___nostr_address__e_g___address____or_private_key__nsec___You_must_enter_your_private_key_to_be_able_to_post__reply__etc_48e9 = 請輸入你的公鑰npub、nostr 地址(如 { $address }、或私鑰nsec。你必須輸入你的私鑰才能發貼、回覆等等。
# Label for find user button
Find_User_bd12 = 查找用戶
# Timeline kind label for hashtag feeds
Hashtag_a0ab = 標籤
# Display name for hashtag feeds
Hashtags_617e = 標籤
# Title for hashtags column
Hashtags_f8e0 = 標籤
# Display name for home feed
Home_3efc = 主頁
# Title for Home column
Home_8c19 = 主頁
# Label for deck icon selection
Icon_b0ab = 圖標
# Title for individual user column
Individual_b776 = 個人
# Error message for invalid zap amount
Invalid_amount_6630 = 無效金額
# Error message for invalid key input
Invalid_key_4726 = 無效密鑰。
# Error message for invalid Nostr Wallet Connect URI
Invalid_NWC_URI_031b = 無效 NWC URI
# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
k_100K_686c = 10萬
# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
k_10K_f7e6 = 1萬
# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
k_20K_4977 = 2萬
# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
k_50K_c2dc = 5萬
# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
k_5K_f7e6 = 5千
# Description for your notes column
Keep_track_of_your_notes___replies_a334 = 隨時查看你的筆記和回覆
# Title for last note per user column
Last_Note_per_User_17ad = 每個用戶的最新筆記
# Timeline kind label for last notes per pubkey
Last_Notes_aefe = 最新筆記
# Display name for last notes per contact
Last_Per_Pubkey__Contact_33ce = 每個公鑰(聯繫人)的最新筆記
# Bitcoin Lightning network address field label
Lightning_network_address__lud16_ea51 = 閃電網絡地址lud16
# Login page title
Login_9eef = 登錄
# Login button text
Login_now___let_s_do_this_5630 = 立即登錄——讓我們開始吧!
# Text shown on blurred media from unfollowed users
Media_from_someone_you_don_t_follow_5611 = 來自你不關注的用戶的媒體
# Tooltip for moving a column
Moves_this_column_to_another_position_0d4b = 將此列移動到其他位置
# Title for the user's deck
My_Deck_4ac5 = 我的儀表板
# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
New_to_Nostr_a2fd = 第一次使用 Nostr
# NIP-05 identity field label
Nostr_address__NIP-05_identity_74a2 = Nostr 地址NIP-05 標識符)
# Default username when profile is not available
nostrich_df29 = nostr 用戶
# Status label for disconnected relay
Not_Connected_6292 = 未連接
# Link text for note references
note_cad6 = 筆記
# Beta product warning message
Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck 目前是測試版產品。可能會出現故障,如果遇到問題請及時聯繫我們。
# Filter label for notes only view
Notes_03fb = 筆記
# Label for notes-only filter
Notes_60d2 = 筆記
# Filter label for notes and replies view
Notes___Replies_1ec2 = 筆記和回覆
# Label for notes and replies filter
Notes___Replies_6e3b = 筆記和回覆
# Timeline kind label for notifications
Notifications_6228 = 通知
# Display name for notifications
Notifications_8029 = 通知
# Column title for notifications
Notifications_d673 = 通知
# Title for notifications column
Notifications_ef56 = 通知
# Relative time for very recent events (less than 3 seconds)
now_2181 = 剛剛
# Button label to open email client
Open_Email_25e9 = 打開電子郵箱
# Instruction to open email client
Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = 打開你的默認電子郵件客戶端以獲得達摩團隊的幫助
# Placeholder text for NWC URI input
Paste_your_NWC_URI_here_b471 = 在此貼上你的 NWC URI...
# Error message for missing deck name
Please_create_a_name_for_the_deck_38e7 = 請為儀表板創建一個名稱。
# Error message for missing deck name and icon
Please_create_a_name_for_the_deck_and_select_an_icon_0add = 請為儀表板創建一個名稱並選擇一個圖標。
# Error message for missing deck icon
Please_select_an_icon_655b = 請選擇一個圖標。
# Button label to post a note
Post_now_8a49 = 立即發布
# Instruction for copying logs
Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = 請按下面的按鈕將你最近的日誌複製到剪貼板,然後將其粘貼到你的電子郵件。
# Display name for user profiles
Profile_2478 = 個人檔案
# Timeline kind label for user profiles
Profile_9027 = 個人檔案
# Profile picture URL field label
Profile_picture_81ff = 頭像圖片
# Column title for quote composition
Quote_475c = 引用
# Display name for quote composition
Quote_a38e = 引用
# Error message when quote note cannot be found
Quote_of_unknown_note_e4f0 = 引用未知筆記
# Label for read-only profile mode
Read_only_82ff = 只讀
# Display name for relay management
Relays_7335 = 中繼器
# Column title for relay management
Relays_9d89 = 中繼器
# Label for relay list section
Relays_ad5e = 中繼器
# Column title for reply composition
Reply_3bf1 = 回覆
# Display name for reply composition
Reply_b40f = 回覆
# Hover text for reply button
Reply_to_this_note_f5de = 回覆此筆記
# Error message when reply note cannot be found
Reply_to_unknown_note_4401 = 回覆未知筆記
# Fallback template for replying to user
replying_to__user_15ab = 正在回覆{ $user }
# Template for replying to user in unknown thread
replying_to__user__in_someone_s_thread_e148 = 正在回覆某人帖子中的{ $user }
# Template for replying to note in different user's thread
replying_to__user__s__note__in__thread_user__s__thread_daa8 = 正在回覆在{ $thread_user }的{ $thread }中的{ $user }的{ $note }
# Template for replying to user's note
replying_to__user__s__note_ccba = 正在回覆{ $user }的{ $note }
# Template for replying to root thread
replying_to__user__s__thread_444d = 正在回覆{ $user }的{ $thread }
# Fallback text when reply note is not found
replying_to_a_note_e0bc = 正在回覆筆記
# Hover text for repost button
Repost_this_note_8e56 = 轉發此筆記
# Label for reposted notes
Reposted_61c8 = 已轉發
# Heading for support section
Running_into_a_bug_1796 = 遇到故障了嗎?
# Label for satoshis (Bitcoin unit) for custom zap amount input field
SATS_45d7 = 聰
# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
sats_e5ec = 聰
# Button to save default zap amount
Save_6f7c = 保存
# Button label to save profile changes
Save_changes_00db = 保存變更
# Display name for search results
Search_0aa0 = 搜索
# Display name for search page
Search_4503 = 搜索
# Timeline kind label for search results
Search_a0b8 = 搜索
# Column title for search page
Search_c573 = 搜索
# Placeholder for search notes input field
Search_notes_42a6 = 搜索筆記...
# Search in progress message
Searching_for___query_5d18 = 正在搜索「{ $query }」
# Description for Home column
See_notes_from_your_contacts_ac16 = 查看來自你的聯繫人的筆記
# Description for universe column
See_the_whole_nostr_universe_7694 = 查看整個 nostr 宇宙
# Button label to send a zap
Send_1ea4 = 發送
# Description for last note per user column
Show_the_last_note_for_each_user_from_a_list_50e7 = 顯示列表中每個用戶的最後一條筆記
# Button label to sign out of account
Sign_out_337b = 登出
# Title for someone else's notes column
Someone_else_s_Notes_7e5f = 其他人的筆記
# Title for someone else's notifications column
Someone_else_s_Notifications_82e6 = 其他人的通知
# Description for contact list column
Source_the_last_note_for_each_user_in_your_contact_list_e157 = 獲取你的聯繫人列表中每個用戶的最新一條筆記
# Description for hashtags column
Stay_up_to_date_with_a_certain_hashtag_88e3 = 獲取某個標籤的最新動態
# Description for notifications column
Stay_up_to_date_with_notifications_and_mentions_6f4e = 獲取通知和提及的最新動態
# Description for someone else's notes column
Stay_up_to_date_with_someone_else_s_notes___replies_464c = 獲取其他用戶的筆記和回覆的最新動態
# Description for someone else's notifications column
Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = 獲取其他用戶的通知和提及的最新動態
# Description for individual user column
Stay_up_to_date_with_someone_s_notes___replies_aa78 = 獲取某人的筆記和回覆的最新動態
# Description for your notifications column
Stay_up_to_date_with_your_notifications_and_mentions_e73e = 獲取你的通知和提及的最新動態
# Step 1 label in support instructions
Step_1_8656 = 第一步
# Step 2 label in support instructions
Step_2_d08d = 第二步
# Column title for subscribing to external user
Subscribe_to_someone_else_s_notes_d1e9 = 訂閱他人的筆記
# Column title for subscribing to individual user
Subscribe_to_someone_s_notes_b3c8 = 訂閱某人的筆記
# Display name for support page
Support_a4b4 = 獲取幫助
# Hover text for dark mode toggle button
Switch_to_dark_mode_4dec = 切換到暗色模式
# Hover text for light mode toggle button
Switch_to_light_mode_72ce = 切換到亮色模式
# Button text to load blurred media
Tap_to_Load_4b05 = 點擊加載
# Message shown when Dave trial period has ended
The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = Dave Nostr AI 助手試用期已經結束 :(。感謝測試!可打閃付款的 Dave 即將來臨!
# Column title for note thread view
Thread_0f20 = 串文
# Display name for thread view
Thread_9957 = 串文
# Link text for thread references
thread_ad1f = 串文
# Generic timeline kind label
Timeline_b0fc = 時間線
# Timeline kind label for universe feed
Universe_0a3e = 宇宙
# Display name for universe feed
Universe_d47e = 宇宙
# Title for universe column
Universe_e01e = 宇宙
# Column title for universe feed
Universe_ffaa = 宇宙
# Checkbox label for using wallet only for current account
Use_this_wallet_for_the_current_account_only_61dc = 此錢包僅限用於當前帳戶
# Username and domain identification message
username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" 於 "{ $domain }" 將被用於身份識別
# Profile username field label
Username_daa7 = 用戶名
# Column title for wallet management
Wallet_5e50 = 錢包
# Display name for wallet management
Wallet_cdca = 錢包
# Hint for deck name input field
We_recommend_short_names_083e = 我們推薦使用簡短的名稱
# Profile website field label
Website_7980 = 網站
# Placeholder for note input field
Write_a_banger_note_here_bad2 = 在這裡寫條超讚的筆記...
# Placeholder text for key input field
Your_key_here_81bd = 在此輸入你的密鑰...
# Title for your notes column
Your_Notes_f6db = 你的筆記
# Title for your notifications column
Your_Notifications_080d = 你的通知
# Heading for zap (tip) action
Zap_16b4 = 打閃
# Hover text for zap button
Zap_this_note_42b2 = 打閃此筆記
# Pluralized strings
# Search results count
Got__count__results_for___query_85fb =
{
$count ->
[one] 查詢"{ $query }"得到{ $count }條結果
*[other] 查詢"{ $query }"得到{ $count }條結果
}

10
build.rs Normal file
View File

@@ -0,0 +1,10 @@
use std::process::Command;
fn main() {
if let Ok(output) = Command::new("git").args(["rev-parse", "HEAD"]).output() {
if output.status.success() {
let hash = String::from_utf8_lossy(&output.stdout);
println!("cargo:rustc-env=GIT_COMMIT_HASH={}", hash.trim());
}
}
}

View File

@@ -1,23 +0,0 @@
[package]
name = "enostr"
version = "0.3.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
ewebsock = { version = "0.8.0", features = ["tls"] }
serde_derive = { workspace = true }
serde = { workspace = true, features = ["derive"] } # You only need this if you want app persistence
serde_json = { workspace = true }
nostr = { workspace = true }
bech32 = { workspace = true }
nostrdb = { workspace = true }
hex = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
mio = { workspace = true }
tokio = { workspace = true }
tokenator = { workspace = true }
hashbrown = { workspace = true }

View File

@@ -1,3 +0,0 @@
mod message;
pub use message::{ClientMessage, EventClientMessage};

View File

@@ -1,61 +0,0 @@
//use nostr::prelude::secp256k1;
use std::array::TryFromSliceError;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("message is empty")]
Empty,
#[error("decoding failed: {0}")]
DecodeFailed(String),
#[error("hex decoding failed")]
HexDecodeFailed,
#[error("invalid bech32")]
InvalidBech32,
#[error("invalid byte size")]
InvalidByteSize,
#[error("invalid signature")]
InvalidSignature,
#[error("invalid public key")]
InvalidPublicKey,
#[error("invalid relay url")]
InvalidRelayUrl,
// Secp(secp256k1::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("nostrdb error: {0}")]
Nostrdb(#[from] nostrdb::Error),
#[error("{0}")]
Generic(String),
}
impl From<String> for Error {
fn from(s: String) -> Self {
Error::Generic(s)
}
}
impl From<TryFromSliceError> for Error {
fn from(_e: TryFromSliceError) -> Self {
Error::InvalidByteSize
}
}
impl From<hex::FromHexError> for Error {
fn from(_e: hex::FromHexError) -> Self {
Error::HexDecodeFailed
}
}

View File

@@ -1,291 +0,0 @@
use nostr::nips::nip19::FromBech32;
use nostr::nips::nip19::ToBech32;
use nostr::nips::nip49::EncryptedSecretKey;
use serde::Deserialize;
use serde::Serialize;
use tokenator::ParseError;
use tokenator::TokenParser;
use tokenator::TokenSerializable;
use crate::Pubkey;
use crate::SecretKey;
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct Keypair {
pub pubkey: Pubkey,
pub secret_key: Option<SecretKey>,
}
pub struct KeypairUnowned<'a> {
pub pubkey: &'a Pubkey,
pub secret_key: Option<&'a SecretKey>,
}
impl<'a> From<&'a Keypair> for KeypairUnowned<'a> {
fn from(value: &'a Keypair) -> Self {
Self {
pubkey: &value.pubkey,
secret_key: value.secret_key.as_ref(),
}
}
}
impl Keypair {
pub fn from_secret(secret_key: SecretKey) -> Self {
let cloned_secret_key = secret_key.clone();
let nostr_keys = nostr::Keys::new(secret_key);
Keypair {
pubkey: Pubkey::new(nostr_keys.public_key().to_bytes()),
secret_key: Some(cloned_secret_key),
}
}
pub fn new(pubkey: Pubkey, secret_key: Option<SecretKey>) -> Self {
Keypair { pubkey, secret_key }
}
pub fn only_pubkey(pubkey: Pubkey) -> Self {
Keypair {
pubkey,
secret_key: None,
}
}
pub fn to_full(&self) -> Option<FilledKeypair<'_>> {
self.secret_key.as_ref().map(|secret_key| FilledKeypair {
pubkey: &self.pubkey,
secret_key,
})
}
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct FullKeypair {
pub pubkey: Pubkey,
pub secret_key: SecretKey,
}
#[derive(Debug, Eq, PartialEq, Clone, Copy)]
pub struct FilledKeypair<'a> {
pub pubkey: &'a Pubkey,
pub secret_key: &'a SecretKey,
}
impl<'a> FilledKeypair<'a> {
pub fn new(pubkey: &'a Pubkey, secret_key: &'a SecretKey) -> Self {
FilledKeypair { pubkey, secret_key }
}
pub fn to_full(&self) -> FullKeypair {
FullKeypair {
pubkey: self.pubkey.to_owned(),
secret_key: self.secret_key.to_owned(),
}
}
}
impl<'a> From<&'a FilledKeypair<'a>> for KeypairUnowned<'a> {
fn from(value: &'a FilledKeypair<'a>) -> Self {
Self {
pubkey: value.pubkey,
secret_key: Some(value.secret_key),
}
}
}
impl FullKeypair {
pub fn new(pubkey: Pubkey, secret_key: SecretKey) -> Self {
FullKeypair { pubkey, secret_key }
}
pub fn to_filled(&self) -> FilledKeypair<'_> {
FilledKeypair::new(&self.pubkey, &self.secret_key)
}
pub fn generate() -> Self {
let mut rng = nostr::secp256k1::rand::rngs::OsRng;
let (secret_key, _) = &nostr::SECP256K1.generate_keypair(&mut rng);
let (xopk, _) = secret_key.x_only_public_key(&nostr::SECP256K1);
let secret_key = nostr::SecretKey::from(*secret_key);
FullKeypair {
pubkey: Pubkey::new(xopk.serialize()),
secret_key,
}
}
pub fn to_keypair(self) -> Keypair {
Keypair {
pubkey: self.pubkey,
secret_key: Some(self.secret_key),
}
}
}
impl std::fmt::Display for Keypair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Keypair:\n\tpublic: {}\n\tsecret: {}",
self.pubkey,
match self.secret_key {
Some(_) => "Some(<hidden>)",
None => "None",
}
)
}
}
impl std::fmt::Display for FullKeypair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Keypair:\n\tpublic: {}\n\tsecret: <hidden>", self.pubkey)
}
}
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SerializableKeypair {
pub pubkey: Pubkey,
pub encrypted_secret_key: Option<EncryptedSecretKey>,
}
impl SerializableKeypair {
pub fn from_keypair(kp: &Keypair, pass: &str, log_n: u8) -> Self {
Self {
pubkey: kp.pubkey,
encrypted_secret_key: kp.secret_key.clone().and_then(|s| {
EncryptedSecretKey::new(&s, pass, log_n, nostr::nips::nip49::KeySecurity::Weak).ok()
}),
}
}
pub fn to_keypair(&self, pass: &str) -> Keypair {
Keypair::new(
self.pubkey,
self.encrypted_secret_key
.and_then(|e| e.to_secret_key(pass).ok()),
)
}
}
impl TokenSerializable for Pubkey {
fn parse_from_tokens<'a>(parser: &mut TokenParser<'a>) -> Result<Self, ParseError<'a>> {
parser.parse_token(PUBKEY_TOKEN)?;
let raw = parser.pull_token()?;
let pubkey =
Pubkey::try_from_bech32_string(raw, true).map_err(|_| ParseError::DecodeFailed)?;
Ok(pubkey)
}
fn serialize_tokens(&self, writer: &mut tokenator::TokenWriter) {
writer.write_token(PUBKEY_TOKEN);
let Some(bech) = self.npub() else {
tracing::error!("Could not convert pubkey to bech: {}", self.hex());
return;
};
writer.write_token(&bech);
}
}
impl TokenSerializable for Keypair {
fn parse_from_tokens<'a>(parser: &mut TokenParser<'a>) -> Result<Self, ParseError<'a>> {
TokenParser::alt(
parser,
&[
|p| Ok(Keypair::only_pubkey(Pubkey::parse_from_tokens(p)?)),
|p| Ok(Keypair::from_secret(parse_seckey(p)?)),
],
)
}
fn serialize_tokens(&self, writer: &mut tokenator::TokenWriter) {
if let Some(seckey) = &self.secret_key {
writer.write_token(ESECKEY_TOKEN);
let maybe_eseckey = EncryptedSecretKey::new(
seckey,
ESECKEY_PASS,
7,
nostr::nips::nip49::KeySecurity::Unknown,
);
let Ok(eseckey) = maybe_eseckey else {
tracing::error!("Could not convert seckey to EncryptedSecretKey");
return;
};
let Ok(serialized) = eseckey.to_bech32() else {
tracing::error!("Could not serialize ncryptsec");
return;
};
writer.write_token(&serialized);
} else {
self.pubkey.serialize_tokens(writer);
}
}
}
const ESECKEY_TOKEN: &str = "eseckey";
const ESECKEY_PASS: &str = "notedeck";
const PUBKEY_TOKEN: &str = "pubkey";
fn parse_seckey<'a>(parser: &mut TokenParser<'a>) -> Result<SecretKey, ParseError<'a>> {
parser.parse_token(ESECKEY_TOKEN)?;
let raw = parser.pull_token()?;
let eseckey = EncryptedSecretKey::from_bech32(raw).map_err(|_| ParseError::DecodeFailed)?;
let seckey = eseckey
.to_secret_key(ESECKEY_PASS)
.map_err(|_| ParseError::DecodeFailed)?;
Ok(seckey)
}
#[cfg(test)]
mod tests {
use tokenator::{TokenParser, TokenSerializable, TokenWriter};
use super::{FullKeypair, Keypair};
#[test]
fn test_token_eseckey_serialize_deserialize() {
let kp = FullKeypair::generate();
let mut writer = TokenWriter::new("\t");
kp.clone().to_keypair().serialize_tokens(&mut writer);
let serialized = writer.str();
let data = &serialized.split("\t").collect::<Vec<&str>>();
let mut parser = TokenParser::new(data);
let m_new_kp = Keypair::parse_from_tokens(&mut parser);
assert!(m_new_kp.is_ok());
let new_kp = m_new_kp.unwrap();
assert_eq!(kp, new_kp.to_full().unwrap().to_full());
}
#[test]
fn test_token_pubkey_serialize_deserialize() {
let kp = Keypair::only_pubkey(FullKeypair::generate().pubkey);
let mut writer = TokenWriter::new("\t");
kp.clone().serialize_tokens(&mut writer);
let serialized = writer.str();
let data = &serialized.split("\t").collect::<Vec<&str>>();
let mut parser = TokenParser::new(data);
let m_new_kp = Keypair::parse_from_tokens(&mut parser);
assert!(m_new_kp.is_ok());
let new_kp = m_new_kp.unwrap();
assert_eq!(kp, new_kp);
}
}

View File

@@ -1,110 +0,0 @@
use serde_json::{Map, Value};
#[derive(Debug, Clone)]
pub struct ProfileState(Value);
impl Default for ProfileState {
fn default() -> Self {
ProfileState::new(Map::default())
}
}
impl ProfileState {
pub fn new(value: Map<String, Value>) -> Self {
Self(Value::Object(value))
}
pub fn get_str(&self, name: &str) -> Option<&str> {
self.0.get(name).and_then(|v| v.as_str())
}
pub fn values_mut(&mut self) -> &mut Map<String, Value> {
self.0.as_object_mut().unwrap()
}
/// Insert or overwrite an existing value with a string
pub fn str_mut(&mut self, name: &str) -> &mut String {
let val = self
.values_mut()
.entry(name)
.or_insert(Value::String("".to_string()));
// if its not a string, make it one. this will overrwrite
// the old value, so be careful
if !val.is_string() {
*val = Value::String("".to_string());
}
match val {
Value::String(s) => s,
// SAFETY: we replace it above, so its impossible to be something
// other than a string
_ => panic!("impossible"),
}
}
pub fn value(&self) -> &Value {
&self.0
}
pub fn to_json(&self) -> String {
// SAFETY: serializing a value should be irrefutable
serde_json::to_string(self.value()).unwrap()
}
#[inline]
pub fn name(&self) -> Option<&str> {
self.get_str("name")
}
#[inline]
pub fn banner(&self) -> Option<&str> {
self.get_str("name")
}
#[inline]
pub fn display_name(&self) -> Option<&str> {
self.get_str("display_name")
}
#[inline]
pub fn lud06(&self) -> Option<&str> {
self.get_str("lud06")
}
#[inline]
pub fn nip05(&self) -> Option<&str> {
self.get_str("nip05")
}
#[inline]
pub fn lud16(&self) -> Option<&str> {
self.get_str("lud16")
}
#[inline]
pub fn about(&self) -> Option<&str> {
self.get_str("about")
}
#[inline]
pub fn picture(&self) -> Option<&str> {
self.get_str("picture")
}
#[inline]
pub fn website(&self) -> Option<&str> {
self.get_str("website")
}
pub fn from_note_contents(contents: &str) -> Self {
let json = serde_json::from_str(contents);
let data = if let Ok(Value::Object(data)) = json {
data
} else {
Map::new()
};
Self::new(data)
}
}

View File

@@ -1,225 +0,0 @@
use ewebsock::{Options, WsEvent, WsMessage, WsReceiver, WsSender};
use mio::net::UdpSocket;
use std::io;
use std::net::IpAddr;
use std::net::{SocketAddr, SocketAddrV4};
use std::time::{Duration, Instant};
use crate::{ClientMessage, EventClientMessage, Result};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::net::Ipv4Addr;
use tracing::{debug, error};
pub mod message;
pub mod pool;
pub mod subs_debug;
#[derive(Debug, Copy, Clone)]
pub enum RelayStatus {
Connected,
Connecting,
Disconnected,
}
pub struct MulticastRelay {
last_join: Instant,
status: RelayStatus,
address: SocketAddrV4,
socket: UdpSocket,
interface: Ipv4Addr,
}
impl MulticastRelay {
pub fn new(address: SocketAddrV4, socket: UdpSocket, interface: Ipv4Addr) -> Self {
let last_join = Instant::now();
let status = RelayStatus::Connected;
MulticastRelay {
status,
address,
socket,
interface,
last_join,
}
}
/// Multicast seems to fail every 260 seconds. We force a rejoin every 200 seconds or
/// so to ensure we are always in the group
pub fn rejoin(&mut self) -> Result<()> {
self.last_join = Instant::now();
self.status = RelayStatus::Disconnected;
self.socket
.leave_multicast_v4(self.address.ip(), &self.interface)?;
self.socket
.join_multicast_v4(self.address.ip(), &self.interface)?;
self.status = RelayStatus::Connected;
Ok(())
}
pub fn should_rejoin(&self) -> bool {
(Instant::now() - self.last_join) >= Duration::from_secs(200)
}
pub fn try_recv(&self) -> Option<WsEvent> {
let mut buffer = [0u8; 65535];
// Read the size header
match self.socket.recv_from(&mut buffer) {
Ok((size, src)) => {
let parsed_size = u32::from_be_bytes(buffer[0..4].try_into().ok()?) as usize;
debug!("multicast: read size {} from start of header", size - 4);
if size != parsed_size + 4 {
error!(
"multicast: partial data received: expected {}, got {}",
parsed_size, size
);
return None;
}
let text = String::from_utf8_lossy(&buffer[4..size]);
debug!("multicast: received {} bytes from {}: {}", size, src, &text);
Some(WsEvent::Message(WsMessage::Text(text.to_string())))
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
// No data available, continue
None
}
Err(e) => {
error!("multicast: error receiving data: {}", e);
None
}
}
}
pub fn send(&self, msg: &EventClientMessage) -> Result<()> {
let json = msg.to_json();
let len = json.len();
debug!("writing to multicast relay");
let mut buf: Vec<u8> = Vec::with_capacity(4 + len);
// Write the length of the message as 4 bytes (big-endian)
buf.extend_from_slice(&(len as u32).to_be_bytes());
// Append the JSON message bytes
buf.extend_from_slice(json.as_bytes());
self.socket.send_to(&buf, SocketAddr::V4(self.address))?;
Ok(())
}
}
pub fn setup_multicast_relay(
wakeup: impl Fn() + Send + Sync + Clone + 'static,
) -> Result<MulticastRelay> {
use mio::{Events, Interest, Poll, Token};
let port = 9797;
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), port);
let multicast_ip = Ipv4Addr::new(239, 19, 88, 1);
let mut socket = UdpSocket::bind(address)?;
let interface = Ipv4Addr::UNSPECIFIED;
let multicast_address = SocketAddrV4::new(multicast_ip, port);
socket.join_multicast_v4(&multicast_ip, &interface)?;
let mut poll = Poll::new()?;
poll.registry().register(
&mut socket,
Token(0),
Interest::READABLE | Interest::WRITABLE,
)?;
// wakeup our render thread when we have new stuff on the socket
std::thread::spawn(move || {
let mut events = Events::with_capacity(1);
loop {
if let Err(err) = poll.poll(&mut events, Some(Duration::from_millis(100))) {
error!("multicast socket poll error: {err}. ending multicast poller.");
return;
}
wakeup();
std::thread::yield_now();
}
});
Ok(MulticastRelay::new(multicast_address, socket, interface))
}
pub struct Relay {
pub url: nostr::RelayUrl,
pub status: RelayStatus,
pub sender: WsSender,
pub receiver: WsReceiver,
}
impl fmt::Debug for Relay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Relay")
.field("url", &self.url)
.field("status", &self.status)
.finish()
}
}
impl Hash for Relay {
fn hash<H: Hasher>(&self, state: &mut H) {
// Hashes the Relay by hashing the URL
self.url.hash(state);
}
}
impl PartialEq for Relay {
fn eq(&self, other: &Self) -> bool {
self.url == other.url
}
}
impl Eq for Relay {}
impl Relay {
pub fn new(url: nostr::RelayUrl, wakeup: impl Fn() + Send + Sync + 'static) -> Result<Self> {
let status = RelayStatus::Connecting;
let (sender, receiver) =
ewebsock::connect_with_wakeup(url.as_str(), Options::default(), wakeup)?;
Ok(Self {
url,
sender,
receiver,
status,
})
}
pub fn send(&mut self, msg: &ClientMessage) {
let json = match msg.to_json() {
Ok(json) => {
debug!("sending {} to {}", json, self.url);
json
}
Err(e) => {
error!("error serializing json for filter: {e}");
return;
}
};
let txt = WsMessage::Text(json);
self.sender.send(txt);
}
pub fn connect(&mut self, wakeup: impl Fn() + Send + Sync + 'static) -> Result<()> {
let (sender, receiver) =
ewebsock::connect_with_wakeup(self.url.as_str(), Options::default(), wakeup)?;
self.status = RelayStatus::Connecting;
self.sender = sender;
self.receiver = receiver;
Ok(())
}
pub fn ping(&mut self) {
let msg = WsMessage::Ping(vec![]);
self.sender.send(msg);
}
}

View File

@@ -1,411 +0,0 @@
use crate::relay::{setup_multicast_relay, MulticastRelay, Relay, RelayStatus};
use crate::{ClientMessage, Error, Result};
use nostrdb::Filter;
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
use url::Url;
#[cfg(not(target_arch = "wasm32"))]
use ewebsock::{WsEvent, WsMessage};
#[cfg(not(target_arch = "wasm32"))]
use tracing::{debug, error};
use super::subs_debug::SubsDebug;
#[derive(Debug)]
pub struct PoolEvent<'a> {
pub relay: &'a str,
pub event: ewebsock::WsEvent,
}
impl PoolEvent<'_> {
pub fn into_owned(self) -> PoolEventBuf {
PoolEventBuf {
relay: self.relay.to_owned(),
event: self.event,
}
}
}
pub struct PoolEventBuf {
pub relay: String,
pub event: ewebsock::WsEvent,
}
pub enum PoolRelay {
Websocket(WebsocketRelay),
Multicast(MulticastRelay),
}
pub struct WebsocketRelay {
pub relay: Relay,
pub last_ping: Instant,
pub last_connect_attempt: Instant,
pub retry_connect_after: Duration,
}
impl PoolRelay {
pub fn url(&self) -> &str {
match self {
Self::Websocket(wsr) => wsr.relay.url.as_str(),
Self::Multicast(_wsr) => "multicast",
}
}
pub fn set_status(&mut self, status: RelayStatus) {
match self {
Self::Websocket(wsr) => {
wsr.relay.status = status;
}
Self::Multicast(_mcr) => {}
}
}
pub fn try_recv(&self) -> Option<WsEvent> {
match self {
Self::Websocket(recvr) => recvr.relay.receiver.try_recv(),
Self::Multicast(recvr) => recvr.try_recv(),
}
}
pub fn status(&self) -> RelayStatus {
match self {
Self::Websocket(wsr) => wsr.relay.status,
Self::Multicast(mcr) => mcr.status,
}
}
pub fn send(&mut self, msg: &ClientMessage) -> Result<()> {
match self {
Self::Websocket(wsr) => {
wsr.relay.send(msg);
Ok(())
}
Self::Multicast(mcr) => {
// we only send event client messages at the moment
if let ClientMessage::Event(ecm) = msg {
mcr.send(ecm)?;
}
Ok(())
}
}
}
pub fn subscribe(&mut self, subid: String, filter: Vec<Filter>) -> Result<()> {
self.send(&ClientMessage::req(subid, filter))
}
pub fn websocket(relay: Relay) -> Self {
Self::Websocket(WebsocketRelay::new(relay))
}
pub fn multicast(wakeup: impl Fn() + Send + Sync + Clone + 'static) -> Result<Self> {
Ok(Self::Multicast(setup_multicast_relay(wakeup)?))
}
}
impl WebsocketRelay {
pub fn new(relay: Relay) -> Self {
Self {
relay,
last_ping: Instant::now(),
last_connect_attempt: Instant::now(),
retry_connect_after: Self::initial_reconnect_duration(),
}
}
pub fn initial_reconnect_duration() -> Duration {
Duration::from_secs(5)
}
}
pub struct RelayPool {
pub relays: Vec<PoolRelay>,
pub ping_rate: Duration,
pub debug: Option<SubsDebug>,
}
impl Default for RelayPool {
fn default() -> Self {
RelayPool::new()
}
}
impl RelayPool {
// Constructs a new, empty RelayPool.
pub fn new() -> RelayPool {
RelayPool {
relays: vec![],
ping_rate: Duration::from_secs(45),
debug: None,
}
}
pub fn add_multicast_relay(
&mut self,
wakeup: impl Fn() + Send + Sync + Clone + 'static,
) -> Result<()> {
let multicast_relay = PoolRelay::multicast(wakeup)?;
self.relays.push(multicast_relay);
Ok(())
}
pub fn use_debug(&mut self) {
self.debug = Some(SubsDebug::default());
}
pub fn ping_rate(&mut self, duration: Duration) -> &mut Self {
self.ping_rate = duration;
self
}
pub fn has(&self, url: &str) -> bool {
for relay in &self.relays {
if relay.url() == url {
return true;
}
}
false
}
pub fn urls(&self) -> BTreeSet<String> {
self.relays
.iter()
.map(|pool_relay| pool_relay.url().to_string())
.collect()
}
pub fn send(&mut self, cmd: &ClientMessage) {
for relay in &mut self.relays {
if let Some(debug) = &mut self.debug {
debug.send_cmd(relay.url().to_owned(), cmd);
}
if let Err(err) = relay.send(cmd) {
error!("error sending {:?} to {}: {err}", cmd, relay.url());
}
}
}
pub fn unsubscribe(&mut self, subid: String) {
for relay in &mut self.relays {
let cmd = ClientMessage::close(subid.clone());
if let Some(debug) = &mut self.debug {
debug.send_cmd(relay.url().to_owned(), &cmd);
}
if let Err(err) = relay.send(&cmd) {
error!(
"error unsubscribing from {} on {}: {err}",
&subid,
relay.url()
);
}
}
}
pub fn subscribe(&mut self, subid: String, filter: Vec<Filter>) {
for relay in &mut self.relays {
if let Some(debug) = &mut self.debug {
debug.send_cmd(
relay.url().to_owned(),
&ClientMessage::req(subid.clone(), filter.clone()),
);
}
if let Err(err) = relay.send(&ClientMessage::req(subid.clone(), filter.clone())) {
error!("error subscribing to {}: {err}", relay.url());
}
}
}
/// Keep relay connectiongs alive by pinging relays that haven't been
/// pinged in awhile. Adjust ping rate with [`ping_rate`].
pub fn keepalive_ping(&mut self, wakeup: impl Fn() + Send + Sync + Clone + 'static) {
for relay in &mut self.relays {
let now = std::time::Instant::now();
match relay {
PoolRelay::Multicast(_) => {}
PoolRelay::Websocket(relay) => {
match relay.relay.status {
RelayStatus::Disconnected => {
let reconnect_at =
relay.last_connect_attempt + relay.retry_connect_after;
if now > reconnect_at {
relay.last_connect_attempt = now;
let next_duration = Duration::from_millis(3000);
debug!(
"bumping reconnect duration from {:?} to {:?} and retrying connect",
relay.retry_connect_after, next_duration
);
relay.retry_connect_after = next_duration;
if let Err(err) = relay.relay.connect(wakeup.clone()) {
error!("error connecting to relay: {}", err);
}
} else {
// let's wait a bit before we try again
}
}
RelayStatus::Connected => {
relay.retry_connect_after =
WebsocketRelay::initial_reconnect_duration();
let should_ping = now - relay.last_ping > self.ping_rate;
if should_ping {
debug!("pinging {}", relay.relay.url);
relay.relay.ping();
relay.last_ping = Instant::now();
}
}
RelayStatus::Connecting => {
// cool story bro
}
}
}
}
}
}
pub fn send_to(&mut self, cmd: &ClientMessage, relay_url: &str) {
for relay in &mut self.relays {
if relay.url() == relay_url {
if let Some(debug) = &mut self.debug {
debug.send_cmd(relay.url().to_owned(), cmd);
}
if let Err(err) = relay.send(cmd) {
error!("send_to err: {err}");
}
return;
}
}
}
/// check whether a relay url is valid to add
pub fn is_valid_url(&self, url: &str) -> bool {
if url.is_empty() {
return false;
}
let url = match Url::parse(url) {
Ok(parsed_url) => parsed_url.to_string(),
Err(_err) => {
// debug!("bad relay url \"{}\": {:?}", url, err);
return false;
}
};
if self.has(&url) {
return false;
}
true
}
// Adds a websocket url to the RelayPool.
pub fn add_url(
&mut self,
url: String,
wakeup: impl Fn() + Send + Sync + Clone + 'static,
) -> Result<()> {
let url = Self::canonicalize_url(url);
// Check if the URL already exists in the pool.
if self.has(&url) {
return Ok(());
}
let relay = Relay::new(
nostr::RelayUrl::parse(url).map_err(|_| Error::InvalidRelayUrl)?,
wakeup,
)?;
let pool_relay = PoolRelay::websocket(relay);
self.relays.push(pool_relay);
Ok(())
}
pub fn add_urls(
&mut self,
urls: BTreeSet<String>,
wakeup: impl Fn() + Send + Sync + Clone + 'static,
) -> Result<()> {
for url in urls {
self.add_url(url, wakeup.clone())?;
}
Ok(())
}
pub fn remove_urls(&mut self, urls: &BTreeSet<String>) {
self.relays
.retain(|pool_relay| !urls.contains(pool_relay.url()));
}
// standardize the format (ie, trailing slashes)
fn canonicalize_url(url: String) -> String {
match Url::parse(&url) {
Ok(parsed_url) => parsed_url.to_string(),
Err(_) => url, // If parsing fails, return the original URL.
}
}
/// Attempts to receive a pool event from a list of relays. The
/// function searches each relay in the list in order, attempting to
/// receive a message from each. If a message is received, return it.
/// If no message is received from any relays, None is returned.
pub fn try_recv(&mut self) -> Option<PoolEvent<'_>> {
for relay in &mut self.relays {
if let PoolRelay::Multicast(mcr) = relay {
// try rejoin on multicast
if mcr.should_rejoin() {
if let Err(err) = mcr.rejoin() {
error!("multicast: rejoin error: {err}");
}
}
}
if let Some(event) = relay.try_recv() {
match &event {
WsEvent::Opened => {
relay.set_status(RelayStatus::Connected);
}
WsEvent::Closed => {
relay.set_status(RelayStatus::Disconnected);
}
WsEvent::Error(err) => {
error!("{:?}", err);
relay.set_status(RelayStatus::Disconnected);
}
WsEvent::Message(ev) => {
// let's just handle pongs here.
// We only need to do this natively.
#[cfg(not(target_arch = "wasm32"))]
if let WsMessage::Ping(ref bs) = ev {
debug!("pong {}", relay.url());
match relay {
PoolRelay::Websocket(wsr) => {
wsr.relay.sender.send(WsMessage::Pong(bs.to_owned()));
}
PoolRelay::Multicast(_mcr) => {}
}
}
}
}
if let Some(debug) = &mut self.debug {
debug.receive_cmd(relay.url().to_owned(), (&event).into());
}
let pool_event = PoolEvent {
event,
relay: relay.url(),
};
return Some(pool_event);
}
}
None
}
}

View File

@@ -1,267 +0,0 @@
use std::{collections::HashMap, mem, time::SystemTime};
use ewebsock::WsMessage;
use nostrdb::Filter;
use crate::{ClientMessage, Error, RelayEvent, RelayMessage};
use super::message::calculate_command_result_size;
type RelayId = String;
type SubId = String;
pub struct SubsDebug {
data: HashMap<RelayId, RelayStats>,
time_incd: SystemTime,
pub relay_events_selection: Option<RelayId>,
}
#[derive(Default)]
pub struct RelayStats {
pub count: TransferStats,
pub events: Vec<RelayLogEvent>,
pub sub_data: HashMap<SubId, SubStats>,
}
#[derive(Clone)]
pub enum RelayLogEvent {
Send(ClientMessage),
Recieve(OwnedRelayEvent),
}
#[derive(Clone)]
pub enum OwnedRelayEvent {
Opened,
Closed,
Other(String),
Error(String),
Message(String),
}
impl From<RelayEvent<'_>> for OwnedRelayEvent {
fn from(value: RelayEvent<'_>) -> Self {
match value {
RelayEvent::Opened => OwnedRelayEvent::Opened,
RelayEvent::Closed => OwnedRelayEvent::Closed,
RelayEvent::Other(ws_message) => {
let ws_str = match ws_message {
WsMessage::Binary(_) => "Binary".to_owned(),
WsMessage::Text(t) => format!("Text:{t}"),
WsMessage::Unknown(u) => format!("Unknown:{u}"),
WsMessage::Ping(_) => "Ping".to_owned(),
WsMessage::Pong(_) => "Pong".to_owned(),
};
OwnedRelayEvent::Other(ws_str)
}
RelayEvent::Error(error) => OwnedRelayEvent::Error(error.to_string()),
RelayEvent::Message(relay_message) => {
let relay_msg = match relay_message {
RelayMessage::OK(_) => "OK".to_owned(),
RelayMessage::Eose(s) => format!("EOSE:{s}"),
RelayMessage::Event(_, s) => format!("EVENT:{s}"),
RelayMessage::Notice(s) => format!("NOTICE:{s}"),
};
OwnedRelayEvent::Message(relay_msg)
}
}
}
}
#[derive(PartialEq, Eq, Hash, Clone)]
pub struct RelaySub {
pub(crate) subid: String,
pub(crate) filter: String,
}
#[derive(Default)]
pub struct SubStats {
pub filter: String,
pub count: TransferStats,
}
#[derive(Default)]
pub struct TransferStats {
pub up_total: usize,
pub down_total: usize,
// 1 sec < last tick < 2 sec
pub up_sec_prior: usize,
pub down_sec_prior: usize,
// < 1 sec since last tick
up_sec_cur: usize,
down_sec_cur: usize,
}
impl Default for SubsDebug {
fn default() -> Self {
Self {
data: Default::default(),
time_incd: SystemTime::now(),
relay_events_selection: None,
}
}
}
impl SubsDebug {
pub fn get_data(&self) -> &HashMap<RelayId, RelayStats> {
&self.data
}
pub(crate) fn send_cmd(&mut self, relay: String, cmd: &ClientMessage) {
let data = self.data.entry(relay).or_default();
let msg_num_bytes = calculate_client_message_size(cmd);
match cmd {
ClientMessage::Req { sub_id, filters } => {
data.sub_data.insert(
sub_id.to_string(),
SubStats {
filter: filters_to_string(filters),
count: Default::default(),
},
);
}
ClientMessage::Close { sub_id } => {
data.sub_data.remove(sub_id);
}
_ => {}
}
data.count.up_sec_cur += msg_num_bytes;
data.events.push(RelayLogEvent::Send(cmd.clone()));
}
pub(crate) fn receive_cmd(&mut self, relay: String, cmd: RelayEvent) {
let data = self.data.entry(relay).or_default();
let msg_num_bytes = calculate_relay_event_size(&cmd);
if let RelayEvent::Message(RelayMessage::Event(sid, _)) = cmd {
if let Some(sub_data) = data.sub_data.get_mut(sid) {
let c = &mut sub_data.count;
c.down_sec_cur += msg_num_bytes;
}
};
data.count.down_sec_cur += msg_num_bytes;
data.events.push(RelayLogEvent::Recieve(cmd.into()));
}
pub fn try_increment_stats(&mut self) {
let cur_time = SystemTime::now();
if let Ok(dur) = cur_time.duration_since(self.time_incd) {
if dur.as_secs() >= 1 {
self.time_incd = cur_time;
self.internal_inc_stats();
}
}
}
fn internal_inc_stats(&mut self) {
for relay_data in self.data.values_mut() {
let c = &mut relay_data.count;
inc_data_count(c);
for sub in relay_data.sub_data.values_mut() {
inc_data_count(&mut sub.count);
}
}
}
}
fn inc_data_count(c: &mut TransferStats) {
c.up_total += c.up_sec_cur;
c.up_sec_prior = c.up_sec_cur;
c.down_total += c.down_sec_cur;
c.down_sec_prior = c.down_sec_cur;
c.up_sec_cur = 0;
c.down_sec_cur = 0;
}
fn calculate_client_message_size(message: &ClientMessage) -> usize {
match message {
ClientMessage::Event(note) => note.note_json.len() + 10, // 10 is ["EVENT",]
ClientMessage::Req { sub_id, filters } => {
mem::size_of_val(message)
+ mem::size_of_val(sub_id)
+ sub_id.len()
+ filters.iter().map(mem::size_of_val).sum::<usize>()
}
ClientMessage::Close { sub_id } => {
mem::size_of_val(message) + mem::size_of_val(sub_id) + sub_id.len()
}
ClientMessage::Raw(data) => mem::size_of_val(message) + data.len(),
}
}
fn calculate_relay_event_size(event: &RelayEvent<'_>) -> usize {
let base_size = mem::size_of_val(event); // Size of the enum on the stack
let variant_size = match event {
RelayEvent::Opened | RelayEvent::Closed => 0, // No additional data
RelayEvent::Other(ws_message) => calculate_ws_message_size(ws_message),
RelayEvent::Error(error) => calculate_error_size(error),
RelayEvent::Message(message) => calculate_relay_message_size(message),
};
base_size + variant_size
}
fn calculate_ws_message_size(message: &WsMessage) -> usize {
match message {
WsMessage::Binary(vec) | WsMessage::Ping(vec) | WsMessage::Pong(vec) => {
mem::size_of_val(message) + vec.len()
}
WsMessage::Text(string) | WsMessage::Unknown(string) => {
mem::size_of_val(message) + string.len()
}
}
}
fn calculate_error_size(error: &Error) -> usize {
match error {
Error::Empty
| Error::HexDecodeFailed
| Error::InvalidBech32
| Error::InvalidByteSize
| Error::InvalidSignature
| Error::InvalidRelayUrl
| Error::Io(_)
| Error::InvalidPublicKey => mem::size_of_val(error), // No heap usage
Error::DecodeFailed(string) => mem::size_of_val(error) + string.len(),
Error::Json(json_err) => mem::size_of_val(error) + json_err.to_string().len(),
Error::Nostrdb(nostrdb_err) => mem::size_of_val(error) + nostrdb_err.to_string().len(),
Error::Generic(string) => mem::size_of_val(error) + string.len(),
}
}
fn calculate_relay_message_size(message: &RelayMessage) -> usize {
match message {
RelayMessage::OK(result) => calculate_command_result_size(result),
RelayMessage::Eose(str_ref)
| RelayMessage::Event(str_ref, _)
| RelayMessage::Notice(str_ref) => mem::size_of_val(message) + str_ref.len(),
}
}
fn filters_to_string(f: &Vec<Filter>) -> String {
let mut cur_str = String::new();
for filter in f {
if let Ok(json) = filter.json() {
if !cur_str.is_empty() {
cur_str.push_str(", ");
}
cur_str.push_str(&json);
}
}
cur_str
}

View File

@@ -1,58 +0,0 @@
[package]
name = "notedeck"
version = { workspace = true }
edition = "2021"
description = "The APIs and data structures used by notedeck apps"
[dependencies]
nostrdb = { workspace = true }
jni = { workspace = true }
url = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
dirs = { workspace = true }
enostr = { workspace = true }
nostr = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true }
image = { workspace = true }
base32 = { workspace = true }
poll-promise = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
hex = { workspace = true }
thiserror = { workspace = true }
puffin = { workspace = true, optional = true }
puffin_egui = { workspace = true, optional = true }
sha2 = { workspace = true }
bincode = { workspace = true }
ehttp = {workspace = true }
mime_guess = { workspace = true }
egui-winit = { workspace = true }
tokenator = { workspace = true }
profiling = { workspace = true }
nwc = { workspace = true }
tokio = { workspace = true }
bech32 = { workspace = true }
lightning-invoice = { workspace = true }
secp256k1 = { workspace = true }
hashbrown = { workspace = true }
fluent = { workspace = true }
fluent-resmgr = { workspace = true }
fluent-langneg = { workspace = true }
unic-langid = { workspace = true }
once_cell = { workspace = true }
md5 = { workspace = true }
regex = "1"
[dev-dependencies]
tempfile = { workspace = true }
tokio = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
[features]
puffin = ["puffin_egui", "dep:puffin"]

View File

@@ -1,396 +0,0 @@
# Notedeck Developer Documentation
This document provides technical details and guidance for developers working with the Notedeck crate.
## Architecture Overview
Notedeck is built around a modular architecture that separates concerns into distinct components:
### Core Components
1. **App Framework (`app.rs`)**
- `Notedeck` - The main application framework that ties everything together
- `App` - The trait that specific applications must implement
2. **Data Layer**
- `Ndb` - NostrDB database for efficient storage and querying
- `NoteCache` - In-memory cache for expensive-to-compute note data like nip10 structure
- `Images` - Image and GIF cache management
3. **Network Layer**
- `RelayPool` - Manages connections to Nostr relays
- `UnknownIds` - Tracks and resolves unknown profiles and notes
4. **User Accounts**
- `Accounts` - Manages user keypairs and account information
- `AccountStorage` - Handles persistent storage of account data
5. **Wallet Integration**
- `Wallet` - Lightning wallet integration
- `Zaps` - Handles Nostr zap functionality
6. **UI Components**
- `NotedeckTextStyle` - Text styling utilities
- `ColorTheme` - Theme management
- Various UI helpers
7. **Localization System**
- `LocalizationManager` - Core localization functionality
- `LocalizationContext` - Thread-safe context for sharing localization
- Fluent-based translation system
## Key Concepts
### Note Context and Actions
Notes have associated context and actions that define how users can interact with them:
```rust
pub enum NoteAction {
Reply(NoteId), // Reply to a note
Quote(NoteId), // Quote a note
Hashtag(String), // Click on a hashtag
Profile(Pubkey), // View a profile
Note(NoteId), // View a note
Context(ContextSelection), // Context menu options
Zap(ZapAction), // Zap (tip) interaction
}
```
### Relay Management
Notedeck handles relays through the `RelaySpec` structure, which implements NIP-65 functionality for marking relays as read or write.
### Filtering and Subscriptions
The `FilterState` enum manages the state of subscriptions to Nostr relays:
```rust
pub enum FilterState {
NeedsRemote(Vec<Filter>),
FetchingRemote(UnifiedSubscription),
GotRemote(Subscription),
Ready(Vec<Filter>),
Broken(FilterError),
}
```
## Development Workflow
### Setting Up Your Environment
1. Clone the repository
2. Build with `cargo build`
3. Test with `cargo test`
### Creating a New Notedeck App
1. Import the notedeck crate
2. Implement the `App` trait
3. Use the `Notedeck` struct as your application framework
Example:
```rust
use notedeck::{App, Notedeck, AppContext};
struct MyNostrApp {
// Your app-specific state
}
impl App for MyNostrApp {
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) {
// Your app's UI and logic here
}
}
fn main() {
let notedeck = Notedeck::new(...).app(MyNostrApp { /* ... */ });
// Run your app
}
```
### Working with Notes
Notes are the core data structure in Nostr. Here's how to work with them:
```rust
// Get a note by ID
let txn = Transaction::new(&ndb).expect("txn");
if let Ok(note) = ndb.get_note_by_id(&txn, note_id.bytes()) {
// Process the note
}
// Create a cached note
let cached_note = note_cache.cached_note_or_insert(note_key, &note);
```
### Adding Account Management
Account management is handled through the `Accounts` struct:
```rust
// Add a new account
let action = accounts.add_account(keypair);
action.process_action(&mut unknown_ids, &ndb, &txn);
// Get the current account
if let Some(account) = accounts.get_selected_account() {
// Use the account
}
```
## Advanced Topics
### Zaps Implementation
Notedeck implements the zap (tipping) functionality according to the Nostr protocol:
1. Creates a zap request note (kind 9734)
2. Fetches a Lightning invoice via LNURL or LUD-16
3. Pays the invoice using a connected wallet
4. Tracks the zap state
### Image Caching
The image caching system efficiently manages images and animated GIFs:
1. Downloads images from URLs
2. Stores them in a local cache
3. Handles conversion between formats
4. Manages memory usage
### Persistent Storage
Notedeck provides several persistence mechanisms:
- `AccountStorage` - For user accounts
- `TimedSerializer` - For settings that need to be saved after a delay
- Various handlers for specific settings (zoom, theme, app size)
### Localization System
Notedeck includes a comprehensive internationalization system built on the [Fluent](https://projectfluent.org/) translation framework. The system is designed for performance and developer experience.
#### Architecture
The localization system consists of several key components:
1. **LocalizationManager** - Core functionality for managing locales and translations
2. **LocalizationContext** - Thread-safe context for sharing localization across the application
3. **Fluent Resources** - Translation files in `.ftl` format stored in `assets/translations/`
#### Key Features
- **Efficient Caching**: Parsed Fluent resources and formatted strings are cached for performance
- **Thread Safety**: Uses `RwLock` for safe concurrent access
- **Dynamic Locale Switching**: Change languages at runtime without restarting
- **Argument Support**: Localized strings can include dynamic arguments
- **Development Tools**: Pseudolocale support for testing UI layout
#### Using the tr! and tr_plural! Macros
The `tr!` and `tr_plural!` macros are the primary way to use localization in Notedeck code. They provide a convenient, type-safe interface for getting localized strings.
##### The tr! Macro
```rust
use notedeck::tr;
// Simple string with comment
let welcome = tr!("Welcome to Notedeck!", "Main welcome message");
let cancel = tr!("Cancel", "Button label to cancel an action");
// String with parameters
let greeting = tr!("Hello, {name}!", "Greeting message", name="Alice");
// Multiple parameters
let message = tr!(
"Welcome {name} to {app}!",
"Welcome message with app name",
name="Alice",
app="Notedeck"
);
// In UI components
ui.button(tr!("Reply to {user}", "Reply button text", user="alice@example.com"));
```
##### The tr_plural! Macro
Use tr_plural! when there can be multiple variations of the same string depending on
some numeric count.
Not all languages follow the same pluralization rules
```rust
use notedeck::tr_plural;
// Simple pluralization
let count = 5;
let message = tr_plural!(
"You have {count} note", // Singular form
"You have {count} notes", // Plural form
"Note count message", // Comment
count // Count value
);
// With additional parameters
let user = "Alice";
let message = tr_plural!(
"{user} has {count} note", // Singular
"{user} has {count} notes", // Plural
"User note count message", // Comment
count, // Count
user=user // Additional parameter
);
```
##### Key Features
- **Automatic Key Normalization**: Converts messages and comments into valid FTL keys
- **Fallback Handling**: Falls back to original message if translation not found
- **Parameter Interpolation**: Automatically handles named parameters
- **Comment Context**: Provides context for translators
##### Best Practices
1. **Always Include Comments**: Comments provide context for translators
```rust
// Good
tr!("Add", "Button label to add something")
// Bad
tr!("Add", "")
```
2. **Use Descriptive Comments**: Make comments specific and helpful
```rust
// Good
tr!("Reply", "Button to reply to a note")
// Bad
tr!("Reply", "Reply")
```
3. **Consistent Parameter Names**: Use consistent parameter names across related strings
```rust
// Consistent
tr!("Follow {user}", "Follow button", user="alice")
tr!("Unfollow {user}", "Unfollow button", user="alice")
```
4. **Always use tr_plural! for plural strings**: Not all languages follow English pluralization rules
```rust
// Good
// Each language can have more (or less) than just two pluralization forms.
// Let the translators and the localization system help you figure that out implicitly.
let message = tr_plural!(
"You have {count} note", // Singular form
"You have {count} notes", // Plural form
"Note count message", // Comment
count // Count value
);
// Bad
// Not all languages follow pluralization rules of English.
// Some languages can have more (or less) than two variations!
if count == 1 {
tr!("You have 1 note", "Note count message")
} else {
tr!("You have {count} notes", "Note count message")
}
```
#### Translation File Format
Translation files use the [Fluent](https://projectfluent.org/) format (`.ftl`).
Developers should never create their own `.ftl` files. Whenever user-facing strings are changed in code, run `python3 scripts/export_source_strings.py`. This script will generate `assets/translations/en-US/main.ftl` and `assets/translations/en-XA/main.ftl`. The format of the files look like the following:
```ftl
# Simple string
welcome_message = Welcome to Notedeck!
# String with arguments
welcome_user = Welcome {$name}!
# String with pluralization
note_count = {$count ->
[1] One note
*[other] {$count} notes
}
```
#### Adding New Languages
TODO
#### Development with Pseudolocale (en-XA)
For testing that all user-facing strings are going through the localization system and that the
UI layout renders well with different language translations, enable the pseudolocale:
```bash
cargo run -- --debug --locale en-XA
```
The pseudolocale (`en-XA`) transforms English text in a way that is still readable but makes adjustments obvious enough that they are different from the original text (such as replacing English letters with accented equivalents), helping identify potential UI layout issues once it gets translated
to other languages.
Example transformations:
- "Add relay" → "[Àdd rélày]"
- "Cancel" → "[Çàñçél]"
- "Confirm" → "[Çóñfírm]"
#### Performance Considerations
- **Resource Caching**: Parsed Fluent resources are cached per locale
- **String Caching**: Simple strings (without arguments) are cached for repeated access
- **Cache Management**: Caches are automatically cleared when switching locales
- **Memory Limits**: String cache size can be limited to prevent memory growth
#### Testing Localization
The localization system includes comprehensive tests:
```bash
# Run localization tests
cargo test i18n
```
## Troubleshooting
### Common Issues
1. **Relay Connection Issues**
- Check network connectivity
- Verify relay URLs are correct
- Look for relay debug messages
2. **Database Errors**
- Ensure the database path is writable
- Check for database corruption
- Increase map size if needed
3. **Performance Issues**
- Monitor the frame history
- Check for large image caches
- Consider reducing the number of active subscriptions
4. **Localization Issues**
- Verify translation files exist in the correct directory structure
- Check that locale codes are valid (e.g., `en-US`, `es-ES`)
- Ensure FTL files are properly formatted
- Look for missing translation keys in logs
## Contributing
When contributing to Notedeck:
1. Follow the existing code style
2. Add tests for new functionality
3. Update documentation as needed
4. Keep performance in mind, especially for mobile targets
5. For UI changes, test with pseudolocale enabled
6. When adding new strings, ensure they are properly localized

View File

@@ -1,30 +0,0 @@
# Notedeck
Notedeck is a shared Rust library that provides the core functionality for building Nostr client applications. It serves as the foundation for various Notedeck applications like notedeck_chrome, notedeck_columns, and notedeck_dave.
## Overview
The Notedeck crate implements common data types, utilities, and logic used across all Notedeck applications. It provides a unified interface for interacting with the Nostr protocol, managing accounts, handling note data, and rendering UI components.
Key features include:
- **Nostr Protocol Integration**: Connect to relays, subscribe to events, publish notes
- **Account Management**: Handle user accounts, keypairs, and profiles
- **Note Handling**: Cache and process notes efficiently
- **UI Components**: Common UI elements and styles
- **Image Caching**: Efficient image and GIF caching system
- **Wallet Integration**: Lightning wallet support with zaps functionality
- **Theme Support**: Customizable themes and styles
- **Storage**: Persistent storage for settings and data
## Applications
This crate serves as the foundation for several Notedeck applications:
- **notedeck_chrome** - The browser chrome, manages a toolbar for switching between different clients
- **notedeck_columns** - A column-based Nostr client interface
- **notedeck_dave** - A nostr ai assistant
## License
GPLv2

View File

@@ -1,522 +0,0 @@
use uuid::Uuid;
use crate::account::cache::AccountCache;
use crate::account::contacts::Contacts;
use crate::account::mute::AccountMutedData;
use crate::account::relay::{
modify_advertised_relays, update_relay_configuration, AccountRelayData, RelayAction,
RelayDefaults,
};
use crate::storage::AccountStorageWriter;
use crate::user_account::UserAccountSerializable;
use crate::{
AccountStorage, MuteFun, SingleUnkIdAction, UnifiedSubscription, UnknownIds, UserAccount,
ZapWallet,
};
use enostr::{ClientMessage, FilledKeypair, Keypair, Pubkey, RelayPool};
use nostrdb::{Ndb, Note, Transaction};
// TODO: remove this
use std::sync::Arc;
/// The interface for managing the user's accounts.
/// Represents all user-facing operations related to account management.
pub struct Accounts {
pub cache: AccountCache,
storage_writer: Option<AccountStorageWriter>,
relay_defaults: RelayDefaults,
subs: AccountSubs,
}
impl Accounts {
#[allow(clippy::too_many_arguments)]
pub fn new(
key_store: Option<AccountStorage>,
forced_relays: Vec<String>,
fallback: Pubkey,
ndb: &mut Ndb,
txn: &Transaction,
pool: &mut RelayPool,
ctx: &egui::Context,
unknown_ids: &mut UnknownIds,
) -> Self {
let (mut cache, unknown_id) = AccountCache::new(UserAccount::new(
Keypair::only_pubkey(fallback),
AccountData::new(fallback.bytes()),
));
unknown_id.process_action(unknown_ids, ndb, txn);
let mut storage_writer = None;
if let Some(keystore) = key_store {
let (reader, writer) = keystore.rw();
match reader.get_accounts() {
Ok(accounts) => {
for account in accounts {
add_account_from_storage(&mut cache, account).process_action(
unknown_ids,
ndb,
txn,
)
}
}
Err(e) => {
tracing::error!("could not get keys: {e}");
}
}
if let Some(selected) = reader.get_selected_key().ok().flatten() {
cache.select(selected);
}
storage_writer = Some(writer);
};
let relay_defaults = RelayDefaults::new(forced_relays);
let selected = cache.selected_mut();
let selected_data = &mut selected.data;
selected_data.query(ndb, txn);
let subs = {
AccountSubs::new(
ndb,
pool,
&relay_defaults,
&selected.key.pubkey,
selected_data,
create_wakeup(ctx),
)
};
Accounts {
cache,
storage_writer,
relay_defaults,
subs,
}
}
pub fn remove_account(
&mut self,
pk: &Pubkey,
ndb: &mut Ndb,
pool: &mut RelayPool,
ctx: &egui::Context,
) -> bool {
let Some(resp) = self.cache.remove(pk) else {
return false;
};
if pk != self.cache.fallback() {
if let Some(key_store) = &self.storage_writer {
if let Err(e) = key_store.remove_key(&resp.deleted) {
tracing::error!("Could not remove account {pk}: {e}");
}
}
}
if let Some(swap_to) = resp.swap_to {
let txn = Transaction::new(ndb).expect("txn");
self.select_account_internal(&swap_to, ndb, &txn, pool, ctx);
}
true
}
pub fn contains_full_kp(&self, pubkey: &enostr::Pubkey) -> bool {
self.cache
.get(pubkey)
.is_some_and(|u| u.key.secret_key.is_some())
}
#[must_use = "UnknownIdAction's must be handled. Use .process_unknown_id_action()"]
pub fn add_account(&mut self, kp: Keypair) -> Option<AddAccountResponse> {
let acc = if let Some(acc) = self.cache.get_mut(&kp.pubkey) {
if kp.secret_key.is_none() || acc.key.secret_key.is_some() {
tracing::info!("Already have account, not adding");
return None;
}
acc.key = kp.clone();
AccType::Acc(&*acc)
} else {
let new_account_data = AccountData::new(kp.pubkey.bytes());
AccType::Entry(
self.cache
.add(UserAccount::new(kp.clone(), new_account_data)),
)
};
if let Some(key_store) = &self.storage_writer {
if let Err(e) = key_store.write_account(&acc.get_acc().into()) {
tracing::error!("Could not add key for {:?}: {e}", kp.pubkey);
}
}
Some(AddAccountResponse {
switch_to: kp.pubkey,
unk_id_action: SingleUnkIdAction::pubkey(kp.pubkey),
})
}
/// Update the `UserAccount` via callback and save the result to disk.
/// return true if the update was successful
pub fn update_current_account(&mut self, update: impl FnOnce(&mut UserAccount)) -> bool {
let cur_account = self.get_selected_account_mut();
update(cur_account);
let cur_acc = self.get_selected_account();
let Some(key_store) = &self.storage_writer else {
return false;
};
if let Err(err) = key_store.write_account(&cur_acc.into()) {
tracing::error!("Could not add account {:?} to storage: {err}", cur_acc.key);
return false;
}
true
}
pub fn selected_filled(&self) -> Option<FilledKeypair<'_>> {
self.get_selected_account().key.to_full()
}
/// Get the selected account's pubkey as bytes. Common operation so
/// we make it a helper here.
pub fn selected_account_pubkey_bytes(&self) -> &[u8; 32] {
self.get_selected_account().key.pubkey.bytes()
}
pub fn selected_account_pubkey(&self) -> &Pubkey {
&self.get_selected_account().key.pubkey
}
pub fn get_selected_account(&self) -> &UserAccount {
self.cache.selected()
}
pub fn selected_account_has_wallet(&self) -> bool {
self.get_selected_account().wallet.is_some()
}
fn get_selected_account_mut(&mut self) -> &mut UserAccount {
self.cache.selected_mut()
}
pub fn get_selected_wallet_mut(&mut self) -> Option<&mut ZapWallet> {
self.cache.selected_mut().wallet.as_mut()
}
fn get_selected_account_data(&self) -> &AccountData {
&self.cache.selected().data
}
pub fn select_account(
&mut self,
pk_to_select: &Pubkey,
ndb: &mut Ndb,
txn: &Transaction,
pool: &mut RelayPool,
ctx: &egui::Context,
) {
if !self.cache.select(*pk_to_select) {
return;
}
self.select_account_internal(pk_to_select, ndb, txn, pool, ctx);
}
/// Have already selected in `AccountCache`, updating other things
fn select_account_internal(
&mut self,
pk_to_select: &Pubkey,
ndb: &mut Ndb,
txn: &Transaction,
pool: &mut RelayPool,
ctx: &egui::Context,
) {
if let Some(key_store) = &self.storage_writer {
if let Err(e) = key_store.select_key(Some(*pk_to_select)) {
tracing::error!("Could not select key {:?}: {e}", pk_to_select);
}
}
self.get_selected_account_mut().data.query(ndb, txn);
self.subs.swap_to(
ndb,
pool,
&self.relay_defaults,
pk_to_select,
&self.cache.selected().data,
create_wakeup(ctx),
);
}
pub fn mutefun(&self) -> Box<MuteFun> {
let account_data = self.get_selected_account_data();
let muted = Arc::clone(&account_data.muted.muted);
Box::new(move |note: &Note, thread: &[u8; 32]| muted.is_muted(note, thread))
}
pub fn send_initial_filters(&mut self, pool: &mut RelayPool, relay_url: &str) {
let data = &self.get_selected_account().data;
// send the active account's relay list subscription
pool.send_to(
&ClientMessage::req(
self.subs.relay.remote.clone(),
vec![data.relay.filter.clone()],
),
relay_url,
);
// send the active account's muted subscription
pool.send_to(
&ClientMessage::req(
self.subs.mute.remote.clone(),
vec![data.muted.filter.clone()],
),
relay_url,
);
pool.send_to(
&ClientMessage::req(
self.subs.contacts.remote.clone(),
vec![data.contacts.filter.clone()],
),
relay_url,
);
}
pub fn update(&mut self, ndb: &mut Ndb, pool: &mut RelayPool, ctx: &egui::Context) {
// IMPORTANT - This function is called in the UI update loop,
// make sure it is fast when idle
let Some(update) = self
.cache
.selected_mut()
.data
.poll_for_updates(ndb, &self.subs)
else {
return;
};
match update {
// If needed, update the relay configuration
AccountDataUpdate::Relay => {
let acc = self.cache.selected();
update_relay_configuration(
pool,
&self.relay_defaults,
&acc.key.pubkey,
&acc.data.relay,
create_wakeup(ctx),
);
}
}
}
pub fn get_full<'a>(&'a self, pubkey: &Pubkey) -> Option<FilledKeypair<'a>> {
self.cache.get(pubkey).and_then(|r| r.key.to_full())
}
pub fn process_relay_action(
&mut self,
ctx: &egui::Context,
pool: &mut RelayPool,
action: RelayAction,
) {
let acc = self.cache.selected_mut();
modify_advertised_relays(&acc.key, action, pool, &self.relay_defaults, &mut acc.data);
update_relay_configuration(
pool,
&self.relay_defaults,
&acc.key.pubkey,
&acc.data.relay,
create_wakeup(ctx),
);
}
pub fn get_subs(&self) -> &AccountSubs {
&self.subs
}
}
enum AccType<'a> {
Entry(hashbrown::hash_map::OccupiedEntry<'a, Pubkey, UserAccount>),
Acc(&'a UserAccount),
}
impl<'a> AccType<'a> {
fn get_acc(&'a self) -> &'a UserAccount {
match self {
AccType::Entry(occupied_entry) => occupied_entry.get(),
AccType::Acc(user_account) => user_account,
}
}
}
fn create_wakeup(ctx: &egui::Context) -> impl Fn() + Send + Sync + Clone + 'static {
let ctx = ctx.clone();
move || {
ctx.request_repaint();
}
}
fn add_account_from_storage(
cache: &mut AccountCache,
user_account_serializable: UserAccountSerializable,
) -> SingleUnkIdAction {
let Some(acc) = get_acc_from_storage(user_account_serializable) else {
return SingleUnkIdAction::NoAction;
};
let pk = acc.key.pubkey;
cache.add(acc);
SingleUnkIdAction::pubkey(pk)
}
fn get_acc_from_storage(user_account_serializable: UserAccountSerializable) -> Option<UserAccount> {
let keypair = user_account_serializable.key;
let new_account_data = AccountData::new(keypair.pubkey.bytes());
let mut wallet = None;
if let Some(wallet_s) = user_account_serializable.wallet {
let m_wallet: Result<crate::ZapWallet, crate::Error> = wallet_s.into();
match m_wallet {
Ok(w) => wallet = Some(w),
Err(e) => {
tracing::error!("Problem creating wallet from disk: {e}");
}
};
}
Some(UserAccount {
key: keypair,
wallet,
data: new_account_data,
})
}
#[derive(Clone)]
pub struct AccountData {
pub(crate) relay: AccountRelayData,
pub(crate) muted: AccountMutedData,
pub contacts: Contacts,
}
impl AccountData {
pub fn new(pubkey: &[u8; 32]) -> Self {
Self {
relay: AccountRelayData::new(pubkey),
muted: AccountMutedData::new(pubkey),
contacts: Contacts::new(pubkey),
}
}
pub(super) fn poll_for_updates(
&mut self,
ndb: &Ndb,
subs: &AccountSubs,
) -> Option<AccountDataUpdate> {
let txn = Transaction::new(ndb).expect("txn");
let mut resp = None;
if self.relay.poll_for_updates(ndb, &txn, subs.relay.local) {
resp = Some(AccountDataUpdate::Relay);
}
self.muted.poll_for_updates(ndb, &txn, subs.mute.local);
self.contacts
.poll_for_updates(ndb, &txn, subs.contacts.local);
resp
}
/// Note: query should be called as close to the subscription as possible
pub(super) fn query(&mut self, ndb: &Ndb, txn: &Transaction) {
self.relay.query(ndb, txn);
self.muted.query(ndb, txn);
self.contacts.query(ndb, txn);
}
}
pub(super) enum AccountDataUpdate {
Relay,
}
pub struct AddAccountResponse {
pub switch_to: Pubkey,
pub unk_id_action: SingleUnkIdAction,
}
pub struct AccountSubs {
relay: UnifiedSubscription,
mute: UnifiedSubscription,
pub contacts: UnifiedSubscription,
}
impl AccountSubs {
pub(super) fn new(
ndb: &mut Ndb,
pool: &mut RelayPool,
relay_defaults: &RelayDefaults,
pk: &Pubkey,
data: &AccountData,
wakeup: impl Fn() + Send + Sync + Clone + 'static,
) -> Self {
let relay = subscribe(ndb, pool, &data.relay.filter);
let mute = subscribe(ndb, pool, &data.muted.filter);
let contacts = subscribe(ndb, pool, &data.contacts.filter);
update_relay_configuration(pool, relay_defaults, pk, &data.relay, wakeup);
Self {
relay,
mute,
contacts,
}
}
pub(super) fn swap_to(
&mut self,
ndb: &mut Ndb,
pool: &mut RelayPool,
relay_defaults: &RelayDefaults,
pk: &Pubkey,
new_selection_data: &AccountData,
wakeup: impl Fn() + Send + Sync + Clone + 'static,
) {
unsubscribe(ndb, pool, &self.relay);
unsubscribe(ndb, pool, &self.mute);
unsubscribe(ndb, pool, &self.contacts);
*self = AccountSubs::new(ndb, pool, relay_defaults, pk, new_selection_data, wakeup);
}
}
fn subscribe(ndb: &Ndb, pool: &mut RelayPool, filter: &nostrdb::Filter) -> UnifiedSubscription {
let filters = vec![filter.clone()];
let sub = ndb
.subscribe(&filters)
.expect("ndb relay list subscription");
// remote subscription
let subid = Uuid::new_v4().to_string();
pool.subscribe(subid.clone(), filters);
UnifiedSubscription {
local: sub,
remote: subid,
}
}
fn unsubscribe(ndb: &mut Ndb, pool: &mut RelayPool, sub: &UnifiedSubscription) {
pool.unsubscribe(sub.remote.clone());
// local subscription
ndb.unsubscribe(sub.local)
.expect("ndb relay list unsubscribe");
}

View File

@@ -1,126 +0,0 @@
use enostr::Pubkey;
use hashbrown::{hash_map::OccupiedEntry, HashMap};
use crate::{SingleUnkIdAction, UserAccount};
pub struct AccountCache {
selected: Pubkey,
fallback: Pubkey,
fallback_account: UserAccount,
// never empty at rest
accounts: HashMap<Pubkey, UserAccount>,
}
impl AccountCache {
pub(super) fn new(fallback: UserAccount) -> (Self, SingleUnkIdAction) {
let mut accounts = HashMap::with_capacity(1);
let pk = fallback.key.pubkey;
accounts.insert(pk, fallback.clone());
(
Self {
selected: pk,
fallback: pk,
fallback_account: fallback,
accounts,
},
SingleUnkIdAction::pubkey(pk),
)
}
pub fn get(&self, pk: &Pubkey) -> Option<&UserAccount> {
self.accounts.get(pk)
}
pub fn get_bytes(&self, pk: &[u8; 32]) -> Option<&UserAccount> {
self.accounts.get(pk)
}
pub(super) fn get_mut(&mut self, pk: &Pubkey) -> Option<&mut UserAccount> {
self.accounts.get_mut(pk)
}
pub(super) fn add<'a>(
&'a mut self,
account: UserAccount,
) -> OccupiedEntry<'a, Pubkey, UserAccount> {
let pk = account.key.pubkey;
self.accounts.entry(pk).insert(account)
}
pub(super) fn remove(&mut self, pk: &Pubkey) -> Option<AccountDeletionResponse> {
if *pk == self.fallback && self.accounts.len() == 1 {
// no point in removing it since it'll just get re-added anyway
return None;
}
let removed = self.accounts.remove(pk)?;
if self.accounts.is_empty() {
self.accounts
.insert(self.fallback, self.fallback_account.clone());
}
if self.selected == *pk {
// TODO(kernelkind): choose next better
let (next, _) = self
.accounts
.iter()
.next()
.expect("accounts can never be empty");
self.selected = *next;
return Some(AccountDeletionResponse {
deleted: removed.key,
swap_to: Some(*next),
});
}
Some(AccountDeletionResponse {
deleted: removed.key,
swap_to: None,
})
}
/// guarenteed that all selected exist in accounts
pub(super) fn select(&mut self, pk: Pubkey) -> bool {
if !self.accounts.contains_key(&pk) {
return false;
}
self.selected = pk;
true
}
pub fn selected(&self) -> &UserAccount {
self.accounts
.get(&self.selected)
.expect("guarenteed that selected exists in accounts")
}
pub(super) fn selected_mut(&mut self) -> &mut UserAccount {
self.accounts
.get_mut(&self.selected)
.expect("guarenteed that selected exists in accounts")
}
pub fn fallback(&self) -> &Pubkey {
&self.fallback
}
}
impl<'a> IntoIterator for &'a AccountCache {
type Item = (&'a Pubkey, &'a UserAccount);
type IntoIter = hashbrown::hash_map::Iter<'a, Pubkey, UserAccount>;
fn into_iter(self) -> Self::IntoIter {
self.accounts.iter()
}
}
pub struct AccountDeletionResponse {
pub deleted: enostr::Keypair,
pub swap_to: Option<Pubkey>,
}

View File

@@ -1,147 +0,0 @@
use std::collections::HashSet;
use enostr::Pubkey;
use nostrdb::{Filter, Ndb, Note, NoteKey, Subscription, Transaction};
#[derive(Clone)]
pub struct Contacts {
pub filter: Filter,
pub(super) state: ContactState,
}
#[derive(Clone)]
pub enum ContactState {
Unreceived,
Received {
contacts: HashSet<Pubkey>,
note_key: NoteKey,
},
}
#[derive(Eq, PartialEq, Debug, Clone, Copy)]
pub enum IsFollowing {
/// We don't have the contact list, so we don't know
Unknown,
/// We are follow
Yes,
No,
}
impl Contacts {
pub fn new(pubkey: &[u8; 32]) -> Self {
let filter = Filter::new().authors([pubkey]).kinds([3]).limit(1).build();
Self {
filter,
state: ContactState::Unreceived,
}
}
pub(super) fn query(&mut self, ndb: &Ndb, txn: &Transaction) {
let binding = ndb
.query(txn, &[self.filter.clone()], 1)
.expect("query user relays results");
let Some(res) = binding.first() else {
return;
};
update_state(&mut self.state, &res.note, res.note_key);
}
pub fn is_following(&self, other_pubkey: &[u8; 32]) -> IsFollowing {
match &self.state {
ContactState::Unreceived => IsFollowing::Unknown,
ContactState::Received {
contacts,
note_key: _,
} => {
if contacts.contains(other_pubkey) {
IsFollowing::Yes
} else {
IsFollowing::No
}
}
}
}
pub(super) fn poll_for_updates(&mut self, ndb: &Ndb, txn: &Transaction, sub: Subscription) {
let nks = ndb.poll_for_notes(sub, 1);
let Some(key) = nks.first() else {
return;
};
let note = match ndb.get_note_by_key(txn, *key) {
Ok(note) => note,
Err(e) => {
tracing::error!("Could not find note at key {:?}: {e}", key);
return;
}
};
update_state(&mut self.state, &note, *key);
}
pub fn get_state(&self) -> &ContactState {
&self.state
}
}
fn update_state(state: &mut ContactState, note: &Note, key: NoteKey) {
match state {
ContactState::Unreceived => {
*state = ContactState::Received {
contacts: get_contacts_owned(note),
note_key: key,
};
}
ContactState::Received { contacts, note_key } => {
update_contacts(contacts, note);
*note_key = key;
}
};
}
fn get_contacts<'a>(note: &Note<'a>) -> HashSet<&'a [u8; 32]> {
let mut contacts = HashSet::with_capacity(note.tags().count().into());
for tag in note.tags() {
if tag.count() < 2 {
continue;
}
let Some("p") = tag.get_str(0) else {
continue;
};
let Some(cur_id) = tag.get_id(1) else {
continue;
};
contacts.insert(cur_id);
}
contacts
}
fn get_contacts_owned(note: &Note<'_>) -> HashSet<Pubkey> {
get_contacts(note)
.iter()
.map(|p| Pubkey::new(**p))
.collect()
}
fn update_contacts(cur: &mut HashSet<Pubkey>, new: &Note<'_>) {
let new_contacts = get_contacts(new);
cur.retain(|pk| new_contacts.contains(pk.bytes()));
new_contacts.iter().for_each(|c| {
if !cur.contains(*c) {
cur.insert(Pubkey::new(**c));
}
});
}

View File

@@ -1,12 +0,0 @@
pub mod accounts;
pub mod cache;
pub mod contacts;
pub mod mute;
pub mod relay;
pub const FALLBACK_PUBKEY: fn() -> enostr::Pubkey = || {
enostr::Pubkey::new([
170, 115, 48, 129, 228, 240, 247, 157, 212, 48, 35, 216, 152, 50, 101, 89, 63, 43, 65, 169,
136, 103, 28, 252, 239, 63, 72, 155, 145, 173, 147, 254,
])
};

View File

@@ -1,99 +0,0 @@
use std::sync::Arc;
use nostrdb::{Filter, Ndb, NoteKey, Subscription, Transaction};
use tracing::{debug, error};
use crate::Muted;
#[derive(Clone)]
pub(crate) struct AccountMutedData {
pub filter: Filter,
pub muted: Arc<Muted>,
}
impl AccountMutedData {
pub fn new(pubkey: &[u8; 32]) -> Self {
// Construct a filter for the user's NIP-51 muted list
let filter = Filter::new()
.authors([pubkey])
.kinds([10000])
.limit(1)
.build();
AccountMutedData {
filter,
muted: Arc::new(Muted::default()),
}
}
pub(super) fn query(&mut self, ndb: &Ndb, txn: &Transaction) {
// Query the ndb immediately to see if the user's muted list is already there
let lim = self
.filter
.limit()
.unwrap_or(crate::filter::default_limit()) as i32;
let nks = ndb
.query(txn, &[self.filter.clone()], lim)
.expect("query user muted results")
.iter()
.map(|qr| qr.note_key)
.collect::<Vec<NoteKey>>();
let muted = Self::harvest_nip51_muted(ndb, txn, &nks);
debug!("initial muted {:?}", muted);
self.muted = Arc::new(muted);
}
pub(crate) fn harvest_nip51_muted(ndb: &Ndb, txn: &Transaction, nks: &[NoteKey]) -> Muted {
let mut muted = Muted::default();
for nk in nks.iter() {
if let Ok(note) = ndb.get_note_by_key(txn, *nk) {
for tag in note.tags() {
match tag.get(0).and_then(|t| t.variant().str()) {
Some("p") => {
if let Some(id) = tag.get(1).and_then(|f| f.variant().id()) {
muted.pubkeys.insert(*id);
}
}
Some("t") => {
if let Some(str) = tag.get(1).and_then(|f| f.variant().str()) {
muted.hashtags.insert(str.to_string());
}
}
Some("word") => {
if let Some(str) = tag.get(1).and_then(|f| f.variant().str()) {
muted.words.insert(str.to_string());
}
}
Some("e") => {
if let Some(id) = tag.get(1).and_then(|f| f.variant().id()) {
muted.threads.insert(*id);
}
}
Some("alt") => {
// maybe we can ignore these?
}
Some(x) => error!("query_nip51_muted: unexpected tag: {}", x),
None => error!(
"query_nip51_muted: bad tag value: {:?}",
tag.get_unchecked(0).variant()
),
}
}
}
}
muted
}
pub(super) fn poll_for_updates(&mut self, ndb: &Ndb, txn: &Transaction, sub: Subscription) {
let nks = ndb.poll_for_notes(sub, 1);
if nks.is_empty() {
return;
}
let muted = AccountMutedData::harvest_nip51_muted(ndb, txn, &nks);
debug!("updated muted {:?}", muted);
self.muted = Arc::new(muted);
}
}

View File

@@ -1,262 +0,0 @@
use std::collections::BTreeSet;
use enostr::{Keypair, Pubkey, RelayPool};
use nostrdb::{Filter, Ndb, NoteBuilder, NoteKey, Subscription, Transaction};
use tracing::{debug, error, info};
use url::Url;
use crate::{AccountData, RelaySpec};
#[derive(Clone)]
pub(crate) struct AccountRelayData {
pub filter: Filter,
pub local: BTreeSet<RelaySpec>, // used locally but not advertised
pub advertised: BTreeSet<RelaySpec>, // advertised via NIP-65
}
impl AccountRelayData {
pub fn new(pubkey: &[u8; 32]) -> Self {
// Construct a filter for the user's NIP-65 relay list
let filter = Filter::new()
.authors([pubkey])
.kinds([10002])
.limit(1)
.build();
AccountRelayData {
filter,
local: BTreeSet::new(),
advertised: BTreeSet::new(),
}
}
pub fn query(&mut self, ndb: &Ndb, txn: &Transaction) {
// Query the ndb immediately to see if the user list is already there
let lim = self
.filter
.limit()
.unwrap_or(crate::filter::default_limit()) as i32;
let nks = ndb
.query(txn, &[self.filter.clone()], lim)
.expect("query user relays results")
.iter()
.map(|qr| qr.note_key)
.collect::<Vec<NoteKey>>();
let relays = Self::harvest_nip65_relays(ndb, txn, &nks);
debug!("initial relays {:?}", relays);
self.advertised = relays.into_iter().collect()
}
// standardize the format (ie, trailing slashes) to avoid dups
pub fn canonicalize_url(url: &str) -> String {
match Url::parse(url) {
Ok(parsed_url) => parsed_url.to_string(),
Err(_) => url.to_owned(), // If parsing fails, return the original URL.
}
}
pub(crate) fn harvest_nip65_relays(
ndb: &Ndb,
txn: &Transaction,
nks: &[NoteKey],
) -> Vec<RelaySpec> {
let mut relays = Vec::new();
for nk in nks.iter() {
if let Ok(note) = ndb.get_note_by_key(txn, *nk) {
for tag in note.tags() {
match tag.get(0).and_then(|t| t.variant().str()) {
Some("r") => {
if let Some(url) = tag.get(1).and_then(|f| f.variant().str()) {
let has_read_marker = tag
.get(2)
.is_some_and(|m| m.variant().str() == Some("read"));
let has_write_marker = tag
.get(2)
.is_some_and(|m| m.variant().str() == Some("write"));
relays.push(RelaySpec::new(
Self::canonicalize_url(url),
has_read_marker,
has_write_marker,
));
}
}
Some("alt") => {
// ignore for now
}
Some(x) => {
error!("harvest_nip65_relays: unexpected tag type: {}", x);
}
None => {
error!("harvest_nip65_relays: invalid tag");
}
}
}
}
}
relays
}
pub fn publish_nip65_relays(&self, seckey: &[u8; 32], pool: &mut RelayPool) {
let mut builder = NoteBuilder::new().kind(10002).content("");
for rs in &self.advertised {
builder = builder.start_tag().tag_str("r").tag_str(&rs.url);
if rs.has_read_marker {
builder = builder.tag_str("read");
} else if rs.has_write_marker {
builder = builder.tag_str("write");
}
}
let note = builder.sign(seckey).build().expect("note build");
pool.send(&enostr::ClientMessage::event(&note).expect("note client message"));
}
pub fn poll_for_updates(&mut self, ndb: &Ndb, txn: &Transaction, sub: Subscription) -> bool {
let nks = ndb.poll_for_notes(sub, 1);
if nks.is_empty() {
return false;
}
let relays = AccountRelayData::harvest_nip65_relays(ndb, txn, &nks);
debug!("updated relays {:?}", relays);
self.advertised = relays.into_iter().collect();
true
}
}
pub(crate) struct RelayDefaults {
pub forced_relays: BTreeSet<RelaySpec>,
pub bootstrap_relays: BTreeSet<RelaySpec>,
}
impl RelayDefaults {
pub(crate) fn new(forced_relays: Vec<String>) -> Self {
let forced_relays: BTreeSet<RelaySpec> = forced_relays
.into_iter()
.map(|u| RelaySpec::new(AccountRelayData::canonicalize_url(&u), false, false))
.collect();
let bootstrap_relays = [
"wss://relay.damus.io",
// "wss://pyramid.fiatjaf.com", // Uncomment if needed
"wss://nos.lol",
"wss://nostr.wine",
"wss://purplepag.es",
]
.iter()
.map(|&url| url.to_string())
.map(|u| RelaySpec::new(AccountRelayData::canonicalize_url(&u), false, false))
.collect();
Self {
forced_relays,
bootstrap_relays,
}
}
}
pub(super) fn update_relay_configuration(
pool: &mut RelayPool,
relay_defaults: &RelayDefaults,
pk: &Pubkey,
data: &AccountRelayData,
wakeup: impl Fn() + Send + Sync + Clone + 'static,
) {
debug!(
"updating relay configuration for currently selected {:?}",
pk.hex()
);
// If forced relays are set use them only
let mut desired_relays = relay_defaults.forced_relays.clone();
// Compose the desired relay lists from the selected account
if desired_relays.is_empty() {
desired_relays.extend(data.local.iter().cloned());
desired_relays.extend(data.advertised.iter().cloned());
}
// If no relays are specified at this point use the bootstrap list
if desired_relays.is_empty() {
desired_relays = relay_defaults.bootstrap_relays.clone();
}
debug!("current relays: {:?}", pool.urls());
debug!("desired relays: {:?}", desired_relays);
let pool_specs = pool
.urls()
.iter()
.map(|url| RelaySpec::new(url.clone(), false, false))
.collect();
let add: BTreeSet<RelaySpec> = desired_relays.difference(&pool_specs).cloned().collect();
let mut sub: BTreeSet<RelaySpec> = pool_specs.difference(&desired_relays).cloned().collect();
if !add.is_empty() {
debug!("configuring added relays: {:?}", add);
let _ = pool.add_urls(add.iter().map(|r| r.url.clone()).collect(), wakeup);
}
if !sub.is_empty() {
// certain relays are persistent like the multicast relay,
// although we should probably have a way to explicitly
// disable it
sub.remove(&RelaySpec::new("multicast", false, false));
debug!("removing unwanted relays: {:?}", sub);
pool.remove_urls(&sub.iter().map(|r| r.url.clone()).collect());
}
debug!("current relays: {:?}", pool.urls());
}
pub enum RelayAction {
Add(String),
Remove(String),
}
impl RelayAction {
pub(super) fn get_url(&self) -> &str {
match self {
RelayAction::Add(url) => url,
RelayAction::Remove(url) => url,
}
}
}
pub(super) fn modify_advertised_relays(
kp: &Keypair,
action: RelayAction,
pool: &mut RelayPool,
relay_defaults: &RelayDefaults,
account_data: &mut AccountData,
) {
let relay_url = AccountRelayData::canonicalize_url(action.get_url());
match action {
RelayAction::Add(_) => info!("add advertised relay \"{}\"", relay_url),
RelayAction::Remove(_) => info!("remove advertised relay \"{}\"", relay_url),
}
// let selected = self.cache.selected_mut();
let advertised = &mut account_data.relay.advertised;
if advertised.is_empty() {
// If the selected account has no advertised relays,
// initialize with the bootstrapping set.
advertised.extend(relay_defaults.bootstrap_relays.iter().cloned());
}
match action {
RelayAction::Add(_) => {
advertised.insert(RelaySpec::new(relay_url, false, false));
}
RelayAction::Remove(_) => {
advertised.remove(&RelaySpec::new(relay_url, false, false));
}
}
// If we have the secret key publish the NIP-65 relay list
if let Some(secretkey) = &kp.secret_key {
account_data
.relay
.publish_nip65_relays(&secretkey.to_secret_bytes(), pool);
}
}

View File

@@ -1,306 +0,0 @@
use crate::account::FALLBACK_PUBKEY;
use crate::i18n::Localization;
use crate::persist::{AppSizeHandler, ZoomHandler};
use crate::wallet::GlobalWallet;
use crate::zaps::Zaps;
use crate::JobPool;
use crate::{
frame_history::FrameHistory, AccountStorage, Accounts, AppContext, Args, DataPath,
DataPathType, Directory, Images, NoteAction, NoteCache, RelayDebugView, ThemeHandler,
UnknownIds,
};
use egui::Margin;
use egui::ThemePreference;
use egui_winit::clipboard::Clipboard;
use enostr::RelayPool;
use nostrdb::{Config, Ndb, Transaction};
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::path::Path;
use std::rc::Rc;
use tracing::{error, info};
pub enum AppAction {
Note(NoteAction),
ToggleChrome,
}
pub trait App {
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction>;
}
/// Main notedeck app framework
pub struct Notedeck {
ndb: Ndb,
img_cache: Images,
unknown_ids: UnknownIds,
pool: RelayPool,
note_cache: NoteCache,
accounts: Accounts,
global_wallet: GlobalWallet,
path: DataPath,
args: Args,
theme: ThemeHandler,
app: Option<Rc<RefCell<dyn App>>>,
zoom: ZoomHandler,
app_size: AppSizeHandler,
unrecognized_args: BTreeSet<String>,
clipboard: Clipboard,
zaps: Zaps,
frame_history: FrameHistory,
job_pool: JobPool,
i18n: Localization,
}
/// Our chrome, which is basically nothing
fn main_panel(style: &egui::Style) -> egui::CentralPanel {
egui::CentralPanel::default().frame(egui::Frame {
inner_margin: Margin::ZERO,
fill: style.visuals.panel_fill,
..Default::default()
})
}
fn render_notedeck(notedeck: &mut Notedeck, ctx: &egui::Context) {
main_panel(&ctx.style()).show(ctx, |ui| {
// render app
let Some(app) = &notedeck.app else {
return;
};
let app = app.clone();
app.borrow_mut().update(&mut notedeck.app_context(), ui);
// Move the screen up when we have a virtual keyboard
// NOTE: actually, we only want to do this if the keyboard is covering the focused element?
/*
let keyboard_height = crate::platform::virtual_keyboard_height() as f32;
if keyboard_height > 0.0 {
ui.ctx().transform_layer_shapes(
ui.layer_id(),
egui::emath::TSTransform::from_translation(egui::Vec2::new(0.0, -(keyboard_height/2.0))),
);
}
*/
});
}
impl eframe::App for Notedeck {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
profiling::finish_frame!();
self.frame_history
.on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
// handle account updates
self.accounts.update(&mut self.ndb, &mut self.pool, ctx);
self.zaps
.process(&mut self.accounts, &mut self.global_wallet, &self.ndb);
render_notedeck(self, ctx);
self.zoom.try_save_zoom_factor(ctx);
self.app_size.try_save_app_size(ctx);
if self.args.relay_debug {
if self.pool.debug.is_none() {
self.pool.use_debug();
}
if let Some(debug) = &mut self.pool.debug {
RelayDebugView::window(ctx, debug);
}
}
#[cfg(feature = "puffin")]
puffin_egui::profiler_window(ctx);
}
/// Called by the framework to save state before shutdown.
fn save(&mut self, _storage: &mut dyn eframe::Storage) {
//eframe::set_value(storage, eframe::APP_KEY, self);
}
}
#[cfg(feature = "puffin")]
fn setup_puffin() {
info!("setting up puffin");
puffin::set_scopes_on(true); // tell puffin to collect data
}
impl Notedeck {
pub fn new<P: AsRef<Path>>(ctx: &egui::Context, data_path: P, args: &[String]) -> Self {
#[cfg(feature = "puffin")]
setup_puffin();
// Skip the first argument, which is the program name.
let (parsed_args, unrecognized_args) = Args::parse(&args[1..]);
let data_path = parsed_args
.datapath
.clone()
.unwrap_or(data_path.as_ref().to_str().expect("db path ok").to_string());
let path = DataPath::new(&data_path);
let dbpath_str = parsed_args
.dbpath
.clone()
.unwrap_or_else(|| path.path(DataPathType::Db).to_str().unwrap().to_string());
let _ = std::fs::create_dir_all(&dbpath_str);
let img_cache_dir = path.path(DataPathType::Cache);
let _ = std::fs::create_dir_all(img_cache_dir.clone());
let map_size = if cfg!(target_os = "windows") {
// 16 Gib on windows because it actually creates the file
1024usize * 1024usize * 1024usize * 16usize
} else {
// 1 TiB for everything else since its just virtually mapped
1024usize * 1024usize * 1024usize * 1024usize
};
let theme = ThemeHandler::new(&path);
let config = Config::new().set_ingester_threads(2).set_mapsize(map_size);
let keystore = if parsed_args.use_keystore {
let keys_path = path.path(DataPathType::Keys);
let selected_key_path = path.path(DataPathType::SelectedKey);
Some(AccountStorage::new(
Directory::new(keys_path),
Directory::new(selected_key_path),
))
} else {
None
};
// AccountManager will setup the pool on first update
let mut pool = RelayPool::new();
{
let ctx = ctx.clone();
if let Err(err) = pool.add_multicast_relay(move || ctx.request_repaint()) {
error!("error setting up multicast relay: {err}");
}
}
let mut unknown_ids = UnknownIds::default();
let mut ndb = Ndb::new(&dbpath_str, &config).expect("ndb");
let txn = Transaction::new(&ndb).expect("txn");
let mut accounts = Accounts::new(
keystore,
parsed_args.relays.clone(),
FALLBACK_PUBKEY(),
&mut ndb,
&txn,
&mut pool,
ctx,
&mut unknown_ids,
);
{
for key in &parsed_args.keys {
info!("adding account: {}", &key.pubkey);
if let Some(resp) = accounts.add_account(key.clone()) {
resp.unk_id_action
.process_action(&mut unknown_ids, &ndb, &txn);
}
}
}
if let Some(first) = parsed_args.keys.first() {
accounts.select_account(&first.pubkey, &mut ndb, &txn, &mut pool, ctx);
}
let img_cache = Images::new(img_cache_dir);
let note_cache = NoteCache::default();
let zoom = ZoomHandler::new(&path);
let app_size = AppSizeHandler::new(&path);
if let Some(z) = zoom.get_zoom_factor() {
ctx.set_zoom_factor(z);
}
// migrate
if let Err(e) = img_cache.migrate_v0() {
error!("error migrating image cache: {e}");
}
let global_wallet = GlobalWallet::new(&path);
let zaps = Zaps::default();
let job_pool = JobPool::default();
// Initialize localization
let mut i18n = Localization::new();
if let Some(locale) = &parsed_args.locale {
if let Err(err) = i18n.set_locale(locale.to_owned()) {
error!("{err}");
}
}
// Initialize global i18n context
//crate::i18n::init_global_i18n(i18n.clone());
Self {
ndb,
img_cache,
unknown_ids,
pool,
note_cache,
accounts,
global_wallet,
path: path.clone(),
args: parsed_args,
theme,
app: None,
zoom,
app_size,
unrecognized_args,
frame_history: FrameHistory::default(),
clipboard: Clipboard::new(None),
zaps,
job_pool,
i18n,
}
}
pub fn app<A: App + 'static>(mut self, app: A) -> Self {
self.set_app(app);
self
}
pub fn app_context(&mut self) -> AppContext<'_> {
AppContext {
ndb: &mut self.ndb,
img_cache: &mut self.img_cache,
unknown_ids: &mut self.unknown_ids,
pool: &mut self.pool,
note_cache: &mut self.note_cache,
accounts: &mut self.accounts,
global_wallet: &mut self.global_wallet,
path: &self.path,
args: &self.args,
theme: &mut self.theme,
clipboard: &mut self.clipboard,
zaps: &mut self.zaps,
frame_history: &mut self.frame_history,
job_pool: &mut self.job_pool,
i18n: &mut self.i18n,
}
}
pub fn set_app<T: App + 'static>(&mut self, app: T) {
self.app = Some(Rc::new(RefCell::new(app)));
}
pub fn args(&self) -> &Args {
&self.args
}
pub fn theme(&self) -> ThemePreference {
self.theme.load()
}
pub fn unrecognized_args(&self) -> &BTreeSet<String> {
&self.unrecognized_args
}
}

View File

@@ -1,152 +0,0 @@
use std::collections::BTreeSet;
use enostr::{Keypair, Pubkey, SecretKey};
use tracing::error;
use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
pub struct Args {
pub relays: Vec<String>,
pub is_mobile: Option<bool>,
pub locale: Option<LanguageIdentifier>,
pub show_note_client: bool,
pub keys: Vec<Keypair>,
pub light: bool,
pub debug: bool,
pub relay_debug: bool,
/// Enable when running tests so we don't panic on app startup
pub tests: bool,
pub use_keystore: bool,
pub dbpath: Option<String>,
pub datapath: Option<String>,
}
impl Args {
// parse arguments, return set of unrecognized args
pub fn parse(args: &[String]) -> (Self, BTreeSet<String>) {
let mut unrecognized_args = BTreeSet::new();
let mut res = Args {
relays: vec![],
is_mobile: None,
keys: vec![],
light: false,
show_note_client: false,
debug: false,
relay_debug: false,
tests: false,
use_keystore: true,
dbpath: None,
datapath: None,
locale: None,
};
let mut i = 0;
let len = args.len();
while i < len {
let arg = &args[i];
if arg == "--mobile" {
res.is_mobile = Some(true);
} else if arg == "--light" {
res.light = true;
} else if arg == "--locale" {
i += 1;
let Some(locale) = args.get(i) else {
panic!("locale argument missing?");
};
let parsed: Result<LanguageIdentifier, LanguageIdentifierError> = locale.parse();
match parsed {
Err(err) => {
panic!("locale failed to parse: {err}");
}
Ok(locale) => {
tracing::info!(
"parsed locale '{locale}' from args, not sure if we have it yet though."
);
res.locale = Some(locale);
}
}
} else if arg == "--dark" {
res.light = false;
} else if arg == "--debug" {
res.debug = true;
} else if arg == "--testrunner" {
res.tests = true;
} else if arg == "--pub" || arg == "--npub" {
i += 1;
let pubstr = if let Some(next_arg) = args.get(i) {
next_arg
} else {
error!("sec argument missing?");
continue;
};
if let Ok(pk) = Pubkey::parse(pubstr) {
res.keys.push(Keypair::only_pubkey(pk));
} else {
error!(
"failed to parse {} argument. Make sure to use hex or npub.",
arg
);
}
} else if arg == "--sec" || arg == "--nsec" {
i += 1;
let secstr = if let Some(next_arg) = args.get(i) {
next_arg
} else {
error!("sec argument missing?");
continue;
};
if let Ok(sec) = SecretKey::parse(secstr) {
res.keys.push(Keypair::from_secret(sec));
} else {
error!(
"failed to parse {} argument. Make sure to use hex or nsec.",
arg
);
}
} else if arg == "--dbpath" {
i += 1;
let path = if let Some(next_arg) = args.get(i) {
next_arg
} else {
error!("dbpath argument missing?");
continue;
};
res.dbpath = Some(path.clone());
} else if arg == "--datapath" {
i += 1;
let path = if let Some(next_arg) = args.get(i) {
next_arg
} else {
error!("datapath argument missing?");
continue;
};
res.datapath = Some(path.clone());
} else if arg == "-r" || arg == "--relay" {
i += 1;
let relay = if let Some(next_arg) = args.get(i) {
next_arg
} else {
error!("relay argument missing?");
continue;
};
res.relays.push(relay.clone());
} else if arg == "--no-keystore" {
res.use_keystore = false;
} else if arg == "--relay-debug" {
res.relay_debug = true;
} else if arg == "--show-note-client" {
res.show_note_client = true;
} else {
unrecognized_args.insert(arg.clone());
}
i += 1;
}
(res, unrecognized_args)
}
}

View File

@@ -1,24 +0,0 @@
use crate::{
filter::{self, HybridFilter},
Error,
};
use nostrdb::{Filter, Note};
pub fn contacts_filter(pk: &[u8; 32]) -> Filter {
Filter::new().authors([pk]).kinds([3]).limit(1).build()
}
/// Contact filters have an additional kind0 in the remote filter so it can fetch profiles as well
/// we don't need this in the local filter since we only care about the kind1 results
pub fn hybrid_contacts_filter(
note: &Note,
add_pk: Option<&[u8; 32]>,
with_hashtags: bool,
) -> Result<HybridFilter, Error> {
let local = filter::filter_from_tags(note, add_pk, with_hashtags)?
.into_filter([1], filter::default_limit());
let remote = filter::filter_from_tags(note, add_pk, with_hashtags)?
.into_filter([1, 0], filter::default_remote_limit());
Ok(HybridFilter::split(local, remote))
}

View File

@@ -1,29 +0,0 @@
use crate::{
account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization,
wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, ThemeHandler,
UnknownIds,
};
use egui_winit::clipboard::Clipboard;
use enostr::RelayPool;
use nostrdb::Ndb;
// TODO: make this interface more sandboxed
pub struct AppContext<'a> {
pub ndb: &'a mut Ndb,
pub img_cache: &'a mut Images,
pub unknown_ids: &'a mut UnknownIds,
pub pool: &'a mut RelayPool,
pub note_cache: &'a mut NoteCache,
pub accounts: &'a mut Accounts,
pub global_wallet: &'a mut GlobalWallet,
pub path: &'a DataPath,
pub args: &'a Args,
pub theme: &'a mut ThemeHandler,
pub clipboard: &'a mut Clipboard,
pub zaps: &'a mut Zaps,
pub frame_history: &'a mut FrameHistory,
pub job_pool: &'a mut JobPool,
pub i18n: &'a mut Localization,
}

View File

@@ -1,35 +0,0 @@
use std::time::{Duration, Instant};
/// A simple debouncer that tracks when an action was last performed
/// and determines if enough time has passed to perform it again.
#[derive(Debug)]
pub struct Debouncer {
delay: Duration,
last_action: Instant,
}
impl Debouncer {
/// Creates a new Debouncer with the specified delay
pub fn new(delay: Duration) -> Self {
Self {
delay,
last_action: Instant::now() - delay, // Start ready to act
}
}
/// Sets a new delay value and returns self for method chaining
pub fn with_delay(mut self, delay: Duration) -> Self {
self.delay = delay;
self
}
/// Checks if enough time has passed since the last action
pub fn should_act(&self) -> bool {
self.last_action.elapsed() >= self.delay
}
/// Marks an action as performed, updating the timestamp
pub fn bounce(&mut self) {
self.last_action = Instant::now();
}
}

View File

@@ -1,94 +0,0 @@
use std::io;
/// App related errors
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("image error: {0}")]
Image(#[from] image::error::ImageError),
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("subscription error: {0}")]
SubscriptionError(SubscriptionError),
#[error("filter error: {0}")]
Filter(FilterError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Nostrdb(#[from] nostrdb::Error),
#[error("generic error: {0}")]
Generic(String),
#[error("zaps error: {0}")]
Zap(#[from] ZapError),
}
#[derive(Debug, thiserror::Error, Clone)]
pub enum ZapError {
#[error("invalid lud16")]
InvalidLud16(String),
#[error("invalid endpoint response")]
EndpointError(String),
#[error("bech encoding/decoding error")]
Bech(String),
#[error("serialization/deserialization problem")]
Serialization(String),
#[error("nwc error")]
NWC(String),
}
impl From<String> for Error {
fn from(s: String) -> Self {
Error::Generic(s)
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, thiserror::Error)]
pub enum FilterError {
#[error("empty contact list")]
EmptyContactList,
#[error("filter not ready")]
FilterNotReady,
}
#[derive(Debug, Eq, PartialEq, Copy, Clone, thiserror::Error)]
pub enum SubscriptionError {
#[error("no active subscriptions")]
NoActive,
/// When a timeline has an unexpected number
/// of active subscriptions. Should only happen if there
/// is a bug in notedeck
#[error("unexpected subscription count")]
UnexpectedSubscriptionCount(i32),
}
impl Error {
pub fn unexpected_sub_count(c: i32) -> Self {
Error::SubscriptionError(SubscriptionError::UnexpectedSubscriptionCount(c))
}
pub fn no_active_sub() -> Self {
Error::SubscriptionError(SubscriptionError::NoActive)
}
pub fn empty_contact_list() -> Self {
Error::Filter(FilterError::EmptyContactList)
}
}
pub fn show_one_error_message(ui: &mut egui::Ui, message: &str) {
let id = ui.id().with(("error", message));
let res: Option<()> = ui.ctx().data(|d| d.get_temp(id));
if res.is_none() {
ui.ctx().data_mut(|d| d.insert_temp(id, ()));
tracing::error!(message);
}
}

View File

@@ -1,58 +0,0 @@
use crate::{ui, NotedeckTextStyle};
pub enum NamedFontFamily {
Medium,
Bold,
Emoji,
}
impl NamedFontFamily {
pub fn as_str(&mut self) -> &'static str {
match self {
Self::Bold => "bold",
Self::Medium => "medium",
Self::Emoji => "emoji",
}
}
pub fn as_family(&mut self) -> egui::FontFamily {
egui::FontFamily::Name(self.as_str().into())
}
}
pub fn desktop_font_size(text_style: &NotedeckTextStyle) -> f32 {
match text_style {
NotedeckTextStyle::Heading => 24.0,
NotedeckTextStyle::Heading2 => 22.0,
NotedeckTextStyle::Heading3 => 20.0,
NotedeckTextStyle::Heading4 => 14.0,
NotedeckTextStyle::Body => 16.0,
NotedeckTextStyle::Monospace => 13.0,
NotedeckTextStyle::Button => 13.0,
NotedeckTextStyle::Small => 12.0,
NotedeckTextStyle::Tiny => 10.0,
}
}
pub fn mobile_font_size(text_style: &NotedeckTextStyle) -> f32 {
// TODO: tweak text sizes for optimal mobile viewing
match text_style {
NotedeckTextStyle::Heading => 24.0,
NotedeckTextStyle::Heading2 => 22.0,
NotedeckTextStyle::Heading3 => 20.0,
NotedeckTextStyle::Heading4 => 14.0,
NotedeckTextStyle::Body => 13.0,
NotedeckTextStyle::Monospace => 13.0,
NotedeckTextStyle::Button => 13.0,
NotedeckTextStyle::Small => 12.0,
NotedeckTextStyle::Tiny => 10.0,
}
}
pub fn get_font_size(ctx: &egui::Context, text_style: &NotedeckTextStyle) -> f32 {
if ui::is_narrow(ctx) {
mobile_font_size(text_style)
} else {
desktop_font_size(text_style)
}
}

View File

@@ -1,24 +0,0 @@
use super::IntlKeyBuf;
use unic_langid::LanguageIdentifier;
/// App related errors
#[derive(thiserror::Error, Debug)]
pub enum IntlError {
#[error("message not found: {0}")]
NotFound(IntlKeyBuf),
#[error("message has no value: {0}")]
NoValue(IntlKeyBuf),
#[error("Locale({0}) parse error: {1}")]
LocaleParse(LanguageIdentifier, String),
#[error("locale not available: {0}")]
LocaleNotAvailable(LanguageIdentifier),
#[error("FTL for '{0}' is not available")]
NoFtl(LanguageIdentifier),
#[error("Bundle for '{0}' is not available")]
NoBundle(LanguageIdentifier),
}

View File

@@ -1,47 +0,0 @@
use std::fmt;
/// An owned key used to lookup i18n translations. Mostly used for errors
#[derive(Eq, PartialEq, Clone, Debug)]
pub struct IntlKeyBuf(String);
/// A key used to lookup i18n translations
#[derive(Eq, PartialEq, Clone, Copy, Debug)]
pub struct IntlKey<'a>(&'a str);
impl fmt::Display for IntlKey<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// Use `self.number` to refer to each positional data point.
write!(f, "{}", self.0)
}
}
impl fmt::Display for IntlKeyBuf {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// Use `self.number` to refer to each positional data point.
write!(f, "{}", &self.0)
}
}
impl IntlKeyBuf {
pub fn new(string: impl Into<String>) -> Self {
IntlKeyBuf(string.into())
}
pub fn borrow<'a>(&'a self) -> IntlKey<'a> {
IntlKey::new(&self.0)
}
}
impl<'a> IntlKey<'a> {
pub fn new(string: &'a str) -> IntlKey<'a> {
IntlKey(string)
}
pub fn to_owned(&self) -> IntlKeyBuf {
IntlKeyBuf::new(self.0)
}
pub fn as_str(&self) -> &'a str {
self.0
}
}

View File

@@ -1,639 +0,0 @@
use super::{IntlError, IntlKey, IntlKeyBuf};
use fluent::{FluentArgs, FluentBundle, FluentResource};
use fluent_langneg::negotiate_languages;
use std::borrow::Cow;
use std::collections::HashMap;
use unic_langid::{langid, LanguageIdentifier};
const EN_XA: LanguageIdentifier = langid!("en-XA");
const EN_US: LanguageIdentifier = langid!("en-US");
const DE: LanguageIdentifier = langid!("de");
const FR: LanguageIdentifier = langid!("FR");
const ZH_CN: LanguageIdentifier = langid!("ZH_CN");
const ZH_TW: LanguageIdentifier = langid!("ZH_TW");
const NUM_FTLS: usize = 6;
struct StaticBundle {
identifier: LanguageIdentifier,
ftl: &'static str,
}
const FTLS: [StaticBundle; NUM_FTLS] = [
StaticBundle {
identifier: EN_US,
ftl: include_str!("../../../../assets/translations/en-US/main.ftl"),
},
StaticBundle {
identifier: EN_XA,
ftl: include_str!("../../../../assets/translations/en-XA/main.ftl"),
},
StaticBundle {
identifier: DE,
ftl: include_str!("../../../../assets/translations/de/main.ftl"),
},
StaticBundle {
identifier: FR,
ftl: include_str!("../../../../assets/translations/fr/main.ftl"),
},
StaticBundle {
identifier: ZH_CN,
ftl: include_str!("../../../../assets/translations/zh-CN/main.ftl"),
},
StaticBundle {
identifier: ZH_TW,
ftl: include_str!("../../../../assets/translations/zh-TW/main.ftl"),
},
];
type Bundle = FluentBundle<FluentResource>;
/// Manages localization resources and provides localized strings
pub struct Localization {
/// Current locale
current_locale: LanguageIdentifier,
/// Available locales
available_locales: Vec<LanguageIdentifier>,
/// Fallback locale
fallback_locale: LanguageIdentifier,
/// Cached string results per locale (only for strings without arguments)
string_cache: HashMap<LanguageIdentifier, HashMap<String, String>>,
/// Cached normalized keys
normalized_key_cache: HashMap<String, IntlKeyBuf>,
/// Bundles
bundles: HashMap<LanguageIdentifier, Bundle>,
use_isolating: bool,
}
impl Default for Localization {
fn default() -> Self {
// Default to English (US)
let default_locale = &EN_US;
let fallback_locale = default_locale.to_owned();
// Build available locales list
let available_locales = vec![
EN_US.clone(),
EN_XA.clone(),
DE.clone(),
FR.clone(),
ZH_CN.clone(),
ZH_TW.clone(),
];
Self {
current_locale: default_locale.to_owned(),
available_locales,
fallback_locale,
use_isolating: true,
normalized_key_cache: HashMap::new(),
string_cache: HashMap::new(),
bundles: HashMap::new(),
}
}
}
impl Localization {
/// Creates a new Localization with the specified resource directory
pub fn new() -> Self {
Localization::default()
}
/// Disable bidirectional isolation markers. mostly useful for tests
pub fn no_bidi() -> Self {
Localization {
use_isolating: false,
..Localization::default()
}
}
/// Gets a localized string by its ID
pub fn get_string(&mut self, id: IntlKey<'_>) -> Result<String, IntlError> {
self.get_cached_string(id, None)
}
/// Load a fluent bundle given a language identifier. Only looks in the static
/// ftl files baked into the binary
fn load_bundle(lang: &LanguageIdentifier) -> Result<Bundle, IntlError> {
for ftl in &FTLS {
if &ftl.identifier == lang {
let mut bundle = FluentBundle::new(vec![lang.to_owned()]);
let resource = FluentResource::try_new(ftl.ftl.to_string());
match resource {
Err((resource, errors)) => {
for error in errors {
tracing::error!("load_bundle ({lang}): {error}");
}
tracing::warn!("load_bundle ({}: loading bundle with errors", lang);
if let Err(errs) = bundle.add_resource(resource) {
for err in errs {
tracing::error!("adding resource: {err}");
}
}
}
Ok(resource) => {
tracing::info!("loaded {} bundle OK!", lang);
if let Err(errs) = bundle.add_resource(resource) {
for err in errs {
tracing::error!("adding resource 2: {err}");
}
}
}
}
return Ok(bundle);
}
}
// no static ftl for this LanguageIdentifier
Err(IntlError::NoFtl(lang.to_owned()))
}
fn get_bundle<'a>(&'a self, lang: &LanguageIdentifier) -> &'a Bundle {
self.bundles
.get(lang)
.expect("make sure to call ensure_bundle!")
}
fn has_bundle(&self, lang: &LanguageIdentifier) -> bool {
self.bundles.contains_key(lang)
}
fn try_load_bundle(&mut self, lang: &LanguageIdentifier) -> Result<(), IntlError> {
let mut bundle = Self::load_bundle(lang)?;
if !self.use_isolating {
bundle.set_use_isolating(false);
}
self.bundles.insert(lang.to_owned(), bundle);
Ok(())
}
pub fn normalized_ftl_key(&mut self, key: &str, comment: &str) -> IntlKeyBuf {
match self.get_ftl_key(key) {
Some(intl_key) => intl_key,
None => {
self.insert_ftl_key(key, comment);
self.get_ftl_key(key).unwrap()
}
}
}
fn get_ftl_key(&self, cache_key: &str) -> Option<IntlKeyBuf> {
self.normalized_key_cache.get(cache_key).cloned()
}
fn insert_ftl_key(&mut self, cache_key: &str, comment: &str) {
let mut result = fixup_key(cache_key);
// Ensure the key starts with a letter (Fluent requirement)
if result.is_empty() || !result.chars().next().unwrap().is_ascii_alphabetic() {
result = format!("k_{result}");
}
// If we have a comment, append a hash of it to reduce collisions
let hash_str = format!("_{}", simple_hash(comment));
result.push_str(&hash_str);
tracing::debug!(
"normalize_ftl_key: original='{}', final='{}'",
cache_key,
result
);
self.normalized_key_cache
.insert(cache_key.to_owned(), IntlKeyBuf::new(result));
}
fn get_cached_string_no_args<'key>(
&'key self,
lang: &LanguageIdentifier,
id: IntlKey<'key>,
) -> Result<Cow<'key, str>, IntlError> {
// Try to get from string cache first
if let Some(locale_cache) = self.string_cache.get(lang) {
if let Some(cached_string) = locale_cache.get(id.as_str()) {
/*
tracing::trace!(
"Using cached string result for '{}' in locale: {}",
id,
&lang
);
*/
return Ok(Cow::Borrowed(cached_string));
}
}
Err(IntlError::NotFound(id.to_owned()))
}
fn ensure_bundle(&mut self) -> Result<(), IntlError> {
let locale = self.current_locale.clone();
if !self.has_bundle(&locale) {
match self.try_load_bundle(&locale) {
Err(err) => {
tracing::warn!(
"tried to load bundle {} but failed with '{err}'. using fallback {}",
&locale,
&self.fallback_locale
);
self.try_load_bundle(&locale)
.expect("failed to load fallback bundle!?");
Ok(())
}
Ok(()) => Ok(()),
}
} else {
Ok(())
}
}
fn get_current_bundle(&self) -> &Bundle {
if self.has_bundle(&self.current_locale) {
return self.get_bundle(&self.current_locale);
}
self.get_bundle(&self.fallback_locale)
}
/// Gets cached string result, or formats it and caches the result
pub fn get_cached_string(
&mut self,
id: IntlKey<'_>,
args: Option<&FluentArgs>,
) -> Result<String, IntlError> {
self.ensure_bundle()?;
if args.is_none() {
if let Ok(result) = self.get_cached_string_no_args(&self.current_locale, id) {
return Ok(result.to_string());
}
}
let result = {
let bundle = self.get_current_bundle();
let message = bundle
.get_message(id.as_str())
.ok_or_else(|| IntlError::NotFound(id.to_owned()))?;
let pattern = message
.value()
.ok_or_else(|| IntlError::NoValue(id.to_owned()))?;
let mut errors = Vec::with_capacity(0);
let result = bundle.format_pattern(pattern, args, &mut errors);
if !errors.is_empty() {
tracing::warn!("Localization errors for {}: {:?}", id, &errors);
}
result.to_string()
};
// Only cache simple strings without arguments
// This prevents caching issues when the same message ID is used with different arguments
if args.is_none() {
self.cache_string(self.current_locale.clone(), id, result.as_str());
tracing::debug!(
"Cached string result for '{}' in locale: {}",
id,
&self.current_locale
);
} else {
tracing::trace!("Not caching string '{}' due to arguments", id);
}
Ok(result)
}
pub fn cache_string<'a>(&mut self, locale: LanguageIdentifier, id: IntlKey<'a>, result: &str) {
tracing::debug!("Cached string result for '{}' in locale: {}", id, &locale);
let locale_cache = self.string_cache.entry(locale).or_default();
locale_cache.insert(id.to_owned().to_string(), result.to_owned());
}
/// Sets the current locale
pub fn set_locale(&mut self, locale: LanguageIdentifier) -> Result<(), IntlError> {
tracing::info!("Attempting to set locale to: {}", locale);
tracing::info!("Available locales: {:?}", self.available_locales);
// Validate that the locale is available
if !self.available_locales.contains(&locale) {
tracing::error!(
"Locale {} is not available. Available locales: {:?}",
locale,
self.available_locales
);
return Err(IntlError::LocaleNotAvailable(locale));
}
tracing::info!(
"Switching locale from {} to {}",
&self.current_locale,
&locale
);
self.current_locale = locale;
// Clear caches when locale changes since they are locale-specific
self.string_cache.clear();
tracing::debug!("String cache cleared due to locale change");
Ok(())
}
/// Clears the parsed FluentResource cache (useful for development when FTL files change)
pub fn clear_cache(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.bundles.clear();
tracing::debug!("Parsed FluentResource cache cleared");
self.string_cache.clear();
tracing::debug!("String result cache cleared");
Ok(())
}
/// Gets the current locale
pub fn get_current_locale(&self) -> &LanguageIdentifier {
&self.current_locale
}
/// Gets all available locales
pub fn get_available_locales(&self) -> &[LanguageIdentifier] {
&self.available_locales
}
/// Gets the fallback locale
pub fn get_fallback_locale(&self) -> &LanguageIdentifier {
&self.fallback_locale
}
/// Gets cache statistics for monitoring performance
pub fn get_cache_stats(&self) -> Result<CacheStats, Box<dyn std::error::Error + Send + Sync>> {
let mut total_strings = 0;
for locale_cache in self.string_cache.values() {
total_strings += locale_cache.len();
}
Ok(CacheStats {
resource_cache_size: self.bundles.len(),
string_cache_size: total_strings,
cached_locales: self.bundles.keys().cloned().collect(),
})
}
/// Limits the string cache size to prevent memory growth
pub fn limit_string_cache_size(
&mut self,
max_strings_per_locale: usize,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
for locale_cache in self.string_cache.values_mut() {
if locale_cache.len() > max_strings_per_locale {
// Remove oldest entries (simple approach: just clear and let it rebuild)
// In a more sophisticated implementation, you might use an LRU cache
locale_cache.clear();
tracing::debug!("Cleared string cache for locale due to size limit");
}
}
Ok(())
}
/// Negotiates the best locale from a list of preferred locales
pub fn negotiate_locale(&self, preferred: &[LanguageIdentifier]) -> LanguageIdentifier {
let available = self.available_locales.clone();
let negotiated = negotiate_languages(
preferred,
&available,
Some(&self.fallback_locale),
fluent_langneg::NegotiationStrategy::Filtering,
);
negotiated
.first()
.map_or(self.fallback_locale.clone(), |v| (*v).clone())
}
}
/// Statistics about cache usage
#[derive(Debug, Clone)]
pub struct CacheStats {
pub resource_cache_size: usize,
pub string_cache_size: usize,
pub cached_locales: Vec<LanguageIdentifier>,
}
#[cfg(test)]
mod tests {
//
// TODO(jb55): write tests that work, i broke all these during the refacto
//
/*
use super::*;
#[test]
fn test_locale_management() {
let i18n = Localization::default();
// Test default locale
let current = i18n.get_current_locale();
assert_eq!(current.to_string(), "en-US");
// Test available locales
let available = i18n.get_available_locales();
assert_eq!(available.len(), 2);
assert_eq!(available[0].to_string(), "en-US");
assert_eq!(available[1].to_string(), "en-XA");
}
#[test]
fn test_cache_clearing() {
let mut i18n = Localization::default();
// Load and cache the FTL content
let result1 = i18n.get_string(IntlKeyBuf::new("test_key").borrow());
assert!(result1.is_ok());
// Clear the cache
let clear_result = i18n.clear_cache();
assert!(clear_result.is_ok());
// Should still work after clearing cache (will reload)
let result2 = i18n.get_string(IntlKeyBuf::new("test_key").borrow());
assert!(result2.is_ok());
assert_eq!(result2.unwrap(), "Test Value");
}
#[test]
fn test_context_caching() {
let mut i18n = Localization::default();
// Debug: check what the normalized key should be
let normalized_key = i18n.normalized_ftl_key("test_key", "comment");
println!("Normalized key: '{}'", normalized_key);
// First call should load and cache the FTL content
let result1 = i18n.get_string(normalized_key.borrow());
println!("First result: {:?}", result1);
assert!(result1.is_ok());
assert_eq!(result1.unwrap(), "Test Value");
// Second call should use cached FTL content
let result2 = i18n.get_string(normalized_key.borrow());
assert!(result2.is_ok());
assert_eq!(result2.unwrap(), "Test Value");
// Test cache clearing through context
let clear_result = i18n.clear_cache();
assert!(clear_result.is_ok());
// Should still work after clearing cache
let result3 = i18n.get_string(normalized_key.borrow());
assert!(result3.is_ok());
assert_eq!(result3.unwrap(), "Test Value");
}
#[test]
fn test_ftl_caching() {
let mut i18n = Localization::default();
// First call should load and cache the FTL content
let result1 = i18n.get_string(IntlKeyBuf::new("test_key").borrow());
assert!(result1.is_ok());
assert_eq!(result1.as_ref().unwrap(), "Test Value");
// Second call should use cached FTL content
let result2 = i18n.get_string(IntlKeyBuf::new("test_key").borrow());
assert!(result2.is_ok());
assert_eq!(result2.unwrap(), "Test Value");
// Test another key from the same FTL content
let result3 = i18n.get_string(IntlKeyBuf::new("another_key").borrow());
assert!(result3.is_ok());
assert_eq!(result3.unwrap(), "Another Value");
}
#[test]
fn test_bundle_caching() {
let mut i18n = Localization::default();
// First call should create bundle and cache the resource
let result1 = i18n.get_string(IntlKeyBuf::new("test_key").borrow());
assert!(result1.is_ok());
assert_eq!(result1.unwrap(), "Test Value");
// Second call should use cached resource but create new bundle
let result2 = i18n.get_string(IntlKeyBuf::new("another_key").borrow());
assert!(result2.is_ok());
assert_eq!(result2.unwrap(), "Another Value");
// Check cache stats
let stats = i18n.get_cache_stats().unwrap();
assert_eq!(stats.resource_cache_size, 1);
assert_eq!(stats.string_cache_size, 2); // Both strings should be cached
}
#[test]
fn test_string_caching() {
let mut i18n = Localization::default();
let key = i18n.normalized_ftl_key("test_key", "comment");
// First call should format and cache the string
let result1 = i18n.get_string(key.borrow());
assert!(result1.is_ok());
assert_eq!(result1.unwrap(), "Test Value");
// Second call should use cached string
let result2 = i18n.get_string(key.borrow());
assert!(result2.is_ok());
assert_eq!(result2.unwrap(), "Test Value");
// Check cache stats
let stats = i18n.get_cache_stats().unwrap();
assert_eq!(stats.string_cache_size, 1);
}
#[test]
fn test_string_caching_with_arguments() {
let mut manager = Localization::default();
// First call with arguments should not be cached
let mut args = fluent::FluentArgs::new();
args.set("name", "Alice");
let key = IntlKeyBuf::new("welcome_message");
let result1 = manager
.get_cached_string(key.borrow(), Some(&args))
.unwrap();
assert!(result1.contains("Alice"));
// Check that it's not in the string cache
let stats1 = manager.get_cache_stats().unwrap();
assert_eq!(stats1.string_cache_size, 0);
// Second call with different arguments should work correctly
let mut args2 = fluent::FluentArgs::new();
args2.set("name", "Bob");
let result2 = manager.get_cached_string(key.borrow(), Some(&args2));
assert!(result2.is_ok());
let result2_str = result2.unwrap();
assert!(result2_str.contains("Bob"));
// Check that it's still not in the string cache
let stats2 = manager.get_cache_stats().unwrap();
assert_eq!(stats2.string_cache_size, 0);
// Clear cache to start fresh
manager.clear_cache().unwrap();
let result3 = manager.get_string(key.borrow());
assert!(result3.is_ok());
assert_eq!(result3.unwrap(), "Hello World");
// Check that simple string is cached
let stats3 = manager.get_cache_stats().unwrap();
assert_eq!(stats3.string_cache_size, 1);
}
#[test]
fn test_cache_clearing_on_locale_change() {
let mut i18n = Localization::default();
// Check that caches are populated
let stats1 = i18n.get_cache_stats().unwrap();
assert!(stats1.resource_cache_size > 0);
assert!(stats1.string_cache_size > 0);
// Switch to en-XA
let en_xa: LanguageIdentifier = langid!("en-XA");
i18n.set_locale(en_xa).unwrap();
// Check that string cache is cleared (resource cache remains for both locales)
let stats2 = i18n.get_cache_stats().unwrap();
assert_eq!(stats2.string_cache_size, 0);
}
*/
}
/// Replace each invalid character with exactly one underscore
/// This matches the behavior of the Python extraction script
pub fn fixup_key(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' => out.push(ch),
_ => out.push('_'), // always push
}
}
let trimmed = out.trim_matches('_');
trimmed.to_owned()
}
fn simple_hash(s: &str) -> String {
let digest = md5::compute(s.as_bytes());
// Take the first 2 bytes and convert to 4 hex characters
format!("{:02x}{:02x}", digest[0], digest[1])
}

View File

@@ -1,107 +0,0 @@
//! Internationalization (i18n) module for Notedeck
//!
//! This module provides localization support using fluent and fluent-resmgr.
//! It handles loading translation files, managing locales, and providing
//! localized strings throughout the application.
mod error;
mod key;
pub mod manager;
pub use error::IntlError;
pub use key::{IntlKey, IntlKeyBuf};
pub use manager::CacheStats;
pub use manager::Localization;
/// Re-export commonly used types for convenience
pub use fluent::FluentArgs;
pub use fluent::FluentValue;
pub use unic_langid::LanguageIdentifier;
/// Macro for getting localized strings with format-like syntax
///
/// Syntax: tr!("message", comment)
/// tr!("message with {param}", comment, param="value")
/// tr!("message with {first} and {second}", comment, first="value1", second="value2")
///
/// The first argument is the source message (like format!).
/// The second argument is always the comment to provide context for translators.
/// If `{name}` placeholders are found, there must be corresponding named arguments after the comment.
/// All placeholders must be named and start with a letter (a-zA-Z).
#[macro_export]
macro_rules! tr {
($i18n:expr, $message:expr, $comment:expr) => {
{
let key = $i18n.normalized_ftl_key($message, $comment);
match $i18n.get_string(key.borrow()) {
Ok(r) => r,
Err(_err) => {
$message.to_string()
}
}
}
};
// Case with named parameters: message, comment, param=value, ...
($i18n:expr, $message:expr, $comment:expr, $($param:ident = $value:expr),*) => {
{
let key = $i18n.normalized_ftl_key($message, $comment);
let mut args = $crate::i18n::FluentArgs::new();
$(
args.set(stringify!($param), $value);
)*
match $i18n.get_cached_string(key.borrow(), Some(&args)) {
Ok(r) => r,
Err(_) => {
// Fallback: replace placeholders with values
let mut result = $message.to_string();
$(
result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());
)*
result
}
}
}
};
}
/// Macro for getting localized pluralized strings with count and named arguments
///
/// Syntax: tr_plural!(one, other, comment, count, param1=..., param2=...)
/// - one: Message for the singular ("one") plural rule
/// - other: Message for the "other" plural rule
/// - comment: Context for translators
/// - count: The count value
/// - named arguments: Any additional named parameters for interpolation
#[macro_export]
macro_rules! tr_plural {
// With named parameters
($i18n:expr, $one:expr, $other:expr, $comment:expr, $count:expr, $($param:ident = $value:expr),*) => {{
let norm_key = $i18n.normalized_ftl_key($other, $comment);
let mut args = $crate::i18n::FluentArgs::new();
args.set("count", $count);
$(args.set(stringify!($param), $value);)*
match $i18n.get_cached_string(norm_key.borrow(), Some(&args)) {
Ok(s) => s,
Err(_) => {
// Fallback: use simple pluralization
if $count == 1 {
let mut result = $one.to_string();
$(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)*
result = result.replace("{count}", &$count.to_string());
result
} else {
let mut result = $other.to_string();
$(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)*
result = result.replace("{count}", &$count.to_string());
result
}
}
}
}};
// Without named parameters
($one:expr, $other:expr, $comment:expr, $count:expr) => {{
$crate::tr_plural!($one, $other, $comment, $count, )
}};
}

View File

@@ -1,397 +0,0 @@
use crate::urls::{UrlCache, UrlMimes};
use crate::Result;
use egui::TextureHandle;
use image::{Delay, Frame};
use poll_promise::Promise;
use egui::ColorImage;
use std::collections::HashMap;
use std::fs::{create_dir_all, File};
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant, SystemTime};
use hex::ToHex;
use sha2::Digest;
use std::path::PathBuf;
use std::path::{self, Path};
use tracing::warn;
#[derive(Default)]
pub struct TexturesCache {
cache: hashbrown::HashMap<String, TextureStateInternal>,
}
impl TexturesCache {
pub fn handle_and_get_or_insert_loadable(
&mut self,
url: &str,
closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>,
) -> LoadableTextureState {
let internal = self.handle_and_get_state_internal(url, true, closure);
internal.into()
}
pub fn handle_and_get_or_insert(
&mut self,
url: &str,
closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>,
) -> TextureState {
let internal = self.handle_and_get_state_internal(url, false, closure);
internal.into()
}
fn handle_and_get_state_internal(
&mut self,
url: &str,
use_loading: bool,
closure: impl FnOnce() -> Promise<Option<Result<TexturedImage>>>,
) -> &mut TextureStateInternal {
let state = match self.cache.raw_entry_mut().from_key(url) {
hashbrown::hash_map::RawEntryMut::Occupied(entry) => {
let state = entry.into_mut();
handle_occupied(state, use_loading);
state
}
hashbrown::hash_map::RawEntryMut::Vacant(entry) => {
let res = closure();
let (_, state) = entry.insert(url.to_owned(), TextureStateInternal::Pending(res));
state
}
};
state
}
pub fn insert_pending(&mut self, url: &str, promise: Promise<Option<Result<TexturedImage>>>) {
self.cache
.insert(url.to_owned(), TextureStateInternal::Pending(promise));
}
pub fn move_to_loaded(&mut self, url: &str) {
let hashbrown::hash_map::RawEntryMut::Occupied(entry) =
self.cache.raw_entry_mut().from_key(url)
else {
return;
};
entry.replace_entry_with(|_, v| {
let TextureStateInternal::Loading(textured) = v else {
return None;
};
Some(TextureStateInternal::Loaded(textured))
});
}
pub fn get_and_handle(&mut self, url: &str) -> Option<LoadableTextureState> {
self.cache.get_mut(url).map(|state| {
handle_occupied(state, true);
state.into()
})
}
}
fn handle_occupied(state: &mut TextureStateInternal, use_loading: bool) {
let TextureStateInternal::Pending(promise) = state else {
return;
};
let Some(res) = promise.ready_mut() else {
return;
};
let Some(res) = res.take() else {
tracing::error!("Failed to take the promise");
*state =
TextureStateInternal::Error(crate::Error::Generic("Promise already taken".to_owned()));
return;
};
match res {
Ok(textured) => {
*state = if use_loading {
TextureStateInternal::Loading(textured)
} else {
TextureStateInternal::Loaded(textured)
}
}
Err(e) => *state = TextureStateInternal::Error(e),
}
}
pub enum LoadableTextureState<'a> {
Pending,
Error(&'a crate::Error),
Loading {
actual_image_tex: &'a mut TexturedImage,
}, // the texture is in the loading state, for transitioning between the pending and loaded states
Loaded(&'a mut TexturedImage),
}
pub enum TextureState<'a> {
Pending,
Error(&'a crate::Error),
Loaded(&'a mut TexturedImage),
}
impl<'a> From<&'a mut TextureStateInternal> for TextureState<'a> {
fn from(value: &'a mut TextureStateInternal) -> Self {
match value {
TextureStateInternal::Pending(_) => TextureState::Pending,
TextureStateInternal::Error(error) => TextureState::Error(error),
TextureStateInternal::Loading(textured_image) => TextureState::Loaded(textured_image),
TextureStateInternal::Loaded(textured_image) => TextureState::Loaded(textured_image),
}
}
}
pub enum TextureStateInternal {
Pending(Promise<Option<Result<TexturedImage>>>),
Error(crate::Error),
Loading(TexturedImage), // the image is in the loading state, for transitioning between blur and image
Loaded(TexturedImage),
}
impl<'a> From<&'a mut TextureStateInternal> for LoadableTextureState<'a> {
fn from(value: &'a mut TextureStateInternal) -> Self {
match value {
TextureStateInternal::Pending(_) => LoadableTextureState::Pending,
TextureStateInternal::Error(error) => LoadableTextureState::Error(error),
TextureStateInternal::Loading(textured_image) => LoadableTextureState::Loading {
actual_image_tex: textured_image,
},
TextureStateInternal::Loaded(textured_image) => {
LoadableTextureState::Loaded(textured_image)
}
}
}
}
pub enum TexturedImage {
Static(TextureHandle),
Animated(Animation),
}
impl TexturedImage {
pub fn get_first_texture(&self) -> &TextureHandle {
match self {
TexturedImage::Static(texture_handle) => texture_handle,
TexturedImage::Animated(animation) => &animation.first_frame.texture,
}
}
}
pub struct Animation {
pub first_frame: TextureFrame,
pub other_frames: Vec<TextureFrame>,
pub receiver: Option<Receiver<TextureFrame>>,
}
impl Animation {
pub fn get_frame(&self, index: usize) -> Option<&TextureFrame> {
if index == 0 {
Some(&self.first_frame)
} else {
self.other_frames.get(index - 1)
}
}
pub fn num_frames(&self) -> usize {
self.other_frames.len() + 1
}
}
pub struct TextureFrame {
pub delay: Duration,
pub texture: TextureHandle,
}
pub struct ImageFrame {
pub delay: Duration,
pub image: ColorImage,
}
pub struct MediaCache {
pub cache_dir: path::PathBuf,
pub textures_cache: TexturesCache,
pub cache_type: MediaCacheType,
}
#[derive(Debug, Eq, PartialEq, Clone, Copy)]
pub enum MediaCacheType {
Image,
Gif,
}
impl MediaCache {
pub fn new(parent_dir: &Path, cache_type: MediaCacheType) -> Self {
let cache_dir = parent_dir.join(Self::rel_dir(cache_type));
Self {
cache_dir,
textures_cache: TexturesCache::default(),
cache_type,
}
}
pub fn rel_dir(cache_type: MediaCacheType) -> &'static str {
match cache_type {
MediaCacheType::Image => "img",
MediaCacheType::Gif => "gif",
}
}
pub fn write(cache_dir: &path::Path, url: &str, data: ColorImage) -> Result<()> {
let file = Self::create_file(cache_dir, url)?;
let encoder = image::codecs::webp::WebPEncoder::new_lossless(file);
encoder.encode(
data.as_raw(),
data.size[0] as u32,
data.size[1] as u32,
image::ColorType::Rgba8.into(),
)?;
Ok(())
}
fn create_file(cache_dir: &path::Path, url: &str) -> Result<File> {
let file_path = cache_dir.join(Self::key(url));
if let Some(p) = file_path.parent() {
create_dir_all(p)?;
}
Ok(File::options()
.write(true)
.create(true)
.truncate(true)
.open(file_path)?)
}
pub fn write_gif(cache_dir: &path::Path, url: &str, data: Vec<ImageFrame>) -> Result<()> {
let file = Self::create_file(cache_dir, url)?;
let mut encoder = image::codecs::gif::GifEncoder::new(file);
for img in data {
let buf = color_image_to_rgba(img.image);
let frame = Frame::from_parts(buf, 0, 0, Delay::from_saturating_duration(img.delay));
if let Err(e) = encoder.encode_frame(frame) {
tracing::error!("problem encoding frame: {e}");
}
}
Ok(())
}
pub fn key(url: &str) -> String {
let k: String = sha2::Sha256::digest(url.as_bytes()).encode_hex();
PathBuf::from(&k[0..2])
.join(&k[2..4])
.join(k)
.to_string_lossy()
.to_string()
}
/// Migrate from base32 encoded url to sha256 url + sub-dir structure
pub fn migrate_v0(&self) -> Result<()> {
for file in std::fs::read_dir(&self.cache_dir)? {
let file = if let Ok(f) = file {
f
} else {
// not sure how this could fail, skip entry
continue;
};
if !file.path().is_file() {
continue;
}
let old_filename = file.file_name().to_string_lossy().to_string();
let old_url = if let Some(u) =
base32::decode(base32::Alphabet::Crockford, &old_filename)
.and_then(|s| String::from_utf8(s).ok())
{
u
} else {
warn!("Invalid base32 filename: {}", &old_filename);
continue;
};
let new_path = self.cache_dir.join(Self::key(&old_url));
if let Some(p) = new_path.parent() {
create_dir_all(p)?;
}
if let Err(e) = std::fs::rename(file.path(), &new_path) {
warn!(
"Failed to migrate file from {} to {}: {:?}",
file.path().display(),
new_path.display(),
e
);
}
}
Ok(())
}
}
fn color_image_to_rgba(color_image: ColorImage) -> image::RgbaImage {
let width = color_image.width() as u32;
let height = color_image.height() as u32;
let rgba_pixels: Vec<u8> = color_image
.pixels
.iter()
.flat_map(|color| color.to_array()) // Convert Color32 to `[u8; 4]`
.collect();
image::RgbaImage::from_raw(width, height, rgba_pixels)
.expect("Failed to create RgbaImage from ColorImage")
}
pub struct Images {
pub static_imgs: MediaCache,
pub gifs: MediaCache,
pub urls: UrlMimes,
pub gif_states: GifStateMap,
}
impl Images {
/// path to directory to place [`MediaCache`]s
pub fn new(path: path::PathBuf) -> Self {
Self {
static_imgs: MediaCache::new(&path, MediaCacheType::Image),
gifs: MediaCache::new(&path, MediaCacheType::Gif),
urls: UrlMimes::new(UrlCache::new(path.join(UrlCache::rel_dir()))),
gif_states: Default::default(),
}
}
pub fn migrate_v0(&self) -> Result<()> {
self.static_imgs.migrate_v0()?;
self.gifs.migrate_v0()
}
pub fn get_cache(&self, cache_type: MediaCacheType) -> &MediaCache {
match cache_type {
MediaCacheType::Image => &self.static_imgs,
MediaCacheType::Gif => &self.gifs,
}
}
pub fn get_cache_mut(&mut self, cache_type: MediaCacheType) -> &mut MediaCache {
match cache_type {
MediaCacheType::Image => &mut self.static_imgs,
MediaCacheType::Gif => &mut self.gifs,
}
}
}
pub type GifStateMap = HashMap<String, GifState>;
pub struct GifState {
pub last_frame_rendered: Instant,
pub last_frame_duration: Duration,
pub next_frame_time: Option<SystemTime>,
pub last_frame_index: usize,
}

View File

@@ -1,99 +0,0 @@
use std::{
future::Future,
sync::{
mpsc::{self, Sender},
Arc, Mutex,
},
};
use tokio::sync::oneshot;
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct JobPool {
tx: Sender<Job>,
}
impl Default for JobPool {
fn default() -> Self {
JobPool::new(2)
}
}
impl JobPool {
pub fn new(num_threads: usize) -> Self {
let (tx, rx) = mpsc::channel::<Job>();
let arc_rx = Arc::new(Mutex::new(rx));
for _ in 0..num_threads {
let arc_rx_clone = arc_rx.clone();
std::thread::spawn(move || loop {
let job = {
let Ok(unlocked) = arc_rx_clone.lock() else {
continue;
};
let Ok(job) = unlocked.recv() else {
continue;
};
job
};
job();
});
}
Self { tx }
}
pub fn schedule<F, T>(&self, job: F) -> impl Future<Output = T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
let (tx_result, rx_result) = oneshot::channel::<T>();
let job = Box::new(move || {
let output = job();
let _ = tx_result.send(output);
});
self.tx
.send(job)
.expect("receiver should not be deallocated");
async move {
rx_result.await.unwrap_or_else(|_| {
panic!("Worker thread or channel dropped before returning the result.")
})
}
}
}
#[cfg(test)]
mod tests {
use crate::job_pool::JobPool;
fn test_fn(a: u32, b: u32) -> u32 {
a + b
}
#[tokio::test]
async fn test() {
let pool = JobPool::default();
// Now each job can return different T
let future_str = pool.schedule(|| -> String { "hello from string job".into() });
let a = 5;
let b = 6;
let future_int = pool.schedule(move || -> u32 { test_fn(a, b) });
println!("(Meanwhile we can do more async work) ...");
let s = future_str.await;
let i = future_int.await;
println!("Got string: {:?}", s);
println!("Got integer: {}", i);
}
}

View File

@@ -1,87 +0,0 @@
pub mod abbrev;
mod account;
mod app;
mod args;
pub mod contacts;
mod context;
pub mod debouncer;
mod error;
pub mod filter;
pub mod fonts;
mod frame_history;
pub mod i18n;
mod imgcache;
mod job_pool;
mod muted;
pub mod name;
pub mod note;
mod notecache;
mod persist;
pub mod platform;
pub mod profile;
pub mod relay_debug;
pub mod relayspec;
mod result;
pub mod storage;
mod style;
pub mod theme;
mod time;
mod timecache;
mod timed_serializer;
pub mod ui;
mod unknowns;
mod urls;
mod user_account;
mod wallet;
mod zaps;
pub use account::accounts::{AccountData, AccountSubs, Accounts};
pub use account::contacts::{ContactState, IsFollowing};
pub use account::relay::RelayAction;
pub use account::FALLBACK_PUBKEY;
pub use app::{App, AppAction, Notedeck};
pub use args::Args;
pub use context::AppContext;
pub use error::{show_one_error_message, Error, FilterError, ZapError};
pub use filter::{FilterState, FilterStates, UnifiedSubscription};
pub use fonts::NamedFontFamily;
pub use i18n::{CacheStats, FluentArgs, FluentValue, LanguageIdentifier, Localization};
pub use imgcache::{
Animation, GifState, GifStateMap, ImageFrame, Images, LoadableTextureState, MediaCache,
MediaCacheType, TextureFrame, TextureState, TexturedImage, TexturesCache,
};
pub use job_pool::JobPool;
pub use muted::{MuteFun, Muted};
pub use name::NostrName;
pub use note::{
BroadcastContext, ContextSelection, NoteAction, NoteContext, NoteContextSelection, NoteRef,
RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction,
};
pub use notecache::{CachedNote, NoteCache};
pub use persist::*;
pub use profile::get_profile_url;
pub use relay_debug::RelayDebugView;
pub use relayspec::RelaySpec;
pub use result::Result;
pub use storage::{AccountStorage, DataPath, DataPathType, Directory};
pub use style::NotedeckTextStyle;
pub use theme::ColorTheme;
pub use time::time_ago_since;
pub use timecache::TimeCached;
pub use unknowns::{get_unknown_note_ids, NoteRefsUnkIdAction, SingleUnkIdAction, UnknownIds};
pub use urls::{supported_mime_hosted_at_url, SupportedMimeType, UrlMimes};
pub use user_account::UserAccount;
pub use wallet::{
get_current_wallet, get_wallet_for, GlobalWallet, Wallet, WalletError, WalletType,
WalletUIState, ZapWallet,
};
pub use zaps::{
get_current_default_msats, AnyZapState, DefaultZapError, DefaultZapMsats, NoteZapTarget,
NoteZapTargetOwned, PendingDefaultZapState, ZapTarget, ZapTargetOwned, ZappingError,
};
// export libs
pub use enostr;
pub use nostrdb;
pub use zaps::Zaps;

View File

@@ -1,76 +0,0 @@
use nostrdb::ProfileRecord;
pub struct NostrName<'a> {
pub username: Option<&'a str>,
pub display_name: Option<&'a str>,
pub nip05: Option<&'a str>,
}
impl<'a> NostrName<'a> {
/// Our nostr name is usually our display_name, if we don't have
/// that then its just the username
pub fn name(&self) -> &'a str {
if let Some(name) = self.display_name {
name
} else if let Some(name) = self.username {
name
} else {
self.nip05.unwrap_or("??")
}
}
pub fn username_or_displayname(&self) -> &'a str {
if let Some(name) = self.username {
name
} else if let Some(name) = self.display_name {
name
} else {
self.nip05.unwrap_or("??")
}
}
pub fn unknown() -> Self {
Self {
username: None,
display_name: None,
nip05: None,
}
}
}
fn is_empty(s: &str) -> bool {
s.chars().all(|c| c.is_whitespace())
}
pub fn get_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> NostrName<'a> {
let Some(record) = record else {
return NostrName::unknown();
};
let Some(profile) = record.record().profile() else {
return NostrName::unknown();
};
let display_name = profile.display_name().filter(|n| !is_empty(n));
let username = profile.name().filter(|n| !is_empty(n));
let nip05 = if let Some(raw_nip05) = profile.nip05() {
if let Some(at_pos) = raw_nip05.find('@') {
if raw_nip05.starts_with('_') {
raw_nip05.get(at_pos + 1..)
} else {
Some(raw_nip05)
}
} else {
None
}
} else {
None
};
NostrName {
username,
display_name,
nip05,
}
}

View File

@@ -1,122 +0,0 @@
use super::context::ContextSelection;
use crate::{zaps::NoteZapTargetOwned, Images, MediaCacheType, TexturedImage};
use egui::Vec2;
use enostr::{NoteId, Pubkey};
use poll_promise::Promise;
#[derive(Debug)]
pub struct ScrollInfo {
pub velocity: Vec2,
pub offset: Vec2,
}
#[derive(Debug)]
pub enum NoteAction {
/// User has clicked the quote reply action
Reply(NoteId),
/// User has clicked the quote repost action
Quote(NoteId),
/// User has clicked a hashtag
Hashtag(String),
/// User has clicked a profile
Profile(Pubkey),
/// User has clicked a note link
Note { note_id: NoteId, preview: bool },
/// User has selected some context option
Context(ContextSelection),
/// User has clicked the zap action
Zap(ZapAction),
/// User clicked on media
Media(MediaAction),
/// User scrolled the timeline
Scroll(ScrollInfo),
}
impl NoteAction {
pub fn note(id: NoteId) -> NoteAction {
NoteAction::Note {
note_id: id,
preview: false,
}
}
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum ZapAction {
Send(ZapTargetAmount),
CustomizeAmount(NoteZapTargetOwned),
ClearError(NoteZapTargetOwned),
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct ZapTargetAmount {
pub target: NoteZapTargetOwned,
pub specified_msats: Option<u64>, // if None use default amount
}
pub enum MediaAction {
FetchImage {
url: String,
cache_type: MediaCacheType,
no_pfp_promise: Promise<Option<Result<TexturedImage, crate::Error>>>,
},
DoneLoading {
url: String,
cache_type: MediaCacheType,
},
}
impl std::fmt::Debug for MediaAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FetchImage {
url,
cache_type,
no_pfp_promise,
} => f
.debug_struct("FetchNoPfpImage")
.field("url", url)
.field("cache_type", cache_type)
.field("no_pfp_promise ready", &no_pfp_promise.ready().is_some())
.finish(),
Self::DoneLoading { url, cache_type } => f
.debug_struct("DoneLoading")
.field("url", url)
.field("cache_type", cache_type)
.finish(),
}
}
}
impl MediaAction {
pub fn process(self, images: &mut Images) {
match self {
MediaAction::FetchImage {
url,
cache_type,
no_pfp_promise: promise,
} => {
images
.get_cache_mut(cache_type)
.textures_cache
.insert_pending(&url, promise);
}
MediaAction::DoneLoading { url, cache_type } => {
let cache = match cache_type {
MediaCacheType::Image => &mut images.static_imgs,
MediaCacheType::Gif => &mut images.gifs,
};
cache.textures_cache.move_to_loaded(&url);
}
}
}
}

View File

@@ -1,63 +0,0 @@
use enostr::{ClientMessage, NoteId, Pubkey, RelayPool};
use nostrdb::{Note, NoteKey};
use tracing::error;
/// When broadcasting notes, this determines whether to broadcast
/// over the local network via multicast, or globally
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum BroadcastContext {
LocalNetwork,
Everywhere,
}
#[derive(Debug, Clone, Eq, PartialEq)]
#[allow(clippy::enum_variant_names)]
pub enum NoteContextSelection {
CopyText,
CopyPubkey,
CopyNoteId,
CopyNoteJSON,
Broadcast(BroadcastContext),
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct ContextSelection {
pub note_key: NoteKey,
pub action: NoteContextSelection,
}
impl NoteContextSelection {
pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>, pool: &mut RelayPool) {
match self {
NoteContextSelection::Broadcast(context) => {
tracing::info!("Broadcasting note {}", hex::encode(note.id()));
match context {
BroadcastContext::LocalNetwork => {
pool.send_to(&ClientMessage::event(note).unwrap(), "multicast");
}
BroadcastContext::Everywhere => {
pool.send(&ClientMessage::event(note).unwrap());
}
}
}
NoteContextSelection::CopyText => {
ui.ctx().copy_text(note.content().to_string());
}
NoteContextSelection::CopyPubkey => {
if let Some(bech) = Pubkey::new(*note.pubkey()).npub() {
ui.ctx().copy_text(bech);
}
}
NoteContextSelection::CopyNoteId => {
if let Some(bech) = NoteId::new(*note.id()).to_bech() {
ui.ctx().copy_text(bech);
}
}
NoteContextSelection::CopyNoteJSON => match note.json() {
Ok(json) => ui.ctx().copy_text(json),
Err(err) => error!("error copying note json: {err}"),
},
}
}
}

View File

@@ -1,213 +0,0 @@
mod action;
mod context;
pub use action::{MediaAction, NoteAction, ScrollInfo, ZapAction, ZapTargetAmount};
pub use context::{BroadcastContext, ContextSelection, NoteContextSelection};
use crate::Accounts;
use crate::JobPool;
use crate::Localization;
use crate::UnknownIds;
use crate::{notecache::NoteCache, zaps::Zaps, Images};
use enostr::{NoteId, RelayPool};
use nostrdb::{Ndb, Note, NoteKey, QueryResult, Transaction};
use std::borrow::Borrow;
use std::cmp::Ordering;
use std::fmt;
/// Aggregates dependencies to reduce the number of parameters
/// passed to inner UI elements, minimizing prop drilling.
pub struct NoteContext<'d> {
pub ndb: &'d Ndb,
pub accounts: &'d Accounts,
pub i18n: &'d mut Localization,
pub img_cache: &'d mut Images,
pub note_cache: &'d mut NoteCache,
pub zaps: &'d mut Zaps,
pub pool: &'d mut RelayPool,
pub job_pool: &'d mut JobPool,
pub unknown_ids: &'d mut UnknownIds,
pub clipboard: &'d mut egui_winit::clipboard::Clipboard,
pub current_account_has_wallet: bool,
}
#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash)]
pub struct NoteRef {
pub key: NoteKey,
pub created_at: u64,
}
#[derive(Clone, Copy, Eq, PartialEq, Hash)]
pub struct RootNoteIdBuf([u8; 32]);
impl fmt::Debug for RootNoteIdBuf {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "RootNoteIdBuf({})", self.hex())
}
}
#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)]
pub struct RootNoteId<'a>(&'a [u8; 32]);
impl RootNoteIdBuf {
pub fn to_note_id(self) -> NoteId {
NoteId::new(self.0)
}
pub fn bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn new(
ndb: &Ndb,
note_cache: &mut NoteCache,
txn: &Transaction,
id: &[u8; 32],
) -> Result<RootNoteIdBuf, RootIdError> {
root_note_id_from_selected_id(ndb, note_cache, txn, id).map(|rnid| Self(*rnid.bytes()))
}
pub fn hex(&self) -> String {
hex::encode(self.bytes())
}
pub fn new_unsafe(id: [u8; 32]) -> Self {
Self(id)
}
pub fn borrow(&self) -> RootNoteId<'_> {
RootNoteId(self.bytes())
}
}
impl<'a> RootNoteId<'a> {
pub fn to_note_id(self) -> NoteId {
NoteId::new(*self.0)
}
pub fn bytes(&self) -> &[u8; 32] {
self.0
}
pub fn hex(&self) -> String {
hex::encode(self.bytes())
}
pub fn to_owned(&self) -> RootNoteIdBuf {
RootNoteIdBuf::new_unsafe(*self.bytes())
}
pub fn new(
ndb: &Ndb,
note_cache: &mut NoteCache,
txn: &'a Transaction,
id: &'a [u8; 32],
) -> Result<RootNoteId<'a>, RootIdError> {
root_note_id_from_selected_id(ndb, note_cache, txn, id)
}
pub fn new_unsafe(id: &'a [u8; 32]) -> Self {
Self(id)
}
}
impl Borrow<[u8; 32]> for RootNoteIdBuf {
fn borrow(&self) -> &[u8; 32] {
&self.0
}
}
impl Borrow<[u8; 32]> for RootNoteId<'_> {
fn borrow(&self) -> &[u8; 32] {
self.0
}
}
impl NoteRef {
pub fn new(key: NoteKey, created_at: u64) -> Self {
NoteRef { key, created_at }
}
pub fn from_note(note: &Note<'_>) -> Self {
let created_at = note.created_at();
let key = note.key().expect("todo: implement NoteBuf");
NoteRef::new(key, created_at)
}
pub fn from_query_result(qr: QueryResult<'_>) -> Self {
NoteRef {
key: qr.note_key,
created_at: qr.note.created_at(),
}
}
}
impl Ord for NoteRef {
fn cmp(&self, other: &Self) -> Ordering {
match self.created_at.cmp(&other.created_at) {
Ordering::Equal => self.key.cmp(&other.key),
Ordering::Less => Ordering::Greater,
Ordering::Greater => Ordering::Less,
}
}
}
impl PartialOrd for NoteRef {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Copy, Clone)]
pub enum RootIdError {
NoteNotFound,
NoRootId,
}
pub fn root_note_id_from_selected_id<'txn, 'a>(
ndb: &Ndb,
note_cache: &mut NoteCache,
txn: &'txn Transaction,
selected_note_id: &'a [u8; 32],
) -> Result<RootNoteId<'txn>, RootIdError>
where
'a: 'txn,
{
let selected_note_key = if let Ok(key) = ndb.get_notekey_by_id(txn, selected_note_id) {
key
} else {
return Err(RootIdError::NoteNotFound);
};
let note = if let Ok(note) = ndb.get_note_by_key(txn, selected_note_key) {
note
} else {
return Err(RootIdError::NoteNotFound);
};
note_cache
.cached_note_or_insert(selected_note_key, &note)
.reply
.borrow(note.tags())
.root()
.map_or_else(
|| Ok(RootNoteId::new_unsafe(selected_note_id)),
|rnid| Ok(RootNoteId::new_unsafe(rnid.id)),
)
}
pub fn event_tag<'a>(ev: &nostrdb::Note<'a>, name: &str) -> Option<&'a str> {
ev.tags().iter().find_map(|tag| {
if tag.count() < 2 {
return None;
}
let cur_name = tag.get_str(0)?;
if cur_name != name {
return None;
}
tag.get_str(1)
})
}

View File

@@ -1,30 +0,0 @@
use std::time::Duration;
use egui::Context;
use crate::timed_serializer::TimedSerializer;
use crate::{DataPath, DataPathType};
pub struct AppSizeHandler {
serializer: TimedSerializer<egui::Vec2>,
}
impl AppSizeHandler {
pub fn new(path: &DataPath) -> Self {
let serializer =
TimedSerializer::new(path, DataPathType::Setting, "app_size.json".to_owned())
.with_delay(Duration::from_millis(500));
Self { serializer }
}
pub fn try_save_app_size(&mut self, ctx: &Context) {
// There doesn't seem to be a way to check if user is resizing window, so if the rect is different than last saved, we'll wait DELAY before saving again to avoid spamming io
let cur_size = ctx.input(|i| i.screen_rect.size());
self.serializer.try_save(cur_size);
}
pub fn get_app_size(&self) -> Option<egui::Vec2> {
self.serializer.get_item()
}
}

View File

@@ -1,9 +0,0 @@
mod app_size;
mod theme_handler;
mod token_handler;
mod zoom;
pub use app_size::AppSizeHandler;
pub use theme_handler::ThemeHandler;
pub use token_handler::TokenHandler;
pub use zoom::ZoomHandler;

View File

@@ -1,76 +0,0 @@
use egui::ThemePreference;
use tracing::{error, info};
use crate::{storage, DataPath, DataPathType, Directory};
pub struct ThemeHandler {
directory: Directory,
fallback_theme: ThemePreference,
}
const THEME_FILE: &str = "theme.txt";
impl ThemeHandler {
pub fn new(path: &DataPath) -> Self {
let directory = Directory::new(path.path(DataPathType::Setting));
let fallback_theme = ThemePreference::Dark;
Self {
directory,
fallback_theme,
}
}
pub fn load(&self) -> ThemePreference {
match self.directory.get_file(THEME_FILE.to_owned()) {
Ok(contents) => match deserialize_theme(contents) {
Some(theme) => theme,
None => {
error!(
"Could not deserialize theme. Using fallback {:?} instead",
self.fallback_theme
);
self.fallback_theme
}
},
Err(e) => {
error!(
"Could not read {} file: {:?}\nUsing fallback {:?} instead",
THEME_FILE, e, self.fallback_theme
);
self.fallback_theme
}
}
}
pub fn save(&self, theme: ThemePreference) {
match storage::write_file(
&self.directory.file_path,
THEME_FILE.to_owned(),
&theme_to_serialized(&theme),
) {
Ok(_) => info!(
"Successfully saved {:?} theme change to {}",
theme, THEME_FILE
),
Err(_) => error!("Could not save {:?} theme change to {}", theme, THEME_FILE),
}
}
}
fn theme_to_serialized(theme: &ThemePreference) -> String {
match theme {
ThemePreference::Dark => "dark",
ThemePreference::Light => "light",
ThemePreference::System => "system",
}
.to_owned()
}
fn deserialize_theme(serialized_theme: String) -> Option<ThemePreference> {
match serialized_theme.as_str() {
"dark" => Some(ThemePreference::Dark),
"light" => Some(ThemePreference::Light),
"system" => Some(ThemePreference::System),
_ => None,
}
}

View File

@@ -1,54 +0,0 @@
use tokenator::{ParseError, ParseErrorOwned, TokenParser, TokenSerializable, TokenWriter};
use crate::{storage, DataPath, DataPathType, Directory};
pub struct TokenHandler {
directory: Directory,
file_name: &'static str,
}
impl TokenHandler {
pub fn new(path: &DataPath, path_type: DataPathType, file_name: &'static str) -> Self {
let directory = Directory::new(path.path(path_type));
Self {
directory,
file_name,
}
}
pub fn save(
&self,
tokenator: &impl TokenSerializable,
delim: &'static str,
) -> crate::Result<()> {
let mut writer = TokenWriter::new(delim);
tokenator.serialize_tokens(&mut writer);
let to_write = writer.str();
storage::write_file(
&self.directory.file_path,
self.file_name.to_owned(),
to_write,
)
}
pub fn load<T: TokenSerializable>(
&self,
delim: &'static str,
) -> crate::Result<Result<T, ParseErrorOwned>> {
match self.directory.get_file(self.file_name.to_owned()) {
Ok(s) => {
let data = s.split(delim).collect::<Vec<&str>>();
let mut parser = TokenParser::new(&data);
Ok(TokenSerializable::parse_from_tokens(&mut parser).map_err(ParseError::into))
}
Err(e) => Err(e),
}
}
pub fn clear(&self) -> crate::Result<()> {
storage::write_file(&self.directory.file_path, self.file_name.to_owned(), "")
}
}

View File

@@ -1,26 +0,0 @@
use crate::{DataPath, DataPathType};
use egui::Context;
use crate::timed_serializer::TimedSerializer;
pub struct ZoomHandler {
serializer: TimedSerializer<f32>,
}
impl ZoomHandler {
pub fn new(path: &DataPath) -> Self {
let serializer =
TimedSerializer::new(path, DataPathType::Setting, "zoom_level.json".to_owned());
Self { serializer }
}
pub fn try_save_zoom_factor(&mut self, ctx: &Context) {
let cur_zoom_level = ctx.zoom_factor();
self.serializer.try_save(cur_zoom_level);
}
pub fn get_zoom_factor(&self) -> Option<f32> {
self.serializer.get_item()
}
}

View File

@@ -1,26 +0,0 @@
use std::sync::atomic::{AtomicI32, Ordering};
use tracing::debug;
// Thread-safe static global
static KEYBOARD_HEIGHT: AtomicI32 = AtomicI32::new(0);
/// This function is called by our main notedeck android activity when the
/// keyboard height changes. You can use [`virtual_keyboard_height`] to access
/// this
#[no_mangle]
pub extern "C" fn Java_com_damus_notedeck_KeyboardHeightHelper_nativeKeyboardHeightChanged(
_env: jni::JNIEnv,
_class: jni::objects::JClass,
height: jni::sys::jint,
) {
debug!("updating virtual keyboard height {}", height);
// Convert and store atomically
KEYBOARD_HEIGHT.store(height as i32, Ordering::SeqCst);
}
/// Gets the current Android virtual keyboard height. Useful for transforming
/// the view
pub fn virtual_keyboard_height() -> i32 {
KEYBOARD_HEIGHT.load(Ordering::SeqCst)
}

View File

@@ -1,12 +0,0 @@
#[cfg(target_os = "android")]
pub mod android;
#[cfg(target_os = "android")]
pub fn virtual_keyboard_height() -> i32 {
android::virtual_keyboard_height()
}
#[cfg(not(target_os = "android"))]
pub fn virtual_keyboard_height() -> i32 {
0
}

View File

@@ -1,18 +0,0 @@
use nostrdb::ProfileRecord;
pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str {
unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())))
}
pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str {
if let Some(url) = maybe_url {
url
} else {
no_pfp_url()
}
}
#[inline]
pub fn no_pfp_url() -> &'static str {
"https://damus.io/img/no-profile.svg"
}

View File

@@ -1,173 +0,0 @@
use egui::ScrollArea;
use enostr::{RelayLogEvent, SubsDebug};
pub struct RelayDebugView<'a> {
debug: &'a mut SubsDebug,
}
impl<'a> RelayDebugView<'a> {
pub fn new(debug: &'a mut SubsDebug) -> Self {
Self { debug }
}
}
impl RelayDebugView<'_> {
pub fn ui(&mut self, ui: &mut egui::Ui) {
ScrollArea::vertical()
.id_salt(ui.id().with("relays_debug"))
.max_height(ui.max_rect().height() / 2.0)
.show(ui, |ui| {
ui.label("Active Relays:");
for (relay_str, data) in self.debug.get_data() {
egui::CollapsingHeader::new(format!(
"{} {} {}",
relay_str,
format_total(&data.count),
format_sec(&data.count)
))
.default_open(true)
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, sub_data) in data.sub_data.values().enumerate() {
ui.label(format!(
"Filter {} ({})",
i + 1,
format_sec(&sub_data.count)
))
.on_hover_cursor(egui::CursorIcon::Help)
.on_hover_text(sub_data.filter.to_string());
}
})
});
}
});
ui.separator();
egui::ComboBox::from_label("Show events from relay")
.selected_text(
self.debug
.relay_events_selection
.as_ref()
.map_or(String::new(), |s| s.clone()),
)
.show_ui(ui, |ui| {
let mut make_selection = None;
for relay in self.debug.get_data().keys() {
if ui
.selectable_label(
if let Some(s) = &self.debug.relay_events_selection {
*s == *relay
} else {
false
},
relay,
)
.clicked()
{
make_selection = Some(relay.clone());
}
}
if make_selection.is_some() {
self.debug.relay_events_selection = make_selection
}
});
let show_relay_evs =
|ui: &mut egui::Ui, relay: Option<String>, events: Vec<RelayLogEvent>| {
for ev in events {
ui.horizontal_wrapped(|ui| {
if let Some(r) = &relay {
ui.label("relay").on_hover_text(r.clone());
}
match ev {
RelayLogEvent::Send(client_message) => {
ui.label("SEND: ");
let msg = &match client_message {
enostr::ClientMessage::Event { .. } => "Event",
enostr::ClientMessage::Req { .. } => "Req",
enostr::ClientMessage::Close { .. } => "Close",
enostr::ClientMessage::Raw(_) => "Raw",
};
if let Ok(json) = client_message.to_json() {
ui.label(*msg).on_hover_text(json)
} else {
ui.label(*msg)
}
}
RelayLogEvent::Recieve(e) => {
ui.label("RECIEVE: ");
match e {
enostr::OwnedRelayEvent::Opened => ui.label("Opened"),
enostr::OwnedRelayEvent::Closed => ui.label("Closed"),
enostr::OwnedRelayEvent::Other(s) => {
ui.label("Other").on_hover_text(s)
}
enostr::OwnedRelayEvent::Error(s) => {
ui.label("Error").on_hover_text(s)
}
enostr::OwnedRelayEvent::Message(s) => {
ui.label("Message").on_hover_text(s)
}
}
}
}
});
}
};
ScrollArea::vertical()
.id_salt(ui.id().with("events"))
.show(ui, |ui| {
if let Some(relay) = &self.debug.relay_events_selection {
if let Some(data) = self.debug.get_data().get(relay) {
show_relay_evs(ui, None, data.events.clone());
}
} else {
for (relay, data) in self.debug.get_data() {
show_relay_evs(ui, Some(relay.clone()), data.events.clone());
}
}
});
self.debug.try_increment_stats();
}
pub fn window(ctx: &egui::Context, debug: &mut SubsDebug) {
let mut open = true;
egui::Window::new("Relay Debugger")
.open(&mut open)
.show(ctx, |ui| {
RelayDebugView::new(debug).ui(ui);
});
}
}
fn format_sec(c: &enostr::TransferStats) -> String {
format!(
"{} ⬆️{}",
byte_to_string(c.down_sec_prior),
byte_to_string(c.up_sec_prior)
)
}
fn format_total(c: &enostr::TransferStats) -> String {
format!(
"total: ⬇{} ⬆️{}",
byte_to_string(c.down_total),
byte_to_string(c.up_total)
)
}
const MB: usize = 1_048_576;
const KB: usize = 1024;
fn byte_to_string(b: usize) -> String {
if b >= MB {
let mbs = b as f32 / MB as f32;
format!("{mbs:.2} MB")
} else if b >= KB {
let kbs = b as f32 / KB as f32;
format!("{kbs:.2} KB")
} else {
format!("{b} B")
}
}

View File

@@ -1,90 +0,0 @@
use std::cmp::Ordering;
use std::fmt;
// A Relay specification includes NIP-65 defined "markers" which
// indicate if the relay should be used for reading or writing (or
// both).
#[derive(Clone)]
pub struct RelaySpec {
pub url: String,
pub has_read_marker: bool,
pub has_write_marker: bool,
}
impl RelaySpec {
pub fn new(
url: impl Into<String>,
mut has_read_marker: bool,
mut has_write_marker: bool,
) -> Self {
// if both markers are set turn both off ...
if has_read_marker && has_write_marker {
has_read_marker = false;
has_write_marker = false;
}
RelaySpec {
url: url.into(),
has_read_marker,
has_write_marker,
}
}
// The "marker" fields are a little counter-intuitive ... from NIP-65:
//
// "The event MUST include a list of r tags with relay URIs and a read
// or write marker. Relays marked as read / write are called READ /
// WRITE relays, respectively. If the marker is omitted, the relay is
// used for both purposes."
//
pub fn is_readable(&self) -> bool {
!self.has_write_marker // only "write" relays are not readable
}
pub fn is_writable(&self) -> bool {
!self.has_read_marker // only "read" relays are not writable
}
}
// just the url part
impl fmt::Display for RelaySpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.url)
}
}
// add the read and write markers if present
impl fmt::Debug for RelaySpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "\"{self}\"")?;
if self.has_read_marker {
write!(f, " [r]")?;
}
if self.has_write_marker {
write!(f, " [w]")?;
}
Ok(())
}
}
// For purposes of set arithmetic only the url is considered, two
// RelaySpec which differ only in markers are the same ...
impl PartialEq for RelaySpec {
fn eq(&self, other: &Self) -> bool {
self.url == other.url
}
}
impl Eq for RelaySpec {}
impl PartialOrd for RelaySpec {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.url.cmp(&other.url))
}
}
impl Ord for RelaySpec {
fn cmp(&self, other: &Self) -> Ordering {
self.url.cmp(&other.url)
}
}

View File

@@ -1,199 +0,0 @@
use crate::{user_account::UserAccountSerializable, Result};
use enostr::{Keypair, Pubkey, SerializableKeypair};
use tokenator::{TokenParser, TokenSerializable, TokenWriter};
use super::file_storage::{delete_file, write_file, Directory};
static SELECTED_PUBKEY_FILE_NAME: &str = "selected_pubkey";
/// An OS agnostic file key storage implementation
#[derive(Debug, PartialEq, Clone)]
pub struct AccountStorage {
accounts_directory: Directory,
selected_key_directory: Directory,
}
impl AccountStorage {
pub fn new(accounts_directory: Directory, selected_key_directory: Directory) -> Self {
Self {
accounts_directory,
selected_key_directory,
}
}
pub fn rw(self) -> (AccountStorageReader, AccountStorageWriter) {
(
AccountStorageReader::new(self.clone()),
AccountStorageWriter::new(self),
)
}
}
pub struct AccountStorageWriter {
storage: AccountStorage,
}
impl AccountStorageWriter {
pub fn new(storage: AccountStorage) -> Self {
Self { storage }
}
pub fn write_account(&self, account: &UserAccountSerializable) -> Result<()> {
let mut writer = TokenWriter::new("\t");
account.serialize_tokens(&mut writer);
write_file(
&self.storage.accounts_directory.file_path,
account.key.pubkey.hex(),
writer.str(),
)
}
pub fn remove_key(&self, key: &Keypair) -> Result<()> {
delete_file(&self.storage.accounts_directory.file_path, key.pubkey.hex())
}
pub fn select_key(&self, pubkey: Option<Pubkey>) -> Result<()> {
if let Some(pubkey) = pubkey {
write_file(
&self.storage.selected_key_directory.file_path,
SELECTED_PUBKEY_FILE_NAME.to_owned(),
&serde_json::to_string(&pubkey.hex())?,
)
} else if self
.storage
.selected_key_directory
.get_file(SELECTED_PUBKEY_FILE_NAME.to_owned())
.is_ok()
{
// Case where user chose to have no selected pubkey, but one already exists
Ok(delete_file(
&self.storage.selected_key_directory.file_path,
SELECTED_PUBKEY_FILE_NAME.to_owned(),
)?)
} else {
Ok(())
}
}
}
pub struct AccountStorageReader {
storage: AccountStorage,
}
impl AccountStorageReader {
pub fn new(storage: AccountStorage) -> Self {
Self { storage }
}
pub fn get_accounts(&self) -> Result<Vec<UserAccountSerializable>> {
let keys = self
.storage
.accounts_directory
.get_files()?
.values()
.filter_map(|serialized| deserialize_storage(serialized).ok())
.collect();
Ok(keys)
}
pub fn get_selected_key(&self) -> Result<Option<Pubkey>> {
match self
.storage
.selected_key_directory
.get_file(SELECTED_PUBKEY_FILE_NAME.to_owned())
{
Ok(pubkey_str) => Ok(Some(serde_json::from_str(&pubkey_str)?)),
Err(crate::Error::Io(_)) => Ok(None),
Err(e) => Err(e),
}
}
}
fn deserialize_storage(serialized: &str) -> Result<UserAccountSerializable> {
let data = serialized.split("\t").collect::<Vec<&str>>();
let mut parser = TokenParser::new(&data);
if let Ok(acc) = UserAccountSerializable::parse_from_tokens(&mut parser) {
return Ok(acc);
}
// try old deserialization way
Ok(UserAccountSerializable::new(old_deserialization(
serialized,
)?))
}
fn old_deserialization(serialized: &str) -> Result<Keypair> {
Ok(serde_json::from_str::<SerializableKeypair>(serialized)?.to_keypair(""))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::Result;
use super::*;
static CREATE_TMP_DIR: fn() -> Result<PathBuf> =
|| Ok(tempfile::TempDir::new()?.path().to_path_buf());
impl AccountStorage {
fn mock() -> Result<Self> {
Ok(Self {
accounts_directory: Directory::new(CREATE_TMP_DIR()?),
selected_key_directory: Directory::new(CREATE_TMP_DIR()?),
})
}
}
#[test]
fn test_basic() {
let kp = enostr::FullKeypair::generate().to_keypair();
let (reader, writer) = AccountStorage::mock().unwrap().rw();
let resp = writer.write_account(&UserAccountSerializable::new(kp.clone()));
assert!(resp.is_ok());
assert_num_storage(&reader.get_accounts(), 1);
assert!(writer.remove_key(&kp).is_ok());
assert_num_storage(&reader.get_accounts(), 0);
}
fn assert_num_storage(keys_response: &Result<Vec<UserAccountSerializable>>, n: usize) {
match keys_response {
Ok(keys) => {
assert_eq!(keys.len(), n);
}
Err(_e) => {
panic!("could not get keys");
}
}
}
#[test]
fn test_select_key() {
let kp = enostr::FullKeypair::generate().to_keypair();
let (reader, writer) = AccountStorage::mock().unwrap().rw();
let _ = writer.write_account(&UserAccountSerializable::new(kp.clone()));
assert_num_storage(&reader.get_accounts(), 1);
let resp = writer.select_key(Some(kp.pubkey));
assert!(resp.is_ok());
let resp = reader.get_selected_key();
assert!(resp.is_ok());
}
#[test]
fn test_get_selected_key_when_no_file() {
let storage = AccountStorage::mock().unwrap().rw().0;
// Should return Ok(None) when no key has been selected
match storage.get_selected_key() {
Ok(None) => (), // This is what we expect
other => panic!("Expected Ok(None), got {:?}", other),
}
}
}

View File

@@ -1,5 +0,0 @@
mod account_storage;
mod file_storage;
pub use account_storage::{AccountStorage, AccountStorageReader, AccountStorageWriter};
pub use file_storage::{delete_file, write_file, DataPath, DataPathType, Directory};

View File

@@ -1,59 +0,0 @@
use egui::{Context, FontFamily, FontId, TextStyle};
use strum_macros::EnumIter;
use crate::fonts::get_font_size;
#[derive(Copy, Clone, Eq, PartialEq, Debug, EnumIter)]
pub enum NotedeckTextStyle {
Heading,
Heading2,
Heading3,
Heading4,
Body,
Monospace,
Button,
Small,
Tiny,
}
impl NotedeckTextStyle {
pub fn text_style(&self) -> TextStyle {
match self {
Self::Heading => TextStyle::Heading,
Self::Heading2 => TextStyle::Name("Heading2".into()),
Self::Heading3 => TextStyle::Name("Heading3".into()),
Self::Heading4 => TextStyle::Name("Heading4".into()),
Self::Body => TextStyle::Body,
Self::Monospace => TextStyle::Monospace,
Self::Button => TextStyle::Button,
Self::Small => TextStyle::Small,
Self::Tiny => TextStyle::Name("Tiny".into()),
}
}
pub fn font_family(&self) -> FontFamily {
match self {
Self::Heading => FontFamily::Proportional,
Self::Heading2 => FontFamily::Proportional,
Self::Heading3 => FontFamily::Proportional,
Self::Heading4 => FontFamily::Proportional,
Self::Body => FontFamily::Proportional,
Self::Monospace => FontFamily::Monospace,
Self::Button => FontFamily::Proportional,
Self::Small => FontFamily::Proportional,
Self::Tiny => FontFamily::Proportional,
}
}
pub fn get_font_id(&self, ctx: &Context) -> FontId {
FontId::new(get_font_size(ctx, self), self.font_family())
}
pub fn get_bolded_font(&self, ctx: &Context) -> FontId {
FontId::new(
get_font_size(ctx, self),
egui::FontFamily::Name(crate::NamedFontFamily::Bold.as_str().into()),
)
}
}

View File

@@ -1,88 +0,0 @@
use egui::{
style::{Selection, WidgetVisuals, Widgets},
Color32, CornerRadius, Stroke, Visuals,
};
pub struct ColorTheme {
// VISUALS
pub panel_fill: Color32,
pub extreme_bg_color: Color32,
pub text_color: Color32,
pub err_fg_color: Color32,
pub warn_fg_color: Color32,
pub hyperlink_color: Color32,
pub selection_color: Color32,
// WINDOW
pub window_fill: Color32,
pub window_stroke_color: Color32,
// NONINTERACTIVE WIDGET
pub noninteractive_bg_fill: Color32,
pub noninteractive_weak_bg_fill: Color32,
pub noninteractive_bg_stroke_color: Color32,
pub noninteractive_fg_stroke_color: Color32,
// INACTIVE WIDGET
pub inactive_bg_stroke_color: Color32,
pub inactive_bg_fill: Color32,
pub inactive_weak_bg_fill: Color32,
}
const WIDGET_CORNER_RADIUS: CornerRadius = CornerRadius::same(8);
pub fn create_themed_visuals(theme: ColorTheme, default: Visuals) -> Visuals {
Visuals {
hyperlink_color: theme.hyperlink_color,
override_text_color: Some(theme.text_color),
panel_fill: theme.panel_fill,
selection: Selection {
bg_fill: theme.selection_color,
stroke: Stroke {
width: 1.0,
color: theme.selection_color,
},
},
warn_fg_color: theme.warn_fg_color,
widgets: Widgets {
noninteractive: WidgetVisuals {
bg_fill: theme.noninteractive_bg_fill,
weak_bg_fill: theme.noninteractive_weak_bg_fill,
bg_stroke: Stroke {
width: 1.0,
color: theme.noninteractive_bg_stroke_color,
},
fg_stroke: Stroke {
width: 1.0,
color: theme.noninteractive_fg_stroke_color,
},
..default.widgets.noninteractive
},
inactive: WidgetVisuals {
bg_fill: theme.inactive_bg_fill,
weak_bg_fill: theme.inactive_weak_bg_fill,
bg_stroke: Stroke {
width: 1.0,
color: theme.inactive_bg_stroke_color,
},
corner_radius: WIDGET_CORNER_RADIUS,
..default.widgets.inactive
},
hovered: WidgetVisuals {
corner_radius: WIDGET_CORNER_RADIUS,
..default.widgets.hovered
},
active: WidgetVisuals {
corner_radius: WIDGET_CORNER_RADIUS,
..default.widgets.active
},
open: WidgetVisuals {
..default.widgets.open
},
},
extreme_bg_color: theme.extreme_bg_color,
error_fg_color: theme.err_fg_color,
image_loading_spinners: false,
..default
}
}

Some files were not shown because too many files have changed in this diff Show More