Compare commits
1 Commits
crowdin-ac
...
fluent-poc
| Author | SHA1 | Date | |
|---|---|---|---|
|
0e87c22e55
|
9
.envrc
@@ -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
|
||||
|
||||
3
.github/workflows/build-and-test.yml
vendored
@@ -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
|
||||
|
||||
33
.github/workflows/crowdin-download.yml
vendored
@@ -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 }}
|
||||
67
.github/workflows/crowdin-upload.yml
vendored
@@ -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 }}
|
||||
142
.github/workflows/rust.yml
vendored
@@ -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
@@ -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
|
||||
|
||||
68
CHANGELOG.md
@@ -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
221
Cargo.toml
@@ -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" }
|
||||
|
||||
28
Makefile
@@ -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
@@ -1,148 +1,96 @@
|
||||
# Notedeck
|
||||
# Damus Notedeck
|
||||
|
||||
[](https://github.com/damus-io/notedeck/actions/workflows/rust.yml)
|
||||
[](https://deepwiki.com/damus-io/notedeck)
|
||||
[](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
|
||||

|
||||
|
||||
- **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/
|
||||
|
||||
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 719 B |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 866 B |
@@ -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"
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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}'
|
||||
}
|
||||
1
assets/translations/en-US/notedeck.ftl
Normal file
@@ -0,0 +1 @@
|
||||
universe-title = Universe with Fluent Translation
|
||||
@@ -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}'{"]"}
|
||||
}
|
||||
@@ -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 }'
|
||||
}
|
||||
@@ -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 }条结果
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -1,3 +0,0 @@
|
||||
mod message;
|
||||
|
||||
pub use message::{ClientMessage, EventClientMessage};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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, ¬e);
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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, ¬e, *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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¬e).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);
|
||||
}
|
||||
}
|
||||
@@ -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) = ¬edeck.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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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, )
|
||||
}};
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ¬e)
|
||||
.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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(), "")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||