Project Path: notedeck Source Tree: ``` notedeck ├── Cargo.toml ├── spam │ └── db ├── crates │ ├── notedeck_chrome │ │ ├── Cargo.toml │ │ ├── android │ │ │ ├── app │ │ │ │ ├── build.gradle │ │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── damus │ │ │ │ └── notedeck │ │ │ │ └── MainActivity.java │ │ │ ├── Makefile │ │ │ ├── gradle │ │ │ │ └── wrapper │ │ │ │ └── gradle-wrapper.properties │ │ │ ├── gradlew │ │ │ ├── build.gradle │ │ │ ├── gradle.properties │ │ │ ├── gradlew.bat │ │ │ └── settings.gradle │ │ └── src │ │ ├── notedeck.rs │ │ ├── lib.rs │ │ ├── theme.rs │ │ ├── preview.rs │ │ ├── fonts.rs │ │ ├── android.rs │ │ └── setup.rs │ ├── notedeck_columns │ │ ├── Cargo.toml │ │ ├── build.rs │ │ └── src │ │ ├── ui │ │ │ ├── side_panel.rs │ │ │ ├── note │ │ │ │ ├── quote_repost.rs │ │ │ │ ├── contents.rs │ │ │ │ ├── post.rs │ │ │ │ ├── options.rs │ │ │ │ ├── reply_description.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── reply.rs │ │ │ │ └── context.rs │ │ │ ├── star.rs │ │ │ ├── configure_deck.rs │ │ │ ├── anim.rs │ │ │ ├── support.rs │ │ │ ├── thread.rs │ │ │ ├── timeline.rs │ │ │ ├── accounts.rs │ │ │ ├── add_column.rs │ │ │ ├── preview.rs │ │ │ ├── column │ │ │ │ ├── header.rs │ │ │ │ └── mod.rs │ │ │ ├── edit_deck.rs │ │ │ ├── mod.rs │ │ │ ├── profile │ │ │ │ ├── edit.rs │ │ │ │ ├── picture.rs │ │ │ │ ├── preview.rs │ │ │ │ ├── mod.rs │ │ │ │ └── menu.rs │ │ │ ├── mention.rs │ │ │ ├── images.rs │ │ │ ├── account_login_view.rs │ │ │ ├── username.rs │ │ │ ├── relay.rs │ │ │ └── search_results.rs │ │ ├── view_state.rs │ │ ├── abbrev.rs │ │ ├── subscriptions.rs │ │ ├── route.rs │ │ ├── profile.rs │ │ ├── frame_history.rs │ │ ├── gif.rs │ │ ├── multi_subscriber.rs │ │ ├── error.rs │ │ ├── support.rs │ │ ├── lib.rs │ │ ├── actionbar.rs │ │ ├── unknowns.rs │ │ ├── post.rs │ │ ├── test_utils.rs │ │ ├── app_style.rs │ │ ├── storage │ │ │ ├── mod.rs │ │ │ └── decks.rs │ │ ├── draft.rs │ │ ├── login_manager.rs │ │ ├── colors.rs │ │ ├── deck_state.rs │ │ ├── media_upload.rs │ │ ├── column.rs │ │ ├── accounts │ │ │ ├── route.rs │ │ │ └── mod.rs │ │ ├── key_parsing.rs │ │ ├── test_data.rs │ │ ├── profile_state.rs │ │ ├── relay_pool_manager.rs │ │ ├── decks.rs │ │ ├── images.rs │ │ ├── args.rs │ │ ├── nav.rs │ │ ├── app.rs │ │ ├── timeline │ │ │ ├── cache.rs │ │ │ ├── route.rs │ │ │ ├── kind.rs │ │ │ └── mod.rs │ │ └── app_creation.rs │ ├── notedeck │ │ ├── Cargo.toml │ │ └── src │ │ ├── ui.rs │ │ ├── user_account.rs │ │ ├── error.rs │ │ ├── accounts.rs │ │ ├── lib.rs │ │ ├── theme.rs │ │ ├── style.rs │ │ ├── unknowns.rs │ │ ├── time.rs │ │ ├── timecache.rs │ │ ├── storage │ │ │ ├── file_key_storage.rs │ │ │ ├── mod.rs │ │ │ ├── file_storage.rs │ │ │ ├── security_framework_key_storage.rs │ │ │ └── key_storage_impl.rs │ │ ├── muted.rs │ │ ├── relay_debug.rs │ │ ├── note.rs │ │ ├── timed_serializer.rs │ │ ├── notecache.rs │ │ ├── imgcache.rs │ │ ├── urls.rs │ │ ├── fonts.rs │ │ ├── filter.rs │ │ ├── args.rs │ │ ├── app.rs │ │ ├── result.rs │ │ ├── persist │ │ │ ├── app_size.rs │ │ │ ├── zoom.rs │ │ │ ├── theme_handler.rs │ │ │ └── mod.rs │ │ ├── relayspec.rs │ │ └── context.rs │ ├── enostr │ │ ├── Cargo.toml │ │ ├── Cargo.lock │ │ └── src │ │ ├── profile.rs │ │ ├── pubkey.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── note.rs │ │ ├── filter.rs │ │ ├── relay │ │ │ ├── message.rs │ │ │ ├── subs_debug.rs │ │ │ ├── mod.rs │ │ │ └── pool.rs │ │ ├── client │ │ │ ├── message.rs │ │ │ └── mod.rs │ │ └── keypair.rs │ └── tokenator │ ├── Cargo.toml │ ├── README.md │ └── src │ └── lib.rs ├── index.html ├── multicast │ └── db ├── LICENSE ├── CHANGELOG.md ├── Makefile ├── entitlements.plist ├── shell.nix ├── queries ├── Cargo.lock ├── prompt.md ├── damus.keystore ├── README.md ├── multicast2 │ └── db ├── enostr ├── scripts │ ├── svg_to_icns.sh │ ├── windows-installer.iss │ ├── dev_setup.sh │ ├── pre_commit_hook.sh │ └── macos_build.sh ├── preview ├── assets │ ├── damus_rounded.svg │ ├── Logo-Gradient-2x.png │ ├── favicon.ico │ ├── damus.ico │ ├── Welcome to Nostrdeck 2x.png │ ├── damus_rounded_80.png │ ├── icons │ │ ├── repost_light_4x.png │ │ ├── universe_icon_dark_4x.png │ │ ├── add_column_dark_4x.png │ │ ├── reply.svg │ │ ├── column_delete_icon_light_4x.png │ │ ├── move_column_4x.png │ │ ├── signout_icon_4x.png │ │ ├── sparkle.svg │ │ ├── settings_dark_4x.png │ │ ├── add_account_icon_4x.png │ │ ├── algo.svg │ │ ├── connected_icon_4x.png │ │ ├── algo.png │ │ ├── add_column_light_4x.png │ │ ├── settings_light_4x.png │ │ ├── repost_icon_4x.png │ │ ├── connecting_icon_4x.png │ │ ├── zap_4x.png │ │ ├── eye-slash-light.png │ │ ├── notifications_icon_dark_4x.png │ │ ├── reply.png │ │ ├── plus_icon_4x.png │ │ ├── eye-light.png │ │ ├── select_icon_3x.png │ │ ├── help_icon_dark_4x.png │ │ ├── profile_icon_4x.png │ │ ├── home_icon_dark_4x.png │ │ ├── help_icon_inverted_4x.png │ │ ├── hashtag_icon_4x.png │ │ ├── edit_icon_4x_dark.png │ │ ├── new_deck_icon_4x_dark.png │ │ ├── eye-dark.png │ │ ├── key_4x.png │ │ ├── delete_icon_4x.png │ │ ├── reply-dark.png │ │ ├── disconnected_icon_4x.png │ │ ├── eye-slash-dark.png │ │ ├── add_relay_icon_4x.png │ │ ├── verified_4x.png │ │ ├── links_4x.png │ │ ├── media_upload_dark_4x.png │ │ └── column_delete_icon_4x.png │ ├── damus-app-icon.svg │ ├── manifest.json │ ├── damus.svg │ ├── damus-app-icon.png │ ├── fonts │ │ ├── NotoSansThai-Regular.ttf │ │ ├── DejaVuSans.ttf │ │ ├── pressstart │ │ │ └── PressStart2P.ttf │ │ ├── NotoSansCJK-Regular.ttc │ │ ├── DejaVuSans-Bold-SansEmoji.ttf │ │ ├── onest │ │ │ ├── OnestMedium1602-hint.ttf │ │ │ ├── OnestLight1602-hint.ttf │ │ │ ├── OnestBlack1602-hint.ttf │ │ │ ├── OnestThin1602-hint.ttf │ │ │ ├── OnestBold1602-hint.ttf │ │ │ ├── OnestExtraBold1602-hint.ttf │ │ │ └── OnestRegular1602-hint.ttf │ │ ├── DejaVuSans-Bold.ttf │ │ ├── NotoEmoji-Regular.ttf │ │ ├── ark │ │ │ └── ark-pixel-10px-proportional-latin.ttf │ │ ├── DejaVuSansSansEmoji.ttf │ │ └── Inconsolata-Regular.ttf │ ├── sw.js │ └── app_icon.icns ├── check ├── tmp │ ├── settings │ │ ├── decks_cache.json │ │ ├── app_size.json │ │ └── zoom_level.json │ ├── storage │ │ ├── selected_account │ │ │ └── selected_pubkey │ │ └── accounts │ │ └── aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe │ └── db ├── Trunk.toml └── SECURITY.md ``` `/Users/jb55/dev/notedeck/Cargo.toml`: ```toml [workspace] resolver = "2" package.version = "0.3.1" members = [ "crates/notedeck", "crates/notedeck_chrome", "crates/notedeck_columns", "crates/enostr", "crates/tokenator", ] [workspace.dependencies] base32 = "0.4.0" base64 = "0.22.1" bech32 = { version = "0.11", default-features = false } bitflags = "2.5.0" dirs = "5.0.1" eframe = { version = "0.29.1", default-features = false, features = [ "wgpu", "wayland", "x11", "android-native-activity" ] } egui = { version = "0.29.1", features = ["serde"] } egui_extras = { version = "0.29.1", features = ["all_loaders"] } egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "ac7d663307b76634757024b438dd4b899790da99" } egui_tabs = "0.2.0" egui_virtual_list = "0.5.0" ehttp = "0.5.0" enostr = { path = "crates/enostr" } ewebsock = { version = "0.2.0", features = ["tls"] } hex = "0.4.3" image = { version = "0.25", features = ["jpeg", "png", "webp"] } indexmap = "2.6.0" log = "0.4.17" nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49"] } mio = { version = "1.0.3", features = ["os-poll", "net"] } nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "ad3b345416d17ec75362fbfe82309c8196f5ad4b" } #nostrdb = "0.5.2" notedeck = { path = "crates/notedeck" } notedeck_chrome = { path = "crates/notedeck_chrome" } notedeck_columns = { path = "crates/notedeck_columns" } tokenator = { path = "crates/tokenator" } open = "5.3.0" poll-promise = { version = "0.3.0", features = ["tokio"] } puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "70ff86d5503815219b01a009afd3669b7903a057" } puffin_egui = { git = "https://github.com/jb55/puffin", package = "puffin_egui", rev = "70ff86d5503815219b01a009afd3669b7903a057" } serde = { version = "1", features = ["derive"] } # You only need this if you want app persistence serde_derive = "1" serde_json = "1.0.89" strum = "0.26" strum_macros = "0.26" thiserror = "2.0.7" tokio = { version = "1.16", features = ["macros", "rt-multi-thread", "fs"] } tracing = "0.1.40" tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tempfile = "3.13.0" url = "2.5.2" urlencoding = "2.1.3" uuid = { version = "1.10.0", features = ["v4"] } security-framework = "2.11.0" sha2 = "0.10.8" bincode = "1.3.3" mime_guess = "2.0.5" pretty_assertions = "1.4.1" [patch.crates-io] egui = { git = "https://github.com/damus-io/egui", branch = "update_layouter_0.29.1" } epaint = { git = "https://github.com/damus-io/egui", branch = "update_layouter_0.29.1" } [profile.small] inherits = 'release' opt-level = 'z' # Optimize for size lto = true # Enable link-time optimization codegen-units = 1 # Reduce number of codegen units to increase optimizations panic = 'abort' # Abort on panic strip = true # Strip symbols from binary* ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/Cargo.toml`: ```toml [package] name = "notedeck_chrome" version = { workspace = true } authors = ["William Casarin ", "kernelkind "] edition = "2021" default-run = "notedeck" #rust-version = "1.60" license = "GPLv3" description = "The nostr browser" [dependencies] eframe = { workspace = true } egui_extras = { workspace = true } egui = { workspace = true } notedeck_columns = { workspace = true } notedeck = { workspace = true } puffin = { workspace = true, optional = true } puffin_egui = { workspace = true, optional = true } serde_json = { workspace = true } serde = { workspace = true } strum = { workspace = true } tokio = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } [lib] crate-type = ["lib", "cdylib"] [[bin]] name = "notedeck" path = "src/notedeck.rs" [[bin]] name = "ui_preview" path = "src/preview.rs" [features] default = [] profiling = ["notedeck_columns/puffin", "puffin", "puffin_egui"] debug-widget-callstack = ["egui/callstack"] debug-interactive-widgets = [] [target.'cfg(target_os = "android")'.dependencies] tracing-logcat = "0.1.0" log = { workspace = true } android-activity = { version = "0.4", features = [ "native-activity" ] } winit = { version = "0.30.5", features = [ "android-native-activity" ] } [package.metadata.bundle] name = "Notedeck" short_description = "The nostr browser" identifier = "com.damus.notedeck" icon = ["assets/app_icon.icns"] [package.metadata.android] package = "com.damus.app" apk_name = "Notedeck" #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 = "Notedeck" [package.metadata.deb] name = "notedeck" copyright = "2024 Damus, Nostr Inc." [package.metadata.generate-rpm] name = "notedeck" assets = [ { source = "target/release/notedeck", dest = "/usr/bin/notedeck", mode = "755" }, ] ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/app/build.gradle`: ```gradle plugins { id "com.android.application" } android { namespace "com.damus.notedeck" compileSdk 31 defaultConfig { minSdk 29 targetSdk 33 versionCode 1 versionName "1" } buildTypes { debug } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation "com.google.android.material:material:1.5.0" implementation "androidx.games:games-activity:2.0.2" } ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/app/src/main/AndroidManifest.xml`: ```xml [[package.metadata.android.uses_feature]] name = "android.hardware.vulkan.level" required = true version = 1 ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/app/src/main/java/com/damus/notedeck/MainActivity.java`: ```java package com.damus.notedeck; import android.os.Bundle; import android.util.Log; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import androidx.core.graphics.Insets; import androidx.core.view.DisplayCutoutCompat; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; import com.google.androidgamesdk.GameActivity; public class MainActivity extends GameActivity { static { System.loadLibrary("main"); } @Override protected void onCreate(Bundle savedInstanceState) { // Shrink view so it does not get covered by insets. View content = getWindow().getDecorView().findViewById(android.R.id.content); ViewCompat.setOnApplyWindowInsetsListener(content, (v, windowInsets) -> { Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) v.getLayoutParams(); mlp.topMargin = insets.top; mlp.leftMargin = insets.left; mlp.bottomMargin = insets.bottom; mlp.rightMargin = insets.right; v.setLayoutParams(mlp); return WindowInsetsCompat.CONSUMED; }); WindowCompat.setDecorFitsSystemWindows(getWindow(), true); super.onCreate(savedInstanceState); } @Override public boolean onTouchEvent(MotionEvent event) { // Offset the location so it fits the view with margins caused by insets. int[] location = new int[2]; findViewById(android.R.id.content).getLocationOnScreen(location); event.offsetLocation(-location[0], -location[1]); return super.onTouchEvent(event); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/Makefile`: ``` .DEFAULT_GOAL := apk .PHONY: jni apk run-on-device jni: cd rust && cargo ndk --target arm64-v8a -o ../java/app/src/main/jniLibs/ build --profile release apk: jni cd java && ./gradlew build run-on-device: jni cd java && ./gradlew installDebug adb shell am start -n local.walkers/.MainActivity adb logcat -v color -s walkers RustStdoutStderr ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/gradle/wrapper/gradle-wrapper.properties`: ```properties distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/gradlew`: ``` #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in # double quotes to make sure that they get re-expanded; and # * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/build.gradle`: ```gradle // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.4.1' apply false id 'com.android.library' version '8.4.1' apply false } task clean(type: Delete) { delete rootProject.buildDir } ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/gradle.properties`: ```properties # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/gradlew.bat`: ```bat @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/android/settings.gradle`: ```gradle pluginManagement { repositories { gradlePluginPortal() google() mavenCentral() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } include ':app' ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/src/notedeck.rs`: ```rs #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release use notedeck_chrome::setup::{generate_native_options, setup_chrome}; use notedeck::{DataPath, DataPathType, Notedeck}; use notedeck_columns::Damus; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::EnvFilter; // Entry point for wasm //#[cfg(target_arch = "wasm32")] //use wasm_bindgen::prelude::*; fn setup_logging(path: &DataPath) -> Option { #[allow(unused_variables)] // need guard to live for lifetime of program let (maybe_non_blocking, maybe_guard) = { let log_path = path.path(DataPathType::Log); // Setup logging to file use tracing_appender::{ non_blocking, rolling::{RollingFileAppender, Rotation}, }; let file_appender = RollingFileAppender::new( Rotation::DAILY, log_path, format!("notedeck-{}.log", env!("CARGO_PKG_VERSION")), ); let (non_blocking, _guard) = non_blocking(file_appender); (Some(non_blocking), Some(_guard)) }; // Log to stdout (if you run with `RUST_LOG=debug`). if let Some(non_blocking_writer) = maybe_non_blocking { use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt}; let console_layer = fmt::layer().with_target(true).with_writer(std::io::stdout); // Create the file layer (writes to the file) let file_layer = fmt::layer() .with_ansi(false) .with_writer(non_blocking_writer); let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("notedeck=info")); // Set up the subscriber to combine both layers tracing_subscriber::registry() .with(console_layer) .with(file_layer) .with(env_filter) .init(); } else { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); } maybe_guard } // Desktop #[cfg(not(target_arch = "wasm32"))] #[tokio::main] async fn main() { let base_path = DataPath::default_base_or_cwd(); let path = DataPath::new(base_path.clone()); // This guard must be scoped for the duration of the entire program so all logs will be written let _guard = setup_logging(&path); let _res = eframe::run_native( "Damus Notedeck", generate_native_options(path), Box::new(|cc| { let args: Vec = std::env::args().collect(); let ctx = &cc.egui_ctx; let mut notedeck = Notedeck::new(ctx, base_path, &args); setup_chrome(ctx, notedeck.args(), notedeck.theme()); let damus = Damus::new(&mut notedeck.app_context(), &args); // ensure we recognized all the arguments let completely_unrecognized: Vec = notedeck .unrecognized_args() .intersection(damus.unrecognized_args()) .cloned() .collect(); assert!( completely_unrecognized.is_empty(), "unrecognized args: {:?}", completely_unrecognized ); // TODO: move "chrome" frame over Damus app somehow notedeck.set_app(damus); Ok(Box::new(notedeck)) }), ); } /* * TODO: nostrdb not supported on web * #[cfg(target_arch = "wasm32")] pub fn main() { // Make sure panics are logged using `console.error`. console_error_panic_hook::set_once(); // Redirect tracing to console.log and friends: tracing_wasm::set_as_global_default(); wasm_bindgen_futures::spawn_local(async { let web_options = eframe::WebOptions::default(); eframe::start_web( "the_canvas_id", // hardcode it web_options, Box::new(|cc| Box::new(Damus::new(cc, "."))), ) .await .expect("failed to start eframe"); }); } */ #[cfg(test)] mod tests { use super::{Damus, Notedeck}; use std::path::{Path, PathBuf}; fn create_tmp_dir() -> PathBuf { tempfile::TempDir::new() .expect("tmp path") .path() .to_path_buf() } fn rmrf(path: impl AsRef) { let _ = std::fs::remove_dir_all(path); } /// Ensure dbpath actually sets the dbpath correctly. #[tokio::test] async fn test_dbpath() { let datapath = create_tmp_dir(); let dbpath = create_tmp_dir(); let args: Vec = [ "--testrunner", "--datapath", &datapath.to_str().unwrap(), "--dbpath", &dbpath.to_str().unwrap(), ] .iter() .map(|s| s.to_string()) .collect(); let ctx = egui::Context::default(); let _app = Notedeck::new(&ctx, &datapath, &args); assert!(Path::new(&dbpath.join("data.mdb")).exists()); assert!(Path::new(&dbpath.join("lock.mdb")).exists()); assert!(!Path::new(&datapath.join("db")).exists()); rmrf(datapath); rmrf(dbpath); } #[tokio::test] async fn test_column_args() { let tmpdir = create_tmp_dir(); let npub = "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s"; let args: Vec = [ "--testrunner", "--no-keystore", "--pub", npub, "-c", "notifications", "-c", "contacts", ] .iter() .map(|s| s.to_string()) .collect(); let ctx = egui::Context::default(); let mut notedeck = Notedeck::new(&ctx, &tmpdir, &args); let unrecognized_args = notedeck.unrecognized_args().clone(); let mut app_ctx = notedeck.app_context(); let app = Damus::new(&mut app_ctx, &args); // ensure we recognized all the arguments let completely_unrecognized: Vec = unrecognized_args .intersection(app.unrecognized_args()) .cloned() .collect(); assert!( completely_unrecognized.is_empty(), "unrecognized args: {:?}", completely_unrecognized ); assert_eq!(app.columns(app_ctx.accounts).columns().len(), 2); let tl1 = app .columns(app_ctx.accounts) .column(0) .router() .top() .timeline_id() .unwrap(); let tl2 = app .columns(app_ctx.accounts) .column(1) .router() .top() .timeline_id() .unwrap(); assert_eq!(app.timeline_cache.timelines.len(), 2); assert!(app.timeline_cache.timelines.get(&tl1).is_some()); assert!(app.timeline_cache.timelines.get(&tl2).is_some()); rmrf(tmpdir); } #[tokio::test] async fn test_unknown_args() { let tmpdir = create_tmp_dir(); let npub = "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s"; let args: Vec = [ "--testrunner", "--no-keystore", "--unknown-arg", // <-- UNKNOWN "--pub", npub, "-c", "notifications", "-c", "contacts", ] .iter() .map(|s| s.to_string()) .collect(); let ctx = egui::Context::default(); let mut notedeck = Notedeck::new(&ctx, &tmpdir, &args); let mut app_ctx = notedeck.app_context(); let app = Damus::new(&mut app_ctx, &args); // ensure we recognized all the arguments let completely_unrecognized: Vec = notedeck .unrecognized_args() .intersection(app.unrecognized_args()) .cloned() .collect(); assert_eq!(completely_unrecognized, ["--unknown-arg"]); rmrf(tmpdir); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/src/lib.rs`: ```rs pub mod fonts; pub mod setup; pub mod theme; #[cfg(target_os = "android")] mod android; ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/src/theme.rs`: ```rs use egui::{style::Interaction, Color32, FontId, Style, Visuals}; use notedeck::{ColorTheme, NotedeckTextStyle}; use strum::IntoEnumIterator; pub const PURPLE: Color32 = Color32::from_rgb(0xCC, 0x43, 0xC5); const PURPLE_ALT: Color32 = Color32::from_rgb(0x82, 0x56, 0xDD); //pub const DARK_BG: Color32 = egui::Color32::from_rgb(40, 44, 52); pub const GRAY_SECONDARY: Color32 = Color32::from_rgb(0x8A, 0x8A, 0x8A); const BLACK: Color32 = Color32::from_rgb(0x00, 0x00, 0x00); const RED_700: Color32 = Color32::from_rgb(0xC7, 0x37, 0x5A); const ORANGE_700: Color32 = Color32::from_rgb(0xF6, 0xB1, 0x4A); // BACKGROUNDS const SEMI_DARKER_BG: Color32 = Color32::from_rgb(0x39, 0x39, 0x39); const DARKER_BG: Color32 = Color32::from_rgb(0x1F, 0x1F, 0x1F); const DARK_BG: Color32 = Color32::from_rgb(0x2C, 0x2C, 0x2C); const DARK_ISH_BG: Color32 = Color32::from_rgb(0x25, 0x25, 0x25); const SEMI_DARK_BG: Color32 = Color32::from_rgb(0x44, 0x44, 0x44); const LIGHTER_GRAY: Color32 = Color32::from_rgb(0xf8, 0xf8, 0xf8); const LIGHT_GRAY: Color32 = Color32::from_rgb(0xc8, 0xc8, 0xc8); // 78% const DARKER_GRAY: Color32 = Color32::from_rgb(0xa5, 0xa5, 0xa5); // 65% const EVEN_DARKER_GRAY: Color32 = Color32::from_rgb(0x89, 0x89, 0x89); // 54% pub fn desktop_dark_color_theme() -> ColorTheme { ColorTheme { // VISUALS panel_fill: DARKER_BG, extreme_bg_color: DARK_ISH_BG, text_color: Color32::WHITE, err_fg_color: RED_700, warn_fg_color: ORANGE_700, hyperlink_color: PURPLE, selection_color: PURPLE_ALT, // WINDOW window_fill: DARK_ISH_BG, window_stroke_color: DARK_BG, // NONINTERACTIVE WIDGET noninteractive_bg_fill: DARK_ISH_BG, noninteractive_weak_bg_fill: DARK_BG, noninteractive_bg_stroke_color: SEMI_DARKER_BG, noninteractive_fg_stroke_color: GRAY_SECONDARY, // INACTIVE WIDGET inactive_bg_stroke_color: SEMI_DARKER_BG, inactive_bg_fill: Color32::from_rgb(0x25, 0x25, 0x25), inactive_weak_bg_fill: SEMI_DARK_BG, } } pub fn mobile_dark_color_theme() -> ColorTheme { ColorTheme { panel_fill: Color32::BLACK, noninteractive_weak_bg_fill: Color32::from_rgb(0x1F, 0x1F, 0x1F), ..desktop_dark_color_theme() } } pub fn light_color_theme() -> ColorTheme { ColorTheme { // VISUALS panel_fill: Color32::WHITE, extreme_bg_color: LIGHTER_GRAY, text_color: BLACK, err_fg_color: RED_700, warn_fg_color: ORANGE_700, hyperlink_color: PURPLE, selection_color: PURPLE_ALT, // WINDOW window_fill: Color32::WHITE, window_stroke_color: DARKER_GRAY, // NONINTERACTIVE WIDGET noninteractive_bg_fill: Color32::WHITE, noninteractive_weak_bg_fill: LIGHTER_GRAY, noninteractive_bg_stroke_color: LIGHT_GRAY, noninteractive_fg_stroke_color: GRAY_SECONDARY, // INACTIVE WIDGET inactive_bg_stroke_color: EVEN_DARKER_GRAY, inactive_bg_fill: LIGHTER_GRAY, inactive_weak_bg_fill: EVEN_DARKER_GRAY, } } pub fn light_mode() -> Visuals { notedeck::theme::create_themed_visuals(light_color_theme(), Visuals::light()) } pub fn dark_mode(is_oled: bool) -> Visuals { notedeck::theme::create_themed_visuals( if is_oled { mobile_dark_color_theme() } else { desktop_dark_color_theme() }, Visuals::dark(), ) } /// Create custom text sizes for any FontSizes pub fn add_custom_style(is_mobile: bool, style: &mut Style) { let font_size = if is_mobile { notedeck::fonts::mobile_font_size } else { notedeck::fonts::desktop_font_size }; style.text_styles = NotedeckTextStyle::iter() .map(|text_style| { ( text_style.text_style(), FontId::new(font_size(&text_style), text_style.font_family()), ) }) .collect(); style.interaction = Interaction { tooltip_delay: 0.1, show_tooltips_only_when_still: false, ..Interaction::default() }; // debug: show callstack for the current widget on hover if all // modifier keys are pressed down. #[cfg(feature = "debug-widget-callstack")] { #[cfg(not(debug_assertions))] compile_error!( "The `debug-widget-callstack` feature requires a debug build, \ release builds are unsupported." ); style.debug.debug_on_hover_with_all_modifiers = true; } // debug: show an overlay on all interactive widgets #[cfg(feature = "debug-interactive-widgets")] { #[cfg(not(debug_assertions))] compile_error!( "The `debug-interactive-widgets` feature requires a debug build, \ release builds are unsupported." ); style.debug.show_interactive_widgets = true; } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/src/preview.rs`: ```rs use notedeck::{DataPath, Notedeck}; use notedeck_chrome::setup::{generate_native_options, setup_chrome}; use notedeck_columns::ui::configure_deck::ConfigureDeckView; use notedeck_columns::ui::edit_deck::EditDeckView; use notedeck_columns::ui::profile::EditProfileView; use notedeck_columns::ui::{ account_login_view::AccountLoginView, PostView, Preview, PreviewApp, PreviewConfig, ProfilePic, ProfilePreview, RelayView, }; use std::env; struct PreviewRunner {} impl PreviewRunner { fn new() -> Self { PreviewRunner {} } async fn run

(self, preview: P) where P: notedeck::App + 'static, { tracing_subscriber::fmt::init(); let base_path = DataPath::default_base_or_cwd(); let path = DataPath::new(&base_path); let _res = eframe::run_native( "Notedeck Preview", generate_native_options(path), Box::new(|cc| { let args: Vec = std::env::args().collect(); let ctx = &cc.egui_ctx; let mut notedeck = Notedeck::new(ctx, &base_path, &args); assert!( notedeck.unrecognized_args().is_empty(), "unrecognized args: {:?}", notedeck.unrecognized_args() ); setup_chrome(ctx, notedeck.args(), notedeck.theme()); notedeck.set_app(PreviewApp::new(preview)); Ok(Box::new(notedeck)) }), ); } } macro_rules! previews { // Accept a runner and name variable, followed by one or more identifiers for the views ($runner:expr, $name:expr, $is_mobile:expr, $($view:ident),* $(,)?) => { match $name.as_ref() { $( stringify!($view) => { $runner.run($view::preview(PreviewConfig { is_mobile: $is_mobile })).await; } )* _ => println!("Component not found."), } }; } #[tokio::main] async fn main() { let mut name: Option = None; let mut is_mobile: Option = None; let mut light_mode: bool = false; for arg in env::args() { if arg == "--mobile" { is_mobile = Some(true); } else if arg == "--light" { light_mode = true; } else { name = Some(arg); } } let name = if let Some(name) = name { name } else { println!("Please specify a component to test"); return; }; println!( "light mode previews: {}", if light_mode { "enabled" } else { "disabled" } ); let is_mobile = is_mobile.unwrap_or(notedeck::ui::is_compiled_as_mobile()); let runner = PreviewRunner::new(); previews!( runner, name, is_mobile, RelayView, AccountLoginView, ProfilePreview, ProfilePic, PostView, ConfigureDeckView, EditDeckView, EditProfileView, ); } ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/src/fonts.rs`: ```rs use egui::{FontData, FontDefinitions, FontTweak}; use std::collections::BTreeMap; use tracing::debug; use notedeck::fonts::NamedFontFamily; // Use gossip's approach to font loading. This includes japanese fonts // for rending stuff from japanese users. pub fn setup_fonts(ctx: &egui::Context) { let mut font_data: BTreeMap = BTreeMap::new(); let mut families = BTreeMap::new(); font_data.insert( "Onest".to_owned(), FontData::from_static(include_bytes!( "../../../assets/fonts/onest/OnestRegular1602-hint.ttf" )), ); font_data.insert( "OnestMedium".to_owned(), FontData::from_static(include_bytes!( "../../../assets/fonts/onest/OnestMedium1602-hint.ttf" )), ); font_data.insert( "DejaVuSans".to_owned(), FontData::from_static(include_bytes!( "../../../assets/fonts/DejaVuSansSansEmoji.ttf" )), ); font_data.insert( "OnestBold".to_owned(), FontData::from_static(include_bytes!( "../../../assets/fonts/onest/OnestBold1602-hint.ttf" )), ); /* font_data.insert( "DejaVuSansBold".to_owned(), FontData::from_static(include_bytes!( "../assets/fonts/DejaVuSans-Bold-SansEmoji.ttf" )), ); font_data.insert( "DejaVuSans".to_owned(), FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansSansEmoji.ttf")), ); font_data.insert( "DejaVuSansBold".to_owned(), FontData::from_static(include_bytes!( "../assets/fonts/DejaVuSans-Bold-SansEmoji.ttf" )), ); */ font_data.insert( "Inconsolata".to_owned(), FontData::from_static(include_bytes!( "../../../assets/fonts/Inconsolata-Regular.ttf" )) .tweak(FontTweak { scale: 1.22, // This font is smaller than DejaVuSans y_offset_factor: -0.18, // and too low y_offset: 0.0, baseline_offset_factor: 0.0, }), ); font_data.insert( "NotoSansCJK".to_owned(), FontData::from_static(include_bytes!( "../../../assets/fonts/NotoSansCJK-Regular.ttc" )), ); font_data.insert( "NotoSansThai".to_owned(), FontData::from_static(include_bytes!( "../../../assets/fonts/NotoSansThai-Regular.ttf" )), ); // Some good looking emojis. Use as first priority: font_data.insert( "NotoEmoji".to_owned(), FontData::from_static(include_bytes!( "../../../assets/fonts/NotoEmoji-Regular.ttf" )) .tweak(FontTweak { scale: 1.1, // make them a touch larger y_offset_factor: 0.0, y_offset: 0.0, baseline_offset_factor: 0.0, }), ); let base_fonts = vec![ "DejaVuSans".to_owned(), "NotoEmoji".to_owned(), "NotoSansCJK".to_owned(), "NotoSansThai".to_owned(), ]; let mut proportional = vec!["Onest".to_owned()]; proportional.extend(base_fonts.clone()); let mut medium = vec!["OnestMedium".to_owned()]; medium.extend(base_fonts.clone()); let mut mono = vec!["Inconsolata".to_owned()]; mono.extend(base_fonts.clone()); let mut bold = vec!["OnestBold".to_owned()]; bold.extend(base_fonts.clone()); let emoji = vec!["NotoEmoji".to_owned()]; families.insert(egui::FontFamily::Proportional, proportional); families.insert(egui::FontFamily::Monospace, mono); families.insert( egui::FontFamily::Name(NamedFontFamily::Medium.as_str().into()), medium, ); families.insert( egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), bold, ); families.insert( egui::FontFamily::Name(NamedFontFamily::Emoji.as_str().into()), emoji, ); debug!("fonts: {:?}", families); let defs = FontDefinitions { font_data, families, }; ctx.set_fonts(defs); } ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/src/android.rs`: ```rs //#[cfg(target_os = "android")] //use egui_android::run_android; use notedeck_columns::Damus; use winit::platform::android::activity::AndroidApp; use winit::platform::android::EventLoopBuilderExtAndroid; use crate::setup::setup_chrome; use notedeck::Notedeck; use serde_json::Value; use std::fs; use std::path::PathBuf; #[no_mangle] #[tokio::main] pub async fn android_main(app: AndroidApp) { use tracing_logcat::{LogcatMakeWriter, LogcatTag}; use tracing_subscriber::{prelude::*, EnvFilter}; std::env::set_var("RUST_BACKTRACE", "full"); std::env::set_var( "RUST_LOG", "enostr=debug,notedeck_columns=debug,notedeck_chrome=debug", ); let writer = LogcatMakeWriter::new(LogcatTag::Target).expect("Failed to initialize logcat writer"); let fmt_layer = tracing_subscriber::fmt::layer() .with_level(false) .with_target(false) .without_time(); let filter_layer = EnvFilter::try_from_default_env() .or_else(|_| EnvFilter::try_new("info")) .unwrap(); tracing_subscriber::registry() .with(filter_layer) .with(fmt_layer) .init(); let path = app.internal_data_path().expect("data path"); let mut options = eframe::NativeOptions::default(); options.renderer = eframe::Renderer::Wgpu; // Clone `app` to use it both in the closure and later in the function let app_clone_for_event_loop = app.clone(); options.event_loop_builder = Some(Box::new(move |builder| { builder.with_android_app(app_clone_for_event_loop); })); let app_args = get_app_args(app); let _res = eframe::run_native( "Damus Notedeck", options, Box::new(move |cc| { let ctx = &cc.egui_ctx; let mut notedeck = Notedeck::new(ctx, path, &app_args); setup_chrome(ctx, ¬edeck.args(), notedeck.theme()); let damus = Damus::new(&mut notedeck.app_context(), &app_args); // ensure we recognized all the arguments let completely_unrecognized: Vec = notedeck .unrecognized_args() .intersection(damus.unrecognized_args()) .cloned() .collect(); assert!( completely_unrecognized.is_empty(), "unrecognized args: {:?}", completely_unrecognized ); notedeck.set_app(damus); Ok(Box::new(notedeck)) }), ); } /* Read args from a config file: - allows use of more interesting args w/o risk of checking them in by mistake - allows use of different args w/o rebuilding the app - uses compiled in defaults if config file missing or broken Example android-config.json: ``` { "args": [ "--npub", "npub1h50pnxqw9jg7dhr906fvy4mze2yzawf895jhnc3p7qmljdugm6gsrurqev", "-c", "contacts", "-c", "notifications" ] } ``` Install/update android-config.json with: ``` adb push android-config.json /sdcard/Android/data/com.damus.app/files/android-config.json ``` Using internal storage would be better but it seems hard to get the config file onto the device ... */ fn get_app_args(app: AndroidApp) -> Vec { let external_data_path: PathBuf = app .external_data_path() .expect("external data path") .to_path_buf(); let config_file = external_data_path.join("android-config.json"); let default_args = vec![ "--pub", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", "-c", "contacts", "-c", "notifications", "-c", "notifications:3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", ] .into_iter() .map(|s| s.to_string()) .collect(); if config_file.exists() { if let Ok(config_contents) = fs::read_to_string(config_file) { if let Ok(json) = serde_json::from_str::(&config_contents) { if let Some(args_array) = json.get("args").and_then(|v| v.as_array()) { let config_args = args_array .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); return config_args; } } } } default_args // Return the default args if config is missing or invalid } ``` `/Users/jb55/dev/notedeck/crates/notedeck_chrome/src/setup.rs`: ```rs use crate::{fonts, theme}; use eframe::NativeOptions; use egui::ThemePreference; use notedeck::{AppSizeHandler, DataPath}; use tracing::info; pub fn setup_chrome(ctx: &egui::Context, args: ¬edeck::Args, theme: ThemePreference) { let is_mobile = args .is_mobile .unwrap_or(notedeck::ui::is_compiled_as_mobile()); let is_oled = notedeck::ui::is_oled(); // Some people have been running notedeck in debug, let's catch that! if !args.tests && cfg!(debug_assertions) && !args.debug { println!("--- WELCOME TO DAMUS NOTEDECK! ---"); println!("It looks like are running notedeck in debug mode, unless you are a developer, this is not likely what you want."); println!("If you are a developer, run `cargo run -- --debug` to skip this message."); println!("For everyone else, try again with `cargo run --release`. Enjoy!"); println!("---------------------------------"); panic!(); } ctx.options_mut(|o| { info!("Loaded theme {:?} from disk", theme); o.theme_preference = theme; }); ctx.set_visuals_of(egui::Theme::Dark, theme::dark_mode(is_oled)); ctx.set_visuals_of(egui::Theme::Light, theme::light_mode()); setup_cc(ctx, is_mobile); } pub fn setup_cc(ctx: &egui::Context, is_mobile: bool) { fonts::setup_fonts(ctx); //ctx.set_pixels_per_point(ctx.pixels_per_point() + UI_SCALE_FACTOR); //ctx.set_pixels_per_point(1.0); // // //ctx.tessellation_options_mut(|to| to.feathering = false); egui_extras::install_image_loaders(ctx); ctx.all_styles_mut(|style| theme::add_custom_style(is_mobile, style)); } //pub const UI_SCALE_FACTOR: f32 = 0.2; pub fn generate_native_options(paths: DataPath) -> NativeOptions { let window_builder = Box::new(move |builder: egui::ViewportBuilder| { let builder = builder .with_fullsize_content_view(true) .with_titlebar_shown(false) .with_title_shown(false) .with_icon(std::sync::Arc::new( eframe::icon_data::from_png_bytes(app_icon()).expect("icon"), )); if let Some(window_size) = AppSizeHandler::new(&paths).get_app_size() { builder.with_inner_size(window_size) } else { builder } }); eframe::NativeOptions { window_builder: Some(window_builder), viewport: egui::ViewportBuilder::default().with_icon(std::sync::Arc::new( eframe::icon_data::from_png_bytes(app_icon()).expect("icon"), )), ..Default::default() } } fn generate_native_options_with_builder_modifiers( apply_builder_modifiers: fn(egui::ViewportBuilder) -> egui::ViewportBuilder, ) -> NativeOptions { let window_builder = Box::new(move |builder: egui::ViewportBuilder| apply_builder_modifiers(builder)); eframe::NativeOptions { window_builder: Some(window_builder), ..Default::default() } } pub fn app_icon() -> &'static [u8; 271986] { std::include_bytes!("../../../assets/damus-app-icon.png") } pub fn generate_mobile_emulator_native_options() -> eframe::NativeOptions { generate_native_options_with_builder_modifiers(|builder| { builder .with_fullsize_content_view(true) .with_titlebar_shown(false) .with_title_shown(false) .with_inner_size([405.0, 915.0]) .with_icon(eframe::icon_data::from_png_bytes(app_icon()).expect("icon")) }) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/Cargo.toml`: ```toml [package] name = "notedeck_columns" version = { workspace = true } authors = ["William Casarin "] edition = "2021" #rust-version = "1.60" license = "GPLv3" description = "A tweetdeck-style notedeck app" [lib] crate-type = ["lib", "cdylib"] [dependencies] notedeck = { workspace = true } tokenator = { workspace = true } bitflags = { workspace = true } dirs = { workspace = true } eframe = { workspace = true } thiserror = { workspace = true } egui = { workspace = true } egui_extras = { workspace = true } egui_nav = { workspace = true } egui_tabs = { workspace = true } egui_virtual_list = { workspace = true } ehttp = { workspace = true } enostr = { workspace = true } hex = { workspace = true } image = { workspace = true } indexmap = { workspace = true } nostrdb = { workspace = true } open = { workspace = true } poll-promise = { workspace = true } puffin = { workspace = true, optional = true } puffin_egui = { workspace = true, optional = true } serde = { workspace = true } serde_derive = { workspace = true } serde_json = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } sha2 = { workspace = true } base64 = { workspace = true } [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] rfd = "0.15" [dev-dependencies] tempfile = { workspace = true } pretty_assertions = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] security-framework = "2.11.0" [features] default = [] profiling = ["puffin", "puffin_egui", "eframe/puffin"] ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/build.rs`: ```rs use std::process::Command; fn fallback() { if let Some(dirname) = std::env::current_dir() .as_ref() .ok() .and_then(|cwd| cwd.file_name().and_then(|fname| fname.to_str())) { println!("cargo:rustc-env=GIT_COMMIT_HASH={}", dirname); } else { println!("cargo:rustc-env=GIT_COMMIT_HASH=unknown"); } } fn main() { let output = if let Ok(output) = Command::new("git").args(["rev-parse", "HEAD"]).output() { output } else { fallback(); return; }; if output.status.success() { let hash = String::from_utf8_lossy(&output.stdout); println!("cargo:rustc-env=GIT_COMMIT_HASH={}", hash.trim()); } else { fallback(); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/side_panel.rs`: ```rs use egui::{ vec2, Button, Color32, InnerResponse, Label, Layout, Margin, RichText, ScrollArea, Separator, Stroke, ThemePreference, Widget, }; use tracing::{error, info}; use crate::{ accounts::AccountsRoute, app::{get_active_columns_mut, get_decks_mut}, app_style::DECK_ICON_SIZE, colors, decks::{DecksAction, DecksCache}, nav::SwitchingAction, route::Route, support::Support, }; use notedeck::{Accounts, Images, NotedeckTextStyle, ThemeHandler, UserAccount}; use super::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, configure_deck::deck_icon, profile::preview::get_account_url, ProfilePic, View, }; pub static SIDE_PANEL_WIDTH: f32 = 68.0; static ICON_WIDTH: f32 = 40.0; pub struct DesktopSidePanel<'a> { ndb: &'a nostrdb::Ndb, img_cache: &'a mut Images, selected_account: Option<&'a UserAccount>, decks_cache: &'a DecksCache, } impl View for DesktopSidePanel<'_> { fn ui(&mut self, ui: &mut egui::Ui) { self.show(ui); } } #[derive(Debug, Eq, PartialEq, Clone, Copy)] pub enum SidePanelAction { Panel, Account, Settings, Columns, ComposeNote, Search, ExpandSidePanel, Support, NewDeck, SwitchDeck(usize), EditDeck(usize), SaveTheme(ThemePreference), } pub struct SidePanelResponse { pub response: egui::Response, pub action: SidePanelAction, } impl SidePanelResponse { fn new(action: SidePanelAction, response: egui::Response) -> Self { SidePanelResponse { action, response } } } impl<'a> DesktopSidePanel<'a> { pub fn new( ndb: &'a nostrdb::Ndb, img_cache: &'a mut Images, selected_account: Option<&'a UserAccount>, decks_cache: &'a DecksCache, ) -> Self { Self { ndb, img_cache, selected_account, decks_cache, } } pub fn show(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { let mut frame = egui::Frame::none().inner_margin(Margin::same(8.0)); if !ui.visuals().dark_mode { frame = frame.fill(colors::ALMOST_WHITE); } frame.show(ui, |ui| self.show_inner(ui)).inner } fn show_inner(&mut self, ui: &mut egui::Ui) -> SidePanelResponse { let dark_mode = ui.ctx().style().visuals.dark_mode; let inner = ui .vertical(|ui| { let top_resp = ui .with_layout(Layout::top_down(egui::Align::Center), |ui| { // macos needs a bit of space to make room for window // minimize/close buttons if cfg!(target_os = "macos") { ui.add_space(24.0); } let expand_resp = ui.add(expand_side_panel_button()); ui.add_space(4.0); ui.add(milestone_name()); ui.add_space(16.0); let is_interactive = self .selected_account .is_some_and(|s| s.secret_key.is_some()); let compose_resp = ui.add(compose_note_button(is_interactive, dark_mode)); let compose_resp = if is_interactive { compose_resp } else { compose_resp.on_hover_cursor(egui::CursorIcon::NotAllowed) }; // let search_resp = ui.add(search_button()); let column_resp = ui.add(add_column_button(dark_mode)); ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); ui.add_space(8.0); ui.add(egui::Label::new( RichText::new("DECKS") .size(11.0) .color(ui.visuals().noninteractive().fg_stroke.color), )); ui.add_space(8.0); let add_deck_resp = ui.add(add_deck_button()); let decks_inner = ScrollArea::vertical() .max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0))) .show(ui, |ui| { show_decks(ui, self.decks_cache, self.selected_account) }) .inner; if expand_resp.clicked() { Some(InnerResponse::new( SidePanelAction::ExpandSidePanel, expand_resp, )) } else if compose_resp.clicked() { Some(InnerResponse::new( SidePanelAction::ComposeNote, compose_resp, )) // } else if search_resp.clicked() { // Some(InnerResponse::new(SidePanelAction::Search, search_resp)) } else if column_resp.clicked() { Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) } else if add_deck_resp.clicked() { Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp)) } else if decks_inner.response.secondary_clicked() { info!("decks inner secondary click"); if let Some(clicked_index) = decks_inner.inner { Some(InnerResponse::new( SidePanelAction::EditDeck(clicked_index), decks_inner.response, )) } else { None } } else if decks_inner.response.clicked() { if let Some(clicked_index) = decks_inner.inner { Some(InnerResponse::new( SidePanelAction::SwitchDeck(clicked_index), decks_inner.response, )) } else { None } } else { None } }) .inner; ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); let (pfp_resp, bottom_resp) = ui .with_layout(Layout::bottom_up(egui::Align::Center), |ui| { let pfp_resp = self.pfp_button(ui); let settings_resp = ui.add(settings_button(dark_mode)); let save_theme = if let Some((theme, resp)) = match ui.ctx().theme() { egui::Theme::Dark => { let resp = ui .add(Button::new("☀").frame(false)) .on_hover_text("Switch to light mode"); if resp.clicked() { Some((ThemePreference::Light, resp)) } else { None } } egui::Theme::Light => { let resp = ui .add(Button::new("🌙").frame(false)) .on_hover_text("Switch to dark mode"); if resp.clicked() { Some((ThemePreference::Dark, resp)) } else { None } } } { ui.ctx().set_theme(theme); Some((theme, resp)) } else { None }; let support_resp = ui.add(support_button()); let optional_inner = if pfp_resp.clicked() { Some(egui::InnerResponse::new( SidePanelAction::Account, pfp_resp.clone(), )) } else if settings_resp.clicked() || settings_resp.hovered() { Some(egui::InnerResponse::new( SidePanelAction::Settings, settings_resp, )) } else if support_resp.clicked() { Some(egui::InnerResponse::new( SidePanelAction::Support, support_resp, )) } else if let Some((theme, resp)) = save_theme { Some(egui::InnerResponse::new( SidePanelAction::SaveTheme(theme), resp, )) } else { None }; (pfp_resp, optional_inner) }) .inner; if let Some(bottom_inner) = bottom_resp { bottom_inner } else if let Some(top_inner) = top_resp { top_inner } else { egui::InnerResponse::new(SidePanelAction::Panel, pfp_resp) } }) .inner; SidePanelResponse::new(inner.inner, inner.response) } fn pfp_button(&mut self, ui: &mut egui::Ui) -> egui::Response { let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget let helper = AnimationHelper::new(ui, "pfp-button", vec2(max_size, max_size)); let min_pfp_size = ICON_WIDTH; let cur_pfp_size = helper.scale_1d_pos(min_pfp_size); let txn = nostrdb::Transaction::new(self.ndb).expect("should be able to create txn"); let profile_url = get_account_url(&txn, self.ndb, self.selected_account); let widget = ProfilePic::new(self.img_cache, profile_url).size(cur_pfp_size); ui.put(helper.get_animation_rect(), widget); helper.take_animation_response() } pub fn perform_action( decks_cache: &mut DecksCache, accounts: &Accounts, support: &mut Support, theme_handler: &mut ThemeHandler, action: SidePanelAction, ) -> Option { let router = get_active_columns_mut(accounts, decks_cache).get_first_router(); let mut switching_response = None; match action { SidePanelAction::Panel => {} // TODO SidePanelAction::Account => { if router .routes() .iter() .any(|r| r == &Route::Accounts(AccountsRoute::Accounts)) { // return if we are already routing to accounts router.go_back(); } else { router.route_to(Route::accounts()); } } SidePanelAction::Settings => { if router.routes().iter().any(|r| r == &Route::Relays) { // return if we are already routing to accounts router.go_back(); } else { router.route_to(Route::relays()); } } SidePanelAction::Columns => { if router .routes() .iter() .any(|r| matches!(r, Route::AddColumn(_))) { router.go_back(); } else { get_active_columns_mut(accounts, decks_cache).new_column_picker(); } } SidePanelAction::ComposeNote => { if router.routes().iter().any(|r| r == &Route::ComposeNote) { router.go_back(); } else { router.route_to(Route::ComposeNote); } } SidePanelAction::Search => { // TODO info!("Clicked search button"); } SidePanelAction::ExpandSidePanel => { // TODO info!("Clicked expand side panel button"); } SidePanelAction::Support => { if router.routes().iter().any(|r| r == &Route::Support) { router.go_back(); } else { support.refresh(); router.route_to(Route::Support); } } SidePanelAction::NewDeck => { if router.routes().iter().any(|r| r == &Route::NewDeck) { router.go_back(); } else { router.route_to(Route::NewDeck); } } SidePanelAction::SwitchDeck(index) => { switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch( index, ))) } SidePanelAction::EditDeck(index) => { if router.routes().iter().any(|r| r == &Route::EditDeck(index)) { router.go_back(); } else { switching_response = Some(crate::nav::SwitchingAction::Decks( DecksAction::Switch(index), )); if let Some(edit_deck) = get_decks_mut(accounts, decks_cache) .decks_mut() .get_mut(index) { edit_deck .columns_mut() .get_first_router() .route_to(Route::EditDeck(index)); } else { error!("Cannot push EditDeck route to index {}", index); } } } SidePanelAction::SaveTheme(theme) => { theme_handler.save(theme); } } switching_response } } fn settings_button(dark_mode: bool) -> impl Widget { move |ui: &mut egui::Ui| { let img_size = 24.0; let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget let img_data = if dark_mode { egui::include_image!("../../../../assets/icons/settings_dark_4x.png") } else { egui::include_image!("../../../../assets/icons/settings_light_4x.png") }; let img = egui::Image::new(img_data).max_width(img_size); let helper = AnimationHelper::new(ui, "settings-button", vec2(max_size, max_size)); let cur_img_size = helper.scale_1d_pos(img_size); img.paint_at( ui, helper .get_animation_rect() .shrink((max_size - cur_img_size) / 2.0), ); helper.take_animation_response() } } fn add_column_button(dark_mode: bool) -> impl Widget { move |ui: &mut egui::Ui| { let img_size = 24.0; let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget let img_data = if dark_mode { egui::include_image!("../../../../assets/icons/add_column_dark_4x.png") } else { egui::include_image!("../../../../assets/icons/add_column_light_4x.png") }; let img = egui::Image::new(img_data).max_width(img_size); let helper = AnimationHelper::new(ui, "add-column-button", vec2(max_size, max_size)); let cur_img_size = helper.scale_1d_pos(img_size); img.paint_at( ui, helper .get_animation_rect() .shrink((max_size - cur_img_size) / 2.0), ); helper.take_animation_response() } } fn compose_note_button(interactive: bool, dark_mode: bool) -> impl Widget { move |ui: &mut egui::Ui| -> egui::Response { let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget let min_outer_circle_diameter = 40.0; let min_plus_sign_size = 14.0; // length of the plus sign let min_line_width = 2.25; // width of the plus sign let helper = if interactive { AnimationHelper::new(ui, "note-compose-button", vec2(max_size, max_size)) } else { AnimationHelper::no_animation(ui, vec2(max_size, max_size)) }; let painter = ui.painter_at(helper.get_animation_rect()); let use_background_radius = helper.scale_radius(min_outer_circle_diameter); let use_line_width = helper.scale_1d_pos(min_line_width); let use_edge_circle_radius = helper.scale_radius(min_line_width); let fill_color = if interactive { colors::PINK } else { ui.visuals().noninteractive().bg_fill }; painter.circle_filled(helper.center(), use_background_radius, fill_color); let min_half_plus_sign_size = min_plus_sign_size / 2.0; let north_edge = helper.scale_from_center(0.0, min_half_plus_sign_size); let south_edge = helper.scale_from_center(0.0, -min_half_plus_sign_size); let west_edge = helper.scale_from_center(-min_half_plus_sign_size, 0.0); let east_edge = helper.scale_from_center(min_half_plus_sign_size, 0.0); let icon_color = if !dark_mode && !interactive { Color32::BLACK } else { Color32::WHITE }; painter.line_segment( [north_edge, south_edge], Stroke::new(use_line_width, icon_color), ); painter.line_segment( [west_edge, east_edge], Stroke::new(use_line_width, icon_color), ); painter.circle_filled(north_edge, use_edge_circle_radius, Color32::WHITE); painter.circle_filled(south_edge, use_edge_circle_radius, Color32::WHITE); painter.circle_filled(west_edge, use_edge_circle_radius, Color32::WHITE); painter.circle_filled(east_edge, use_edge_circle_radius, Color32::WHITE); helper.take_animation_response() } } #[allow(unused)] fn search_button() -> impl Widget { |ui: &mut egui::Ui| -> egui::Response { let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget let min_line_width_circle = 1.5; // width of the magnifying glass let min_line_width_handle = 1.5; let helper = AnimationHelper::new(ui, "search-button", vec2(max_size, max_size)); let painter = ui.painter_at(helper.get_animation_rect()); let cur_line_width_circle = helper.scale_1d_pos(min_line_width_circle); let cur_line_width_handle = helper.scale_1d_pos(min_line_width_handle); let min_outer_circle_radius = helper.scale_radius(15.0); let cur_outer_circle_radius = helper.scale_1d_pos(min_outer_circle_radius); let min_handle_length = 7.0; let cur_handle_length = helper.scale_1d_pos(min_handle_length); let circle_center = helper.scale_from_center(-2.0, -2.0); let handle_vec = vec2( std::f32::consts::FRAC_1_SQRT_2, std::f32::consts::FRAC_1_SQRT_2, ); let handle_pos_1 = circle_center + (handle_vec * (cur_outer_circle_radius - 3.0)); let handle_pos_2 = circle_center + (handle_vec * (cur_outer_circle_radius + cur_handle_length)); let circle_stroke = Stroke::new(cur_line_width_circle, colors::MID_GRAY); let handle_stroke = Stroke::new(cur_line_width_handle, colors::MID_GRAY); painter.line_segment([handle_pos_1, handle_pos_2], handle_stroke); painter.circle( circle_center, min_outer_circle_radius, ui.style().visuals.widgets.inactive.weak_bg_fill, circle_stroke, ); helper.take_animation_response() } } // TODO: convert to responsive button when expanded side panel impl is finished fn expand_side_panel_button() -> impl Widget { |ui: &mut egui::Ui| -> egui::Response { let img_size = 40.0; let img_data = egui::include_image!("../../../../assets/damus_rounded_80.png"); let img = egui::Image::new(img_data).max_width(img_size); ui.add(img) } } fn support_button() -> impl Widget { |ui: &mut egui::Ui| -> egui::Response { let img_size = 16.0; let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget let img_data = if ui.visuals().dark_mode { egui::include_image!("../../../../assets/icons/help_icon_dark_4x.png") } else { egui::include_image!("../../../../assets/icons/help_icon_inverted_4x.png") }; let img = egui::Image::new(img_data).max_width(img_size); let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size)); let cur_img_size = helper.scale_1d_pos(img_size); img.paint_at( ui, helper .get_animation_rect() .shrink((max_size - cur_img_size) / 2.0), ); helper.take_animation_response() } } fn add_deck_button() -> impl Widget { |ui: &mut egui::Ui| -> egui::Response { let img_size = 40.0; let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget let img_data = egui::include_image!("../../../../assets/icons/new_deck_icon_4x_dark.png"); let img = egui::Image::new(img_data).max_width(img_size); let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size)); let cur_img_size = helper.scale_1d_pos(img_size); img.paint_at( ui, helper .get_animation_rect() .shrink((max_size - cur_img_size) / 2.0), ); helper.take_animation_response() } } fn show_decks<'a>( ui: &mut egui::Ui, decks_cache: &'a DecksCache, selected_account: Option<&'a UserAccount>, ) -> InnerResponse> { let show_decks_id = ui.id().with("show-decks"); let account_id = if let Some(acc) = selected_account { acc.pubkey } else { *decks_cache.get_fallback_pubkey() }; let (cur_decks, account_id) = ( decks_cache.decks(&account_id), show_decks_id.with(account_id), ); let active_index = cur_decks.active_index(); let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click()); let mut clicked_index = None; for (index, deck) in cur_decks.decks().iter().enumerate() { let highlight = index == active_index; let deck_icon_resp = ui .add(deck_icon( account_id.with(index), Some(deck.icon), DECK_ICON_SIZE, 40.0, highlight, )) .on_hover_text_at_pointer(&deck.name); if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() { clicked_index = Some(index); } resp = resp.union(deck_icon_resp); } InnerResponse::new(clicked_index, resp) } fn milestone_name() -> impl Widget { |ui: &mut egui::Ui| -> egui::Response { ui.vertical_centered(|ui| { let font = egui::FontId::new( notedeck::fonts::get_font_size( ui.ctx(), &NotedeckTextStyle::Tiny, ), egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()), ); ui.add(Label::new( RichText::new("ALPHA") .color( ui.style().visuals.noninteractive().fg_stroke.color) .font(font), ).selectable(false)).on_hover_text("Notedeck is an alpha product. Expect bugs and contact us when you run into issues.").on_hover_cursor(egui::CursorIcon::Help) }) .inner } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/note/quote_repost.rs`: ```rs use enostr::{FilledKeypair, NoteId}; use nostrdb::Ndb; use notedeck::{Images, NoteCache}; use crate::{ draft::Draft, ui::{self, note::NoteOptions}, }; use super::{PostResponse, PostType}; pub struct QuoteRepostView<'a> { ndb: &'a Ndb, poster: FilledKeypair<'a>, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, draft: &'a mut Draft, quoting_note: &'a nostrdb::Note<'a>, id_source: Option, inner_rect: egui::Rect, note_options: NoteOptions, } impl<'a> QuoteRepostView<'a> { #[allow(clippy::too_many_arguments)] pub fn new( ndb: &'a Ndb, poster: FilledKeypair<'a>, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, draft: &'a mut Draft, quoting_note: &'a nostrdb::Note<'a>, inner_rect: egui::Rect, note_options: NoteOptions, ) -> Self { let id_source: Option = None; QuoteRepostView { ndb, poster, note_cache, img_cache, draft, quoting_note, id_source, inner_rect, note_options, } } pub fn show(&mut self, ui: &mut egui::Ui) -> PostResponse { let id = self.id(); let quoting_note_id = self.quoting_note.id(); ui::PostView::new( self.ndb, self.draft, PostType::Quote(NoteId::new(quoting_note_id.to_owned())), self.img_cache, self.note_cache, self.poster, self.inner_rect, self.note_options, ) .id_source(id) .ui(self.quoting_note.txn().unwrap(), ui) } pub fn id_source(mut self, id: egui::Id) -> Self { self.id_source = Some(id); self } pub fn id(&self) -> egui::Id { self.id_source .unwrap_or_else(|| egui::Id::new("quote-repost-view")) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/note/contents.rs`: ```rs use crate::gif::{handle_repaint, retrieve_latest_texture}; use crate::ui::images::render_images; use crate::ui::{ self, note::{NoteOptions, NoteResponse}, }; use crate::{actionbar::NoteAction, images::ImageType, timeline::TimelineKind}; use egui::{Color32, Hyperlink, Image, RichText}; use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; use tracing::warn; use notedeck::{supported_mime_hosted_at_url, Images, MediaCacheType, NoteCache}; pub struct NoteContents<'a> { ndb: &'a Ndb, img_cache: &'a mut Images, note_cache: &'a mut NoteCache, txn: &'a Transaction, note: &'a Note<'a>, note_key: NoteKey, options: NoteOptions, action: Option, } impl<'a> NoteContents<'a> { #[allow(clippy::too_many_arguments)] pub fn new( ndb: &'a Ndb, img_cache: &'a mut Images, note_cache: &'a mut NoteCache, txn: &'a Transaction, note: &'a Note, note_key: NoteKey, options: ui::note::NoteOptions, ) -> Self { NoteContents { ndb, img_cache, note_cache, txn, note, note_key, options, action: None, } } pub fn action(&self) -> &Option { &self.action } } impl egui::Widget for &mut NoteContents<'_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { let result = render_note_contents( ui, self.ndb, self.img_cache, self.note_cache, self.txn, self.note, self.note_key, self.options, ); self.action = result.action; result.response } } /// Render an inline note preview with a border. These are used when /// notes are references within a note #[allow(clippy::too_many_arguments)] pub fn render_note_preview( ui: &mut egui::Ui, ndb: &Ndb, note_cache: &mut NoteCache, img_cache: &mut Images, txn: &Transaction, id: &[u8; 32], parent: NoteKey, note_options: NoteOptions, ) -> NoteResponse { #[cfg(feature = "profiling")] puffin::profile_function!(); let note = if let Ok(note) = ndb.get_note_by_id(txn, id) { // TODO: support other preview kinds if note.kind() == 1 { note } else { return NoteResponse::new(ui.colored_label( Color32::RED, format!("TODO: can't preview kind {}", note.kind()), )); } } else { return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD")); /* return ui .horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.colored_label(link_color, "@"); ui.colored_label(link_color, &id_str[4..16]); }) .response; */ }; egui::Frame::none() .fill(ui.visuals().noninteractive().weak_bg_fill) .inner_margin(egui::Margin::same(8.0)) .outer_margin(egui::Margin::symmetric(0.0, 8.0)) .rounding(egui::Rounding::same(10.0)) .stroke(egui::Stroke::new( 1.0, ui.visuals().noninteractive().bg_stroke.color, )) .show(ui, |ui| { ui::NoteView::new(ndb, note_cache, img_cache, ¬e, note_options) .actionbar(false) .small_pfp(true) .wide(true) .note_previews(false) .options_button(true) .parent(parent) .show(ui) }) .inner } #[allow(clippy::too_many_arguments)] fn render_note_contents( ui: &mut egui::Ui, ndb: &Ndb, img_cache: &mut Images, note_cache: &mut NoteCache, txn: &Transaction, note: &Note, note_key: NoteKey, options: NoteOptions, ) -> NoteResponse { #[cfg(feature = "profiling")] puffin::profile_function!(); let selectable = options.has_selectable_text(); let mut images: Vec<(String, MediaCacheType)> = vec![]; let mut note_action: Option = None; let mut inline_note: Option<(&[u8; 32], &str)> = None; let hide_media = options.has_hide_media(); let link_color = ui.visuals().hyperlink_color; let response = ui.horizontal_wrapped(|ui| { let blocks = if let Ok(blocks) = ndb.get_blocks_by_key(txn, note_key) { blocks } else { warn!("missing note content blocks? '{}'", note.content()); ui.weak(note.content()); return; }; ui.spacing_mut().item_spacing.x = 0.0; for block in blocks.iter(note) { match block.blocktype() { BlockType::MentionBech32 => match block.as_mention().unwrap() { Mention::Profile(profile) => { let act = ui::Mention::new(ndb, img_cache, txn, profile.pubkey()) .show(ui) .inner; if act.is_some() { note_action = act; } } Mention::Pubkey(npub) => { let act = ui::Mention::new(ndb, img_cache, txn, npub.pubkey()) .show(ui) .inner; if act.is_some() { note_action = act; } } Mention::Note(note) if options.has_note_previews() => { inline_note = Some((note.id(), block.as_str())); } Mention::Event(note) if options.has_note_previews() => { inline_note = Some((note.id(), block.as_str())); } _ => { ui.colored_label(link_color, format!("@{}", &block.as_str()[4..16])); } }, BlockType::Hashtag => { #[cfg(feature = "profiling")] puffin::profile_scope!("hashtag contents"); let resp = ui.colored_label(link_color, format!("#{}", block.as_str())); if resp.clicked() { note_action = Some(NoteAction::OpenTimeline(TimelineKind::Hashtag( block.as_str().to_string(), ))); } else if resp.hovered() { ui::show_pointer(ui); } } BlockType::Url => { let mut found_supported = || -> bool { let url = block.as_str(); if let Some(cache_type) = supported_mime_hosted_at_url(&mut img_cache.urls, url) { images.push((url.to_string(), cache_type)); true } else { false } }; if hide_media || !found_supported() { #[cfg(feature = "profiling")] puffin::profile_scope!("url contents"); ui.add(Hyperlink::from_label_and_url( RichText::new(block.as_str()).color(link_color), block.as_str(), )); } } BlockType::Text => { #[cfg(feature = "profiling")] puffin::profile_scope!("text contents"); if options.has_scramble_text() { ui.add(egui::Label::new(rot13(block.as_str())).selectable(selectable)); } else { ui.add(egui::Label::new(block.as_str()).selectable(selectable)); } } _ => { ui.colored_label(link_color, block.as_str()); } } } }); let preview_note_action = if let Some((id, _block_str)) = inline_note { render_note_preview(ui, ndb, note_cache, img_cache, txn, id, note_key, options).action } else { None }; if !images.is_empty() && !options.has_textmode() { ui.add_space(2.0); let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note"))); image_carousel(ui, img_cache, images, carousel_id); ui.add_space(2.0); } let note_action = preview_note_action.or(note_action); NoteResponse::new(response.response).with_action(note_action) } fn rot13(input: &str) -> String { input .chars() .map(|c| { if c.is_ascii_lowercase() { // Rotate lowercase letters (((c as u8 - b'a' + 13) % 26) + b'a') as char } else if c.is_ascii_uppercase() { // Rotate uppercase letters (((c as u8 - b'A' + 13) % 26) + b'A') as char } else { // Leave other characters unchanged c } }) .collect() } fn image_carousel( ui: &mut egui::Ui, img_cache: &mut Images, images: Vec<(String, MediaCacheType)>, carousel_id: egui::Id, ) { // let's make sure everything is within our area let height = 360.0; let width = ui.available_size().x; let spinsz = if height > width { width } else { height }; ui.add_sized([width, height], |ui: &mut egui::Ui| { egui::ScrollArea::horizontal() .id_salt(carousel_id) .show(ui, |ui| { ui.horizontal(|ui| { for (image, cache_type) in images { render_images( ui, img_cache, &image, ImageType::Content(width.round() as u32, height.round() as u32), cache_type, |ui| { ui.allocate_space(egui::vec2(spinsz, spinsz)); }, |ui, _| { ui.allocate_space(egui::vec2(spinsz, spinsz)); }, |ui, url, renderable_media, gifs| { let texture = handle_repaint( ui, retrieve_latest_texture(&image, gifs, renderable_media), ); let img_resp = ui.add( Image::new(texture) .max_height(height) .rounding(5.0) .fit_to_original_size(1.0), ); img_resp.context_menu(|ui| { if ui.button("Copy Link").clicked() { ui.ctx().copy_text(url.to_owned()); ui.close_menu(); } }); }, ); } }) .response }) .inner }); } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/note/post.rs`: ```rs use crate::draft::{Draft, Drafts, MentionHint}; use crate::gif::{handle_repaint, retrieve_latest_texture}; use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; use crate::post::{downcast_post_buffer, MentionType, NewPost}; use crate::profile::get_display_name; use crate::ui::images::render_images; use crate::ui::search_results::SearchResultsView; use crate::ui::{self, note::NoteOptions, Preview, PreviewConfig}; use crate::Result; use egui::text::{CCursorRange, LayoutJob}; use egui::text_edit::TextEditOutput; use egui::widgets::text_edit::TextEdit; use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense, TextBuffer}; use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; use notedeck::{supported_mime_hosted_at_url, Images, NoteCache}; use tracing::error; use super::contents::render_note_preview; pub struct PostView<'a> { ndb: &'a Ndb, draft: &'a mut Draft, post_type: PostType, img_cache: &'a mut Images, note_cache: &'a mut NoteCache, poster: FilledKeypair<'a>, id_source: Option, inner_rect: egui::Rect, note_options: NoteOptions, } #[derive(Clone)] pub enum PostType { New, Quote(NoteId), Reply(NoteId), } pub struct PostAction { post_type: PostType, post: NewPost, } impl PostAction { pub fn new(post_type: PostType, post: NewPost) -> Self { PostAction { post_type, post } } pub fn execute( &self, ndb: &Ndb, txn: &Transaction, pool: &mut RelayPool, drafts: &mut Drafts, ) -> Result<()> { let seckey = self.post.account.secret_key.to_secret_bytes(); let note = match self.post_type { PostType::New => self.post.to_note(&seckey), PostType::Reply(target) => { let replying_to = ndb.get_note_by_id(txn, target.bytes())?; self.post.to_reply(&seckey, &replying_to) } PostType::Quote(target) => { let quoting = ndb.get_note_by_id(txn, target.bytes())?; self.post.to_quote(&seckey, "ing) } }; pool.send(&enostr::ClientMessage::event(note)?); drafts.get_from_post_type(&self.post_type).clear(); Ok(()) } } pub struct PostResponse { pub action: Option, pub edit_response: egui::Response, } impl<'a> PostView<'a> { #[allow(clippy::too_many_arguments)] pub fn new( ndb: &'a Ndb, draft: &'a mut Draft, post_type: PostType, img_cache: &'a mut Images, note_cache: &'a mut NoteCache, poster: FilledKeypair<'a>, inner_rect: egui::Rect, note_options: NoteOptions, ) -> Self { let id_source: Option = None; PostView { ndb, draft, img_cache, note_cache, poster, id_source, post_type, inner_rect, note_options, } } pub fn id_source(mut self, id_source: impl std::hash::Hash) -> Self { self.id_source = Some(egui::Id::new(id_source)); self } fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> egui::Response { ui.spacing_mut().item_spacing.x = 12.0; let pfp_size = 24.0; // TODO: refactor pfp control to do all of this for us let poster_pfp = self .ndb .get_profile_by_pubkey(txn, self.poster.pubkey.bytes()) .as_ref() .ok() .and_then(|p| Some(ui::ProfilePic::from_profile(self.img_cache, p)?.size(pfp_size))); if let Some(pfp) = poster_pfp { ui.add(pfp); } else { ui.add( ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size), ); } let mut updated_layout = false; let mut layouter = |ui: &egui::Ui, buf: &dyn TextBuffer, wrap_width: f32| { if let Some(post_buffer) = downcast_post_buffer(buf) { let maybe_job = if post_buffer.need_new_layout(self.draft.cur_layout.as_ref()) { Some(post_buffer.to_layout_job(ui)) } else { None }; if let Some(job) = maybe_job { self.draft.cur_layout = Some((post_buffer.text_buffer.clone(), job)); updated_layout = true; } }; let mut layout_job = if let Some((_, job)) = &self.draft.cur_layout { job.clone() } else { error!("Failed to get custom mentions layouter"); text_edit_default_layout(ui, buf.as_str().to_owned(), wrap_width) }; layout_job.wrap.max_width = wrap_width; ui.fonts(|f| f.layout_job(layout_job)) }; let textedit = TextEdit::multiline(&mut self.draft.buffer) .hint_text(egui::RichText::new("Write a banger note here...").weak()) .frame(false) .desired_width(ui.available_width()) .layouter(&mut layouter); let out = textedit.show(ui); if updated_layout { self.draft.buffer.selected_mention = false; } if let Some(cursor_index) = get_cursor_index(&out.state.cursor.char_range()) { self.show_mention_hints(txn, ui, cursor_index, &out); } let focused = out.response.has_focus(); ui.ctx().data_mut(|d| d.insert_temp(self.id(), focused)); out.response } fn show_mention_hints( &mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui, cursor_index: usize, textedit_output: &TextEditOutput, ) { if let Some(mention) = &self.draft.buffer.get_mention(cursor_index) { if mention.info.mention_type == MentionType::Pending { let mention_str = self.draft.buffer.get_mention_string(mention); if !mention_str.is_empty() { if let Some(mention_hint) = &mut self.draft.cur_mention_hint { if mention_hint.index != mention.index { mention_hint.index = mention.index; mention_hint.pos = calculate_mention_hints_pos( textedit_output, mention.info.start_index, ); } mention_hint.text = mention_str.to_owned(); } else { self.draft.cur_mention_hint = Some(MentionHint { index: mention.index, text: mention_str.to_owned(), pos: calculate_mention_hints_pos( textedit_output, mention.info.start_index, ), }); } } if let Some(hint) = &self.draft.cur_mention_hint { let hint_rect = { let mut hint_rect = self.inner_rect; hint_rect.set_top(hint.pos.y); hint_rect }; if let Ok(res) = self.ndb.search_profile(txn, mention_str, 10) { let hint_selection = SearchResultsView::new(self.img_cache, self.ndb, txn, &res) .show_in_rect(hint_rect, ui); if let Some(hint_index) = hint_selection { if let Some(pk) = res.get(hint_index) { let record = self.ndb.get_profile_by_pubkey(txn, pk); self.draft.buffer.select_mention_and_replace_name( mention.index, get_display_name(record.ok().as_ref()).name(), Pubkey::new(**pk), ); self.draft.cur_mention_hint = None; } } } } } } } fn focused(&self, ui: &egui::Ui) -> bool { ui.ctx() .data(|d| d.get_temp::(self.id()).unwrap_or(false)) } fn id(&self) -> egui::Id { self.id_source.unwrap_or_else(|| egui::Id::new("post")) } pub fn outer_margin() -> f32 { 16.0 } pub fn inner_margin() -> f32 { 12.0 } pub fn ui(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> PostResponse { let focused = self.focused(ui); let stroke = if focused { ui.visuals().selection.stroke } else { ui.visuals().noninteractive().bg_stroke }; let mut frame = egui::Frame::default() .inner_margin(egui::Margin::same(PostView::inner_margin())) .outer_margin(egui::Margin::same(PostView::outer_margin())) .fill(ui.visuals().extreme_bg_color) .stroke(stroke) .rounding(12.0); if focused { frame = frame.shadow(egui::epaint::Shadow { offset: egui::vec2(0.0, 0.0), blur: 8.0, spread: 0.0, color: stroke.color, }); } frame .show(ui, |ui| { ui.vertical(|ui| { let edit_response = ui.horizontal(|ui| self.editbox(txn, ui)).inner; if let PostType::Quote(id) = self.post_type { let avail_size = ui.available_size_before_wrap(); ui.with_layout(Layout::left_to_right(egui::Align::TOP), |ui| { Frame::none().show(ui, |ui| { ui.vertical(|ui| { ui.set_max_width(avail_size.x * 0.8); render_note_preview( ui, self.ndb, self.note_cache, self.img_cache, txn, id.bytes(), nostrdb::NoteKey::new(0), self.note_options, ); }); }); }); } Frame::none() .inner_margin(Margin::symmetric(0.0, 8.0)) .show(ui, |ui| { ScrollArea::horizontal().show(ui, |ui| { ui.with_layout(Layout::left_to_right(egui::Align::Min), |ui| { ui.add_space(4.0); self.show_media(ui); }); }); }); self.transfer_uploads(ui); self.show_upload_errors(ui); let action = ui .horizontal(|ui| { ui.with_layout( egui::Layout::left_to_right(egui::Align::BOTTOM), |ui| { self.show_upload_media_button(ui); }, ); ui.with_layout(egui::Layout::right_to_left(egui::Align::BOTTOM), |ui| { let post_button_clicked = ui .add_sized( [91.0, 32.0], post_button(!self.draft.buffer.is_empty()), ) .clicked(); let ctrl_enter_pressed = ui .input(|i| i.modifiers.ctrl && i.key_pressed(egui::Key::Enter)); if post_button_clicked || (!self.draft.buffer.is_empty() && ctrl_enter_pressed) { let output = self.draft.buffer.output(); let new_post = NewPost::new( output.text, self.poster.to_full(), self.draft.uploaded_media.clone(), output.mentions, ); Some(PostAction::new(self.post_type.clone(), new_post)) } else { None } }) .inner }) .inner; PostResponse { action, edit_response, } }) .inner }) .inner } fn show_media(&mut self, ui: &mut egui::Ui) { let mut to_remove = Vec::new(); for (i, media) in self.draft.uploaded_media.iter().enumerate() { let (width, height) = if let Some(dims) = media.dimensions { (dims.0, dims.1) } else { (300, 300) }; if let Some(cache_type) = supported_mime_hosted_at_url(&mut self.img_cache.urls, &media.url) { render_images( ui, self.img_cache, &media.url, crate::images::ImageType::Content(width, height), cache_type, |ui| { ui.spinner(); }, |_, e| { self.draft.upload_errors.push(e.to_string()); error!("{e}"); }, |ui, url, renderable_media, gifs| { let media_size = vec2(width as f32, height as f32); let max_size = vec2(300.0, 300.0); let size = if media_size.x > max_size.x || media_size.y > max_size.y { max_size } else { media_size }; let texture_handle = handle_repaint( ui, retrieve_latest_texture(url, gifs, renderable_media), ); let img_resp = ui.add( egui::Image::new(texture_handle) .max_size(size) .rounding(12.0), ); let remove_button_rect = { let top_left = img_resp.rect.left_top(); let spacing = 13.0; let center = Pos2::new(top_left.x + spacing, top_left.y + spacing); egui::Rect::from_center_size(center, egui::vec2(26.0, 26.0)) }; if show_remove_upload_button(ui, remove_button_rect).clicked() { to_remove.push(i); } ui.advance_cursor_after_rect(img_resp.rect); }, ); } else { self.draft .upload_errors .push("Uploaded media is not supported.".to_owned()); error!("Unsupported mime type at url: {}", &media.url); } } to_remove.reverse(); for i in to_remove { self.draft.uploaded_media.remove(i); } } fn show_upload_media_button(&mut self, ui: &mut egui::Ui) { if ui.add(media_upload_button()).clicked() { #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] { if let Some(files) = rfd::FileDialog::new().pick_files() { for file in files { match MediaPath::new(file) { Ok(media_path) => { let promise = nostrbuild_nip96_upload( self.poster.secret_key.secret_bytes(), media_path, ); self.draft.uploading_media.push(promise); } Err(e) => { error!("{e}"); self.draft.upload_errors.push(e.to_string()); } } } } } } } fn transfer_uploads(&mut self, ui: &mut egui::Ui) { let mut indexes_to_remove = Vec::new(); for (i, promise) in self.draft.uploading_media.iter().enumerate() { match promise.ready() { Some(Ok(media)) => { self.draft.uploaded_media.push(media.clone()); indexes_to_remove.push(i); } Some(Err(e)) => { self.draft.upload_errors.push(e.to_string()); error!("{e}"); } None => { ui.spinner(); } } } indexes_to_remove.reverse(); for i in indexes_to_remove { let _ = self.draft.uploading_media.remove(i); } } fn show_upload_errors(&mut self, ui: &mut egui::Ui) { let mut to_remove = Vec::new(); for (i, error) in self.draft.upload_errors.iter().enumerate() { if ui .add( egui::Label::new(egui::RichText::new(error).color(ui.visuals().warn_fg_color)) .sense(Sense::click()) .selectable(false), ) .on_hover_text_at_pointer("Dismiss") .clicked() { to_remove.push(i); } } to_remove.reverse(); for i in to_remove { self.draft.upload_errors.remove(i); } } } fn post_button(interactive: bool) -> impl egui::Widget { move |ui: &mut egui::Ui| { let button = egui::Button::new("Post now"); if interactive { ui.add(button) } else { ui.add( button .sense(egui::Sense::hover()) .fill(ui.visuals().widgets.noninteractive.bg_fill) .stroke(ui.visuals().widgets.noninteractive.bg_stroke), ) .on_hover_cursor(egui::CursorIcon::NotAllowed) } } } fn media_upload_button() -> impl egui::Widget { |ui: &mut egui::Ui| -> egui::Response { let resp = ui.allocate_response(egui::vec2(32.0, 32.0), egui::Sense::click()); let painter = ui.painter(); let (fill_color, stroke) = if resp.hovered() { ( ui.visuals().widgets.hovered.bg_fill, ui.visuals().widgets.hovered.bg_stroke, ) } else if resp.clicked() { ( ui.visuals().widgets.active.bg_fill, ui.visuals().widgets.active.bg_stroke, ) } else { ( ui.visuals().widgets.inactive.bg_fill, ui.visuals().widgets.inactive.bg_stroke, ) }; painter.rect_filled(resp.rect, 8.0, fill_color); painter.rect_stroke(resp.rect, 8.0, stroke); egui::Image::new(egui::include_image!( "../../../../../assets/icons/media_upload_dark_4x.png" )) .max_size(egui::vec2(16.0, 16.0)) .paint_at(ui, resp.rect.shrink(8.0)); resp } } fn show_remove_upload_button(ui: &mut egui::Ui, desired_rect: egui::Rect) -> egui::Response { let resp = ui.allocate_rect(desired_rect, egui::Sense::click()); let size = 24.0; let (fill_color, stroke) = if resp.hovered() { ( ui.visuals().widgets.hovered.bg_fill, ui.visuals().widgets.hovered.bg_stroke, ) } else if resp.clicked() { ( ui.visuals().widgets.active.bg_fill, ui.visuals().widgets.active.bg_stroke, ) } else { ( ui.visuals().widgets.inactive.bg_fill, ui.visuals().widgets.inactive.bg_stroke, ) }; let center = desired_rect.center(); let painter = ui.painter_at(desired_rect); let radius = size / 2.0; painter.circle_filled(center, radius, fill_color); painter.circle_stroke(center, radius, stroke); painter.line_segment( [ Pos2::new(center.x - 4.0, center.y - 4.0), Pos2::new(center.x + 4.0, center.y + 4.0), ], egui::Stroke::new(1.33, ui.visuals().text_color()), ); painter.line_segment( [ Pos2::new(center.x + 4.0, center.y - 4.0), Pos2::new(center.x - 4.0, center.y + 4.0), ], egui::Stroke::new(1.33, ui.visuals().text_color()), ); resp } fn get_cursor_index(cursor: &Option) -> Option { let range = cursor.as_ref()?; if range.primary.index == range.secondary.index { Some(range.primary.index) } else { None } } fn calculate_mention_hints_pos(out: &TextEditOutput, char_pos: usize) -> egui::Pos2 { let mut cur_pos = 0; for row in &out.galley.rows { if cur_pos + row.glyphs.len() <= char_pos { cur_pos += row.glyphs.len(); } else if let Some(glyph) = row.glyphs.get(char_pos - cur_pos) { let mut pos = glyph.pos + out.galley_pos.to_vec2(); pos.y += row.rect.height(); return pos; } } out.text_clip_rect.left_bottom() } fn text_edit_default_layout(ui: &egui::Ui, text: String, wrap_width: f32) -> LayoutJob { LayoutJob::simple( text, egui::FontSelection::default().resolve(ui.style()), ui.visuals() .override_text_color .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()), wrap_width, ) } mod preview { use crate::media_upload::Nip94Event; use super::*; use notedeck::{App, AppContext}; pub struct PostPreview { draft: Draft, poster: FullKeypair, } impl PostPreview { fn new() -> Self { let mut draft = Draft::new(); // can use any url here draft.uploaded_media.push(Nip94Event::new( "https://image.nostr.build/41b40657dd6abf7c275dffc86b29bd863e9337a74870d4ee1c33a72a91c9d733.jpg".to_owned(), 612, 407, )); draft.uploaded_media.push(Nip94Event::new( "https://image.nostr.build/thumb/fdb46182b039d29af0f5eac084d4d30cd4ad2580ea04fe6c7e79acfe095f9852.png".to_owned(), 80, 80, )); draft.uploaded_media.push(Nip94Event::new( "https://i.nostr.build/7EznpHsnBZ36Akju.png".to_owned(), 2438, 1476, )); draft.uploaded_media.push(Nip94Event::new( "https://i.nostr.build/qCCw8szrjTydTiMV.png".to_owned(), 2002, 2272, )); PostPreview { draft, poster: FullKeypair::generate(), } } } impl App for PostPreview { fn update(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) { let txn = Transaction::new(app.ndb).expect("txn"); PostView::new( app.ndb, &mut self.draft, PostType::New, app.img_cache, app.note_cache, self.poster.to_filled(), ui.available_rect_before_wrap(), NoteOptions::default(), ) .ui(&txn, ui); } } impl Preview for PostView<'_> { type Prev = PostPreview; fn preview(_cfg: PreviewConfig) -> Self::Prev { PostPreview::new() } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/note/options.rs`: ```rs use crate::ui::ProfilePic; use bitflags::bitflags; bitflags! { // Attributes can be applied to flags types #[repr(transparent)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct NoteOptions: u64 { const actionbar = 0b0000000000000001; const note_previews = 0b0000000000000010; const small_pfp = 0b0000000000000100; const medium_pfp = 0b0000000000001000; const wide = 0b0000000000010000; const selectable_text = 0b0000000000100000; const textmode = 0b0000000001000000; const options_button = 0b0000000010000000; const hide_media = 0b0000000100000000; /// Scramble text so that its not distracting during development const scramble_text = 0b0000001000000000; } } impl Default for NoteOptions { fn default() -> NoteOptions { NoteOptions::options_button | NoteOptions::note_previews | NoteOptions::actionbar } } macro_rules! create_bit_methods { ($fn_name:ident, $has_name:ident, $option:ident) => { #[inline] pub fn $fn_name(&mut self, enable: bool) { if enable { *self |= NoteOptions::$option; } else { *self &= !NoteOptions::$option; } } #[inline] pub fn $has_name(self) -> bool { (self & NoteOptions::$option) == NoteOptions::$option } }; } impl NoteOptions { create_bit_methods!(set_small_pfp, has_small_pfp, small_pfp); create_bit_methods!(set_medium_pfp, has_medium_pfp, medium_pfp); create_bit_methods!(set_note_previews, has_note_previews, note_previews); create_bit_methods!(set_selectable_text, has_selectable_text, selectable_text); create_bit_methods!(set_textmode, has_textmode, textmode); create_bit_methods!(set_actionbar, has_actionbar, actionbar); create_bit_methods!(set_wide, has_wide, wide); create_bit_methods!(set_options_button, has_options_button, options_button); create_bit_methods!(set_hide_media, has_hide_media, hide_media); create_bit_methods!(set_scramble_text, has_scramble_text, scramble_text); pub fn new(is_universe_timeline: bool) -> Self { let mut options = NoteOptions::default(); options.set_hide_media(is_universe_timeline); options } pub fn pfp_size(&self) -> f32 { if self.has_small_pfp() { ProfilePic::small_size() } else if self.has_medium_pfp() { ProfilePic::medium_size() } else { ProfilePic::default_size() } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/note/reply_description.rs`: ```rs use crate::{ actionbar::NoteAction, ui::{self, note::NoteOptions}, }; use egui::{Label, RichText, Sense}; use nostrdb::{Ndb, Note, NoteReply, Transaction}; use notedeck::{Images, NoteCache}; #[must_use = "Please handle the resulting note action"] pub fn reply_desc( ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, ndb: &Ndb, img_cache: &mut Images, note_cache: &mut NoteCache, note_options: NoteOptions, ) -> Option { #[cfg(feature = "profiling")] puffin::profile_function!(); let mut note_action: Option = None; let size = 10.0; let selectable = false; let visuals = ui.visuals(); let color = visuals.noninteractive().fg_stroke.color; let link_color = visuals.hyperlink_color; // note link renderer helper let note_link = |ui: &mut egui::Ui, note_cache: &mut NoteCache, img_cache: &mut Images, text: &str, note: &Note<'_>| { let r = ui.add( Label::new(RichText::new(text).size(size).color(link_color)) .sense(Sense::click()) .selectable(selectable), ); if r.clicked() { // TODO: jump to note } if r.hovered() { r.on_hover_ui_at_pointer(|ui| { ui.set_max_width(400.0); ui::NoteView::new(ndb, note_cache, img_cache, note, note_options) .actionbar(false) .wide(true) .show(ui); }); } }; ui.add(Label::new(RichText::new("replying to").size(size).color(color)).selectable(selectable)); let reply = note_reply.reply()?; let reply_note = if let Ok(reply_note) = ndb.get_note_by_id(txn, reply.id) { reply_note } else { ui.add(Label::new(RichText::new("a note").size(size).color(color)).selectable(selectable)); return None; }; if note_reply.is_reply_to_root() { // We're replying to the root, let's show this let action = ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey()) .size(size) .selectable(selectable) .show(ui) .inner; if action.is_some() { note_action = action; } ui.add(Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable)); note_link(ui, note_cache, img_cache, "thread", &reply_note); } else if let Some(root) = note_reply.root() { // replying to another post in a thread, not the root if let Ok(root_note) = ndb.get_note_by_id(txn, root.id) { if root_note.pubkey() == reply_note.pubkey() { // simply "replying to bob's note" when replying to bob in his thread let action = ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey()) .size(size) .selectable(selectable) .show(ui) .inner; if action.is_some() { note_action = action; } ui.add( Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable), ); note_link(ui, note_cache, img_cache, "note", &reply_note); } else { // replying to bob in alice's thread let action = ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey()) .size(size) .selectable(selectable) .show(ui) .inner; if action.is_some() { note_action = action; } ui.add( Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable), ); note_link(ui, note_cache, img_cache, "note", &reply_note); ui.add( Label::new(RichText::new("in").size(size).color(color)).selectable(selectable), ); let action = ui::Mention::new(ndb, img_cache, txn, root_note.pubkey()) .size(size) .selectable(selectable) .show(ui) .inner; if action.is_some() { note_action = action; } ui.add( Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable), ); note_link(ui, note_cache, img_cache, "thread", &root_note); } } else { let action = ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey()) .size(size) .selectable(selectable) .show(ui) .inner; if action.is_some() { note_action = action; } ui.add( Label::new(RichText::new("in someone's thread").size(size).color(color)) .selectable(selectable), ); } } note_action } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/note/mod.rs`: ```rs pub mod contents; pub mod context; pub mod options; pub mod post; pub mod quote_repost; pub mod reply; pub mod reply_description; pub use contents::NoteContents; pub use context::{NoteContextButton, NoteContextSelection}; pub use options::NoteOptions; pub use post::{PostAction, PostResponse, PostType, PostView}; pub use quote_repost::QuoteRepostView; pub use reply::PostReplyView; pub use reply_description::reply_desc; use crate::{ actionbar::NoteAction, profile::get_display_name, timeline::{ThreadSelection, TimelineKind}, ui::{self, View}, }; use egui::emath::{pos2, Vec2}; use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense}; use enostr::{NoteId, Pubkey}; use nostrdb::{Ndb, Note, NoteKey, Transaction}; use notedeck::{CachedNote, Images, NoteCache, NotedeckTextStyle}; use super::profile::preview::one_line_display_name_widget; pub struct NoteView<'a> { ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, parent: Option, note: &'a nostrdb::Note<'a>, flags: NoteOptions, } pub struct NoteResponse { pub response: egui::Response, pub context_selection: Option, pub action: Option, } impl NoteResponse { pub fn new(response: egui::Response) -> Self { Self { response, context_selection: None, action: None, } } pub fn with_action(mut self, action: Option) -> Self { self.action = action; self } pub fn select_option(mut self, context_selection: Option) -> Self { self.context_selection = context_selection; self } } impl View for NoteView<'_> { fn ui(&mut self, ui: &mut egui::Ui) { self.show(ui); } } impl<'a> NoteView<'a> { pub fn new( ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, note: &'a nostrdb::Note<'a>, mut flags: NoteOptions, ) -> Self { flags.set_actionbar(true); flags.set_note_previews(true); let parent: Option = None; Self { ndb, note_cache, img_cache, parent, note, flags, } } pub fn textmode(mut self, enable: bool) -> Self { self.options_mut().set_textmode(enable); self } pub fn actionbar(mut self, enable: bool) -> Self { self.options_mut().set_actionbar(enable); self } pub fn small_pfp(mut self, enable: bool) -> Self { self.options_mut().set_small_pfp(enable); self } pub fn medium_pfp(mut self, enable: bool) -> Self { self.options_mut().set_medium_pfp(enable); self } pub fn note_previews(mut self, enable: bool) -> Self { self.options_mut().set_note_previews(enable); self } pub fn selectable_text(mut self, enable: bool) -> Self { self.options_mut().set_selectable_text(enable); self } pub fn wide(mut self, enable: bool) -> Self { self.options_mut().set_wide(enable); self } pub fn options_button(mut self, enable: bool) -> Self { self.options_mut().set_options_button(enable); self } pub fn options(&self) -> NoteOptions { self.flags } pub fn options_mut(&mut self) -> &mut NoteOptions { &mut self.flags } pub fn parent(mut self, parent: NoteKey) -> Self { self.parent = Some(parent); self } fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { let note_key = self.note.key().expect("todo: implement non-db notes"); let txn = self.note.txn().expect("todo: implement non-db notes"); ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); //ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let cached_note = self .note_cache .cached_note_or_insert_mut(note_key, self.note); let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0)); ui.allocate_rect(rect, Sense::hover()); ui.put(rect, |ui: &mut egui::Ui| { render_reltime(ui, cached_note, false).response }); let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0)); ui.allocate_rect(rect, Sense::hover()); ui.put(rect, |ui: &mut egui::Ui| { ui.add( ui::Username::new(profile.as_ref().ok(), self.note.pubkey()) .abbreviated(6) .pk_colored(true), ) }); ui.add(&mut NoteContents::new( self.ndb, self.img_cache, self.note_cache, txn, self.note, note_key, self.flags, )); //}); }) .response } pub fn expand_size() -> f32 { 5.0 } fn pfp( &mut self, note_key: NoteKey, profile: &Result, nostrdb::Error>, ui: &mut egui::Ui, ) -> egui::Response { if !self.options().has_wide() { ui.spacing_mut().item_spacing.x = 16.0; } else { ui.spacing_mut().item_spacing.x = 4.0; } let pfp_size = self.options().pfp_size(); let sense = Sense::click(); match profile .as_ref() .ok() .and_then(|p| p.record().profile()?.picture()) { // these have different lifetimes and types, // so the calls must be separate Some(pic) => { let anim_speed = 0.05; let profile_key = profile.as_ref().unwrap().record().note_key(); let note_key = note_key.as_u64(); let (rect, size, resp) = ui::anim::hover_expand( ui, egui::Id::new((profile_key, note_key)), pfp_size, ui::NoteView::expand_size(), anim_speed, ); ui.put(rect, ui::ProfilePic::new(self.img_cache, pic).size(size)) .on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); ui.add(ui::ProfilePreview::new( profile.as_ref().unwrap(), self.img_cache, )); }); if resp.hovered() || resp.clicked() { ui::show_pointer(ui); } resp } None => { // This has to match the expand size from the above case to // prevent bounciness let size = pfp_size + ui::NoteView::expand_size(); let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); ui.put( rect, ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()) .size(pfp_size), ) .interact(sense) } } } pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { if self.options().has_textmode() { NoteResponse::new(self.textmode_ui(ui)) } else { let txn = self.note.txn().expect("txn"); if let Some(note_to_repost) = get_reposted_note(self.ndb, txn, self.note) { let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); let style = NotedeckTextStyle::Small; ui.horizontal(|ui| { ui.vertical(|ui| { ui.add_space(2.0); ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode)); }); ui.add_space(6.0); let resp = ui.add(one_line_display_name_widget( ui.visuals(), get_display_name(profile.as_ref().ok()), style, )); if let Ok(rec) = &profile { resp.on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); ui.add(ui::ProfilePreview::new(rec, self.img_cache)); }); } let color = ui.style().visuals.noninteractive().fg_stroke.color; ui.add_space(4.0); ui.label( RichText::new("Reposted") .color(color) .text_style(style.text_style()), ); }); NoteView::new( self.ndb, self.note_cache, self.img_cache, ¬e_to_repost, self.flags, ) .show(ui) } else { self.show_standard(ui) } } } fn note_header( ui: &mut egui::Ui, note_cache: &mut NoteCache, note: &Note, profile: &Result, nostrdb::Error>, options: NoteOptions, container_right: Pos2, ) -> NoteResponse { #[cfg(feature = "profiling")] puffin::profile_function!(); let note_key = note.key().unwrap(); let inner_response = ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); render_reltime(ui, cached_note, true); if options.has_options_button() { let context_pos = { let size = NoteContextButton::max_width(); let min = Pos2::new(container_right.x - size, container_right.y); Rect::from_min_size(min, egui::vec2(size, size)) }; let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); NoteContextButton::menu(ui, resp.clone()) } else { None } }); NoteResponse::new(inner_response.response).select_option(inner_response.inner) } fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse { #[cfg(feature = "profiling")] puffin::profile_function!(); let note_key = self.note.key().expect("todo: support non-db notes"); let txn = self.note.txn().expect("todo: support non-db notes"); let mut note_action: Option = None; let mut selected_option: Option = None; let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent); let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id); let container_right = { let r = ui.available_rect_before_wrap(); let x = r.max.x; let y = r.min.y; Pos2::new(x, y) }; // wide design let response = if self.options().has_wide() { ui.vertical(|ui| { ui.horizontal(|ui| { if self.pfp(note_key, &profile, ui).clicked() { note_action = Some(NoteAction::OpenTimeline(TimelineKind::profile( Pubkey::new(*self.note.pubkey()), ))); }; let size = ui.available_size(); ui.vertical(|ui| { ui.add_sized([size.x, self.options().pfp_size()], |ui: &mut egui::Ui| { ui.horizontal_centered(|ui| { selected_option = NoteView::note_header( ui, self.note_cache, self.note, &profile, self.options(), container_right, ) .context_selection; }) .response }); let note_reply = self .note_cache .cached_note_or_insert_mut(note_key, self.note) .reply .borrow(self.note.tags()); if note_reply.reply().is_some() { let action = ui .horizontal(|ui| { reply_desc( ui, txn, ¬e_reply, self.ndb, self.img_cache, self.note_cache, self.flags, ) }) .inner; if action.is_some() { note_action = action; } } }); }); let mut contents = NoteContents::new( self.ndb, self.img_cache, self.note_cache, txn, self.note, note_key, self.options(), ); ui.add(&mut contents); if let Some(action) = contents.action() { note_action = Some(action.clone()); } if self.options().has_actionbar() { if let Some(action) = render_note_actionbar(ui, self.note.id(), note_key).inner { note_action = Some(action); } } }) .response } else { // main design ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { if self.pfp(note_key, &profile, ui).clicked() { note_action = Some(NoteAction::OpenTimeline(TimelineKind::Profile( Pubkey::new(*self.note.pubkey()), ))); }; ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { selected_option = NoteView::note_header( ui, self.note_cache, self.note, &profile, self.options(), container_right, ) .context_selection; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let note_reply = self .note_cache .cached_note_or_insert_mut(note_key, self.note) .reply .borrow(self.note.tags()); if note_reply.reply().is_some() { let action = reply_desc( ui, txn, ¬e_reply, self.ndb, self.img_cache, self.note_cache, self.flags, ); if action.is_some() { note_action = action; } } }); let mut contents = NoteContents::new( self.ndb, self.img_cache, self.note_cache, txn, self.note, note_key, self.options(), ); ui.add(&mut contents); if let Some(action) = contents.action() { note_action = Some(action.clone()); } if self.options().has_actionbar() { if let Some(action) = render_note_actionbar(ui, self.note.id(), note_key).inner { note_action = Some(action); } } }); }) .response }; let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) { if let Ok(selection) = ThreadSelection::from_note_id( self.ndb, self.note_cache, self.note.txn().unwrap(), NoteId::new(*self.note.id()), ) { Some(NoteAction::OpenTimeline(TimelineKind::Thread(selection))) } else { None } } else { note_action }; NoteResponse::new(response) .with_action(note_action) .select_option(selected_option) } } fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option> { let new_note_id: &[u8; 32] = if note.kind() == 6 { let mut res = None; for tag in note.tags().iter() { if tag.count() == 0 { continue; } if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) { if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) { res = Some(note_id); break; } } } res? } else { return None; }; let note = ndb.get_note_by_id(txn, new_note_id).ok(); note.filter(|note| note.kind() == 1) } fn note_hitbox_id( note_key: NoteKey, note_options: NoteOptions, parent: Option, ) -> egui::Id { Id::new(("note_size", note_key, note_options, parent)) } fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option { ui.ctx() .data_mut(|d| d.get_persisted(hitbox_id)) .map(|note_size: Vec2| { // The hitbox should extend the entire width of the // container. The hitbox height was cached last layout. let container_rect = ui.max_rect(); let rect = Rect { min: pos2(container_rect.min.x, container_rect.min.y), max: pos2(container_rect.max.x, container_rect.min.y + note_size.y), }; let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click()); response .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox")); response }) } fn note_hitbox_clicked( ui: &mut egui::Ui, hitbox_id: egui::Id, note_rect: &Rect, maybe_hitbox: Option, ) -> bool { // Stash the dimensions of the note content so we can render the // hitbox in the next frame ui.ctx().data_mut(|d| { d.insert_persisted(hitbox_id, note_rect.size()); }); // If there was an hitbox and it was clicked open the thread match maybe_hitbox { Some(hitbox) => hitbox.clicked(), _ => false, } } fn render_note_actionbar( ui: &mut egui::Ui, note_id: &[u8; 32], note_key: NoteKey, ) -> egui::InnerResponse> { #[cfg(feature = "profiling")] puffin::profile_function!(); ui.horizontal(|ui| { let reply_resp = reply_button(ui, note_key); let quote_resp = quote_repost_button(ui, note_key); if reply_resp.clicked() { Some(NoteAction::Reply(NoteId::new(*note_id))) } else if quote_resp.clicked() { Some(NoteAction::Quote(NoteId::new(*note_id))) } else { None } }) } fn secondary_label(ui: &mut egui::Ui, s: impl Into) { let color = ui.style().visuals.noninteractive().fg_stroke.color; ui.add(Label::new(RichText::new(s).size(10.0).color(color))); } fn render_reltime( ui: &mut egui::Ui, note_cache: &mut CachedNote, before: bool, ) -> egui::InnerResponse<()> { #[cfg(feature = "profiling")] puffin::profile_function!(); ui.horizontal(|ui| { if before { secondary_label(ui, "⋅"); } secondary_label(ui, note_cache.reltime_str_mut()); if !before { secondary_label(ui, "⋅"); } }) } fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { let img_data = if ui.style().visuals.dark_mode { egui::include_image!("../../../../../assets/icons/reply.png") } else { egui::include_image!("../../../../../assets/icons/reply-dark.png") }; let (rect, size, resp) = ui::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); // align rect to note contents let expand_size = 5.0; // from hover_expand_small let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size)); resp.union(put_resp) } fn repost_icon(dark_mode: bool) -> egui::Image<'static> { let img_data = if dark_mode { egui::include_image!("../../../../../assets/icons/repost_icon_4x.png") } else { egui::include_image!("../../../../../assets/icons/repost_light_4x.png") }; egui::Image::new(img_data) } fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { let size = 14.0; let expand_size = 5.0; let anim_speed = 0.05; let id = ui.id().with(("repost_anim", note_key)); let (rect, size, resp) = ui::anim::hover_expand(ui, id, size, expand_size, anim_speed); let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0)); let put_resp = ui.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)); resp.union(put_resp) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/note/reply.rs`: ```rs use crate::draft::Draft; use crate::ui; use crate::ui::note::{NoteOptions, PostResponse, PostType}; use enostr::{FilledKeypair, NoteId}; use nostrdb::Ndb; use notedeck::{Images, NoteCache}; pub struct PostReplyView<'a> { ndb: &'a Ndb, poster: FilledKeypair<'a>, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, draft: &'a mut Draft, note: &'a nostrdb::Note<'a>, id_source: Option, inner_rect: egui::Rect, note_options: NoteOptions, } impl<'a> PostReplyView<'a> { #[allow(clippy::too_many_arguments)] pub fn new( ndb: &'a Ndb, poster: FilledKeypair<'a>, draft: &'a mut Draft, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, note: &'a nostrdb::Note<'a>, inner_rect: egui::Rect, note_options: NoteOptions, ) -> Self { let id_source: Option = None; PostReplyView { ndb, poster, draft, note, note_cache, img_cache, id_source, inner_rect, note_options, } } pub fn id_source(mut self, id: egui::Id) -> Self { self.id_source = Some(id); self } pub fn id(&self) -> egui::Id { self.id_source .unwrap_or_else(|| egui::Id::new("post-reply-view")) } pub fn show(&mut self, ui: &mut egui::Ui) -> PostResponse { ui.vertical(|ui| { let avail_rect = ui.available_rect_before_wrap(); // This is the offset of the post view's pfp. We use this // to indent things so that the reply line is aligned let pfp_offset = ui::PostView::outer_margin() + ui::PostView::inner_margin() + ui::ProfilePic::small_size() / 2.0; let note_offset = pfp_offset - ui::ProfilePic::medium_size() / 2.0 - ui::NoteView::expand_size() / 2.0; egui::Frame::none() .outer_margin(egui::Margin::same(note_offset)) .show(ui, |ui| { ui::NoteView::new( self.ndb, self.note_cache, self.img_cache, self.note, self.note_options, ) .actionbar(false) .medium_pfp(true) .options_button(true) .show(ui); }); let id = self.id(); let replying_to = self.note.id(); let rect_before_post = ui.min_rect(); let post_response = { ui::PostView::new( self.ndb, self.draft, PostType::Reply(NoteId::new(*replying_to)), self.img_cache, self.note_cache, self.poster, self.inner_rect, self.note_options, ) .id_source(id) .ui(self.note.txn().unwrap(), ui) }; // // reply line // // Position and draw the reply line let mut rect = ui.min_rect(); // Position the line right above the poster's profile pic in // the post box. Use the PostView's margin values to // determine this offset. rect.min.x = avail_rect.min.x + pfp_offset; // honestly don't know what the fuck I'm doing here. just trying // to get the line under the profile picture rect.min.y = avail_rect.min.y + (ui::ProfilePic::medium_size() / 2.0 + ui::ProfilePic::medium_size() + ui::NoteView::expand_size() * 2.0) + 1.0; // For some reason we need to nudge the reply line's height a // few more pixels? let nudge = if post_response.edit_response.has_focus() { // we nudge by one less pixel if focused, otherwise it // overlaps the focused PostView purple border color 2.0 } else { // we have to nudge by one more pixel when not focused // otherwise it looks like there's a gap(?) 3.0 }; rect.max.y = rect_before_post.max.y + ui::PostView::outer_margin() + nudge; ui.painter().vline( rect.left(), rect.y_range(), ui.visuals().widgets.noninteractive.bg_stroke, ); post_response }) .inner } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/note/context.rs`: ```rs use egui::{Rect, Vec2}; use enostr::{NoteId, Pubkey}; use nostrdb::{Note, NoteKey}; use tracing::error; #[derive(Clone)] #[allow(clippy::enum_variant_names)] pub enum NoteContextSelection { CopyText, CopyPubkey, CopyNoteId, CopyNoteJSON, } impl NoteContextSelection { pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>) { match self { NoteContextSelection::CopyText => { ui.output_mut(|w| { w.copied_text = note.content().to_string(); }); } NoteContextSelection::CopyPubkey => { ui.output_mut(|w| { if let Some(bech) = Pubkey::new(*note.pubkey()).to_bech() { w.copied_text = bech; } }); } NoteContextSelection::CopyNoteId => { ui.output_mut(|w| { if let Some(bech) = NoteId::new(*note.id()).to_bech() { w.copied_text = bech; } }); } NoteContextSelection::CopyNoteJSON => { ui.output_mut(|w| match note.json() { Ok(json) => w.copied_text = json, Err(err) => error!("error copying note json: {err}"), }); } } } } pub struct NoteContextButton { put_at: Option, note_key: NoteKey, } impl egui::Widget for NoteContextButton { fn ui(self, ui: &mut egui::Ui) -> egui::Response { let r = if let Some(r) = self.put_at { r } else { let mut place = ui.available_rect_before_wrap(); let size = Self::max_width(); place.set_width(size); place.set_height(size); place }; Self::show(ui, self.note_key, r) } } impl NoteContextButton { pub fn new(note_key: NoteKey) -> Self { let put_at: Option = None; NoteContextButton { note_key, put_at } } pub fn place_at(mut self, rect: Rect) -> Self { self.put_at = Some(rect); self } pub fn max_width() -> f32 { Self::max_radius() * 3.0 + Self::max_distance_between_circles() * 2.0 } pub fn size() -> Vec2 { let width = Self::max_width(); egui::vec2(width, width) } fn max_radius() -> f32 { 4.0 } fn min_radius() -> f32 { 2.0 } fn max_distance_between_circles() -> f32 { 2.0 } fn expansion_multiple() -> f32 { 2.0 } fn min_distance_between_circles() -> f32 { Self::max_distance_between_circles() / Self::expansion_multiple() } pub fn show(ui: &mut egui::Ui, note_key: NoteKey, put_at: Rect) -> egui::Response { #[cfg(feature = "profiling")] puffin::profile_function!(); let id = ui.id().with(("more_options_anim", note_key)); let min_radius = Self::min_radius(); let anim_speed = 0.05; let response = ui.interact(put_at, id, egui::Sense::click()); let hovered = response.hovered(); let animation_progress = ui.ctx().animate_bool_with_time(id, hovered, anim_speed); if hovered { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } let min_distance = Self::min_distance_between_circles(); let cur_distance = min_distance + (Self::max_distance_between_circles() - min_distance) * animation_progress; let cur_radius = min_radius + (Self::max_radius() - min_radius) * animation_progress; let center = put_at.center(); let left_circle_center = center - egui::vec2(cur_distance + cur_radius, 0.0); let right_circle_center = center + egui::vec2(cur_distance + cur_radius, 0.0); let translated_radius = (cur_radius - 1.0) / 2.0; // This works in both themes let color = ui.style().visuals.noninteractive().fg_stroke.color; // Draw circles let painter = ui.painter_at(put_at); painter.circle_filled(left_circle_center, translated_radius, color); painter.circle_filled(center, translated_radius, color); painter.circle_filled(right_circle_center, translated_radius, color); response } pub fn menu( ui: &mut egui::Ui, button_response: egui::Response, ) -> Option { #[cfg(feature = "profiling")] puffin::profile_function!(); let mut context_selection: Option = None; stationary_arbitrary_menu_button(ui, button_response, |ui| { ui.set_max_width(200.0); if ui.button("Copy text").clicked() { context_selection = Some(NoteContextSelection::CopyText); ui.close_menu(); } if ui.button("Copy user public key").clicked() { context_selection = Some(NoteContextSelection::CopyPubkey); ui.close_menu(); } if ui.button("Copy note id").clicked() { context_selection = Some(NoteContextSelection::CopyNoteId); ui.close_menu(); } if ui.button("Copy note json").clicked() { context_selection = Some(NoteContextSelection::CopyNoteJSON); ui.close_menu(); } }); context_selection } } fn stationary_arbitrary_menu_button( ui: &mut egui::Ui, button_response: egui::Response, add_contents: impl FnOnce(&mut egui::Ui) -> R, ) -> egui::InnerResponse> { let bar_id = ui.id(); let mut bar_state = egui::menu::BarState::load(ui.ctx(), bar_id); let inner = bar_state.bar_menu(&button_response, add_contents); bar_state.store(ui.ctx(), bar_id); egui::InnerResponse::new(inner.map(|r| r.inner), button_response) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/star.rs`: ```rs use egui::{epaint::PathShape, Color32, Painter, Pos2, Rect, Stroke}; pub fn paint_star_icon(painter: &Painter, paint_at: Rect) { // Define star points let star_points = [ Pos2::new(8.66663, 2.0), Pos2::new(9.82276, 5.00591), Pos2::new(10.0108, 5.49473), Pos2::new(10.1048, 5.73914), Pos2::new(10.251, 5.94473), Pos2::new(10.3805, 6.12693), Pos2::new(10.5397, 6.28613), Pos2::new(10.7219, 6.41569), Pos2::new(10.9275, 6.56187), Pos2::new(11.1719, 6.65587), Pos2::new(11.6607, 6.84387), Pos2::new(14.6666, 8.0), Pos2::new(11.6607, 9.15613), Pos2::new(11.1719, 9.34413), Pos2::new(10.9275, 9.43813), Pos2::new(10.7219, 9.58433), Pos2::new(10.5397, 9.71387), Pos2::new(10.3805, 9.87307), Pos2::new(10.251, 10.0553), Pos2::new(10.1048, 10.2609), Pos2::new(10.0108, 10.5053), Pos2::new(9.82276, 10.9941), Pos2::new(8.66663, 14.0), Pos2::new(7.51049, 10.9941), Pos2::new(7.32249, 10.5053), Pos2::new(7.22849, 10.2609), Pos2::new(7.08229, 10.0553), Pos2::new(6.95276, 9.87307), Pos2::new(6.79356, 9.71387), Pos2::new(6.61135, 9.58433), Pos2::new(6.40577, 9.43813), Pos2::new(6.16135, 9.34413), Pos2::new(5.67253, 9.15613), Pos2::new(2.66663, 8.0), Pos2::new(5.67253, 6.84387), Pos2::new(6.16135, 6.65587), Pos2::new(6.40577, 6.56187), Pos2::new(6.61135, 6.41569), Pos2::new(6.79356, 6.28613), Pos2::new(6.95276, 6.12693), Pos2::new(7.08229, 5.94473), Pos2::new(7.22849, 5.73914), Pos2::new(7.32249, 5.49473), Pos2::new(7.51049, 5.00591), ]; // Compute uniform scale and center for aspect ratio preservation let scale = paint_at.width().min(paint_at.height()) / 16.0; let center = paint_at.center(); // Helper function to transform points from 16x16 space to Rect space let transform = |p: Pos2| { Pos2::new( center.x + (p.x - 8.0) * scale, center.y + (p.y - 8.0) * scale, ) }; // Transform star points let transformed_star_points: Vec = star_points.iter().map(|&p| transform(p)).collect(); // Define original line segments (additional decorative elements) let original_lines = [ [Pos2::new(3.0, 14.6667), Pos2::new(3.0, 11.3334)], // Vertical line 1 [Pos2::new(3.0, 4.66671), Pos2::new(3.0, 1.33337)], // Vertical line 2 [Pos2::new(1.33337, 3.0), Pos2::new(4.66671, 3.0)], // Horizontal line 1 [Pos2::new(1.33337, 13.0), Pos2::new(4.66671, 13.0)], // Horizontal line 2 ]; // Transform line segments let transformed_lines: Vec<[Pos2; 2]> = original_lines .iter() .map(|[p0, p1]| [transform(*p0), transform(*p1)]) .collect(); // Filled star (opacity 0.12) let alpha = (0.12 * 255.0) as u8; // ≈ 30 let fill_color = Color32::from_rgba_premultiplied(alpha, alpha, alpha, alpha); painter.add(PathShape { points: transformed_star_points.clone(), closed: true, fill: fill_color, stroke: Stroke::default().into(), }); // Stroke for lines and stroked star let stroke = Stroke::new(1.5, Color32::WHITE); // Draw line segments for line in transformed_lines { painter.line_segment(line, stroke); } // Stroked star painter.add(PathShape { points: transformed_star_points, closed: true, fill: Color32::TRANSPARENT, stroke: stroke.into(), }); } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/configure_deck.rs`: ```rs use crate::{app_style::deck_icon_font_sized, colors::PINK, deck_state::DeckState}; use egui::{vec2, Button, Color32, Label, RichText, Stroke, Ui, Widget}; use notedeck::{NamedFontFamily, NotedeckTextStyle}; use super::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, padding, }; pub struct ConfigureDeckView<'a> { state: &'a mut DeckState, create_button_text: String, } pub struct ConfigureDeckResponse { pub icon: char, pub name: String, } static CREATE_TEXT: &str = "Create Deck"; impl<'a> ConfigureDeckView<'a> { pub fn new(state: &'a mut DeckState) -> Self { Self { state, create_button_text: CREATE_TEXT.to_owned(), } } pub fn with_create_text(mut self, text: &str) -> Self { self.create_button_text = text.to_owned(); self } pub fn ui(&mut self, ui: &mut Ui) -> Option { let title_font = egui::FontId::new( notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Heading4), egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), ); padding(16.0, ui, |ui| { ui.add(Label::new( RichText::new("Deck name").font(title_font.clone()), )); ui.add_space(8.0); ui.text_edit_singleline(&mut self.state.deck_name); ui.add_space(8.0); ui.add(Label::new( RichText::new("We recommend short names") .color(ui.visuals().noninteractive().fg_stroke.color) .size(notedeck::fonts::get_font_size( ui.ctx(), &NotedeckTextStyle::Small, )), )); ui.add_space(32.0); ui.add(Label::new(RichText::new("Icon").font(title_font))); if ui .add(deck_icon( ui.id().with("config-deck"), self.state.selected_glyph, 38.0, 64.0, false, )) .clicked() { self.state.selecting_glyph = !self.state.selecting_glyph; } if self.state.selecting_glyph { let max_height = if ui.available_height() - 100.0 > 0.0 { ui.available_height() - 100.0 } else { ui.available_height() }; egui::Frame::window(ui.style()).show(ui, |ui| { let glyphs = self.state.get_glyph_options(ui); if let Some(selected_glyph) = glyph_options_ui(ui, 16.0, max_height, glyphs) { self.state.selected_glyph = Some(selected_glyph); self.state.selecting_glyph = false; } }); ui.add_space(16.0); } if self.state.warn_no_icon && self.state.selected_glyph.is_some() { self.state.warn_no_icon = false; } if self.state.warn_no_title && !self.state.deck_name.is_empty() { self.state.warn_no_title = false; } show_warnings(ui, self.state.warn_no_icon, self.state.warn_no_title); let mut resp = None; if ui .add(create_deck_button(&self.create_button_text)) .clicked() { if self.state.deck_name.is_empty() { self.state.warn_no_title = true; } if self.state.selected_glyph.is_none() { self.state.warn_no_icon = true; } if !self.state.deck_name.is_empty() { if let Some(glyph) = self.state.selected_glyph { resp = Some(ConfigureDeckResponse { icon: glyph, name: self.state.deck_name.clone(), }); } } } resp }) .inner } } fn show_warnings(ui: &mut Ui, warn_no_icon: bool, warn_no_title: bool) { if warn_no_icon || warn_no_title { let messages = [ if warn_no_title { "create a name for the deck" } else { "" }, if warn_no_icon { "select an icon" } else { "" }, ]; let message = messages .iter() .filter(|&&m| !m.is_empty()) .copied() .collect::>() .join(" and "); ui.add( egui::Label::new( RichText::new(format!("Please {}.", message)).color(ui.visuals().error_fg_color), ) .wrap(), ); } } fn create_deck_button(text: &str) -> impl Widget + '_ { move |ui: &mut egui::Ui| { let size = vec2(108.0, 40.0); ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| { ui.add(Button::new(text).fill(PINK).min_size(size)) }) .inner } } pub fn deck_icon( id: egui::Id, glyph: Option, font_size: f32, full_size: f32, highlight: bool, ) -> impl Widget { move |ui: &mut egui::Ui| -> egui::Response { let max_size = full_size * ICON_EXPANSION_MULTIPLE; let helper = AnimationHelper::new(ui, id, vec2(max_size, max_size)); let painter = ui.painter_at(helper.get_animation_rect()); let bg_center = helper.get_animation_rect().center(); let (stroke, fill_color) = if highlight { ( ui.visuals().selection.stroke, ui.visuals().widgets.noninteractive.weak_bg_fill, ) } else { ( Stroke::new( ui.visuals().widgets.inactive.bg_stroke.width, ui.visuals().widgets.inactive.weak_bg_fill, ), ui.visuals().widgets.noninteractive.weak_bg_fill, ) }; let radius = helper.scale_1d_pos((full_size / 2.0) - stroke.width); painter.circle(bg_center, radius, fill_color, stroke); if let Some(glyph) = glyph { let font = deck_icon_font_sized(helper.scale_1d_pos(font_size / std::f32::consts::SQRT_2)); let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, ui.visuals().text_color()); let top_left = { let mut glyph_rect = glyph_galley.rect; glyph_rect.set_center(bg_center); glyph_rect.left_top() }; painter.galley(top_left, glyph_galley, Color32::WHITE); } helper.take_animation_response() } } fn glyph_icon_max_size(ui: &egui::Ui, glyph: &char, font_size: f32) -> egui::Vec2 { let painter = ui.painter(); let font = deck_icon_font_sized(font_size * ICON_EXPANSION_MULTIPLE); let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, Color32::WHITE); glyph_galley.rect.size() } fn glyph_icon(glyph: char, font_size: f32, max_size: egui::Vec2, color: Color32) -> impl Widget { move |ui: &mut egui::Ui| { let helper = AnimationHelper::new(ui, ("glyph", glyph), max_size); let painter = ui.painter_at(helper.get_animation_rect()); let font = deck_icon_font_sized(helper.scale_1d_pos(font_size)); let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, color); let top_left = { let mut glyph_rect = glyph_galley.rect; glyph_rect.set_center(helper.get_animation_rect().center()); glyph_rect.left_top() }; painter.galley(top_left, glyph_galley, Color32::WHITE); helper.take_animation_response() } } fn glyph_options_ui( ui: &mut egui::Ui, font_size: f32, max_height: f32, glyphs: &[char], ) -> Option { let mut selected_glyph = None; egui::ScrollArea::vertical() .max_height(max_height) .show(ui, |ui| { let max_width = ui.available_width(); let mut row_glyphs = Vec::new(); let mut cur_width = 0.0; let spacing = ui.spacing().item_spacing.x; for (index, glyph) in glyphs.iter().enumerate() { let next_glyph_size = glyph_icon_max_size(ui, glyph, font_size); if cur_width + spacing + next_glyph_size.x > max_width { if let Some(selected) = paint_row(ui, &row_glyphs, font_size) { selected_glyph = Some(selected); } row_glyphs.clear(); cur_width = 0.0; } cur_width += spacing; cur_width += next_glyph_size.x; row_glyphs.push(*glyph); if index == glyphs.len() - 1 { if let Some(selected) = paint_row(ui, &row_glyphs, font_size) { selected_glyph = Some(selected); } } } }); selected_glyph } fn paint_row(ui: &mut egui::Ui, row_glyphs: &[char], font_size: f32) -> Option { let mut selected_glyph = None; ui.horizontal(|ui| { for glyph in row_glyphs { let glyph_size = glyph_icon_max_size(ui, glyph, font_size); if ui .add(glyph_icon( *glyph, font_size, glyph_size, ui.visuals().text_color(), )) .clicked() { selected_glyph = Some(*glyph); } } }); selected_glyph } mod preview { use crate::{ deck_state::DeckState, ui::{Preview, PreviewConfig}, }; use super::ConfigureDeckView; use notedeck::{App, AppContext}; pub struct ConfigureDeckPreview { state: DeckState, } impl ConfigureDeckPreview { fn new() -> Self { let state = DeckState::default(); ConfigureDeckPreview { state } } } impl App for ConfigureDeckPreview { fn update(&mut self, _app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) { ConfigureDeckView::new(&mut self.state).ui(ui); } } impl Preview for ConfigureDeckView<'_> { type Prev = ConfigureDeckPreview; fn preview(_cfg: PreviewConfig) -> Self::Prev { ConfigureDeckPreview::new() } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/anim.rs`: ```rs use egui::{Pos2, Rect, Response, Sense}; pub fn hover_expand( ui: &mut egui::Ui, id: egui::Id, size: f32, expand_size: f32, anim_speed: f32, ) -> (egui::Rect, f32, egui::Response) { // Allocate space for the profile picture with a fixed size let default_size = size + expand_size; let (rect, response) = ui.allocate_exact_size(egui::vec2(default_size, default_size), egui::Sense::click()); let val = ui .ctx() .animate_bool_with_time(id, response.hovered(), anim_speed); let size = size + val * expand_size; (rect, size, response) } pub fn hover_expand_small(ui: &mut egui::Ui, id: egui::Id) -> (egui::Rect, f32, egui::Response) { let size = 10.0; let expand_size = 5.0; let anim_speed = 0.05; hover_expand(ui, id, size, expand_size, anim_speed) } pub static ICON_EXPANSION_MULTIPLE: f32 = 1.2; pub static ANIM_SPEED: f32 = 0.05; pub struct AnimationHelper { rect: Rect, center: Pos2, response: Response, animation_progress: f32, expansion_multiple: f32, } impl AnimationHelper { pub fn new( ui: &mut egui::Ui, animation_name: impl std::hash::Hash, max_size: egui::Vec2, ) -> Self { let id = ui.id().with(animation_name); let (rect, response) = ui.allocate_exact_size(max_size, Sense::click()); let animation_progress = ui.ctx() .animate_bool_with_time(id, response.hovered(), ANIM_SPEED); Self { rect, center: rect.center(), response, animation_progress, expansion_multiple: ICON_EXPANSION_MULTIPLE, } } pub fn no_animation(ui: &mut egui::Ui, size: egui::Vec2) -> Self { let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); Self { rect, center: rect.center(), response, animation_progress: 0.0, expansion_multiple: ICON_EXPANSION_MULTIPLE, } } pub fn new_from_rect( ui: &mut egui::Ui, animation_name: impl std::hash::Hash, animation_rect: egui::Rect, ) -> Self { let id = ui.id().with(animation_name); let response = ui.allocate_rect(animation_rect, Sense::click()); let animation_progress = ui.ctx() .animate_bool_with_time(id, response.hovered(), ANIM_SPEED); Self { rect: animation_rect, center: animation_rect.center(), response, animation_progress, expansion_multiple: ICON_EXPANSION_MULTIPLE, } } pub fn scale_1d_pos(&self, min_object_size: f32) -> f32 { let max_object_size = min_object_size * self.expansion_multiple; if self.response.is_pointer_button_down_on() { min_object_size } else { min_object_size + ((max_object_size - min_object_size) * self.animation_progress) } } pub fn scale_radius(&self, min_diameter: f32) -> f32 { self.scale_1d_pos((min_diameter - 1.0) / 2.0) } pub fn get_animation_rect(&self) -> egui::Rect { self.rect } pub fn center(&self) -> Pos2 { self.rect.center() } pub fn take_animation_response(self) -> egui::Response { self.response } // Scale a minimum position from center to the current animation position pub fn scale_from_center(&self, x_min: f32, y_min: f32) -> Pos2 { Pos2::new( self.center.x + self.scale_1d_pos(x_min), self.center.y + self.scale_1d_pos(y_min), ) } pub fn scale_pos_from_center(&self, min_pos: Pos2) -> Pos2 { self.scale_from_center(min_pos.x, min_pos.y) } /// New method for min/max scaling when needed pub fn scale_1d_pos_min_max(&self, min_object_size: f32, max_object_size: f32) -> f32 { min_object_size + ((max_object_size - min_object_size) * self.animation_progress) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/support.rs`: ```rs use egui::{vec2, Button, Label, Layout, RichText}; use tracing::error; use crate::{colors::PINK, support::Support}; use super::padding; use notedeck::{NamedFontFamily, NotedeckTextStyle}; pub struct SupportView<'a> { support: &'a mut Support, } impl<'a> SupportView<'a> { pub fn new(support: &'a mut Support) -> Self { Self { support } } pub fn show(&mut self, ui: &mut egui::Ui) { padding(8.0, ui, |ui| { ui.spacing_mut().item_spacing = egui::vec2(0.0, 8.0); let font = egui::FontId::new( notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body), egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), ); ui.add(Label::new(RichText::new("Running into a bug?").font(font))); ui.label(RichText::new("Step 1").text_style(NotedeckTextStyle::Heading3.text_style())); padding(8.0, ui, |ui| { ui.label("Open your default email client to get help from the Damus team"); let size = vec2(120.0, 40.0); ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| { let font_size = notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body); let button_resp = ui.add(open_email_button(font_size, size)); if button_resp.clicked() { if let Err(e) = open::that(self.support.get_mailto_url()) { error!( "Failed to open URL {} because: {}", self.support.get_mailto_url(), e ); }; }; button_resp.on_hover_text_at_pointer(self.support.get_mailto_url()); }) }); ui.add_space(8.0); if let Some(logs) = self.support.get_most_recent_log() { ui.label( RichText::new("Step 2").text_style(NotedeckTextStyle::Heading3.text_style()), ); let size = vec2(80.0, 40.0); let copy_button = Button::new(RichText::new("Copy").size( notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body), )) .fill(PINK) .min_size(size); padding(8.0, ui, |ui| { ui.add(Label::new("Press the button below to copy your most recent logs to your system's clipboard. Then paste it into your email.").wrap()); ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| { if ui.add(copy_button).clicked() { ui.output_mut(|w| { w.copied_text = logs.to_string(); }); } }); }); } else { ui.label( egui::RichText::new("ERROR: Could not find logs on system") .color(egui::Color32::RED), ); } ui.label(format!("Notedeck {}", env!("CARGO_PKG_VERSION"))); ui.label(format!("Commit hash: {}", env!("GIT_COMMIT_HASH"))); }); } } fn open_email_button(font_size: f32, size: egui::Vec2) -> impl egui::Widget { Button::new(RichText::new("Open Email").size(font_size)) .fill(PINK) .min_size(size) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/thread.rs`: ```rs use crate::{ actionbar::NoteAction, timeline::{ThreadSelection, TimelineCache, TimelineKind}, ui::note::NoteOptions, }; use nostrdb::{Ndb, Transaction}; use notedeck::{Images, MuteFun, NoteCache, RootNoteId, UnknownIds}; use tracing::error; use super::timeline::TimelineTabView; pub struct ThreadView<'a> { timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, unknown_ids: &'a mut UnknownIds, img_cache: &'a mut Images, selected_note_id: &'a [u8; 32], note_options: NoteOptions, id_source: egui::Id, is_muted: &'a MuteFun, } impl<'a> ThreadView<'a> { #[allow(clippy::too_many_arguments)] pub fn new( timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, unknown_ids: &'a mut UnknownIds, img_cache: &'a mut Images, selected_note_id: &'a [u8; 32], note_options: NoteOptions, is_muted: &'a MuteFun, ) -> Self { let id_source = egui::Id::new("threadscroll_threadview"); ThreadView { timeline_cache, ndb, note_cache, unknown_ids, img_cache, selected_note_id, note_options, id_source, is_muted, } } pub fn id_source(mut self, id: egui::Id) -> Self { self.id_source = id; self } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { let txn = Transaction::new(self.ndb).expect("txn"); ui.label( egui::RichText::new("Threads ALPHA! It's not done. Things will be broken.") .color(egui::Color32::RED), ); egui::ScrollArea::vertical() .id_salt(self.id_source) .animated(false) .auto_shrink([false, false]) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible) .show(ui, |ui| { let root_id = match RootNoteId::new(self.ndb, self.note_cache, &txn, self.selected_note_id) { Ok(root_id) => root_id, Err(err) => { ui.label(format!("Error loading thread: {:?}", err)); return None; } }; let thread_timeline = self .timeline_cache .notes( self.ndb, self.note_cache, &txn, &TimelineKind::Thread(ThreadSelection::from_root_id(root_id.to_owned())), ) .get_ptr(); // TODO(jb55): skip poll if ThreadResult is fresh? let reversed = true; // poll for new notes and insert them into our existing notes if let Err(err) = thread_timeline.poll_notes_into_view( self.ndb, &txn, self.unknown_ids, self.note_cache, reversed, ) { error!("error polling notes into thread timeline: {err}"); } TimelineTabView::new( thread_timeline.current_view(), true, self.note_options, &txn, self.ndb, self.note_cache, self.img_cache, self.is_muted, ) .show(ui) }) .inner } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/timeline.rs`: ```rs use std::f32::consts::PI; use crate::actionbar::NoteAction; use crate::timeline::TimelineTab; use crate::{ timeline::{TimelineCache, TimelineKind, ViewFilter}, ui, ui::note::NoteOptions, }; use egui::containers::scroll_area::ScrollBarVisibility; use egui::{vec2, Direction, Layout, Pos2, Stroke}; use egui_tabs::TabColor; use nostrdb::{Ndb, Transaction}; use notedeck::note::root_note_id_from_selected_id; use notedeck::{Images, MuteFun, NoteCache}; use tracing::{error, warn}; use super::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}; pub struct TimelineView<'a> { timeline_id: &'a TimelineKind, timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, note_options: NoteOptions, reverse: bool, is_muted: &'a MuteFun, } impl<'a> TimelineView<'a> { #[allow(clippy::too_many_arguments)] pub fn new( timeline_id: &'a TimelineKind, timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, note_options: NoteOptions, is_muted: &'a MuteFun, ) -> TimelineView<'a> { let reverse = false; TimelineView { ndb, timeline_id, timeline_cache, note_cache, img_cache, reverse, note_options, is_muted, } } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { timeline_ui( ui, self.ndb, self.timeline_id, self.timeline_cache, self.note_cache, self.img_cache, self.reverse, self.note_options, self.is_muted, ) } pub fn reversed(mut self) -> Self { self.reverse = true; self } } #[allow(clippy::too_many_arguments)] fn timeline_ui( ui: &mut egui::Ui, ndb: &Ndb, timeline_id: &TimelineKind, timeline_cache: &mut TimelineCache, note_cache: &mut NoteCache, img_cache: &mut Images, reversed: bool, note_options: NoteOptions, is_muted: &MuteFun, ) -> Option { //padding(4.0, ui, |ui| ui.heading("Notifications")); /* let font_id = egui::TextStyle::Body.resolve(ui.style()); let row_height = ui.fonts(|f| f.row_height(&font_id)) + ui.spacing().item_spacing.y; */ let scroll_id = { let timeline = if let Some(timeline) = timeline_cache.timelines.get_mut(timeline_id) { timeline } else { error!("tried to render timeline in column, but timeline was missing"); // TODO (jb55): render error when timeline is missing? // this shouldn't happen... return None; }; timeline.selected_view = tabs_ui(ui, timeline.selected_view, &timeline.views); // need this for some reason?? ui.add_space(3.0); egui::Id::new(("tlscroll", timeline.view_id())) }; let show_top_button_id = ui.id().with((scroll_id, "at_top")); let show_top_button = ui .ctx() .data(|d| d.get_temp::(show_top_button_id)) .unwrap_or(false); let goto_top_resp = if show_top_button { let top_button_pos = ui.available_rect_before_wrap().right_top() - vec2(48.0, -24.0); egui::Area::new(ui.id().with("foreground_area")) .order(egui::Order::Foreground) .fixed_pos(top_button_pos) .show(ui.ctx(), |ui| Some(ui.add(goto_top_button(top_button_pos)))) .inner } else { None }; let mut scroll_area = egui::ScrollArea::vertical() .id_salt(scroll_id) .animated(false) .auto_shrink([false, false]) .scroll_bar_visibility(ScrollBarVisibility::AlwaysVisible); if let Some(goto_top_resp) = goto_top_resp { if goto_top_resp.clicked() { scroll_area = scroll_area.vertical_scroll_offset(0.0); } else if goto_top_resp.hovered() { ui::show_pointer(ui); } } let scroll_output = scroll_area.show(ui, |ui| { let timeline = if let Some(timeline) = timeline_cache.timelines.get(timeline_id) { timeline } else { error!("tried to render timeline in column, but timeline was missing"); // TODO (jb55): render error when timeline is missing? // this shouldn't happen... return None; }; let txn = Transaction::new(ndb).expect("failed to create txn"); TimelineTabView::new( timeline.current_view(), reversed, note_options, &txn, ndb, note_cache, img_cache, is_muted, ) .show(ui) }); let at_top_after_scroll = scroll_output.state.offset.y == 0.0; let cur_show_top_button = ui.ctx().data(|d| d.get_temp::(show_top_button_id)); if at_top_after_scroll { if cur_show_top_button != Some(false) { ui.ctx() .data_mut(|d| d.insert_temp(show_top_button_id, false)); } } else if cur_show_top_button == Some(false) { ui.ctx() .data_mut(|d| d.insert_temp(show_top_button_id, true)); } scroll_output.inner } fn goto_top_button(center: Pos2) -> impl egui::Widget { move |ui: &mut egui::Ui| -> egui::Response { let radius = 12.0; let max_size = vec2( ICON_EXPANSION_MULTIPLE * 2.0 * radius, ICON_EXPANSION_MULTIPLE * 2.0 * radius, ); let helper = AnimationHelper::new_from_rect(ui, "goto_top", { let painter = ui.painter(); let center = painter.round_pos_to_pixel_center(center); egui::Rect::from_center_size(center, max_size) }); let painter = ui.painter(); painter.circle_filled(center, helper.scale_1d_pos(radius), crate::colors::PINK); let create_pt = |angle: f32| { let side = radius / 2.0; let x = side * angle.cos(); let mut y = side * angle.sin(); let height = (side * (3.0_f32).sqrt()) / 2.0; y += height / 2.0; Pos2 { x, y } }; let left_pt = painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI))); let center_pt = painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(-PI / 2.0))); let right_pt = painter.round_pos_to_pixel_center(helper.scale_pos_from_center(create_pt(0.0))); let line_width = helper.scale_1d_pos(4.0); let line_color = ui.visuals().text_color(); painter.line_segment([left_pt, center_pt], Stroke::new(line_width, line_color)); painter.line_segment([center_pt, right_pt], Stroke::new(line_width, line_color)); let end_radius = (line_width - 1.0) / 2.0; painter.circle_filled(left_pt, end_radius, line_color); painter.circle_filled(center_pt, end_radius, line_color); painter.circle_filled(right_pt, end_radius, line_color); helper.take_animation_response() } } pub fn tabs_ui(ui: &mut egui::Ui, selected: usize, views: &[TimelineTab]) -> usize { ui.spacing_mut().item_spacing.y = 0.0; let tab_res = egui_tabs::Tabs::new(views.len() as i32) .selected(selected as i32) .hover_bg(TabColor::none()) .selected_fg(TabColor::none()) .selected_bg(TabColor::none()) .hover_bg(TabColor::none()) //.hover_bg(TabColor::custom(egui::Color32::RED)) .height(32.0) .layout(Layout::centered_and_justified(Direction::TopDown)) .show(ui, |ui, state| { ui.spacing_mut().item_spacing.y = 0.0; let ind = state.index(); let txt = match views[ind as usize].filter { ViewFilter::Notes => "Notes", ViewFilter::NotesAndReplies => "Notes & Replies", }; let res = ui.add(egui::Label::new(txt).selectable(false)); // underline if state.is_selected() { let rect = res.rect; let underline = shrink_range_to_width(rect.x_range(), get_label_width(ui, txt) * 1.15); let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5; return (underline, underline_y); } (egui::Rangef::new(0.0, 0.0), 0.0) }); //ui.add_space(0.5); ui::hline(ui); let sel = tab_res.selected().unwrap_or_default(); let (underline, underline_y) = tab_res.inner()[sel as usize].inner; let underline_width = underline.span(); let tab_anim_id = ui.id().with("tab_anim"); let tab_anim_size = tab_anim_id.with("size"); let stroke = egui::Stroke { color: ui.visuals().hyperlink_color, width: 2.0, }; let speed = 0.1f32; // animate underline position let x = ui .ctx() .animate_value_with_time(tab_anim_id, underline.min, speed); // animate underline width let w = ui .ctx() .animate_value_with_time(tab_anim_size, underline_width, speed); let underline = egui::Rangef::new(x, x + w); ui.painter().hline(underline, underline_y, stroke); sel as usize } fn get_label_width(ui: &mut egui::Ui, text: &str) -> f32 { let font_id = egui::FontId::default(); let galley = ui.fonts(|r| r.layout_no_wrap(text.to_string(), font_id, egui::Color32::WHITE)); galley.rect.width() } fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef { let midpoint = (range.min + range.max) / 2.0; let half_width = width / 2.0; let min = midpoint - half_width; let max = midpoint + half_width; egui::Rangef::new(min, max) } pub struct TimelineTabView<'a> { tab: &'a TimelineTab, reversed: bool, note_options: NoteOptions, txn: &'a Transaction, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, is_muted: &'a MuteFun, } impl<'a> TimelineTabView<'a> { #[allow(clippy::too_many_arguments)] pub fn new( tab: &'a TimelineTab, reversed: bool, note_options: NoteOptions, txn: &'a Transaction, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, is_muted: &'a MuteFun, ) -> Self { Self { tab, reversed, txn, note_options, ndb, note_cache, img_cache, is_muted, } } pub fn show(&mut self, ui: &mut egui::Ui) -> Option { let mut action: Option = None; let len = self.tab.notes.len(); let is_muted = self.is_muted; self.tab .list .clone() .borrow_mut() .ui_custom_layout(ui, len, |ui, start_index| { ui.spacing_mut().item_spacing.y = 0.0; ui.spacing_mut().item_spacing.x = 4.0; let ind = if self.reversed { len - start_index - 1 } else { start_index }; let note_key = self.tab.notes[ind].key; let note = if let Ok(note) = self.ndb.get_note_by_key(self.txn, note_key) { note } else { warn!("failed to query note {:?}", note_key); return 0; }; // should we mute the thread? we might not have it! let muted = if let Ok(root_id) = root_note_id_from_selected_id(self.ndb, self.note_cache, self.txn, note.id()) { is_muted(¬e, root_id.bytes()) } else { false }; if !muted { ui::padding(8.0, ui, |ui| { let resp = ui::NoteView::new( self.ndb, self.note_cache, self.img_cache, ¬e, self.note_options, ) .show(ui); if let Some(note_action) = resp.action { action = Some(note_action) } if let Some(context) = resp.context_selection { context.process(ui, ¬e); } }); ui::hline(ui); } 1 }); action } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/accounts.rs`: ```rs use crate::colors::PINK; use egui::{ Align, Button, Frame, Image, InnerResponse, Layout, RichText, ScrollArea, Ui, UiBuilder, Vec2, }; use nostrdb::{Ndb, Transaction}; use notedeck::{Accounts, Images}; use super::profile::preview::SimpleProfilePreview; pub struct AccountsView<'a> { ndb: &'a Ndb, accounts: &'a Accounts, img_cache: &'a mut Images, } #[derive(Clone, Debug)] pub enum AccountsViewResponse { SelectAccount(usize), RemoveAccount(usize), RouteToLogin, } #[derive(Debug)] enum ProfilePreviewAction { RemoveAccount, SwitchTo, } impl<'a> AccountsView<'a> { pub fn new(ndb: &'a Ndb, accounts: &'a Accounts, img_cache: &'a mut Images) -> Self { AccountsView { ndb, accounts, img_cache, } } pub fn ui(&mut self, ui: &mut Ui) -> InnerResponse> { Frame::none().outer_margin(12.0).show(ui, |ui| { if let Some(resp) = Self::top_section_buttons_widget(ui).inner { return Some(resp); } ui.add_space(8.0); scroll_area() .show(ui, |ui| { Self::show_accounts(ui, self.accounts, self.ndb, self.img_cache) }) .inner }) } fn show_accounts( ui: &mut Ui, accounts: &Accounts, ndb: &Ndb, img_cache: &mut Images, ) -> Option { let mut return_op: Option = None; ui.allocate_ui_with_layout( Vec2::new(ui.available_size_before_wrap().x, 32.0), Layout::top_down(egui::Align::Min), |ui| { let txn = if let Ok(txn) = Transaction::new(ndb) { txn } else { return; }; for i in 0..accounts.num_accounts() { let (account_pubkey, has_nsec) = match accounts.get_account(i) { Some(acc) => (acc.pubkey.bytes(), acc.secret_key.is_some()), None => continue, }; let profile = ndb.get_profile_by_pubkey(&txn, account_pubkey).ok(); let is_selected = if let Some(selected) = accounts.get_selected_account_index() { i == selected } else { false }; let profile_peview_view = { let max_size = egui::vec2(ui.available_width(), 77.0); let resp = ui.allocate_response(max_size, egui::Sense::click()); ui.allocate_new_ui(UiBuilder::new().max_rect(resp.rect), |ui| { let preview = SimpleProfilePreview::new(profile.as_ref(), img_cache, has_nsec); show_profile_card(ui, preview, max_size, is_selected, resp) }) .inner }; if let Some(op) = profile_peview_view { return_op = Some(match op { ProfilePreviewAction::SwitchTo => { AccountsViewResponse::SelectAccount(i) } ProfilePreviewAction::RemoveAccount => { AccountsViewResponse::RemoveAccount(i) } }); } } }, ); return_op } fn top_section_buttons_widget( ui: &mut egui::Ui, ) -> InnerResponse> { ui.allocate_ui_with_layout( Vec2::new(ui.available_size_before_wrap().x, 32.0), Layout::left_to_right(egui::Align::Center), |ui| { if ui.add(add_account_button()).clicked() { Some(AccountsViewResponse::RouteToLogin) } else { None } }, ) } } fn show_profile_card( ui: &mut egui::Ui, preview: SimpleProfilePreview, max_size: egui::Vec2, is_selected: bool, card_resp: egui::Response, ) -> Option { let mut op: Option = None; ui.add_sized(max_size, |ui: &mut egui::Ui| { let mut frame = Frame::none(); if is_selected || card_resp.hovered() { frame = frame.fill(ui.visuals().noninteractive().weak_bg_fill); } if is_selected { frame = frame.stroke(ui.visuals().noninteractive().fg_stroke); } frame .rounding(8.0) .inner_margin(8.0) .show(ui, |ui| { ui.horizontal(|ui| { ui.add(preview); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if card_resp.clicked() { op = Some(ProfilePreviewAction::SwitchTo); } if ui .add_sized(egui::Vec2::new(84.0, 32.0), sign_out_button()) .clicked() { op = Some(ProfilePreviewAction::RemoveAccount) } }); }); }) .response }); ui.add_space(8.0); op } fn scroll_area() -> ScrollArea { egui::ScrollArea::vertical() .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .auto_shrink([false; 2]) } fn add_account_button() -> Button<'static> { let img_data = egui::include_image!("../../../../assets/icons/add_account_icon_4x.png"); let img = Image::new(img_data).fit_to_exact_size(Vec2::new(48.0, 48.0)); Button::image_and_text( img, RichText::new(" Add account") .size(16.0) // TODO: this color should not be hard coded. Find some way to add it to the visuals .color(PINK), ) .frame(false) } fn sign_out_button() -> egui::Button<'static> { egui::Button::new(RichText::new("Sign out")) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/add_column.rs`: ```rs use core::f32; use std::collections::HashMap; use egui::{ pos2, vec2, Align, Button, Color32, FontId, Id, ImageSource, Margin, Pos2, Rect, RichText, Separator, Ui, Vec2, Widget, }; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; use tracing::error; use crate::{ login_manager::AcquireKeyState, route::Route, timeline::{kind::ListKind, PubkeySource, TimelineKind}, ui::anim::ICON_EXPANSION_MULTIPLE, Damus, }; use notedeck::{AppContext, Images, NotedeckTextStyle, UserAccount}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; use super::{anim::AnimationHelper, padding, ProfilePreview}; pub enum AddColumnResponse { Timeline(TimelineKind), UndecidedNotification, ExternalNotification, Hashtag, Algo(AlgoOption), UndecidedIndividual, ExternalIndividual, } pub enum NotificationColumnType { Contacts, External, } #[derive(Clone, Debug)] pub enum Decision { Undecided, Decided(T), } #[derive(Clone, Debug)] pub enum AlgoOption { LastPerPubkey(Decision), } #[derive(Clone, Debug)] enum AddColumnOption { Universe, UndecidedNotification, ExternalNotification, Algo(AlgoOption), Notification(PubkeySource), Contacts(PubkeySource), UndecidedHashtag, UndecidedIndividual, ExternalIndividual, Individual(PubkeySource), } #[derive(Clone, Copy, Eq, PartialEq, Debug, Default)] pub enum AddAlgoRoute { #[default] Base, LastPerPubkey, } #[derive(Clone, Copy, Eq, PartialEq, Debug)] pub enum AddColumnRoute { Base, UndecidedNotification, ExternalNotification, Hashtag, Algo(AddAlgoRoute), UndecidedIndividual, ExternalIndividual, } // Parser for the common case without any payloads fn parse_column_route<'a>( parser: &mut TokenParser<'a>, route: AddColumnRoute, ) -> Result> { parser.parse_all(|p| { for token in route.tokens() { p.parse_token(token)?; } Ok(route) }) } impl AddColumnRoute { /// Route tokens use in both serialization and deserialization fn tokens(&self) -> &'static [&'static str] { match self { Self::Base => &["column"], Self::UndecidedNotification => &["column", "notification_selection"], Self::ExternalNotification => &["column", "external_notif_selection"], Self::UndecidedIndividual => &["column", "individual_selection"], Self::ExternalIndividual => &["column", "external_individual_selection"], Self::Hashtag => &["column", "hashtag"], Self::Algo(AddAlgoRoute::Base) => &["column", "algo_selection"], Self::Algo(AddAlgoRoute::LastPerPubkey) => { &["column", "algo_selection", "last_per_pubkey"] } // NOTE!!! When adding to this, update the parser for TokenSerializable below } } } impl TokenSerializable for AddColumnRoute { fn serialize_tokens(&self, writer: &mut TokenWriter) { for token in self.tokens() { writer.write_token(token); } } fn parse_from_tokens<'a>(parser: &mut TokenParser<'a>) -> Result> { parser.peek_parse_token("column")?; TokenParser::alt( parser, &[ |p| parse_column_route(p, AddColumnRoute::Base), |p| parse_column_route(p, AddColumnRoute::UndecidedNotification), |p| parse_column_route(p, AddColumnRoute::ExternalNotification), |p| parse_column_route(p, AddColumnRoute::UndecidedIndividual), |p| parse_column_route(p, AddColumnRoute::ExternalIndividual), |p| parse_column_route(p, AddColumnRoute::Hashtag), |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::Base)), |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey)), ], ) } } impl AddColumnOption { pub fn take_as_response(self, cur_account: &UserAccount) -> AddColumnResponse { match self { AddColumnOption::Algo(algo_option) => AddColumnResponse::Algo(algo_option), AddColumnOption::Universe => AddColumnResponse::Timeline(TimelineKind::Universe), AddColumnOption::Notification(pubkey) => AddColumnResponse::Timeline( TimelineKind::Notifications(*pubkey.as_pubkey(&cur_account.pubkey)), ), AddColumnOption::UndecidedNotification => AddColumnResponse::UndecidedNotification, AddColumnOption::Contacts(pk_src) => AddColumnResponse::Timeline( TimelineKind::contact_list(*pk_src.as_pubkey(&cur_account.pubkey)), ), AddColumnOption::ExternalNotification => AddColumnResponse::ExternalNotification, AddColumnOption::UndecidedHashtag => AddColumnResponse::Hashtag, AddColumnOption::UndecidedIndividual => AddColumnResponse::UndecidedIndividual, AddColumnOption::ExternalIndividual => AddColumnResponse::ExternalIndividual, AddColumnOption::Individual(pubkey_source) => AddColumnResponse::Timeline( TimelineKind::profile(*pubkey_source.as_pubkey(&cur_account.pubkey)), ), } } } pub struct AddColumnView<'a> { key_state_map: &'a mut HashMap, ndb: &'a Ndb, img_cache: &'a mut Images, cur_account: Option<&'a UserAccount>, } impl<'a> AddColumnView<'a> { pub fn new( key_state_map: &'a mut HashMap, ndb: &'a Ndb, img_cache: &'a mut Images, cur_account: Option<&'a UserAccount>, ) -> Self { Self { key_state_map, ndb, img_cache, cur_account, } } pub fn ui(&mut self, ui: &mut Ui) -> Option { let mut selected_option: Option = None; for column_option_data in self.get_base_options() { let option = column_option_data.option.clone(); if self.column_option_ui(ui, column_option_data).clicked() { selected_option = self.cur_account.map(|acct| option.take_as_response(acct)) } ui.add(Separator::default().spacing(0.0)); } selected_option } fn notifications_ui(&mut self, ui: &mut Ui) -> Option { let mut selected_option: Option = None; for column_option_data in self.get_notifications_options() { let option = column_option_data.option.clone(); if self.column_option_ui(ui, column_option_data).clicked() { selected_option = self.cur_account.map(|acct| option.take_as_response(acct)); } ui.add(Separator::default().spacing(0.0)); } selected_option } fn external_notification_ui(&mut self, ui: &mut Ui) -> Option { let id = ui.id().with("external_notif"); self.external_ui(ui, id, |pubkey| { AddColumnOption::Notification(PubkeySource::Explicit(pubkey)) }) } fn algo_last_per_pk_ui( &mut self, ui: &mut Ui, deck_author: Pubkey, ) -> Option { let algo_option = ColumnOptionData { title: "Contact List", description: "Source the last note for each user in your contact list", icon: egui::include_image!("../../../../assets/icons/home_icon_dark_4x.png"), option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Decided( ListKind::contact_list(deck_author), ))), }; let option = algo_option.option.clone(); if self.column_option_ui(ui, algo_option).clicked() { self.cur_account.map(|acct| option.take_as_response(acct)) } else { None } } fn algo_ui(&mut self, ui: &mut Ui) -> Option { let algo_option = ColumnOptionData { title: "Last Note per User", description: "Show the last note for each user from a list", icon: egui::include_image!("../../../../assets/icons/algo.png"), option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), }; let option = algo_option.option.clone(); if self.column_option_ui(ui, algo_option).clicked() { self.cur_account.map(|acct| option.take_as_response(acct)) } else { None } } fn individual_ui(&mut self, ui: &mut Ui) -> Option { let mut selected_option: Option = None; for column_option_data in self.get_individual_options() { let option = column_option_data.option.clone(); if self.column_option_ui(ui, column_option_data).clicked() { selected_option = self.cur_account.map(|acct| option.take_as_response(acct)); } ui.add(Separator::default().spacing(0.0)); } selected_option } fn external_individual_ui(&mut self, ui: &mut Ui) -> Option { let id = ui.id().with("external_individual"); self.external_ui(ui, id, |pubkey| { AddColumnOption::Individual(PubkeySource::Explicit(pubkey)) }) } fn external_ui( &mut self, ui: &mut Ui, id: egui::Id, to_option: fn(Pubkey) -> AddColumnOption, ) -> Option { padding(16.0, ui, |ui| { let key_state = self.key_state_map.entry(id).or_default(); let text_edit = key_state.get_acquire_textedit(|text| { egui::TextEdit::singleline(text) .hint_text( RichText::new("Enter the user's key (npub, hex, nip05) here...") .text_style(NotedeckTextStyle::Body.text_style()), ) .vertical_align(Align::Center) .desired_width(f32::INFINITY) .min_size(Vec2::new(0.0, 40.0)) .margin(Margin::same(12.0)) }); ui.add(text_edit); key_state.handle_input_change_after_acquire(); key_state.loading_and_error_ui(ui); if key_state.get_login_keypair().is_none() && ui.add(find_user_button()).clicked() { key_state.apply_acquire(); } let resp = if let Some(keypair) = key_state.get_login_keypair() { { let txn = Transaction::new(self.ndb).expect("txn"); if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, keypair.pubkey.bytes()) { egui::Frame::window(ui.style()) .outer_margin(Margin { left: 4.0, right: 4.0, top: 12.0, bottom: 32.0, }) .show(ui, |ui| { ProfilePreview::new(&profile, self.img_cache).ui(ui); }); } } if ui.add(add_column_button()).clicked() { self.cur_account .map(|acc| to_option(keypair.pubkey).take_as_response(acc)) } else { None } } else { None }; if resp.is_some() { self.key_state_map.remove(&id); }; resp }) .inner } fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { let icon_padding = 8.0; let min_icon_width = 32.0; let height_padding = 12.0; let inter_text_padding = 4.0; // Padding between title and description let max_width = ui.available_width(); let title_style = NotedeckTextStyle::Body; let desc_style = NotedeckTextStyle::Button; let title_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &title_style); let desc_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &desc_style); let max_height = { let max_wrap_width = max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE)); let title_max_font = FontId::new( title_min_font_size * ICON_EXPANSION_MULTIPLE, title_style.font_family(), ); let desc_max_font = FontId::new( desc_min_font_size * ICON_EXPANSION_MULTIPLE, desc_style.font_family(), ); let max_desc_galley = ui.fonts(|f| { f.layout( data.description.to_string(), desc_max_font, ui.style().visuals.noninteractive().fg_stroke.color, max_wrap_width, ) }); let max_title_galley = ui.fonts(|f| { f.layout( data.title.to_string(), title_max_font, Color32::WHITE, max_wrap_width, ) }); let desc_font_max_size = max_desc_galley.rect.height(); let title_font_max_size = max_title_galley.rect.height(); title_font_max_size + inter_text_padding + desc_font_max_size + (2.0 * height_padding) }; let helper = AnimationHelper::new(ui, data.title, vec2(max_width, max_height)); let animation_rect = helper.get_animation_rect(); let cur_icon_width = helper.scale_1d_pos(min_icon_width); let painter = ui.painter_at(animation_rect); let cur_icon_size = vec2(cur_icon_width, cur_icon_width); let cur_icon_x_pos = animation_rect.left() + icon_padding + (cur_icon_width / 2.0); let title_cur_font = FontId::new( helper.scale_1d_pos(title_min_font_size), title_style.font_family(), ); let desc_cur_font = FontId::new( helper.scale_1d_pos(desc_min_font_size), desc_style.font_family(), ); let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0)); let text_color = ui.style().visuals.text_color(); let fallback_color = ui.style().visuals.noninteractive().fg_stroke.color; let title_galley = painter.layout( data.title.to_string(), title_cur_font, text_color, wrap_width, ); let desc_galley = painter.layout( data.description.to_string(), desc_cur_font, fallback_color, wrap_width, ); let total_content_height = title_galley.rect.height() + inter_text_padding + desc_galley.rect.height(); let cur_height_padding = (animation_rect.height() - total_content_height) / 2.0; let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding; let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding); let desc_corner_pos = Pos2::new( corner_x_pos, title_corner_pos.y + title_galley.rect.height() + inter_text_padding, ); let icon_cur_y = animation_rect.top() + cur_height_padding + (total_content_height / 2.0); let icon_img = egui::Image::new(data.icon).fit_to_exact_size(cur_icon_size); let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size); icon_img.paint_at(ui, icon_rect); painter.galley(title_corner_pos, title_galley, text_color); painter.galley(desc_corner_pos, desc_galley, fallback_color); helper.take_animation_response() } fn get_base_options(&self) -> Vec { let mut vec = Vec::new(); vec.push(ColumnOptionData { title: "Universe", description: "See the whole nostr universe", icon: egui::include_image!("../../../../assets/icons/universe_icon_dark_4x.png"), option: AddColumnOption::Universe, }); if let Some(acc) = self.cur_account { let source = if acc.secret_key.is_some() { PubkeySource::DeckAuthor } else { PubkeySource::Explicit(acc.pubkey) }; vec.push(ColumnOptionData { title: "Contacts", description: "See notes from your contacts", icon: egui::include_image!("../../../../assets/icons/home_icon_dark_4x.png"), option: AddColumnOption::Contacts(source), }); } vec.push(ColumnOptionData { title: "Notifications", description: "Stay up to date with notifications and mentions", icon: egui::include_image!("../../../../assets/icons/notifications_icon_dark_4x.png"), option: AddColumnOption::UndecidedNotification, }); vec.push(ColumnOptionData { title: "Hashtag", description: "Stay up to date with a certain hashtag", icon: egui::include_image!("../../../../assets/icons/hashtag_icon_4x.png"), option: AddColumnOption::UndecidedHashtag, }); vec.push(ColumnOptionData { title: "Individual", description: "Stay up to date with someone's notes & replies", icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"), option: AddColumnOption::UndecidedIndividual, }); vec.push(ColumnOptionData { title: "Algo", description: "Algorithmic feeds to aid in note discovery", icon: egui::include_image!("../../../../assets/icons/algo.png"), option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), }); vec } fn get_notifications_options(&self) -> Vec { let mut vec = Vec::new(); if let Some(acc) = self.cur_account { let source = if acc.secret_key.is_some() { PubkeySource::DeckAuthor } else { PubkeySource::Explicit(acc.pubkey) }; vec.push(ColumnOptionData { title: "Your Notifications", description: "Stay up to date with your notifications and mentions", icon: egui::include_image!( "../../../../assets/icons/notifications_icon_dark_4x.png" ), option: AddColumnOption::Notification(source), }); } vec.push(ColumnOptionData { title: "Someone else's Notifications", description: "Stay up to date with someone else's notifications and mentions", icon: egui::include_image!("../../../../assets/icons/notifications_icon_dark_4x.png"), option: AddColumnOption::ExternalNotification, }); vec } fn get_individual_options(&self) -> Vec { let mut vec = Vec::new(); if let Some(acc) = self.cur_account { let source = if acc.secret_key.is_some() { PubkeySource::DeckAuthor } else { PubkeySource::Explicit(acc.pubkey) }; vec.push(ColumnOptionData { title: "Your Notes", description: "Keep track of your notes & replies", icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"), option: AddColumnOption::Individual(source), }); } vec.push(ColumnOptionData { title: "Someone else's Notes", description: "Stay up to date with someone else's notes & replies", icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"), option: AddColumnOption::ExternalIndividual, }); vec } } fn find_user_button() -> impl Widget { sized_button("Find User") } fn add_column_button() -> impl Widget { sized_button("Add") } pub(crate) fn sized_button(text: &str) -> impl Widget + '_ { move |ui: &mut egui::Ui| -> egui::Response { let painter = ui.painter(); let galley = painter.layout( text.to_owned(), NotedeckTextStyle::Body.get_font_id(ui.ctx()), Color32::WHITE, ui.available_width(), ); ui.add_sized( galley.rect.expand2(vec2(16.0, 8.0)).size(), Button::new(galley).rounding(8.0).fill(crate::colors::PINK), ) } } struct ColumnOptionData { title: &'static str, description: &'static str, icon: ImageSource<'static>, option: AddColumnOption, } pub fn render_add_column_routes( ui: &mut egui::Ui, app: &mut Damus, ctx: &mut AppContext<'_>, col: usize, route: &AddColumnRoute, ) { let mut add_column_view = AddColumnView::new( &mut app.view_state.id_state_map, ctx.ndb, ctx.img_cache, ctx.accounts.get_selected_account(), ); let resp = match route { AddColumnRoute::Base => add_column_view.ui(ui), AddColumnRoute::Algo(r) => match r { AddAlgoRoute::Base => add_column_view.algo_ui(ui), AddAlgoRoute::LastPerPubkey => { if let Some(deck_author) = ctx.accounts.get_selected_account() { add_column_view.algo_last_per_pk_ui(ui, deck_author.pubkey) } else { None } } }, AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), AddColumnRoute::Hashtag => hashtag_ui(ui, &mut app.view_state.id_string_map), AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui), AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui), }; if let Some(resp) = resp { match resp { AddColumnResponse::Timeline(timeline_kind) => 'leave: { let txn = Transaction::new(ctx.ndb).unwrap(); let mut timeline = if let Some(timeline) = timeline_kind.into_timeline(&txn, ctx.ndb) { timeline } else { error!("Could not convert column response to timeline"); break 'leave; }; crate::timeline::setup_new_timeline( &mut timeline, ctx.ndb, &mut app.subscriptions, ctx.pool, ctx.note_cache, app.since_optimize, ); app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .route_to_replaced(Route::timeline(timeline.kind.clone())); app.timeline_cache .timelines .insert(timeline.kind.clone(), timeline); } AddColumnResponse::Algo(algo_option) => match algo_option { // If we are undecided, we simply route to the LastPerPubkey // algo route selection AlgoOption::LastPerPubkey(Decision::Undecided) => { app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .route_to(Route::AddColumn(AddColumnRoute::Algo( AddAlgoRoute::LastPerPubkey, ))); } // We have a decision on where we want the last per pubkey // source to be, so let;s create a timeline from that and // add it to our list of timelines AlgoOption::LastPerPubkey(Decision::Decided(list_kind)) => { let maybe_timeline = { let txn = Transaction::new(ctx.ndb).unwrap(); TimelineKind::last_per_pubkey(list_kind).into_timeline(&txn, ctx.ndb) }; if let Some(mut timeline) = maybe_timeline { crate::timeline::setup_new_timeline( &mut timeline, ctx.ndb, &mut app.subscriptions, ctx.pool, ctx.note_cache, app.since_optimize, ); app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .route_to_replaced(Route::timeline(timeline.kind.clone())); app.timeline_cache .timelines .insert(timeline.kind.clone(), timeline); } else { // we couldn't fetch the timeline yet... let's let // the user know ? // TODO: spin off the list search here instead ui.label(format!("error: could not find {:?}", list_kind)); } } }, AddColumnResponse::UndecidedNotification => { app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .route_to(Route::AddColumn(AddColumnRoute::UndecidedNotification)); } AddColumnResponse::ExternalNotification => { app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .route_to(crate::route::Route::AddColumn( AddColumnRoute::ExternalNotification, )); } AddColumnResponse::Hashtag => { app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag)); } AddColumnResponse::UndecidedIndividual => { app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .route_to(crate::route::Route::AddColumn( AddColumnRoute::UndecidedIndividual, )); } AddColumnResponse::ExternalIndividual => { app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .route_to(crate::route::Route::AddColumn( AddColumnRoute::ExternalIndividual, )); } }; } } pub fn hashtag_ui( ui: &mut Ui, id_string_map: &mut HashMap, ) -> Option { padding(16.0, ui, |ui| { let id = ui.id().with("hashtag)"); let text_buffer = id_string_map.entry(id).or_default(); let text_edit = egui::TextEdit::singleline(text_buffer) .hint_text( RichText::new("Enter the desired hashtag here") .text_style(NotedeckTextStyle::Body.text_style()), ) .vertical_align(Align::Center) .desired_width(f32::INFINITY) .min_size(Vec2::new(0.0, 40.0)) .margin(Margin::same(12.0)); ui.add(text_edit); ui.add_space(8.0); if ui .add_sized(egui::vec2(50.0, 40.0), add_column_button()) .clicked() { let resp = AddColumnResponse::Timeline(TimelineKind::Hashtag(sanitize_hashtag(text_buffer))); id_string_map.remove(&id); Some(resp) } else { None } }) .inner } fn sanitize_hashtag(raw_hashtag: &str) -> String { raw_hashtag.replace("#", "") } #[cfg(test)] mod tests { use super::*; #[test] fn test_column_serialize() { use super::{AddAlgoRoute, AddColumnRoute}; { let data_str = "column:algo_selection:last_per_pubkey"; let data = &data_str.split(":").collect::>(); let mut token_writer = TokenWriter::default(); let mut parser = TokenParser::new(&data); let parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap(); let expected = AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey); parsed.serialize_tokens(&mut token_writer); assert_eq!(expected, parsed); assert_eq!(token_writer.str(), data_str); } { let data_str = "column"; let mut token_writer = TokenWriter::default(); let data: &[&str] = &[data_str]; let mut parser = TokenParser::new(data); let parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap(); let expected = AddColumnRoute::Base; parsed.serialize_tokens(&mut token_writer); assert_eq!(expected, parsed); assert_eq!(token_writer.str(), data_str); } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/preview.rs`: ```rs pub struct PreviewConfig { pub is_mobile: bool, } pub trait Preview { type Prev: notedeck::App; fn preview(cfg: PreviewConfig) -> Self::Prev; } pub struct PreviewApp { view: Box, } impl PreviewApp { pub fn new(view: impl notedeck::App + 'static) -> PreviewApp { let view = Box::new(view); Self { view } } } impl notedeck::App for PreviewApp { fn update(&mut self, app_ctx: &mut notedeck::AppContext<'_>, ui: &mut egui::Ui) { self.view.update(app_ctx, ui); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/column/header.rs`: ```rs use crate::colors; use crate::column::ColumnsAction; use crate::nav::RenderNavAction; use crate::nav::SwitchingAction; use crate::{ column::Columns, route::Route, timeline::{ColumnTitle, TimelineKind}, ui::{ self, anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, }, }; use egui::Margin; use egui::{RichText, Stroke, UiBuilder}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; use notedeck::{Images, NotedeckTextStyle}; pub struct NavTitle<'a> { ndb: &'a Ndb, img_cache: &'a mut Images, columns: &'a Columns, routes: &'a [Route], col_id: usize, } impl<'a> NavTitle<'a> { pub fn new( ndb: &'a Ndb, img_cache: &'a mut Images, columns: &'a Columns, routes: &'a [Route], col_id: usize, ) -> Self { NavTitle { ndb, img_cache, columns, routes, col_id, } } pub fn show(&mut self, ui: &mut egui::Ui) -> Option { ui::padding(8.0, ui, |ui| { let mut rect = ui.available_rect_before_wrap(); rect.set_height(48.0); let mut child_ui = ui.new_child( UiBuilder::new() .max_rect(rect) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let r = self.title_bar(&mut child_ui); ui.advance_cursor_after_rect(rect); r }) .inner } fn title_bar(&mut self, ui: &mut egui::Ui) -> Option { let item_spacing = 8.0; ui.spacing_mut().item_spacing.x = item_spacing; let chev_x = 8.0; let back_button_resp = prev(self.routes).map(|r| self.back_button(ui, r, egui::Vec2::new(chev_x, 15.0))); if let Some(back_resp) = &back_button_resp { if back_resp.hovered() || back_resp.clicked() { ui::show_pointer(ui); } } else { // add some space where chevron would have been. this makes the ui // less bumpy when navigating ui.add_space(chev_x + item_spacing); } let title_resp = self.title(ui, self.routes.last().unwrap(), back_button_resp.is_some()); if let Some(resp) = title_resp { match resp { TitleResponse::RemoveColumn => Some(RenderNavAction::RemoveColumn), TitleResponse::MoveColumn(to_index) => { let from = self.col_id; Some(RenderNavAction::SwitchingAction(SwitchingAction::Columns( ColumnsAction::Switch(from, to_index), ))) } } } else if back_button_resp.is_some_and(|r| r.clicked()) { Some(RenderNavAction::Back) } else { None } } fn back_button( &mut self, ui: &mut egui::Ui, prev: &Route, chev_size: egui::Vec2, ) -> egui::Response { //let color = ui.visuals().hyperlink_color; let color = ui.style().visuals.noninteractive().fg_stroke.color; //let spacing_prev = ui.spacing().item_spacing.x; //ui.spacing_mut().item_spacing.x = 0.0; let chev_resp = chevron(ui, 2.0, chev_size, Stroke::new(2.0, color)); //ui.spacing_mut().item_spacing.x = spacing_prev; // NOTE(jb55): include graphic in back label as well because why // not it looks cool self.title_pfp(ui, prev, 32.0); let column_title = prev.title(); let back_resp = match &column_title { ColumnTitle::Simple(title) => ui.add(Self::back_label(title, color)), ColumnTitle::NeedsDb(need_db) => { let txn = Transaction::new(self.ndb).unwrap(); let title = need_db.title(&txn, self.ndb); ui.add(Self::back_label(title, color)) } }; back_resp.union(chev_resp) } fn back_label(title: &str, color: egui::Color32) -> egui::Label { egui::Label::new( RichText::new(title.to_string()) .color(color) .text_style(NotedeckTextStyle::Body.text_style()), ) .selectable(false) .sense(egui::Sense::click()) } fn delete_column_button(&self, ui: &mut egui::Ui, icon_width: f32) -> egui::Response { let img_size = 16.0; let max_size = icon_width * ICON_EXPANSION_MULTIPLE; let img_data = if ui.visuals().dark_mode { egui::include_image!("../../../../../assets/icons/column_delete_icon_4x.png") } else { egui::include_image!("../../../../../assets/icons/column_delete_icon_light_4x.png") }; let img = egui::Image::new(img_data).max_width(img_size); let helper = AnimationHelper::new(ui, "delete-column-button", egui::vec2(max_size, max_size)); let cur_img_size = helper.scale_1d_pos_min_max(0.0, img_size); let animation_rect = helper.get_animation_rect(); let animation_resp = helper.take_animation_response(); img.paint_at(ui, animation_rect.shrink((max_size - cur_img_size) / 2.0)); animation_resp } fn delete_button_section(&self, ui: &mut egui::Ui) -> bool { let id = ui.id().with("title"); let delete_button_resp = self.delete_column_button(ui, 32.0); if delete_button_resp.clicked() { ui.data_mut(|d| d.insert_temp(id, true)); } if ui.data_mut(|d| *d.get_temp_mut_or_default(id)) { let mut confirm_pressed = false; delete_button_resp.show_tooltip_ui(|ui| { let confirm_resp = ui.button("Confirm"); if confirm_resp.clicked() { confirm_pressed = true; } if confirm_resp.clicked() || ui.button("Cancel").clicked() { ui.data_mut(|d| d.insert_temp(id, false)); } }); if !confirm_pressed && delete_button_resp.clicked_elsewhere() { ui.data_mut(|d| d.insert_temp(id, false)); } confirm_pressed } else { false } } // returns the column index to switch to, if any fn move_button_section(&mut self, ui: &mut egui::Ui) -> Option { let cur_id = ui.id().with("move"); let mut move_resp = ui.add(grab_button()); // showing the hover text while showing the move tooltip causes some weird visuals if ui.data(|d| d.get_temp::(cur_id).is_none()) { move_resp = move_resp.on_hover_text("Moves this column to another positon"); } if move_resp.clicked() { ui.data_mut(|d| { if let Some(val) = d.get_temp::(cur_id) { if val { d.remove_temp::(cur_id); } else { d.insert_temp(cur_id, true); } } else { d.insert_temp(cur_id, true); } }); } else if move_resp.hovered() { ui::show_pointer(ui); } ui.data(|d| d.get_temp(cur_id)).and_then(|val| { if val { let resp = self.add_move_tooltip(cur_id, &move_resp); if move_resp.clicked_elsewhere() || resp.is_some() { ui.data_mut(|d| d.remove_temp::(cur_id)); } resp } else { None } }) } fn move_tooltip_col_presentation(&mut self, ui: &mut egui::Ui, col: usize) -> egui::Response { ui.horizontal(|ui| { self.title_presentation(ui, self.columns.column(col).router().top(), 32.0); }) .response } fn add_move_tooltip(&mut self, id: egui::Id, move_resp: &egui::Response) -> Option { let mut inner_resp = None; move_resp.show_tooltip_ui(|ui| { // dnd frame color workaround ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::default(); let x_range = ui.available_rect_before_wrap().x_range(); let is_dragging = egui::DragAndDrop::payload::(ui.ctx()).is_some(); // must be outside ui.dnd_drop_zone to capture properly let (_, _) = ui.dnd_drop_zone::( egui::Frame::none().inner_margin(Margin::same(8.0)), |ui| { let distances: Vec<(egui::Response, f32)> = self.collect_column_distances(ui, id); if let Some((closest_index, closest_resp, distance)) = self.find_closest_column(&distances) { if is_dragging && closest_index != self.col_id { if self.should_draw_hint(closest_index, distance) { ui.painter().hline( x_range, self.calculate_hint_y( &distances, closest_resp, closest_index, distance, ), egui::Stroke::new(1.0, ui.visuals().text_color()), ); } if ui.input(|i| i.pointer.any_released()) { inner_resp = Some(self.calculate_new_index(closest_index, distance)); } } } }, ); }); inner_resp } fn collect_column_distances( &mut self, ui: &mut egui::Ui, id: egui::Id, ) -> Vec<(egui::Response, f32)> { let y_margin = 4.0; let item_frame = egui::Frame::none() .rounding(egui::Rounding::same(8.0)) .inner_margin(Margin::symmetric(8.0, y_margin)); (0..self.columns.num_columns()) .filter_map(|col| { let item_id = id.with(col); let col_resp = if col == self.col_id { ui.dnd_drag_source(item_id, col, |ui| { item_frame .stroke(egui::Stroke::new(2.0, colors::PINK)) .fill(ui.visuals().widgets.noninteractive.bg_stroke.color) .show(ui, |ui| self.move_tooltip_col_presentation(ui, col)); }) .response } else { item_frame .show(ui, |ui| { self.move_tooltip_col_presentation(ui, col) .on_hover_cursor(egui::CursorIcon::NotAllowed) }) .response }; ui.input(|i| i.pointer.interact_pos()).map(|pointer| { let distance = pointer.y - col_resp.rect.center().y; (col_resp, distance) }) }) .collect() } fn find_closest_column( &'a self, distances: &'a [(egui::Response, f32)], ) -> Option<(usize, &'a egui::Response, f32)> { distances .iter() .enumerate() .min_by(|(_, (_, dist1)), (_, (_, dist2))| { dist1.abs().partial_cmp(&dist2.abs()).unwrap() }) .filter(|(index, (_, distance))| { (index + 1 != self.col_id && *distance > 0.0) || (index.saturating_sub(1) != self.col_id && *distance < 0.0) }) .map(|(index, (resp, dist))| (index, resp, *dist)) } fn should_draw_hint(&self, closest_index: usize, distance: f32) -> bool { let is_above = distance < 0.0; (is_above && closest_index.saturating_sub(1) != self.col_id) || (!is_above && closest_index + 1 != self.col_id) } fn calculate_new_index(&self, closest_index: usize, distance: f32) -> usize { let moving_up = self.col_id > closest_index; match (distance < 0.0, moving_up) { (true, true) | (false, false) => closest_index, (true, false) => closest_index.saturating_sub(1), (false, true) => closest_index + 1, } } fn calculate_hint_y( &self, distances: &[(egui::Response, f32)], closest_resp: &egui::Response, closest_index: usize, distance: f32, ) -> f32 { let y_margin = 4.0; let offset = if distance < 0.0 { distances .get(closest_index.wrapping_sub(1)) .map(|(above_resp, _)| (closest_resp.rect.top() - above_resp.rect.bottom()) / 2.0) .unwrap_or(y_margin) } else { distances .get(closest_index + 1) .map(|(below_resp, _)| (below_resp.rect.top() - closest_resp.rect.bottom()) / 2.0) .unwrap_or(y_margin) }; if distance < 0.0 { closest_resp.rect.top() - offset } else { closest_resp.rect.bottom() + offset } } fn pubkey_pfp<'txn, 'me>( &'me mut self, txn: &'txn Transaction, pubkey: &[u8; 32], pfp_size: f32, ) -> Option> { self.ndb .get_profile_by_pubkey(txn, pubkey) .as_ref() .ok() .and_then(move |p| { Some(ui::ProfilePic::from_profile(self.img_cache, p)?.size(pfp_size)) }) } fn timeline_pfp(&mut self, ui: &mut egui::Ui, id: &TimelineKind, pfp_size: f32) { let txn = Transaction::new(self.ndb).unwrap(); if let Some(pfp) = id .pubkey() .and_then(|pk| self.pubkey_pfp(&txn, pk.bytes(), pfp_size)) { ui.add(pfp); } else { ui.add( ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size), ); } } fn title_pfp(&mut self, ui: &mut egui::Ui, top: &Route, pfp_size: f32) { match top { Route::Timeline(kind) => match kind { TimelineKind::Hashtag(_ht) => { ui.add( egui::Image::new(egui::include_image!( "../../../../../assets/icons/hashtag_icon_4x.png" )) .fit_to_exact_size(egui::vec2(pfp_size, pfp_size)), ); } TimelineKind::Profile(pubkey) => { self.show_profile(ui, pubkey, pfp_size); } TimelineKind::Thread(_) => { // no pfp for threads } TimelineKind::Universe | TimelineKind::Algo(_) | TimelineKind::Notifications(_) | TimelineKind::Generic(_) | TimelineKind::List(_) => { self.timeline_pfp(ui, kind, pfp_size); } }, Route::Reply(_) => {} Route::Quote(_) => {} Route::Accounts(_as) => {} Route::ComposeNote => {} Route::AddColumn(_add_col_route) => {} Route::Support => {} Route::Relays => {} Route::NewDeck => {} Route::EditDeck(_) => {} Route::EditProfile(pubkey) => { self.show_profile(ui, pubkey, pfp_size); } } } fn show_profile(&mut self, ui: &mut egui::Ui, pubkey: &Pubkey, pfp_size: f32) { let txn = Transaction::new(self.ndb).unwrap(); if let Some(pfp) = self.pubkey_pfp(&txn, pubkey.bytes(), pfp_size) { ui.add(pfp); } else { ui.add( ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size), ); }; } fn title_label_value(title: &str) -> egui::Label { egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style())) .selectable(false) } fn title_label(&self, ui: &mut egui::Ui, top: &Route) { let column_title = top.title(); match &column_title { ColumnTitle::Simple(title) => { ui.add(Self::title_label_value(title)); } ColumnTitle::NeedsDb(need_db) => { let txn = Transaction::new(self.ndb).unwrap(); let title = need_db.title(&txn, self.ndb); ui.add(Self::title_label_value(title)); } }; } fn title(&mut self, ui: &mut egui::Ui, top: &Route, navigating: bool) -> Option { if !navigating { self.title_presentation(ui, top, 32.0); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if navigating { self.title_presentation(ui, top, 32.0); None } else { let move_col = self.move_button_section(ui); let remove_col = self.delete_button_section(ui); if let Some(col) = move_col { Some(TitleResponse::MoveColumn(col)) } else if remove_col { Some(TitleResponse::RemoveColumn) } else { None } } }) .inner } fn title_presentation(&mut self, ui: &mut egui::Ui, top: &Route, pfp_size: f32) { self.title_pfp(ui, top, pfp_size); self.title_label(ui, top); } } enum TitleResponse { RemoveColumn, MoveColumn(usize), } fn prev(xs: &[R]) -> Option<&R> { xs.get(xs.len().checked_sub(2)?) } fn chevron( ui: &mut egui::Ui, pad: f32, size: egui::Vec2, stroke: impl Into, ) -> egui::Response { let (r, painter) = ui.allocate_painter(size, egui::Sense::click()); let min = r.rect.min; let max = r.rect.max; let apex = egui::Pos2::new(min.x + pad, min.y + size.y / 2.0); let top = egui::Pos2::new(max.x - pad, min.y + pad); let bottom = egui::Pos2::new(max.x - pad, max.y - pad); let stroke = stroke.into(); painter.line_segment([apex, top], stroke); painter.line_segment([apex, bottom], stroke); r } fn grab_button() -> impl egui::Widget { |ui: &mut egui::Ui| -> egui::Response { let max_size = egui::vec2(20.0, 20.0); let helper = AnimationHelper::new(ui, "grab", max_size); let painter = ui.painter_at(helper.get_animation_rect()); let min_circle_radius = 1.0; let cur_circle_radius = helper.scale_1d_pos(min_circle_radius); let horiz_spacing = 4.0; let vert_spacing = 10.0; let horiz_from_center = (horiz_spacing + min_circle_radius) / 2.0; let vert_from_center = (vert_spacing + min_circle_radius) / 2.0; let color = ui.style().visuals.noninteractive().fg_stroke.color; let middle_left = helper.scale_from_center(-horiz_from_center, 0.0); let middle_right = helper.scale_from_center(horiz_from_center, 0.0); let top_left = helper.scale_from_center(-horiz_from_center, -vert_from_center); let top_right = helper.scale_from_center(horiz_from_center, -vert_from_center); let bottom_left = helper.scale_from_center(-horiz_from_center, vert_from_center); let bottom_right = helper.scale_from_center(horiz_from_center, vert_from_center); painter.circle_filled(middle_left, cur_circle_radius, color); painter.circle_filled(middle_right, cur_circle_radius, color); painter.circle_filled(top_left, cur_circle_radius, color); painter.circle_filled(top_right, cur_circle_radius, color); painter.circle_filled(bottom_left, cur_circle_radius, color); painter.circle_filled(bottom_right, cur_circle_radius, color); helper.take_animation_response() } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/column/mod.rs`: ```rs mod header; pub use header::NavTitle; ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/edit_deck.rs`: ```rs use egui::Widget; use crate::deck_state::DeckState; use super::{ configure_deck::{ConfigureDeckResponse, ConfigureDeckView}, padding, }; pub struct EditDeckView<'a> { config_view: ConfigureDeckView<'a>, } static EDIT_TEXT: &str = "Edit Deck"; pub enum EditDeckResponse { Edit(ConfigureDeckResponse), Delete, } impl<'a> EditDeckView<'a> { pub fn new(state: &'a mut DeckState) -> Self { let config_view = ConfigureDeckView::new(state).with_create_text(EDIT_TEXT); Self { config_view } } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { let mut edit_deck_resp = None; padding(egui::Margin::symmetric(16.0, 4.0), ui, |ui| { if ui.add(delete_button()).clicked() { edit_deck_resp = Some(EditDeckResponse::Delete); } }); if let Some(config_resp) = self.config_view.ui(ui) { edit_deck_resp = Some(EditDeckResponse::Edit(config_resp)) } edit_deck_resp } } fn delete_button() -> impl Widget { |ui: &mut egui::Ui| { let size = egui::vec2(108.0, 40.0); ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| { ui.add( egui::Button::new("Delete Deck") .fill(ui.visuals().error_fg_color) .min_size(size), ) }) .inner } } mod preview { use crate::{ deck_state::DeckState, ui::{Preview, PreviewConfig}, }; use super::EditDeckView; use notedeck::{App, AppContext}; pub struct EditDeckPreview { state: DeckState, } impl EditDeckPreview { fn new() -> Self { let state = DeckState::default(); EditDeckPreview { state } } } impl App for EditDeckPreview { fn update(&mut self, _app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) { EditDeckView::new(&mut self.state).ui(ui); } } impl Preview for EditDeckView<'_> { type Prev = EditDeckPreview; fn preview(_cfg: PreviewConfig) -> Self::Prev { EditDeckPreview::new() } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/mod.rs`: ```rs pub mod account_login_view; pub mod accounts; pub mod add_column; pub mod anim; pub mod column; pub mod configure_deck; pub mod edit_deck; pub mod images; pub mod mention; pub mod note; pub mod preview; pub mod profile; pub mod relay; pub mod search_results; pub mod side_panel; pub mod support; pub mod thread; pub mod timeline; pub mod username; pub use accounts::AccountsView; pub use mention::Mention; pub use note::{NoteResponse, NoteView, PostReplyView, PostView}; pub use preview::{Preview, PreviewApp, PreviewConfig}; pub use profile::{ProfilePic, ProfilePreview}; pub use relay::RelayView; pub use side_panel::{DesktopSidePanel, SidePanelAction}; pub use thread::ThreadView; pub use timeline::TimelineView; pub use username::Username; use egui::Margin; /// This is kind of like the Widget trait but is meant for larger top-level /// views that are typically stateful. /// /// The Widget trait forces us to add mutable /// implementations at the type level, which screws us when generating Previews /// for a Widget. I would have just Widget instead of making this Trait otherwise. /// /// There is some precendent for this, it looks like there's a similar trait /// in the egui demo library. pub trait View { fn ui(&mut self, ui: &mut egui::Ui); } pub fn padding( amount: impl Into, ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R, ) -> egui::InnerResponse { egui::Frame::none() .inner_margin(amount) .show(ui, add_contents) } pub fn hline(ui: &egui::Ui) { // pixel perfect horizontal line let rect = ui.available_rect_before_wrap(); let resize_y = ui.painter().round_to_pixel(rect.top()) - 0.5; let stroke = ui.style().visuals.widgets.noninteractive.bg_stroke; ui.painter().hline(rect.x_range(), resize_y, stroke); } pub fn show_pointer(ui: &egui::Ui) { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/profile/edit.rs`: ```rs use core::f32; use egui::{vec2, Button, Layout, Margin, RichText, Rounding, ScrollArea, TextEdit}; use notedeck::{Images, NotedeckTextStyle}; use crate::{colors, profile_state::ProfileState}; use super::{banner, unwrap_profile_url, ProfilePic}; pub struct EditProfileView<'a> { state: &'a mut ProfileState, img_cache: &'a mut Images, } impl<'a> EditProfileView<'a> { pub fn new(state: &'a mut ProfileState, img_cache: &'a mut Images) -> Self { Self { state, img_cache } } // return true to save pub fn ui(&mut self, ui: &mut egui::Ui) -> bool { ScrollArea::vertical() .show(ui, |ui| { banner(ui, Some(&self.state.banner), 188.0); let padding = 24.0; crate::ui::padding(padding, ui, |ui| { self.inner(ui, padding); }); ui.separator(); let mut save = false; crate::ui::padding(padding, ui, |ui| { ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { if ui .add(button("Save changes", 119.0).fill(colors::PINK)) .clicked() { save = true; } }); }); save }) .inner } fn inner(&mut self, ui: &mut egui::Ui, padding: f32) { ui.spacing_mut().item_spacing = egui::vec2(0.0, 16.0); let mut pfp_rect = ui.available_rect_before_wrap(); let size = 80.0; pfp_rect.set_width(size); pfp_rect.set_height(size); let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); let pfp_url = unwrap_profile_url(if self.state.picture.is_empty() { None } else { Some(&self.state.picture) }); ui.put( pfp_rect, ProfilePic::new(self.img_cache, pfp_url) .size(size) .border(ProfilePic::border_stroke(ui)), ); in_frame(ui, |ui| { ui.add(label("Display name")); ui.add(singleline_textedit(&mut self.state.display_name)); }); in_frame(ui, |ui| { ui.add(label("Username")); ui.add(singleline_textedit(&mut self.state.name)); }); in_frame(ui, |ui| { ui.add(label("Profile picture")); ui.add(multiline_textedit(&mut self.state.picture)); }); in_frame(ui, |ui| { ui.add(label("Banner")); ui.add(multiline_textedit(&mut self.state.banner)); }); in_frame(ui, |ui| { ui.add(label("About")); ui.add(multiline_textedit(&mut self.state.about)); }); in_frame(ui, |ui| { ui.add(label("Website")); ui.add(singleline_textedit(&mut self.state.website)); }); in_frame(ui, |ui| { ui.add(label("Lightning network address (lud16)")); ui.add(multiline_textedit(&mut self.state.lud16)); }); in_frame(ui, |ui| { ui.add(label("Nostr address (NIP-05 identity)")); ui.add(singleline_textedit(&mut self.state.nip05)); let split = &mut self.state.nip05.split('@'); let prefix = split.next(); let suffix = split.next(); if let Some(prefix) = prefix { if let Some(suffix) = suffix { let use_domain = if let Some(f) = prefix.chars().next() { f == '_' } else { false }; ui.colored_label( ui.visuals().noninteractive().fg_stroke.color, RichText::new(if use_domain { format!("\"{}\" will be used for identification", suffix) } else { format!( "\"{}\" at \"{}\" will be used for identification", prefix, suffix ) }), ); } } }); } } fn label(text: &str) -> impl egui::Widget + '_ { move |ui: &mut egui::Ui| -> egui::Response { ui.label(RichText::new(text).font(NotedeckTextStyle::Body.get_bolded_font(ui.ctx()))) } } fn singleline_textedit(data: &mut String) -> impl egui::Widget + '_ { TextEdit::singleline(data) .min_size(vec2(0.0, 40.0)) .vertical_align(egui::Align::Center) .margin(Margin::symmetric(12.0, 10.0)) .desired_width(f32::INFINITY) } fn multiline_textedit(data: &mut String) -> impl egui::Widget + '_ { TextEdit::multiline(data) // .min_size(vec2(0.0, 40.0)) .vertical_align(egui::Align::TOP) .margin(Margin::symmetric(12.0, 10.0)) .desired_width(f32::INFINITY) .desired_rows(1) } fn in_frame(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) { egui::Frame::none().show(ui, |ui| { ui.spacing_mut().item_spacing = egui::vec2(0.0, 8.0); contents(ui); }); } fn button(text: &str, width: f32) -> egui::Button<'static> { Button::new(text) .rounding(Rounding::same(8.0)) .min_size(vec2(width, 40.0)) } mod preview { use notedeck::App; use crate::{ profile_state::ProfileState, test_data, ui::{Preview, PreviewConfig}, }; use super::EditProfileView; pub struct EditProfilePreivew { state: ProfileState, } impl Default for EditProfilePreivew { fn default() -> Self { Self { state: ProfileState::from_profile(&test_data::test_profile_record()), } } } impl App for EditProfilePreivew { fn update(&mut self, ctx: &mut notedeck::AppContext<'_>, ui: &mut egui::Ui) { EditProfileView::new(&mut self.state, ctx.img_cache).ui(ui); } } impl Preview for EditProfileView<'_> { type Prev = EditProfilePreivew; fn preview(_cfg: PreviewConfig) -> Self::Prev { EditProfilePreivew::default() } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/profile/picture.rs`: ```rs use crate::gif::{handle_repaint, retrieve_latest_texture}; use crate::images::ImageType; use crate::ui::images::render_images; use crate::ui::{Preview, PreviewConfig}; use egui::{vec2, Sense, Stroke, TextureHandle}; use nostrdb::{Ndb, Transaction}; use tracing::info; use notedeck::{supported_mime_hosted_at_url, AppContext, Images}; pub struct ProfilePic<'cache, 'url> { cache: &'cache mut Images, url: &'url str, size: f32, border: Option, } impl egui::Widget for ProfilePic<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { render_pfp(ui, self.cache, self.url, self.size, self.border) } } impl<'cache, 'url> ProfilePic<'cache, 'url> { pub fn new(cache: &'cache mut Images, url: &'url str) -> Self { let size = Self::default_size(); ProfilePic { cache, url, size, border: None, } } pub fn border_stroke(ui: &egui::Ui) -> Stroke { Stroke::new(4.0, ui.visuals().panel_fill) } pub fn from_profile( cache: &'cache mut Images, profile: &nostrdb::ProfileRecord<'url>, ) -> Option { profile .record() .profile() .and_then(|p| p.picture()) .map(|url| ProfilePic::new(cache, url)) } #[inline] pub fn default_size() -> f32 { 38.0 } #[inline] pub fn medium_size() -> f32 { 32.0 } #[inline] pub fn small_size() -> f32 { 24.0 } #[inline] pub fn no_pfp_url() -> &'static str { "https://damus.io/img/no-profile.svg" } #[inline] pub fn size(mut self, size: f32) -> Self { self.size = size; self } #[inline] pub fn border(mut self, stroke: Stroke) -> Self { self.border = Some(stroke); self } } fn render_pfp( ui: &mut egui::Ui, img_cache: &mut Images, url: &str, ui_size: f32, border: Option, ) -> egui::Response { #[cfg(feature = "profiling")] puffin::profile_function!(); // We will want to downsample these so it's not blurry on hi res displays let img_size = 128u32; let cache_type = supported_mime_hosted_at_url(&mut img_cache.urls, url) .unwrap_or(notedeck::MediaCacheType::Image); render_images( ui, img_cache, url, ImageType::Profile(img_size), cache_type, |ui| { paint_circle(ui, ui_size, border); }, |ui, _| { paint_circle(ui, ui_size, border); }, |ui, url, renderable_media, gifs| { let texture_handle = handle_repaint(ui, retrieve_latest_texture(url, gifs, renderable_media)); pfp_image(ui, texture_handle, ui_size, border); }, ) } fn pfp_image( ui: &mut egui::Ui, img: &TextureHandle, size: f32, border: Option, ) -> egui::Response { #[cfg(feature = "profiling")] puffin::profile_function!(); let (rect, response) = ui.allocate_at_least(vec2(size, size), Sense::hover()); if let Some(stroke) = border { draw_bg_border(ui, rect.center(), size, stroke); } ui.put(rect, egui::Image::new(img).max_width(size)); response } fn paint_circle(ui: &mut egui::Ui, size: f32, border: Option) -> egui::Response { let (rect, response) = ui.allocate_at_least(vec2(size, size), Sense::hover()); if let Some(stroke) = border { draw_bg_border(ui, rect.center(), size, stroke); } ui.painter() .circle_filled(rect.center(), size / 2.0, ui.visuals().weak_text_color()); response } fn draw_bg_border(ui: &mut egui::Ui, center: egui::Pos2, size: f32, stroke: Stroke) { let border_size = size + (stroke.width * 2.0); ui.painter() .circle_filled(center, border_size / 2.0, stroke.color); } mod preview { use super::*; use crate::ui; use nostrdb::*; use std::collections::HashSet; pub struct ProfilePicPreview { keys: Option>, } impl ProfilePicPreview { fn new() -> Self { ProfilePicPreview { keys: None } } fn show(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) { egui::ScrollArea::both().show(ui, |ui| { ui.horizontal_wrapped(|ui| { let txn = Transaction::new(app.ndb).unwrap(); let keys = if let Some(keys) = &self.keys { keys } else { return; }; for key in keys { let profile = app.ndb.get_profile_by_key(&txn, *key).unwrap(); let url = profile .record() .profile() .expect("should have profile") .picture() .expect("should have picture"); let expand_size = 10.0; let anim_speed = 0.05; let (rect, size, _resp) = ui::anim::hover_expand( ui, egui::Id::new(profile.key().unwrap()), ui::ProfilePic::default_size(), expand_size, anim_speed, ); ui.put( rect, ui::ProfilePic::new(app.img_cache, url) .size(size) .border(ui::ProfilePic::border_stroke(ui)), ) .on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); ui.add(ui::ProfilePreview::new(&profile, app.img_cache)); }); } }); }); } fn setup(&mut self, ndb: &Ndb) { let txn = Transaction::new(ndb).unwrap(); let filters = vec![Filter::new().kinds(vec![0]).build()]; let mut pks = HashSet::new(); let mut keys = HashSet::new(); for query_result in ndb.query(&txn, &filters, 20000).unwrap() { pks.insert(query_result.note.pubkey()); } for pk in pks { let profile = if let Ok(profile) = ndb.get_profile_by_pubkey(&txn, pk) { profile } else { continue; }; if profile .record() .profile() .and_then(|p| p.picture()) .is_none() { continue; } keys.insert(profile.key().expect("should not be owned")); } let keys: Vec = keys.into_iter().collect(); info!("Loaded {} profiles", keys.len()); self.keys = Some(keys); } } impl notedeck::App for ProfilePicPreview { fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) { if self.keys.is_none() { self.setup(ctx.ndb); } self.show(ctx, ui) } } impl Preview for ProfilePic<'_, '_> { type Prev = ProfilePicPreview; fn preview(_cfg: PreviewConfig) -> Self::Prev { ProfilePicPreview::new() } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/profile/preview.rs`: ```rs use crate::ui::ProfilePic; use crate::NostrName; use egui::{Frame, Label, RichText, Widget}; use egui_extras::Size; use nostrdb::ProfileRecord; use notedeck::{Images, NotedeckTextStyle, UserAccount}; use super::{about_section_widget, banner, display_name_widget, get_display_name, get_profile_url}; pub struct ProfilePreview<'a, 'cache> { profile: &'a ProfileRecord<'a>, cache: &'cache mut Images, banner_height: Size, } impl<'a, 'cache> ProfilePreview<'a, 'cache> { pub fn new(profile: &'a ProfileRecord<'a>, cache: &'cache mut Images) -> Self { let banner_height = Size::exact(80.0); ProfilePreview { profile, cache, banner_height, } } pub fn banner_height(&mut self, size: Size) { self.banner_height = size; } fn body(self, ui: &mut egui::Ui) { let padding = 12.0; crate::ui::padding(padding, ui, |ui| { let mut pfp_rect = ui.available_rect_before_wrap(); let size = 80.0; pfp_rect.set_width(size); pfp_rect.set_height(size); let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); ui.put( pfp_rect, ProfilePic::new(self.cache, get_profile_url(Some(self.profile))) .size(size) .border(ProfilePic::border_stroke(ui)), ); ui.add(display_name_widget( get_display_name(Some(self.profile)), false, )); ui.add(about_section_widget(self.profile)); }); } } impl egui::Widget for ProfilePreview<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { ui.vertical(|ui| { banner( ui, self.profile.record().profile().and_then(|p| p.banner()), 80.0, ); self.body(ui); }) .response } } pub struct SimpleProfilePreview<'a, 'cache> { profile: Option<&'a ProfileRecord<'a>>, cache: &'cache mut Images, is_nsec: bool, } impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> { pub fn new( profile: Option<&'a ProfileRecord<'a>>, cache: &'cache mut Images, is_nsec: bool, ) -> Self { SimpleProfilePreview { profile, cache, is_nsec, } } } impl egui::Widget for SimpleProfilePreview<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { Frame::none() .show(ui, |ui| { ui.add(ProfilePic::new(self.cache, get_profile_url(self.profile)).size(48.0)); ui.vertical(|ui| { ui.add(display_name_widget(get_display_name(self.profile), true)); if !self.is_nsec { ui.add( Label::new( RichText::new("Read only") .size(notedeck::fonts::get_font_size( ui.ctx(), &NotedeckTextStyle::Tiny, )) .color(ui.visuals().warn_fg_color), ) .selectable(false), ); } }); }) .response } } mod previews { use super::*; use crate::test_data::test_profile_record; use crate::ui::{Preview, PreviewConfig}; use notedeck::{App, AppContext}; pub struct ProfilePreviewPreview<'a> { profile: ProfileRecord<'a>, } impl ProfilePreviewPreview<'_> { pub fn new() -> Self { let profile = test_profile_record(); ProfilePreviewPreview { profile } } } impl Default for ProfilePreviewPreview<'_> { fn default() -> Self { ProfilePreviewPreview::new() } } impl App for ProfilePreviewPreview<'_> { fn update(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) { ProfilePreview::new(&self.profile, app.img_cache).ui(ui); } } impl<'a> Preview for ProfilePreview<'a, '_> { /// A preview of the profile preview :D type Prev = ProfilePreviewPreview<'a>; fn preview(_cfg: PreviewConfig) -> Self::Prev { ProfilePreviewPreview::new() } } } pub fn get_profile_url_owned(profile: Option>) -> &str { if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) { url } else { ProfilePic::no_pfp_url() } } pub fn get_account_url<'a>( txn: &'a nostrdb::Transaction, ndb: &nostrdb::Ndb, account: Option<&UserAccount>, ) -> &'a str { if let Some(selected_account) = account { if let Ok(profile) = ndb.get_profile_by_pubkey(txn, selected_account.pubkey.bytes()) { get_profile_url_owned(Some(profile)) } else { get_profile_url_owned(None) } } else { get_profile_url(None) } } pub fn one_line_display_name_widget<'a>( visuals: &egui::Visuals, display_name: NostrName<'a>, style: NotedeckTextStyle, ) -> impl egui::Widget + 'a { let text_style = style.text_style(); let color = visuals.noninteractive().fg_stroke.color; move |ui: &mut egui::Ui| -> egui::Response { ui.label( RichText::new(display_name.name()) .text_style(text_style) .color(color), ) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/profile/mod.rs`: ```rs pub mod edit; pub mod picture; pub mod preview; pub use edit::EditProfileView; use egui::load::TexturePoll; use egui::{vec2, Color32, Label, Layout, Rect, RichText, Rounding, ScrollArea, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{Ndb, ProfileRecord, Transaction}; pub use picture::ProfilePic; pub use preview::ProfilePreview; use tracing::error; use crate::{ actionbar::NoteAction, colors, images, profile::get_display_name, timeline::{TimelineCache, TimelineKind}, ui::{ note::NoteOptions, timeline::{tabs_ui, TimelineTabView}, }, NostrName, }; use notedeck::{Accounts, Images, MuteFun, NoteCache, NotedeckTextStyle, UnknownIds}; pub struct ProfileView<'a> { pubkey: &'a Pubkey, accounts: &'a Accounts, col_id: usize, timeline_cache: &'a mut TimelineCache, note_options: NoteOptions, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, unknown_ids: &'a mut UnknownIds, is_muted: &'a MuteFun, } pub enum ProfileViewAction { EditProfile, Note(NoteAction), } impl<'a> ProfileView<'a> { #[allow(clippy::too_many_arguments)] pub fn new( pubkey: &'a Pubkey, accounts: &'a Accounts, col_id: usize, timeline_cache: &'a mut TimelineCache, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut Images, unknown_ids: &'a mut UnknownIds, is_muted: &'a MuteFun, note_options: NoteOptions, ) -> Self { ProfileView { pubkey, accounts, col_id, timeline_cache, ndb, note_cache, img_cache, unknown_ids, note_options, is_muted, } } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey)); ScrollArea::vertical() .id_salt(scroll_id) .show(ui, |ui| { let mut action = None; let txn = Transaction::new(self.ndb).expect("txn"); if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, self.pubkey.bytes()) { if self.profile_body(ui, profile) { action = Some(ProfileViewAction::EditProfile); } } let profile_timeline = self .timeline_cache .notes( self.ndb, self.note_cache, &txn, &TimelineKind::Profile(*self.pubkey), ) .get_ptr(); profile_timeline.selected_view = tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views); let reversed = false; // poll for new notes and insert them into our existing notes if let Err(e) = profile_timeline.poll_notes_into_view( self.ndb, &txn, self.unknown_ids, self.note_cache, reversed, ) { error!("Profile::poll_notes_into_view: {e}"); } if let Some(note_action) = TimelineTabView::new( profile_timeline.current_view(), reversed, self.note_options, &txn, self.ndb, self.note_cache, self.img_cache, self.is_muted, ) .show(ui) { action = Some(ProfileViewAction::Note(note_action)); } action }) .inner } fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool { let mut action = false; ui.vertical(|ui| { banner( ui, profile.record().profile().and_then(|p| p.banner()), 120.0, ); let padding = 12.0; crate::ui::padding(padding, ui, |ui| { let mut pfp_rect = ui.available_rect_before_wrap(); let size = 80.0; pfp_rect.set_width(size); pfp_rect.set_height(size); let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0)))); ui.horizontal(|ui| { ui.put( pfp_rect, ProfilePic::new(self.img_cache, get_profile_url(Some(&profile))) .size(size) .border(ProfilePic::border_stroke(ui)), ); if ui.add(copy_key_widget(&pfp_rect)).clicked() { ui.output_mut(|w| { w.copied_text = if let Some(bech) = self.pubkey.to_bech() { bech } else { error!("Could not convert Pubkey to bech"); String::new() } }); } if self.accounts.contains_full_kp(self.pubkey) { ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| { if ui.add(edit_profile_button()).clicked() { action = true; } }); } }); ui.add_space(18.0); ui.add(display_name_widget(get_display_name(Some(&profile)), false)); ui.add_space(8.0); ui.add(about_section_widget(&profile)); ui.horizontal_wrapped(|ui| { if let Some(website_url) = profile .record() .profile() .and_then(|p| p.website()) .filter(|s| !s.is_empty()) { handle_link(ui, website_url); } if let Some(lud16) = profile .record() .profile() .and_then(|p| p.lud16()) .filter(|s| !s.is_empty()) { handle_lud16(ui, lud16); } }); }); }); action } } fn handle_link(ui: &mut egui::Ui, website_url: &str) { ui.image(egui::include_image!( "../../../../../assets/icons/links_4x.png" )); if ui .label(RichText::new(website_url).color(colors::PINK)) .on_hover_cursor(egui::CursorIcon::PointingHand) .interact(Sense::click()) .clicked() { if let Err(e) = open::that(website_url) { error!("Failed to open URL {} because: {}", website_url, e); }; } } fn handle_lud16(ui: &mut egui::Ui, lud16: &str) { ui.image(egui::include_image!( "../../../../../assets/icons/zap_4x.png" )); let _ = ui.label(RichText::new(lud16).color(colors::PINK)); } fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ { |ui: &mut egui::Ui| -> egui::Response { let painter = ui.painter(); let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size( pfp_rect.center_bottom(), egui::vec2(48.0, 28.0), )); let resp = ui.interact( copy_key_rect, ui.id().with("custom_painter"), Sense::click(), ); let copy_key_rounding = Rounding::same(100.0); let fill_color = if resp.hovered() { ui.visuals().widgets.inactive.weak_bg_fill } else { ui.visuals().noninteractive().bg_stroke.color }; painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color); let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill; painter.rect_stroke( copy_key_rect.shrink(1.0), copy_key_rounding, Stroke::new(1.0, stroke_color), ); egui::Image::new(egui::include_image!( "../../../../../assets/icons/key_4x.png" )) .paint_at( ui, painter.round_rect_to_pixels(egui::Rect::from_center_size( copy_key_rect.center(), egui::vec2(16.0, 16.0), )), ); resp } } fn edit_profile_button() -> impl egui::Widget + 'static { |ui: &mut egui::Ui| -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click()); let painter = ui.painter_at(rect); let rect = painter.round_rect_to_pixels(rect); painter.rect_filled( rect, Rounding::same(8.0), if resp.hovered() { ui.visuals().widgets.active.bg_fill } else { ui.visuals().widgets.inactive.bg_fill }, ); painter.rect_stroke( rect.shrink(1.0), Rounding::same(8.0), if resp.hovered() { ui.visuals().widgets.active.bg_stroke } else { ui.visuals().widgets.inactive.bg_stroke }, ); let edit_icon_size = vec2(16.0, 16.0); let galley = painter.layout( "Edit Profile".to_owned(), NotedeckTextStyle::Button.get_font_id(ui.ctx()), ui.visuals().text_color(), rect.width(), ); let space_between_icon_galley = 8.0; let half_icon_size = edit_icon_size.x / 2.0; let galley_rect = { let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size()); galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0)) }; let edit_icon_rect = { let mut center = galley_rect.left_center(); center.x -= half_icon_size + space_between_icon_galley; painter.round_rect_to_pixels(Rect::from_center_size( painter.round_pos_to_pixel_center(center), edit_icon_size, )) }; painter.galley(galley_rect.left_top(), galley, Color32::WHITE); egui::Image::new(egui::include_image!( "../../../../../assets/icons/edit_icon_4x_dark.png" )) .paint_at(ui, edit_icon_rect); resp } } fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl egui::Widget + '_ { move |ui: &mut egui::Ui| -> egui::Response { let disp_resp = name.display_name.map(|disp_name| { ui.add( Label::new( RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()), ) .selectable(false), ) }); let (username_resp, nip05_resp) = ui .horizontal(|ui| { let username_resp = name.username.map(|username| { ui.add( Label::new( RichText::new(format!("@{}", username)) .size(16.0) .color(colors::MID_GRAY), ) .selectable(false), ) }); let nip05_resp = name.nip05.map(|nip05| { ui.image(egui::include_image!( "../../../../../assets/icons/verified_4x.png" )); ui.add(Label::new( RichText::new(nip05).size(16.0).color(colors::TEAL), )) }); (username_resp, nip05_resp) }) .inner; let resp = match (disp_resp, username_resp, nip05_resp) { (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05), (Some(disp), Some(username), None) => disp.union(username), (Some(disp), None, None) => disp, (None, Some(username), Some(nip05)) => username.union(nip05), (None, Some(username), None) => username, _ => ui.add(Label::new(RichText::new(name.name()))), }; if add_placeholder_space { ui.add_space(16.0); } resp } } 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 { ProfilePic::no_pfp_url() } } fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b where 'b: 'a, { move |ui: &mut egui::Ui| { if let Some(about) = profile.record().profile().and_then(|p| p.about()) { let resp = ui.label(about); ui.add_space(8.0); resp } else { // need any Response so we dont need an Option ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) } } } fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option { // TODO: cache banner if !banner_url.is_empty() { let texture_load_res = egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size()); if let Ok(texture_poll) = texture_load_res { match texture_poll { TexturePoll::Pending { .. } => {} TexturePoll::Ready { texture, .. } => return Some(texture), } } } None } fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response { ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| { banner_url .and_then(|url| banner_texture(ui, url)) .map(|texture| { images::aspect_fill( ui, Sense::hover(), texture.id, texture.size.x / texture.size.y, ) }) .unwrap_or_else(|| ui.label("")) }) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/profile/menu.rs`: ```rs // profile menu ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/mention.rs`: ```rs use crate::ui; use crate::{actionbar::NoteAction, profile::get_display_name, timeline::TimelineKind}; use egui::Sense; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; use notedeck::Images; pub struct Mention<'a> { ndb: &'a Ndb, img_cache: &'a mut Images, txn: &'a Transaction, pk: &'a [u8; 32], selectable: bool, size: f32, } impl<'a> Mention<'a> { pub fn new( ndb: &'a Ndb, img_cache: &'a mut Images, txn: &'a Transaction, pk: &'a [u8; 32], ) -> Self { let size = 16.0; let selectable = true; Mention { ndb, img_cache, txn, pk, selectable, size, } } pub fn selectable(mut self, selectable: bool) -> Self { self.selectable = selectable; self } pub fn size(mut self, size: f32) -> Self { self.size = size; self } pub fn show(self, ui: &mut egui::Ui) -> egui::InnerResponse> { mention_ui( self.ndb, self.img_cache, self.txn, self.pk, ui, self.size, self.selectable, ) } } impl egui::Widget for Mention<'_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { self.show(ui).response } } #[allow(clippy::too_many_arguments)] fn mention_ui( ndb: &Ndb, img_cache: &mut Images, txn: &Transaction, pk: &[u8; 32], ui: &mut egui::Ui, size: f32, selectable: bool, ) -> egui::InnerResponse> { #[cfg(feature = "profiling")] puffin::profile_function!(); let link_color = ui.visuals().hyperlink_color; ui.horizontal(|ui| { let profile = ndb.get_profile_by_pubkey(txn, pk).ok(); let name: String = format!("@{}", get_display_name(profile.as_ref()).name()); let resp = ui.add( egui::Label::new(egui::RichText::new(name).color(link_color).size(size)) .sense(Sense::click()) .selectable(selectable), ); let note_action = if resp.clicked() { ui::show_pointer(ui); Some(NoteAction::OpenTimeline(TimelineKind::profile( Pubkey::new(*pk), ))) } else if resp.hovered() { ui::show_pointer(ui); None } else { None }; if let Some(rec) = profile.as_ref() { resp.on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); ui.add(ui::ProfilePreview::new(rec, img_cache)); }); } note_action }) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/images.rs`: ```rs use notedeck::{GifStateMap, Images, MediaCache, MediaCacheType, TexturedImage}; use crate::images::ImageType; use super::ProfilePic; #[allow(clippy::too_many_arguments)] pub fn render_images( ui: &mut egui::Ui, images: &mut Images, url: &str, img_type: ImageType, cache_type: MediaCacheType, show_waiting: impl FnOnce(&mut egui::Ui), show_error: impl FnOnce(&mut egui::Ui, String), show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), ) -> egui::Response { let cache = match cache_type { MediaCacheType::Image => &mut images.static_imgs, MediaCacheType::Gif => &mut images.gifs, }; render_media_cache( ui, cache, &mut images.gif_states, url, img_type, cache_type, show_waiting, show_error, show_success, ) } #[allow(clippy::too_many_arguments)] fn render_media_cache( ui: &mut egui::Ui, cache: &mut MediaCache, gif_states: &mut GifStateMap, url: &str, img_type: ImageType, cache_type: MediaCacheType, show_waiting: impl FnOnce(&mut egui::Ui), show_error: impl FnOnce(&mut egui::Ui, String), show_success: impl FnOnce(&mut egui::Ui, &str, &mut TexturedImage, &mut GifStateMap), ) -> egui::Response { let m_cached_promise = cache.map().get(url); if m_cached_promise.is_none() { let res = crate::images::fetch_img(cache, ui.ctx(), url, img_type, cache_type.clone()); cache.map_mut().insert(url.to_owned(), res); } egui::Frame::none() .show(ui, |ui| { match cache.map_mut().get_mut(url).and_then(|p| p.ready_mut()) { None => show_waiting(ui), Some(Err(err)) => { let err = err.to_string(); let no_pfp = crate::images::fetch_img( cache, ui.ctx(), ProfilePic::no_pfp_url(), ImageType::Profile(128), cache_type, ); cache.map_mut().insert(url.to_owned(), no_pfp); show_error(ui, err) } Some(Ok(renderable_media)) => show_success(ui, url, renderable_media, gif_states), } }) .response } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/account_login_view.rs`: ```rs use crate::login_manager::AcquireKeyState; use crate::ui::{Preview, PreviewConfig}; use egui::{ Align, Button, Color32, Frame, Image, InnerResponse, Margin, RichText, TextBuffer, Vec2, }; use egui::{Layout, TextEdit}; use enostr::Keypair; use notedeck::fonts::get_font_size; use notedeck::NotedeckTextStyle; pub struct AccountLoginView<'a> { manager: &'a mut AcquireKeyState, } pub enum AccountLoginResponse { CreateNew, LoginWith(Keypair), } impl<'a> AccountLoginView<'a> { pub fn new(state: &'a mut AcquireKeyState) -> Self { AccountLoginView { manager: state } } pub fn ui(&mut self, ui: &mut egui::Ui) -> InnerResponse> { Frame::none() .outer_margin(12.0) .show(ui, |ui| self.show(ui)) } fn show(&mut self, ui: &mut egui::Ui) -> Option { ui.vertical(|ui| { ui.vertical_centered(|ui| { ui.add_space(32.0); ui.label(login_title_text()); }); ui.horizontal(|ui| { ui.label(login_textedit_info_text()); }); ui.vertical_centered_justified(|ui| { ui.horizontal(|ui| { let available_width = ui.available_width(); let button_width = 32.0; let text_edit_width = available_width - button_width; ui.add_sized([text_edit_width, 40.0], login_textedit(self.manager)); if eye_button(ui, self.manager.password_visible()).clicked() { self.manager.toggle_password_visibility(); } }); ui.with_layout(Layout::left_to_right(Align::TOP), |ui| { let help_text_style = NotedeckTextStyle::Small; ui.add(egui::Label::new( RichText::new("Enter your public key (npub), nostr address (e.g. vrod@damus.io), or private key (nsec). You must enter your private key to be able to post, reply, etc.") .text_style(help_text_style.text_style()) .size(get_font_size(ui.ctx(), &help_text_style)).color(ui.visuals().weak_text_color()), ).wrap()) }); self.manager.loading_and_error_ui(ui); if ui.add(login_button()).clicked() { self.manager.apply_acquire(); } }); ui.horizontal(|ui| { ui.label( RichText::new("New to Nostr?") .color(ui.style().visuals.noninteractive().fg_stroke.color) .text_style(NotedeckTextStyle::Body.text_style()), ); if ui .add(Button::new(RichText::new("Create Account")).frame(false)) .clicked() { self.manager.should_create_new(); } }); }); if self.manager.check_for_create_new() { return Some(AccountLoginResponse::CreateNew); } if let Some(keypair) = self.manager.get_login_keypair() { return Some(AccountLoginResponse::LoginWith(keypair.clone())); } None } } fn login_title_text() -> RichText { RichText::new("Login") .text_style(NotedeckTextStyle::Heading2.text_style()) .strong() } fn login_textedit_info_text() -> RichText { RichText::new("Enter your key") .strong() .text_style(NotedeckTextStyle::Body.text_style()) } fn login_button() -> Button<'static> { Button::new( RichText::new("Login now — let's do this!") .text_style(NotedeckTextStyle::Body.text_style()) .strong(), ) .fill(Color32::from_rgb(0xF8, 0x69, 0xB6)) // TODO: gradient .min_size(Vec2::new(0.0, 40.0)) } fn login_textedit(manager: &mut AcquireKeyState) -> TextEdit { let create_textedit: fn(&mut dyn TextBuffer) -> TextEdit = |text| { egui::TextEdit::singleline(text) .hint_text( RichText::new("Your key here...").text_style(NotedeckTextStyle::Body.text_style()), ) .vertical_align(Align::Center) .min_size(Vec2::new(0.0, 40.0)) .margin(Margin::same(12.0)) }; let is_visible = manager.password_visible(); let mut text_edit = manager.get_acquire_textedit(create_textedit); if !is_visible { text_edit = text_edit.password(true); } text_edit } fn eye_button(ui: &mut egui::Ui, is_visible: bool) -> egui::Response { let is_dark_mode = ui.visuals().dark_mode; let icon = Image::new(if is_visible && is_dark_mode { egui::include_image!("../../../../assets/icons/eye-dark.png") } else if is_visible { egui::include_image!("../../../../assets/icons/eye-light.png") } else if is_dark_mode { egui::include_image!("../../../../assets/icons/eye-slash-dark.png") } else { egui::include_image!("../../../../assets/icons/eye-slash-light.png") }); ui.add(Button::image(icon).frame(false)) } mod preview { use super::*; use notedeck::{App, AppContext}; pub struct AccountLoginPreview { manager: AcquireKeyState, } impl App for AccountLoginPreview { fn update(&mut self, _app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) { AccountLoginView::new(&mut self.manager).ui(ui); } } impl Preview for AccountLoginView<'_> { type Prev = AccountLoginPreview; fn preview(cfg: PreviewConfig) -> Self::Prev { let _ = cfg; let manager = AcquireKeyState::new(); AccountLoginPreview { manager } } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/username.rs`: ```rs use egui::{Color32, RichText, Widget}; use nostrdb::ProfileRecord; use notedeck::fonts::NamedFontFamily; pub struct Username<'a> { profile: Option<&'a ProfileRecord<'a>>, pk: &'a [u8; 32], pk_colored: bool, abbrev: usize, } impl<'a> Username<'a> { pub fn pk_colored(mut self, pk_colored: bool) -> Self { self.pk_colored = pk_colored; self } pub fn abbreviated(mut self, amount: usize) -> Self { self.abbrev = amount; self } pub fn new(profile: Option<&'a ProfileRecord>, pk: &'a [u8; 32]) -> Self { let pk_colored = false; let abbrev: usize = 1000; Username { profile, pk, pk_colored, abbrev, } } } impl Widget for Username<'_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let color = if self.pk_colored { Some(pk_color(self.pk)) } else { None }; if let Some(profile) = self.profile { if let Some(prof) = profile.record().profile() { if prof.display_name().is_some() && prof.display_name().unwrap() != "" { ui_abbreviate_name(ui, prof.display_name().unwrap(), self.abbrev, color); } else if let Some(name) = prof.name() { ui_abbreviate_name(ui, name, self.abbrev, color); } } } else { let mut txt = RichText::new("nostrich").family(NamedFontFamily::Medium.as_family()); if let Some(col) = color { txt = txt.color(col) } ui.label(txt); } }) .response } } fn colored_name(name: &str, color: Option) -> RichText { let mut txt = RichText::new(name).family(NamedFontFamily::Medium.as_family()); if let Some(color) = color { txt = txt.color(color); } txt } fn ui_abbreviate_name(ui: &mut egui::Ui, name: &str, len: usize, color: Option) { let should_abbrev = name.len() > len; let name = if should_abbrev { let closest = crate::abbrev::floor_char_boundary(name, len); &name[..closest] } else { name }; ui.label(colored_name(name, color)); if should_abbrev { ui.label(colored_name("..", color)); } } fn pk_color(pk: &[u8; 32]) -> Color32 { Color32::from_rgb(pk[8], pk[10], pk[12]) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/relay.rs`: ```rs use std::collections::HashMap; use crate::colors::PINK; use crate::relay_pool_manager::{RelayPoolManager, RelayStatus}; use crate::ui::{Preview, PreviewConfig, View}; use egui::{Align, Button, Frame, Id, Image, Layout, Margin, Rgba, RichText, Rounding, Ui, Vec2}; use enostr::RelayPool; use notedeck::{Accounts, NotedeckTextStyle}; use tracing::debug; use super::add_column::sized_button; use super::padding; pub struct RelayView<'a> { accounts: &'a mut Accounts, manager: RelayPoolManager<'a>, id_string_map: &'a mut HashMap, } impl View for RelayView<'_> { fn ui(&mut self, ui: &mut egui::Ui) { ui.add_space(24.0); ui.horizontal(|ui| { ui.with_layout(Layout::left_to_right(Align::Center), |ui| { ui.label( RichText::new("Relays").text_style(NotedeckTextStyle::Heading2.text_style()), ); }); }); ui.add_space(8.0); egui::ScrollArea::vertical() .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .auto_shrink([false; 2]) .show(ui, |ui| { if let Some(relay_to_remove) = self.show_relays(ui) { self.accounts .remove_advertised_relay(&relay_to_remove, self.manager.pool); } ui.add_space(8.0); if let Some(relay_to_add) = self.show_add_relay_ui(ui) { self.accounts .add_advertised_relay(&relay_to_add, self.manager.pool); } }); } } impl<'a> RelayView<'a> { pub fn new( accounts: &'a mut Accounts, manager: RelayPoolManager<'a>, id_string_map: &'a mut HashMap, ) -> Self { RelayView { accounts, manager, id_string_map, } } pub fn panel(&mut self, ui: &mut egui::Ui) { egui::CentralPanel::default().show(ui.ctx(), |ui| self.ui(ui)); } /// Show the current relays and return a relay the user selected to delete fn show_relays(&'a self, ui: &mut Ui) -> Option { let mut relay_to_remove = None; for (index, relay_info) in self.manager.get_relay_infos().iter().enumerate() { ui.add_space(8.0); ui.vertical_centered_justified(|ui| { relay_frame(ui).show(ui, |ui| { ui.horizontal(|ui| { ui.with_layout(Layout::left_to_right(Align::Center), |ui| { Frame::none() // This frame is needed to add margin because the label will be added to the outer frame first and centered vertically before the connection status is added so the vertical centering isn't accurate. // TODO: remove this hack and actually center the url & status at the same time .inner_margin(Margin::symmetric(0.0, 4.0)) .show(ui, |ui| { egui::ScrollArea::horizontal() .id_salt(index) .max_width( ui.max_rect().width() - get_right_side_width(relay_info.status), ) // TODO: refactor to dynamically check the size of the 'right to left' portion and set the max width to be the screen width minus padding minus 'right to left' width .show(ui, |ui| { ui.label( RichText::new(relay_info.relay_url) .text_style( NotedeckTextStyle::Monospace.text_style(), ) .color( ui.style() .visuals .noninteractive() .fg_stroke .color, ), ); }); }); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.add(delete_button(ui.visuals().dark_mode)).clicked() { relay_to_remove = Some(relay_info.relay_url.to_string()); }; show_connection_status(ui, relay_info.status); }); }); }); }); } relay_to_remove } const RELAY_PREFILL: &'static str = "wss://"; fn show_add_relay_ui(&mut self, ui: &mut Ui) -> Option { let id = ui.id().with("add-relay)"); match self.id_string_map.get(&id) { None => { ui.with_layout(Layout::top_down(Align::Min), |ui| { let relay_button = add_relay_button(); if ui.add(relay_button).clicked() { debug!("add relay clicked"); self.id_string_map .insert(id, Self::RELAY_PREFILL.to_string()); }; }); None } Some(_) => { ui.with_layout(Layout::top_down(Align::Min), |ui| { self.add_relay_entry(ui, id) }) .inner } } } pub fn add_relay_entry(&mut self, ui: &mut Ui, id: Id) -> Option { padding(16.0, ui, |ui| { let text_buffer = self .id_string_map .entry(id) .or_insert_with(|| Self::RELAY_PREFILL.to_string()); let is_enabled = self.manager.is_valid_relay(text_buffer); let text_edit = egui::TextEdit::singleline(text_buffer) .hint_text( RichText::new("Enter the relay here") .text_style(NotedeckTextStyle::Body.text_style()), ) .vertical_align(Align::Center) .desired_width(f32::INFINITY) .min_size(Vec2::new(0.0, 40.0)) .margin(Margin::same(12.0)); ui.add(text_edit); ui.add_space(8.0); if ui .add_sized(egui::vec2(50.0, 40.0), add_relay_button2(is_enabled)) .clicked() { self.id_string_map.remove(&id) // remove and return the value } else { None } }) .inner } } fn add_relay_button() -> Button<'static> { let img_data = egui::include_image!("../../../../assets/icons/add_relay_icon_4x.png"); let img = Image::new(img_data).fit_to_exact_size(Vec2::new(48.0, 48.0)); Button::image_and_text( img, RichText::new(" Add relay") .size(16.0) // TODO: this color should not be hard coded. Find some way to add it to the visuals .color(PINK), ) .frame(false) } fn add_relay_button2(is_enabled: bool) -> impl egui::Widget + 'static { move |ui: &mut egui::Ui| -> egui::Response { let button_widget = sized_button("Add"); ui.add_enabled(is_enabled, button_widget) } } fn get_right_side_width(status: RelayStatus) -> f32 { match status { RelayStatus::Connected => 150.0, RelayStatus::Connecting => 160.0, RelayStatus::Disconnected => 175.0, } } fn delete_button(_dark_mode: bool) -> egui::Button<'static> { /* let img_data = if dark_mode { egui::include_image!("../../assets/icons/delete_icon_4x.png") } else { // TODO: use light delete icon egui::include_image!("../../assets/icons/delete_icon_4x.png") }; */ let img_data = egui::include_image!("../../../../assets/icons/delete_icon_4x.png"); egui::Button::image(egui::Image::new(img_data).max_width(10.0)).frame(false) } fn relay_frame(ui: &mut Ui) -> Frame { Frame::none() .inner_margin(Margin::same(8.0)) .rounding(ui.style().noninteractive().rounding) .stroke(ui.style().visuals.noninteractive().bg_stroke) } fn show_connection_status(ui: &mut Ui, status: RelayStatus) { let fg_color = match status { RelayStatus::Connected => ui.visuals().selection.bg_fill, RelayStatus::Connecting => ui.visuals().warn_fg_color, RelayStatus::Disconnected => ui.visuals().error_fg_color, }; let bg_color = egui::lerp(Rgba::from(fg_color)..=Rgba::BLACK, 0.8).into(); let label_text = match status { RelayStatus::Connected => "Connected", RelayStatus::Connecting => "Connecting...", RelayStatus::Disconnected => "Not Connected", }; let frame = Frame::none() .rounding(Rounding::same(100.0)) .fill(bg_color) .inner_margin(Margin::symmetric(12.0, 4.0)); frame.show(ui, |ui| { ui.label(RichText::new(label_text).color(fg_color)); ui.add(get_connection_icon(status)); }); } fn get_connection_icon(status: RelayStatus) -> egui::Image<'static> { let img_data = match status { RelayStatus::Connected => { egui::include_image!("../../../../assets/icons/connected_icon_4x.png") } RelayStatus::Connecting => { egui::include_image!("../../../../assets/icons/connecting_icon_4x.png") } RelayStatus::Disconnected => { egui::include_image!("../../../../assets/icons/disconnected_icon_4x.png") } }; egui::Image::new(img_data) } // PREVIEWS mod preview { use super::*; use crate::test_data::sample_pool; use notedeck::{App, AppContext}; pub struct RelayViewPreview { pool: RelayPool, } impl RelayViewPreview { fn new() -> Self { RelayViewPreview { pool: sample_pool(), } } } impl App for RelayViewPreview { fn update(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) { self.pool.try_recv(); let mut id_string_map = HashMap::new(); RelayView::new( app.accounts, RelayPoolManager::new(&mut self.pool), &mut id_string_map, ) .ui(ui); } } impl Preview for RelayView<'_> { type Prev = RelayViewPreview; fn preview(_cfg: PreviewConfig) -> Self::Prev { RelayViewPreview::new() } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/ui/search_results.rs`: ```rs use egui::{vec2, FontId, Pos2, Rect, ScrollArea, Vec2b}; use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{fonts::get_font_size, Images, NotedeckTextStyle}; use tracing::error; use crate::{ profile::get_display_name, ui::anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, }; use super::{profile::get_profile_url, ProfilePic}; pub struct SearchResultsView<'a> { ndb: &'a Ndb, txn: &'a Transaction, img_cache: &'a mut Images, results: &'a Vec<&'a [u8; 32]>, } impl<'a> SearchResultsView<'a> { pub fn new( img_cache: &'a mut Images, ndb: &'a Ndb, txn: &'a Transaction, results: &'a Vec<&'a [u8; 32]>, ) -> Self { Self { ndb, txn, img_cache, results, } } fn show(&mut self, ui: &mut egui::Ui, width: f32) -> Option { let mut selection = None; ui.vertical(|ui| { for (i, res) in self.results.iter().enumerate() { let profile = match self.ndb.get_profile_by_pubkey(self.txn, res) { Ok(rec) => rec, Err(e) => { error!("Error fetching profile for pubkey {:?}: {e}", res); return; } }; if ui .add(user_result(&profile, self.img_cache, i, width)) .clicked() { selection = Some(i) } } }); selection } pub fn show_in_rect(&mut self, rect: egui::Rect, ui: &mut egui::Ui) -> Option { let widget_id = ui.id().with("search_results"); let area_resp = egui::Area::new(widget_id) .order(egui::Order::Foreground) .fixed_pos(rect.left_top()) .constrain_to(rect) .show(ui.ctx(), |ui| { egui::Frame::none() .fill(ui.visuals().panel_fill) .inner_margin(8.0) .show(ui, |ui| { let width = rect.width(); let scroll_resp = ScrollArea::vertical() .max_width(width) .auto_shrink(Vec2b::FALSE) .show(ui, |ui| self.show(ui, width)); ui.advance_cursor_after_rect(rect); scroll_resp.inner }) .inner }); area_resp.inner } } fn user_result<'a>( profile: &'a ProfileRecord<'_>, cache: &'a mut Images, index: usize, width: f32, ) -> impl egui::Widget + 'a { move |ui: &mut egui::Ui| -> egui::Response { let min_img_size = 48.0; let max_image = min_img_size * ICON_EXPANSION_MULTIPLE; let spacing = 8.0; let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); let helper = AnimationHelper::new(ui, ("user_result", index), vec2(width, max_image)); let icon_rect = { let r = helper.get_animation_rect(); let mut center = r.center(); center.x = r.left() + (max_image / 2.0); let size = helper.scale_1d_pos(min_img_size); Rect::from_center_size(center, vec2(size, size)) }; let pfp_resp = ui.put( icon_rect, ProfilePic::new(cache, get_profile_url(Some(profile))) .size(helper.scale_1d_pos(min_img_size)), ); let name_font = FontId::new( helper.scale_1d_pos(body_font_size), NotedeckTextStyle::Body.font_family(), ); let painter = ui.painter_at(helper.get_animation_rect()); let name_galley = painter.layout( get_display_name(Some(profile)).name().to_owned(), name_font, ui.visuals().text_color(), width, ); let galley_pos = { let right_top = pfp_resp.rect.right_top(); let galley_pos_y = pfp_resp.rect.center().y - (name_galley.rect.height() / 2.0); Pos2::new(right_top.x + spacing, galley_pos_y) }; painter.galley(galley_pos, name_galley, ui.visuals().text_color()); ui.advance_cursor_after_rect(helper.get_animation_rect()); pfp_resp.union(helper.take_animation_response()) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/view_state.rs`: ```rs use std::collections::HashMap; use enostr::Pubkey; use crate::deck_state::DeckState; use crate::login_manager::AcquireKeyState; use crate::profile_state::ProfileState; /// Various state for views #[derive(Default)] pub struct ViewState { pub login: AcquireKeyState, pub id_to_deck_state: HashMap, pub id_state_map: HashMap, pub id_string_map: HashMap, pub pubkey_to_profile_state: HashMap, } impl ViewState { pub fn login_mut(&mut self) -> &mut AcquireKeyState { &mut self.login } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/abbrev.rs`: ```rs #[inline] pub fn floor_char_boundary(s: &str, index: usize) -> usize { if index >= s.len() { s.len() } else { let lower_bound = index.saturating_sub(3); let new_index = s.as_bytes()[lower_bound..=index] .iter() .rposition(|b| is_utf8_char_boundary(*b)); // SAFETY: we know that the character boundary will be within four bytes unsafe { lower_bound + new_index.unwrap_unchecked() } } } #[inline] fn is_utf8_char_boundary(c: u8) -> bool { // This is bit magic equivalent to: b < 128 || b >= 192 (c as i8) >= -0x40 } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/subscriptions.rs`: ```rs use crate::timeline::TimelineKind; use std::collections::HashMap; use uuid::Uuid; #[derive(Debug, Clone)] pub enum SubKind { /// Initial subscription. This is the first time we do a remote subscription /// for a timeline Initial, /// One shot requests, we can just close after we receive EOSE OneShot, Timeline(TimelineKind), /// We are fetching a contact list so that we can use it for our follows /// Filter. // TODO: generalize this to any list? FetchingContactList(TimelineKind), } /// Subscriptions that need to be tracked at various stages. Sometimes we /// need to do A, then B, then C. Tracking requests at various stages by /// mapping uuid subids to explicit states happens here. #[derive(Default)] pub struct Subscriptions { pub subs: HashMap, } pub fn new_sub_id() -> String { Uuid::new_v4().to_string() } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/route.rs`: ```rs use enostr::{NoteId, Pubkey}; use std::fmt::{self}; use crate::{ accounts::AccountsRoute, timeline::{ kind::{AlgoTimeline, ColumnTitle, ListKind}, ThreadSelection, TimelineKind, }, ui::add_column::{AddAlgoRoute, AddColumnRoute}, }; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; /// App routing. These describe different places you can go inside Notedeck. #[derive(Clone, Eq, PartialEq, Debug)] pub enum Route { Timeline(TimelineKind), Accounts(AccountsRoute), Reply(NoteId), Quote(NoteId), Relays, ComposeNote, AddColumn(AddColumnRoute), EditProfile(Pubkey), Support, NewDeck, EditDeck(usize), } impl Route { pub fn timeline(timeline_kind: TimelineKind) -> Self { Route::Timeline(timeline_kind) } pub fn timeline_id(&self) -> Option<&TimelineKind> { if let Route::Timeline(tid) = self { Some(tid) } else { None } } pub fn relays() -> Self { Route::Relays } pub fn thread(thread_selection: ThreadSelection) -> Self { Route::Timeline(TimelineKind::Thread(thread_selection)) } pub fn profile(pubkey: Pubkey) -> Self { Route::Timeline(TimelineKind::profile(pubkey)) } pub fn reply(replying_to: NoteId) -> Self { Route::Reply(replying_to) } pub fn quote(quoting: NoteId) -> Self { Route::Quote(quoting) } pub fn accounts() -> Self { Route::Accounts(AccountsRoute::Accounts) } pub fn add_account() -> Self { Route::Accounts(AccountsRoute::AddAccount) } pub fn serialize_tokens(&self, writer: &mut TokenWriter) { match self { Route::Timeline(timeline_kind) => timeline_kind.serialize_tokens(writer), Route::Accounts(routes) => routes.serialize_tokens(writer), Route::AddColumn(routes) => routes.serialize_tokens(writer), Route::Reply(note_id) => { writer.write_token("reply"); writer.write_token(¬e_id.hex()); } Route::Quote(note_id) => { writer.write_token("quote"); writer.write_token(¬e_id.hex()); } Route::EditDeck(ind) => { writer.write_token("deck"); writer.write_token("edit"); writer.write_token(&ind.to_string()); } Route::EditProfile(pubkey) => { writer.write_token("profile"); writer.write_token("edit"); writer.write_token(&pubkey.hex()); } Route::Relays => { writer.write_token("relay"); } Route::ComposeNote => { writer.write_token("compose"); } Route::Support => { writer.write_token("support"); } Route::NewDeck => { writer.write_token("deck"); writer.write_token("new"); } } } pub fn parse<'a>( parser: &mut TokenParser<'a>, deck_author: &Pubkey, ) -> Result> { let tlkind = parser.try_parse(|p| Ok(Route::Timeline(TimelineKind::parse(p, deck_author)?))); if tlkind.is_ok() { return tlkind; } TokenParser::alt( parser, &[ |p| Ok(Route::Accounts(AccountsRoute::parse_from_tokens(p)?)), |p| Ok(Route::AddColumn(AddColumnRoute::parse_from_tokens(p)?)), |p| { p.parse_all(|p| { p.parse_token("deck")?; p.parse_token("edit")?; let ind_str = p.pull_token()?; let parsed_index = ind_str .parse::() .map_err(|_| ParseError::DecodeFailed)?; Ok(Route::EditDeck(parsed_index)) }) }, |p| { p.parse_all(|p| { p.parse_token("profile")?; p.parse_token("edit")?; let pubkey = Pubkey::from_hex(p.pull_token()?) .map_err(|_| ParseError::HexDecodeFailed)?; Ok(Route::EditProfile(pubkey)) }) }, |p| { p.parse_all(|p| { p.parse_token("relay")?; Ok(Route::Relays) }) }, |p| { p.parse_all(|p| { p.parse_token("quote")?; Ok(Route::Quote(NoteId::new(tokenator::parse_hex_id(p)?))) }) }, |p| { p.parse_all(|p| { p.parse_token("reply")?; Ok(Route::Reply(NoteId::new(tokenator::parse_hex_id(p)?))) }) }, |p| { p.parse_all(|p| { p.parse_token("compose")?; Ok(Route::ComposeNote) }) }, |p| { p.parse_all(|p| { p.parse_token("support")?; Ok(Route::Support) }) }, |p| { p.parse_all(|p| { p.parse_token("deck")?; p.parse_token("new")?; Ok(Route::NewDeck) }) }, ], ) } pub fn title(&self) -> ColumnTitle<'_> { match self { Route::Timeline(kind) => kind.to_title(), Route::Reply(_id) => ColumnTitle::simple("Reply"), Route::Quote(_id) => ColumnTitle::simple("Quote"), Route::Relays => ColumnTitle::simple("Relays"), Route::Accounts(amr) => match amr { AccountsRoute::Accounts => ColumnTitle::simple("Accounts"), AccountsRoute::AddAccount => ColumnTitle::simple("Add Account"), }, Route::ComposeNote => ColumnTitle::simple("Compose Note"), Route::AddColumn(c) => match c { AddColumnRoute::Base => ColumnTitle::simple("Add Column"), AddColumnRoute::Algo(r) => match r { AddAlgoRoute::Base => ColumnTitle::simple("Add Algo Column"), AddAlgoRoute::LastPerPubkey => ColumnTitle::simple("Add Last Notes Column"), }, AddColumnRoute::UndecidedNotification => { ColumnTitle::simple("Add Notifications Column") } AddColumnRoute::ExternalNotification => { ColumnTitle::simple("Add External Notifications Column") } AddColumnRoute::Hashtag => ColumnTitle::simple("Add Hashtag Column"), AddColumnRoute::UndecidedIndividual => { ColumnTitle::simple("Subscribe to someone's notes") } AddColumnRoute::ExternalIndividual => { ColumnTitle::simple("Subscribe to someone else's notes") } }, Route::Support => ColumnTitle::simple("Damus Support"), Route::NewDeck => ColumnTitle::simple("Add Deck"), Route::EditDeck(_) => ColumnTitle::simple("Edit Deck"), Route::EditProfile(_) => ColumnTitle::simple("Edit Profile"), } } } // TODO: add this to egui-nav so we don't have to deal with returning // and navigating headaches #[derive(Clone, Debug)] pub struct Router { routes: Vec, pub returning: bool, pub navigating: bool, replacing: bool, } impl Router { pub fn new(routes: Vec) -> Self { if routes.is_empty() { panic!("routes can't be empty") } let returning = false; let navigating = false; let replacing = false; Router { routes, returning, navigating, replacing, } } pub fn route_to(&mut self, route: R) { self.navigating = true; self.routes.push(route); } // Route to R. Then when it is successfully placed, should call `remove_previous_routes` to remove all previous routes pub fn route_to_replaced(&mut self, route: R) { self.navigating = true; self.replacing = true; self.routes.push(route); } /// Go back, start the returning process pub fn go_back(&mut self) -> Option { if self.returning || self.routes.len() == 1 { return None; } self.returning = true; self.prev().cloned() } /// Pop a route, should only be called on a NavRespose::Returned reseponse pub fn pop(&mut self) -> Option { if self.routes.len() == 1 { return None; } self.returning = false; self.routes.pop() } pub fn remove_previous_routes(&mut self) { let num_routes = self.routes.len(); if num_routes <= 1 { return; } self.returning = false; self.replacing = false; self.routes.drain(..num_routes - 1); } pub fn is_replacing(&self) -> bool { self.replacing } pub fn top(&self) -> &R { self.routes.last().expect("routes can't be empty") } pub fn prev(&self) -> Option<&R> { self.routes.get(self.routes.len() - 2) } pub fn routes(&self) -> &Vec { &self.routes } } impl fmt::Display for Route { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Route::Timeline(kind) => match kind { TimelineKind::List(ListKind::Contact(_pk)) => write!(f, "Contacts"), TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::Contact(_))) => { write!(f, "Last Per Pubkey (Contact)") } TimelineKind::Notifications(_) => write!(f, "Notifications"), TimelineKind::Universe => write!(f, "Universe"), TimelineKind::Generic(_) => write!(f, "Custom"), TimelineKind::Hashtag(ht) => write!(f, "Hashtag ({})", ht), TimelineKind::Thread(_id) => write!(f, "Thread"), TimelineKind::Profile(_id) => write!(f, "Profile"), }, Route::Reply(_id) => write!(f, "Reply"), Route::Quote(_id) => write!(f, "Quote"), Route::Relays => write!(f, "Relays"), Route::Accounts(amr) => match amr { AccountsRoute::Accounts => write!(f, "Accounts"), AccountsRoute::AddAccount => write!(f, "Add Account"), }, Route::ComposeNote => write!(f, "Compose Note"), Route::AddColumn(_) => write!(f, "Add Column"), Route::Support => write!(f, "Support"), Route::NewDeck => write!(f, "Add Deck"), Route::EditDeck(_) => write!(f, "Edit Deck"), Route::EditProfile(_) => write!(f, "Edit Profile"), } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/profile.rs`: ```rs use std::collections::HashMap; use enostr::{FullKeypair, Pubkey, RelayPool}; use nostrdb::{Ndb, Note, NoteBuildOptions, NoteBuilder, ProfileRecord}; use tracing::info; use crate::{ profile_state::ProfileState, route::{Route, Router}, }; pub struct NostrName<'a> { pub username: Option<&'a str>, pub display_name: Option<&'a str>, pub nip05: Option<&'a str>, } impl<'a> NostrName<'a> { pub fn name(&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> { if let Some(record) = record { if let Some(profile) = record.record().profile() { 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, } } else { NostrName::unknown() } } else { NostrName::unknown() } } pub struct SaveProfileChanges { pub kp: FullKeypair, pub state: ProfileState, } impl SaveProfileChanges { pub fn new(kp: FullKeypair, state: ProfileState) -> Self { Self { kp, state } } pub fn to_note(&self) -> Note { let sec = &self.kp.secret_key.to_secret_bytes(); add_client_tag(NoteBuilder::new()) .kind(0) .content(&self.state.to_json()) .options(NoteBuildOptions::default().created_at(true).sign(sec)) .build() .expect("should build") } } fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { builder .start_tag() .tag_str("client") .tag_str("Damus Notedeck") } pub enum ProfileAction { Edit(FullKeypair), SaveChanges(SaveProfileChanges), } impl ProfileAction { pub fn process( &self, state_map: &mut HashMap, ndb: &Ndb, pool: &mut RelayPool, router: &mut Router, ) { match self { ProfileAction::Edit(kp) => { router.route_to(Route::EditProfile(kp.pubkey)); } ProfileAction::SaveChanges(changes) => { let raw_msg = format!("[\"EVENT\",{}]", changes.to_note().json().unwrap()); let _ = ndb.process_client_event(raw_msg.as_str()); let _ = state_map.remove_entry(&changes.kp.pubkey); info!("sending {}", raw_msg); pool.send(&enostr::ClientMessage::raw(raw_msg)); router.go_back(); } } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/frame_history.rs`: ```rs /* use egui::util::History; pub struct FrameHistory { frame_times: History, } impl Default for FrameHistory { fn default() -> Self { let max_age: f32 = 1.0; let max_len = (max_age * 300.0).round() as usize; Self { frame_times: History::new(0..max_len, max_age), } } } impl FrameHistory { // Called first pub fn on_new_frame(&mut self, now: f64, previous_frame_time: Option) { let previous_frame_time = previous_frame_time.unwrap_or_default(); if let Some(latest) = self.frame_times.latest_mut() { *latest = previous_frame_time; // rewrite history now that we know } self.frame_times.add(now, previous_frame_time); // projected } #[allow(unused)] pub fn mean_frame_time(&self) -> f32 { self.frame_times.average().unwrap_or_default() } #[allow(unused)] pub fn fps(&self) -> f32 { 1.0 / self.frame_times.mean_time_interval().unwrap_or_default() } pub fn _ui(&mut self, ui: &mut egui::Ui) { ui.label(format!( "Mean CPU usage: {:.2} ms / frame", 1e3 * self.mean_frame_time() )) .on_hover_text( "Includes egui layout and tessellation time.\n\ Does not include GPU usage, nor overhead for sending data to GPU.", ); egui::warn_if_debug_build(ui); } } */ ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/gif.rs`: ```rs use std::{ sync::mpsc::TryRecvError, time::{Instant, SystemTime}, }; use egui::TextureHandle; use notedeck::{GifState, GifStateMap, TexturedImage}; pub struct LatextTexture<'a> { pub texture: &'a TextureHandle, pub request_next_repaint: Option, } /// This is necessary because other repaint calls can effectively steal our repaint request. /// So we must keep on requesting to repaint at our desired time to ensure our repaint goes through. /// See [`egui::Context::request_repaint_after`] pub fn handle_repaint<'a>(ui: &egui::Ui, latest: LatextTexture<'a>) -> &'a TextureHandle { if let Some(repaint) = latest.request_next_repaint { if let Ok(dur) = repaint.duration_since(SystemTime::now()) { ui.ctx().request_repaint_after(dur); } } latest.texture } #[must_use = "caller should pass the return value to `gif::handle_repaint`"] pub fn retrieve_latest_texture<'a>( url: &str, gifs: &'a mut GifStateMap, cached_image: &'a mut TexturedImage, ) -> LatextTexture<'a> { match cached_image { TexturedImage::Static(texture) => LatextTexture { texture, request_next_repaint: None, }, TexturedImage::Animated(animation) => { if let Some(receiver) = &animation.receiver { loop { match receiver.try_recv() { Ok(frame) => animation.other_frames.push(frame), Err(TryRecvError::Empty) => { break; } Err(TryRecvError::Disconnected) => { animation.receiver = None; break; } } } } let now = Instant::now(); let (texture, maybe_new_state, request_next_repaint) = match gifs.get(url) { Some(prev_state) => { let should_advance = now - prev_state.last_frame_rendered >= prev_state.last_frame_duration; if should_advance { let maybe_new_index = if animation.receiver.is_some() || prev_state.last_frame_index < animation.num_frames() - 1 { prev_state.last_frame_index + 1 } else { 0 }; match animation.get_frame(maybe_new_index) { Some(frame) => { let next_frame_time = SystemTime::now().checked_add(frame.delay); ( &frame.texture, Some(GifState { last_frame_rendered: now, last_frame_duration: frame.delay, next_frame_time, last_frame_index: maybe_new_index, }), next_frame_time, ) } None => { let (tex, state) = match animation.get_frame(prev_state.last_frame_index) { Some(frame) => (&frame.texture, None), None => (&animation.first_frame.texture, None), }; (tex, state, prev_state.next_frame_time) } } } else { let (tex, state) = match animation.get_frame(prev_state.last_frame_index) { Some(frame) => (&frame.texture, None), None => (&animation.first_frame.texture, None), }; (tex, state, prev_state.next_frame_time) } } None => ( &animation.first_frame.texture, Some(GifState { last_frame_rendered: now, last_frame_duration: animation.first_frame.delay, next_frame_time: None, last_frame_index: 0, }), None, ), }; if let Some(new_state) = maybe_new_state { gifs.insert(url.to_owned(), new_state); } LatextTexture { texture, request_next_repaint, } } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/multi_subscriber.rs`: ```rs use enostr::{Filter, RelayPool}; use nostrdb::{Ndb, Subscription}; use tracing::{error, info}; use uuid::Uuid; #[derive(Debug)] pub struct MultiSubscriber { pub filters: Vec, pub local_subid: Option, pub remote_subid: Option, local_subscribers: u32, remote_subscribers: u32, } impl MultiSubscriber { /// Create a MultiSubscriber with an initial local subscription. pub fn with_initial_local_sub(sub: Subscription, filters: Vec) -> Self { let mut msub = MultiSubscriber::new(filters); msub.local_subid = Some(sub); msub.local_subscribers = 1; msub } pub fn new(filters: Vec) -> Self { Self { filters, local_subid: None, remote_subid: None, local_subscribers: 0, remote_subscribers: 0, } } fn unsubscribe_remote(&mut self, ndb: &Ndb, pool: &mut RelayPool) { let remote_subid = if let Some(remote_subid) = &self.remote_subid { remote_subid } else { self.err_log(ndb, "unsubscribe_remote: nothing to unsubscribe from?"); return; }; pool.unsubscribe(remote_subid.clone()); self.remote_subid = None; } /// Locally unsubscribe if we have one fn unsubscribe_local(&mut self, ndb: &mut Ndb) { let local_sub = if let Some(local_sub) = self.local_subid { local_sub } else { self.err_log(ndb, "unsubscribe_local: nothing to unsubscribe from?"); return; }; match ndb.unsubscribe(local_sub) { Err(e) => { self.err_log(ndb, &format!("Failed to unsubscribe: {e}")); } Ok(_) => { self.local_subid = None; } } } pub fn unsubscribe(&mut self, ndb: &mut Ndb, pool: &mut RelayPool) -> bool { if self.local_subscribers == 0 && self.remote_subscribers == 0 { self.err_log( ndb, "Called multi_subscriber unsubscribe when both sub counts are 0", ); return false; } self.local_subscribers = self.local_subscribers.saturating_sub(1); self.remote_subscribers = self.remote_subscribers.saturating_sub(1); if self.local_subscribers == 0 && self.remote_subscribers == 0 { self.info_log(ndb, "Locally unsubscribing"); self.unsubscribe_local(ndb); self.unsubscribe_remote(ndb, pool); self.local_subscribers = 0; self.remote_subscribers = 0; true } else { false } } fn info_log(&self, ndb: &Ndb, msg: &str) { info!( "{msg}. {}/{}/{} active ndb/local/remote subscriptions.", ndb.subscription_count(), self.local_subscribers, self.remote_subscribers, ); } fn err_log(&self, ndb: &Ndb, msg: &str) { error!( "{msg}. {}/{}/{} active ndb/local/remote subscriptions.", ndb.subscription_count(), self.local_subscribers, self.remote_subscribers, ); } pub fn subscribe(&mut self, ndb: &Ndb, pool: &mut RelayPool) { self.local_subscribers += 1; self.remote_subscribers += 1; if self.remote_subscribers == 1 { if self.remote_subid.is_some() { self.err_log( ndb, "Object is first subscriber, but it already had a subscription", ); return; } else { let subid = Uuid::new_v4().to_string(); pool.subscribe(subid.clone(), self.filters.clone()); self.info_log(ndb, "First remote subscription"); self.remote_subid = Some(subid); } } if self.local_subscribers == 1 { if self.local_subid.is_some() { self.err_log(ndb, "Should not have a local subscription already"); return; } match ndb.subscribe(&self.filters) { Ok(sub) => { self.info_log(ndb, "First local subscription"); self.local_subid = Some(sub); } Err(err) => { error!("multi_subscriber: error subscribing locally: '{err}'") } } } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/error.rs`: ```rs use std::io; #[derive(thiserror::Error, Debug)] pub enum Error { #[error("timeline not found")] TimelineNotFound, #[error("timeline is missing a subscription")] MissingSubscription, #[error("load failed")] LoadFailed, #[error("network error: {0}")] Nostr(#[from] enostr::Error), #[error("database error: {0}")] Ndb(#[from] nostrdb::Error), #[error("io error: {0}")] Io(#[from] io::Error), #[error("notedeck app error: {0}")] App(#[from] notedeck::Error), #[error("generic error: {0}")] Generic(String), } impl From for Error { fn from(s: String) -> Self { Error::Generic(s) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/support.rs`: ```rs use tracing::error; use notedeck::{DataPath, DataPathType, Directory}; pub struct Support { directory: Directory, mailto_url: String, most_recent_log: Option, } fn new_log_dir(paths: &DataPath) -> Directory { Directory::new(paths.path(DataPathType::Log)) } impl Support { pub fn new(path: &DataPath) -> Self { let directory = new_log_dir(path); Self { mailto_url: MailtoBuilder::new(SUPPORT_EMAIL.to_string()) .with_subject("Help Needed".to_owned()) .with_content(EMAIL_TEMPLATE.to_owned()) .build(), directory, most_recent_log: None, } } } static MAX_LOG_LINES: usize = 500; static SUPPORT_EMAIL: &str = "support@damus.io"; static EMAIL_TEMPLATE: &str = concat!("version ", env!("CARGO_PKG_VERSION"), "\nCommit hash: ", env!("GIT_COMMIT_HASH"), "\n\nDescribe the bug you have encountered:\n<-- your statement here -->\n\n===== Paste your log below =====\n\n"); impl Support { pub fn refresh(&mut self) { self.most_recent_log = get_log_str(&self.directory); } pub fn get_mailto_url(&self) -> &str { &self.mailto_url } pub fn get_log_dir(&self) -> Option<&str> { self.directory.file_path.to_str() } pub fn get_most_recent_log(&self) -> Option<&String> { self.most_recent_log.as_ref() } } fn get_log_str(interactor: &Directory) -> Option { match interactor.get_most_recent() { Ok(Some(most_recent_name)) => { match interactor.get_file_last_n_lines(most_recent_name.clone(), MAX_LOG_LINES) { Ok(file_output) => { return Some( get_prefix( &most_recent_name, file_output.output_num_lines, file_output.total_lines_in_file, ) + &file_output.output, ) } Err(e) => { error!( "Error retrieving the last lines from file {}: {:?}", most_recent_name, e ); } } } Ok(None) => { error!("No files were found."); } Err(e) => { error!("Error fetching the most recent file: {:?}", e); } } None } fn get_prefix(file_name: &str, lines_displayed: usize, num_total_lines: usize) -> String { format!( "===\nDisplaying the last {} of {} lines in file {}\n===\n\n", lines_displayed, num_total_lines, file_name, ) } struct MailtoBuilder { content: Option, address: String, subject: Option, } impl MailtoBuilder { fn new(address: String) -> Self { Self { content: None, address, subject: None, } } // will be truncated so the whole URL is at most 2000 characters pub fn with_content(mut self, content: String) -> Self { self.content = Some(content); self } pub fn with_subject(mut self, subject: String) -> Self { self.subject = Some(subject); self } pub fn build(self) -> String { let mut url = String::new(); url.push_str("mailto:"); url.push_str(&self.address); let has_subject = self.subject.is_some(); if has_subject || self.content.is_some() { url.push('?'); } if let Some(subject) = self.subject { url.push_str("subject="); url.push_str(&urlencoding::encode(&subject)); } if let Some(content) = self.content { if has_subject { url.push('&'); } url.push_str("body="); let body = urlencoding::encode(&content); url.push_str(&body); } url } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/lib.rs`: ```rs mod app; //mod camera; mod error; //mod note; //mod block; mod abbrev; pub mod accounts; mod actionbar; pub mod app_creation; mod app_style; mod args; mod colors; mod column; mod deck_state; mod decks; mod draft; mod frame_history; mod gif; mod images; mod key_parsing; pub mod login_manager; mod media_upload; mod multi_subscriber; mod nav; mod post; mod profile; mod profile_state; pub mod relay_pool_manager; mod route; mod subscriptions; mod support; mod test_data; mod timeline; pub mod ui; mod unknowns; mod view_state; #[cfg(test)] #[macro_use] mod test_utils; pub mod storage; pub use app::Damus; pub use error::Error; pub use profile::NostrName; pub type Result = std::result::Result; ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/actionbar.rs`: ```rs use crate::{ column::Columns, route::{Route, Router}, timeline::{TimelineCache, TimelineKind}, }; use enostr::{NoteId, RelayPool}; use nostrdb::{Ndb, NoteKey, Transaction}; use notedeck::{NoteCache, UnknownIds}; use tracing::error; #[derive(Debug, Eq, PartialEq, Clone)] pub enum NoteAction { Reply(NoteId), Quote(NoteId), OpenTimeline(TimelineKind), } pub struct NewNotes { pub id: TimelineKind, pub notes: Vec, } pub enum TimelineOpenResult { NewNotes(NewNotes), } impl NoteAction { #[allow(clippy::too_many_arguments)] pub fn execute( &self, ndb: &Ndb, router: &mut Router, timeline_cache: &mut TimelineCache, note_cache: &mut NoteCache, pool: &mut RelayPool, txn: &Transaction, ) -> Option { match self { NoteAction::Reply(note_id) => { router.route_to(Route::reply(*note_id)); None } NoteAction::OpenTimeline(kind) => { router.route_to(Route::Timeline(kind.to_owned())); timeline_cache.open(ndb, note_cache, txn, pool, kind) } NoteAction::Quote(note_id) => { router.route_to(Route::quote(*note_id)); None } } } /// Execute the NoteAction and process the TimelineOpenResult #[allow(clippy::too_many_arguments)] pub fn execute_and_process_result( &self, ndb: &Ndb, columns: &mut Columns, col: usize, timeline_cache: &mut TimelineCache, note_cache: &mut NoteCache, pool: &mut RelayPool, txn: &Transaction, unknown_ids: &mut UnknownIds, ) { let router = columns.column_mut(col).router_mut(); if let Some(br) = self.execute(ndb, router, timeline_cache, note_cache, pool, txn) { br.process(ndb, note_cache, txn, timeline_cache, unknown_ids); } } } impl TimelineOpenResult { pub fn new_notes(notes: Vec, id: TimelineKind) -> Self { Self::NewNotes(NewNotes::new(notes, id)) } pub fn process( &self, ndb: &Ndb, note_cache: &mut NoteCache, txn: &Transaction, storage: &mut TimelineCache, unknown_ids: &mut UnknownIds, ) { match self { // update the thread for next render if we have new notes TimelineOpenResult::NewNotes(new_notes) => { new_notes.process(storage, ndb, txn, unknown_ids, note_cache); } } } } impl NewNotes { pub fn new(notes: Vec, id: TimelineKind) -> Self { NewNotes { notes, id } } /// Simple helper for processing a NewThreadNotes result. It simply /// inserts/merges the notes into the corresponding timeline cache pub fn process( &self, timeline_cache: &mut TimelineCache, ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, ) { let reversed = matches!(&self.id, TimelineKind::Thread(_)); let timeline = if let Some(profile) = timeline_cache.timelines.get_mut(&self.id) { profile } else { error!("NewNotes: could not get timeline for key {}", self.id); return; }; if let Err(err) = timeline.insert(&self.notes, ndb, txn, unknown_ids, note_cache, reversed) { error!("error inserting notes into profile timeline: {err}") } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/unknowns.rs`: ```rs use crate::{timeline::TimelineCache, Result}; use nostrdb::{Ndb, NoteKey, Transaction}; use notedeck::{CachedNote, NoteCache, UnknownIds}; use tracing::error; pub fn update_from_columns( txn: &Transaction, unknown_ids: &mut UnknownIds, timeline_cache: &TimelineCache, ndb: &Ndb, note_cache: &mut NoteCache, ) -> bool { let before = unknown_ids.ids_iter().len(); if let Err(e) = get_unknown_ids(txn, unknown_ids, timeline_cache, ndb, note_cache) { error!("UnknownIds::update {e}"); } let after = unknown_ids.ids_iter().len(); if before != after { unknown_ids.mark_updated(); true } else { false } } pub fn get_unknown_ids( txn: &Transaction, unknown_ids: &mut UnknownIds, timeline_cache: &TimelineCache, ndb: &Ndb, note_cache: &mut NoteCache, ) -> Result<()> { #[cfg(feature = "profiling")] puffin::profile_function!(); let mut new_cached_notes: Vec<(NoteKey, CachedNote)> = vec![]; for (_kind, timeline) in timeline_cache.timelines.iter() { for noteref in timeline.all_or_any_notes() { let note = ndb.get_note_by_key(txn, noteref.key)?; let note_key = note.key().unwrap(); let cached_note = note_cache.cached_note(noteref.key); let cached_note = if let Some(cn) = cached_note { cn.clone() } else { let new_cached_note = CachedNote::new(¬e); new_cached_notes.push((note_key, new_cached_note.clone())); new_cached_note }; let _ = notedeck::get_unknown_note_ids( ndb, &cached_note, txn, ¬e, unknown_ids.ids_mut(), ); } } // This is mainly done to avoid the double mutable borrow that would happen // if we tried to update the note_cache mutably in the loop above for (note_key, note) in new_cached_notes { note_cache.cache_mut().insert(note_key, note); } Ok(()) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/post.rs`: ```rs use egui::{text::LayoutJob, TextBuffer, TextFormat}; use enostr::{FullKeypair, Pubkey}; use nostrdb::{Note, NoteBuilder, NoteReply}; use std::{ any::TypeId, collections::{BTreeMap, HashMap, HashSet}, hash::{DefaultHasher, Hash, Hasher}, ops::Range, }; use tracing::error; use crate::media_upload::Nip94Event; pub struct NewPost { pub content: String, pub account: FullKeypair, pub media: Vec, pub mentions: Vec, } fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { builder .start_tag() .tag_str("client") .tag_str("Damus Notedeck") } impl NewPost { pub fn new( content: String, account: enostr::FullKeypair, media: Vec, mentions: Vec, ) -> Self { NewPost { content, account, media, mentions, } } pub fn to_note(&self, seckey: &[u8; 32]) -> Note { let mut content = self.content.clone(); append_urls(&mut content, &self.media); let mut builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content); for hashtag in Self::extract_hashtags(&self.content) { builder = builder.start_tag().tag_str("t").tag_str(&hashtag); } if !self.media.is_empty() { builder = add_imeta_tags(builder, &self.media); } if !self.mentions.is_empty() { builder = add_mention_tags(builder, &self.mentions); } builder.sign(seckey).build().expect("note should be ok") } pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note { let mut content = self.content.clone(); append_urls(&mut content, &self.media); let builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content); let nip10 = NoteReply::new(replying_to.tags()); let mut builder = if let Some(root) = nip10.root() { builder .start_tag() .tag_str("e") .tag_str(&hex::encode(root.id)) .tag_str("") .tag_str("root") .start_tag() .tag_str("e") .tag_str(&hex::encode(replying_to.id())) .tag_str("") .tag_str("reply") .sign(seckey) } else { // we're replying to a post that isn't in a thread, // just add a single reply-to-root tag builder .start_tag() .tag_str("e") .tag_str(&hex::encode(replying_to.id())) .tag_str("") .tag_str("root") .sign(seckey) }; let mut seen_p: HashSet<&[u8; 32]> = HashSet::new(); builder = builder .start_tag() .tag_str("p") .tag_str(&hex::encode(replying_to.pubkey())); seen_p.insert(replying_to.pubkey()); for tag in replying_to.tags() { if tag.count() < 2 { continue; } if tag.get_unchecked(0).variant().str() != Some("p") { continue; } let id = if let Some(id) = tag.get_unchecked(1).variant().id() { id } else { continue; }; if seen_p.contains(id) { continue; } seen_p.insert(id); builder = builder.start_tag().tag_str("p").tag_str(&hex::encode(id)); } if !self.media.is_empty() { builder = add_imeta_tags(builder, &self.media); } if !self.mentions.is_empty() { builder = add_mention_tags(builder, &self.mentions); } builder .sign(seckey) .build() .expect("expected build to work") } pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note { let mut new_content = format!( "{}\nnostr:{}", self.content, enostr::NoteId::new(*quoting.id()).to_bech().unwrap() ); append_urls(&mut new_content, &self.media); let mut builder = NoteBuilder::new().kind(1).content(&new_content); for hashtag in Self::extract_hashtags(&self.content) { builder = builder.start_tag().tag_str("t").tag_str(&hashtag); } if !self.media.is_empty() { builder = add_imeta_tags(builder, &self.media); } if !self.mentions.is_empty() { builder = add_mention_tags(builder, &self.mentions); } builder .start_tag() .tag_str("q") .tag_str(&hex::encode(quoting.id())) .start_tag() .tag_str("p") .tag_str(&hex::encode(quoting.pubkey())) .sign(seckey) .build() .expect("expected build to work") } fn extract_hashtags(content: &str) -> HashSet { let mut hashtags = HashSet::new(); for word in content.split(|c: char| c.is_whitespace() || (c.is_ascii_punctuation() && c != '#')) { if word.starts_with('#') && word.len() > 1 { let tag = word[1..].to_lowercase(); if !tag.is_empty() { hashtags.insert(tag); } } } hashtags } } fn append_urls(content: &mut String, media: &Vec) { for ev in media { content.push(' '); content.push_str(&ev.url); } } fn add_mention_tags<'a>(builder: NoteBuilder<'a>, mentions: &Vec) -> NoteBuilder<'a> { let mut builder = builder; for mention in mentions { builder = builder.start_tag().tag_str("p").tag_str(&mention.hex()); } builder } fn add_imeta_tags<'a>(builder: NoteBuilder<'a>, media: &Vec) -> NoteBuilder<'a> { let mut builder = builder; for item in media { builder = builder .start_tag() .tag_str("imeta") .tag_str(&format!("url {}", item.url)); if let Some(ox) = &item.ox { builder = builder.tag_str(&format!("ox {ox}")); }; if let Some(x) = &item.x { builder = builder.tag_str(&format!("x {x}")); } if let Some(media_type) = &item.media_type { builder = builder.tag_str(&format!("m {media_type}")); } if let Some(dims) = &item.dimensions { builder = builder.tag_str(&format!("dim {}x{}", dims.0, dims.1)); } if let Some(bh) = &item.blurhash { builder = builder.tag_str(&format!("blurhash {bh}")); } if let Some(thumb) = &item.thumb { builder = builder.tag_str(&format!("thumb {thumb}")); } } builder } type MentionKey = usize; #[derive(Debug, Clone)] pub struct PostBuffer { pub text_buffer: String, pub mention_indicator: char, pub mentions: HashMap, mentions_key: MentionKey, pub selected_mention: bool, // the start index of a mention is inclusive pub mention_starts: BTreeMap, // maps the mention start index with the correct `MentionKey` // the end index of a mention is exclusive pub mention_ends: BTreeMap, // maps the mention end index with the correct `MentionKey` } impl Default for PostBuffer { fn default() -> Self { Self { mention_indicator: '@', mentions_key: 0, selected_mention: false, text_buffer: Default::default(), mentions: Default::default(), mention_starts: Default::default(), mention_ends: Default::default(), } } } impl PostBuffer { pub fn get_new_mentions_key(&mut self) -> usize { let prev = self.mentions_key; self.mentions_key += 1; prev } pub fn get_mention(&self, cursor_index: usize) -> Option> { self.mention_ends .range(cursor_index..) .next() .and_then(|(_, mention_key)| { self.mentions .get(mention_key) .filter(|info| { if let MentionType::Finalized(_) = info.mention_type { // should exclude the last character if we're finalized info.start_index <= cursor_index && cursor_index < info.end_index } else { info.start_index <= cursor_index && cursor_index <= info.end_index } }) .map(|info| MentionIndex { index: *mention_key, info, }) }) } pub fn get_mention_string<'a>(&'a self, mention_key: &MentionIndex<'a>) -> &'a str { self.text_buffer .char_range(mention_key.info.start_index + 1..mention_key.info.end_index) // don't include the delim } pub fn select_full_mention(&mut self, mention_key: usize, pk: Pubkey) { if let Some(info) = self.mentions.get_mut(&mention_key) { info.mention_type = MentionType::Finalized(pk); self.selected_mention = true; } else { error!("Error selecting mention for index: {mention_key}. Have the following mentions: {:?}", self.mentions); } } pub fn select_mention_and_replace_name( &mut self, mention_key: usize, full_name: &str, pk: Pubkey, ) { if let Some(info) = self.mentions.get(&mention_key) { let text_start_index = info.start_index + 1; self.delete_char_range(text_start_index..info.end_index); self.insert_text(full_name, text_start_index); self.select_full_mention(mention_key, pk); } else { error!("Error selecting mention for index: {mention_key}. Have the following mentions: {:?}", self.mentions); } } pub fn is_empty(&self) -> bool { self.text_buffer.is_empty() } pub fn output(&self) -> PostOutput { let mut out = self.text_buffer.clone(); let mut mentions = Vec::new(); for (cur_end_ind, mention_ind) in self.mention_ends.iter().rev() { if let Some(info) = self.mentions.get(mention_ind) { if let MentionType::Finalized(pk) = info.mention_type { if let Some(bech) = pk.to_bech() { if let Some(byte_range) = char_indices_to_byte(&out, info.start_index..*cur_end_ind) { out.replace_range(byte_range, &format!("nostr:{bech}")); mentions.push(pk); } } } } } mentions.reverse(); PostOutput { text: out, mentions, } } pub fn to_layout_job(&self, ui: &egui::Ui) -> LayoutJob { let mut job = LayoutJob::default(); let colored_fmt = default_text_format_colored(ui, crate::colors::PINK); let mut prev_text_char_index = 0; let mut prev_text_byte_index = 0; for (start_char_index, mention_ind) in &self.mention_starts { if let Some(info) = self.mentions.get(mention_ind) { if matches!(info.mention_type, MentionType::Finalized(_)) { let end_char_index = info.end_index; let char_indices = prev_text_char_index..*start_char_index; if let Some(byte_indicies) = char_indices_to_byte(&self.text_buffer, char_indices.clone()) { if let Some(prev_text) = self.text_buffer.get(byte_indicies.clone()) { job.append(prev_text, 0.0, default_text_format(ui)); prev_text_char_index = *start_char_index; prev_text_byte_index = byte_indicies.end; } } let char_indices = *start_char_index..end_char_index; if let Some(byte_indicies) = char_indices_to_byte(&self.text_buffer, char_indices.clone()) { if let Some(cur_text) = self.text_buffer.get(byte_indicies.clone()) { job.append(cur_text, 0.0, colored_fmt.clone()); prev_text_char_index = end_char_index; prev_text_byte_index = byte_indicies.end; } } } } } if prev_text_byte_index < self.text_buffer.len() { if let Some(cur_text) = self.text_buffer.get(prev_text_byte_index..) { job.append(cur_text, 0.0, default_text_format(ui)); } else { error!( "could not retrieve substring from [{} to {}) in PostBuffer::text_buffer", prev_text_byte_index, self.text_buffer.len() ); } } job } pub fn need_new_layout(&self, cache: Option<&(String, LayoutJob)>) -> bool { if let Some((text, _)) = cache { if self.selected_mention { return true; } self.text_buffer != *text } else { true } } } fn char_indices_to_byte(text: &str, char_range: Range) -> Option> { let mut char_indices = text.char_indices(); let start = char_indices.nth(char_range.start)?.0; let end = if char_range.end < text.chars().count() { char_indices.nth(char_range.end - char_range.start - 1)?.0 } else { text.len() }; Some(start..end) } pub fn downcast_post_buffer(buffer: &dyn TextBuffer) -> Option<&PostBuffer> { let mut hasher = DefaultHasher::new(); TypeId::of::().hash(&mut hasher); let post_id = hasher.finish() as usize; if buffer.type_id() == post_id { unsafe { Some(&*(buffer as *const dyn TextBuffer as *const PostBuffer)) } } else { None } } fn default_text_format(ui: &egui::Ui) -> TextFormat { default_text_format_colored( ui, ui.visuals() .override_text_color .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()), ) } fn default_text_format_colored(ui: &egui::Ui, color: egui::Color32) -> TextFormat { TextFormat::simple(egui::FontSelection::default().resolve(ui.style()), color) } pub struct PostOutput { pub text: String, pub mentions: Vec, } #[derive(Debug)] pub struct MentionIndex<'a> { pub index: usize, pub info: &'a MentionInfo, } #[derive(Clone, Debug, PartialEq)] pub enum MentionType { Pending, Finalized(Pubkey), } impl TextBuffer for PostBuffer { fn is_mutable(&self) -> bool { true } fn as_str(&self) -> &str { self.text_buffer.as_str() } fn insert_text(&mut self, text: &str, char_index: usize) -> usize { if text.is_empty() { return 0; } let text_num_chars = text.chars().count(); self.text_buffer.insert_text(text, char_index); // the text was inserted before or inside these mentions. We need to at least move their ends let pending_ends_to_update: Vec = self .mention_ends .range(char_index..) .filter(|(k, v)| { let is_last = **k == char_index; let is_finalized = if let Some(info) = self.mentions.get(*v) { matches!(info.mention_type, MentionType::Finalized(_)) } else { false }; !(is_last && is_finalized) }) .map(|(&k, _)| k) .collect(); for cur_end in pending_ends_to_update { let mention_key = if let Some(mention_key) = self.mention_ends.get(&cur_end) { *mention_key } else { continue; }; self.mention_ends.remove(&cur_end); let new_end = cur_end + text_num_chars; self.mention_ends.insert(new_end, mention_key); // replaced the current end with the new value if let Some(mention_info) = self.mentions.get_mut(&mention_key) { if mention_info.start_index >= char_index { // the text is being inserted before this mention. move the start index as well self.mention_starts.remove(&mention_info.start_index); let new_start = mention_info.start_index + text_num_chars; self.mention_starts.insert(new_start, mention_key); mention_info.start_index = new_start; } else { // text is being inserted inside this mention. Make sure it is in the pending state mention_info.mention_type = MentionType::Pending; } mention_info.end_index = new_end; } else { error!("Could not find mention at index {}", mention_key); } } if first_is_desired_char(text, self.mention_indicator) { // if a mention already exists where we're inserting the delim, remove it let to_remove = self.get_mention(char_index).map(|old_mention| { ( old_mention.index, old_mention.info.start_index..old_mention.info.end_index, ) }); if let Some((key, range)) = to_remove { self.mention_ends.remove(&range.end); self.mention_starts.remove(&range.start); self.mentions.remove(&key); } let start_index = char_index; let end_index = char_index + text_num_chars; let mention_key = self.get_new_mentions_key(); self.mentions.insert( mention_key, MentionInfo { start_index, end_index, mention_type: MentionType::Pending, }, ); self.mention_starts.insert(start_index, mention_key); self.mention_ends.insert(end_index, mention_key); } text_num_chars } fn delete_char_range(&mut self, char_range: Range) { let deletion_num_chars = char_range.len(); let Range { start: deletion_start, end: deletion_end, } = char_range; self.text_buffer.delete_char_range(char_range); // these mentions will be affected by the deletion let ends_to_update: Vec = self .mention_ends .range(deletion_start..) .map(|(&k, _)| k) .collect(); for cur_mention_end in ends_to_update { let mention_key = match &self.mention_ends.get(&cur_mention_end) { Some(ind) => **ind, None => continue, }; let cur_mention_start = match self.mentions.get(&mention_key) { Some(i) => i.start_index, None => { error!("Could not find mention at index {}", mention_key); continue; } }; if cur_mention_end <= deletion_start { // nothing happens to this mention continue; } let status = if cur_mention_start >= deletion_start { if cur_mention_start >= deletion_end { // mention falls after the range // need to shift both start and end DeletionStatus::ShiftStartAndEnd( cur_mention_start - deletion_num_chars, cur_mention_end - deletion_num_chars, ) } else { // fully delete mention DeletionStatus::FullyRemove } } else if cur_mention_end > deletion_end { // inner partial delete DeletionStatus::ShiftEnd(cur_mention_end - deletion_num_chars) } else { // outer partial delete DeletionStatus::ShiftEnd(deletion_start) }; match status { DeletionStatus::FullyRemove => { self.mention_starts.remove(&cur_mention_start); self.mention_ends.remove(&cur_mention_end); self.mentions.remove(&mention_key); } DeletionStatus::ShiftEnd(new_end) | DeletionStatus::ShiftStartAndEnd(_, new_end) => { let mention_info = match self.mentions.get_mut(&mention_key) { Some(i) => i, None => { error!("Could not find mention at index {}", mention_key); continue; } }; self.mention_ends.remove(&cur_mention_end); self.mention_ends.insert(new_end, mention_key); mention_info.end_index = new_end; if let DeletionStatus::ShiftStartAndEnd(new_start, _) = status { self.mention_starts.remove(&cur_mention_start); self.mention_starts.insert(new_start, mention_key); mention_info.start_index = new_start; } if let DeletionStatus::ShiftEnd(_) = status { mention_info.mention_type = MentionType::Pending; } } } } } fn type_id(&self) -> usize { let mut hasher = DefaultHasher::new(); TypeId::of::().hash(&mut hasher); hasher.finish() as usize } } fn first_is_desired_char(text: &str, desired: char) -> bool { if let Some(char) = text.chars().next() { char == desired } else { false } } #[derive(Debug)] enum DeletionStatus { FullyRemove, ShiftEnd(usize), ShiftStartAndEnd(usize, usize), } #[derive(Debug, PartialEq, Clone)] pub struct MentionInfo { pub start_index: usize, pub end_index: usize, pub mention_type: MentionType, } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; impl MentionInfo { pub fn bounds(&self) -> Range { self.start_index..self.end_index } } const JB55: fn() -> Pubkey = || { Pubkey::from_hex("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245") .unwrap() }; const KK: fn() -> Pubkey = || { Pubkey::from_hex("4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967") .unwrap() }; #[derive(PartialEq, Clone, Debug)] struct MentionExample { text: String, mention1: Option, mention2: Option, mention3: Option, mention4: Option, } fn apply_mention_example(buf: &mut PostBuffer) -> MentionExample { buf.insert_text("test ", 0); buf.insert_text("@jb55", 5); buf.select_full_mention(0, JB55()); buf.insert_text(" test ", 10); buf.insert_text("@vrod", 16); buf.select_full_mention(1, JB55()); buf.insert_text(" test ", 21); buf.insert_text("@elsat", 27); buf.select_full_mention(2, JB55()); buf.insert_text(" test ", 33); buf.insert_text("@kernelkind", 39); buf.select_full_mention(3, KK()); buf.insert_text(" test", 50); let mention1_bounds = 5..10; let mention2_bounds = 16..21; let mention3_bounds = 27..33; let mention4_bounds = 39..50; let text = "test @jb55 test @vrod test @elsat test @kernelkind test"; assert_eq!(buf.as_str(), text); assert_eq!(buf.mentions.len(), 4); let mention1 = buf.mentions.get(&0).unwrap(); assert_eq!(mention1.bounds(), mention1_bounds); assert_eq!(mention1.mention_type, MentionType::Finalized(JB55())); let mention2 = buf.mentions.get(&1).unwrap(); assert_eq!(mention2.bounds(), mention2_bounds); assert_eq!(mention2.mention_type, MentionType::Finalized(JB55())); let mention3 = buf.mentions.get(&2).unwrap(); assert_eq!(mention3.bounds(), mention3_bounds); assert_eq!(mention3.mention_type, MentionType::Finalized(JB55())); let mention4 = buf.mentions.get(&3).unwrap(); assert_eq!(mention4.bounds(), mention4_bounds); assert_eq!(mention4.mention_type, MentionType::Finalized(KK())); let text = text.to_owned(); MentionExample { text, mention1: Some(mention1.clone()), mention2: Some(mention2.clone()), mention3: Some(mention3.clone()), mention4: Some(mention4.clone()), } } impl PostBuffer { fn to_example(&self) -> MentionExample { let mention1 = self.mentions.get(&0).cloned(); let mention2 = self.mentions.get(&1).cloned(); let mention3 = self.mentions.get(&2).cloned(); let mention4 = self.mentions.get(&3).cloned(); MentionExample { text: self.text_buffer.clone(), mention1, mention2, mention3, mention4, } } } impl MentionInfo { fn shifted(mut self, offset: usize) -> Self { self.end_index -= offset; self.start_index -= offset; self } } #[test] fn test_extract_hashtags() { let test_cases = vec![ ("Hello #world", vec!["world"]), ("Multiple #tags #in #one post", vec!["tags", "in", "one"]), ("No hashtags here", vec![]), ("#tag1 with #tag2!", vec!["tag1", "tag2"]), ("Ignore # empty", vec![]), ("Testing emoji #🍌banana", vec!["🍌banana"]), ("Testing emoji #🍌", vec!["🍌"]), ("Duplicate #tag #tag #tag", vec!["tag"]), ("Mixed case #TaG #tag #TAG", vec!["tag"]), ( "#tag1, #tag2, #tag3 with commas", vec!["tag1", "tag2", "tag3"], ), ("Separated by commas #tag1,#tag2", vec!["tag1", "tag2"]), ("Separated by periods #tag1.#tag2", vec!["tag1", "tag2"]), ("Separated by semicolons #tag1;#tag2", vec!["tag1", "tag2"]), ]; for (input, expected) in test_cases { let result = NewPost::extract_hashtags(input); let expected: HashSet = expected.into_iter().map(String::from).collect(); assert_eq!(result, expected, "Failed for input: {}", input); } } #[test] fn test_insert_single_mention() { let mut buf = PostBuffer::default(); buf.insert_text("test ", 0); buf.insert_text("@", 5); assert!(buf.get_mention(5).is_some()); buf.insert_text("jb55", 6); assert_eq!(buf.as_str(), "test @jb55"); assert_eq!(buf.mentions.len(), 1); assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 5..10); buf.select_full_mention(0, JB55()); assert_eq!( buf.mentions.get(&0).unwrap().mention_type, MentionType::Finalized(JB55()) ); } #[test] fn test_insert_mention_with_space() { let mut buf = PostBuffer::default(); buf.insert_text("@", 0); buf.insert_text("jb", 1); buf.insert_text("55", 3); assert!(buf.get_mention(1).is_some()); assert_eq!(buf.mentions.len(), 1); assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..5); buf.insert_text(" test", 5); assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..10); assert_eq!(buf.as_str(), "@jb55 test"); buf.select_full_mention(0, JB55()); assert_eq!( buf.mentions.get(&0).unwrap().mention_type, MentionType::Finalized(JB55()) ); } #[test] fn test_insert_mention_with_emojis() { let mut buf = PostBuffer::default(); buf.insert_text("test ", 0); buf.insert_text("@test😀 🏴‍☠️ :D", 5); buf.select_full_mention(0, JB55()); buf.insert_text(" test", 19); assert_eq!(buf.as_str(), "test @test😀 🏴‍☠️ :D test"); let mention = buf.mentions.get(&0).unwrap(); assert_eq!( *mention, MentionInfo { start_index: 5, end_index: 19, mention_type: MentionType::Finalized(JB55()) } ); } #[test] fn test_insert_partial_to_full() { let mut buf = PostBuffer::default(); buf.insert_text("@jb", 0); assert_eq!(buf.mentions.len(), 1); assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..3); buf.select_mention_and_replace_name(0, "jb55", JB55()); assert_eq!(buf.as_str(), "@jb55"); buf.insert_text(" test", 5); assert_eq!(buf.as_str(), "@jb55 test"); assert_eq!(buf.mentions.len(), 1); let mention = buf.mentions.get(&0).unwrap(); assert_eq!(mention.bounds(), 0..5); assert_eq!(mention.mention_type, MentionType::Finalized(JB55())); } #[test] fn test_insert_mention_after() { let mut buf = PostBuffer::default(); buf.insert_text("test text here", 0); buf.insert_text("@jb55", 4); assert!(buf.get_mention(4).is_some()); assert_eq!(buf.mentions.len(), 1); assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 4..9); assert_eq!("test@jb55 text here", buf.as_str()); buf.select_full_mention(0, JB55()); assert_eq!( buf.mentions.get(&0).unwrap().mention_type, MentionType::Finalized(JB55()) ); } #[test] fn test_insert_mention_then_text() { let mut buf = PostBuffer::default(); buf.insert_text("@jb55", 0); buf.select_full_mention(0, JB55()); buf.insert_text(" test", 5); assert_eq!(buf.as_str(), "@jb55 test"); assert_eq!(buf.mentions.len(), 1); assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..5); assert!(buf.get_mention(6).is_none()); } #[test] fn test_insert_two_mentions() { let mut buf = PostBuffer::default(); buf.insert_text("@jb55", 0); buf.select_full_mention(0, JB55()); buf.insert_text(" test ", 5); buf.insert_text("@kernelkind", 11); buf.select_full_mention(1, KK()); buf.insert_text(" test", 22); assert_eq!(buf.as_str(), "@jb55 test @kernelkind test"); assert_eq!(buf.mentions.len(), 2); assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..5); assert_eq!(buf.mentions.get(&1).unwrap().bounds(), 11..22); } #[test] fn test_insert_into_mention() { let mut buf = PostBuffer::default(); buf.insert_text("@jb55", 0); buf.select_full_mention(0, JB55()); buf.insert_text(" test", 5); assert_eq!(buf.mentions.len(), 1); let mention = buf.mentions.get(&0).unwrap(); assert_eq!(mention.bounds(), 0..5); assert_eq!(mention.mention_type, MentionType::Finalized(JB55())); buf.insert_text("oops", 2); assert_eq!(buf.as_str(), "@joopsb55 test"); assert_eq!(buf.mentions.len(), 1); let mention = buf.mentions.get(&0).unwrap(); assert_eq!(mention.bounds(), 0..9); assert_eq!(mention.mention_type, MentionType::Pending); } #[test] fn test_insert_mention_inside_mention() { let mut buf = PostBuffer::default(); buf.insert_text("@jb55", 0); buf.select_full_mention(0, JB55()); buf.insert_text(" test", 5); assert_eq!(buf.mentions.len(), 1); let mention = buf.mentions.get(&0).unwrap(); assert_eq!(mention.bounds(), 0..5); assert_eq!(mention.mention_type, MentionType::Finalized(JB55())); buf.insert_text("@oops", 3); assert_eq!(buf.as_str(), "@jb@oops55 test"); assert_eq!(buf.mentions.len(), 1); assert_eq!(buf.mention_ends.len(), 1); assert_eq!(buf.mention_starts.len(), 1); let mention = buf.mentions.get(&1).unwrap(); assert_eq!(mention.bounds(), 3..8); assert_eq!(mention.mention_type, MentionType::Pending); } #[test] fn test_delete_before_mention() { let mut buf = PostBuffer::default(); let before = apply_mention_example(&mut buf); let range = 1..5; let len = range.len(); buf.delete_char_range(range); assert_eq!( MentionExample { text: "t@jb55 test @vrod test @elsat test @kernelkind test".to_owned(), mention1: Some(before.mention1.clone().unwrap().shifted(len)), mention2: Some(before.mention2.clone().unwrap().shifted(len)), mention3: Some(before.mention3.clone().unwrap().shifted(len)), mention4: Some(before.mention4.clone().unwrap().shifted(len)), }, buf.to_example(), ); } #[test] fn test_delete_after_mention() { let mut buf = PostBuffer::default(); let before = apply_mention_example(&mut buf); let range = 11..16; let len = range.len(); buf.delete_char_range(range); assert_eq!( MentionExample { text: "test @jb55 @vrod test @elsat test @kernelkind test".to_owned(), mention2: Some(before.mention2.clone().unwrap().shifted(len)), mention3: Some(before.mention3.clone().unwrap().shifted(len)), mention4: Some(before.mention4.clone().unwrap().shifted(len)), ..before.clone() }, buf.to_example(), ); } #[test] fn test_delete_mention_partial_inner() { let mut buf = PostBuffer::default(); let before = apply_mention_example(&mut buf); let range = 17..20; let len = range.len(); buf.delete_char_range(range); assert_eq!( MentionExample { text: "test @jb55 test @d test @elsat test @kernelkind test".to_owned(), mention2: Some(MentionInfo { start_index: 16, end_index: 18, mention_type: MentionType::Pending, }), mention3: Some(before.mention3.clone().unwrap().shifted(len)), mention4: Some(before.mention4.clone().unwrap().shifted(len)), ..before.clone() }, buf.to_example(), ); } #[test] fn test_delete_mention_partial_outer() { let mut buf = PostBuffer::default(); let before = apply_mention_example(&mut buf); let range = 17..27; let len = range.len(); buf.delete_char_range(range); assert_eq!( MentionExample { text: "test @jb55 test @@elsat test @kernelkind test".to_owned(), mention2: Some(MentionInfo { start_index: 16, end_index: 17, mention_type: MentionType::Pending }), mention3: Some(before.mention3.clone().unwrap().shifted(len)), mention4: Some(before.mention4.clone().unwrap().shifted(len)), ..before.clone() }, buf.to_example(), ); } #[test] fn test_delete_mention_partial_and_full() { let mut buf = PostBuffer::default(); let before = apply_mention_example(&mut buf); buf.delete_char_range(17..28); assert_eq!( MentionExample { text: "test @jb55 test @elsat test @kernelkind test".to_owned(), mention2: Some(MentionInfo { end_index: 17, mention_type: MentionType::Pending, ..before.mention2.clone().unwrap() }), mention3: None, mention4: Some(MentionInfo { start_index: 28, end_index: 39, ..before.mention4.clone().unwrap() }), ..before.clone() }, buf.to_example() ) } #[test] fn test_delete_mention_full_one() { let mut buf = PostBuffer::default(); let before = apply_mention_example(&mut buf); let range = 10..26; let len = range.len(); buf.delete_char_range(range); assert_eq!( MentionExample { text: "test @jb55 @elsat test @kernelkind test".to_owned(), mention2: None, mention3: Some(before.mention3.clone().unwrap().shifted(len)), mention4: Some(before.mention4.clone().unwrap().shifted(len)), ..before.clone() }, buf.to_example() ); } #[test] fn test_delete_mention_full_two() { let mut buf = PostBuffer::default(); let before = apply_mention_example(&mut buf); buf.delete_char_range(11..28); assert_eq!( MentionExample { text: "test @jb55 elsat test @kernelkind test".to_owned(), mention2: None, mention3: None, mention4: Some(MentionInfo { start_index: 22, end_index: 33, ..before.mention4.clone().unwrap() }), ..before.clone() }, buf.to_example() ) } #[test] fn test_two_then_one_between() { let mut buf = PostBuffer::default(); buf.insert_text("@jb", 0); buf.select_mention_and_replace_name(0, "jb55", JB55()); buf.insert_text(" test ", 5); buf.insert_text("@kernel", 11); buf.select_mention_and_replace_name(1, "KernelKind", KK()); buf.insert_text(" test", 22); assert_eq!(buf.as_str(), "@jb55 test @KernelKind test"); assert_eq!(buf.mentions.len(), 2); buf.insert_text(" ", 5); buf.insert_text("@els", 6); assert_eq!(buf.mentions.len(), 3); assert_eq!(buf.mentions.get(&2).unwrap().bounds(), 6..10); buf.select_mention_and_replace_name(2, "elsat", JB55()); assert_eq!(buf.as_str(), "@jb55 @elsat test @KernelKind test"); let jb_mention = buf.mentions.get(&0).unwrap(); let kk_mention = buf.mentions.get(&1).unwrap(); let el_mention = buf.mentions.get(&2).unwrap(); assert_eq!(jb_mention.bounds(), 0..5); assert_eq!(jb_mention.mention_type, MentionType::Finalized(JB55())); assert_eq!(kk_mention.bounds(), 18..29); assert_eq!(kk_mention.mention_type, MentionType::Finalized(KK())); assert_eq!(el_mention.bounds(), 6..12); assert_eq!(el_mention.mention_type, MentionType::Finalized(JB55())); } #[test] fn note_single_mention() { let mut buf = PostBuffer::default(); buf.insert_text("@jb55", 0); buf.select_full_mention(0, JB55()); let out = buf.output(); let kp = FullKeypair::generate(); let post = NewPost::new(out.text, kp.clone(), Vec::new(), out.mentions); let note = post.to_note(&kp.pubkey); let mut tags_iter = note.tags().iter(); tags_iter.next(); //ignore the first one, the client tag let tag = tags_iter.next().unwrap(); assert_eq!(tag.count(), 2); assert_eq!(tag.get(0).unwrap().str().unwrap(), "p"); assert_eq!(tag.get(1).unwrap().id().unwrap(), JB55().bytes()); assert!(tags_iter.next().is_none()); assert_eq!( note.content(), "nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s" ); } #[test] fn note_two_mentions() { let mut buf = PostBuffer::default(); buf.insert_text("@jb55", 0); buf.select_full_mention(0, JB55()); buf.insert_text(" test ", 5); buf.insert_text("@KernelKind", 11); buf.select_full_mention(1, KK()); buf.insert_text(" test", 22); assert_eq!(buf.as_str(), "@jb55 test @KernelKind test"); let out = buf.output(); let kp = FullKeypair::generate(); let post = NewPost::new(out.text, kp.clone(), Vec::new(), out.mentions); let note = post.to_note(&kp.pubkey); let mut tags_iter = note.tags().iter(); tags_iter.next(); //ignore the first one, the client tag let jb_tag = tags_iter.next().unwrap(); assert_eq!(jb_tag.count(), 2); assert_eq!(jb_tag.get(0).unwrap().str().unwrap(), "p"); assert_eq!(jb_tag.get(1).unwrap().id().unwrap(), JB55().bytes()); let kk_tag = tags_iter.next().unwrap(); assert_eq!(kk_tag.count(), 2); assert_eq!(kk_tag.get(0).unwrap().str().unwrap(), "p"); assert_eq!(kk_tag.get(1).unwrap().id().unwrap(), KK().bytes()); assert!(tags_iter.next().is_none()); assert_eq!(note.content(), "nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s test nostr:npub1fgz3pungsr2quse0fpjuk4c5m8fuyqx2d6a3ddqc4ek92h6hf9ns0mjeck test"); } #[test] fn note_one_pending() { let mut buf = PostBuffer::default(); buf.insert_text("test ", 0); buf.insert_text("@jb55 test", 5); let out = buf.output(); let kp = FullKeypair::generate(); let post = NewPost::new(out.text, kp.clone(), Vec::new(), out.mentions); let note = post.to_note(&kp.pubkey); let mut tags_iter = note.tags().iter(); tags_iter.next(); //ignore the first one, the client tag assert!(tags_iter.next().is_none()); assert_eq!(note.content(), "test @jb55 test"); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/test_utils.rs`: ```rs use poll_promise::Promise; use std::thread; use std::time::Duration; pub fn promise_wait<'a, T: Send + 'a>(promise: &'a Promise) -> &'a T { let mut count = 1; loop { if let Some(result) = promise.ready() { println!("quieried promise num times: {}", count); return result; } else { count += 1; thread::sleep(Duration::from_millis(10)); } } } /// `promise_assert` macro /// /// This macro is designed to emulate the nature of immediate mode asynchronous code by repeatedly calling /// promise.ready() for a promise, sleeping for a short period of time, and repeating until the promise is ready. /// /// Arguments: /// - `$assertion_closure`: the assertion closure which takes two arguments: the actual result of the promise and /// the expected value. This macro is used as an assertion closure to compare the actual and expected values. /// - `$expected`: The expected value of type `T` that the promise's result is compared against. /// - `$asserted_promise`: A `Promise` that returns a value of type `T` when the promise is satisfied. This /// represents the asynchronous operation whose result will be tested. /// #[macro_export] macro_rules! promise_assert { ($assertion_closure:ident, $expected:expr, $asserted_promise:expr) => { let result = $crate::test_utils::promise_wait($asserted_promise); $assertion_closure!(*result, $expected); }; } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/app_style.rs`: ```rs use egui::{FontFamily, FontId}; use notedeck::fonts::NamedFontFamily; pub static DECK_ICON_SIZE: f32 = 24.0; pub fn deck_icon_font_sized(size: f32) -> FontId { egui::FontId::new(size, emoji_font_family()) } pub fn emoji_font_family() -> FontFamily { egui::FontFamily::Name(NamedFontFamily::Emoji.as_str().into()) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/storage/mod.rs`: ```rs mod decks; pub use decks::{load_decks_cache, save_decks_cache, DECKS_CACHE_FILE}; ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/storage/decks.rs`: ```rs use std::{collections::HashMap, fmt, str::FromStr}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; use serde::{Deserialize, Serialize}; use tracing::{error, info}; use crate::{ column::{Columns, IntermediaryRoute}, decks::{Deck, Decks, DecksCache}, route::Route, timeline::{TimelineCache, TimelineKind}, Error, }; use notedeck::{storage, DataPath, DataPathType, Directory}; use tokenator::{ParseError, TokenParser, TokenWriter}; pub static DECKS_CACHE_FILE: &str = "decks_cache.json"; pub fn load_decks_cache( path: &DataPath, ndb: &Ndb, timeline_cache: &mut TimelineCache, ) -> Option { let data_path = path.path(DataPathType::Setting); let decks_cache_str = match Directory::new(data_path).get_file(DECKS_CACHE_FILE.to_owned()) { Ok(s) => s, Err(e) => { error!( "Could not read decks cache from file {}: {}", DECKS_CACHE_FILE, e ); return None; } }; let serializable_decks_cache = serde_json::from_str::(&decks_cache_str).ok()?; serializable_decks_cache .decks_cache(ndb, timeline_cache) .ok() } pub fn save_decks_cache(path: &DataPath, decks_cache: &DecksCache) { let serialized_decks_cache = match serde_json::to_string(&SerializableDecksCache::to_serializable(decks_cache)) { Ok(s) => s, Err(e) => { error!("Could not serialize decks cache: {}", e); return; } }; let data_path = path.path(DataPathType::Setting); if let Err(e) = storage::write_file( &data_path, DECKS_CACHE_FILE.to_string(), &serialized_decks_cache, ) { error!( "Could not write decks cache to file {}: {}", DECKS_CACHE_FILE, e ); } else { info!("Successfully wrote decks cache to {}", DECKS_CACHE_FILE); } } #[derive(Serialize, Deserialize)] struct SerializableDecksCache { #[serde(serialize_with = "serialize_map", deserialize_with = "deserialize_map")] decks_cache: HashMap, } impl SerializableDecksCache { fn to_serializable(decks_cache: &DecksCache) -> Self { SerializableDecksCache { decks_cache: decks_cache .get_mapping() .iter() .map(|(k, v)| (*k, SerializableDecks::from_decks(v))) .collect(), } } pub fn decks_cache( self, ndb: &Ndb, timeline_cache: &mut TimelineCache, ) -> Result { let account_to_decks = self .decks_cache .into_iter() .map(|(pubkey, serializable_decks)| { serializable_decks .decks(ndb, timeline_cache, &pubkey) .map(|decks| (pubkey, decks)) }) .collect::, Error>>()?; Ok(DecksCache::new(account_to_decks)) } } fn serialize_map( map: &HashMap, serializer: S, ) -> Result where S: serde::Serializer, { let stringified_map: HashMap = map.iter().map(|(k, v)| (k.hex(), v)).collect(); stringified_map.serialize(serializer) } fn deserialize_map<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { let stringified_map: HashMap = HashMap::deserialize(deserializer)?; stringified_map .into_iter() .map(|(k, v)| { let key = Pubkey::from_hex(&k).map_err(serde::de::Error::custom)?; Ok((key, v)) }) .collect() } #[derive(Serialize, Deserialize)] struct SerializableDecks { active_deck: usize, decks: Vec, } impl SerializableDecks { pub fn from_decks(decks: &Decks) -> Self { Self { active_deck: decks.active_index(), decks: decks .decks() .iter() .map(SerializableDeck::from_deck) .collect(), } } fn decks( self, ndb: &Ndb, timeline_cache: &mut TimelineCache, deck_key: &Pubkey, ) -> Result { Ok(Decks::from_decks( self.active_deck, self.decks .into_iter() .map(|d| d.deck(ndb, timeline_cache, deck_key)) .collect::>()?, )) } } #[derive(Serialize, Deserialize)] struct SerializableDeck { metadata: Vec, columns: Vec>, } #[derive(PartialEq, Clone)] enum MetadataKeyword { Icon, Name, } impl MetadataKeyword { const MAPPING: &'static [(&'static str, MetadataKeyword)] = &[ ("icon", MetadataKeyword::Icon), ("name", MetadataKeyword::Name), ]; } impl fmt::Display for MetadataKeyword { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if let Some(name) = MetadataKeyword::MAPPING .iter() .find(|(_, keyword)| keyword == self) .map(|(name, _)| *name) { write!(f, "{}", name) } else { write!(f, "UnknownMetadataKeyword") } } } impl FromStr for MetadataKeyword { type Err = Error; fn from_str(serialized: &str) -> Result { MetadataKeyword::MAPPING .iter() .find(|(name, _)| *name == serialized) .map(|(_, keyword)| keyword.clone()) .ok_or(Error::Generic( "Could not convert string to Keyword enum".to_owned(), )) } } struct MetadataPayload { keyword: MetadataKeyword, value: String, } impl MetadataPayload { fn new(keyword: MetadataKeyword, value: String) -> Self { Self { keyword, value } } } fn serialize_metadata(payloads: Vec) -> Vec { payloads .into_iter() .map(|payload| format!("{}:{}", payload.keyword, payload.value)) .collect() } fn deserialize_metadata(serialized_metadatas: Vec) -> Option> { let mut payloads = Vec::new(); for serialized_metadata in serialized_metadatas { let cur_split: Vec<&str> = serialized_metadata.split(':').collect(); if cur_split.len() != 2 { continue; } if let Ok(keyword) = MetadataKeyword::from_str(cur_split.first().unwrap()) { payloads.push(MetadataPayload { keyword, value: cur_split.get(1).unwrap().to_string(), }); } } if payloads.is_empty() { None } else { Some(payloads) } } impl SerializableDeck { pub fn from_deck(deck: &Deck) -> Self { let columns = serialize_columns(deck.columns()); let metadata = serialize_metadata(vec![ MetadataPayload::new(MetadataKeyword::Icon, deck.icon.to_string()), MetadataPayload::new(MetadataKeyword::Name, deck.name.clone()), ]); SerializableDeck { metadata, columns } } pub fn deck( self, ndb: &Ndb, timeline_cache: &mut TimelineCache, deck_user: &Pubkey, ) -> Result { let columns = deserialize_columns(ndb, timeline_cache, deck_user, self.columns); let deserialized_metadata = deserialize_metadata(self.metadata) .ok_or(Error::Generic("Could not deserialize metadata".to_owned()))?; let icon = deserialized_metadata .iter() .find(|p| p.keyword == MetadataKeyword::Icon) .map_or_else(|| "🇩", |f| &f.value); let name = deserialized_metadata .iter() .find(|p| p.keyword == MetadataKeyword::Name) .map_or_else(|| "Deck", |f| &f.value) .to_string(); Ok(Deck::new_with_columns( icon.parse::() .map_err(|_| Error::Generic("could not convert String -> char".to_owned()))?, name, columns, )) } } fn serialize_columns(columns: &Columns) -> Vec> { let mut cols_serialized: Vec> = Vec::new(); for column in columns.columns() { let mut column_routes = Vec::new(); for route in column.router().routes() { let mut writer = TokenWriter::default(); route.serialize_tokens(&mut writer); column_routes.push(writer.str().to_string()); } cols_serialized.push(column_routes); } cols_serialized } fn deserialize_columns( ndb: &Ndb, timeline_cache: &mut TimelineCache, deck_user: &Pubkey, columns: Vec>, ) -> Columns { let mut cols = Columns::new(); for column in columns { let mut cur_routes = Vec::new(); for route in column { let tokens: Vec<&str> = route.split(":").collect(); let mut parser = TokenParser::new(&tokens); match CleanIntermediaryRoute::parse(&mut parser, deck_user) { Ok(route_intermediary) => { if let Some(ir) = route_intermediary.into_intermediary_route(ndb) { cur_routes.push(ir); } } Err(err) => { error!("could not turn tokens to RouteIntermediary: {:?}", err); } } } if !cur_routes.is_empty() { cols.insert_intermediary_routes(timeline_cache, cur_routes); } } cols } enum CleanIntermediaryRoute { ToTimeline(TimelineKind), ToRoute(Route), } impl CleanIntermediaryRoute { fn into_intermediary_route(self, ndb: &Ndb) -> Option { match self { CleanIntermediaryRoute::ToTimeline(timeline_kind) => { let txn = Transaction::new(ndb).unwrap(); Some(IntermediaryRoute::Timeline( timeline_kind.into_timeline(&txn, ndb)?, )) } CleanIntermediaryRoute::ToRoute(route) => Some(IntermediaryRoute::Route(route)), } } fn parse<'a>( parser: &mut TokenParser<'a>, deck_author: &Pubkey, ) -> Result> { let timeline = parser.try_parse(|p| { Ok(CleanIntermediaryRoute::ToTimeline(TimelineKind::parse( p, deck_author, )?)) }); if timeline.is_ok() { return timeline; } parser.try_parse(|p| { Ok(CleanIntermediaryRoute::ToRoute(Route::parse( p, deck_author, )?)) }) } } #[cfg(test)] mod tests { //use enostr::Pubkey; //use crate::{route::Route, timeline::TimelineRoute}; //use super::deserialize_columns; /* TODO: re-enable once we have test_app working again #[test] fn test_deserialize_columns() { let serialized = vec![ vec!["universe".to_owned()], vec![ "notifs:explicit:aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe" .to_owned(), ], ]; let user = Pubkey::from_hex("aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe") .unwrap(); let app = test_app(); let cols = deserialize_columns(&app.ndb, user.bytes(), serialized); assert_eq!(cols.columns().len(), 2); let router = cols.column(0).router(); assert_eq!(router.routes().len(), 1); if let Route::Timeline(TimelineRoute::Timeline(_)) = router.routes().first().unwrap() { } else { panic!("The first router route is not a TimelineRoute::Timeline variant"); } let router = cols.column(1).router(); assert_eq!(router.routes().len(), 1); if let Route::Timeline(TimelineRoute::Timeline(_)) = router.routes().first().unwrap() { } else { panic!("The second router route is not a TimelineRoute::Timeline variant"); } } */ } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/draft.rs`: ```rs use egui::text::LayoutJob; use poll_promise::Promise; use crate::{media_upload::Nip94Event, post::PostBuffer, ui::note::PostType, Error}; use std::collections::HashMap; #[derive(Default)] pub struct Draft { pub buffer: PostBuffer, pub cur_layout: Option<(String, LayoutJob)>, // `PostBuffer::text_buffer` to current `LayoutJob` pub cur_mention_hint: Option, pub uploaded_media: Vec, // media uploads to include pub uploading_media: Vec>>, // promises that aren't ready yet pub upload_errors: Vec, // media upload errors to show the user } pub struct MentionHint { pub index: usize, pub pos: egui::Pos2, pub text: String, } #[derive(Default)] pub struct Drafts { replies: HashMap<[u8; 32], Draft>, quotes: HashMap<[u8; 32], Draft>, compose: Draft, } impl Drafts { pub fn compose_mut(&mut self) -> &mut Draft { &mut self.compose } pub fn get_from_post_type(&mut self, post_type: &PostType) -> &mut Draft { match post_type { PostType::New => self.compose_mut(), PostType::Quote(note_id) => self.quote_mut(note_id.bytes()), PostType::Reply(note_id) => self.reply_mut(note_id.bytes()), } } pub fn reply_mut(&mut self, id: &[u8; 32]) -> &mut Draft { self.replies.entry(*id).or_default() } pub fn quote_mut(&mut self, id: &[u8; 32]) -> &mut Draft { self.quotes.entry(*id).or_default() } } impl Draft { pub fn new() -> Self { Draft::default() } pub fn clear(&mut self) { self.buffer = PostBuffer::default(); self.upload_errors = Vec::new(); self.uploaded_media = Vec::new(); self.uploading_media = Vec::new(); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/login_manager.rs`: ```rs use crate::key_parsing::perform_key_retrieval; use crate::key_parsing::AcquireKeyError; use egui::{TextBuffer, TextEdit}; use enostr::Keypair; use poll_promise::Promise; /// The state data for acquiring a nostr key #[derive(Default)] pub struct AcquireKeyState { desired_key: String, promise_query: Option<(String, Promise>)>, error: Option, key_on_error: Option, should_create_new: bool, show_password: bool, } impl<'a> AcquireKeyState { pub fn new() -> Self { AcquireKeyState::default() } /// Get the textedit for the UI without exposing the key variable pub fn get_acquire_textedit( &'a mut self, textedit_closure: fn(&'a mut dyn TextBuffer) -> TextEdit<'a>, ) -> TextEdit<'a> { textedit_closure(&mut self.desired_key) } /// User pressed the 'acquire' button pub fn apply_acquire(&'a mut self) { let new_promise = match &self.promise_query { Some((query, _)) => { if query != &self.desired_key { Some(perform_key_retrieval(&self.desired_key)) } else { None } } None => Some(perform_key_retrieval(&self.desired_key)), }; if let Some(new_promise) = new_promise { self.promise_query = Some((self.desired_key.clone(), new_promise)); } } pub fn is_awaiting_network(&self) -> bool { if let Some((_, promise)) = &self.promise_query { promise.ready().is_none() } else { false } } /// Whether to indicate to the user that a login error occured pub fn check_for_error(&'a mut self) -> Option<&'a AcquireKeyError> { if let Some(error_key) = &self.key_on_error { if self.desired_key != *error_key { self.error = None; self.key_on_error = None; } } self.error.as_ref() } /// Whether to indicate to the user that a successful login occured pub fn get_login_keypair(&mut self) -> Option<&Keypair> { if let Some((_, promise)) = &self.promise_query { match promise.poll() { std::task::Poll::Ready(inner) => match inner { Ok(kp) => Some(kp), Err(e) => { self.error = Some(e.clone()); self.key_on_error = Some(self.desired_key.clone()); None } }, std::task::Poll::Pending => None, } } else { None } } pub fn handle_input_change_after_acquire(&mut self) { if let Some((query, _)) = &self.promise_query { if *query != self.desired_key { self.promise_query = None; } } } pub fn should_create_new(&mut self) { self.should_create_new = true; } pub fn check_for_create_new(&self) -> bool { self.should_create_new } pub fn loading_and_error_ui(&mut self, ui: &mut egui::Ui) { ui.add_space(8.0); ui.vertical_centered(|ui| { if self.is_awaiting_network() { ui.add(egui::Spinner::new()); } }); if let Some(err) = self.check_for_error() { show_error(ui, err); } ui.add_space(8.0); } pub fn toggle_password_visibility(&mut self) { self.show_password = !self.show_password; } pub fn password_visible(&self) -> bool { self.show_password } } fn show_error(ui: &mut egui::Ui, err: &AcquireKeyError) { ui.horizontal(|ui| { let error_label = match err { AcquireKeyError::InvalidKey => egui::Label::new( egui::RichText::new("Invalid key.").color(ui.visuals().error_fg_color), ), AcquireKeyError::Nip05Failed(e) => { egui::Label::new(egui::RichText::new(e).color(ui.visuals().error_fg_color)) } }; ui.add(error_label.truncate()); }); } #[cfg(test)] mod tests { use enostr::Pubkey; use super::*; use std::time::{Duration, Instant}; #[test] fn test_retrieve_key() { let mut manager = AcquireKeyState::new(); let expected_str = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681"; let expected_key = Keypair::only_pubkey(Pubkey::from_hex(expected_str).unwrap()); let start_time = Instant::now(); while start_time.elapsed() < Duration::from_millis(50u64) { let cur_time = start_time.elapsed(); if cur_time < Duration::from_millis(10u64) { let _ = manager.get_acquire_textedit(|text| { text.clear(); text.insert_text("test", 0); egui::TextEdit::singleline(text) }); manager.apply_acquire(); } else if cur_time < Duration::from_millis(30u64) { let _ = manager.get_acquire_textedit(|text| { text.clear(); text.insert_text("test2", 0); egui::TextEdit::singleline(text) }); manager.apply_acquire(); } else { let _ = manager.get_acquire_textedit(|text| { text.clear(); text.insert_text( "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", 0, ); egui::TextEdit::singleline(text) }); manager.apply_acquire(); } if let Some(key) = manager.get_login_keypair() { assert_eq!(expected_key, key.clone()); return; } } panic!(); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/colors.rs`: ```rs use egui::Color32; pub const ALMOST_WHITE: Color32 = Color32::from_rgb(0xFA, 0xFA, 0xFA); pub const MID_GRAY: Color32 = Color32::from_rgb(0xbd, 0xbd, 0xbd); pub const PINK: Color32 = Color32::from_rgb(0xE4, 0x5A, 0xC9); pub const TEAL: Color32 = Color32::from_rgb(0x77, 0xDC, 0xE1); ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/deck_state.rs`: ```rs use crate::{app_style::emoji_font_family, decks::Deck}; /// State for UI creating/editing deck pub struct DeckState { pub deck_name: String, pub selected_glyph: Option, pub selecting_glyph: bool, pub warn_no_title: bool, pub warn_no_icon: bool, glyph_options: Option>, } impl DeckState { pub fn load(&mut self, deck: &Deck) { self.deck_name = deck.name.clone(); self.selected_glyph = Some(deck.icon); } pub fn from_deck(deck: &Deck) -> Self { let deck_name = deck.name.clone(); let selected_glyph = Some(deck.icon); Self { deck_name, selected_glyph, ..Default::default() } } pub fn clear(&mut self) { *self = Default::default(); } pub fn get_glyph_options(&mut self, ui: &egui::Ui) -> &Vec { self.glyph_options .get_or_insert_with(|| available_characters(ui, emoji_font_family())) } } impl Default for DeckState { fn default() -> Self { Self { deck_name: Default::default(), selected_glyph: Default::default(), selecting_glyph: true, warn_no_icon: Default::default(), warn_no_title: Default::default(), glyph_options: Default::default(), } } } fn available_characters(ui: &egui::Ui, family: egui::FontFamily) -> Vec { ui.fonts(|f| { f.lock() .fonts .font(&egui::FontId::new(10.0, family)) // size is arbitrary for getting the characters .characters() .iter() .map(|(chr, _v)| chr) .filter(|chr| !chr.is_whitespace() && !chr.is_ascii_control()) .copied() .collect() }) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/media_upload.rs`: ```rs use std::path::PathBuf; use base64::{prelude::BASE64_URL_SAFE, Engine}; use ehttp::Request; use nostrdb::{Note, NoteBuilder}; use notedeck::SupportedMimeType; use poll_promise::Promise; use sha2::{Digest, Sha256}; use url::Url; use crate::{images::fetch_binary_from_disk, Error}; pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap(); const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json"; fn get_upload_url(nip96_url: Url) -> Promise> { let request = Request::get(nip96_url); let (sender, promise) = Promise::new(); ehttp::fetch(request, move |response| { let result = match response { Ok(resp) => { if resp.status == 200 { if let Some(text) = resp.text() { get_api_url_from_json(text) } else { Err(Error::Generic( "ehttp::Response payload is not text".to_owned(), )) } } else { Err(Error::Generic(format!( "ehttp::Response status: {}", resp.status ))) } } Err(e) => Err(Error::Generic(e)), }; sender.send(result); }); promise } fn get_api_url_from_json(json: &str) -> Result { match serde_json::from_str::(json) { Ok(json) => { if let Some(url) = json .get("api_url") .and_then(|url| url.as_str()) .map(|url| url.to_string()) { Ok(url) } else { Err(Error::Generic( "api_url key not found in ehttp::Response".to_owned(), )) } } Err(e) => Err(Error::Generic(e.to_string())), } } fn get_upload_url_from_provider(mut provider_url: Url) -> Promise> { provider_url.set_path(NIP96_WELL_KNOWN); get_upload_url(provider_url) } pub fn get_nostr_build_upload_url() -> Promise> { get_upload_url_from_provider(NOSTR_BUILD_URL()) } fn create_nip98_note(seckey: &[u8; 32], upload_url: String, payload_hash: String) -> Note { NoteBuilder::new() .kind(27235) .start_tag() .tag_str("u") .tag_str(&upload_url) .start_tag() .tag_str("method") .tag_str("POST") .start_tag() .tag_str("payload") .tag_str(&payload_hash) .sign(seckey) .build() .expect("build note") } fn create_nip96_request( upload_url: &str, media_path: MediaPath, file_contents: Vec, nip98_base64: &str, ) -> ehttp::Request { let boundary = "----boundary"; let mut body = format!( "--{}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n", boundary, media_path.file_name, media_path.media_type.to_mime() ) .into_bytes(); body.extend(file_contents); body.extend(format!("\r\n--{}--\r\n", boundary).as_bytes()); let headers = ehttp::Headers::new(&[ ( "Content-Type", format!("multipart/form-data; boundary={boundary}").as_str(), ), ("Authorization", format!("Nostr {nip98_base64}").as_str()), ]); Request { method: "POST".to_string(), url: upload_url.to_string(), headers, body, } } fn sha256_hex(contents: &Vec) -> String { let mut hasher = Sha256::new(); hasher.update(contents); let hash = hasher.finalize(); hex::encode(hash) } pub fn nip96_upload( seckey: [u8; 32], upload_url: String, media_path: MediaPath, ) -> Promise> { let bytes_res = fetch_binary_from_disk(media_path.full_path.clone()); let file_bytes = match bytes_res { Ok(bytes) => bytes, Err(e) => { return Promise::from_ready(Err(Error::Generic(format!( "could not read contents of file to upload: {e}" )))) } }; internal_nip96_upload(seckey, upload_url, media_path, file_bytes) } pub fn nostrbuild_nip96_upload( seckey: [u8; 32], media_path: MediaPath, ) -> Promise> { let (sender, promise) = Promise::new(); std::thread::spawn(move || { let upload_url = match get_nostr_build_upload_url().block_and_take() { Ok(url) => url, Err(e) => { sender.send(Err(Error::Generic(format!( "could not get nostrbuild upload url: {e}" )))); return; } }; let res = nip96_upload(seckey, upload_url, media_path).block_and_take(); sender.send(res); }); promise } fn internal_nip96_upload( seckey: [u8; 32], upload_url: String, media_path: MediaPath, file_contents: Vec, ) -> Promise> { let file_hash = sha256_hex(&file_contents); let nip98_note = create_nip98_note(&seckey, upload_url.to_owned(), file_hash); let nip98_base64 = match nip98_note.json() { Ok(json) => BASE64_URL_SAFE.encode(json), Err(e) => return Promise::from_ready(Err(Error::Generic(e.to_string()))), }; let request = create_nip96_request(&upload_url, media_path, file_contents, &nip98_base64); let (sender, promise) = Promise::new(); ehttp::fetch(request, move |response| { let maybe_uploaded_media = match response { Ok(response) => { if response.ok { match String::from_utf8(response.bytes.clone()) { Ok(str_response) => find_nip94_ev_in_json(str_response), Err(e) => Err(Error::Generic(e.to_string())), } } else { Err(Error::Generic(format!( "ehttp Response was unsuccessful. Code {} with message: {}", response.status, response.status_text ))) } } Err(e) => Err(Error::Generic(e)), }; sender.send(maybe_uploaded_media); }); promise } fn find_nip94_ev_in_json(json: String) -> Result { match serde_json::from_str::(&json) { Ok(v) => { let tags = v["nip94_event"]["tags"].clone(); let content = v["nip94_event"]["content"] .as_str() .unwrap_or_default() .to_string(); match serde_json::from_value::>>(tags) { Ok(tags) => Nip94Event::from_tags_and_content(tags, content) .map_err(|e| Error::Generic(e.to_owned())), Err(e) => Err(Error::Generic(e.to_string())), } } Err(e) => Err(Error::Generic(e.to_string())), } } #[derive(Debug)] pub struct MediaPath { full_path: PathBuf, file_name: String, media_type: SupportedMimeType, } impl MediaPath { pub fn new(path: PathBuf) -> Result { if let Some(ex) = path.extension().and_then(|f| f.to_str()) { let media_type = SupportedMimeType::from_extension(ex)?; let file_name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or(&format!("file.{}", ex)) .to_owned(); Ok(MediaPath { full_path: path, file_name, media_type, }) } else { Err(Error::Generic(format!( "{:?} does not have an extension", path ))) } } } #[derive(Clone, Debug, serde::Deserialize)] pub struct Nip94Event { pub url: String, pub ox: Option, pub x: Option, pub media_type: Option, pub dimensions: Option<(u32, u32)>, pub blurhash: Option, pub thumb: Option, pub content: String, } impl Nip94Event { pub fn new(url: String, width: u32, height: u32) -> Self { Self { url, ox: None, x: None, media_type: None, dimensions: Some((width, height)), blurhash: None, thumb: None, content: String::new(), } } } const URL: &str = "url"; const OX: &str = "ox"; const X: &str = "x"; const M: &str = "m"; const DIM: &str = "dim"; const BLURHASH: &str = "blurhash"; const THUMB: &str = "thumb"; impl Nip94Event { fn from_tags_and_content( tags: Vec>, content: String, ) -> Result { let mut url = None; let mut ox = None; let mut x = None; let mut media_type = None; let mut dimensions = None; let mut blurhash = None; let mut thumb = None; for tag in tags { match tag.as_slice() { [key, value] if key == URL => url = Some(value.to_string()), [key, value] if key == OX => ox = Some(value.to_string()), [key, value] if key == X => x = Some(value.to_string()), [key, value] if key == M => media_type = Some(value.to_string()), [key, value] if key == DIM => { if let Some((w, h)) = value.split_once('x') { if let (Ok(w), Ok(h)) = (w.parse::(), h.parse::()) { dimensions = Some((w, h)); } } } [key, value] if key == BLURHASH => blurhash = Some(value.to_string()), [key, value] if key == THUMB => thumb = Some(value.to_string()), _ => {} } } Ok(Self { url: url.ok_or("Missing url")?, ox, x, media_type, dimensions, blurhash, thumb, content, }) } } #[cfg(test)] mod tests { use std::{fs, path::PathBuf, str::FromStr}; use enostr::FullKeypair; use crate::media_upload::{ get_upload_url_from_provider, nostrbuild_nip96_upload, MediaPath, NOSTR_BUILD_URL, }; use super::internal_nip96_upload; #[test] fn test_nostrbuild_upload_url() { let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); let url = promise.block_until_ready(); assert!(url.is_ok()); } #[test] #[ignore] // this test should not run automatically since it sends data to a real server fn test_internal_nip96() { // just a random image to test image upload let file_path = PathBuf::from_str("../../../assets/damus_rounded_80.png").unwrap(); let media_path = MediaPath::new(file_path).unwrap(); let img_bytes = include_bytes!("../../../assets/damus_rounded_80.png"); let promise = get_upload_url_from_provider(NOSTR_BUILD_URL()); let kp = FullKeypair::generate(); println!("Using pubkey: {:?}", kp.pubkey); if let Ok(upload_url) = promise.block_until_ready() { let promise = internal_nip96_upload( kp.secret_key.secret_bytes(), upload_url.to_string(), media_path, img_bytes.to_vec(), ); let res = promise.block_until_ready(); assert!(res.is_ok()) } else { panic!() } } #[tokio::test] #[ignore] // this test should not run automatically since it sends data to a real server async fn test_nostrbuild_nip96() { // just a random image to test image upload let file_path = fs::canonicalize(PathBuf::from_str("../../assets/damus_rounded_80.png").unwrap()) .unwrap(); let media_path = MediaPath::new(file_path).unwrap(); let kp = FullKeypair::generate(); println!("Using pubkey: {:?}", kp.pubkey); let promise = nostrbuild_nip96_upload(kp.secret_key.secret_bytes(), media_path); let out = promise.block_and_take(); assert!(out.is_ok()); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/column.rs`: ```rs use crate::{ actionbar::TimelineOpenResult, route::{Route, Router}, timeline::{Timeline, TimelineCache, TimelineKind}, }; use enostr::RelayPool; use nostrdb::{Ndb, Transaction}; use notedeck::NoteCache; use std::iter::Iterator; use tracing::warn; #[derive(Clone, Debug)] pub struct Column { router: Router, } impl Column { pub fn new(routes: Vec) -> Self { let router = Router::new(routes); Column { router } } pub fn router(&self) -> &Router { &self.router } pub fn router_mut(&mut self) -> &mut Router { &mut self.router } } #[derive(Default, Debug)] pub struct Columns { /// Columns are simply routers into settings, timelines, etc columns: Vec, /// The selected column for key navigation selected: i32, } impl Columns { pub fn new() -> Self { Columns::default() } pub fn add_new_timeline_column( &mut self, timeline_cache: &mut TimelineCache, txn: &Transaction, ndb: &Ndb, note_cache: &mut NoteCache, pool: &mut RelayPool, kind: &TimelineKind, ) -> Option { self.columns .push(Column::new(vec![Route::timeline(kind.to_owned())])); timeline_cache.open(ndb, note_cache, txn, pool, kind) } pub fn new_column_picker(&mut self) { self.add_column(Column::new(vec![Route::AddColumn( crate::ui::add_column::AddColumnRoute::Base, )])); } pub fn insert_intermediary_routes( &mut self, timeline_cache: &mut TimelineCache, intermediary_routes: Vec, ) { let routes = intermediary_routes .into_iter() .map(|r| match r { IntermediaryRoute::Timeline(timeline) => { let route = Route::timeline(timeline.kind.clone()); timeline_cache .timelines .insert(timeline.kind.clone(), timeline); route } IntermediaryRoute::Route(route) => route, }) .collect(); self.columns.push(Column::new(routes)); } pub fn add_column_at(&mut self, column: Column, index: u32) { self.columns.insert(index as usize, column); } pub fn add_column(&mut self, column: Column) { self.columns.push(column); } pub fn columns_mut(&mut self) -> &mut Vec { &mut self.columns } pub fn num_columns(&self) -> usize { self.columns.len() } // Get the first router in the columns if there are columns present. // Otherwise, create a new column picker and return the router pub fn get_first_router(&mut self) -> &mut Router { if self.columns.is_empty() { self.new_column_picker(); } self.columns[0].router_mut() } pub fn column(&self, ind: usize) -> &Column { &self.columns[ind] } pub fn columns(&self) -> &[Column] { &self.columns } pub fn selected(&mut self) -> &mut Column { &mut self.columns[self.selected as usize] } pub fn column_mut(&mut self, ind: usize) -> &mut Column { &mut self.columns[ind] } pub fn select_down(&mut self) { warn!("todo: implement select_down"); } pub fn select_up(&mut self) { warn!("todo: implement select_up"); } pub fn select_left(&mut self) { if self.selected - 1 < 0 { return; } self.selected -= 1; } pub fn select_right(&mut self) { if self.selected + 1 >= self.columns.len() as i32 { return; } self.selected += 1; } #[must_use = "you must call timeline_cache.pop() for each returned value"] pub fn delete_column(&mut self, index: usize) -> Vec { let mut kinds_to_pop: Vec = vec![]; for route in self.columns[index].router().routes() { if let Route::Timeline(kind) = route { kinds_to_pop.push(kind.clone()); } } self.columns.remove(index); if self.columns.is_empty() { self.new_column_picker(); } kinds_to_pop } pub fn move_col(&mut self, from_index: usize, to_index: usize) { if from_index == to_index || from_index >= self.columns.len() || to_index >= self.columns.len() { return; } self.columns.swap(from_index, to_index); } } pub enum IntermediaryRoute { Timeline(Timeline), Route(Route), } pub enum ColumnsAction { Switch(usize, usize), // from Switch.0 to Switch.1, Remove(usize), } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/accounts/route.rs`: ```rs use super::{AccountLoginResponse, AccountsViewResponse}; use serde::{Deserialize, Serialize}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; pub enum AccountsRouteResponse { Accounts(AccountsViewResponse), AddAccount(AccountLoginResponse), } #[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)] pub enum AccountsRoute { Accounts, AddAccount, } impl AccountsRoute { /// Route tokens use in both serialization and deserialization fn tokens(&self) -> &'static [&'static str] { match self { Self::Accounts => &["accounts", "show"], Self::AddAccount => &["accounts", "new"], } } } impl TokenSerializable for AccountsRoute { fn serialize_tokens(&self, writer: &mut TokenWriter) { for token in self.tokens() { writer.write_token(token); } } fn parse_from_tokens<'a>(parser: &mut TokenParser<'a>) -> Result> { parser.peek_parse_token("accounts")?; TokenParser::alt( parser, &[ |p| parse_accounts_route(p, AccountsRoute::Accounts), |p| parse_accounts_route(p, AccountsRoute::AddAccount), ], ) } } fn parse_accounts_route<'a>( parser: &mut TokenParser<'a>, route: AccountsRoute, ) -> Result> { parser.parse_all(|p| { for token in route.tokens() { p.parse_token(token)?; } Ok(route) }) } #[cfg(test)] mod tests { use super::*; use tokenator::{TokenParser, TokenSerializable, TokenWriter}; #[test] fn test_accounts_route_serialize() { let data_str = "accounts:show"; let data = &data_str.split(":").collect::>(); let mut token_writer = TokenWriter::default(); let mut parser = TokenParser::new(&data); let parsed = AccountsRoute::parse_from_tokens(&mut parser).unwrap(); let expected = AccountsRoute::Accounts; parsed.serialize_tokens(&mut token_writer); assert_eq!(expected, parsed); assert_eq!(token_writer.str(), data_str); } #[test] fn test_new_accounts_route_serialize() { let data_str = "accounts:new"; let data = &data_str.split(":").collect::>(); let mut token_writer = TokenWriter::default(); let mut parser = TokenParser::new(data); let parsed = AccountsRoute::parse_from_tokens(&mut parser).unwrap(); let expected = AccountsRoute::AddAccount; parsed.serialize_tokens(&mut token_writer); assert_eq!(expected, parsed); assert_eq!(token_writer.str(), data_str); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/accounts/mod.rs`: ```rs use enostr::FullKeypair; use nostrdb::Ndb; use notedeck::{ Accounts, AccountsAction, AddAccountAction, Images, SingleUnkIdAction, SwitchAccountAction, }; use crate::app::get_active_columns_mut; use crate::decks::DecksCache; use crate::{ login_manager::AcquireKeyState, route::Route, ui::{ account_login_view::{AccountLoginResponse, AccountLoginView}, accounts::{AccountsView, AccountsViewResponse}, }, }; use tracing::info; mod route; pub use route::{AccountsRoute, AccountsRouteResponse}; /// Render account management views from a route #[allow(clippy::too_many_arguments)] pub fn render_accounts_route( ui: &mut egui::Ui, ndb: &Ndb, col: usize, img_cache: &mut Images, accounts: &mut Accounts, decks: &mut DecksCache, login_state: &mut AcquireKeyState, route: AccountsRoute, ) -> AddAccountAction { let resp = match route { AccountsRoute::Accounts => AccountsView::new(ndb, accounts, img_cache) .ui(ui) .inner .map(AccountsRouteResponse::Accounts), AccountsRoute::AddAccount => AccountLoginView::new(login_state) .ui(ui) .inner .map(AccountsRouteResponse::AddAccount), }; if let Some(resp) = resp { match resp { AccountsRouteResponse::Accounts(response) => { let action = process_accounts_view_response(accounts, decks, col, response); AddAccountAction { accounts_action: action, unk_id_action: SingleUnkIdAction::no_action(), } } AccountsRouteResponse::AddAccount(response) => { let action = process_login_view_response(accounts, decks, response); *login_state = Default::default(); let router = get_active_columns_mut(accounts, decks) .column_mut(col) .router_mut(); router.go_back(); action } } } else { AddAccountAction { accounts_action: None, unk_id_action: SingleUnkIdAction::no_action(), } } } pub fn process_accounts_view_response( accounts: &mut Accounts, decks: &mut DecksCache, col: usize, response: AccountsViewResponse, ) -> Option { let router = get_active_columns_mut(accounts, decks) .column_mut(col) .router_mut(); let mut selection = None; match response { AccountsViewResponse::RemoveAccount(index) => { let acc_sel = AccountsAction::Remove(index); info!("account selection: {:?}", acc_sel); selection = Some(acc_sel); } AccountsViewResponse::SelectAccount(index) => { let acc_sel = AccountsAction::Switch(SwitchAccountAction::new(Some(col), index)); info!("account selection: {:?}", acc_sel); selection = Some(acc_sel); } AccountsViewResponse::RouteToLogin => { router.route_to(Route::add_account()); } } accounts.needs_relay_config(); selection } pub fn process_login_view_response( manager: &mut Accounts, decks: &mut DecksCache, response: AccountLoginResponse, ) -> AddAccountAction { let (r, pubkey) = match response { AccountLoginResponse::CreateNew => { let kp = FullKeypair::generate().to_keypair(); let pubkey = kp.pubkey; (manager.add_account(kp), pubkey) } AccountLoginResponse::LoginWith(keypair) => { let pubkey = keypair.pubkey; (manager.add_account(keypair), pubkey) } }; decks.add_deck_default(pubkey); r } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/key_parsing.rs`: ```rs use std::collections::HashMap; use std::str::FromStr; use crate::Error; use ehttp::{Request, Response}; use enostr::{Keypair, Pubkey, SecretKey}; use poll_promise::Promise; use serde::{Deserialize, Serialize}; use tracing::error; #[derive(Debug, PartialEq, Clone)] pub enum AcquireKeyError { InvalidKey, Nip05Failed(String), } impl std::fmt::Display for AcquireKeyError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { AcquireKeyError::InvalidKey => write!(f, "The inputted key is invalid."), AcquireKeyError::Nip05Failed(e) => { write!(f, "Failed to get pubkey from Nip05 address: {e}") } } } } impl std::error::Error for AcquireKeyError {} #[derive(Deserialize, Serialize)] pub struct Nip05Result { pub names: HashMap, pub relays: Option>>, } fn parse_nip05_response(response: Response) -> Result { serde_json::from_slice::(&response.bytes) .map_err(|e| Error::Generic(e.to_string())) } fn get_pubkey_from_result(result: Nip05Result, user: String) -> Result { match result.names.get(&user).to_owned() { Some(pubkey_str) => Pubkey::from_hex(pubkey_str).map_err(|e| { Error::Generic("Could not parse pubkey: ".to_string() + e.to_string().as_str()) }), None => Err(Error::Generic("Could not find user in json.".to_string())), } } fn get_nip05_pubkey(id: &str) -> Promise> { let (sender, promise) = Promise::new(); let mut parts = id.split('@'); let user = match parts.next() { Some(user) => user, None => { sender.send(Err(Error::Generic( "Address does not contain username.".to_string(), ))); return promise; } }; let host = match parts.next() { Some(host) => host, None => { sender.send(Err(Error::Generic( "Nip05 address does not contain host.".to_string(), ))); return promise; } }; if parts.next().is_some() { sender.send(Err(Error::Generic( "Nip05 address contains extraneous parts.".to_string(), ))); return promise; } let url = format!("https://{host}/.well-known/nostr.json?name={user}"); let request = Request::get(url); let cloned_user = user.to_string(); ehttp::fetch(request, move |response: Result| { let result = match response { Ok(resp) => parse_nip05_response(resp) .and_then(move |result| get_pubkey_from_result(result, cloned_user)), Err(e) => Err(Error::Generic(e.to_string())), }; sender.send(result); }); promise } fn retrieving_nip05_pubkey(key: &str) -> bool { key.contains('@') } fn nip05_promise_wrapper(id: &str) -> Promise> { let (sender, promise) = Promise::new(); let original_promise = get_nip05_pubkey(id); std::thread::spawn(move || { let result = original_promise.block_and_take(); let transformed_result = match result { Ok(public_key) => Ok(Keypair::only_pubkey(public_key)), Err(e) => { error!("Nip05 Failed: {e}"); Err(AcquireKeyError::Nip05Failed(e.to_string())) } }; sender.send(transformed_result); }); promise } /// Attempts to turn a string slice key from the user into a Nostr-Sdk Keypair object. /// The `key` can be in any of the following formats: /// - Public Bech32 key (prefix "npub"): "npub1xyz..." /// - Private Bech32 key (prefix "nsec"): "nsec1xyz..." /// - Public hex key: "02a1..." /// - Private hex key: "5dab..." /// - NIP-05 address: "example@nostr.com" /// pub fn perform_key_retrieval(key: &str) -> Promise> { let tmp_key: &str = if let Some(stripped) = key.strip_prefix('@') { stripped } else { key }; if retrieving_nip05_pubkey(tmp_key) { nip05_promise_wrapper(tmp_key) } else { let res = if let Ok(pubkey) = Pubkey::try_from_bech32_string(tmp_key, true) { Ok(Keypair::only_pubkey(pubkey)) } else if let Ok(pubkey) = Pubkey::try_from_hex_str_with_verify(tmp_key) { Ok(Keypair::only_pubkey(pubkey)) } else if let Ok(secret_key) = SecretKey::from_str(tmp_key) { Ok(Keypair::from_secret(secret_key)) } else { Err(AcquireKeyError::InvalidKey) }; Promise::from_ready(res) } } #[cfg(test)] mod tests { use super::*; use crate::promise_assert; #[test] fn test_pubkey() { let pubkey_str = "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s"; let expected_pubkey = Pubkey::try_from_bech32_string(pubkey_str, false).expect("Should not have errored."); let login_key_result = perform_key_retrieval(pubkey_str); promise_assert!( assert_eq, Ok(Keypair::only_pubkey(expected_pubkey)), &login_key_result ); } #[test] fn test_hex_pubkey() { let pubkey_str = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"; let expected_pubkey = Pubkey::from_hex(pubkey_str).expect("Should not have errored."); let login_key_result = perform_key_retrieval(pubkey_str); promise_assert!( assert_eq, Ok(Keypair::only_pubkey(expected_pubkey)), &login_key_result ); } #[test] fn test_privkey() { let privkey_str = "nsec1g8wt3hlwjpa4827xylr3r0lccufxltyekhraexes8lqmpp2hensq5aujhs"; let expected_privkey = SecretKey::from_str(privkey_str).expect("Should not have errored."); let login_key_result = perform_key_retrieval(privkey_str); promise_assert!( assert_eq, Ok(Keypair::from_secret(expected_privkey)), &login_key_result ); } #[test] fn test_hex_privkey() { let privkey_str = "41dcb8dfee907b53abc627c711bff8c7126fac99b5c7dc9b303fc1b08557cce0"; let expected_privkey = SecretKey::from_str(privkey_str).expect("Should not have errored."); let login_key_result = perform_key_retrieval(privkey_str); promise_assert!( assert_eq, Ok(Keypair::from_secret(expected_privkey)), &login_key_result ); } #[test] fn test_nip05() { let nip05_str = "damus@damus.io"; let expected_pubkey = Pubkey::try_from_bech32_string( "npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955", false, ) .expect("Should not have errored."); let login_key_result = perform_key_retrieval(nip05_str); promise_assert!( assert_eq, Ok(Keypair::only_pubkey(expected_pubkey)), &login_key_result ); } #[test] fn test_nip05_pubkey() { let nip05_str = "damus@damus.io"; let expected_pubkey = Pubkey::try_from_bech32_string( "npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955", false, ) .expect("Should not have errored."); let login_key_result = get_nip05_pubkey(nip05_str); let res = login_key_result.block_and_take().expect("Should not error"); assert_eq!(expected_pubkey, res); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/test_data.rs`: ```rs use enostr::RelayPool; use nostrdb::ProfileRecord; #[allow(unused_must_use)] pub fn sample_pool() -> RelayPool { let mut pool = RelayPool::new(); let wakeup = move || {}; pool.add_url("wss://relay.damus.io".to_string(), wakeup); pool.add_url("wss://eden.nostr.land".to_string(), wakeup); pool.add_url("wss://nostr.wine".to_string(), wakeup); pool.add_url("wss://nos.lol".to_string(), wakeup); pool.add_url("wss://test_relay_url_long_00000000000000000000000000000000000000000000000000000000000000000000000000000000000".to_string(), wakeup); for _ in 0..20 { pool.add_url("tmp".to_string(), wakeup); } pool } // my (jb55) profile const TEST_PROFILE_DATA: [u8; 448] = [ 0x04, 0x00, 0x00, 0x00, 0x54, 0xfe, 0xff, 0xff, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xd6, 0xd9, 0xc6, 0x65, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x66, 0x69, 0x78, 0x6d, 0x65, 0x00, 0x00, 0x00, 0x78, 0x01, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xda, 0xff, 0xff, 0xff, 0x64, 0x01, 0x00, 0x00, 0x50, 0x01, 0x00, 0x00, 0x34, 0x01, 0x00, 0x00, 0x08, 0x01, 0x00, 0x00, 0xec, 0x00, 0x00, 0x00, 0xdc, 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x00, 0x24, 0x00, 0x18, 0x00, 0x14, 0x00, 0x20, 0x00, 0x0c, 0x00, 0x1c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x10, 0x00, 0x08, 0x00, 0x52, 0x00, 0x00, 0x00, 0x49, 0x20, 0x6d, 0x61, 0x64, 0x65, 0x20, 0x64, 0x61, 0x6d, 0x75, 0x73, 0x2c, 0x20, 0x6e, 0x70, 0x75, 0x62, 0x73, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x7a, 0x61, 0x70, 0x73, 0x2e, 0x20, 0x62, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x20, 0x62, 0x79, 0x20, 0x61, 0x70, 0x70, 0x6c, 0x65, 0x20, 0x26, 0x20, 0x74, 0x68, 0x65, 0x20, 0x63, 0x63, 0x70, 0x2e, 0x20, 0x6d, 0x79, 0x20, 0x6e, 0x6f, 0x74, 0x65, 0x73, 0x20, 0x61, 0x72, 0x65, 0x20, 0x6e, 0x6f, 0x74, 0x20, 0x66, 0x6f, 0x72, 0x20, 0x73, 0x61, 0x6c, 0x65, 0x00, 0x00, 0x5a, 0x00, 0x00, 0x00, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x6e, 0x6f, 0x73, 0x74, 0x72, 0x2e, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x2f, 0x69, 0x2f, 0x33, 0x64, 0x36, 0x66, 0x32, 0x32, 0x64, 0x34, 0x35, 0x64, 0x39, 0x35, 0x65, 0x63, 0x63, 0x32, 0x63, 0x31, 0x39, 0x62, 0x31, 0x61, 0x63, 0x64, 0x65, 0x63, 0x35, 0x37, 0x61, 0x61, 0x31, 0x35, 0x66, 0x32, 0x64, 0x62, 0x61, 0x39, 0x63, 0x34, 0x32, 0x33, 0x62, 0x35, 0x33, 0x36, 0x65, 0x32, 0x36, 0x66, 0x63, 0x36, 0x32, 0x37, 0x30, 0x37, 0x63, 0x31, 0x32, 0x35, 0x66, 0x35, 0x35, 0x37, 0x2e, 0x6a, 0x70, 0x67, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x6a, 0x62, 0x35, 0x35, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x64, 0x61, 0x6d, 0x75, 0x73, 0x2e, 0x69, 0x6f, 0x00, 0x00, 0x00, 0x00, 0x23, 0x00, 0x00, 0x00, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x63, 0x64, 0x6e, 0x2e, 0x6a, 0x62, 0x35, 0x35, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x69, 0x6d, 0x67, 0x2f, 0x72, 0x65, 0x64, 0x2d, 0x6d, 0x65, 0x2e, 0x6a, 0x70, 0x67, 0x00, 0x11, 0x00, 0x00, 0x00, 0x6a, 0x62, 0x35, 0x35, 0x40, 0x73, 0x65, 0x6e, 0x64, 0x73, 0x61, 0x74, 0x73, 0x2e, 0x6c, 0x6f, 0x6c, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x5f, 0x40, 0x6a, 0x62, 0x35, 0x35, 0x2e, 0x63, 0x6f, 0x6d, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x57, 0x69, 0x6c, 0x6c, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x24, 0x00, 0x04, 0x00, 0x0c, 0x00, 0x1c, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, ]; /* const TEST_PUBKEY: [u8; 32] = [ 0x32, 0xe1, 0x82, 0x76, 0x35, 0x45, 0x0e, 0xbb, 0x3c, 0x5a, 0x7d, 0x12, 0xc1, 0xf8, 0xe7, 0xb2, 0xb5, 0x14, 0x43, 0x9a, 0xc1, 0x0a, 0x67, 0xee, 0xf3, 0xd9, 0xfd, 0x9c, 0x5c, 0x68, 0xe2, 0x45, ]; pub fn test_pubkey() -> &'static [u8; 32] { &TEST_PUBKEY } */ pub fn test_profile_record() -> ProfileRecord<'static> { ProfileRecord::new_owned(&TEST_PROFILE_DATA).unwrap() } /* const TEN_ACCOUNT_HEXES: [&str; 10] = [ "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91", "5c10ed0678805156d39ef1ef6d46110fe1e7e590ae04986ccf48ba1299cb53e2", "4c96d763eb2fe01910f7e7220b7c7ecdbe1a70057f344b9f79c28af080c3ee30", "edf16b1dd61eab353a83af470cc13557029bff6827b4cb9b7fc9bdb632a2b8e6", "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", ]; pub fn get_test_accounts() -> Vec { TEN_ACCOUNT_HEXES .iter() .map(|account_hex| { let mut kp = FullKeypair::generate().to_keypair(); kp.pubkey = Pubkey::from_hex(account_hex).unwrap(); kp }) .collect() } pub fn test_app() -> Damus { let db_dir = Path::new("target/testdbs/test_app"); let path = db_dir.to_str().unwrap(); let mut app = Damus::mock(path); let accounts = get_test_accounts(); let txn = Transaction::new(&app.ndb).expect("txn"); for account in accounts { app.accounts_mut() .add_account(account) .process_action(&mut app.unknown_ids, &app.ndb, &txn) } app } */ ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/profile_state.rs`: ```rs use nostrdb::{NdbProfile, ProfileRecord}; #[derive(Default, Debug)] pub struct ProfileState { pub display_name: String, pub name: String, pub picture: String, pub banner: String, pub about: String, pub website: String, pub lud16: String, pub nip05: String, } impl ProfileState { pub fn from_profile(record: &ProfileRecord<'_>) -> Self { let display_name = get_item(record, |p| p.display_name()); let username = get_item(record, |p| p.name()); let profile_picture = get_item(record, |p| p.picture()); let cover_image = get_item(record, |p| p.banner()); let about = get_item(record, |p| p.about()); let website = get_item(record, |p| p.website()); let lud16 = get_item(record, |p| p.lud16()); let nip05 = get_item(record, |p| p.nip05()); Self { display_name, name: username, picture: profile_picture, banner: cover_image, about, website, lud16, nip05, } } pub fn to_json(&self) -> String { let mut fields = Vec::new(); if !self.display_name.is_empty() { fields.push(format!(r#""display_name":"{}""#, self.display_name)); } if !self.name.is_empty() { fields.push(format!(r#""name":"{}""#, self.name)); } if !self.picture.is_empty() { fields.push(format!(r#""picture":"{}""#, self.picture)); } if !self.banner.is_empty() { fields.push(format!(r#""banner":"{}""#, self.banner)); } if !self.about.is_empty() { fields.push(format!(r#""about":"{}""#, self.about)); } if !self.website.is_empty() { fields.push(format!(r#""website":"{}""#, self.website)); } if !self.lud16.is_empty() { fields.push(format!(r#""lud16":"{}""#, self.lud16)); } if !self.nip05.is_empty() { fields.push(format!(r#""nip05":"{}""#, self.nip05)); } format!("{{{}}}", fields.join(",")) } } fn get_item<'a>( record: &ProfileRecord<'a>, item_retriever: fn(NdbProfile<'a>) -> Option<&'a str>, ) -> String { record .record() .profile() .and_then(item_retriever) .map_or_else(String::new, ToString::to_string) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/relay_pool_manager.rs`: ```rs use enostr::RelayPool; pub use enostr::RelayStatus; /// The interface to a RelayPool for UI components. /// Represents all user-facing operations that can be performed for a user's relays pub struct RelayPoolManager<'a> { pub pool: &'a mut RelayPool, } pub struct RelayInfo<'a> { pub relay_url: &'a str, pub status: RelayStatus, } impl<'a> RelayPoolManager<'a> { pub fn new(pool: &'a mut RelayPool) -> Self { RelayPoolManager { pool } } pub fn get_relay_infos(&self) -> Vec { self.pool .relays .iter() .map(|relay| RelayInfo { relay_url: relay.url(), status: relay.status(), }) .collect() } /// index of the Vec from get_relay_infos pub fn remove_relay(&mut self, index: usize) { if index < self.pool.relays.len() { self.pool.relays.remove(index); } } /// removes all specified relay indicies shown in get_relay_infos pub fn remove_relays(&mut self, mut indices: Vec) { indices.sort_unstable_by(|a, b| b.cmp(a)); indices.iter().for_each(|index| self.remove_relay(*index)); } // FIXME - this is not ever called? pub fn add_relay(&mut self, ctx: &egui::Context, relay_url: String) { let _ = self.pool.add_url(relay_url, create_wakeup(ctx)); } /// check whether a relay url is valid pub fn is_valid_relay(&self, url: &str) -> bool { self.pool.is_valid_url(url) } } pub fn create_wakeup(ctx: &egui::Context) -> impl Fn() + Send + Sync + Clone + 'static { let ctx = ctx.clone(); move || { ctx.request_repaint(); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/decks.rs`: ```rs use std::collections::{hash_map::ValuesMut, HashMap}; use enostr::Pubkey; use nostrdb::Transaction; use notedeck::AppContext; use tracing::{error, info}; use crate::{ accounts::AccountsRoute, column::{Column, Columns}, route::Route, timeline::{TimelineCache, TimelineKind}, ui::{add_column::AddColumnRoute, configure_deck::ConfigureDeckResponse}, }; pub static FALLBACK_PUBKEY: fn() -> Pubkey = || { Pubkey::from_hex("aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe").unwrap() }; pub enum DecksAction { Switch(usize), Removing(usize), } pub struct DecksCache { account_to_decks: HashMap, fallback_pubkey: Pubkey, } impl Default for DecksCache { fn default() -> Self { let mut account_to_decks: HashMap = Default::default(); account_to_decks.insert(FALLBACK_PUBKEY(), Decks::default()); DecksCache::new(account_to_decks) } } impl DecksCache { pub fn new(mut account_to_decks: HashMap) -> Self { let fallback_pubkey = FALLBACK_PUBKEY(); account_to_decks.entry(fallback_pubkey).or_default(); Self { account_to_decks, fallback_pubkey, } } pub fn new_with_demo_config(timeline_cache: &mut TimelineCache, ctx: &mut AppContext) -> Self { let mut account_to_decks: HashMap = Default::default(); let fallback_pubkey = FALLBACK_PUBKEY(); account_to_decks.insert( fallback_pubkey, demo_decks(fallback_pubkey, timeline_cache, ctx), ); DecksCache::new(account_to_decks) } pub fn decks(&self, key: &Pubkey) -> &Decks { self.account_to_decks .get(key) .unwrap_or_else(|| self.fallback()) } pub fn decks_mut(&mut self, key: &Pubkey) -> &mut Decks { self.account_to_decks.entry(*key).or_default() } pub fn fallback(&self) -> &Decks { self.account_to_decks .get(&self.fallback_pubkey) .unwrap_or_else(|| panic!("fallback deck not found")) } pub fn fallback_mut(&mut self) -> &mut Decks { self.account_to_decks .get_mut(&self.fallback_pubkey) .unwrap_or_else(|| panic!("fallback deck not found")) } pub fn add_deck_default(&mut self, key: Pubkey) { self.account_to_decks.insert(key, Decks::default()); info!( "Adding new default deck for {:?}. New decks size is {}", key, self.account_to_decks.get(&key).unwrap().decks.len() ); } pub fn add_decks(&mut self, key: Pubkey, decks: Decks) { self.account_to_decks.insert(key, decks); info!( "Adding new deck for {:?}. New decks size is {}", key, self.account_to_decks.get(&key).unwrap().decks.len() ); } pub fn add_deck(&mut self, key: Pubkey, deck: Deck) { match self.account_to_decks.entry(key) { std::collections::hash_map::Entry::Occupied(mut entry) => { let decks = entry.get_mut(); decks.add_deck(deck); info!( "Created new deck for {:?}. New number of decks is {}", key, decks.decks.len() ); } std::collections::hash_map::Entry::Vacant(entry) => { info!("Created first deck for {:?}", key); entry.insert(Decks::new(deck)); } } } pub fn remove_for(&mut self, key: &Pubkey) { info!("Removing decks for {:?}", key); self.account_to_decks.remove(key); } pub fn get_fallback_pubkey(&self) -> &Pubkey { &self.fallback_pubkey } pub fn get_all_decks_mut(&mut self) -> ValuesMut { self.account_to_decks.values_mut() } pub fn get_mapping(&self) -> &HashMap { &self.account_to_decks } } pub struct Decks { active_deck: usize, removal_request: Option, decks: Vec, } impl Default for Decks { fn default() -> Self { Decks::new(Deck::default()) } } impl Decks { pub fn new(deck: Deck) -> Self { let decks = vec![deck]; Decks { active_deck: 0, removal_request: None, decks, } } pub fn from_decks(active_deck: usize, decks: Vec) -> Self { Self { active_deck, removal_request: None, decks, } } pub fn active(&self) -> &Deck { self.decks .get(self.active_deck) .expect("active_deck index was invalid") } pub fn active_mut(&mut self) -> &mut Deck { self.decks .get_mut(self.active_deck) .expect("active_deck index was invalid") } pub fn decks(&self) -> &Vec { &self.decks } pub fn decks_mut(&mut self) -> &mut Vec { &mut self.decks } pub fn add_deck(&mut self, deck: Deck) { self.decks.push(deck); } pub fn active_index(&self) -> usize { self.active_deck } pub fn set_active(&mut self, index: usize) { if index < self.decks.len() { self.active_deck = index; } else { error!( "requested deck change that is invalid. decks len: {}, requested index: {}", self.decks.len(), index ); } } pub fn remove_deck(&mut self, index: usize) { if index < self.decks.len() { if self.decks.len() > 1 { self.decks.remove(index); let info_prefix = format!("Removed deck at index {}", index); match index.cmp(&self.active_deck) { std::cmp::Ordering::Less => { info!( "{}. The active deck was index {}, now it is {}", info_prefix, self.active_deck, self.active_deck - 1 ); self.active_deck -= 1 } std::cmp::Ordering::Greater => { info!( "{}. Active deck remains at index {}.", info_prefix, self.active_deck ) } std::cmp::Ordering::Equal => { if index != 0 { info!( "{}. Active deck was index {}, now it is {}", info_prefix, self.active_deck, self.active_deck - 1 ); self.active_deck -= 1; } else { info!( "{}. Active deck remains at index {}.", info_prefix, self.active_deck ) } } } self.removal_request = None; } else { error!("attempted unsucessfully to remove the last deck for this account"); } } else { error!("index was out of bounds"); } } } pub struct Deck { pub icon: char, pub name: String, columns: Columns, } impl Default for Deck { fn default() -> Self { let mut columns = Columns::default(); columns.new_column_picker(); Self { icon: '🇩', name: String::from("Default Deck"), columns, } } } impl Deck { pub fn new(icon: char, name: String) -> Self { let mut columns = Columns::default(); columns.new_column_picker(); Self { icon, name, columns, } } pub fn new_with_columns(icon: char, name: String, columns: Columns) -> Self { Self { icon, name, columns, } } pub fn columns(&self) -> &Columns { &self.columns } pub fn columns_mut(&mut self) -> &mut Columns { &mut self.columns } pub fn edit(&mut self, changes: ConfigureDeckResponse) { self.name = changes.name; self.icon = changes.icon; } } pub fn demo_decks( demo_pubkey: Pubkey, timeline_cache: &mut TimelineCache, ctx: &mut AppContext, ) -> Decks { let deck = { let mut columns = Columns::default(); columns.add_column(Column::new(vec![ Route::AddColumn(AddColumnRoute::Base), Route::Accounts(AccountsRoute::Accounts), ])); let kind = TimelineKind::contact_list(demo_pubkey); let txn = Transaction::new(ctx.ndb).unwrap(); if let Some(results) = columns.add_new_timeline_column( timeline_cache, &txn, ctx.ndb, ctx.note_cache, ctx.pool, &kind, ) { results.process( ctx.ndb, ctx.note_cache, &txn, timeline_cache, ctx.unknown_ids, ); } //columns.add_new_timeline_column(Timeline::hashtag("introductions".to_string())); Deck { icon: '🇩', name: String::from("Demo Deck"), columns, } }; Decks::new(deck) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/images.rs`: ```rs use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint}; use image::codecs::gif::GifDecoder; use image::imageops::FilterType; use image::AnimationDecoder; use image::DynamicImage; use image::FlatSamples; use image::Frame; use notedeck::Animation; use notedeck::ImageFrame; use notedeck::MediaCache; use notedeck::MediaCacheType; use notedeck::Result; use notedeck::TextureFrame; use notedeck::TexturedImage; use poll_promise::Promise; use std::collections::VecDeque; use std::io::Cursor; use std::path; use std::path::PathBuf; use std::sync::mpsc; use std::sync::mpsc::SyncSender; use std::thread; use std::time::Duration; use tokio::fs; // NOTE(jb55): chatgpt wrote this because I was too dumb to pub fn aspect_fill( ui: &mut egui::Ui, sense: Sense, texture_id: egui::TextureId, aspect_ratio: f32, ) -> egui::Response { let frame = ui.available_rect_before_wrap(); // Get the available frame space in the current layout let frame_ratio = frame.width() / frame.height(); let (width, height) = if frame_ratio > aspect_ratio { // Frame is wider than the content (frame.width(), frame.width() / aspect_ratio) } else { // Frame is taller than the content (frame.height() * aspect_ratio, frame.height()) }; let content_rect = Rect::from_min_size( frame.min + egui::vec2( (frame.width() - width) / 2.0, (frame.height() - height) / 2.0, ), egui::vec2(width, height), ); // Set the clipping rectangle to the frame //let clip_rect = ui.clip_rect(); // Preserve the original clipping rectangle //ui.set_clip_rect(frame); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); let (response, painter) = ui.allocate_painter(ui.available_size(), sense); // Draw the texture within the calculated rect, potentially clipping it painter.rect_filled(content_rect, 0.0, ui.ctx().style().visuals.window_fill()); painter.image(texture_id, content_rect, uv, Color32::WHITE); // Restore the original clipping rectangle //ui.set_clip_rect(clip_rect); response } pub fn round_image(image: &mut ColorImage) { #[cfg(feature = "profiling")] puffin::profile_function!(); // The radius to the edge of of the avatar circle let edge_radius = image.size[0] as f32 / 2.0; let edge_radius_squared = edge_radius * edge_radius; for (pixnum, pixel) in image.pixels.iter_mut().enumerate() { // y coordinate let uy = pixnum / image.size[0]; let y = uy as f32; let y_offset = edge_radius - y; // x coordinate let ux = pixnum % image.size[0]; let x = ux as f32; let x_offset = edge_radius - x; // The radius to this pixel (may be inside or outside the circle) let pixel_radius_squared: f32 = x_offset * x_offset + y_offset * y_offset; // If inside of the avatar circle if pixel_radius_squared <= edge_radius_squared { // squareroot to find how many pixels we are from the edge let pixel_radius: f32 = pixel_radius_squared.sqrt(); let distance = edge_radius - pixel_radius; // If we are within 1 pixel of the edge, we should fade, to // antialias the edge of the circle. 1 pixel from the edge should // be 100% of the original color, and right on the edge should be // 0% of the original color. if distance <= 1.0 { *pixel = Color32::from_rgba_premultiplied( (pixel.r() as f32 * distance) as u8, (pixel.g() as f32 * distance) as u8, (pixel.b() as f32 * distance) as u8, (pixel.a() as f32 * distance) as u8, ); } } else { // Outside of the avatar circle *pixel = Color32::TRANSPARENT; } } } fn process_pfp_bitmap(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { #[cfg(feature = "profiling")] puffin::profile_function!(); match imgtyp { ImageType::Content(w, h) => { let image = image.resize(w, h, FilterType::CatmullRom); // DynamicImage let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) let color_image = ColorImage::from_rgba_unmultiplied( [ image_buffer.width() as usize, image_buffer.height() as usize, ], image_buffer.as_flat_samples().as_slice(), ); color_image } ImageType::Profile(size) => { // Crop square let smaller = image.width().min(image.height()); if image.width() > smaller { let excess = image.width() - smaller; image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height()); } else if image.height() > smaller { let excess = image.height() - smaller; image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess); } let image = image.resize(size, size, FilterType::CatmullRom); // DynamicImage let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) let mut color_image = ColorImage::from_rgba_unmultiplied( [ image_buffer.width() as usize, image_buffer.height() as usize, ], image_buffer.as_flat_samples().as_slice(), ); round_image(&mut color_image); color_image } } } fn parse_img_response(response: ehttp::Response, imgtyp: ImageType) -> Result { #[cfg(feature = "profiling")] puffin::profile_function!(); let content_type = response.content_type().unwrap_or_default(); let size_hint = match imgtyp { ImageType::Profile(size) => SizeHint::Size(size, size), ImageType::Content(w, h) => SizeHint::Size(w, h), }; if content_type.starts_with("image/svg") { #[cfg(feature = "profiling")] puffin::profile_scope!("load_svg"); let mut color_image = egui_extras::image::load_svg_bytes_with_size(&response.bytes, Some(size_hint))?; round_image(&mut color_image); Ok(color_image) } else if content_type.starts_with("image/") { #[cfg(feature = "profiling")] puffin::profile_scope!("load_from_memory"); let dyn_image = image::load_from_memory(&response.bytes)?; Ok(process_pfp_bitmap(imgtyp, dyn_image)) } else { Err(format!("Expected image, found content-type {:?}", content_type).into()) } } fn fetch_img_from_disk( ctx: &egui::Context, url: &str, path: &path::Path, cache_type: MediaCacheType, ) -> Promise> { let ctx = ctx.clone(); let url = url.to_owned(); let path = path.to_owned(); Promise::spawn_async(async move { match cache_type { MediaCacheType::Image => { let data = fs::read(path).await?; let image_buffer = image::load_from_memory(&data).map_err(notedeck::Error::Image)?; let img = buffer_to_color_image( image_buffer.as_flat_samples_u8(), image_buffer.width(), image_buffer.height(), ); Ok(TexturedImage::Static(ctx.load_texture( &url, img, Default::default(), ))) } MediaCacheType::Gif => { let gif_bytes = fs::read(path.clone()).await?; // Read entire file into a Vec generate_gif(ctx, url, &path, gif_bytes, false, |i| { buffer_to_color_image(i.as_flat_samples_u8(), i.width(), i.height()) }) } } }) } fn generate_gif( ctx: egui::Context, url: String, path: &path::Path, data: Vec, write_to_disk: bool, process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static, ) -> Result { let decoder = { let reader = Cursor::new(data.as_slice()); GifDecoder::new(reader)? }; let (tex_input, tex_output) = mpsc::sync_channel(4); let (maybe_encoder_input, maybe_encoder_output) = if write_to_disk { let (inp, out) = mpsc::sync_channel(4); (Some(inp), Some(out)) } else { (None, None) }; let mut frames: VecDeque = decoder .into_frames() .collect::, image::ImageError>>() .map_err(|e| notedeck::Error::Generic(e.to_string()))?; let first_frame = frames.pop_front().map(|frame| { generate_animation_frame( &ctx, &url, 0, frame, maybe_encoder_input.as_ref(), process_to_egui, ) }); let cur_url = url.clone(); thread::spawn(move || { for (index, frame) in frames.into_iter().enumerate() { let texture_frame = generate_animation_frame( &ctx, &cur_url, index, frame, maybe_encoder_input.as_ref(), process_to_egui, ); if tex_input.send(texture_frame).is_err() { tracing::error!("AnimationTextureFrame mpsc stopped abruptly"); break; } } }); if let Some(encoder_output) = maybe_encoder_output { let path = path.to_owned(); thread::spawn(move || { let mut imgs = Vec::new(); while let Ok(img) = encoder_output.recv() { imgs.push(img); } if let Err(e) = MediaCache::write_gif(&path, &url, imgs) { tracing::error!("Could not write gif to disk: {e}"); } }); } first_frame.map_or_else( || { Err(notedeck::Error::Generic( "first frame not found for gif".to_owned(), )) }, |first_frame| { Ok(TexturedImage::Animated(Animation { other_frames: Default::default(), receiver: Some(tex_output), first_frame, })) }, ) } fn generate_animation_frame( ctx: &egui::Context, url: &str, index: usize, frame: image::Frame, maybe_encoder_input: Option<&SyncSender>, process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + 'static, ) -> TextureFrame { let delay = Duration::from(frame.delay()); let img = DynamicImage::ImageRgba8(frame.into_buffer()); let color_img = process_to_egui(img); if let Some(sender) = maybe_encoder_input { if let Err(e) = sender.send(ImageFrame { delay, image: color_img.clone(), }) { tracing::error!("ImageFrame mpsc unexpectedly closed: {e}"); } } TextureFrame { delay, texture: ctx.load_texture(format!("{}{}", url, index), color_img, Default::default()), } } fn buffer_to_color_image( samples: Option>, width: u32, height: u32, ) -> ColorImage { // TODO(jb55): remove unwrap here let flat_samples = samples.unwrap(); ColorImage::from_rgba_unmultiplied([width as usize, height as usize], flat_samples.as_slice()) } pub fn fetch_binary_from_disk(path: PathBuf) -> Result> { std::fs::read(path).map_err(|e| notedeck::Error::Generic(e.to_string())) } /// Controls type-specific handling #[derive(Debug, Clone, Copy)] pub enum ImageType { /// Profile Image (size) Profile(u32), /// Content Image (width, height) Content(u32, u32), } pub fn fetch_img( img_cache: &MediaCache, ctx: &egui::Context, url: &str, imgtyp: ImageType, cache_type: MediaCacheType, ) -> Promise> { let key = MediaCache::key(url); let path = img_cache.cache_dir.join(key); if path.exists() { fetch_img_from_disk(ctx, url, &path, cache_type) } else { fetch_img_from_net(&img_cache.cache_dir, ctx, url, imgtyp, cache_type) } // TODO: fetch image from local cache } fn fetch_img_from_net( cache_path: &path::Path, ctx: &egui::Context, url: &str, imgtyp: ImageType, cache_type: MediaCacheType, ) -> Promise> { let (sender, promise) = Promise::new(); let request = ehttp::Request::get(url); let ctx = ctx.clone(); let cloned_url = url.to_owned(); let cache_path = cache_path.to_owned(); ehttp::fetch(request, move |response| { let handle = response.map_err(notedeck::Error::Generic).and_then(|resp| { match cache_type { MediaCacheType::Image => { let img = parse_img_response(resp, imgtyp); img.map(|img| { let texture_handle = ctx.load_texture(&cloned_url, img.clone(), Default::default()); // write to disk std::thread::spawn(move || { MediaCache::write(&cache_path, &cloned_url, img) }); TexturedImage::Static(texture_handle) }) } MediaCacheType::Gif => { let gif_bytes = resp.bytes; generate_gif( ctx.clone(), cloned_url, &cache_path, gif_bytes, true, move |img| process_pfp_bitmap(imgtyp, img), ) } } }); sender.send(handle); // send the results back to the UI thread. ctx.request_repaint(); }); promise } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/args.rs`: ```rs use std::collections::BTreeSet; use crate::timeline::TimelineKind; use enostr::{Filter, Pubkey}; use tracing::{debug, error, info}; pub struct ColumnsArgs { pub columns: Vec, pub since_optimize: bool, pub textmode: bool, pub scramble: bool, pub no_media: bool, } impl ColumnsArgs { pub fn parse(args: &[String], deck_author: Option<&Pubkey>) -> (Self, BTreeSet) { let mut unrecognized_args = BTreeSet::new(); let mut res = Self { columns: vec![], since_optimize: true, textmode: false, scramble: false, no_media: false, }; let mut i = 0; let len = args.len(); while i < len { let arg = &args[i]; if arg == "--textmode" { res.textmode = true; } else if arg == "--no-since-optimize" { res.since_optimize = false; } else if arg == "--scramble" { res.scramble = true; } else if arg == "--no-media" { res.no_media = true; } else if arg == "--filter" { i += 1; let filter = if let Some(next_arg) = args.get(i) { next_arg } else { error!("filter argument missing?"); continue; }; if let Ok(filter) = Filter::from_json(filter) { res.columns.push(ArgColumn::Generic(vec![filter])); } else { error!("failed to parse filter '{}'", filter); } } else if arg == "--column" || arg == "-c" { i += 1; let column_name = if let Some(next_arg) = args.get(i) { next_arg } else { error!("column argument missing"); continue; }; if let Some(rest) = column_name.strip_prefix("contacts:") { if let Ok(pubkey) = Pubkey::parse(rest) { info!("contact column for user {}", pubkey.hex()); res.columns .push(ArgColumn::Timeline(TimelineKind::contact_list(pubkey))) } else { error!("error parsing contacts pubkey {}", rest); continue; } } else if column_name == "contacts" { if let Some(deck_author) = deck_author { res.columns .push(ArgColumn::Timeline(TimelineKind::contact_list( deck_author.to_owned(), ))) } else { panic!("No accounts available, could not handle implicit pubkey contacts column") } } else if let Some(notif_pk_str) = column_name.strip_prefix("notifications:") { if let Ok(pubkey) = Pubkey::parse(notif_pk_str) { info!("got notifications column for user {}", pubkey.hex()); res.columns .push(ArgColumn::Timeline(TimelineKind::notifications(pubkey))) } else { error!("error parsing notifications pubkey {}", notif_pk_str); continue; } } else if column_name == "notifications" { debug!("got notification column for default user"); if let Some(deck_author) = deck_author { res.columns .push(ArgColumn::Timeline(TimelineKind::notifications( deck_author.to_owned(), ))); } else { panic!("Tried to push notifications timeline with no available users"); } } else if column_name == "profile" { debug!("got profile column for default user"); if let Some(deck_author) = deck_author { res.columns.push(ArgColumn::Timeline(TimelineKind::profile( deck_author.to_owned(), ))); } else { panic!("Tried to push profile timeline with no available users"); } } else if column_name == "universe" { debug!("got universe column"); res.columns .push(ArgColumn::Timeline(TimelineKind::Universe)) } else if let Some(profile_pk_str) = column_name.strip_prefix("profile:") { if let Ok(pubkey) = Pubkey::parse(profile_pk_str) { info!("got profile column for user {}", pubkey.hex()); res.columns .push(ArgColumn::Timeline(TimelineKind::profile(pubkey))) } else { error!("error parsing profile pubkey {}", profile_pk_str); continue; } } } else if arg == "--filter-file" || arg == "-f" { i += 1; let filter_file = if let Some(next_arg) = args.get(i) { next_arg } else { error!("filter file argument missing?"); continue; }; let data = if let Ok(data) = std::fs::read(filter_file) { data } else { error!("failed to read filter file '{}'", filter_file); continue; }; if let Some(filter) = std::str::from_utf8(&data) .ok() .and_then(|s| Filter::from_json(s).ok()) { res.columns.push(ArgColumn::Generic(vec![filter])); } else { error!("failed to parse filter in '{}'", filter_file); } } else { unrecognized_args.insert(arg.clone()); } i += 1; } (res, unrecognized_args) } } /// A way to define columns from the commandline. Can be column kinds or /// generic queries #[derive(Debug)] pub enum ArgColumn { Timeline(TimelineKind), Generic(Vec), } impl ArgColumn { pub fn into_timeline_kind(self) -> TimelineKind { match self { ArgColumn::Generic(_filters) => { // TODO: fix generic filters by referencing some filter map TimelineKind::Generic(0) } ArgColumn::Timeline(tk) => tk, } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/nav.rs`: ```rs use crate::{ accounts::render_accounts_route, actionbar::NoteAction, app::{get_active_columns_mut, get_decks_mut}, column::ColumnsAction, deck_state::DeckState, decks::{Deck, DecksAction, DecksCache}, profile::{ProfileAction, SaveProfileChanges}, profile_state::ProfileState, relay_pool_manager::RelayPoolManager, route::Route, timeline::{route::render_timeline_route, TimelineCache}, ui::{ self, add_column::render_add_column_routes, column::NavTitle, configure_deck::ConfigureDeckView, edit_deck::{EditDeckResponse, EditDeckView}, note::{PostAction, PostType}, profile::EditProfileView, support::SupportView, RelayView, View, }, Damus, }; use egui_nav::{Nav, NavAction, NavResponse, NavUiType}; use nostrdb::Transaction; use notedeck::{AccountsAction, AppContext}; use tracing::error; #[allow(clippy::enum_variant_names)] pub enum RenderNavAction { Back, RemoveColumn, PostAction(PostAction), NoteAction(NoteAction), ProfileAction(ProfileAction), SwitchingAction(SwitchingAction), } pub enum SwitchingAction { Accounts(AccountsAction), Columns(ColumnsAction), Decks(crate::decks::DecksAction), } impl SwitchingAction { /// process the action, and return whether switching occured pub fn process( &self, timeline_cache: &mut TimelineCache, decks_cache: &mut DecksCache, ctx: &mut AppContext<'_>, ) -> bool { match &self { SwitchingAction::Accounts(account_action) => match account_action { AccountsAction::Switch(switch_action) => { ctx.accounts.select_account(switch_action.switch_to); // pop nav after switch if let Some(src) = switch_action.source { get_active_columns_mut(ctx.accounts, decks_cache) .column_mut(src) .router_mut() .go_back(); } } AccountsAction::Remove(index) => ctx.accounts.remove_account(*index), }, SwitchingAction::Columns(columns_action) => match *columns_action { ColumnsAction::Remove(index) => { let kinds_to_pop = get_active_columns_mut(ctx.accounts, decks_cache).delete_column(index); for kind in &kinds_to_pop { if let Err(err) = timeline_cache.pop(kind, ctx.ndb, ctx.pool) { error!("error popping timeline: {err}"); } } } ColumnsAction::Switch(from, to) => { get_active_columns_mut(ctx.accounts, decks_cache).move_col(from, to); } }, SwitchingAction::Decks(decks_action) => match *decks_action { DecksAction::Switch(index) => { get_decks_mut(ctx.accounts, decks_cache).set_active(index) } DecksAction::Removing(index) => { get_decks_mut(ctx.accounts, decks_cache).remove_deck(index) } }, } true } } impl From for RenderNavAction { fn from(post_action: PostAction) -> Self { Self::PostAction(post_action) } } impl From for RenderNavAction { fn from(note_action: NoteAction) -> RenderNavAction { Self::NoteAction(note_action) } } pub type NotedeckNavResponse = NavResponse>; pub struct RenderNavResponse { column: usize, response: NotedeckNavResponse, } impl RenderNavResponse { #[allow(private_interfaces)] pub fn new(column: usize, response: NotedeckNavResponse) -> Self { RenderNavResponse { column, response } } #[must_use = "Make sure to save columns if result is true"] pub fn process_render_nav_response(&self, app: &mut Damus, ctx: &mut AppContext<'_>) -> bool { let mut switching_occured: bool = false; let col = self.column; if let Some(action) = self .response .response .as_ref() .or(self.response.title_response.as_ref()) { // start returning when we're finished posting match action { RenderNavAction::Back => { app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .go_back(); } RenderNavAction::RemoveColumn => { let kinds_to_pop = app.columns_mut(ctx.accounts).delete_column(col); for kind in &kinds_to_pop { if let Err(err) = app.timeline_cache.pop(kind, ctx.ndb, ctx.pool) { error!("error popping timeline: {err}"); } } switching_occured = true; } RenderNavAction::PostAction(post_action) => { let txn = Transaction::new(ctx.ndb).expect("txn"); let _ = post_action.execute(ctx.ndb, &txn, ctx.pool, &mut app.drafts); get_active_columns_mut(ctx.accounts, &mut app.decks_cache) .column_mut(col) .router_mut() .go_back(); } RenderNavAction::NoteAction(note_action) => { let txn = Transaction::new(ctx.ndb).expect("txn"); note_action.execute_and_process_result( ctx.ndb, get_active_columns_mut(ctx.accounts, &mut app.decks_cache), col, &mut app.timeline_cache, ctx.note_cache, ctx.pool, &txn, ctx.unknown_ids, ); } RenderNavAction::SwitchingAction(switching_action) => { switching_occured = switching_action.process( &mut app.timeline_cache, &mut app.decks_cache, ctx, ); } RenderNavAction::ProfileAction(profile_action) => { profile_action.process( &mut app.view_state.pubkey_to_profile_state, ctx.ndb, ctx.pool, get_active_columns_mut(ctx.accounts, &mut app.decks_cache) .column_mut(col) .router_mut(), ); } } } if let Some(action) = self.response.action { match action { NavAction::Returned => { let r = app .columns_mut(ctx.accounts) .column_mut(col) .router_mut() .pop(); if let Some(Route::Timeline(kind)) = &r { if let Err(err) = app.timeline_cache.pop(kind, ctx.ndb, ctx.pool) { error!("popping timeline had an error: {err} for {:?}", kind); } }; switching_occured = true; } NavAction::Navigated => { let cur_router = app.columns_mut(ctx.accounts).column_mut(col).router_mut(); cur_router.navigating = false; if cur_router.is_replacing() { cur_router.remove_previous_routes(); } switching_occured = true; } NavAction::Dragging => {} NavAction::Returning => {} NavAction::Resetting => {} NavAction::Navigating => {} } } switching_occured } } fn render_nav_body( ui: &mut egui::Ui, app: &mut Damus, ctx: &mut AppContext<'_>, top: &Route, depth: usize, col: usize, inner_rect: egui::Rect, ) -> Option { match top { Route::Timeline(kind) => render_timeline_route( ctx.ndb, ctx.img_cache, ctx.unknown_ids, ctx.note_cache, &mut app.timeline_cache, ctx.accounts, kind, col, app.note_options, depth, ui, ), Route::Accounts(amr) => { let mut action = render_accounts_route( ui, ctx.ndb, col, ctx.img_cache, ctx.accounts, &mut app.decks_cache, &mut app.view_state.login, *amr, ); let txn = Transaction::new(ctx.ndb).expect("txn"); action.process_action(ctx.unknown_ids, ctx.ndb, &txn); action .accounts_action .map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f))) } Route::Relays => { let manager = RelayPoolManager::new(ctx.pool); RelayView::new(ctx.accounts, manager, &mut app.view_state.id_string_map).ui(ui); None } Route::Reply(id) => { let txn = if let Ok(txn) = Transaction::new(ctx.ndb) { txn } else { ui.label("Reply to unknown note"); return None; }; let note = if let Ok(note) = ctx.ndb.get_note_by_id(&txn, id.bytes()) { note } else { ui.label("Reply to unknown note"); return None; }; let id = egui::Id::new(("post", col, note.key().unwrap())); let poster = ctx.accounts.selected_or_first_nsec()?; let action = { let draft = app.drafts.reply_mut(note.id()); let response = egui::ScrollArea::vertical().show(ui, |ui| { ui::PostReplyView::new( ctx.ndb, poster, draft, ctx.note_cache, ctx.img_cache, ¬e, inner_rect, app.note_options, ) .id_source(id) .show(ui) }); response.inner.action }; action.map(Into::into) } Route::Quote(id) => { let txn = Transaction::new(ctx.ndb).expect("txn"); let note = if let Ok(note) = ctx.ndb.get_note_by_id(&txn, id.bytes()) { note } else { ui.label("Quote of unknown note"); return None; }; let id = egui::Id::new(("post", col, note.key().unwrap())); let poster = ctx.accounts.selected_or_first_nsec()?; let draft = app.drafts.quote_mut(note.id()); let response = egui::ScrollArea::vertical().show(ui, |ui| { crate::ui::note::QuoteRepostView::new( ctx.ndb, poster, ctx.note_cache, ctx.img_cache, draft, ¬e, inner_rect, app.note_options, ) .id_source(id) .show(ui) }); response.inner.action.map(Into::into) } Route::ComposeNote => { let kp = ctx.accounts.get_selected_account()?.to_full()?; let draft = app.drafts.compose_mut(); let txn = Transaction::new(ctx.ndb).expect("txn"); let post_response = ui::PostView::new( ctx.ndb, draft, PostType::New, ctx.img_cache, ctx.note_cache, kp, inner_rect, app.note_options, ) .ui(&txn, ui); post_response.action.map(Into::into) } Route::AddColumn(route) => { render_add_column_routes(ui, app, ctx, col, route); None } Route::Support => { SupportView::new(&mut app.support).show(ui); None } Route::NewDeck => { let id = ui.id().with("new-deck"); let new_deck_state = app.view_state.id_to_deck_state.entry(id).or_default(); let mut resp = None; if let Some(config_resp) = ConfigureDeckView::new(new_deck_state).ui(ui) { if let Some(cur_acc) = ctx.accounts.get_selected_account() { app.decks_cache.add_deck( cur_acc.pubkey, Deck::new(config_resp.icon, config_resp.name), ); // set new deck as active let cur_index = get_decks_mut(ctx.accounts, &mut app.decks_cache) .decks() .len() - 1; resp = Some(RenderNavAction::SwitchingAction(SwitchingAction::Decks( DecksAction::Switch(cur_index), ))); } new_deck_state.clear(); get_active_columns_mut(ctx.accounts, &mut app.decks_cache) .get_first_router() .go_back(); } resp } Route::EditDeck(index) => { let mut action = None; let cur_deck = get_decks_mut(ctx.accounts, &mut app.decks_cache) .decks_mut() .get_mut(*index) .expect("index wasn't valid"); let id = ui.id().with(( "edit-deck", ctx.accounts.get_selected_account().map(|k| k.pubkey), index, )); let deck_state = app .view_state .id_to_deck_state .entry(id) .or_insert_with(|| DeckState::from_deck(cur_deck)); if let Some(resp) = EditDeckView::new(deck_state).ui(ui) { match resp { EditDeckResponse::Edit(configure_deck_response) => { cur_deck.edit(configure_deck_response); } EditDeckResponse::Delete => { action = Some(RenderNavAction::SwitchingAction(SwitchingAction::Decks( DecksAction::Removing(*index), ))); } } get_active_columns_mut(ctx.accounts, &mut app.decks_cache) .get_first_router() .go_back(); } action } Route::EditProfile(pubkey) => { let mut action = None; if let Some(kp) = ctx.accounts.get_full(pubkey.bytes()) { let state = app .view_state .pubkey_to_profile_state .entry(*kp.pubkey) .or_insert_with(|| { let txn = Transaction::new(ctx.ndb).expect("txn"); if let Ok(record) = ctx.ndb.get_profile_by_pubkey(&txn, kp.pubkey.bytes()) { ProfileState::from_profile(&record) } else { ProfileState::default() } }); if EditProfileView::new(state, ctx.img_cache).ui(ui) { if let Some(taken_state) = app.view_state.pubkey_to_profile_state.remove(kp.pubkey) { action = Some(RenderNavAction::ProfileAction(ProfileAction::SaveChanges( SaveProfileChanges::new(kp.to_full(), taken_state), ))) } } } else { error!("Pubkey in EditProfile route did not have an nsec attached in Accounts"); } action } } } #[must_use = "RenderNavResponse must be handled by calling .process_render_nav_response(..)"] pub fn render_nav( col: usize, inner_rect: egui::Rect, app: &mut Damus, ctx: &mut AppContext<'_>, ui: &mut egui::Ui, ) -> RenderNavResponse { let nav_response = Nav::new( &app.columns(ctx.accounts) .column(col) .router() .routes() .clone(), ) .navigating( app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .navigating, ) .returning( app.columns_mut(ctx.accounts) .column_mut(col) .router_mut() .returning, ) .id_source(egui::Id::new(("nav", col))) .show_mut(ui, |ui, render_type, nav| match render_type { NavUiType::Title => NavTitle::new( ctx.ndb, ctx.img_cache, get_active_columns_mut(ctx.accounts, &mut app.decks_cache), nav.routes(), col, ) .show(ui), NavUiType::Body => { if let Some(top) = nav.routes().last() { render_nav_body(ui, app, ctx, top, nav.routes().len(), col, inner_rect) } else { None } } }); RenderNavResponse::new(col, nav_response) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/app.rs`: ```rs use crate::{ args::ColumnsArgs, column::Columns, decks::{Decks, DecksCache, FALLBACK_PUBKEY}, draft::Drafts, nav, storage, subscriptions::{SubKind, Subscriptions}, support::Support, timeline::{self, TimelineCache}, ui::{self, note::NoteOptions, DesktopSidePanel}, unknowns, view_state::ViewState, Result, }; use notedeck::{Accounts, AppContext, DataPath, DataPathType, FilterState, UnknownIds}; use enostr::{ClientMessage, Keypair, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool}; use uuid::Uuid; use egui_extras::{Size, StripBuilder}; use nostrdb::{Ndb, Transaction}; use std::collections::{BTreeSet, HashMap}; use std::path::Path; use std::time::Duration; use tracing::{debug, error, info, trace, warn}; #[derive(Debug, Eq, PartialEq, Clone)] pub enum DamusState { Initializing, Initialized, } /// We derive Deserialize/Serialize so we can persist app state on shutdown. pub struct Damus { state: DamusState, pub decks_cache: DecksCache, pub view_state: ViewState, pub drafts: Drafts, pub timeline_cache: TimelineCache, pub subscriptions: Subscriptions, pub support: Support, //frame_history: crate::frame_history::FrameHistory, // TODO: make these bitflags /// Were columns loaded from the commandline? If so disable persistence. pub tmp_columns: bool, pub debug: bool, pub since_optimize: bool, pub note_options: NoteOptions, pub unrecognized_args: BTreeSet, } fn handle_key_events(input: &egui::InputState, columns: &mut Columns) { for event in &input.raw.events { if let egui::Event::Key { key, pressed: true, .. } = event { match key { egui::Key::J => { columns.select_down(); } egui::Key::K => { columns.select_up(); } egui::Key::H => { columns.select_left(); } egui::Key::L => { columns.select_left(); } _ => {} } } } } fn try_process_event( damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context, ) -> Result<()> { let current_columns = get_active_columns_mut(app_ctx.accounts, &mut damus.decks_cache); ctx.input(|i| handle_key_events(i, current_columns)); let ctx2 = ctx.clone(); let wakeup = move || { ctx2.request_repaint(); }; app_ctx.pool.keepalive_ping(wakeup); // NOTE: we don't use the while let loop due to borrow issues #[allow(clippy::while_let_loop)] loop { let ev = if let Some(ev) = app_ctx.pool.try_recv() { ev.into_owned() } else { break; }; match (&ev.event).into() { RelayEvent::Opened => { app_ctx .accounts .send_initial_filters(app_ctx.pool, &ev.relay); timeline::send_initial_timeline_filters( app_ctx.ndb, damus.since_optimize, &mut damus.timeline_cache, &mut damus.subscriptions, app_ctx.pool, &ev.relay, ); } // TODO: handle reconnects RelayEvent::Closed => warn!("{} connection closed", &ev.relay), RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e), RelayEvent::Other(msg) => trace!("other event {:?}", &msg), RelayEvent::Message(msg) => { process_message(damus, app_ctx, &ev.relay, &msg); } } } for (_kind, timeline) in damus.timeline_cache.timelines.iter_mut() { let is_ready = timeline::is_timeline_ready(app_ctx.ndb, app_ctx.pool, app_ctx.note_cache, timeline); if is_ready { let txn = Transaction::new(app_ctx.ndb).expect("txn"); // only thread timelines are reversed let reversed = false; if let Err(err) = timeline.poll_notes_into_view( app_ctx.ndb, &txn, app_ctx.unknown_ids, app_ctx.note_cache, reversed, ) { error!("poll_notes_into_view: {err}"); } } else { // TODO: show loading? } } if app_ctx.unknown_ids.ready_to_send() { unknown_id_send(app_ctx.unknown_ids, app_ctx.pool); } Ok(()) } fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut RelayPool) { debug!("unknown_id_send called on: {:?}", &unknown_ids); let filter = unknown_ids.filter().expect("filter"); info!( "Getting {} unknown ids from relays", unknown_ids.ids_iter().len() ); let msg = ClientMessage::req("unknownids".to_string(), filter); unknown_ids.clear(); pool.send(&msg); } fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context) { app_ctx.img_cache.urls.cache.handle_io(); match damus.state { DamusState::Initializing => { damus.state = DamusState::Initialized; // this lets our eose handler know to close unknownids right away damus .subscriptions() .insert("unknownids".to_string(), SubKind::OneShot); if let Err(err) = timeline::setup_initial_nostrdb_subs( app_ctx.ndb, app_ctx.note_cache, &mut damus.timeline_cache, ) { warn!("update_damus init: {err}"); } } DamusState::Initialized => (), }; if let Err(err) = try_process_event(damus, app_ctx, ctx) { error!("error processing event: {}", err); } } fn handle_eose( subscriptions: &Subscriptions, timeline_cache: &mut TimelineCache, ctx: &mut AppContext<'_>, subid: &str, relay_url: &str, ) -> Result<()> { let sub_kind = if let Some(sub_kind) = subscriptions.subs.get(subid) { sub_kind } else { let n_subids = subscriptions.subs.len(); warn!( "got unknown eose subid {}, {} tracked subscriptions", subid, n_subids ); return Ok(()); }; match sub_kind { SubKind::Timeline(_) => { // eose on timeline? whatevs } SubKind::Initial => { let txn = Transaction::new(ctx.ndb)?; unknowns::update_from_columns( &txn, ctx.unknown_ids, timeline_cache, ctx.ndb, ctx.note_cache, ); // this is possible if this is the first time if ctx.unknown_ids.ready_to_send() { unknown_id_send(ctx.unknown_ids, ctx.pool); } } // oneshot subs just close when they're done SubKind::OneShot => { let msg = ClientMessage::close(subid.to_string()); ctx.pool.send_to(&msg, relay_url); } SubKind::FetchingContactList(timeline_uid) => { let timeline = if let Some(tl) = timeline_cache.timelines.get_mut(timeline_uid) { tl } else { error!( "timeline uid:{} not found for FetchingContactList", timeline_uid ); return Ok(()); }; let filter_state = timeline.filter.get_mut(relay_url); // If this request was fetching a contact list, our filter // state should be "FetchingRemote". We look at the local // subscription for that filter state and get the subscription id let local_sub = if let FilterState::FetchingRemote(unisub) = filter_state { unisub.local } else { // TODO: we could have multiple contact list results, we need // to check to see if this one is newer and use that instead warn!( "Expected timeline to have FetchingRemote state but was {:?}", timeline.filter ); return Ok(()); }; info!( "got contact list from {}, updating filter_state to got_remote", relay_url ); // We take the subscription id and pass it to the new state of // "GotRemote". This will let future frames know that it can try // to look for the contact list in nostrdb. timeline .filter .set_relay_state(relay_url.to_string(), FilterState::got_remote(local_sub)); } } Ok(()) } fn process_message(damus: &mut Damus, ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessage) { match msg { RelayMessage::Event(_subid, ev) => { let relay = if let Some(relay) = ctx.pool.relays.iter().find(|r| r.url() == relay) { relay } else { error!("couldn't find relay {} for note processing!?", relay); return; }; match relay { PoolRelay::Websocket(_) => { //info!("processing event {}", event); if let Err(err) = ctx.ndb.process_event(ev) { error!("error processing event {ev}: {err}"); } } PoolRelay::Multicast(_) => { // multicast events are client events if let Err(err) = ctx.ndb.process_client_event(ev) { error!("error processing multicast event {ev}: {err}"); } } } } RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg), RelayMessage::OK(cr) => info!("OK {:?}", cr), RelayMessage::Eose(sid) => { if let Err(err) = handle_eose( &damus.subscriptions, &mut damus.timeline_cache, ctx, sid, relay, ) { error!("error handling eose: {}", err); } } } } fn render_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) { if notedeck::ui::is_narrow(ui.ctx()) { render_damus_mobile(damus, app_ctx, ui); } else { render_damus_desktop(damus, app_ctx, ui); } // We use this for keeping timestamps and things up to date ui.ctx().request_repaint_after(Duration::from_secs(1)); } /* fn determine_key_storage_type() -> KeyStorageType { #[cfg(target_os = "macos")] { KeyStorageType::MacOS } #[cfg(target_os = "linux")] { KeyStorageType::Linux } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { KeyStorageType::None } } */ impl Damus { /// Called once before the first frame. pub fn new(ctx: &mut AppContext<'_>, args: &[String]) -> Self { // arg parsing let (parsed_args, unrecognized_args) = ColumnsArgs::parse( args, ctx.accounts .get_selected_account() .as_ref() .map(|kp| &kp.pubkey), ); let account = ctx .accounts .get_selected_account() .as_ref() .map(|a| a.pubkey.bytes()); let mut timeline_cache = TimelineCache::default(); let tmp_columns = !parsed_args.columns.is_empty(); let decks_cache = if tmp_columns { info!("DecksCache: loading from command line arguments"); let mut columns: Columns = Columns::new(); let txn = Transaction::new(ctx.ndb).unwrap(); for col in parsed_args.columns { let timeline_kind = col.into_timeline_kind(); if let Some(add_result) = columns.add_new_timeline_column( &mut timeline_cache, &txn, ctx.ndb, ctx.note_cache, ctx.pool, &timeline_kind, ) { add_result.process( ctx.ndb, ctx.note_cache, &txn, &mut timeline_cache, ctx.unknown_ids, ); } } columns_to_decks_cache(columns, account) } else if let Some(decks_cache) = crate::storage::load_decks_cache(ctx.path, ctx.ndb, &mut timeline_cache) { info!( "DecksCache: loading from disk {}", crate::storage::DECKS_CACHE_FILE ); decks_cache } else { info!("DecksCache: creating new with demo configuration"); let mut cache = DecksCache::new_with_demo_config(&mut timeline_cache, ctx); for account in ctx.accounts.get_accounts() { cache.add_deck_default(account.pubkey); } set_demo(&mut cache, ctx.ndb, ctx.accounts, ctx.unknown_ids); cache }; let debug = ctx.args.debug; let support = Support::new(ctx.path); let mut note_options = NoteOptions::default(); note_options.set_textmode(parsed_args.textmode); note_options.set_scramble_text(parsed_args.scramble); note_options.set_hide_media(parsed_args.no_media); Self { subscriptions: Subscriptions::default(), since_optimize: parsed_args.since_optimize, timeline_cache, drafts: Drafts::default(), state: DamusState::Initializing, note_options, //frame_history: FrameHistory::default(), view_state: ViewState::default(), tmp_columns, support, decks_cache, debug, unrecognized_args, } } pub fn columns_mut(&mut self, accounts: &Accounts) -> &mut Columns { get_active_columns_mut(accounts, &mut self.decks_cache) } pub fn columns(&self, accounts: &Accounts) -> &Columns { get_active_columns(accounts, &self.decks_cache) } pub fn gen_subid(&self, kind: &SubKind) -> String { if self.debug { format!("{:?}", kind) } else { Uuid::new_v4().to_string() } } pub fn mock>(data_path: P) -> Self { let decks_cache = DecksCache::default(); let path = DataPath::new(&data_path); let imgcache_dir = path.path(DataPathType::Cache); let _ = std::fs::create_dir_all(imgcache_dir.clone()); let debug = true; let support = Support::new(&path); Self { debug, subscriptions: Subscriptions::default(), since_optimize: true, timeline_cache: TimelineCache::default(), drafts: Drafts::default(), state: DamusState::Initializing, note_options: NoteOptions::default(), tmp_columns: true, //frame_history: FrameHistory::default(), view_state: ViewState::default(), support, decks_cache, unrecognized_args: BTreeSet::default(), } } pub fn subscriptions(&mut self) -> &mut HashMap { &mut self.subscriptions.subs } pub fn unrecognized_args(&self) -> &BTreeSet { &self.unrecognized_args } } /* fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) { let stroke = ui.style().interact(&response).fg_stroke; let radius = egui::lerp(2.0..=3.0, openness); ui.painter() .circle_filled(response.rect.center(), radius, stroke.color); } */ fn render_damus_mobile(app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) { #[cfg(feature = "profiling")] puffin::profile_function!(); //let routes = app.timelines[0].routes.clone(); if !app.columns(app_ctx.accounts).columns().is_empty() && nav::render_nav(0, ui.available_rect_before_wrap(), app, app_ctx, ui) .process_render_nav_response(app, app_ctx) && !app.tmp_columns { storage::save_decks_cache(app_ctx.path, &app.decks_cache); } } fn render_damus_desktop(app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) { #[cfg(feature = "profiling")] puffin::profile_function!(); let screen_size = ui.ctx().screen_rect().width(); let calc_panel_width = (screen_size / get_active_columns(app_ctx.accounts, &app.decks_cache).num_columns() as f32) - 30.0; let min_width = 320.0; let need_scroll = calc_panel_width < min_width; let panel_sizes = if need_scroll { Size::exact(min_width) } else { Size::remainder() }; ui.spacing_mut().item_spacing.x = 0.0; if need_scroll { egui::ScrollArea::horizontal().show(ui, |ui| { timelines_view(ui, panel_sizes, app, app_ctx); }); } else { timelines_view(ui, panel_sizes, app, app_ctx); } } fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, ctx: &mut AppContext<'_>) { StripBuilder::new(ui) .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH)) .sizes( sizes, get_active_columns(ctx.accounts, &app.decks_cache).num_columns(), ) .clip(true) .horizontal(|mut strip| { let mut side_panel_action: Option = None; strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); let side_panel = DesktopSidePanel::new( ctx.ndb, ctx.img_cache, ctx.accounts.get_selected_account(), &app.decks_cache, ) .show(ui); if side_panel.response.clicked() || side_panel.response.secondary_clicked() { if let Some(action) = DesktopSidePanel::perform_action( &mut app.decks_cache, ctx.accounts, &mut app.support, ctx.theme, side_panel.action, ) { side_panel_action = Some(action); } } // vertical sidebar line ui.painter().vline( rect.right(), rect.y_range(), ui.visuals().widgets.noninteractive.bg_stroke, ); }); let mut save_cols = false; if let Some(action) = side_panel_action { save_cols = save_cols || action.process(&mut app.timeline_cache, &mut app.decks_cache, ctx); } let num_cols = app.columns(ctx.accounts).num_columns(); let mut responses = Vec::with_capacity(num_cols); for col_index in 0..num_cols { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); let v_line_stroke = ui.visuals().widgets.noninteractive.bg_stroke; let inner_rect = { let mut inner = rect; inner.set_right(rect.right() - v_line_stroke.width); inner }; responses.push(nav::render_nav(col_index, inner_rect, app, ctx, ui)); // vertical line ui.painter() .vline(rect.right(), rect.y_range(), v_line_stroke); }); //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); } for response in responses { let save = response.process_render_nav_response(app, ctx); save_cols = save_cols || save; } if app.tmp_columns { save_cols = false; } if save_cols { storage::save_decks_cache(ctx.path, &app.decks_cache); } }); } impl notedeck::App for Damus { fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) { /* self.app .frame_history .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage); */ update_damus(self, ctx, ui.ctx()); render_damus(self, ctx, ui); } } pub fn get_active_columns<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Columns { get_decks(accounts, decks_cache).active().columns() } pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Decks { let key = if let Some(acc) = accounts.get_selected_account() { &acc.pubkey } else { decks_cache.get_fallback_pubkey() }; decks_cache.decks(key) } pub fn get_active_columns_mut<'a>( accounts: &Accounts, decks_cache: &'a mut DecksCache, ) -> &'a mut Columns { get_decks_mut(accounts, decks_cache) .active_mut() .columns_mut() } pub fn get_decks_mut<'a>(accounts: &Accounts, decks_cache: &'a mut DecksCache) -> &'a mut Decks { if let Some(acc) = accounts.get_selected_account() { decks_cache.decks_mut(&acc.pubkey) } else { decks_cache.fallback_mut() } } pub fn set_demo( decks_cache: &mut DecksCache, ndb: &Ndb, accounts: &mut Accounts, unk_ids: &mut UnknownIds, ) { let txn = Transaction::new(ndb).expect("txn"); accounts .add_account(Keypair::only_pubkey(*decks_cache.get_fallback_pubkey())) .process_action(unk_ids, ndb, &txn); accounts.select_account(accounts.num_accounts() - 1); } fn columns_to_decks_cache(cols: Columns, key: Option<&[u8; 32]>) -> DecksCache { let mut account_to_decks: HashMap = Default::default(); let decks = Decks::new(crate::decks::Deck::new_with_columns( crate::decks::Deck::default().icon, "My Deck".to_owned(), cols, )); let account = if let Some(key) = key { Pubkey::new(*key) } else { FALLBACK_PUBKEY() }; account_to_decks.insert(account, decks); DecksCache::new(account_to_decks) } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/timeline/cache.rs`: ```rs use crate::{ actionbar::TimelineOpenResult, error::Error, multi_subscriber::MultiSubscriber, //subscriptions::SubRefs, timeline::{Timeline, TimelineKind}, }; use notedeck::{filter, FilterState, NoteCache, NoteRef}; use enostr::RelayPool; use nostrdb::{Filter, Ndb, Transaction}; use std::collections::HashMap; use tracing::{debug, error, info, warn}; #[derive(Default)] pub struct TimelineCache { pub timelines: HashMap, } pub enum Vitality<'a, M> { Fresh(&'a mut M), Stale(&'a mut M), } impl<'a, M> Vitality<'a, M> { pub fn get_ptr(self) -> &'a mut M { match self { Self::Fresh(ptr) => ptr, Self::Stale(ptr) => ptr, } } pub fn is_stale(&self) -> bool { match self { Self::Fresh(_ptr) => false, Self::Stale(_ptr) => true, } } } impl TimelineCache { /// Pop a timeline from the timeline cache. This only removes the timeline /// if it has reached 0 subscribers, meaning it was the last one to be /// removed pub fn pop( &mut self, id: &TimelineKind, ndb: &mut Ndb, pool: &mut RelayPool, ) -> Result<(), Error> { let timeline = if let Some(timeline) = self.timelines.get_mut(id) { timeline } else { return Err(Error::TimelineNotFound); }; if let Some(sub) = &mut timeline.subscription { // if this is the last subscriber, remove the timeline from cache if sub.unsubscribe(ndb, pool) { debug!( "popped last timeline {:?}, removing from timeline cache", id ); self.timelines.remove(id); } Ok(()) } else { Err(Error::MissingSubscription) } } fn get_expected_mut(&mut self, key: &TimelineKind) -> &mut Timeline { self.timelines .get_mut(key) .expect("expected notes in timline cache") } /// Insert a new timeline into the cache, based on the TimelineKind #[allow(clippy::too_many_arguments)] fn insert_new( &mut self, id: TimelineKind, txn: &Transaction, ndb: &Ndb, notes: &[NoteRef], note_cache: &mut NoteCache, ) { let mut timeline = if let Some(timeline) = id.clone().into_timeline(txn, ndb) { timeline } else { error!("Error creating timeline from {:?}", &id); return; }; // insert initial notes into timeline timeline.insert_new(txn, ndb, note_cache, notes); self.timelines.insert(id, timeline); } /// Get and/or update the notes associated with this timeline pub fn notes<'a>( &'a mut self, ndb: &Ndb, note_cache: &mut NoteCache, txn: &Transaction, id: &TimelineKind, ) -> Vitality<'a, Timeline> { // we can't use the naive hashmap entry API here because lookups // require a copy, wait until we have a raw entry api. We could // also use hashbrown? if self.timelines.contains_key(id) { return Vitality::Stale(self.get_expected_mut(id)); } let notes = if let FilterState::Ready(filters) = id.filters(txn, ndb) { if let Ok(results) = ndb.query(txn, &filters, 1000) { results .into_iter() .map(NoteRef::from_query_result) .collect() } else { debug!("got no results from TimelineCache lookup for {:?}", id); vec![] } } else { // filter is not ready yet vec![] }; if notes.is_empty() { warn!("NotesHolder query returned 0 notes? ") } else { info!("found NotesHolder with {} notes", notes.len()); } self.insert_new(id.to_owned(), txn, ndb, ¬es, note_cache); Vitality::Fresh(self.get_expected_mut(id)) } /// Open a timeline, this is another way of saying insert a timeline /// into the timeline cache. If there exists a timeline already, we /// bump its subscription reference count. If it's new we start a new /// subscription pub fn open( &mut self, ndb: &Ndb, note_cache: &mut NoteCache, txn: &Transaction, pool: &mut RelayPool, id: &TimelineKind, ) -> Option { let (open_result, timeline) = match self.notes(ndb, note_cache, txn, id) { Vitality::Stale(timeline) => { // The timeline cache is stale, let's update it let notes = find_new_notes( timeline.all_or_any_notes(), timeline.subscription.as_ref().map(|s| &s.filters)?, txn, ndb, ); let open_result = if notes.is_empty() { None } else { let new_notes = notes.iter().map(|n| n.key).collect(); Some(TimelineOpenResult::new_notes(new_notes, id.clone())) }; // we can't insert and update the VirtualList now, because we // are already borrowing it mutably. Let's pass it as a // result instead // // holder.get_view().insert(¬es); <-- no (open_result, timeline) } Vitality::Fresh(timeline) => (None, timeline), }; if let Some(multi_sub) = &mut timeline.subscription { debug!("got open with *old* subscription for {:?}", &timeline.kind); multi_sub.subscribe(ndb, pool); } else if let Some(filter) = timeline.filter.get_any_ready() { debug!("got open with *new* subscription for {:?}", &timeline.kind); let mut multi_sub = MultiSubscriber::new(filter.clone()); multi_sub.subscribe(ndb, pool); timeline.subscription = Some(multi_sub); } else { // This should never happen reasoning, self.notes would have // failed above if the filter wasn't ready error!( "open: filter not ready, so could not setup subscription. this should never happen" ); }; open_result } } /// Look for new thread notes since our last fetch fn find_new_notes( notes: &[NoteRef], filters: &[Filter], txn: &Transaction, ndb: &Ndb, ) -> Vec { if notes.is_empty() { return vec![]; } let last_note = notes[0]; let filters = filter::make_filters_since(filters, last_note.created_at + 1); if let Ok(results) = ndb.query(txn, &filters, 1000) { debug!("got {} results from NotesHolder update", results.len()); results .into_iter() .map(NoteRef::from_query_result) .collect() } else { debug!("got no results from NotesHolder update",); vec![] } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/timeline/route.rs`: ```rs use crate::{ nav::RenderNavAction, profile::ProfileAction, timeline::{TimelineCache, TimelineKind}, ui::{self, note::NoteOptions, profile::ProfileView}, }; use enostr::Pubkey; use nostrdb::Ndb; use notedeck::{Accounts, Images, MuteFun, NoteCache, UnknownIds}; #[allow(clippy::too_many_arguments)] pub fn render_timeline_route( ndb: &Ndb, img_cache: &mut Images, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, timeline_cache: &mut TimelineCache, accounts: &mut Accounts, kind: &TimelineKind, col: usize, mut note_options: NoteOptions, depth: usize, ui: &mut egui::Ui, ) -> Option { if kind == &TimelineKind::Universe { note_options.set_hide_media(true); } match kind { TimelineKind::List(_) | TimelineKind::Algo(_) | TimelineKind::Notifications(_) | TimelineKind::Universe | TimelineKind::Hashtag(_) | TimelineKind::Generic(_) => { let note_action = ui::TimelineView::new( kind, timeline_cache, ndb, note_cache, img_cache, note_options, &accounts.mutefun(), ) .ui(ui); note_action.map(RenderNavAction::NoteAction) } TimelineKind::Profile(pubkey) => { if depth > 1 { render_profile_route( pubkey, accounts, ndb, timeline_cache, img_cache, note_cache, unknown_ids, col, ui, &accounts.mutefun(), note_options, ) } else { // we render profiles like timelines if they are at the root let note_action = ui::TimelineView::new( kind, timeline_cache, ndb, note_cache, img_cache, note_options, &accounts.mutefun(), ) .ui(ui); note_action.map(RenderNavAction::NoteAction) } } TimelineKind::Thread(id) => ui::ThreadView::new( timeline_cache, ndb, note_cache, unknown_ids, img_cache, id.selected_or_root(), note_options, &accounts.mutefun(), ) .id_source(egui::Id::new(("threadscroll", col))) .ui(ui) .map(Into::into), } } #[allow(clippy::too_many_arguments)] pub fn render_profile_route( pubkey: &Pubkey, accounts: &Accounts, ndb: &Ndb, timeline_cache: &mut TimelineCache, img_cache: &mut Images, note_cache: &mut NoteCache, unknown_ids: &mut UnknownIds, col: usize, ui: &mut egui::Ui, is_muted: &MuteFun, note_options: NoteOptions, ) -> Option { let action = ProfileView::new( pubkey, accounts, col, timeline_cache, ndb, note_cache, img_cache, unknown_ids, is_muted, note_options, ) .ui(ui); if let Some(action) = action { match action { ui::profile::ProfileViewAction::EditProfile => accounts .get_full(pubkey.bytes()) .map(|kp| RenderNavAction::ProfileAction(ProfileAction::Edit(kp.to_full()))), ui::profile::ProfileViewAction::Note(note_action) => { Some(RenderNavAction::NoteAction(note_action)) } } } else { None } } #[cfg(test)] mod tests { use enostr::NoteId; use tokenator::{TokenParser, TokenWriter}; use crate::timeline::{ThreadSelection, TimelineKind}; use enostr::Pubkey; use notedeck::RootNoteIdBuf; #[test] fn test_timeline_route_serialize() { let note_id_hex = "1c54e5b0c386425f7e017d9e068ddef8962eb2ce1bb08ed27e24b93411c12e60"; let note_id = NoteId::from_hex(note_id_hex).unwrap(); let data_str = format!("thread:{}", note_id_hex); let data = &data_str.split(":").collect::>(); let mut token_writer = TokenWriter::default(); let mut parser = TokenParser::new(&data); let parsed = TimelineKind::parse(&mut parser, &Pubkey::new(*note_id.bytes())).unwrap(); let expected = TimelineKind::Thread(ThreadSelection::from_root_id( RootNoteIdBuf::new_unsafe(*note_id.bytes()), )); parsed.serialize_tokens(&mut token_writer); assert_eq!(expected, parsed); assert_eq!(token_writer.str(), data_str); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/timeline/kind.rs`: ```rs use crate::error::Error; use crate::timeline::{Timeline, TimelineTab}; use enostr::{Filter, NoteId, Pubkey}; use nostrdb::{Ndb, Transaction}; use notedeck::{ filter::{self, default_limit}, FilterError, FilterState, NoteCache, RootIdError, RootNoteIdBuf, }; use serde::{Deserialize, Serialize}; use std::hash::{Hash, Hasher}; use std::{borrow::Cow, fmt::Display}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; use tracing::{error, warn}; #[derive(Clone, Hash, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PubkeySource { Explicit(Pubkey), #[default] DeckAuthor, } #[derive(Debug, Clone, Copy, PartialEq, Hash, Eq)] pub enum ListKind { Contact(Pubkey), } impl ListKind { pub fn pubkey(&self) -> Option<&Pubkey> { match self { Self::Contact(pk) => Some(pk), } } } impl PubkeySource { pub fn pubkey(pubkey: Pubkey) -> Self { PubkeySource::Explicit(pubkey) } pub fn as_pubkey<'a>(&'a self, deck_author: &'a Pubkey) -> &'a Pubkey { match self { PubkeySource::Explicit(pk) => pk, PubkeySource::DeckAuthor => deck_author, } } } impl TokenSerializable for PubkeySource { fn serialize_tokens(&self, writer: &mut TokenWriter) { match self { PubkeySource::DeckAuthor => { writer.write_token("deck_author"); } PubkeySource::Explicit(pk) => { writer.write_token(&hex::encode(pk.bytes())); } } } fn parse_from_tokens<'a>(parser: &mut TokenParser<'a>) -> Result> { parser.try_parse(|p| { match p.pull_token() { // we handle bare payloads and assume they are explicit pubkey sources Ok("explicit") => { if let Ok(hex) = p.pull_token() { let pk = Pubkey::from_hex(hex).map_err(|_| ParseError::HexDecodeFailed)?; Ok(PubkeySource::Explicit(pk)) } else { Err(ParseError::HexDecodeFailed) } } Err(_) | Ok("deck_author") => Ok(PubkeySource::DeckAuthor), Ok(hex) => { let pk = Pubkey::from_hex(hex).map_err(|_| ParseError::HexDecodeFailed)?; Ok(PubkeySource::Explicit(pk)) } } }) } } impl ListKind { pub fn contact_list(pk: Pubkey) -> Self { ListKind::Contact(pk) } pub fn parse<'a>( parser: &mut TokenParser<'a>, deck_author: &Pubkey, ) -> Result> { parser.parse_all(|p| { p.parse_token("contact")?; let pk_src = PubkeySource::parse_from_tokens(p)?; Ok(ListKind::Contact(*pk_src.as_pubkey(deck_author))) }) /* here for u when you need more things to parse TokenParser::alt( parser, &[|p| { p.parse_all(|p| { p.parse_token("contact")?; let pk_src = PubkeySource::parse_from_tokens(p)?; Ok(ListKind::Contact(pk_src)) }); },|p| { // more cases... }], ) */ } pub fn serialize_tokens(&self, writer: &mut TokenWriter) { match self { ListKind::Contact(pk) => { writer.write_token("contact"); PubkeySource::pubkey(*pk).serialize_tokens(writer); } } } } /// Thread selection hashing is done in a specific way. For TimelineCache /// lookups, we want to only let the root_id influence thread selection. /// This way Thread TimelineKinds always map to the same cached timeline /// for now (we will likely have to rework this since threads aren't /// *really* timelines) #[derive(Debug, Clone)] pub struct ThreadSelection { pub root_id: RootNoteIdBuf, /// The selected note, if different than the root_id. None here /// means the root is selected pub selected_note: Option, } impl ThreadSelection { pub fn selected_or_root(&self) -> &[u8; 32] { self.selected_note .as_ref() .map(|sn| sn.bytes()) .unwrap_or(self.root_id.bytes()) } pub fn from_root_id(root_id: RootNoteIdBuf) -> Self { Self { root_id, selected_note: None, } } pub fn from_note_id( ndb: &Ndb, note_cache: &mut NoteCache, txn: &Transaction, note_id: NoteId, ) -> Result { let root_id = RootNoteIdBuf::new(ndb, note_cache, txn, note_id.bytes())?; Ok(if root_id.bytes() == note_id.bytes() { Self::from_root_id(root_id) } else { Self { root_id, selected_note: Some(note_id), } }) } } impl Hash for ThreadSelection { fn hash(&self, state: &mut H) { // only hash the root id for thread selection self.root_id.hash(state) } } // need this to only match root_id or else hash lookups will fail impl PartialEq for ThreadSelection { fn eq(&self, other: &Self) -> bool { self.root_id == other.root_id } } impl Eq for ThreadSelection {} /// /// What kind of timeline is it? /// - Follow List /// - Notifications /// - DM /// - filter /// - ... etc /// #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum TimelineKind { List(ListKind), /// The last not per pubkey Algo(AlgoTimeline), Notifications(Pubkey), Profile(Pubkey), Thread(ThreadSelection), Universe, /// Generic filter, references a hash of a filter Generic(u64), Hashtag(String), } const NOTIFS_TOKEN_DEPRECATED: &str = "notifs"; const NOTIFS_TOKEN: &str = "notifications"; /// Hardcoded algo timelines #[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)] pub enum AlgoTimeline { /// LastPerPubkey: a special nostr query that fetches the last N /// notes for each pubkey on the list LastPerPubkey(ListKind), } /// The identifier for our last per pubkey algo const LAST_PER_PUBKEY_TOKEN: &str = "last_per_pubkey"; impl AlgoTimeline { pub fn serialize_tokens(&self, writer: &mut TokenWriter) { match self { AlgoTimeline::LastPerPubkey(list_kind) => { writer.write_token(LAST_PER_PUBKEY_TOKEN); list_kind.serialize_tokens(writer); } } } pub fn parse<'a>( parser: &mut TokenParser<'a>, deck_author: &Pubkey, ) -> Result> { parser.parse_all(|p| { p.parse_token(LAST_PER_PUBKEY_TOKEN)?; Ok(AlgoTimeline::LastPerPubkey(ListKind::parse( p, deck_author, )?)) }) } } impl Display for TimelineKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimelineKind::List(ListKind::Contact(_src)) => f.write_str("Contacts"), TimelineKind::Algo(AlgoTimeline::LastPerPubkey(_lk)) => f.write_str("Last Notes"), TimelineKind::Generic(_) => f.write_str("Timeline"), TimelineKind::Notifications(_) => f.write_str("Notifications"), TimelineKind::Profile(_) => f.write_str("Profile"), TimelineKind::Universe => f.write_str("Universe"), TimelineKind::Hashtag(_) => f.write_str("Hashtag"), TimelineKind::Thread(_) => f.write_str("Thread"), } } } impl TimelineKind { pub fn pubkey(&self) -> Option<&Pubkey> { match self { TimelineKind::List(list_kind) => list_kind.pubkey(), TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) => list_kind.pubkey(), TimelineKind::Notifications(pk) => Some(pk), TimelineKind::Profile(pk) => Some(pk), TimelineKind::Universe => None, TimelineKind::Generic(_) => None, TimelineKind::Hashtag(_ht) => None, TimelineKind::Thread(_ht) => None, } } /// Some feeds are not realtime, like certain algo feeds pub fn should_subscribe_locally(&self) -> bool { match self { TimelineKind::Algo(AlgoTimeline::LastPerPubkey(_list_kind)) => false, TimelineKind::List(_list_kind) => true, TimelineKind::Notifications(_pk_src) => true, TimelineKind::Profile(_pk_src) => true, TimelineKind::Universe => true, TimelineKind::Generic(_) => true, TimelineKind::Hashtag(_ht) => true, TimelineKind::Thread(_ht) => true, } } pub fn serialize_tokens(&self, writer: &mut TokenWriter) { match self { TimelineKind::List(list_kind) => list_kind.serialize_tokens(writer), TimelineKind::Algo(algo_timeline) => algo_timeline.serialize_tokens(writer), TimelineKind::Notifications(pk) => { writer.write_token(NOTIFS_TOKEN); PubkeySource::pubkey(*pk).serialize_tokens(writer); } TimelineKind::Profile(pk) => { writer.write_token("profile"); PubkeySource::pubkey(*pk).serialize_tokens(writer); } TimelineKind::Thread(root_note_id) => { writer.write_token("thread"); writer.write_token(&root_note_id.root_id.hex()); } TimelineKind::Universe => { writer.write_token("universe"); } TimelineKind::Generic(_usize) => { // TODO: lookup filter and then serialize writer.write_token("generic"); } TimelineKind::Hashtag(ht) => { writer.write_token("hashtag"); writer.write_token(ht); } } } pub fn parse<'a>( parser: &mut TokenParser<'a>, deck_author: &Pubkey, ) -> Result> { let profile = parser.try_parse(|p| { p.parse_token("profile")?; let pk_src = PubkeySource::parse_from_tokens(p)?; Ok(TimelineKind::Profile(*pk_src.as_pubkey(deck_author))) }); if profile.is_ok() { return profile; } let notifications = parser.try_parse(|p| { // still handle deprecated form (notifs) p.parse_any_token(&[NOTIFS_TOKEN, NOTIFS_TOKEN_DEPRECATED])?; let pk_src = PubkeySource::parse_from_tokens(p)?; Ok(TimelineKind::Notifications(*pk_src.as_pubkey(deck_author))) }); if notifications.is_ok() { return notifications; } let list_tl = parser.try_parse(|p| Ok(TimelineKind::List(ListKind::parse(p, deck_author)?))); if list_tl.is_ok() { return list_tl; } let algo_tl = parser.try_parse(|p| Ok(TimelineKind::Algo(AlgoTimeline::parse(p, deck_author)?))); if algo_tl.is_ok() { return algo_tl; } TokenParser::alt( parser, &[ |p| { p.parse_token("thread")?; Ok(TimelineKind::Thread(ThreadSelection::from_root_id( RootNoteIdBuf::new_unsafe(tokenator::parse_hex_id(p)?), ))) }, |p| { p.parse_token("universe")?; Ok(TimelineKind::Universe) }, |p| { p.parse_token("generic")?; // TODO: generic filter serialization Ok(TimelineKind::Generic(0)) }, |p| { p.parse_token("hashtag")?; Ok(TimelineKind::Hashtag(p.pull_token()?.to_string())) }, ], ) } pub fn last_per_pubkey(list_kind: ListKind) -> Self { TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) } pub fn contact_list(pk: Pubkey) -> Self { TimelineKind::List(ListKind::contact_list(pk)) } pub fn is_contacts(&self) -> bool { matches!(self, TimelineKind::List(ListKind::Contact(_))) } pub fn profile(pk: Pubkey) -> Self { TimelineKind::Profile(pk) } pub fn thread(selected_note: ThreadSelection) -> Self { TimelineKind::Thread(selected_note) } pub fn is_notifications(&self) -> bool { matches!(self, TimelineKind::Notifications(_)) } pub fn notifications(pk: Pubkey) -> Self { TimelineKind::Notifications(pk) } // TODO: probably should set default limit here pub fn filters(&self, txn: &Transaction, ndb: &Ndb) -> FilterState { match self { TimelineKind::Universe => FilterState::ready(universe_filter()), TimelineKind::List(list_k) => match list_k { ListKind::Contact(pubkey) => contact_filter_state(txn, ndb, pubkey), }, // TODO: still need to update this to fetch likes, zaps, etc TimelineKind::Notifications(pubkey) => FilterState::ready(vec![Filter::new() .pubkeys([pubkey.bytes()]) .kinds([1]) .limit(default_limit()) .build()]), TimelineKind::Hashtag(hashtag) => FilterState::ready(vec![Filter::new() .kinds([1]) .limit(filter::default_limit()) .tags([hashtag.to_lowercase()], 't') .build()]), TimelineKind::Algo(algo_timeline) => match algo_timeline { AlgoTimeline::LastPerPubkey(list_k) => match list_k { ListKind::Contact(pubkey) => last_per_pubkey_filter_state(ndb, pubkey), }, }, TimelineKind::Generic(_) => { todo!("implement generic filter lookups") } TimelineKind::Thread(selection) => FilterState::ready(vec![ nostrdb::Filter::new() .kinds([1]) .event(selection.root_id.bytes()) .build(), nostrdb::Filter::new() .ids([selection.root_id.bytes()]) .limit(1) .build(), ]), TimelineKind::Profile(pk) => FilterState::ready(vec![Filter::new() .authors([pk.bytes()]) .kinds([1]) .limit(default_limit()) .build()]), } } pub fn into_timeline(self, txn: &Transaction, ndb: &Ndb) -> Option { match self { TimelineKind::Universe => Some(Timeline::new( TimelineKind::Universe, FilterState::ready(universe_filter()), TimelineTab::no_replies(), )), TimelineKind::Thread(root_id) => Some(Timeline::thread(root_id)), TimelineKind::Generic(_filter_id) => { warn!("you can't convert a TimelineKind::Generic to a Timeline"); // TODO: you actually can! just need to look up the filter id None } TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::Contact(pk))) => { let contact_filter = Filter::new() .authors([pk.bytes()]) .kinds([3]) .limit(1) .build(); let results = ndb .query(txn, &[contact_filter.clone()], 1) .expect("contact query failed?"); let kind_fn = TimelineKind::last_per_pubkey; let tabs = TimelineTab::only_notes_and_replies(); if results.is_empty() { return Some(Timeline::new( kind_fn(ListKind::contact_list(pk)), FilterState::needs_remote(vec![contact_filter.clone()]), tabs, )); } let list_kind = ListKind::contact_list(pk); match Timeline::last_per_pubkey(&results[0].note, &list_kind) { Err(Error::App(notedeck::Error::Filter(FilterError::EmptyContactList))) => { Some(Timeline::new( kind_fn(list_kind), FilterState::needs_remote(vec![contact_filter]), tabs, )) } Err(e) => { error!("Unexpected error: {e}"); None } Ok(tl) => Some(tl), } } TimelineKind::Profile(pk) => { let filter = Filter::new() .authors([pk.bytes()]) .kinds([1]) .limit(default_limit()) .build(); Some(Timeline::new( TimelineKind::profile(pk), FilterState::ready(vec![filter]), TimelineTab::full_tabs(), )) } TimelineKind::Notifications(pk) => { let notifications_filter = Filter::new() .pubkeys([pk.bytes()]) .kinds([1]) .limit(default_limit()) .build(); Some(Timeline::new( TimelineKind::notifications(pk), FilterState::ready(vec![notifications_filter]), TimelineTab::only_notes_and_replies(), )) } TimelineKind::Hashtag(hashtag) => Some(Timeline::hashtag(hashtag)), TimelineKind::List(ListKind::Contact(pk)) => Some(Timeline::new( TimelineKind::contact_list(pk), contact_filter_state(txn, ndb, &pk), TimelineTab::full_tabs(), )), } } pub fn to_title(&self) -> ColumnTitle<'_> { match self { TimelineKind::List(list_kind) => match list_kind { ListKind::Contact(_pubkey_source) => ColumnTitle::simple("Contacts"), }, TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) => match list_kind { ListKind::Contact(_pubkey_source) => ColumnTitle::simple("Contacts (last notes)"), }, TimelineKind::Notifications(_pubkey_source) => ColumnTitle::simple("Notifications"), TimelineKind::Profile(_pubkey_source) => ColumnTitle::needs_db(self), TimelineKind::Thread(_root_id) => ColumnTitle::simple("Thread"), TimelineKind::Universe => ColumnTitle::simple("Universe"), TimelineKind::Generic(_) => ColumnTitle::simple("Custom"), TimelineKind::Hashtag(hashtag) => ColumnTitle::formatted(hashtag.to_string()), } } } #[derive(Debug)] pub struct TitleNeedsDb<'a> { kind: &'a TimelineKind, } impl<'a> TitleNeedsDb<'a> { pub fn new(kind: &'a TimelineKind) -> Self { TitleNeedsDb { kind } } pub fn title<'txn>(&self, txn: &'txn Transaction, ndb: &Ndb) -> &'txn str { if let TimelineKind::Profile(pubkey) = self.kind { let profile = ndb.get_profile_by_pubkey(txn, pubkey); let m_name = profile .as_ref() .ok() .map(|p| crate::profile::get_display_name(Some(p)).name()); m_name.unwrap_or("Profile") } else { "Unknown" } } } /// This saves us from having to construct a transaction if we don't need to /// for a particular column when rendering the title #[derive(Debug)] pub enum ColumnTitle<'a> { Simple(Cow<'static, str>), NeedsDb(TitleNeedsDb<'a>), } impl<'a> ColumnTitle<'a> { pub fn simple(title: &'static str) -> Self { Self::Simple(Cow::Borrowed(title)) } pub fn formatted(title: String) -> Self { Self::Simple(Cow::Owned(title)) } pub fn needs_db(kind: &'a TimelineKind) -> ColumnTitle<'a> { Self::NeedsDb(TitleNeedsDb::new(kind)) } } fn contact_filter_state(txn: &Transaction, ndb: &Ndb, pk: &Pubkey) -> FilterState { let contact_filter = Filter::new() .authors([pk.bytes()]) .kinds([3]) .limit(1) .build(); let results = ndb .query(txn, &[contact_filter.clone()], 1) .expect("contact query failed?"); if results.is_empty() { FilterState::needs_remote(vec![contact_filter.clone()]) } else { let with_hashtags = false; match filter::filter_from_tags(&results[0].note, Some(pk.bytes()), with_hashtags) { Err(notedeck::Error::Filter(FilterError::EmptyContactList)) => { FilterState::needs_remote(vec![contact_filter]) } Err(err) => { error!("Error getting contact filter state: {err}"); FilterState::Broken(FilterError::EmptyContactList) } Ok(filter) => FilterState::ready(filter.into_follow_filter()), } } } fn last_per_pubkey_filter_state(ndb: &Ndb, pk: &Pubkey) -> FilterState { let contact_filter = Filter::new() .authors([pk.bytes()]) .kinds([3]) .limit(1) .build(); let txn = Transaction::new(ndb).expect("txn"); let results = ndb .query(&txn, &[contact_filter.clone()], 1) .expect("contact query failed?"); if results.is_empty() { FilterState::needs_remote(vec![contact_filter]) } else { let kind = 1; let notes_per_pk = 1; match filter::last_n_per_pubkey_from_tags(&results[0].note, kind, notes_per_pk) { Err(notedeck::Error::Filter(FilterError::EmptyContactList)) => { FilterState::needs_remote(vec![contact_filter]) } Err(err) => { error!("Error getting contact filter state: {err}"); FilterState::Broken(FilterError::EmptyContactList) } Ok(filter) => FilterState::ready(filter), } } } fn universe_filter() -> Vec { vec![Filter::new().kinds([1]).limit(default_limit()).build()] } ``` `/Users/jb55/dev/notedeck/crates/notedeck_columns/src/timeline/mod.rs`: ```rs use crate::{ error::Error, multi_subscriber::MultiSubscriber, subscriptions::{self, SubKind, Subscriptions}, timeline::kind::ListKind, Result, }; use notedeck::{ filter, CachedNote, FilterError, FilterState, FilterStates, NoteCache, NoteRef, UnknownIds, }; use egui_virtual_list::VirtualList; use enostr::{PoolRelay, Pubkey, RelayPool}; use nostrdb::{Filter, Ndb, Note, NoteKey, Transaction}; use std::cell::RefCell; use std::rc::Rc; use tracing::{debug, error, info, warn}; pub mod cache; pub mod kind; pub mod route; pub use cache::TimelineCache; pub use kind::{ColumnTitle, PubkeySource, ThreadSelection, TimelineKind}; //#[derive(Debug, Hash, Clone, Eq, PartialEq)] //pub type TimelineId = TimelineKind; /* impl TimelineId { pub fn kind(&self) -> &TimelineKind { &self.kind } pub fn new(id: TimelineKind) -> Self { TimelineId(id) } pub fn profile(pubkey: Pubkey) -> Self { TimelineId::new(TimelineKind::Profile(PubkeySource::pubkey(pubkey))) } } impl fmt::Display for TimelineId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "TimelineId({})", self.0) } } */ #[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] pub enum ViewFilter { Notes, #[default] NotesAndReplies, } impl ViewFilter { pub fn name(&self) -> &'static str { match self { ViewFilter::Notes => "Notes", ViewFilter::NotesAndReplies => "Notes & Replies", } } pub fn filter_notes(cache: &CachedNote, note: &Note) -> bool { !cache.reply.borrow(note.tags()).is_reply() } fn identity(_cache: &CachedNote, _note: &Note) -> bool { true } pub fn filter(&self) -> fn(&CachedNote, &Note) -> bool { match self { ViewFilter::Notes => ViewFilter::filter_notes, ViewFilter::NotesAndReplies => ViewFilter::identity, } } } /// A timeline view is a filtered view of notes in a timeline. Two standard views /// are "Notes" and "Notes & Replies". A timeline is associated with a Filter, /// but a TimelineTab is a further filtered view of this Filter that can't /// be captured by a Filter itself. #[derive(Default, Debug)] pub struct TimelineTab { pub notes: Vec, pub selection: i32, pub filter: ViewFilter, pub list: Rc>, } impl TimelineTab { pub fn new(filter: ViewFilter) -> Self { TimelineTab::new_with_capacity(filter, 1000) } pub fn only_notes_and_replies() -> Vec { vec![TimelineTab::new(ViewFilter::NotesAndReplies)] } pub fn no_replies() -> Vec { vec![TimelineTab::new(ViewFilter::Notes)] } pub fn full_tabs() -> Vec { vec![ TimelineTab::new(ViewFilter::Notes), TimelineTab::new(ViewFilter::NotesAndReplies), ] } pub fn new_with_capacity(filter: ViewFilter, cap: usize) -> Self { let selection = 0i32; let mut list = VirtualList::new(); list.hide_on_resize(None); list.over_scan(1000.0); let list = Rc::new(RefCell::new(list)); let notes: Vec = Vec::with_capacity(cap); TimelineTab { notes, selection, filter, list, } } fn insert(&mut self, new_refs: &[NoteRef], reversed: bool) { if new_refs.is_empty() { return; } let num_prev_items = self.notes.len(); let (notes, merge_kind) = crate::timeline::merge_sorted_vecs(&self.notes, new_refs); self.notes = notes; let new_items = self.notes.len() - num_prev_items; // TODO: technically items could have been added inbetween if new_items > 0 { let mut list = self.list.borrow_mut(); match merge_kind { // TODO: update egui_virtual_list to support spliced inserts MergeKind::Spliced => { debug!( "spliced when inserting {} new notes, resetting virtual list", new_refs.len() ); list.reset(); } MergeKind::FrontInsert => { // only run this logic if we're reverse-chronological // reversed in this case means chronological, since the // default is reverse-chronological. yeah it's confusing. if !reversed { debug!("inserting {} new notes at start", new_refs.len()); list.items_inserted_at_start(new_items); } } } } } pub fn select_down(&mut self) { debug!("select_down {}", self.selection + 1); if self.selection + 1 > self.notes.len() as i32 { return; } self.selection += 1; } pub fn select_up(&mut self) { debug!("select_up {}", self.selection - 1); if self.selection - 1 < 0 { return; } self.selection -= 1; } } /// A column in a deck. Holds navigation state, loaded notes, column kind, etc. #[derive(Debug)] pub struct Timeline { pub kind: TimelineKind, // We may not have the filter loaded yet, so let's make it an option so // that codepaths have to explicitly handle it pub filter: FilterStates, pub views: Vec, pub selected_view: usize, pub subscription: Option, } impl Timeline { /// Create a timeline from a contact list pub fn contact_list(contact_list: &Note, pubkey: &[u8; 32]) -> Result { let with_hashtags = false; let filter = filter::filter_from_tags(contact_list, Some(pubkey), with_hashtags)? .into_follow_filter(); Ok(Timeline::new( TimelineKind::contact_list(Pubkey::new(*pubkey)), FilterState::ready(filter), TimelineTab::full_tabs(), )) } pub fn thread(selection: ThreadSelection) -> Self { let filter = vec![ nostrdb::Filter::new() .kinds([1]) .event(selection.root_id.bytes()) .build(), nostrdb::Filter::new() .ids([selection.root_id.bytes()]) .limit(1) .build(), ]; Timeline::new( TimelineKind::Thread(selection), FilterState::ready(filter), TimelineTab::only_notes_and_replies(), ) } pub fn last_per_pubkey(list: &Note, list_kind: &ListKind) -> Result { let kind = 1; let notes_per_pk = 1; let filter = filter::last_n_per_pubkey_from_tags(list, kind, notes_per_pk)?; Ok(Timeline::new( TimelineKind::last_per_pubkey(*list_kind), FilterState::ready(filter), TimelineTab::only_notes_and_replies(), )) } pub fn hashtag(hashtag: String) -> Self { let filter = Filter::new() .kinds([1]) .limit(filter::default_limit()) .tags([hashtag.to_lowercase()], 't') .build(); Timeline::new( TimelineKind::Hashtag(hashtag), FilterState::ready(vec![filter]), TimelineTab::only_notes_and_replies(), ) } pub fn make_view_id(id: &TimelineKind, selected_view: usize) -> egui::Id { egui::Id::new((id, selected_view)) } pub fn view_id(&self) -> egui::Id { Timeline::make_view_id(&self.kind, self.selected_view) } pub fn new(kind: TimelineKind, filter_state: FilterState, views: Vec) -> Self { let filter = FilterStates::new(filter_state); let subscription: Option = None; let selected_view = 0; Timeline { kind, filter, views, subscription, selected_view, } } pub fn current_view(&self) -> &TimelineTab { &self.views[self.selected_view] } pub fn current_view_mut(&mut self) -> &mut TimelineTab { &mut self.views[self.selected_view] } /// Get the note refs for NotesAndReplies. If we only have Notes, then /// just return that instead pub fn all_or_any_notes(&self) -> &[NoteRef] { self.notes(ViewFilter::NotesAndReplies).unwrap_or_else(|| { self.notes(ViewFilter::Notes) .expect("should have at least notes") }) } pub fn notes(&self, view: ViewFilter) -> Option<&[NoteRef]> { self.view(view).map(|v| &*v.notes) } pub fn view(&self, view: ViewFilter) -> Option<&TimelineTab> { self.views.iter().find(|tab| tab.filter == view) } pub fn view_mut(&mut self, view: ViewFilter) -> Option<&mut TimelineTab> { self.views.iter_mut().find(|tab| tab.filter == view) } /// Initial insert of notes into a timeline. Subsequent inserts should /// just use the insert function pub fn insert_new( &mut self, txn: &Transaction, ndb: &Ndb, note_cache: &mut NoteCache, notes: &[NoteRef], ) { let filters = { let views = &self.views; let filters: Vec bool> = views.iter().map(|v| v.filter.filter()).collect(); filters }; for note_ref in notes { for (view, filter) in filters.iter().enumerate() { if let Ok(note) = ndb.get_note_by_key(txn, note_ref.key) { if filter( note_cache.cached_note_or_insert_mut(note_ref.key, ¬e), ¬e, ) { self.views[view].notes.push(*note_ref) } } } } } /// The main function used for inserting notes into timelines. Handles /// inserting into multiple views if we have them. All timeline note /// insertions should use this function. pub fn insert( &mut self, new_note_ids: &[NoteKey], ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, reversed: bool, ) -> Result<()> { let mut new_refs: Vec<(Note, NoteRef)> = Vec::with_capacity(new_note_ids.len()); for key in new_note_ids { let note = if let Ok(note) = ndb.get_note_by_key(txn, *key) { note } else { error!("hit race condition in poll_notes_into_view: https://github.com/damus-io/nostrdb/issues/35 note {:?} was not added to timeline", key); continue; }; // Ensure that unknown ids are captured when inserting notes // into the timeline UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, ¬e); let created_at = note.created_at(); new_refs.push(( note, NoteRef { key: *key, created_at, }, )); } for view in &mut self.views { match view.filter { ViewFilter::NotesAndReplies => { let refs: Vec = new_refs.iter().map(|(_note, nr)| *nr).collect(); view.insert(&refs, reversed); } ViewFilter::Notes => { let mut filtered_refs = Vec::with_capacity(new_refs.len()); for (note, nr) in &new_refs { let cached_note = note_cache.cached_note_or_insert(nr.key, note); if ViewFilter::filter_notes(cached_note, note) { filtered_refs.push(*nr); } } view.insert(&filtered_refs, reversed); } } } Ok(()) } pub fn poll_notes_into_view( &mut self, ndb: &Ndb, txn: &Transaction, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, reversed: bool, ) -> Result<()> { if !self.kind.should_subscribe_locally() { // don't need to poll for timelines that don't have local subscriptions return Ok(()); } let sub = self .subscription .as_ref() .and_then(|s| s.local_subid) .ok_or(Error::App(notedeck::Error::no_active_sub()))?; let new_note_ids = ndb.poll_for_notes(sub, 500); if new_note_ids.is_empty() { return Ok(()); } else { debug!("{} new notes! {:?}", new_note_ids.len(), new_note_ids); } self.insert(&new_note_ids, ndb, txn, unknown_ids, note_cache, reversed) } } pub enum MergeKind { FrontInsert, Spliced, } pub fn merge_sorted_vecs(vec1: &[T], vec2: &[T]) -> (Vec, MergeKind) { let mut merged = Vec::with_capacity(vec1.len() + vec2.len()); let mut i = 0; let mut j = 0; let mut result: Option = None; while i < vec1.len() && j < vec2.len() { if vec1[i] <= vec2[j] { if result.is_none() && j < vec2.len() { // if we're pushing from our large list and still have // some left in vec2, then this is a splice result = Some(MergeKind::Spliced); } merged.push(vec1[i]); i += 1; } else { merged.push(vec2[j]); j += 1; } } // Append any remaining elements from either vector if i < vec1.len() { merged.extend_from_slice(&vec1[i..]); } if j < vec2.len() { merged.extend_from_slice(&vec2[j..]); } (merged, result.unwrap_or(MergeKind::FrontInsert)) } /// When adding a new timeline, we may have a situation where the /// FilterState is NeedsRemote. This can happen if we don't yet have the /// contact list, etc. For these situations, we query all of the relays /// with the same sub_id. We keep track of this sub_id and update the /// filter with the latest version of the returned filter (ie contact /// list) when they arrive. /// /// We do this by maintaining this sub_id in the filter state, even when /// in the ready state. See: [`FilterReady`] #[allow(clippy::too_many_arguments)] pub fn setup_new_timeline( timeline: &mut Timeline, ndb: &Ndb, subs: &mut Subscriptions, pool: &mut RelayPool, note_cache: &mut NoteCache, since_optimize: bool, ) { // if we're ready, setup local subs if is_timeline_ready(ndb, pool, note_cache, timeline) { if let Err(err) = setup_timeline_nostrdb_sub(ndb, note_cache, timeline) { error!("setup_new_timeline: {err}"); } } for relay in &mut pool.relays { send_initial_timeline_filter(ndb, since_optimize, subs, relay, timeline); } } /// Send initial filters for a specific relay. This typically gets called /// when we first connect to a new relay for the first time. For /// situations where you are adding a new timeline, use /// setup_new_timeline. pub fn send_initial_timeline_filters( ndb: &Ndb, since_optimize: bool, timeline_cache: &mut TimelineCache, subs: &mut Subscriptions, pool: &mut RelayPool, relay_id: &str, ) -> Option<()> { info!("Sending initial filters to {}", relay_id); let relay = &mut pool.relays.iter_mut().find(|r| r.url() == relay_id)?; for (_kind, timeline) in timeline_cache.timelines.iter_mut() { send_initial_timeline_filter(ndb, since_optimize, subs, relay, timeline); } Some(()) } pub fn send_initial_timeline_filter( ndb: &Ndb, can_since_optimize: bool, subs: &mut Subscriptions, relay: &mut PoolRelay, timeline: &mut Timeline, ) { let filter_state = timeline.filter.get_mut(relay.url()); match filter_state { FilterState::Broken(err) => { error!( "FetchingRemote state in broken state when sending initial timeline filter? {err}" ); } FilterState::FetchingRemote(_unisub) => { error!("FetchingRemote state when sending initial timeline filter?"); } FilterState::GotRemote(_sub) => { error!("GotRemote state when sending initial timeline filter?"); } FilterState::Ready(filter) => { let filter = filter.to_owned(); let new_filters = filter.into_iter().map(|f| { // limit the size of remote filters let default_limit = filter::default_remote_limit(); let mut lim = f.limit().unwrap_or(default_limit); let mut filter = f; if lim > default_limit { lim = default_limit; filter = filter.limit_mut(lim); } let notes = timeline.all_or_any_notes(); // Should we since optimize? Not always. For example // if we only have a few notes locally. One way to // determine this is by looking at the current filter // and seeing what its limit is. If we have less // notes than the limit, we might want to backfill // older notes if can_since_optimize && filter::should_since_optimize(lim, notes.len()) { filter = filter::since_optimize_filter(filter, notes); } else { warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", &timeline.kind); } filter }).collect(); //let sub_id = damus.gen_subid(&SubKind::Initial); let sub_id = subscriptions::new_sub_id(); subs.subs.insert(sub_id.clone(), SubKind::Initial); if let Err(err) = relay.subscribe(sub_id, new_filters) { error!("error subscribing: {err}"); } } // we need some data first FilterState::NeedsRemote(filter) => { fetch_contact_list(filter.to_owned(), ndb, subs, relay, timeline) } } } fn fetch_contact_list( filter: Vec, ndb: &Ndb, subs: &mut Subscriptions, relay: &mut PoolRelay, timeline: &mut Timeline, ) { let sub_kind = SubKind::FetchingContactList(timeline.kind.clone()); let sub_id = subscriptions::new_sub_id(); let local_sub = ndb.subscribe(&filter).expect("sub"); timeline.filter.set_relay_state( relay.url().to_string(), FilterState::fetching_remote(sub_id.clone(), local_sub), ); subs.subs.insert(sub_id.clone(), sub_kind); info!("fetching contact list from {}", relay.url()); if let Err(err) = relay.subscribe(sub_id, filter) { error!("error subscribing: {err}"); } } fn setup_initial_timeline( ndb: &Ndb, timeline: &mut Timeline, note_cache: &mut NoteCache, filters: &[Filter], ) -> Result<()> { // some timelines are one-shot and a refreshed, like last_per_pubkey algo feed if timeline.kind.should_subscribe_locally() { let local_sub = ndb.subscribe(filters)?; match &mut timeline.subscription { None => { timeline.subscription = Some(MultiSubscriber::with_initial_local_sub( local_sub, filters.to_vec(), )); } Some(msub) => { msub.local_subid = Some(local_sub); } }; } debug!( "querying nostrdb sub {:?} {:?}", timeline.subscription, timeline.filter ); let mut lim = 0i32; for filter in filters { lim += filter.limit().unwrap_or(1) as i32; } let txn = Transaction::new(ndb)?; let notes: Vec = ndb .query(&txn, filters, lim)? .into_iter() .map(NoteRef::from_query_result) .collect(); timeline.insert_new(&txn, ndb, note_cache, ¬es); Ok(()) } pub fn setup_initial_nostrdb_subs( ndb: &Ndb, note_cache: &mut NoteCache, timeline_cache: &mut TimelineCache, ) -> Result<()> { for (_kind, timeline) in timeline_cache.timelines.iter_mut() { if let Err(err) = setup_timeline_nostrdb_sub(ndb, note_cache, timeline) { error!("setup_initial_nostrdb_subs: {err}"); } } Ok(()) } fn setup_timeline_nostrdb_sub( ndb: &Ndb, note_cache: &mut NoteCache, timeline: &mut Timeline, ) -> Result<()> { let filter_state = timeline .filter .get_any_ready() .ok_or(Error::App(notedeck::Error::empty_contact_list()))? .to_owned(); setup_initial_timeline(ndb, timeline, note_cache, &filter_state)?; Ok(()) } /// Check our timeline filter and see if we have any filter data ready. /// Our timelines may require additional data before it is functional. For /// example, when we have to fetch a contact list before we do the actual /// following list query. pub fn is_timeline_ready( ndb: &Ndb, pool: &mut RelayPool, note_cache: &mut NoteCache, timeline: &mut Timeline, ) -> bool { // TODO: we should debounce the filter states a bit to make sure we have // seen all of the different contact lists from each relay if let Some(_f) = timeline.filter.get_any_ready() { return true; } let (relay_id, sub) = if let Some((relay_id, sub)) = timeline.filter.get_any_gotremote() { (relay_id.to_string(), sub) } else { return false; }; // We got at least one eose for our filter request. Let's see // if nostrdb is done processing it yet. let res = ndb.poll_for_notes(sub, 1); if res.is_empty() { debug!( "check_timeline_filter_state: no notes found (yet?) for timeline {:?}", timeline ); return false; } info!("notes found for contact timeline after GotRemote!"); let note_key = res[0]; let with_hashtags = false; let filter = { let txn = Transaction::new(ndb).expect("txn"); let note = ndb.get_note_by_key(&txn, note_key).expect("note"); let add_pk = timeline.kind.pubkey().map(|pk| pk.bytes()); filter::filter_from_tags(¬e, add_pk, with_hashtags).map(|f| f.into_follow_filter()) }; // TODO: into_follow_filter is hardcoded to contact lists, let's generalize match filter { Err(notedeck::Error::Filter(e)) => { error!("got broken when building filter {e}"); timeline .filter .set_relay_state(relay_id, FilterState::broken(e)); false } Err(err) => { error!("got broken when building filter {err}"); timeline .filter .set_relay_state(relay_id, FilterState::broken(FilterError::EmptyContactList)); false } Ok(filter) => { // we just switched to the ready state, we should send initial // queries and setup the local subscription info!("Found contact list! Setting up local and remote contact list query"); setup_initial_timeline(ndb, timeline, note_cache, &filter).expect("setup init"); timeline .filter .set_relay_state(relay_id, FilterState::ready(filter.clone())); //let ck = &timeline.kind; //let subid = damus.gen_subid(&SubKind::Column(ck.clone())); let subid = subscriptions::new_sub_id(); pool.subscribe(subid, filter); true } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/Cargo.toml`: ```toml [package] name = "notedeck" version = { workspace = true } edition = "2021" description = "The APIs and data structures used by notedeck apps" [dependencies] nostrdb = { workspace = true } url = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } dirs = { workspace = true } enostr = { 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 } [dev-dependencies] tempfile = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] security-framework = { workspace = true } [features] profiling = ["puffin", "puffin_egui"] ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/ui.rs`: ```rs /// Determine if the screen is narrow. This is useful for detecting mobile /// contexts, but with the nuance that we may also have a wide android tablet. pub fn is_narrow(ctx: &egui::Context) -> bool { let screen_size = ctx.input(|c| c.screen_rect().size()); screen_size.x < 550.0 } pub fn is_oled() -> bool { is_compiled_as_mobile() } #[inline] #[allow(unreachable_code)] pub fn is_compiled_as_mobile() -> bool { #[cfg(any(target_os = "android", target_os = "ios"))] { true } #[cfg(not(any(target_os = "android", target_os = "ios")))] { false } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/user_account.rs`: ```rs use enostr::Keypair; //pub struct UserAccount { //pub key: Keypair, //pub relays: RelayPool, //pub relays: Vec, //} pub type UserAccount = Keypair; ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/error.rs`: ```rs 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), } impl From 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) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/accounts.rs`: ```rs use tracing::{debug, error, info}; use crate::{ KeyStorageResponse, KeyStorageType, MuteFun, Muted, RelaySpec, SingleUnkIdAction, UnknownIds, UserAccount, }; use enostr::{ClientMessage, FilledKeypair, Keypair, RelayPool}; use nostrdb::{Filter, Ndb, Note, NoteBuilder, NoteKey, Subscription, Transaction}; use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; use url::Url; use uuid::Uuid; // TODO: remove this use std::sync::Arc; #[derive(Debug, Clone)] pub struct SwitchAccountAction { /// Some index representing the source of the action pub source: Option, /// The account index to switch to pub switch_to: usize, } impl SwitchAccountAction { pub fn new(source: Option, switch_to: usize) -> Self { SwitchAccountAction { source, switch_to } } } #[derive(Debug)] pub enum AccountsAction { Switch(SwitchAccountAction), Remove(usize), } pub struct AccountRelayData { filter: Filter, subid: Option, sub: Option, local: BTreeSet, // used locally but not advertised advertised: BTreeSet, // advertised via NIP-65 } #[derive(Default)] pub struct ContainsAccount { pub has_nsec: bool, pub index: usize, } #[must_use = "You must call process_login_action on this to handle unknown ids"] pub struct AddAccountAction { pub accounts_action: Option, pub unk_id_action: SingleUnkIdAction, } impl AccountRelayData { pub fn new(ndb: &Ndb, 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(); // Query the ndb immediately to see if the user list is already there let txn = Transaction::new(ndb).expect("transaction"); let lim = filter.limit().unwrap_or(crate::filter::default_limit()) as i32; let nks = ndb .query(&txn, &[filter.clone()], lim) .expect("query user relays results") .iter() .map(|qr| qr.note_key) .collect::>(); let relays = Self::harvest_nip65_relays(ndb, &txn, &nks); debug!( "pubkey {}: initial relays {:?}", hex::encode(pubkey), relays ); AccountRelayData { filter, subid: None, sub: None, local: BTreeSet::new(), advertised: relays.into_iter().collect(), } } // make this account the current selected account pub fn activate(&mut self, ndb: &Ndb, pool: &mut RelayPool) { debug!("activating relay sub {}", self.filter.json().unwrap()); assert_eq!(self.subid, None, "subid already exists"); assert_eq!(self.sub, None, "sub already exists"); // local subscription let sub = ndb .subscribe(&[self.filter.clone()]) .expect("ndb relay list subscription"); // remote subscription let subid = Uuid::new_v4().to_string(); pool.subscribe(subid.clone(), vec![self.filter.clone()]); self.sub = Some(sub); self.subid = Some(subid); } // this account is no longer the selected account pub fn deactivate(&mut self, ndb: &mut Ndb, pool: &mut RelayPool) { debug!("deactivating relay sub {}", self.filter.json().unwrap()); assert_ne!(self.subid, None, "subid doesn't exist"); assert_ne!(self.sub, None, "sub doesn't exist"); // remote subscription pool.unsubscribe(self.subid.as_ref().unwrap().clone()); // local subscription ndb.unsubscribe(self.sub.unwrap()) .expect("ndb relay list unsubscribe"); self.sub = None; self.subid = None; } // 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. } } fn harvest_nip65_relays(ndb: &Ndb, txn: &Transaction, nks: &[NoteKey]) -> Vec { let mut relays = Vec::new(); for nk in nks.iter() { if let Ok(note) = ndb.get_note_by_key(txn, *nk) { for tag in note.tags() { match tag.get(0).and_then(|t| t.variant().str()) { Some("r") => { if let Some(url) = tag.get(1).and_then(|f| f.variant().str()) { let has_read_marker = tag .get(2) .is_some_and(|m| m.variant().str() == Some("read")); let has_write_marker = tag .get(2) .is_some_and(|m| m.variant().str() == Some("write")); relays.push(RelaySpec::new( Self::canonicalize_url(url), has_read_marker, has_write_marker, )); } } Some("alt") => { // ignore for now } Some(x) => { error!("harvest_nip65_relays: unexpected tag type: {}", x); } None => { error!("harvest_nip65_relays: invalid tag"); } } } } } relays } pub fn publish_nip65_relays(&self, seckey: &[u8; 32], pool: &mut RelayPool) { let mut builder = NoteBuilder::new().kind(10002).content(""); for rs in &self.advertised { builder = builder.start_tag().tag_str("r").tag_str(&rs.url); if rs.has_read_marker { builder = builder.tag_str("read"); } else if rs.has_write_marker { builder = builder.tag_str("write"); } } let note = builder.sign(seckey).build().expect("note build"); pool.send(&enostr::ClientMessage::event(note).expect("note client message")); } } pub struct AccountMutedData { filter: Filter, subid: Option, sub: Option, muted: Arc, } impl AccountMutedData { pub fn new(ndb: &Ndb, 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(); // Query the ndb immediately to see if the user's muted list is already there let txn = Transaction::new(ndb).expect("transaction"); let lim = filter.limit().unwrap_or(crate::filter::default_limit()) as i32; let nks = ndb .query(&txn, &[filter.clone()], lim) .expect("query user muted results") .iter() .map(|qr| qr.note_key) .collect::>(); let muted = Self::harvest_nip51_muted(ndb, &txn, &nks); debug!("pubkey {}: initial muted {:?}", hex::encode(pubkey), muted); AccountMutedData { filter, subid: None, sub: None, muted: Arc::new(muted), } } // make this account the current selected account pub fn activate(&mut self, ndb: &Ndb, pool: &mut RelayPool) { debug!("activating muted sub {}", self.filter.json().unwrap()); assert_eq!(self.subid, None, "subid already exists"); assert_eq!(self.sub, None, "sub already exists"); // local subscription let sub = ndb .subscribe(&[self.filter.clone()]) .expect("ndb muted subscription"); // remote subscription let subid = Uuid::new_v4().to_string(); pool.subscribe(subid.clone(), vec![self.filter.clone()]); self.sub = Some(sub); self.subid = Some(subid); } // this account is no longer the selected account pub fn deactivate(&mut self, ndb: &mut Ndb, pool: &mut RelayPool) { debug!("deactivating muted sub {}", self.filter.json().unwrap()); assert_ne!(self.subid, None, "subid doesn't exist"); assert_ne!(self.sub, None, "sub doesn't exist"); // remote subscription pool.unsubscribe(self.subid.as_ref().unwrap().clone()); // local subscription ndb.unsubscribe(self.sub.unwrap()) .expect("ndb muted unsubscribe"); self.sub = None; self.subid = None; } 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 struct AccountData { relay: AccountRelayData, muted: AccountMutedData, } /// The interface for managing the user's accounts. /// Represents all user-facing operations related to account management. pub struct Accounts { currently_selected_account: Option, accounts: Vec, key_store: KeyStorageType, account_data: BTreeMap<[u8; 32], AccountData>, forced_relays: BTreeSet, bootstrap_relays: BTreeSet, needs_relay_config: bool, } impl Accounts { pub fn new(key_store: KeyStorageType, forced_relays: Vec) -> Self { let accounts = if let KeyStorageResponse::ReceivedResult(res) = key_store.get_keys() { res.unwrap_or_default() } else { Vec::new() }; let currently_selected_account = get_selected_index(&accounts, &key_store); let account_data = BTreeMap::new(); let forced_relays: BTreeSet = 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(); Accounts { currently_selected_account, accounts, key_store, account_data, forced_relays, bootstrap_relays, needs_relay_config: true, } } pub fn get_accounts(&self) -> &Vec { &self.accounts } pub fn get_account(&self, ind: usize) -> Option<&UserAccount> { self.accounts.get(ind) } pub fn find_account(&self, pk: &[u8; 32]) -> Option<&UserAccount> { self.accounts.iter().find(|acc| acc.pubkey.bytes() == pk) } pub fn remove_account(&mut self, index: usize) { if let Some(account) = self.accounts.get(index) { let _ = self.key_store.remove_key(account); self.accounts.remove(index); if let Some(selected_index) = self.currently_selected_account { match selected_index.cmp(&index) { Ordering::Greater => { self.select_account(selected_index - 1); } Ordering::Equal => { if self.accounts.is_empty() { // If no accounts remain, clear the selection self.clear_selected_account(); } else if index >= self.accounts.len() { // If the removed account was the last one, select the new last account self.select_account(self.accounts.len() - 1); } else { // Otherwise, select the account at the same position self.select_account(index); } } Ordering::Less => {} } } } } pub fn needs_relay_config(&mut self) { self.needs_relay_config = true; } fn contains_account(&self, pubkey: &[u8; 32]) -> Option { for (index, account) in self.accounts.iter().enumerate() { let has_pubkey = account.pubkey.bytes() == pubkey; let has_nsec = account.secret_key.is_some(); if has_pubkey { return Some(ContainsAccount { has_nsec, index }); } } None } pub fn contains_full_kp(&self, pubkey: &enostr::Pubkey) -> bool { if let Some(contains) = self.contains_account(pubkey.bytes()) { contains.has_nsec } else { false } } #[must_use = "UnknownIdAction's must be handled. Use .process_unknown_id_action()"] pub fn add_account(&mut self, account: Keypair) -> AddAccountAction { let pubkey = account.pubkey; let switch_to_index = if let Some(contains_acc) = self.contains_account(pubkey.bytes()) { if account.secret_key.is_some() && !contains_acc.has_nsec { info!( "user provided nsec, but we already have npub {}. Upgrading to nsec", pubkey ); let _ = self.key_store.add_key(&account); self.accounts[contains_acc.index] = account; } else { info!("already have account, not adding {}", pubkey); } contains_acc.index } else { info!("adding new account {}", pubkey); let _ = self.key_store.add_key(&account); self.accounts.push(account); self.accounts.len() - 1 }; let source: Option = None; AddAccountAction { accounts_action: Some(AccountsAction::Switch(SwitchAccountAction::new( source, switch_to_index, ))), unk_id_action: SingleUnkIdAction::pubkey(pubkey), } } pub fn num_accounts(&self) -> usize { self.accounts.len() } pub fn get_selected_account_index(&self) -> Option { self.currently_selected_account } pub fn selected_or_first_nsec(&self) -> Option> { self.get_selected_account() .and_then(|kp| kp.to_full()) .or_else(|| self.accounts.iter().find_map(|a| a.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) -> Option<&[u8; 32]> { self.get_selected_account().map(|kp| kp.pubkey.bytes()) } pub fn get_selected_account(&self) -> Option<&UserAccount> { if let Some(account_index) = self.currently_selected_account { if let Some(account) = self.get_account(account_index) { Some(account) } else { None } } else { None } } pub fn get_selected_account_data(&mut self) -> Option<&mut AccountData> { let account_pubkey = { let account = self.get_selected_account()?; *account.pubkey.bytes() }; self.account_data.get_mut(&account_pubkey) } pub fn select_account(&mut self, index: usize) { if let Some(account) = self.accounts.get(index) { self.currently_selected_account = Some(index); self.key_store.select_key(Some(account.pubkey)); } } pub fn clear_selected_account(&mut self) { self.currently_selected_account = None; self.key_store.select_key(None); } pub fn mutefun(&self) -> Box { if let Some(index) = self.currently_selected_account { if let Some(account) = self.accounts.get(index) { let pubkey = account.pubkey.bytes(); if let Some(account_data) = self.account_data.get(pubkey) { let muted = Arc::clone(&account_data.muted.muted); return Box::new(move |note: &Note, thread: &[u8; 32]| { muted.is_muted(note, thread) }); } } } Box::new(|_: &Note, _: &[u8; 32]| false) } pub fn send_initial_filters(&mut self, pool: &mut RelayPool, relay_url: &str) { for data in self.account_data.values() { // send the active account's relay list subscription if let Some(relay_subid) = &data.relay.subid { pool.send_to( &ClientMessage::req(relay_subid.clone(), vec![data.relay.filter.clone()]), relay_url, ); } // send the active account's muted subscription if let Some(muted_subid) = &data.muted.subid { pool.send_to( &ClientMessage::req(muted_subid.clone(), vec![data.muted.filter.clone()]), relay_url, ); } } } // Return accounts which have no account_data yet (added) and accounts // which have still data but are no longer in our account list (removed). fn delta_accounts(&self) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) { let mut added = Vec::new(); for pubkey in self.accounts.iter().map(|a| a.pubkey.bytes()) { if !self.account_data.contains_key(pubkey) { added.push(*pubkey); } } let mut removed = Vec::new(); for pubkey in self.account_data.keys() { if self.contains_account(pubkey).is_none() { removed.push(*pubkey); } } (added, removed) } fn handle_added_account(&mut self, ndb: &Ndb, pubkey: &[u8; 32]) { debug!("handle_added_account {}", hex::encode(pubkey)); // Create the user account data let new_account_data = AccountData { relay: AccountRelayData::new(ndb, pubkey), muted: AccountMutedData::new(ndb, pubkey), }; self.account_data.insert(*pubkey, new_account_data); } fn handle_removed_account(&mut self, pubkey: &[u8; 32]) { debug!("handle_removed_account {}", hex::encode(pubkey)); // FIXME - we need to unsubscribe here self.account_data.remove(pubkey); } fn poll_for_updates(&mut self, ndb: &Ndb) -> bool { let mut changed = false; for (pubkey, data) in &mut self.account_data { if let Some(sub) = data.relay.sub { let nks = ndb.poll_for_notes(sub, 1); if !nks.is_empty() { let txn = Transaction::new(ndb).expect("txn"); let relays = AccountRelayData::harvest_nip65_relays(ndb, &txn, &nks); debug!( "pubkey {}: updated relays {:?}", hex::encode(pubkey), relays ); data.relay.advertised = relays.into_iter().collect(); changed = true; } } if let Some(sub) = data.muted.sub { let nks = ndb.poll_for_notes(sub, 1); if !nks.is_empty() { let txn = Transaction::new(ndb).expect("txn"); let muted = AccountMutedData::harvest_nip51_muted(ndb, &txn, &nks); debug!("pubkey {}: updated muted {:?}", hex::encode(pubkey), muted); data.muted.muted = Arc::new(muted); changed = true; } } } changed } fn update_relay_configuration( &mut self, pool: &mut RelayPool, wakeup: impl Fn() + Send + Sync + Clone + 'static, ) { debug!( "updating relay configuration for currently selected {:?}", self.currently_selected_account .map(|i| hex::encode(self.accounts.get(i).unwrap().pubkey.bytes())) ); // If forced relays are set use them only let mut desired_relays = self.forced_relays.clone(); // Compose the desired relay lists from the selected account if desired_relays.is_empty() { if let Some(data) = self.get_selected_account_data() { desired_relays.extend(data.relay.local.iter().cloned()); desired_relays.extend(data.relay.advertised.iter().cloned()); } } // If no relays are specified at this point use the bootstrap list if desired_relays.is_empty() { desired_relays = self.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 = desired_relays.difference(&pool_specs).cloned().collect(); let mut sub: BTreeSet = 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 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 // On the initial update the relays need config even if nothing changes below let mut need_reconfig = self.needs_relay_config; let ctx2 = ctx.clone(); let wakeup = move || { ctx2.request_repaint(); }; // Do we need to deactivate any existing account subs? for (ndx, account) in self.accounts.iter().enumerate() { if Some(ndx) != self.currently_selected_account { // this account is not currently selected if let Some(data) = self.account_data.get_mut(account.pubkey.bytes()) { if data.relay.sub.is_some() { // this account has relay subs, deactivate them data.relay.deactivate(ndb, pool); } if data.muted.sub.is_some() { // this account has muted subs, deactivate them data.muted.deactivate(ndb, pool); } } } } // Were any accounts added or removed? let (added, removed) = self.delta_accounts(); for pk in added { self.handle_added_account(ndb, &pk); need_reconfig = true; } for pk in removed { self.handle_removed_account(&pk); need_reconfig = true; } // Did any accounts receive updates (ie NIP-65 relay lists) need_reconfig = self.poll_for_updates(ndb) || need_reconfig; // If needed, update the relay configuration if need_reconfig { self.update_relay_configuration(pool, wakeup); self.needs_relay_config = false; } // Do we need to activate account subs? if let Some(data) = self.get_selected_account_data() { if data.relay.sub.is_none() { // the currently selected account doesn't have relay subs, activate them data.relay.activate(ndb, pool); } if data.muted.sub.is_none() { // the currently selected account doesn't have muted subs, activate them data.muted.activate(ndb, pool); } } } pub fn get_full<'a>(&'a self, pubkey: &[u8; 32]) -> Option> { if let Some(contains) = self.contains_account(pubkey) { if contains.has_nsec { if let Some(kp) = self.get_account(contains.index) { return kp.to_full(); } } } None } fn modify_advertised_relays( &mut self, relay_url: &str, pool: &mut RelayPool, action: RelayAction, ) { let relay_url = AccountRelayData::canonicalize_url(relay_url); match action { RelayAction::Add => info!("add advertised relay \"{}\"", relay_url), RelayAction::Remove => info!("remove advertised relay \"{}\"", relay_url), } match self.currently_selected_account { None => error!("no account is currently selected."), Some(index) => match self.accounts.get(index) { None => error!("selected account index {} is out of range.", index), Some(keypair) => { let key_bytes: [u8; 32] = *keypair.pubkey.bytes(); match self.account_data.get_mut(&key_bytes) { None => error!("no account data found for the provided key."), Some(account_data) => { 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(self.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)); } } self.needs_relay_config = true; // If we have the secret key publish the NIP-65 relay list if let Some(secretkey) = &keypair.secret_key { account_data .relay .publish_nip65_relays(&secretkey.to_secret_bytes(), pool); } } } } }, } } pub fn add_advertised_relay(&mut self, relay_to_add: &str, pool: &mut RelayPool) { self.modify_advertised_relays(relay_to_add, pool, RelayAction::Add); } pub fn remove_advertised_relay(&mut self, relay_to_remove: &str, pool: &mut RelayPool) { self.modify_advertised_relays(relay_to_remove, pool, RelayAction::Remove); } } enum RelayAction { Add, Remove, } fn get_selected_index(accounts: &[UserAccount], keystore: &KeyStorageType) -> Option { match keystore.get_selected_key() { KeyStorageResponse::ReceivedResult(Ok(Some(pubkey))) => { return accounts.iter().position(|account| account.pubkey == pubkey); } KeyStorageResponse::ReceivedResult(Err(e)) => error!("Error getting selected key: {}", e), KeyStorageResponse::Waiting | KeyStorageResponse::ReceivedResult(Ok(None)) => {} }; None } impl AddAccountAction { // Simple wrapper around processing the unknown action to expose too // much internal logic. This allows us to have a must_use on our // LoginAction type, otherwise the SingleUnkIdAction's must_use will // be lost when returned in the login action pub fn process_action(&mut self, ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) { self.unk_id_action.process_action(ids, ndb, txn); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/lib.rs`: ```rs mod accounts; mod app; mod args; mod context; mod error; pub mod filter; pub mod fonts; mod imgcache; mod muted; pub mod note; mod notecache; mod persist; 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; pub use accounts::{AccountData, Accounts, AccountsAction, AddAccountAction, SwitchAccountAction}; pub use app::{App, Notedeck}; pub use args::Args; pub use context::AppContext; pub use error::{Error, FilterError}; pub use filter::{FilterState, FilterStates, UnifiedSubscription}; pub use fonts::NamedFontFamily; pub use imgcache::{ Animation, GifState, GifStateMap, ImageFrame, Images, MediaCache, MediaCacheType, MediaCacheValue, TextureFrame, TexturedImage, }; pub use muted::{MuteFun, Muted}; pub use note::{NoteRef, RootIdError, RootNoteId, RootNoteIdBuf}; pub use notecache::{CachedNote, NoteCache}; pub use persist::*; pub use relay_debug::RelayDebugView; pub use relayspec::RelaySpec; pub use result::Result; pub use storage::{ DataPath, DataPathType, Directory, FileKeyStorage, KeyStorageResponse, KeyStorageType, }; 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; // export libs pub use enostr; pub use nostrdb; ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/theme.rs`: ```rs use egui::{ style::{Selection, WidgetVisuals, Widgets}, Color32, Rounding, Shadow, 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_ROUNDING: Rounding = Rounding::same(8.0); 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, }, rounding: WIDGET_ROUNDING, ..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, }, rounding: WIDGET_ROUNDING, ..default.widgets.inactive }, hovered: WidgetVisuals { rounding: WIDGET_ROUNDING, ..default.widgets.hovered }, active: WidgetVisuals { rounding: WIDGET_ROUNDING, ..default.widgets.active }, open: WidgetVisuals { ..default.widgets.open }, }, extreme_bg_color: theme.extreme_bg_color, error_fg_color: theme.err_fg_color, window_rounding: Rounding::same(8.0), window_fill: theme.window_fill, window_shadow: Shadow { offset: [0.0, 8.0].into(), blur: 24.0, spread: 0.0, color: egui::Color32::from_rgba_unmultiplied(0x6D, 0x6D, 0x6D, 0x14), }, window_stroke: Stroke { width: 1.0, color: theme.window_stroke_color, }, image_loading_spinners: false, ..default } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/style.rs`: ```rs 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()), ) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/unknowns.rs`: ```rs use crate::{ note::NoteRef, notecache::{CachedNote, NoteCache}, Result, }; use enostr::{Filter, NoteId, Pubkey}; use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; use tracing::error; #[must_use = "process_action should be used on this result"] pub enum SingleUnkIdAction { NoAction, NeedsProcess(UnknownId), } #[must_use = "process_action should be used on this result"] pub enum NoteRefsUnkIdAction { NoAction, NeedsProcess(Vec), } impl NoteRefsUnkIdAction { pub fn new(refs: Vec) -> Self { NoteRefsUnkIdAction::NeedsProcess(refs) } pub fn no_action() -> Self { Self::NoAction } pub fn process_action( &self, txn: &Transaction, ndb: &Ndb, unk_ids: &mut UnknownIds, note_cache: &mut NoteCache, ) { match self { Self::NoAction => {} Self::NeedsProcess(refs) => { UnknownIds::update_from_note_refs(txn, ndb, unk_ids, note_cache, refs); } } } } impl SingleUnkIdAction { pub fn new(id: UnknownId) -> Self { SingleUnkIdAction::NeedsProcess(id) } pub fn no_action() -> Self { Self::NoAction } pub fn pubkey(pubkey: Pubkey) -> Self { SingleUnkIdAction::new(UnknownId::Pubkey(pubkey)) } pub fn note_id(note_id: NoteId) -> Self { SingleUnkIdAction::new(UnknownId::Id(note_id)) } /// Some functions may return unknown id actions that need to be processed. /// For example, when we add a new account we need to make sure we have the /// profile for that account. This function ensures we add this to the /// unknown id tracker without adding side effects to functions. pub fn process_action(&self, ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) { match self { Self::NeedsProcess(id) => { ids.add_unknown_id_if_missing(ndb, txn, id); } Self::NoAction => {} } } } type RelayUrl = String; /// Unknown Id searcher #[derive(Default, Debug)] pub struct UnknownIds { ids: HashMap>, first_updated: Option, last_updated: Option, } impl UnknownIds { /// Simple debouncer pub fn ready_to_send(&self) -> bool { if self.ids.is_empty() { return false; } // we trigger on first set if self.first_updated == self.last_updated { return true; } let last_updated = if let Some(last) = self.last_updated { last } else { // if we've return true; }; Instant::now() - last_updated >= Duration::from_secs(2) } pub fn ids_iter(&self) -> impl ExactSizeIterator { self.ids.keys() } pub fn ids_mut(&mut self) -> &mut HashMap> { &mut self.ids } pub fn clear(&mut self) { self.ids = HashMap::default(); } pub fn filter(&self) -> Option> { let ids: Vec<&UnknownId> = self.ids.keys().collect(); get_unknown_ids_filter(&ids) } /// We've updated some unknown ids, update the last_updated time to now pub fn mark_updated(&mut self) { let now = Instant::now(); if self.first_updated.is_none() { self.first_updated = Some(now); } self.last_updated = Some(now); } pub fn update_from_note_key( txn: &Transaction, ndb: &Ndb, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, key: NoteKey, ) -> bool { let note = if let Ok(note) = ndb.get_note_by_key(txn, key) { note } else { return false; }; UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, ¬e) } /// Should be called on freshly polled notes from subscriptions pub fn update_from_note_refs( txn: &Transaction, ndb: &Ndb, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, note_refs: &[NoteRef], ) { for note_ref in note_refs { Self::update_from_note_key(txn, ndb, unknown_ids, note_cache, note_ref.key); } } pub fn update_from_note( txn: &Transaction, ndb: &Ndb, unknown_ids: &mut UnknownIds, note_cache: &mut NoteCache, note: &Note, ) -> bool { let before = unknown_ids.ids_iter().len(); let key = note.key().expect("note key"); //let cached_note = note_cache.cached_note_or_insert(key, note).clone(); let cached_note = note_cache.cached_note_or_insert(key, note); if let Err(e) = get_unknown_note_ids(ndb, cached_note, txn, note, unknown_ids.ids_mut()) { error!("UnknownIds::update_from_note {e}"); } let after = unknown_ids.ids_iter().len(); if before != after { unknown_ids.mark_updated(); true } else { false } } pub fn add_unknown_id_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, unk_id: &UnknownId) { match unk_id { UnknownId::Pubkey(pk) => self.add_pubkey_if_missing(ndb, txn, pk), UnknownId::Id(note_id) => self.add_note_id_if_missing(ndb, txn, note_id), } } pub fn add_pubkey_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, pubkey: &Pubkey) { // we already have this profile, skip if ndb.get_profile_by_pubkey(txn, pubkey).is_ok() { return; } self.ids.entry(UnknownId::Pubkey(*pubkey)).or_default(); self.mark_updated(); } pub fn add_note_id_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, note_id: &NoteId) { // we already have this note, skip if ndb.get_note_by_id(txn, note_id.bytes()).is_ok() { return; } self.ids.entry(UnknownId::Id(*note_id)).or_default(); self.mark_updated(); } } #[derive(Hash, Clone, Copy, PartialEq, Eq, Debug)] pub enum UnknownId { Pubkey(Pubkey), Id(NoteId), } impl UnknownId { pub fn is_pubkey(&self) -> Option<&Pubkey> { match self { UnknownId::Pubkey(pk) => Some(pk), _ => None, } } pub fn is_id(&self) -> Option<&NoteId> { match self { UnknownId::Id(id) => Some(id), _ => None, } } } /// Look for missing notes in various parts of notes that we see: /// /// - pubkeys and notes mentioned inside the note /// - notes being replied to /// /// We return all of this in a HashSet so that we can fetch these from /// remote relays. /// pub fn get_unknown_note_ids<'a>( ndb: &Ndb, cached_note: &CachedNote, txn: &'a Transaction, note: &Note<'a>, ids: &mut HashMap>, ) -> Result<()> { #[cfg(feature = "profiling")] puffin::profile_function!(); // the author pubkey if ndb.get_profile_by_pubkey(txn, note.pubkey()).is_err() { ids.entry(UnknownId::Pubkey(Pubkey::new(*note.pubkey()))) .or_default(); } // pull notes that notes are replying to if cached_note.reply.root.is_some() { let note_reply = cached_note.reply.borrow(note.tags()); if let Some(root) = note_reply.root() { if ndb.get_note_by_id(txn, root.id).is_err() { ids.entry(UnknownId::Id(NoteId::new(*root.id))).or_default(); } } if !note_reply.is_reply_to_root() { if let Some(reply) = note_reply.reply() { if ndb.get_note_by_id(txn, reply.id).is_err() { ids.entry(UnknownId::Id(NoteId::new(*reply.id))) .or_default(); } } } } let blocks = ndb.get_blocks_by_key(txn, note.key().expect("note key"))?; for block in blocks.iter(note) { if block.blocktype() != BlockType::MentionBech32 { continue; } match block.as_mention().unwrap() { Mention::Pubkey(npub) => { if ndb.get_profile_by_pubkey(txn, npub.pubkey()).is_err() { ids.entry(UnknownId::Pubkey(Pubkey::new(*npub.pubkey()))) .or_default(); } } Mention::Profile(nprofile) => { if ndb.get_profile_by_pubkey(txn, nprofile.pubkey()).is_err() { let id = UnknownId::Pubkey(Pubkey::new(*nprofile.pubkey())); let relays = nprofile .relays_iter() .map(String::from) .collect::>(); ids.entry(id).or_default().extend(relays); } } Mention::Event(ev) => { let relays = ev .relays_iter() .map(String::from) .collect::>(); match ndb.get_note_by_id(txn, ev.id()) { Err(_) => { ids.entry(UnknownId::Id(NoteId::new(*ev.id()))) .or_default() .extend(relays.clone()); if let Some(pk) = ev.pubkey() { if ndb.get_profile_by_pubkey(txn, pk).is_err() { ids.entry(UnknownId::Pubkey(Pubkey::new(*pk))) .or_default() .extend(relays); } } } Ok(note) => { if ndb.get_profile_by_pubkey(txn, note.pubkey()).is_err() { ids.entry(UnknownId::Pubkey(Pubkey::new(*note.pubkey()))) .or_default() .extend(relays); } } } } Mention::Note(note) => match ndb.get_note_by_id(txn, note.id()) { Err(_) => { ids.entry(UnknownId::Id(NoteId::new(*note.id()))) .or_default(); } Ok(note) => { if ndb.get_profile_by_pubkey(txn, note.pubkey()).is_err() { ids.entry(UnknownId::Pubkey(Pubkey::new(*note.pubkey()))) .or_default(); } } }, _ => {} } } Ok(()) } fn get_unknown_ids_filter(ids: &[&UnknownId]) -> Option> { if ids.is_empty() { return None; } let ids = &ids[0..500.min(ids.len())]; let mut filters: Vec = vec![]; let pks: Vec<&[u8; 32]> = ids .iter() .flat_map(|id| id.is_pubkey().map(|pk| pk.bytes())) .collect(); if !pks.is_empty() { let pk_filter = Filter::new().authors(pks).kinds([0]).build(); filters.push(pk_filter); } let note_ids: Vec<&[u8; 32]> = ids .iter() .flat_map(|id| id.is_id().map(|id| id.bytes())) .collect(); if !note_ids.is_empty() { filters.push(Filter::new().ids(note_ids).build()); } Some(filters) } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/time.rs`: ```rs use std::time::{SystemTime, UNIX_EPOCH}; pub fn time_ago_since(timestamp: u64) -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs(); // Determine if the timestamp is in the future or the past let duration = if now >= timestamp { now.saturating_sub(timestamp) } else { timestamp.saturating_sub(now) }; let future = timestamp > now; let relstr = if future { "+" } else { "" }; let years = duration / 31_536_000; // seconds in a year if years >= 1 { return format!("{}{}yr", relstr, years); } let months = duration / 2_592_000; // seconds in a month (30.44 days) if months >= 1 { return format!("{}{}mth", relstr, months); } let weeks = duration / 604_800; // seconds in a week if weeks >= 1 { return format!("{}{}wk", relstr, weeks); } let days = duration / 86_400; // seconds in a day if days >= 1 { return format!("{}{}d", relstr, days); } let hours = duration / 3600; // seconds in an hour if hours >= 1 { return format!("{}{}h", relstr, hours); } let minutes = duration / 60; // seconds in a minute if minutes >= 1 { return format!("{}{}m", relstr, minutes); } let seconds = duration; if seconds >= 3 { return format!("{}{}s", relstr, seconds); } "now".to_string() } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/timecache.rs`: ```rs use std::rc::Rc; use std::time::{Duration, Instant}; #[derive(Clone)] pub struct TimeCached { last_update: Instant, expires_in: Duration, value: Option, refresh: Rc T + 'static>, } impl TimeCached { pub fn new(expires_in: Duration, refresh: impl Fn() -> T + 'static) -> Self { TimeCached { last_update: Instant::now(), expires_in, value: None, refresh: Rc::new(refresh), } } pub fn needs_update(&self) -> bool { self.value.is_none() || self.last_update.elapsed() > self.expires_in } pub fn update(&mut self) { self.last_update = Instant::now(); self.value = Some((self.refresh)()); } pub fn get(&self) -> Option<&T> { self.value.as_ref() } pub fn get_mut(&mut self) -> &T { if self.needs_update() { self.update(); } self.value.as_ref().unwrap() // This unwrap is safe because we just set the value if it was None. } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/storage/file_key_storage.rs`: ```rs use crate::Result; use enostr::{Keypair, Pubkey, SerializableKeypair}; use super::{ file_storage::{delete_file, write_file, Directory}, key_storage_impl::KeyStorageResponse, }; static SELECTED_PUBKEY_FILE_NAME: &str = "selected_pubkey"; /// An OS agnostic file key storage implementation #[derive(Debug, PartialEq)] pub struct FileKeyStorage { keys_directory: Directory, selected_key_directory: Directory, } impl FileKeyStorage { pub fn new(keys_directory: Directory, selected_key_directory: Directory) -> Self { Self { keys_directory, selected_key_directory, } } fn add_key_internal(&self, key: &Keypair) -> Result<()> { write_file( &self.keys_directory.file_path, key.pubkey.hex(), &serde_json::to_string(&SerializableKeypair::from_keypair(key, "", 7))?, ) } fn get_keys_internal(&self) -> Result> { let keys = self .keys_directory .get_files()? .values() .filter_map(|str_key| serde_json::from_str::(str_key).ok()) .map(|serializable_keypair| serializable_keypair.to_keypair("")) .collect(); Ok(keys) } fn remove_key_internal(&self, key: &Keypair) -> Result<()> { delete_file(&self.keys_directory.file_path, key.pubkey.hex()) } fn get_selected_pubkey(&self) -> Result> { match self .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 select_pubkey(&self, pubkey: Option) -> Result<()> { if let Some(pubkey) = pubkey { write_file( &self.selected_key_directory.file_path, SELECTED_PUBKEY_FILE_NAME.to_owned(), &serde_json::to_string(&pubkey.hex())?, ) } else if self .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.selected_key_directory.file_path, SELECTED_PUBKEY_FILE_NAME.to_owned(), )?) } else { Ok(()) } } } impl FileKeyStorage { pub fn get_keys(&self) -> KeyStorageResponse> { KeyStorageResponse::ReceivedResult(self.get_keys_internal()) } pub fn add_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { KeyStorageResponse::ReceivedResult(self.add_key_internal(key)) } pub fn remove_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { KeyStorageResponse::ReceivedResult(self.remove_key_internal(key)) } pub fn get_selected_key(&self) -> KeyStorageResponse> { KeyStorageResponse::ReceivedResult(self.get_selected_pubkey()) } pub fn select_key(&self, key: Option) -> KeyStorageResponse<()> { KeyStorageResponse::ReceivedResult(self.select_pubkey(key)) } } #[cfg(test)] mod tests { use std::path::PathBuf; use super::Result; use super::*; use enostr::Keypair; static CREATE_TMP_DIR: fn() -> Result = || Ok(tempfile::TempDir::new()?.path().to_path_buf()); impl FileKeyStorage { fn mock() -> Result { Ok(Self { keys_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 storage = FileKeyStorage::mock().unwrap(); let resp = storage.add_key(&kp); assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(()))); assert_num_storage(&storage.get_keys(), 1); assert_eq!( storage.remove_key(&kp), KeyStorageResponse::ReceivedResult(Ok(())) ); assert_num_storage(&storage.get_keys(), 0); } fn assert_num_storage(keys_response: &KeyStorageResponse>, n: usize) { match keys_response { KeyStorageResponse::ReceivedResult(Ok(keys)) => { assert_eq!(keys.len(), n); } KeyStorageResponse::ReceivedResult(Err(_e)) => { panic!("could not get keys"); } KeyStorageResponse::Waiting => { panic!("did not receive result"); } } } #[test] fn test_select_key() { let kp = enostr::FullKeypair::generate().to_keypair(); let storage = FileKeyStorage::mock().unwrap(); let _ = storage.add_key(&kp); assert_num_storage(&storage.get_keys(), 1); let resp = storage.select_pubkey(Some(kp.pubkey)); assert!(resp.is_ok()); let resp = storage.get_selected_pubkey(); assert!(resp.is_ok()); } #[test] fn test_get_selected_key_when_no_file() { let storage = FileKeyStorage::mock().unwrap(); // Should return Ok(None) when no key has been selected match storage.get_selected_key() { KeyStorageResponse::ReceivedResult(Ok(None)) => (), // This is what we expect other => panic!("Expected Ok(None), got {:?}", other), } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/storage/mod.rs`: ```rs mod file_key_storage; mod file_storage; pub use file_key_storage::FileKeyStorage; pub use file_storage::{delete_file, write_file, DataPath, DataPathType, Directory}; #[cfg(target_os = "macos")] mod security_framework_key_storage; pub mod key_storage_impl; pub use key_storage_impl::{KeyStorageResponse, KeyStorageType}; ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/storage/file_storage.rs`: ```rs use std::{ collections::{HashMap, VecDeque}, fs::{self, File}, io::{self, BufRead}, path::{Path, PathBuf}, time::SystemTime, }; use crate::{Error, Result}; #[derive(Debug, Clone)] pub struct DataPath { base: PathBuf, } impl DataPath { pub fn new(base: impl AsRef) -> Self { let base = base.as_ref().to_path_buf(); Self { base } } pub fn default_base() -> Option { dirs::data_local_dir().map(|pb| pb.join("notedeck")) } pub fn default_base_or_cwd() -> PathBuf { use std::str::FromStr; Self::default_base().unwrap_or_else(|| PathBuf::from_str(".").unwrap()) } pub fn rel_path(&self, typ: DataPathType) -> PathBuf { match typ { DataPathType::Log => PathBuf::from("logs"), DataPathType::Setting => PathBuf::from("settings"), DataPathType::Keys => PathBuf::from("storage").join("accounts"), DataPathType::SelectedKey => PathBuf::from("storage").join("selected_account"), DataPathType::Db => PathBuf::from("db"), DataPathType::Cache => PathBuf::from("cache"), } } pub fn path(&self, typ: DataPathType) -> PathBuf { self.base.join(self.rel_path(typ)) } } impl Default for DataPath { fn default() -> Self { Self::new(Self::default_base_or_cwd()) } } pub enum DataPathType { Log, Setting, Keys, SelectedKey, Db, Cache, } #[derive(Debug, PartialEq)] pub struct Directory { pub file_path: PathBuf, } impl Directory { pub fn new(file_path: PathBuf) -> Self { Self { file_path } } /// Get the files in the current directory where the key is the file name and the value is the file contents pub fn get_files(&self) -> Result> { let dir = fs::read_dir(self.file_path.clone())?; let map = dir .filter_map(|f| f.ok()) .filter(|f| f.path().is_file()) .filter_map(|f| { let file_name = f.file_name().into_string().ok()?; let contents = fs::read_to_string(f.path()).ok()?; Some((file_name, contents)) }) .collect(); Ok(map) } pub fn get_file_names(&self) -> Result> { let dir = fs::read_dir(self.file_path.clone())?; let names = dir .filter_map(|f| f.ok()) .filter(|f| f.path().is_file()) .filter_map(|f| f.file_name().into_string().ok()) .collect(); Ok(names) } pub fn get_file(&self, file_name: String) -> Result { let filepath = self.file_path.clone().join(file_name.clone()); if filepath.exists() && filepath.is_file() { let filepath_str = filepath .to_str() .ok_or_else(|| Error::Generic("Could not turn path to string".to_owned()))?; Ok(fs::read_to_string(filepath_str)?) } else { Err(Error::Io(io::Error::new( io::ErrorKind::NotFound, format!("Requested file was not found: {}", file_name), ))) } } pub fn get_file_last_n_lines(&self, file_name: String, n: usize) -> Result { let filepath = self.file_path.clone().join(file_name.clone()); if filepath.exists() && filepath.is_file() { let file = File::open(&filepath)?; let reader = io::BufReader::new(file); let mut queue: VecDeque = VecDeque::with_capacity(n); let mut total_lines_in_file = 0; for line in reader.lines() { let line = line?; queue.push_back(line); if queue.len() > n { queue.pop_front(); } total_lines_in_file += 1; } let output_num_lines = queue.len(); let output = queue.into_iter().collect::>().join("\n"); Ok(FileResult { output, output_num_lines, total_lines_in_file, }) } else { Err(Error::Generic(format!( "Requested file was not found: {}", file_name ))) } } /// Get the file name which is most recently modified in the directory pub fn get_most_recent(&self) -> Result> { let mut most_recent: Option<(SystemTime, String)> = None; for entry in fs::read_dir(&self.file_path)? { let entry = entry?; let metadata = entry.metadata()?; if metadata.is_file() { let modified = metadata.modified()?; let file_name = entry.file_name().to_string_lossy().to_string(); match most_recent { Some((last_modified, _)) if modified > last_modified => { most_recent = Some((modified, file_name)); } None => { most_recent = Some((modified, file_name)); } _ => {} } } } Ok(most_recent.map(|(_, file_name)| file_name)) } } pub struct FileResult { pub output: String, pub output_num_lines: usize, pub total_lines_in_file: usize, } /// Write the file to the directory pub fn write_file(directory: &Path, file_name: String, data: &str) -> Result<()> { if !directory.exists() { fs::create_dir_all(directory)? } std::fs::write(directory.join(file_name), data)?; Ok(()) } pub fn delete_file(directory: &Path, file_name: String) -> Result<()> { let file_to_delete = directory.join(file_name.clone()); if file_to_delete.exists() && file_to_delete.is_file() { fs::remove_file(file_to_delete).map_err(Error::Io) } else { Err(Error::Generic(format!( "Requested file to delete was not found: {}", file_name ))) } } #[cfg(test)] mod tests { use std::path::PathBuf; use crate::{ storage::file_storage::{delete_file, write_file}, Result, }; use super::Directory; static CREATE_TMP_DIR: fn() -> Result = || Ok(tempfile::TempDir::new()?.path().to_path_buf()); #[test] fn test_add_get_delete() { if let Ok(path) = CREATE_TMP_DIR() { let directory = Directory::new(path); let file_name = "file_test_name.txt".to_string(); let file_contents = "test"; let write_res = write_file(&directory.file_path, file_name.clone(), file_contents); assert!(write_res.is_ok()); if let Ok(asserted_file_contents) = directory.get_file(file_name.clone()) { assert_eq!(asserted_file_contents, file_contents); } else { panic!("File not found"); } let delete_res = delete_file(&directory.file_path, file_name); assert!(delete_res.is_ok()); } else { panic!("could not get interactor") } } #[test] fn test_get_multiple() { if let Ok(path) = CREATE_TMP_DIR() { let directory = Directory::new(path); for i in 0..10 { let file_name = format!("file{}.txt", i); let write_res = write_file(&directory.file_path, file_name, "test"); assert!(write_res.is_ok()); } if let Ok(files) = directory.get_files() { for i in 0..10 { let file_name = format!("file{}.txt", i); assert!(files.contains_key(&file_name)); assert_eq!(files.get(&file_name).unwrap(), "test"); } } else { panic!("Files not found"); } if let Ok(file_names) = directory.get_file_names() { for i in 0..10 { let file_name = format!("file{}.txt", i); assert!(file_names.contains(&file_name)); } } else { panic!("File names not found"); } for i in 0..10 { let file_name = format!("file{}.txt", i); assert!(delete_file(&directory.file_path, file_name).is_ok()); } } else { panic!("could not get interactor") } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/storage/security_framework_key_storage.rs`: ```rs use std::borrow::Cow; use enostr::{Keypair, Pubkey, SecretKey}; use security_framework::{ item::{ItemClass, ItemSearchOptions, Limit, SearchResult}, passwords::{delete_generic_password, set_generic_password}, }; use tracing::error; use crate::{Error, Result}; use super::KeyStorageResponse; #[derive(Debug, PartialEq)] pub struct SecurityFrameworkKeyStorage { pub service_name: Cow<'static, str>, } impl SecurityFrameworkKeyStorage { pub fn new(service_name: String) -> Self { SecurityFrameworkKeyStorage { service_name: Cow::Owned(service_name), } } fn add_key_internal(&self, key: &Keypair) -> Result<()> { match set_generic_password( &self.service_name, key.pubkey.hex().as_str(), key.secret_key .as_ref() .map_or_else(|| &[] as &[u8], |sc| sc.as_secret_bytes()), ) { Ok(_) => Ok(()), Err(e) => Err(Error::Generic(e.to_string())), } } fn get_pubkey_strings(&self) -> Vec { let search_results = ItemSearchOptions::new() .class(ItemClass::generic_password()) .service(&self.service_name) .load_attributes(true) .limit(Limit::All) .search(); let mut accounts = Vec::new(); if let Ok(search_results) = search_results { for result in search_results { if let Some(map) = result.simplify_dict() { if let Some(val) = map.get("acct") { accounts.push(val.clone()); } } } } accounts } fn get_pubkeys(&self) -> Vec { self.get_pubkey_strings() .iter_mut() .filter_map(|pubkey_str| Pubkey::from_hex(pubkey_str.as_str()).ok()) .collect() } fn get_privkey_bytes_for(&self, account: &str) -> Option> { let search_result = ItemSearchOptions::new() .class(ItemClass::generic_password()) .service(&self.service_name) .load_data(true) .account(account) .search(); if let Ok(results) = search_result { if let Some(SearchResult::Data(vec)) = results.first() { return Some(vec.clone()); } } None } fn get_secret_key_for_pubkey(&self, pubkey: &Pubkey) -> Option { if let Some(bytes) = self.get_privkey_bytes_for(pubkey.hex().as_str()) { SecretKey::from_slice(bytes.as_slice()).ok() } else { None } } fn get_all_keypairs(&self) -> Vec { self.get_pubkeys() .iter() .map(|pubkey| { let maybe_secret = self.get_secret_key_for_pubkey(pubkey); Keypair::new(*pubkey, maybe_secret) }) .collect() } fn delete_key(&self, pubkey: &Pubkey) -> Result<()> { match delete_generic_password(&self.service_name, pubkey.hex().as_str()) { Ok(_) => Ok(()), Err(e) => { error!("delete key error {}", e); Err(Error::Generic(e.to_string())) } } } } impl SecurityFrameworkKeyStorage { pub fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { KeyStorageResponse::ReceivedResult(self.add_key_internal(key)) } pub fn get_keys(&self) -> KeyStorageResponse> { KeyStorageResponse::ReceivedResult(Ok(self.get_all_keypairs())) } pub fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { KeyStorageResponse::ReceivedResult(self.delete_key(&key.pubkey)) } } #[cfg(test)] mod tests { use super::*; use enostr::FullKeypair; static TEST_SERVICE_NAME: &str = "NOTEDECKTEST"; static STORAGE: SecurityFrameworkKeyStorage = SecurityFrameworkKeyStorage { service_name: Cow::Borrowed(TEST_SERVICE_NAME), }; // individual tests are ignored so test runner doesn't run them all concurrently // TODO: a way to run them all serially should be devised #[test] #[ignore] fn add_and_remove_test_pubkey_only() { let num_keys_before_test = STORAGE.get_pubkeys().len(); let keypair = FullKeypair::generate().to_keypair(); let add_result = STORAGE.add_key_internal(&keypair); assert!(add_result.is_ok()); let get_pubkeys_result = STORAGE.get_pubkeys(); assert_eq!(get_pubkeys_result.len() - num_keys_before_test, 1); let remove_result = STORAGE.delete_key(&keypair.pubkey); assert!(remove_result.is_ok()); let keys = STORAGE.get_pubkeys(); assert_eq!(keys.len() - num_keys_before_test, 0); } fn add_and_remove_full_n(n: usize) { let num_keys_before_test = STORAGE.get_all_keypairs().len(); // there must be zero keys in storage for the test to work as intended assert_eq!(num_keys_before_test, 0); let expected_keypairs: Vec = (0..n) .map(|_| FullKeypair::generate().to_keypair()) .collect(); expected_keypairs.iter().for_each(|keypair| { let add_result = STORAGE.add_key_internal(keypair); assert!(add_result.is_ok()); }); let asserted_keypairs = STORAGE.get_all_keypairs(); assert_eq!(expected_keypairs, asserted_keypairs); expected_keypairs.iter().for_each(|keypair| { let remove_result = STORAGE.delete_key(&keypair.pubkey); assert!(remove_result.is_ok()); }); let num_keys_after_test = STORAGE.get_all_keypairs().len(); assert_eq!(num_keys_after_test, 0); } #[test] #[ignore] fn add_and_remove_full() { add_and_remove_full_n(1); } #[test] #[ignore] fn add_and_remove_full_10() { add_and_remove_full_n(10); } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/storage/key_storage_impl.rs`: ```rs use enostr::{Keypair, Pubkey}; use super::file_key_storage::FileKeyStorage; use crate::Result; #[cfg(target_os = "macos")] use super::security_framework_key_storage::SecurityFrameworkKeyStorage; #[derive(Debug, PartialEq)] pub enum KeyStorageType { None, FileSystem(FileKeyStorage), #[cfg(target_os = "macos")] SecurityFramework(SecurityFrameworkKeyStorage), } #[allow(dead_code)] #[derive(Debug)] pub enum KeyStorageResponse { Waiting, ReceivedResult(Result), } impl PartialEq for KeyStorageResponse { fn eq(&self, other: &Self) -> bool { match (self, other) { (KeyStorageResponse::Waiting, KeyStorageResponse::Waiting) => true, ( KeyStorageResponse::ReceivedResult(Ok(r1)), KeyStorageResponse::ReceivedResult(Ok(r2)), ) => r1 == r2, ( KeyStorageResponse::ReceivedResult(Err(_)), KeyStorageResponse::ReceivedResult(Err(_)), ) => true, _ => false, } } } impl KeyStorageType { pub fn get_keys(&self) -> KeyStorageResponse> { match self { Self::None => KeyStorageResponse::ReceivedResult(Ok(Vec::new())), Self::FileSystem(f) => f.get_keys(), #[cfg(target_os = "macos")] Self::SecurityFramework(f) => f.get_keys(), } } pub fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { let _ = key; match self { Self::None => KeyStorageResponse::ReceivedResult(Ok(())), Self::FileSystem(f) => f.add_key(key), #[cfg(target_os = "macos")] Self::SecurityFramework(f) => f.add_key(key), } } pub fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { let _ = key; match self { Self::None => KeyStorageResponse::ReceivedResult(Ok(())), Self::FileSystem(f) => f.remove_key(key), #[cfg(target_os = "macos")] Self::SecurityFramework(f) => f.remove_key(key), } } pub fn get_selected_key(&self) -> KeyStorageResponse> { match self { Self::None => KeyStorageResponse::ReceivedResult(Ok(None)), Self::FileSystem(f) => f.get_selected_key(), #[cfg(target_os = "macos")] Self::SecurityFramework(_) => unimplemented!(), } } pub fn select_key(&self, key: Option) -> KeyStorageResponse<()> { match self { Self::None => KeyStorageResponse::ReceivedResult(Ok(())), Self::FileSystem(f) => f.select_key(key), #[cfg(target_os = "macos")] Self::SecurityFramework(_) => unimplemented!(), } } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/muted.rs`: ```rs use nostrdb::Note; use std::collections::BTreeSet; //use tracing::{debug, trace}; // If the note is muted return a reason string, otherwise None pub type MuteFun = dyn Fn(&Note, &[u8; 32]) -> bool; #[derive(Default)] pub struct Muted { // TODO - implement private mutes pub pubkeys: BTreeSet<[u8; 32]>, pub hashtags: BTreeSet, pub words: BTreeSet, pub threads: BTreeSet<[u8; 32]>, } impl std::fmt::Debug for Muted { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Muted") .field( "pubkeys", &self.pubkeys.iter().map(hex::encode).collect::>(), ) .field("hashtags", &self.hashtags) .field("words", &self.words) .field( "threads", &self.threads.iter().map(hex::encode).collect::>(), ) .finish() } } impl Muted { // If the note is muted return a reason string, otherwise None pub fn is_muted(&self, note: &Note, thread: &[u8; 32]) -> bool { /* trace!( "{}: thread: {}", hex::encode(note.id()), hex::encode(thread) ); */ if self.pubkeys.contains(note.pubkey()) { /* trace!( "{}: MUTED pubkey: {}", hex::encode(note.id()), hex::encode(note.pubkey()) ); */ return true; } // FIXME - Implement hashtag muting here // TODO - let's not add this for now, we will likely need to // have an optimized data structure in nostrdb to properly // mute words. this mutes substrings which is not ideal. // // let content = note.content().to_lowercase(); // for word in &self.words { // if content.contains(&word.to_lowercase()) { // debug!("{}: MUTED word: {}", hex::encode(note.id()), word); // return Some(format!("muted word {}", word)); // } // } if self.threads.contains(thread) { /* trace!( "{}: MUTED thread: {}", hex::encode(note.id()), hex::encode(thread) ); */ return true; } false } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/relay_debug.rs`: ```rs 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, events: Vec| { 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!("{:.2} MB", mbs) } else if b >= KB { let kbs = b as f32 / KB as f32; format!("{:.2} KB", kbs) } else { format!("{} B", b) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/note.rs`: ```rs use crate::notecache::NoteCache; use enostr::NoteId; use nostrdb::{Ndb, Note, NoteKey, QueryResult, Transaction}; use std::borrow::Borrow; use std::cmp::Ordering; use std::fmt; #[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 { 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, 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 { 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, 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)), ) } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/timed_serializer.rs`: ```rs use std::time::{Duration, Instant}; use crate::{storage, DataPath, DataPathType, Directory}; use serde::{Deserialize, Serialize}; use tracing::info; pub struct TimedSerializer Deserialize<'de>> { directory: Directory, file_name: String, delay: Duration, last_saved: Instant, saved_item: Option, } impl Deserialize<'de>> TimedSerializer { pub fn new(path: &DataPath, path_type: DataPathType, file_name: String) -> Self { let directory = Directory::new(path.path(path_type)); let delay = Duration::from_millis(1000); Self { directory, file_name, delay, last_saved: Instant::now() - delay, saved_item: None, } } pub fn with_delay(mut self, delay: Duration) -> Self { self.delay = delay; self } fn should_save(&self) -> bool { self.last_saved.elapsed() >= self.delay } // returns whether successful pub fn try_save(&mut self, cur_item: T) -> bool { if self.should_save() { if let Some(saved_item) = self.saved_item { if saved_item != cur_item { return self.save(cur_item); } } else { return self.save(cur_item); } } false } pub fn get_item(&self) -> Option { if self.saved_item.is_some() { return self.saved_item; } if let Ok(file_contents) = self.directory.get_file(self.file_name.clone()) { if let Ok(item) = serde_json::from_str::(&file_contents) { return Some(item); } } else { info!("Could not find file {}", self.file_name); } None } fn save(&mut self, cur_item: T) -> bool { if let Ok(serialized_item) = serde_json::to_string(&cur_item) { if storage::write_file( &self.directory.file_path, self.file_name.clone(), &serialized_item, ) .is_ok() { info!("wrote item {}", serialized_item); self.last_saved = Instant::now(); self.saved_item = Some(cur_item); return true; } } false } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/notecache.rs`: ```rs use crate::{time_ago_since, TimeCached}; use nostrdb::{Note, NoteKey, NoteReply, NoteReplyBuf}; use std::collections::HashMap; use std::time::Duration; #[derive(Default)] pub struct NoteCache { pub cache: HashMap, } impl NoteCache { pub fn cached_note_or_insert_mut(&mut self, note_key: NoteKey, note: &Note) -> &mut CachedNote { self.cache .entry(note_key) .or_insert_with(|| CachedNote::new(note)) } pub fn cached_note(&self, note_key: NoteKey) -> Option<&CachedNote> { self.cache.get(¬e_key) } pub fn cache_mut(&mut self) -> &mut HashMap { &mut self.cache } pub fn cached_note_or_insert(&mut self, note_key: NoteKey, note: &Note) -> &CachedNote { self.cache .entry(note_key) .or_insert_with(|| CachedNote::new(note)) } } #[derive(Clone)] pub struct CachedNote { reltime: TimeCached, pub reply: NoteReplyBuf, } impl CachedNote { pub fn new(note: &Note<'_>) -> Self { let created_at = note.created_at(); let reltime = TimeCached::new( Duration::from_secs(1), Box::new(move || time_ago_since(created_at)), ); let reply = NoteReply::new(note.tags()).to_owned(); CachedNote { reltime, reply } } pub fn reltime_str_mut(&mut self) -> &str { self.reltime.get_mut() } pub fn reltime_str(&self) -> Option<&str> { self.reltime.get().map(|x| x.as_str()) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/imgcache.rs`: ```rs 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; use std::path::PathBuf; use tracing::warn; pub type MediaCacheValue = Promise>; pub type MediaCacheMap = HashMap; pub enum TexturedImage { Static(TextureHandle), Animated(Animation), } pub struct Animation { pub first_frame: TextureFrame, pub other_frames: Vec, pub receiver: Option>, } 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, url_imgs: MediaCacheMap, } #[derive(Clone)] pub enum MediaCacheType { Image, Gif, } impl MediaCache { pub fn new(cache_dir: path::PathBuf) -> Self { Self { cache_dir, url_imgs: HashMap::new(), } } 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 { 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) -> 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(()) } pub fn map(&self) -> &MediaCacheMap { &self.url_imgs } pub fn map_mut(&mut self) -> &mut MediaCacheMap { &mut self.url_imgs } } 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 = 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.join(MediaCache::rel_dir(MediaCacheType::Image))), gifs: MediaCache::new(path.join(MediaCache::rel_dir(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 type GifStateMap = HashMap; pub struct GifState { pub last_frame_rendered: Instant, pub last_frame_duration: Duration, pub next_frame_time: Option, pub last_frame_index: usize, } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/urls.rs`: ```rs use std::{ collections::HashMap, fs::File, io::{Read, Write}, path::PathBuf, sync::{Arc, RwLock}, time::{Duration, SystemTime}, }; use egui::TextBuffer; use poll_promise::Promise; use url::Url; use crate::{Error, MediaCacheType}; const FILE_NAME: &str = "urls.bin"; const SAVE_INTERVAL: Duration = Duration::from_secs(60); type UrlsToMime = HashMap; /// caches mime type for a URL. saves to disk on interval [`SAVE_INTERVAL`] pub struct UrlCache { last_saved: SystemTime, path: PathBuf, cache: Arc>, from_disk_promise: Option>>, } impl UrlCache { pub fn rel_dir() -> &'static str { FILE_NAME } pub fn new(path: PathBuf) -> Self { Self { last_saved: SystemTime::now(), path: path.clone(), cache: Default::default(), from_disk_promise: Some(read_from_disk(path)), } } pub fn get_type(&self, url: &str) -> Option { self.cache.read().ok()?.get(url).cloned() } pub fn set_type(&mut self, url: String, mime_type: String) { if let Ok(mut locked_cache) = self.cache.write() { locked_cache.insert(url, mime_type); } } pub fn handle_io(&mut self) { if let Some(promise) = &mut self.from_disk_promise { if let Some(maybe_cache) = promise.ready_mut() { if let Some(cache) = maybe_cache.take() { merge_cache(self.cache.clone(), cache) } self.from_disk_promise = None; } } if let Ok(cur_duration) = SystemTime::now().duration_since(self.last_saved) { if cur_duration >= SAVE_INTERVAL { save_to_disk(self.path.clone(), self.cache.clone()); self.last_saved = SystemTime::now(); } } } } fn merge_cache(cur_cache: Arc>, from_disk: UrlsToMime) { std::thread::spawn(move || { if let Ok(mut locked_cache) = cur_cache.write() { locked_cache.extend(from_disk); } }); } fn read_from_disk(path: PathBuf) -> Promise> { let (sender, promise) = Promise::new(); std::thread::spawn(move || { let result: Result = (|| { let mut file = File::open(path)?; let mut buffer = Vec::new(); file.read_to_end(&mut buffer)?; let data: UrlsToMime = bincode::deserialize(&buffer).map_err(|e| Error::Generic(e.to_string()))?; Ok(data) })(); match result { Ok(data) => sender.send(Some(data)), Err(e) => { tracing::error!("problem deserializing UrlMimes: {e}"); sender.send(None) } } }); promise } fn save_to_disk(path: PathBuf, cache: Arc>) { std::thread::spawn(move || { let result: Result<(), Error> = (|| { if let Ok(cache) = cache.read() { let cache = &*cache; let encoded = bincode::serialize(cache).map_err(|e| Error::Generic(e.to_string()))?; let mut file = File::create(&path)?; file.write_all(&encoded)?; file.sync_all()?; tracing::info!("Saved UrlCache to disk."); Ok(()) } else { Err(Error::Generic( "Could not read UrlMimes behind RwLock".to_owned(), )) } })(); if let Err(e) = result { tracing::error!("Failed to save UrlMimes: {}", e); } }); } fn ehttp_get_mime_type(url: &str, sender: poll_promise::Sender) { let request = ehttp::Request::head(url); let url = url.to_owned(); ehttp::fetch( request, move |response: Result| match response { Ok(resp) => { if let Some(content_type) = resp.headers.get("content-type") { sender.send(MimeResult::Ok(extract_mime_type(content_type).to_owned())); } else { sender.send(MimeResult::Err(HttpError::MissingHeader)); tracing::error!("Content-Type header not found for {url}"); } } Err(err) => { sender.send(MimeResult::Err(HttpError::HttpFailure)); tracing::error!("failed ehttp for UrlMimes: {err}"); } }, ); } #[derive(Debug)] enum HttpError { HttpFailure, MissingHeader, } type MimeResult = Result; fn extract_mime_type(content_type: &str) -> &str { content_type .split(';') .next() .unwrap_or(content_type) .trim() } pub struct UrlMimes { pub cache: UrlCache, in_flight: HashMap>, } impl UrlMimes { pub fn new(url_cache: UrlCache) -> Self { Self { cache: url_cache, in_flight: Default::default(), } } pub fn get(&mut self, url: &str) -> Option { if let Some(mime_type) = self.cache.get_type(url) { Some(mime_type) } else if let Some(promise) = self.in_flight.get_mut(url) { if let Some(mime_result) = promise.ready_mut() { match mime_result { Ok(mime_type) => { let mime_type = mime_type.take(); self.cache.set_type(url.to_owned(), mime_type.clone()); self.in_flight.remove(url); Some(mime_type) } Err(HttpError::HttpFailure) => { // allow retrying //self.in_flight.remove(url); None } Err(HttpError::MissingHeader) => { // response was malformed, don't retry None } } } else { None } } else { let (sender, promise) = Promise::new(); ehttp_get_mime_type(url, sender); self.in_flight.insert(url.to_owned(), promise); None } } } #[derive(Debug)] pub struct SupportedMimeType { mime: mime_guess::Mime, } impl SupportedMimeType { pub fn from_extension(extension: &str) -> Result { if let Some(mime) = mime_guess::from_ext(extension) .first() .filter(is_mime_supported) { Ok(Self { mime }) } else { Err(Error::Generic("Unsupported mime type".to_owned())) } } pub fn from_mime(mime: mime_guess::mime::Mime) -> Result { if is_mime_supported(&mime) { Ok(Self { mime }) } else { Err(Error::Generic("Unsupported mime type".to_owned())) } } pub fn to_mime(&self) -> &str { self.mime.essence_str() } pub fn to_cache_type(&self) -> MediaCacheType { if self.mime == mime_guess::mime::IMAGE_GIF { MediaCacheType::Gif } else { MediaCacheType::Image } } } fn is_mime_supported(mime: &mime_guess::Mime) -> bool { mime.type_() == mime_guess::mime::IMAGE } fn url_has_supported_mime(url: &str) -> MimeHostedAtUrl { if let Ok(url) = Url::parse(url) { if let Some(path) = url.path_segments() { if let Some(file_name) = path.last() { if let Some(ext) = std::path::Path::new(file_name) .extension() .and_then(|ext| ext.to_str()) { if let Ok(supported) = SupportedMimeType::from_extension(ext) { return MimeHostedAtUrl::Yes(supported.to_cache_type()); } else { return MimeHostedAtUrl::No; } } } } } MimeHostedAtUrl::Maybe } pub fn supported_mime_hosted_at_url(urls: &mut UrlMimes, url: &str) -> Option { match url_has_supported_mime(url) { MimeHostedAtUrl::Yes(cache_type) => Some(cache_type), MimeHostedAtUrl::Maybe => urls .get(url) .and_then(|s| s.parse::().ok()) .and_then(|mime: mime_guess::mime::Mime| { SupportedMimeType::from_mime(mime) .ok() .map(|s| s.to_cache_type()) }), MimeHostedAtUrl::No => None, } } enum MimeHostedAtUrl { Yes(MediaCacheType), Maybe, No, } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/fonts.rs`: ```rs 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 => 48.0, NotedeckTextStyle::Heading2 => 24.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 => 48.0, NotedeckTextStyle::Heading2 => 24.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) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/filter.rs`: ```rs use crate::error::{Error, FilterError}; use crate::note::NoteRef; use nostrdb::{Filter, FilterBuilder, Note, Subscription}; use std::collections::HashMap; use tracing::{debug, warn}; /// A unified subscription has a local and remote component. The remote subid /// tracks data received remotely, and local #[derive(Debug, Clone)] pub struct UnifiedSubscription { pub local: Subscription, pub remote: String, } /// Each relay can have a different filter state. For example, some /// relays may have the contact list, some may not. Let's capture all of /// these states so that some relays don't stop the states of other /// relays. #[derive(Debug)] pub struct FilterStates { pub initial_state: FilterState, pub states: HashMap, } impl FilterStates { pub fn get_mut(&mut self, relay: &str) -> &FilterState { // if our initial state is ready, then just use that if let FilterState::Ready(_) = self.initial_state { &self.initial_state } else { // otherwise we look at relay states if !self.states.contains_key(relay) { self.states .insert(relay.to_string(), self.initial_state.clone()); } self.states.get(relay).unwrap() } } pub fn get_any_gotremote(&self) -> Option<(&str, Subscription)> { for (k, v) in self.states.iter() { if let FilterState::GotRemote(sub) = v { return Some((k, *sub)); } } None } pub fn get_any_ready(&self) -> Option<&Vec> { if let FilterState::Ready(fs) = &self.initial_state { Some(fs) } else { for (_k, v) in self.states.iter() { if let FilterState::Ready(ref fs) = v { return Some(fs); } } None } } pub fn new(initial_state: FilterState) -> Self { Self { initial_state, states: HashMap::new(), } } pub fn set_relay_state(&mut self, relay: String, state: FilterState) { if self.states.contains_key(&relay) { let current_state = self.states.get(&relay).unwrap(); debug!( "set_relay_state: {:?} -> {:?} on {}", current_state, state, &relay, ); } self.states.insert(relay, state); } } /// We may need to fetch some data from relays before our filter is ready. /// [`FilterState`] tracks this. #[derive(Debug, Clone)] pub enum FilterState { NeedsRemote(Vec), FetchingRemote(UnifiedSubscription), GotRemote(Subscription), Ready(Vec), Broken(FilterError), } impl FilterState { /// We tried to fetch a filter but we wither got no data or the data /// was corrupted, preventing us from getting to the Ready state. /// Just mark the timeline as broken so that we can signal to the /// user that something went wrong pub fn broken(reason: FilterError) -> Self { Self::Broken(reason) } /// The filter is ready pub fn ready(filter: Vec) -> Self { Self::Ready(filter) } /// We need some data from relays before we can continue. Example: /// for home timelines where we don't have a contact list yet. We /// need to fetch the contact list before we have the right timeline /// filter. pub fn needs_remote(filter: Vec) -> Self { Self::NeedsRemote(filter) } /// We got the remote data. Local data should be available to build /// the filter for the [`FilterState::Ready`] state pub fn got_remote(local_sub: Subscription) -> Self { Self::GotRemote(local_sub) } /// We have sent off a remote subscription to get data needed for the /// filter. The string is the subscription id pub fn fetching_remote(sub_id: String, local_sub: Subscription) -> Self { let unified_sub = UnifiedSubscription { local: local_sub, remote: sub_id, }; Self::FetchingRemote(unified_sub) } } pub fn should_since_optimize(limit: u64, num_notes: usize) -> bool { // rough heuristic for bailing since optimization if we don't have enough notes limit as usize <= num_notes } pub fn since_optimize_filter_with(filter: Filter, notes: &[NoteRef], since_gap: u64) -> Filter { // Get the latest entry in the events if notes.is_empty() { return filter; } // get the latest note let latest = notes[0]; let since = latest.created_at - since_gap; filter.since_mut(since) } pub fn since_optimize_filter(filter: Filter, notes: &[NoteRef]) -> Filter { since_optimize_filter_with(filter, notes, 60) } pub fn default_limit() -> u64 { 500 } pub fn default_remote_limit() -> u64 { 250 } pub struct FilteredTags { pub authors: Option, pub hashtags: Option, } impl FilteredTags { pub fn into_follow_filter(self) -> Vec { self.into_filter([1], default_limit()) } // TODO: make this more general pub fn into_filter(self, kinds: I, limit: u64) -> Vec where I: IntoIterator + Copy, { let mut filters: Vec = Vec::with_capacity(2); if let Some(authors) = self.authors { filters.push(authors.kinds(kinds).limit(limit).build()) } if let Some(hashtags) = self.hashtags { filters.push(hashtags.kinds(kinds).limit(limit).build()) } filters } } /// Create a "last N notes per pubkey" query. pub fn last_n_per_pubkey_from_tags( note: &Note, kind: u64, notes_per_pubkey: u64, ) -> Result, Error> { let mut filters: Vec = vec![]; for tag in note.tags() { // TODO: fix arbitrary MAX_FILTER limit in nostrdb if filters.len() == 15 { break; } if tag.count() < 2 { continue; } let t = if let Some(t) = tag.get_unchecked(0).variant().str() { t } else { continue; }; if t == "p" { let author = if let Some(author) = tag.get_unchecked(1).variant().id() { author } else { continue; }; let mut filter = Filter::new(); filter.start_authors_field()?; filter.add_id_element(author)?; filter.end_field(); filters.push(filter.kinds([kind]).limit(notes_per_pubkey).build()); } else if t == "t" { let hashtag = if let Some(hashtag) = tag.get_unchecked(1).variant().str() { hashtag } else { continue; }; let mut filter = Filter::new(); filter.start_tags_field('t')?; filter.add_str_element(hashtag)?; filter.end_field(); filters.push(filter.kinds([kind]).limit(notes_per_pubkey).build()); } } Ok(filters) } /// Create a filter from tags. This can be used to create a filter /// from a contact list pub fn filter_from_tags( note: &Note, add_pubkey: Option<&[u8; 32]>, with_hashtags: bool, ) -> Result { let mut author_filter = Filter::new(); let mut hashtag_filter = Filter::new(); let mut author_res: Option = None; let mut hashtag_res: Option = None; let mut author_count = 0i32; let mut hashtag_count = 0i32; let mut has_added_pubkey = false; let tags = note.tags(); author_filter.start_authors_field()?; hashtag_filter.start_tags_field('t')?; for tag in tags { if tag.count() < 2 { continue; } let t = if let Some(t) = tag.get_unchecked(0).variant().str() { t } else { continue; }; if t == "p" { let author = if let Some(author) = tag.get_unchecked(1).variant().id() { author } else { continue; }; if let Some(pk) = add_pubkey { if author == pk { // we don't need to add it afterwards has_added_pubkey = true; } } author_filter.add_id_element(author)?; author_count += 1; } else if t == "t" && with_hashtags { let hashtag = if let Some(hashtag) = tag.get_unchecked(1).variant().str() { hashtag } else { continue; }; hashtag_filter.add_str_element(hashtag)?; hashtag_count += 1; } } // some additional ad-hoc logic for adding a pubkey if let Some(pk) = add_pubkey { if !has_added_pubkey { author_filter.add_id_element(pk)?; author_count += 1; } } author_filter.end_field(); hashtag_filter.end_field(); if author_count == 0 && hashtag_count == 0 { warn!("no authors or hashtags found in contact list"); return Err(Error::empty_contact_list()); } debug!( "adding {} authors and {} hashtags to contact filter", author_count, hashtag_count ); // if we hit these ooms, we need to expand filter buffer size if author_count > 0 { author_res = Some(author_filter) } if hashtag_count > 0 { hashtag_res = Some(hashtag_filter) } Ok(FilteredTags { authors: author_res, hashtags: hashtag_res, }) } pub fn make_filters_since(raw: &[Filter], since: u64) -> Vec { let mut filters = Vec::with_capacity(raw.len()); for builder in raw { filters.push(Filter::copy_from(builder).since(since).build()); } filters } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/args.rs`: ```rs use std::collections::BTreeSet; use enostr::{Keypair, Pubkey, SecretKey}; use tracing::error; pub struct Args { pub relays: Vec, pub is_mobile: Option, pub keys: Vec, 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, pub datapath: Option, } impl Args { // parse arguments, return set of unrecognized args pub fn parse(args: &[String]) -> (Self, BTreeSet) { let mut unrecognized_args = BTreeSet::new(); let mut res = Args { relays: vec![], is_mobile: None, keys: vec![], light: false, debug: false, relay_debug: false, tests: false, use_keystore: true, dbpath: None, datapath: 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 == "--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 { unrecognized_args.insert(arg.clone()); } i += 1; } (res, unrecognized_args) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/app.rs`: ```rs use crate::persist::{AppSizeHandler, ZoomHandler}; use crate::{ Accounts, AppContext, Args, DataPath, DataPathType, Directory, FileKeyStorage, Images, KeyStorageType, NoteCache, RelayDebugView, ThemeHandler, UnknownIds, }; use egui::ThemePreference; 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 trait App { fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui); } /// Main notedeck app framework pub struct Notedeck { ndb: Ndb, img_cache: Images, unknown_ids: UnknownIds, pool: RelayPool, note_cache: NoteCache, accounts: Accounts, path: DataPath, args: Args, theme: ThemeHandler, app: Option>>, zoom: ZoomHandler, app_size: AppSizeHandler, unrecognized_args: BTreeSet, } fn margin_top(narrow: bool) -> f32 { #[cfg(target_os = "android")] { // FIXME - query the system bar height and adjust more precisely let _ = narrow; // suppress compiler warning on android 40.0 } #[cfg(not(target_os = "android"))] { if narrow { 50.0 } else { 0.0 } } } /// Our chrome, which is basically nothing fn main_panel(style: &egui::Style, narrow: bool) -> egui::CentralPanel { let inner_margin = egui::Margin { top: margin_top(narrow), left: 0.0, right: 0.0, bottom: 0.0, }; egui::CentralPanel::default().frame(egui::Frame { inner_margin, fill: style.visuals.panel_fill, ..Default::default() }) } impl eframe::App for Notedeck { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { #[cfg(feature = "profiling")] puffin::GlobalProfiler::lock().new_frame(); // handle account updates self.accounts.update(&mut self.ndb, &mut self.pool, ctx); main_panel(&ctx.style(), crate::ui::is_narrow(ctx)).show(ctx, |ui| { // render app if let Some(app) = &self.app { let app = app.clone(); app.borrow_mut().update(&mut self.app_context(), ui); } }); 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 = "profiling")] 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 = "profiling")] fn setup_profiling() { puffin::set_scopes_on(true); // tell puffin to collect data } impl Notedeck { pub fn new>(ctx: &egui::Context, data_path: P, args: &[String]) -> Self { #[cfg(feature = "profiling")] setup_profiling(); // 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(4).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); KeyStorageType::FileSystem(FileKeyStorage::new( Directory::new(keys_path), Directory::new(selected_key_path), )) } else { KeyStorageType::None }; let mut accounts = Accounts::new(keystore, parsed_args.relays.clone()); let num_keys = parsed_args.keys.len(); let mut unknown_ids = UnknownIds::default(); let ndb = Ndb::new(&dbpath_str, &config).expect("ndb"); { let txn = Transaction::new(&ndb).expect("txn"); for key in &parsed_args.keys { info!("adding account: {}", &key.pubkey); accounts .add_account(key.clone()) .process_action(&mut unknown_ids, &ndb, &txn); } } if num_keys != 0 { accounts.select_account(0); } // 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 img_cache = Images::new(img_cache_dir); let note_cache = NoteCache::default(); let unknown_ids = UnknownIds::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}"); } Self { ndb, img_cache, unknown_ids, pool, note_cache, accounts, path: path.clone(), args: parsed_args, theme, app: None, zoom, app_size, unrecognized_args, } } pub fn app(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, path: &self.path, args: &self.args, theme: &mut self.theme, } } pub fn set_app(&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 { &self.unrecognized_args } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/result.rs`: ```rs use crate::Error; pub type Result = std::result::Result; ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/persist/app_size.rs`: ```rs use std::time::Duration; use egui::Context; use crate::timed_serializer::TimedSerializer; use crate::{DataPath, DataPathType}; pub struct AppSizeHandler { serializer: TimedSerializer, } 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 { self.serializer.get_item() } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/persist/zoom.rs`: ```rs use crate::{DataPath, DataPathType}; use egui::Context; use crate::timed_serializer::TimedSerializer; pub struct ZoomHandler { serializer: TimedSerializer, } 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 { self.serializer.get_item() } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/persist/theme_handler.rs`: ```rs 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 { match serialized_theme.as_str() { "dark" => Some(ThemePreference::Dark), "light" => Some(ThemePreference::Light), "system" => Some(ThemePreference::System), _ => None, } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/persist/mod.rs`: ```rs mod app_size; mod theme_handler; mod zoom; pub use app_size::AppSizeHandler; pub use theme_handler::ThemeHandler; pub use zoom::ZoomHandler; ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/relayspec.rs`: ```rs 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, 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 { Some(self.url.cmp(&other.url)) } } impl Ord for RelaySpec { fn cmp(&self, other: &Self) -> Ordering { self.url.cmp(&other.url) } } ``` `/Users/jb55/dev/notedeck/crates/notedeck/src/context.rs`: ```rs use crate::{Accounts, Args, DataPath, Images, NoteCache, ThemeHandler, UnknownIds}; 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 path: &'a DataPath, pub args: &'a Args, pub theme: &'a mut ThemeHandler, } ``` `/Users/jb55/dev/notedeck/crates/enostr/Cargo.toml`: ```toml [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 } ``` `/Users/jb55/dev/notedeck/crates/enostr/Cargo.lock`: ```lock # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "aes" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" dependencies = [ "cfg-if", "cipher", "cpufeatures", ] [[package]] name = "aho-corasick" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" dependencies = [ "memchr", ] [[package]] name = "anstream" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" [[package]] name = "anstyle-parse" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" dependencies = [ "anstyle", "windows-sys 0.52.0", ] [[package]] name = "async-stream" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e" dependencies = [ "async-stream-impl", "futures-core", ] [[package]] name = "async-stream-impl" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "bech32" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" [[package]] name = "bip39" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93f2635620bf0b9d4576eb7bb9a38a55df78bd1205d26fa994b25911a69f212f" dependencies = [ "bitcoin_hashes 0.11.0", "serde", "unicode-normalization", ] [[package]] name = "bitcoin" version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1945a5048598e4189e239d3f809b19bdad4845c4b2ba400d304d2dcf26d2c462" dependencies = [ "bech32", "bitcoin-private", "bitcoin_hashes 0.12.0", "hex_lit", "secp256k1", "serde", ] [[package]] name = "bitcoin-private" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" [[package]] name = "bitcoin_hashes" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90064b8dee6815a6470d60bad07bbbaee885c0e12d04177138fa3291a01b7bc4" [[package]] name = "bitcoin_hashes" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d7066118b13d4b20b23645932dfb3a81ce7e29f95726c2036fa33cd7b092501" dependencies = [ "bitcoin-private", "serde", ] [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" [[package]] name = "block-buffer" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" dependencies = [ "generic-array", ] [[package]] name = "block-padding" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ "generic-array", ] [[package]] name = "bumpalo" version = "3.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" [[package]] name = "byteorder" version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" [[package]] name = "cbc" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ "cipher", ] [[package]] name = "cc" version = "1.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f73505338f7d905b19d18738976aae232eb46b8efc15554ffc56deb5d9ebe4" [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chacha20" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", "cpufeatures", ] [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", ] [[package]] name = "colorchoice" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "cpufeatures" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320" dependencies = [ "libc", ] [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", ] [[package]] name = "digest" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" dependencies = [ "block-buffer", "crypto-common", ] [[package]] name = "either" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" [[package]] name = "encoding_rs" version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" dependencies = [ "cfg-if", ] [[package]] name = "enostr" version = "0.1.0" dependencies = [ "env_logger 0.11.1", "ewebsock", "hex", "log", "nostr", "serde", "serde_derive", "serde_json", "shatter", "tracing", ] [[package]] name = "env_filter" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a009aa4810eb158359dda09d0c87378e4bbb89b5a801f016885a4707ba24f7ea" dependencies = [ "log", "regex", ] [[package]] name = "env_logger" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" dependencies = [ "humantime", "is-terminal", "log", "regex", "termcolor", ] [[package]] name = "env_logger" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05e7cf40684ae96ade6232ed84582f40ce0a66efcd43a5117aef610534f8e0b8" dependencies = [ "anstream", "anstyle", "env_filter", "humantime", "log", ] [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" dependencies = [ "errno-dragonfly", "libc", "windows-sys 0.48.0", ] [[package]] name = "errno-dragonfly" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" dependencies = [ "cc", "libc", ] [[package]] name = "ewebsock" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "689197e24a57aee379b3bbef527e70607fc6d4b58fae4f1d98a2c6d91503e230" dependencies = [ "async-stream", "futures", "futures-util", "js-sys", "tokio", "tokio-tungstenite", "tracing", "tungstenite", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] [[package]] name = "futures" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0" dependencies = [ "futures-channel", "futures-core", "futures-executor", "futures-io", "futures-sink", "futures-task", "futures-util", ] [[package]] name = "futures-channel" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" [[package]] name = "futures-executor" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" dependencies = [ "futures-core", "futures-task", "futures-util", ] [[package]] name = "futures-io" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb" [[package]] name = "futures-macro" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdfb8ce053d86b91919aad980c220b1fb8401a9394410e1c289ed7e66b61835d" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "futures-sink" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9" [[package]] name = "futures-task" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" [[package]] name = "futures-util" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" dependencies = [ "futures-channel", "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "generic-array" version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" dependencies = [ "typenum", "version_check", ] [[package]] name = "getrandom" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen", ] [[package]] name = "h2" version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" dependencies = [ "bytes", "fnv", "futures-core", "futures-sink", "futures-util", "http", "indexmap 2.2.2", "slab", "tokio", "tokio-util", "tracing", ] [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" [[package]] name = "hermit-abi" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex_lit" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" [[package]] name = "http" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" dependencies = [ "bytes", "fnv", "itoa", ] [[package]] name = "http-body" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", "http", "pin-project-lite", ] [[package]] name = "httparse" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" [[package]] name = "httpdate" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" version = "0.14.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" dependencies = [ "bytes", "futures-channel", "futures-core", "futures-util", "h2", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "socket2", "tokio", "tower-service", "tracing", "want", ] [[package]] name = "hyper-rustls" version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", "http", "hyper", "rustls 0.21.10", "tokio", "tokio-rustls 0.24.1", ] [[package]] name = "idna" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" dependencies = [ "unicode-bidi", "unicode-normalization", ] [[package]] name = "idna" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", ] [[package]] name = "indexmap" version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", ] [[package]] name = "indexmap" version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824b2ae422412366ba479e8111fd301f7b5faece8149317bb81925979a53f520" dependencies = [ "equivalent", "hashbrown 0.14.3", ] [[package]] name = "inout" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" dependencies = [ "block-padding", "generic-array", ] [[package]] name = "instant" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ "cfg-if", "js-sys", "wasm-bindgen", "web-sys", ] [[package]] name = "ipnet" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" [[package]] name = "is-terminal" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ "hermit-abi", "rustix", "windows-sys 0.48.0", ] [[package]] name = "itoa" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" [[package]] name = "js-sys" version = "0.3.60" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47" dependencies = [ "wasm-bindgen", ] [[package]] name = "libc" version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" [[package]] name = "linux-raw-sys" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" [[package]] name = "log" version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "memchr" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" dependencies = [ "libc", "log", "wasi", "windows-sys 0.42.0", ] [[package]] name = "negentropy" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e664971378a3987224f7a0e10059782035e89899ae403718ee07de85bec42afe" [[package]] name = "nostr" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e47228d958fd65ef3e04650a3b1dd80f16f10f0243c80ed969556dead0f48c8" dependencies = [ "aes", "base64 0.21.7", "bip39", "bitcoin", "cbc", "chacha20", "getrandom", "instant", "js-sys", "negentropy", "once_cell", "reqwest", "serde", "serde_json", "tracing", "url-fork", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] [[package]] name = "num_cpus" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ "hermit-abi", "libc", ] [[package]] name = "once_cell" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "ppv-lite86" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" dependencies = [ "proc-macro2", ] [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom", ] [[package]] name = "regex" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89089e897c013b3deb627116ae56a6955a72b8bed395c9526af31c9fe528b484" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa250384981ea14565685dea16a9ccc4d1c541a13f82b9c168572264d1df8c56" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ab07dc67230e4a4718e70fd5c20055a4334b121f1f9db8fe63ef39ce9b8c846" [[package]] name = "reqwest" version = "0.11.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" dependencies = [ "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", "h2", "http", "http-body", "hyper", "hyper-rustls", "ipnet", "js-sys", "log", "mime", "once_cell", "percent-encoding", "pin-project-lite", "rustls 0.21.10", "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "system-configuration", "tokio", "tokio-rustls 0.24.1", "tokio-socks", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "webpki-roots 0.25.4", "winreg", ] [[package]] name = "ring" version = "0.16.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" dependencies = [ "cc", "libc", "once_cell", "spin 0.5.2", "untrusted 0.7.1", "web-sys", "winapi", ] [[package]] name = "ring" version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babe80d5c16becf6594aa32ad2be8fe08498e7ae60b77de8df700e67f191d7e" dependencies = [ "cc", "getrandom", "libc", "spin 0.9.8", "untrusted 0.9.0", "windows-sys 0.48.0", ] [[package]] name = "rustix" version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac5ffa1efe7548069688cd7028f32591853cd7b5b756d41bcffd2353e4fc75b4" dependencies = [ "bitflags 2.3.3", "errno", "libc", "linux-raw-sys", "windows-sys 0.48.0", ] [[package]] name = "rustls" version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "539a2bfe908f471bfa933876bd1eb6a19cf2176d375f82ef7f99530a40e48c2c" dependencies = [ "log", "ring 0.16.20", "sct", "webpki", ] [[package]] name = "rustls" version = "0.21.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" dependencies = [ "log", "ring 0.17.3", "rustls-webpki", "sct", ] [[package]] name = "rustls-pemfile" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ "base64 0.21.7", ] [[package]] name = "rustls-webpki" version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring 0.17.3", "untrusted 0.9.0", ] [[package]] name = "ryu" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" [[package]] name = "sct" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" dependencies = [ "ring 0.16.20", "untrusted 0.7.1", ] [[package]] name = "secp256k1" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" dependencies = [ "bitcoin_hashes 0.12.0", "rand", "secp256k1-sys", "serde", ] [[package]] name = "secp256k1-sys" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70a129b9e9efbfb223753b9163c4ab3b13cff7fd9c7f010fbac25ab4099fa07e" dependencies = [ "cc", ] [[package]] name = "serde" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "256b9932320c590e707b94576e3cc1f7c9024d0ee6612dfbcf1cb106cbe8e055" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4eae9b04cbffdfd550eb462ed33bc6a1b68c935127d008b27444d08380f94e4" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "020ff22c755c2ed3f8cf162dbb41a7268d934702f3ed3631656ea597e08fc3db" dependencies = [ "indexmap 1.9.3", "itoa", "ryu", "serde", ] [[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", "itoa", "ryu", "serde", ] [[package]] name = "sha-1" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "shatter" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13e1a884c07efb10e6073798fb9ca44ade69959588c6377400f70bbc21e31834" dependencies = [ "env_logger 0.10.0", "log", ] [[package]] name = "slab" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" dependencies = [ "autocfg", ] [[package]] name = "socket2" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd" dependencies = [ "libc", "winapi", ] [[package]] name = "spin" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "syn" version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b9b43d45702de4c839cb9b51d9f529c5dd26a4aff255b42b1ebc03e88ee908" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "sync_wrapper" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] name = "system-configuration" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "termcolor" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" dependencies = [ "winapi-util", ] [[package]] name = "thiserror" version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tinyvec" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46" dependencies = [ "autocfg", "bytes", "libc", "memchr", "mio", "num_cpus", "pin-project-lite", "socket2", "windows-sys 0.42.0", ] [[package]] name = "tokio-rustls" version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" dependencies = [ "rustls 0.20.7", "tokio", "webpki", ] [[package]] name = "tokio-rustls" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ "rustls 0.21.10", "tokio", ] [[package]] name = "tokio-socks" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51165dfa029d2a65969413a6cc96f354b86b464498702f174a4efa13608fd8c0" dependencies = [ "either", "futures-util", "thiserror", "tokio", ] [[package]] name = "tokio-tungstenite" version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181" dependencies = [ "futures-util", "log", "rustls 0.20.7", "tokio", "tokio-rustls 0.23.4", "tungstenite", "webpki", "webpki-roots 0.22.6", ] [[package]] name = "tokio-util" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", "tracing", ] [[package]] name = "tower-service" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" version = "0.1.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" dependencies = [ "cfg-if", "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-attributes" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4017f8f45139870ca7e672686113917c71c7a6e02d4924eda67186083c03081a" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tracing-core" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" dependencies = [ "once_cell", ] [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e27992fd6a8c29ee7eef28fc78349aa244134e10ad447ce3b9f0ac0ed0fa4ce0" dependencies = [ "base64 0.13.1", "byteorder", "bytes", "http", "httparse", "log", "rand", "rustls 0.20.7", "sha-1", "thiserror", "url", "utf-8", "webpki", ] [[package]] name = "typenum" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" [[package]] name = "unicode-bidi" version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-ident" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" [[package]] name = "unicode-normalization" version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" dependencies = [ "tinyvec", ] [[package]] name = "untrusted" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" dependencies = [ "form_urlencoded", "idna 0.3.0", "percent-encoding", ] [[package]] name = "url-fork" version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fa3323c39b8e786154d3000b70ae9af0e9bd746c9791456da0d4a1f68ad89d6" dependencies = [ "form_urlencoded", "idna 0.5.0", "percent-encoding", "serde", ] [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8parse" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "version_check" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "want" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ "try-lock", ] [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268" dependencies = [ "cfg-if", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23639446165ca5a5de86ae1d8896b737ae80319560fbaa4c2887b7da6e7ebd7d" dependencies = [ "cfg-if", "js-sys", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" dependencies = [ "proc-macro2", "quote", "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" [[package]] name = "web-sys" version = "0.3.60" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcda906d8be16e728fd5adc5b729afad4e444e106ab28cd1c7256e54fa61510f" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "webpki" version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" dependencies = [ "ring 0.16.20", "untrusted 0.7.1", ] [[package]] name = "webpki-roots" version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" dependencies = [ "webpki", ] [[package]] name = "webpki-roots" version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" dependencies = [ "winapi", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ "windows_aarch64_gnullvm 0.42.0", "windows_aarch64_msvc 0.42.0", "windows_i686_gnu 0.42.0", "windows_i686_msvc 0.42.0", "windows_x86_64_gnu 0.42.0", "windows_x86_64_gnullvm 0.42.0", "windows_x86_64_msvc 0.42.0", ] [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ "windows-targets 0.48.1", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.0", ] [[package]] name = "windows-targets" version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" dependencies = [ "windows_aarch64_gnullvm 0.48.0", "windows_aarch64_msvc 0.48.0", "windows_i686_gnu 0.48.0", "windows_i686_msvc 0.48.0", "windows_x86_64_gnu 0.48.0", "windows_x86_64_gnullvm 0.48.0", "windows_x86_64_msvc 0.48.0", ] [[package]] name = "windows-targets" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" dependencies = [ "windows_aarch64_gnullvm 0.52.0", "windows_aarch64_msvc 0.52.0", "windows_i686_gnu 0.52.0", "windows_i686_msvc 0.52.0", "windows_x86_64_gnu 0.52.0", "windows_x86_64_gnullvm 0.52.0", "windows_x86_64_msvc 0.52.0", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" [[package]] name = "windows_aarch64_gnullvm" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" [[package]] name = "windows_aarch64_gnullvm" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" [[package]] name = "windows_aarch64_msvc" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" [[package]] name = "windows_aarch64_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" [[package]] name = "windows_aarch64_msvc" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" [[package]] name = "windows_i686_gnu" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" [[package]] name = "windows_i686_gnu" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" [[package]] name = "windows_i686_gnu" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" [[package]] name = "windows_i686_msvc" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" [[package]] name = "windows_i686_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" [[package]] name = "windows_i686_msvc" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" [[package]] name = "windows_x86_64_gnu" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" [[package]] name = "windows_x86_64_gnu" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" [[package]] name = "windows_x86_64_gnu" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" [[package]] name = "windows_x86_64_gnullvm" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" [[package]] name = "windows_x86_64_gnullvm" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" [[package]] name = "windows_x86_64_msvc" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" [[package]] name = "windows_x86_64_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" [[package]] name = "windows_x86_64_msvc" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] name = "winreg" version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ "cfg-if", "windows-sys 0.48.0", ] ``` `/Users/jb55/dev/notedeck/crates/enostr/src/profile.rs`: ```rs use serde_json::Value; #[derive(Debug, Clone)] pub struct Profile(Value); impl Profile { pub fn new(value: Value) -> Profile { Profile(value) } pub fn name(&self) -> Option<&str> { self.0["name"].as_str() } pub fn display_name(&self) -> Option<&str> { self.0["display_name"].as_str() } pub fn lud06(&self) -> Option<&str> { self.0["lud06"].as_str() } pub fn lud16(&self) -> Option<&str> { self.0["lud16"].as_str() } pub fn about(&self) -> Option<&str> { self.0["about"].as_str() } pub fn picture(&self) -> Option<&str> { self.0["picture"].as_str() } pub fn website(&self) -> Option<&str> { self.0["website"].as_str() } } ``` `/Users/jb55/dev/notedeck/crates/enostr/src/pubkey.rs`: ```rs use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::Error; use std::borrow::Borrow; use std::fmt; use std::ops::Deref; use tracing::debug; #[derive(Eq, PartialEq, Clone, Copy, Hash, Ord, PartialOrd)] pub struct Pubkey([u8; 32]); #[derive(Eq, PartialEq, Clone, Copy, Hash, Ord, PartialOrd)] pub struct PubkeyRef<'a>(&'a [u8; 32]); static HRP_NPUB: bech32::Hrp = bech32::Hrp::parse_unchecked("npub"); impl Borrow<[u8; 32]> for PubkeyRef<'_> { fn borrow(&self) -> &[u8; 32] { self.0 } } impl<'a> PubkeyRef<'a> { pub fn new(bytes: &'a [u8; 32]) -> Self { Self(bytes) } pub fn bytes(&self) -> &[u8; 32] { self.0 } pub fn to_owned(&self) -> Pubkey { Pubkey::new(*self.bytes()) } pub fn hex(&self) -> String { hex::encode(self.bytes()) } } impl Deref for Pubkey { type Target = [u8; 32]; fn deref(&self) -> &Self::Target { &self.0 } } impl Borrow<[u8; 32]> for Pubkey { fn borrow(&self) -> &[u8; 32] { &self.0 } } impl Pubkey { pub fn new(data: [u8; 32]) -> Self { Self(data) } pub fn hex(&self) -> String { hex::encode(self.bytes()) } pub fn bytes(&self) -> &[u8; 32] { &self.0 } pub fn as_ref(&self) -> PubkeyRef<'_> { PubkeyRef(self.bytes()) } pub fn parse(s: &str) -> Result { match Pubkey::from_hex(s) { Ok(pk) => Ok(pk), Err(_) => Pubkey::try_from_bech32_string(s, false), } } pub fn from_hex(hex_str: &str) -> Result { Ok(Pubkey(hex::decode(hex_str)?.as_slice().try_into()?)) } pub fn try_from_hex_str_with_verify(hex_str: &str) -> Result { let vec: Vec = hex::decode(hex_str)?; if vec.len() != 32 { Err(Error::HexDecodeFailed) } else { let _ = match nostr::secp256k1::XOnlyPublicKey::from_slice(&vec) { Ok(r) => Ok(r), Err(_) => Err(Error::InvalidPublicKey), }?; Ok(Pubkey(vec.try_into().unwrap())) } } pub fn try_from_bech32_string(s: &str, verify: bool) -> Result { let data = match bech32::decode(s) { Ok(res) => Ok(res), Err(_) => Err(Error::InvalidBech32), }?; if data.0 != HRP_NPUB { Err(Error::InvalidBech32) } else if data.1.len() != 32 { Err(Error::InvalidByteSize) } else { if verify { let _ = match nostr::secp256k1::XOnlyPublicKey::from_slice(&data.1) { Ok(r) => Ok(r), Err(_) => Err(Error::InvalidPublicKey), }?; } Ok(Pubkey(data.1.try_into().unwrap())) } } pub fn to_bech(&self) -> Option { bech32::encode::(HRP_NPUB, &self.0).ok() } } impl fmt::Display for Pubkey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.hex()) } } impl fmt::Debug for PubkeyRef<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.hex()) } } impl fmt::Debug for Pubkey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.hex()) } } impl From for String { fn from(pk: Pubkey) -> Self { pk.hex() } } // Custom serialize function for Pubkey impl Serialize for Pubkey { fn serialize(&self, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&self.hex()) } } // Custom deserialize function for Pubkey impl<'de> Deserialize<'de> for Pubkey { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { debug!("decoding pubkey start"); let s = String::deserialize(deserializer)?; debug!("decoding pubkey {}", &s); Pubkey::from_hex(&s).map_err(serde::de::Error::custom) } } ``` `/Users/jb55/dev/notedeck/crates/enostr/src/error.rs`: ```rs //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, // 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 for Error { fn from(s: String) -> Self { Error::Generic(s) } } impl From for Error { fn from(_e: TryFromSliceError) -> Self { Error::InvalidByteSize } } impl From for Error { fn from(_e: hex::FromHexError) -> Self { Error::HexDecodeFailed } } ``` `/Users/jb55/dev/notedeck/crates/enostr/src/lib.rs`: ```rs mod client; mod error; mod filter; mod keypair; mod note; mod profile; mod pubkey; mod relay; pub use client::{ClientMessage, EventClientMessage}; pub use error::Error; pub use ewebsock; pub use filter::Filter; pub use keypair::{FilledKeypair, FullKeypair, Keypair, SerializableKeypair}; pub use nostr::SecretKey; pub use note::{Note, NoteId}; pub use profile::Profile; pub use pubkey::{Pubkey, PubkeyRef}; pub use relay::message::{RelayEvent, RelayMessage}; pub use relay::pool::{PoolEvent, PoolRelay, RelayPool}; pub use relay::subs_debug::{OwnedRelayEvent, RelayLogEvent, SubsDebug, TransferStats}; pub use relay::{Relay, RelayStatus}; pub type Result = std::result::Result; ``` `/Users/jb55/dev/notedeck/crates/enostr/src/note.rs`: ```rs use crate::{Error, Pubkey}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; use std::hash::{Hash, Hasher}; #[derive(Clone, Copy, Eq, PartialEq, Hash)] pub struct NoteId([u8; 32]); impl fmt::Debug for NoteId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "NoteId({})", self.hex()) } } static HRP_NOTE: bech32::Hrp = bech32::Hrp::parse_unchecked("note"); impl NoteId { pub fn new(bytes: [u8; 32]) -> Self { NoteId(bytes) } pub fn bytes(&self) -> &[u8; 32] { &self.0 } pub fn hex(&self) -> String { hex::encode(self.bytes()) } pub fn from_hex(hex_str: &str) -> Result { let evid = NoteId(hex::decode(hex_str)?.as_slice().try_into().unwrap()); Ok(evid) } pub fn to_bech(&self) -> Option { bech32::encode::(HRP_NOTE, &self.0).ok() } } /// Event is the struct used to represent a Nostr event #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Note { /// 32-bytes sha256 of the the serialized event data pub id: NoteId, /// 32-bytes hex-encoded public key of the event creator pub pubkey: Pubkey, /// unix timestamp in seconds pub created_at: u64, /// integer /// 0: NostrEvent pub kind: u64, /// Tags pub tags: Vec>, /// arbitrary string pub content: String, /// 64-bytes signature of the sha256 hash of the serialized event data, which is the same as the "id" field pub sig: String, } // Implement Hash trait impl Hash for Note { fn hash(&self, state: &mut H) { self.id.0.hash(state); } } impl PartialEq for Note { fn eq(&self, other: &Self) -> bool { self.id == other.id } } impl Eq for Note {} impl Note { pub fn from_json(s: &str) -> Result { serde_json::from_str(s).map_err(Into::into) } pub fn verify(&self) -> Result { Err(Error::InvalidSignature) } /// This is just for serde sanity checking #[allow(dead_code)] pub(crate) fn new_dummy( id: &str, pubkey: &str, created_at: u64, kind: u64, tags: Vec>, content: &str, sig: &str, ) -> Result { Ok(Note { id: NoteId::from_hex(id)?, pubkey: Pubkey::from_hex(pubkey)?, created_at, kind, tags, content: content.to_string(), sig: sig.to_string(), }) } } impl std::str::FromStr for Note { type Err = Error; fn from_str(s: &str) -> Result { Note::from_json(s) } } // Custom serialize function for Pubkey impl Serialize for NoteId { fn serialize(&self, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&self.hex()) } } // Custom deserialize function for Pubkey impl<'de> Deserialize<'de> for NoteId { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; NoteId::from_hex(&s).map_err(serde::de::Error::custom) } } ``` `/Users/jb55/dev/notedeck/crates/enostr/src/filter.rs`: ```rs pub type Filter = nostrdb::Filter; ``` `/Users/jb55/dev/notedeck/crates/enostr/src/relay/message.rs`: ```rs use crate::{Error, Result}; use ewebsock::{WsEvent, WsMessage}; #[derive(Debug, Eq, PartialEq)] pub struct CommandResult<'a> { event_id: &'a str, status: bool, message: &'a str, } pub fn calculate_command_result_size(result: &CommandResult) -> usize { std::mem::size_of_val(result) + result.event_id.len() + result.message.len() } #[derive(Debug, Eq, PartialEq)] pub enum RelayMessage<'a> { OK(CommandResult<'a>), Eose(&'a str), Event(&'a str, &'a str), Notice(&'a str), } #[derive(Debug)] pub enum RelayEvent<'a> { Opened, Closed, Other(&'a WsMessage), Error(Error), Message(RelayMessage<'a>), } impl<'a> From<&'a WsEvent> for RelayEvent<'a> { fn from(event: &'a WsEvent) -> RelayEvent<'a> { match event { WsEvent::Opened => RelayEvent::Opened, WsEvent::Closed => RelayEvent::Closed, WsEvent::Message(ref ws_msg) => ws_msg.into(), WsEvent::Error(s) => RelayEvent::Error(Error::Generic(s.to_owned())), } } } impl<'a> From<&'a WsMessage> for RelayEvent<'a> { fn from(wsmsg: &'a WsMessage) -> RelayEvent<'a> { match wsmsg { WsMessage::Text(s) => match RelayMessage::from_json(s).map(RelayEvent::Message) { Ok(msg) => msg, Err(err) => RelayEvent::Error(err), }, wsmsg => RelayEvent::Other(wsmsg), } } } impl<'a> RelayMessage<'a> { pub fn eose(subid: &'a str) -> Self { RelayMessage::Eose(subid) } pub fn notice(msg: &'a str) -> Self { RelayMessage::Notice(msg) } pub fn ok(event_id: &'a str, status: bool, message: &'a str) -> Self { RelayMessage::OK(CommandResult { event_id, status, message, }) } pub fn event(ev: &'a str, sub_id: &'a str) -> Self { RelayMessage::Event(sub_id, ev) } pub fn from_json(msg: &'a str) -> Result> { if msg.is_empty() { return Err(Error::Empty); } // make sure we can inspect the begning of the message below ... if msg.len() < 12 { return Err(Error::DecodeFailed("message too short".into())); } // Notice // Relay response format: ["NOTICE", ] if msg.len() >= 12 && &msg[0..=9] == "[\"NOTICE\"," { // TODO: there could be more than one space, whatever let start = if msg.as_bytes().get(10).copied() == Some(b' ') { 12 } else { 11 }; let end = msg.len() - 2; return Ok(Self::notice(&msg[start..end])); } // Event // Relay response format: ["EVENT", , ] if &msg[0..=7] == "[\"EVENT\"" { let mut start = 9; while let Some(&b' ') = msg.as_bytes().get(start) { start += 1; // Move past optional spaces } if let Some(comma_index) = msg[start..].find(',') { let subid_end = start + comma_index; let subid = &msg[start..subid_end].trim().trim_matches('"'); return Ok(Self::event(msg, subid)); } else { return Err(Error::DecodeFailed("Invalid EVENT format".into())); } } // EOSE (NIP-15) // Relay response format: ["EOSE", ] if &msg[0..=7] == "[\"EOSE\"," { let start = if msg.as_bytes().get(8).copied() == Some(b' ') { 10 // Skip space after the comma } else { 9 // Start immediately after the comma }; // Use rfind to locate the last quote if let Some(end_bracket_index) = msg.rfind(']') { let end = end_bracket_index - 1; // Account for space before bracket if start < end { // Trim subscription id and remove extra spaces and quotes let subid = &msg[start..end].trim().trim_matches('"').trim(); return Ok(RelayMessage::eose(subid)); } } return Err(Error::DecodeFailed( "Invalid subscription ID or format".into(), )); } // OK (NIP-20) // Relay response format: ["OK",, , ] if &msg[0..=5] == "[\"OK\"," && msg.len() >= 78 { let event_id = &msg[7..71]; let booly = &msg[73..77]; let status: bool = if booly == "true" { true } else if booly == "false" { false } else { return Err(Error::DecodeFailed("bad boolean value".into())); }; let message_start = msg.rfind(',').unwrap() + 1; let message = &msg[message_start..msg.len() - 2].trim().trim_matches('"'); return Ok(Self::ok(event_id, status, message)); } Err(Error::DecodeFailed("unrecognized message type".into())) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_handle_various_messages() -> Result<()> { let tests = vec![ // Valid cases ( // shortest valid message r#"["EOSE","x"]"#, Ok(RelayMessage::eose("x")), ), ( // also very short r#"["NOTICE",""]"#, Ok(RelayMessage::notice("")), ), ( r#"["NOTICE","Invalid event format!"]"#, Ok(RelayMessage::notice("Invalid event format!")), ), ( r#"["EVENT", "random_string", {"id":"example","content":"test"}]"#, Ok(RelayMessage::event( r#"["EVENT", "random_string", {"id":"example","content":"test"}]"#, "random_string", )), ), ( r#"["EOSE","random-subscription-id"]"#, Ok(RelayMessage::eose("random-subscription-id")), ), ( r#"["EOSE", "random-subscription-id"]"#, Ok(RelayMessage::eose("random-subscription-id")), ), ( r#"["EOSE", "random-subscription-id" ]"#, Ok(RelayMessage::eose("random-subscription-id")), ), ( r#"["OK","b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30",true,"pow: difficulty 25>=24"]"#, Ok(RelayMessage::ok( "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, "pow: difficulty 25>=24", )), ), // Invalid cases ( r#"["EVENT","random_string"]"#, Err(Error::DecodeFailed("Invalid EVENT format".into())), ), ( r#"["EOSE"]"#, Err(Error::DecodeFailed("message too short".into())), ), ( r#"["NOTICE"]"#, Err(Error::DecodeFailed("message too short".into())), ), ( r#"["NOTICE": 404]"#, Err(Error::DecodeFailed("unrecognized message type".into())), ), ( r#"["OK","event_id"]"#, Err(Error::DecodeFailed("unrecognized message type".into())), ), ( r#"["OK","b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30"]"#, Err(Error::DecodeFailed("unrecognized message type".into())), ), ( r#"["OK","b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30",hello,""]"#, Err(Error::DecodeFailed("bad boolean value".into())), ), ( r#"["OK","b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30",hello,404]"#, Err(Error::DecodeFailed("bad boolean value".into())), ), ]; for (input, expected) in tests { match expected { Ok(expected_msg) => { let result = RelayMessage::from_json(input); assert_eq!( result?, expected_msg, "Expected {:?} for input: {}", expected_msg, input ); } Err(expected_err) => { let result = RelayMessage::from_json(input); assert!( matches!(result, Err(ref e) if *e.to_string() == expected_err.to_string()), "Expected error {:?} for input: {}, but got: {:?}", expected_err, input, result ); } } } Ok(()) } /* #[test] fn test_handle_valid_event() -> Result<()> { use tracing::debug; let valid_event_msg = r#"["EVENT", "random_string", {"id":"70b10f70c1318967eddf12527799411b1a9780ad9c43858f5e5fcd45486a13a5","pubkey":"379e863e8357163b5bce5d2688dc4f1dcc2d505222fb8d74db600f30535dfdfe","created_at":1612809991,"kind":1,"tags":[],"content":"test","sig":"273a9cd5d11455590f4359500bccb7a89428262b96b3ea87a756b770964472f8c3e87f5d5e64d8d2e859a71462a3f477b554565c4f2f326cb01dd7620db71502"}]"#; let id = "70b10f70c1318967eddf12527799411b1a9780ad9c43858f5e5fcd45486a13a5"; let pubkey = "379e863e8357163b5bce5d2688dc4f1dcc2d505222fb8d74db600f30535dfdfe"; let created_at = 1612809991; let kind = 1; let tags = vec![]; let content = "test"; let sig = "273a9cd5d11455590f4359500bccb7a89428262b96b3ea87a756b770964472f8c3e87f5d5e64d8d2e859a71462a3f477b554565c4f2f326cb01dd7620db71502"; let handled_event = Note::new_dummy(id, pubkey, created_at, kind, tags, content, sig).expect("ev"); debug!("event {:?}", handled_event); let msg = RelayMessage::from_json(valid_event_msg).expect("valid json"); debug!("msg {:?}", msg); let note_json = serde_json::to_string(&handled_event).expect("json ev"); assert_eq!( msg, RelayMessage::event(¬e_json, "random_string") ); Ok(()) } #[test] fn test_handle_invalid_event() { //Mising Event field let invalid_event_msg = r#"["EVENT","random_string"]"#; //Event JSON with incomplete content let invalid_event_msg_content = r#"["EVENT","random_string",{"id":"70b10f70c1318967eddf12527799411b1a9780ad9c43858f5e5fcd45486a13a5","pubkey":"379e863e8357163b5bce5d2688dc4f1dcc2d505222fb8d74db600f30535dfdfe"}]"#; assert!(matches!( RelayMessage::from_json(invalid_event_msg).unwrap_err(), Error::DecodeFailed )); assert!(matches!( RelayMessage::from_json(invalid_event_msg_content).unwrap_err(), Error::DecodeFailed )); } */ // TODO: fix these tests /* #[test] fn test_handle_invalid_eose() { // Missing subscription ID assert!(matches!( RelayMessage::from_json(r#"["EOSE"]"#).unwrap_err(), Error::DecodeFailed )); // The subscription ID is not string assert!(matches!( RelayMessage::from_json(r#"["EOSE",404]"#).unwrap_err(), Error::DecodeFailed )); } #[test] fn test_handle_valid_ok() -> Result<()> { let valid_ok_msg = r#"["OK","b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30",true,"pow: difficulty 25>=24"]"#; let handled_valid_ok_msg = RelayMessage::ok( "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, "pow: difficulty 25>=24".into(), ); assert_eq!(RelayMessage::from_json(valid_ok_msg)?, handled_valid_ok_msg); Ok(()) } */ } ``` `/Users/jb55/dev/notedeck/crates/enostr/src/relay/subs_debug.rs`: ```rs 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, time_incd: SystemTime, pub relay_events_selection: Option, } #[derive(Default)] pub struct RelayStats { pub count: TransferStats, pub events: Vec, pub sub_data: HashMap, } #[derive(Clone)] pub enum RelayLogEvent { Send(ClientMessage), Recieve(OwnedRelayEvent), } #[derive(Clone)] pub enum OwnedRelayEvent { Opened, Closed, Other(String), Error(String), Message(String), } impl From> 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 { &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::() } 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::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) -> 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 } ``` `/Users/jb55/dev/notedeck/crates/enostr/src/relay/mod.rs`: ```rs 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 { 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 = 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 { 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: String, 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(&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: String, wakeup: impl Fn() + Send + Sync + 'static) -> Result { let status = RelayStatus::Connecting; let (sender, receiver) = ewebsock::connect_with_wakeup(&url, 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, 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); } } ``` `/Users/jb55/dev/notedeck/crates/enostr/src/relay/pool.rs`: ```rs use crate::relay::{setup_multicast_relay, MulticastRelay, Relay, RelayStatus}; use crate::{ClientMessage, 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, 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 { 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) -> 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 { 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, pub ping_rate: Duration, pub debug: Option, } 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 { 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) { 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(url, wakeup)?; let pool_relay = PoolRelay::websocket(relay); self.relays.push(pool_relay); Ok(()) } pub fn add_urls( &mut self, urls: BTreeSet, 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) { 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> { 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 } } ``` `/Users/jb55/dev/notedeck/crates/enostr/src/client/message.rs`: ```rs use crate::Error; use nostrdb::{Filter, Note}; use serde_json::json; #[derive(Debug, Clone)] pub struct EventClientMessage { pub note_json: String, } impl EventClientMessage { pub fn to_json(&self) -> String { format!("[\"EVENT\", {}]", self.note_json) } } /// Messages sent by clients, received by relays #[derive(Debug, Clone)] pub enum ClientMessage { Event(EventClientMessage), Req { sub_id: String, filters: Vec, }, Close { sub_id: String, }, Raw(String), } impl ClientMessage { pub fn event(note: Note) -> Result { Ok(ClientMessage::Event(EventClientMessage { note_json: note.json()?, })) } pub fn raw(raw: String) -> Self { ClientMessage::Raw(raw) } pub fn req(sub_id: String, filters: Vec) -> Self { ClientMessage::Req { sub_id, filters } } pub fn close(sub_id: String) -> Self { ClientMessage::Close { sub_id } } pub fn to_json(&self) -> Result { Ok(match self { Self::Event(ecm) => ecm.to_json(), Self::Raw(raw) => raw.clone(), Self::Req { sub_id, filters } => { if filters.is_empty() { format!("[\"REQ\",\"{}\",{{ }}]", sub_id) } else if filters.len() == 1 { let filters_json_str = filters[0].json()?; format!("[\"REQ\",\"{}\",{}]", sub_id, filters_json_str) } else { let filters_json_str: Result, Error> = filters .iter() .map(|f| f.json().map_err(Into::::into)) .collect(); format!("[\"REQ\",\"{}\",{}]", sub_id, filters_json_str?.join(",")) } } Self::Close { sub_id } => json!(["CLOSE", sub_id]).to_string(), }) } } ``` `/Users/jb55/dev/notedeck/crates/enostr/src/client/mod.rs`: ```rs mod message; pub use message::{ClientMessage, EventClientMessage}; ``` `/Users/jb55/dev/notedeck/crates/enostr/src/keypair.rs`: ```rs use nostr::nips::nip49::EncryptedSecretKey; use serde::Deserialize; use serde::Serialize; use crate::Pubkey; use crate::SecretKey; #[derive(Debug, Eq, PartialEq, Clone)] pub struct Keypair { pub pubkey: Pubkey, pub secret_key: Option, } 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) -> Self { Keypair { pubkey, secret_key } } pub fn only_pubkey(pubkey: Pubkey) -> Self { Keypair { pubkey, secret_key: None, } } pub fn to_full(&self) -> Option> { 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 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()", 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: ", self.pubkey) } } #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct SerializableKeypair { pub pubkey: Pubkey, pub encrypted_secret_key: Option, } 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()), ) } } ``` `/Users/jb55/dev/notedeck/crates/tokenator/Cargo.toml`: ```toml [package] name = "tokenator" version = "0.1.0" edition = "2021" description = "A simple library for parsing a serializing string tokens" [dependencies] hex = { workspace = true } ``` `/Users/jb55/dev/notedeck/crates/tokenator/README.md`: ```md # tokenator Tokenator is a simple string token parser and serializer. ``` `/Users/jb55/dev/notedeck/crates/tokenator/src/lib.rs`: ```rs #[derive(Debug, Clone)] pub struct UnexpectedToken<'fnd, 'exp> { pub expected: &'exp str, pub found: &'fnd str, } #[derive(Debug, Clone)] pub enum ParseError<'a> { /// Not done parsing yet Incomplete, /// All parsing options failed AltAllFailed, /// There was some issue decoding the data DecodeFailed, HexDecodeFailed, /// We encountered an unexpected token UnexpectedToken(UnexpectedToken<'a, 'static>), /// No more tokens EOF, } pub struct TokenWriter { delim: &'static str, tokens_written: usize, buf: Vec, } impl Default for TokenWriter { fn default() -> Self { Self::new(":") } } impl TokenWriter { pub fn new(delim: &'static str) -> Self { let buf = vec![]; let tokens_written = 0; Self { buf, tokens_written, delim, } } pub fn write_token(&mut self, token: &str) { if self.tokens_written > 0 { self.buf.extend_from_slice(self.delim.as_bytes()) } self.buf.extend_from_slice(token.as_bytes()); self.tokens_written += 1; } pub fn str(&self) -> &str { // SAFETY: only &strs are ever serialized, so its guaranteed to be // correct here unsafe { std::str::from_utf8_unchecked(self.buffer()) } } pub fn buffer(&self) -> &[u8] { &self.buf } } #[derive(Clone)] pub struct TokenParser<'a> { tokens: &'a [&'a str], index: usize, } impl<'a> TokenParser<'a> { /// alt tries each parser in `routes` until one succeeds. /// If all fail, returns `ParseError::AltAllFailed`. #[allow(clippy::type_complexity)] pub fn alt( parser: &mut TokenParser<'a>, routes: &[fn(&mut TokenParser<'a>) -> Result>], ) -> Result> { let start = parser.index; for route in routes { match route(parser) { Ok(r) => return Ok(r), // if success, stop trying more routes Err(_) => { // revert index & try next route parser.index = start; } } } // if we tried them all and none succeeded Err(ParseError::AltAllFailed) } pub fn new(tokens: &'a [&'a str]) -> Self { let index = 0; Self { tokens, index } } pub fn peek_parse_token(&mut self, expected: &'static str) -> Result<&'a str, ParseError<'a>> { let found = self.peek_token()?; if found == expected { Ok(found) } else { Err(ParseError::UnexpectedToken(UnexpectedToken { expected, found, })) } } /// Parse a list of alternative tokens, returning success if any match. pub fn parse_any_token( &mut self, expected: &[&'static str], ) -> Result<&'a str, ParseError<'a>> { for token in expected { let result = self.try_parse(|p| p.parse_token(token)); if result.is_ok() { return result; } } Err(ParseError::AltAllFailed) } pub fn parse_token(&mut self, expected: &'static str) -> Result<&'a str, ParseError<'a>> { let found = self.pull_token()?; if found == expected { Ok(found) } else { Err(ParseError::UnexpectedToken(UnexpectedToken { expected, found, })) } } /// Ensure that we have parsed all tokens. If not the parser backtracks /// and the parse does not succeed, returning [`ParseError::Incomplete`]. pub fn parse_all( &mut self, parse_fn: impl FnOnce(&mut Self) -> Result>, ) -> Result> { let start = self.index; let result = parse_fn(self); // If the parser closure fails, revert the index if result.is_err() { self.index = start; result } else if !self.is_eof() { Err(ParseError::Incomplete) } else { result } } /// Attempt to parse something, backtrack if we fail. pub fn try_parse( &mut self, parse_fn: impl FnOnce(&mut Self) -> Result>, ) -> Result> { let start = self.index; let result = parse_fn(self); // If the parser closure fails, revert the index if result.is_err() { self.index = start; result } else { result } } pub fn pull_token(&mut self) -> Result<&'a str, ParseError<'a>> { let token = self .tokens .get(self.index) .copied() .ok_or(ParseError::EOF)?; self.index += 1; Ok(token) } pub fn unpop_token(&mut self) { if (self.index as isize) - 1 < 0 { return; } self.index -= 1; } pub fn peek_token(&self) -> Result<&'a str, ParseError<'a>> { self.tokens() .first() .ok_or(ParseError::DecodeFailed) .copied() } #[inline] pub fn tokens(&self) -> &'a [&'a str] { let min_index = self.index.min(self.tokens.len()); &self.tokens[min_index..] } #[inline] pub fn is_eof(&self) -> bool { self.tokens().is_empty() } } pub trait TokenSerializable: Sized { /// Return a list of serialization plans for a type. We do this for /// type safety and assume constructing these types are lightweight fn parse_from_tokens<'a>(parser: &mut TokenParser<'a>) -> Result>; fn serialize_tokens(&self, writer: &mut TokenWriter); } /// Parse a 32 byte hex string pub fn parse_hex_id<'a>(parser: &mut TokenParser<'a>) -> Result<[u8; 32], ParseError<'a>> { use hex; let hexid = parser.pull_token()?; hex::decode(hexid) .map_err(|_| ParseError::HexDecodeFailed)? .as_slice() .try_into() .map_err(|_| ParseError::HexDecodeFailed) } ``` `/Users/jb55/dev/notedeck/index.html`: ```html domus ``` `/Users/jb55/dev/notedeck/LICENSE`: ``` Damus Notedeck, a multiplatform nostr client Copyright (C) 2024 William Casarin This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . ``` `/Users/jb55/dev/notedeck/CHANGELOG.md`: ```md # Unreleased ## Added - Added the ability to tag users in posts (kernelkind) - Added ctrl-enter shortcut to send post (Ethan Tuttle) ## Changed - Updated NIP-05 verification text to "Nostr address" (Derek Ross) ## Fixed - Added *experimental* last post per pubkey algo feeds (William Casarin) - Fixed stale feeds (William Casarin) # 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) ``` `/Users/jb55/dev/notedeck/Makefile`: ``` .DEFAULT_GOAL := check .PHONY: fake ANDROID_DIR := crates/notedeck_chrome/android check: cargo check tags: fake rusty-tags vi jni: fake cargo ndk --target arm64-v8a -o $(ANDROID_DIR)/app/src/main/jniLibs/ build --profile release apk: jni cd $(ANDROID_DIR) && ./gradlew build android: jni cd $(ANDROID_DIR) && ./gradlew installDebug adb shell am start -n com.damus.notedeck/.MainActivity adb logcat -v color -s notedeck RustStdoutStderr ``` `/Users/jb55/dev/notedeck/entitlements.plist`: ```plist com.apple.security.app-sandbox com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.network.client ``` `/Users/jb55/dev/notedeck/shell.nix`: ```nix { pkgs ? import { } , android ? "https://github.com/tadfisher/android-nixpkgs/archive/refs/tags/2025-01-27.tar.gz" , use_android ? true , android_emulator ? false }: with pkgs; let x11libs = lib.makeLibraryPath [ xorg.libX11 xorg.libXcursor xorg.libXrandr xorg.libXi libglvnd vulkan-loader vulkan-validation-layers libxkbcommon wayland ]; in mkShell ({ nativeBuildInputs = [ #cargo-udeps #cargo-edit #cargo-watch rustup libiconv pkg-config #cmake fontconfig gradle #gtk3 #gsettings-desktop-schemas #brotli #wabt #gdb #heaptrack ] ++ lib.optionals (!stdenv.isDarwin) [ zenity ] ++ lib.optionals use_android [ jre openssl libiconv cargo-apk ] ++ lib.optional stdenv.isDarwin [ darwin.apple_sdk.frameworks.Security darwin.apple_sdk.frameworks.OpenGL darwin.apple_sdk.frameworks.CoreServices darwin.apple_sdk.frameworks.AppKit ]; } // ( lib.optionalAttrs (!stdenv.isDarwin) { LD_LIBRARY_PATH = "${x11libs}"; #XDG_DATA_DIRS = "${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}"; } ) // ( lib.optionalAttrs use_android ( let android-nixpkgs = callPackage (fetchTarball android) { }; #ndk-version = "24.0.8215888"; ndk-version = "27.2.12479018"; android-sdk = android-nixpkgs.sdk (sdkPkgs: with sdkPkgs; [ cmdline-tools-latest build-tools-34-0-0 platform-tools platforms-android-30 ndk-27-2-12479018 #ndk-24-0-8215888 ] ++ lib.optional android_emulator [ emulator ]); android-sdk-path = "${android-sdk.out}/share/android-sdk"; android-ndk-path = "${android-sdk-path}/ndk/${ndk-version}"; in { buildInputs = [ android-sdk ]; ANDROID_NDK_ROOT = android-ndk-path; GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt}/bin/aapt2"; } ) )) ``` `/Users/jb55/dev/notedeck/Cargo.lock`: ```lock # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "ab_glyph" version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec3672c180e71eeaaac3a541fbbc5f5ad4def8b747c595ad30d674e43049f7b0" dependencies = [ "ab_glyph_rasterizer", "owned_ttf_parser", ] [[package]] name = "ab_glyph_rasterizer" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046" [[package]] name = "accesskit" version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99b76d84ee70e30a4a7e39ab9018e2b17a6a09e31084176cc7c0b2dec036ba45" dependencies = [ "enumn", "serde", ] [[package]] name = "addr2line" version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] [[package]] name = "adler2" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "aead" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common", "generic-array", ] [[package]] name = "ahash" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", "getrandom", "once_cell", "serde", "version_check", "zerocopy", ] [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "aligned-vec" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android-activity" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64529721f27c2314ced0890ce45e469574a73e5e6fdd6e9da1860eb29285f5e0" dependencies = [ "android-properties", "bitflags 1.3.2", "cc", "jni-sys", "libc", "log", "ndk 0.7.0", "ndk-context", "ndk-sys 0.4.1+23.1.7779620", "num_enum 0.6.1", ] [[package]] name = "android-activity" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" dependencies = [ "android-properties", "bitflags 2.6.0", "cc", "cesu8", "jni", "jni-sys", "libc", "log", "ndk 0.9.0", "ndk-context", "ndk-sys 0.6.0+11769913", "num_enum 0.7.3", "thiserror 1.0.69", ] [[package]] name = "android-properties" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anyhow" version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "arbitrary" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" [[package]] name = "arboard" version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df099ccb16cd014ff054ac1bf392c67feeef57164b05c42f037cd40f5d4357f4" dependencies = [ "clipboard-win", "log", "objc2", "objc2-app-kit", "objc2-foundation", "parking_lot", "x11rb", ] [[package]] name = "arg_enum_proc_macro" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "arrayref" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "as-raw-xcb-connection" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] name = "ash" version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ "libloading", ] [[package]] name = "ashpd" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9c39d707614dbcc6bed00015539f488d8e3fe3e66ed60961efc0c90f4b380b3" dependencies = [ "async-fs", "async-net", "enumflags2", "futures-channel", "futures-util", "rand", "raw-window-handle 0.6.2", "serde", "serde_repr", "url", "wayland-backend", "wayland-client", "wayland-protocols", "zbus", ] [[package]] name = "async-broadcast" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ "event-listener", "event-listener-strategy", "futures-core", "pin-project-lite", ] [[package]] name = "async-channel" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" dependencies = [ "concurrent-queue", "event-listener-strategy", "futures-core", "pin-project-lite", ] [[package]] name = "async-executor" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" dependencies = [ "async-task", "concurrent-queue", "fastrand", "futures-lite", "slab", ] [[package]] name = "async-fs" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" dependencies = [ "async-lock", "blocking", "futures-lite", ] [[package]] name = "async-io" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" dependencies = [ "async-lock", "cfg-if", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", "rustix", "slab", "tracing", "windows-sys 0.59.0", ] [[package]] name = "async-lock" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" dependencies = [ "event-listener", "event-listener-strategy", "pin-project-lite", ] [[package]] name = "async-net" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ "async-io", "blocking", "futures-lite", ] [[package]] name = "async-process" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" dependencies = [ "async-channel", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", "event-listener", "futures-lite", "rustix", "tracing", ] [[package]] name = "async-recursion" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "async-signal" version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" dependencies = [ "async-io", "async-lock", "atomic-waker", "cfg-if", "futures-core", "futures-io", "rustix", "signal-hook-registry", "slab", "windows-sys 0.59.0", ] [[package]] name = "async-task" version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "av1-grain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf" dependencies = [ "anyhow", "arrayvec", "log", "nom", "num-rational", "v_frame", ] [[package]] name = "avif-serialize" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e335041290c43101ca215eed6f43ec437eb5a42125573f600fc3fa42b9bddd62" dependencies = [ "arrayvec", ] [[package]] name = "backtrace" version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", "windows-targets 0.52.6", ] [[package]] name = "base32" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" [[package]] name = "base58ck" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ "bitcoin-internals 0.3.0", "bitcoin_hashes 0.14.0", ] [[package]] name = "base64" version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bech32" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" [[package]] name = "bincode" version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ "serde", ] [[package]] name = "bindgen" version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", "itertools 0.12.1", "lazy_static", "lazycell", "log", "prettyplease", "proc-macro2", "quote", "regex", "rustc-hash", "shlex", "syn 2.0.90", "which", ] [[package]] name = "bip39" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387" dependencies = [ "bitcoin_hashes 0.13.0", "serde", "unicode-normalization", ] [[package]] name = "bit-set" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" [[package]] name = "bit_field" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" [[package]] name = "bitcoin" version = "0.32.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" dependencies = [ "base58ck", "bech32", "bitcoin-internals 0.3.0", "bitcoin-io", "bitcoin-units", "bitcoin_hashes 0.14.0", "hex-conservative 0.2.1", "hex_lit", "secp256k1", "serde", ] [[package]] name = "bitcoin-internals" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" [[package]] name = "bitcoin-internals" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" dependencies = [ "serde", ] [[package]] name = "bitcoin-io" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" [[package]] name = "bitcoin-units" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" dependencies = [ "bitcoin-internals 0.3.0", "serde", ] [[package]] name = "bitcoin_hashes" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" dependencies = [ "bitcoin-internals 0.2.0", "hex-conservative 0.1.2", ] [[package]] name = "bitcoin_hashes" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" dependencies = [ "bitcoin-io", "hex-conservative 0.2.1", "serde", ] [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bitstream-io" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" [[package]] name = "block" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "block-padding" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ "generic-array", ] [[package]] name = "block2" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ "objc2", ] [[package]] name = "blocking" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" dependencies = [ "async-channel", "async-task", "futures-io", "futures-lite", "piper", ] [[package]] name = "built" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c360505aed52b7ec96a3636c3f039d99103c37d1d9b4f7a8c743d3ea9ffcd03b" [[package]] name = "bumpalo" version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytemuck" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcfcc3cd946cb52f0bbfdbbcfa2f4e24f75ebb6c0e1002f7c25904fada18b9ec" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "byteorder-lite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "calloop" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ "bitflags 2.6.0", "log", "polling", "rustix", "slab", "thiserror 1.0.69", ] [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ "calloop", "rustix", "wayland-backend", "wayland-client", ] [[package]] name = "cbc" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ "cipher", ] [[package]] name = "cc" version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" dependencies = [ "jobserver", "libc", "shlex", ] [[package]] name = "cesu8" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ "nom", ] [[package]] name = "cfg-expr" version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", "target-lexicon", ] [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "cfg_aliases" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" [[package]] name = "cfg_aliases" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", "cpufeatures", ] [[package]] name = "chacha20poly1305" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20", "cipher", "poly1305", "zeroize", ] [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", "zeroize", ] [[package]] name = "clang-sys" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", "libloading", ] [[package]] name = "clipboard-win" version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" dependencies = [ "error-code", ] [[package]] name = "codespan-reporting" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ "termcolor", "unicode-width", ] [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "com" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" dependencies = [ "com_macros", ] [[package]] name = "com_macros" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" dependencies = [ "com_macros_support", "proc-macro2", "syn 1.0.109", ] [[package]] name = "com_macros_support" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", ] [[package]] name = "combine" version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", "memchr", ] [[package]] name = "concurrent-queue" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ "crossbeam-utils", ] [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", ] [[package]] name = "core-graphics-types" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "libc", ] [[package]] name = "cpufeatures" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" dependencies = [ "libc", ] [[package]] name = "crc32fast" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" dependencies = [ "crossbeam-epoch", "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "crunchy" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "rand_core", "typenum", ] [[package]] name = "cursor-icon" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" [[package]] name = "data-encoding" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" [[package]] name = "data-url" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" [[package]] name = "deranged" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", ] [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", "subtle", ] [[package]] name = "dirs" version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", "redox_users", "windows-sys 0.48.0", ] [[package]] name = "dispatch" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" [[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "dlib" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ "libloading", ] [[package]] name = "document-features" version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0" dependencies = [ "litrs", ] [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dpi" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" [[package]] name = "ecolor" version = "0.29.1" source = "git+https://github.com/damus-io/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" dependencies = [ "bytemuck", "emath", "serde", ] [[package]] name = "eframe" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" dependencies = [ "ahash", "bytemuck", "document-features", "egui", "egui-wgpu", "egui-winit", "egui_glow", "image", "js-sys", "log", "objc2", "objc2-app-kit", "objc2-foundation", "parking_lot", "percent-encoding", "pollster 0.3.0", "puffin 0.19.1 (registry+https://github.com/rust-lang/crates.io-index)", "raw-window-handle 0.6.2", "static_assertions", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "web-time 1.1.0", "wgpu", "winapi", "windows-sys 0.52.0", "winit", ] [[package]] name = "egui" version = "0.29.1" source = "git+https://github.com/damus-io/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" dependencies = [ "accesskit", "ahash", "backtrace", "emath", "epaint", "log", "nohash-hasher", "puffin 0.19.1 (registry+https://github.com/rust-lang/crates.io-index)", "serde", ] [[package]] name = "egui-wgpu" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d00fd5d06d8405397e64a928fa0ef3934b3c30273ea7603e3dc4627b1f7a1a82" dependencies = [ "ahash", "bytemuck", "document-features", "egui", "epaint", "log", "puffin 0.19.1 (registry+https://github.com/rust-lang/crates.io-index)", "thiserror 1.0.69", "type-map", "web-time 1.1.0", "wgpu", "winit", ] [[package]] name = "egui-winit" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" dependencies = [ "ahash", "arboard", "egui", "log", "puffin 0.19.1 (registry+https://github.com/rust-lang/crates.io-index)", "raw-window-handle 0.6.2", "smithay-clipboard", "web-time 1.1.0", "webbrowser", "winit", ] [[package]] name = "egui_extras" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1" dependencies = [ "ahash", "egui", "ehttp", "enum-map", "image", "log", "mime_guess2", "resvg", "serde", ] [[package]] name = "egui_glow" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" dependencies = [ "ahash", "bytemuck", "egui", "egui-winit", "glow 0.14.2", "log", "memoffset", "puffin 0.19.1 (registry+https://github.com/rust-lang/crates.io-index)", "wasm-bindgen", "web-sys", "winit", ] [[package]] name = "egui_nav" version = "0.2.0" source = "git+https://github.com/damus-io/egui-nav?rev=ac7d663307b76634757024b438dd4b899790da99#ac7d663307b76634757024b438dd4b899790da99" dependencies = [ "egui", "egui_extras", ] [[package]] name = "egui_tabs" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ff9e00113ffe8f10d36302c383f2c0ad01ec388479a30851bfda5bcc446a840" dependencies = [ "egui", "egui_extras", ] [[package]] name = "egui_virtual_list" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8191e9cbf6cc7b111655fc115725505fc09d7698c04c36897950c46d6e69521b" dependencies = [ "egui", "web-time 1.1.0", ] [[package]] name = "ehttp" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59a81c221a1e4dad06cb9c9deb19aea1193a5eea084e8cd42d869068132bf876" dependencies = [ "document-features", "js-sys", "ureq", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] [[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "emath" version = "0.29.1" source = "git+https://github.com/damus-io/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" dependencies = [ "bytemuck", "serde", ] [[package]] name = "endi" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" [[package]] name = "enostr" version = "0.3.0" dependencies = [ "bech32", "ewebsock", "hex", "mio", "nostr", "nostrdb", "serde", "serde_derive", "serde_json", "thiserror 2.0.7", "tokio", "tracing", "url", ] [[package]] name = "enum-map" version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" dependencies = [ "enum-map-derive", "serde", ] [[package]] name = "enum-map-derive" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "enumflags2" version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147" dependencies = [ "enumflags2_derive", "serde", ] [[package]] name = "enumflags2_derive" version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "enumn" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "epaint" version = "0.29.1" source = "git+https://github.com/damus-io/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" dependencies = [ "ab_glyph", "ahash", "bytemuck", "ecolor", "emath", "epaint_default_fonts", "log", "nohash-hasher", "parking_lot", "puffin 0.19.1 (registry+https://github.com/rust-lang/crates.io-index)", "serde", ] [[package]] name = "epaint_default_fonts" version = "0.29.1" source = "git+https://github.com/damus-io/egui?branch=update_layouter_0.29.1#4b19e72384345078581f940c9cd17dcb2f43d124" [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", "windows-sys 0.59.0", ] [[package]] name = "error-code" version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" [[package]] name = "event-listener" version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" dependencies = [ "concurrent-queue", "parking", "pin-project-lite", ] [[package]] name = "event-listener-strategy" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" dependencies = [ "event-listener", "pin-project-lite", ] [[package]] name = "ewebsock" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "679247b4a005c82218a5f13b713239b0b6d484ec25347a719f5b7066152a748a" dependencies = [ "document-features", "js-sys", "log", "tungstenite", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] [[package]] name = "exr" version = "1.73.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" dependencies = [ "bit_field", "half", "lebe", "miniz_oxide", "rayon-core", "smallvec", "zune-inflate", ] [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fdeflate" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ "simd-adler32", ] [[package]] name = "flatbuffers" version = "23.5.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dac53e22462d78c16d64a1cd22371b54cc3fe94aa15e7886a2fa6e5d1ab8640" dependencies = [ "bitflags 1.3.2", "rustc_version", ] [[package]] name = "flate2" version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" dependencies = [ "crc32fast", "miniz_oxide", ] [[package]] name = "float-cmp" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foreign-types" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", "foreign-types-shared", ] [[package]] name = "foreign-types-macros" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "foreign-types-shared" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] [[package]] name = "futures" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", "futures-executor", "futures-io", "futures-sink", "futures-task", "futures-util", ] [[package]] name = "futures-channel" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", "futures-util", ] [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" dependencies = [ "fastrand", "futures-core", "futures-io", "parking", "pin-project-lite", ] [[package]] name = "futures-macro" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "futures-sink" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "gethostname" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" dependencies = [ "libc", "windows-targets 0.48.5", ] [[package]] name = "getrandom" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen", ] [[package]] name = "gif" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" dependencies = [ "color_quant", "weezl", ] [[package]] name = "gimli" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "gl_generator" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" dependencies = [ "khronos_api", "log", "xml-rs", ] [[package]] name = "glob" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "glow" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" dependencies = [ "js-sys", "slotmap", "wasm-bindgen", "web-sys", ] [[package]] name = "glow" version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" dependencies = [ "js-sys", "slotmap", "wasm-bindgen", "web-sys", ] [[package]] name = "glutin_wgl_sys" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a4e1951bbd9434a81aa496fe59ccc2235af3820d27b85f9314e279609211e2c" dependencies = [ "gl_generator", ] [[package]] name = "gpu-alloc" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ "bitflags 2.6.0", "gpu-alloc-types", ] [[package]] name = "gpu-alloc-types" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ "bitflags 2.6.0", ] [[package]] name = "gpu-allocator" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" dependencies = [ "log", "presser", "thiserror 1.0.69", "winapi", "windows", ] [[package]] name = "gpu-descriptor" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c08c1f623a8d0b722b8b99f821eb0ba672a1618f0d3b16ddbee1cedd2dd8557" dependencies = [ "bitflags 2.6.0", "gpu-descriptor-types", "hashbrown 0.14.5", ] [[package]] name = "gpu-descriptor-types" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ "bitflags 2.6.0", ] [[package]] name = "half" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" dependencies = [ "cfg-if", "crunchy", ] [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", "allocator-api2", ] [[package]] name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" [[package]] name = "hassle-rs" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" dependencies = [ "bitflags 2.6.0", "com", "libc", "libloading", "thiserror 1.0.69", "widestring", "winapi", ] [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-conservative" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" [[package]] name = "hex-conservative" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" dependencies = [ "arrayvec", ] [[package]] name = "hex_lit" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" [[package]] name = "hexf-parse" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ "digest", ] [[package]] name = "home" version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "http" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" dependencies = [ "bytes", "fnv", "itoa", ] [[package]] name = "httparse" version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] name = "icu_collections" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locid" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_locid_transform" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" dependencies = [ "displaydoc", "icu_locid", "icu_locid_transform_data", "icu_provider", "tinystr", "zerovec", ] [[package]] name = "icu_locid_transform_data" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" [[package]] name = "icu_normalizer" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "utf16_iter", "utf8_iter", "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" [[package]] name = "icu_properties" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ "displaydoc", "icu_collections", "icu_locid_transform", "icu_properties_data", "icu_provider", "tinystr", "zerovec", ] [[package]] name = "icu_properties_data" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" [[package]] name = "icu_provider" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" dependencies = [ "displaydoc", "icu_locid", "icu_provider_macros", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_provider_macros" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "idna" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "image" version = "0.25.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", "exr", "gif", "image-webp", "num-traits", "png", "qoi", "ravif", "rayon", "rgb", "tiff", "zune-core", "zune-jpeg", ] [[package]] name = "image-webp" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f" dependencies = [ "byteorder-lite", "quick-error", ] [[package]] name = "imagesize" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" [[package]] name = "imgref" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" [[package]] name = "indexmap" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown 0.15.2", "serde", ] [[package]] name = "inout" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" dependencies = [ "block-padding", "generic-array", ] [[package]] name = "instant" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", "js-sys", "wasm-bindgen", "web-sys", ] [[package]] name = "interpolate_name" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "is-docker" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" dependencies = [ "once_cell", ] [[package]] name = "is-wsl" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" dependencies = [ "is-docker", "once_cell", ] [[package]] name = "itertools" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "jni" version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", "cfg-if", "combine", "jni-sys", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" dependencies = [ "libc", ] [[package]] name = "jpeg-decoder" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" [[package]] name = "js-sys" version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] name = "khronos-egl" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", "libloading", "pkg-config", ] [[package]] name = "khronos_api" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kurbo" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd85a5776cd9500c2e2059c8c76c3b01528566b7fcbaf8098b55a33fc298849b" dependencies = [ "arrayvec", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lazycell" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "lebe" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "libc" version = "0.2.168" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" [[package]] name = "libfuzzer-sys" version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b9569d2f74e257076d8c6bfa73fb505b46b851e51ddaecc825944aa3bed17fa" dependencies = [ "arbitrary", "cc", ] [[package]] name = "libloading" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", "windows-targets 0.52.6", ] [[package]] name = "libredox" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.6.0", "libc", "redox_syscall 0.5.8", ] [[package]] name = "linux-raw-sys" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "litemap" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "litrs" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" [[package]] name = "lock_api" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", ] [[package]] name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "loop9" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" dependencies = [ "imgref", ] [[package]] name = "lz4_flex" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5" [[package]] name = "malloc_buf" version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" dependencies = [ "libc", ] [[package]] name = "matchers" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" dependencies = [ "regex-automata 0.1.10", ] [[package]] name = "maybe-rayon" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ "cfg-if", "rayon", ] [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memmap2" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" dependencies = [ "libc", ] [[package]] name = "memoffset" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "metal" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ "bitflags 2.6.0", "block", "core-graphics-types", "foreign-types", "log", "objc", "paste", ] [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mime_guess" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ "mime", "unicase", ] [[package]] name = "mime_guess2" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25a3333bb1609500601edc766a39b4c1772874a4ce26022f4d866854dc020c41" dependencies = [ "mime", "unicase", ] [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ "adler2", "simd-adler32", ] [[package]] name = "mio" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", "log", "wasi", "windows-sys 0.52.0", ] [[package]] name = "naga" version = "22.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" dependencies = [ "arrayvec", "bit-set", "bitflags 2.6.0", "cfg_aliases 0.1.1", "codespan-reporting", "hexf-parse", "indexmap", "log", "rustc-hash", "spirv", "termcolor", "thiserror 1.0.69", "unicode-xid", ] [[package]] name = "natord" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" [[package]] name = "ndk" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451422b7e4718271c8b5b3aadf5adedba43dc76312454b387e98fae0fc951aa0" dependencies = [ "bitflags 1.3.2", "jni-sys", "ndk-sys 0.4.1+23.1.7779620", "num_enum 0.5.11", "raw-window-handle 0.5.2", "thiserror 1.0.69", ] [[package]] name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.6.0", "jni-sys", "log", "ndk-sys 0.6.0+11769913", "num_enum 0.7.3", "raw-window-handle 0.6.2", "thiserror 1.0.69", ] [[package]] name = "ndk-context" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "ndk-sys" version = "0.4.1+23.1.7779620" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cf2aae958bd232cac5069850591667ad422d263686d75b52a065f9badeee5a3" dependencies = [ "jni-sys", ] [[package]] name = "ndk-sys" version = "0.5.0+25.2.9519653" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" dependencies = [ "jni-sys", ] [[package]] name = "ndk-sys" version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ "jni-sys", ] [[package]] name = "negentropy" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e664971378a3987224f7a0e10059782035e89899ae403718ee07de85bec42afe" [[package]] name = "negentropy" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a88da9dd148bbcdce323dd6ac47d369b4769d4a3b78c6c52389b9269f77932" [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.6.0", "cfg-if", "cfg_aliases 0.2.1", "libc", "memoffset", ] [[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "minimal-lexical", ] [[package]] name = "noop_proc_macro" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8aad4b767bbed24ac5eb4465bfb83bc1210522eb99d67cf4e547ec2ec7e47786" dependencies = [ "async-trait", "base64 0.22.1", "bech32", "bip39", "bitcoin", "cbc", "chacha20", "chacha20poly1305", "getrandom", "instant", "negentropy 0.3.1", "negentropy 0.4.3", "once_cell", "scrypt", "serde", "serde_json", "unicode-normalization", "url", ] [[package]] name = "nostrdb" version = "0.5.1" source = "git+https://github.com/damus-io/nostrdb-rs?rev=ad3b345416d17ec75362fbfe82309c8196f5ad4b#ad3b345416d17ec75362fbfe82309c8196f5ad4b" dependencies = [ "bindgen", "cc", "flatbuffers", "futures", "libc", "thiserror 2.0.7", "tokio", "tracing", "tracing-subscriber", ] [[package]] name = "notedeck" version = "0.3.1" dependencies = [ "base32", "bincode", "dirs", "eframe", "egui", "ehttp", "enostr", "hex", "image", "mime_guess", "nostrdb", "poll-promise", "puffin 0.19.1 (git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057)", "puffin_egui", "security-framework", "serde", "serde_json", "sha2", "strum", "strum_macros", "tempfile", "thiserror 2.0.7", "tracing", "url", "uuid", ] [[package]] name = "notedeck_chrome" version = "0.3.1" dependencies = [ "android-activity 0.4.3", "eframe", "egui", "egui_extras", "log", "notedeck", "notedeck_columns", "puffin 0.19.1 (git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057)", "puffin_egui", "serde", "serde_json", "strum", "tempfile", "tokio", "tracing", "tracing-appender", "tracing-logcat", "tracing-subscriber", "winit", ] [[package]] name = "notedeck_columns" version = "0.3.1" dependencies = [ "base64 0.22.1", "bitflags 2.6.0", "dirs", "eframe", "egui", "egui_extras", "egui_nav", "egui_tabs", "egui_virtual_list", "ehttp", "enostr", "hex", "image", "indexmap", "nostrdb", "notedeck", "open", "poll-promise", "pretty_assertions", "puffin 0.19.1 (git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057)", "puffin_egui", "rfd", "security-framework", "serde", "serde_derive", "serde_json", "sha2", "strum", "strum_macros", "tempfile", "thiserror 2.0.7", "tokenator", "tokio", "tracing", "tracing-appender", "tracing-subscriber", "url", "urlencoding", "uuid", ] [[package]] name = "nu-ansi-term" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ "overload", "winapi", ] [[package]] name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", ] [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-derive" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "num-integer" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] [[package]] name = "num-rational" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ "num-bigint", "num-integer", "num-traits", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "num_enum" version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" dependencies = [ "num_enum_derive 0.5.11", ] [[package]] name = "num_enum" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a015b430d3c108a207fd776d2e2196aaf8b1cf8cf93253e3a097ff3085076a1" dependencies = [ "num_enum_derive 0.6.1", ] [[package]] name = "num_enum" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" dependencies = [ "num_enum_derive 0.7.3", ] [[package]] name = "num_enum_derive" version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" dependencies = [ "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 1.0.109", ] [[package]] name = "num_enum_derive" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96667db765a921f7b295ffee8b60472b686a51d4f21c2ee4ffdb94c7013b65a6" dependencies = [ "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "num_enum_derive" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "objc" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ "malloc_buf", ] [[package]] name = "objc-sys" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] name = "objc2" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" dependencies = [ "objc-sys", "objc2-encode", ] [[package]] name = "objc2-app-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.6.0", "block2", "libc", "objc2", "objc2-core-data", "objc2-core-image", "objc2-foundation", "objc2-quartz-core", ] [[package]] name = "objc2-cloud-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.6.0", "block2", "objc2", "objc2-core-location", "objc2-foundation", ] [[package]] name = "objc2-contacts" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2", "objc2", "objc2-foundation", ] [[package]] name = "objc2-core-data" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.6.0", "block2", "objc2", "objc2-foundation", ] [[package]] name = "objc2-core-image" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2", "objc2", "objc2-foundation", "objc2-metal", ] [[package]] name = "objc2-core-location" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2", "objc2", "objc2-contacts", "objc2-foundation", ] [[package]] name = "objc2-encode" version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7891e71393cd1f227313c9379a26a584ff3d7e6e7159e988851f0934c993f0f8" [[package]] name = "objc2-foundation" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.6.0", "block2", "dispatch", "libc", "objc2", ] [[package]] name = "objc2-link-presentation" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2", "objc2", "objc2-app-kit", "objc2-foundation", ] [[package]] name = "objc2-metal" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.6.0", "block2", "objc2", "objc2-foundation", ] [[package]] name = "objc2-quartz-core" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.6.0", "block2", "objc2", "objc2-foundation", "objc2-metal", ] [[package]] name = "objc2-symbols" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ "objc2", "objc2-foundation", ] [[package]] name = "objc2-ui-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.6.0", "block2", "objc2", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", "objc2-core-location", "objc2-foundation", "objc2-link-presentation", "objc2-quartz-core", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", ] [[package]] name = "objc2-uniform-type-identifiers" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2", "objc2", "objc2-foundation", ] [[package]] name = "objc2-user-notifications" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.6.0", "block2", "objc2", "objc2-core-location", "objc2-foundation", ] [[package]] name = "object" version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] [[package]] name = "once_cell" version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "opaque-debug" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ecd52f0b8d15c40ce4820aa251ed5de032e5d91fab27f7db2f40d42a8bdf69c" dependencies = [ "is-wsl", "libc", "pathdiff", ] [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbclient" version = "0.3.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba0b26cec2e24f08ed8bb31519a9333140a6599b867dac464bb150bdb796fd43" dependencies = [ "libredox", ] [[package]] name = "ordered-stream" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ "futures-core", "pin-project-lite", ] [[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "owned_ttf_parser" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" dependencies = [ "ttf-parser", ] [[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall 0.5.8", "smallvec", "windows-targets 0.52.6", ] [[package]] name = "password-hash" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", "rand_core", "subtle", ] [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pbkdf2" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", "hmac", ] [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pico-args" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "pin-project-lite" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", "fastrand", "futures-io", ] [[package]] name = "pkg-config" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "png" version = "0.17.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67582bd5b65bdff614270e2ea89a1cf15bef71245cc1e5f7ea126977144211d" dependencies = [ "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] [[package]] name = "poll-promise" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6a58fecbf9da8965bcdb20ce4fd29788d1acee68ddbb64f0ba1b81bccdb7df" dependencies = [ "document-features", "static_assertions", "tokio", ] [[package]] name = "polling" version = "3.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", "rustix", "tracing", "windows-sys 0.59.0", ] [[package]] name = "pollster" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" [[package]] name = "pollster" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures", "opaque-debug", "universal-hash", ] [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ "zerocopy", ] [[package]] name = "presser" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" [[package]] name = "pretty_assertions" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" dependencies = [ "diff", "yansi", ] [[package]] name = "prettyplease" version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" dependencies = [ "proc-macro2", "syn 2.0.90", ] [[package]] name = "proc-macro-crate" version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" dependencies = [ "once_cell", "toml_edit 0.19.15", ] [[package]] name = "proc-macro-crate" version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" dependencies = [ "toml_edit 0.22.22", ] [[package]] name = "proc-macro2" version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] [[package]] name = "profiling" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30" dependencies = [ "quote", "syn 2.0.90", ] [[package]] name = "puffin" version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa9dae7b05c02ec1a6bc9bcf20d8bc64a7dcbf57934107902a872014899b741f" dependencies = [ "anyhow", "byteorder", "cfg-if", "itertools 0.10.5", "once_cell", "parking_lot", ] [[package]] name = "puffin" version = "0.19.1" source = "git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057#70ff86d5503815219b01a009afd3669b7903a057" dependencies = [ "anyhow", "bincode", "byteorder", "cfg-if", "itertools 0.10.5", "lz4_flex", "once_cell", "parking_lot", "serde", ] [[package]] name = "puffin_egui" version = "0.29.0" source = "git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057#70ff86d5503815219b01a009afd3669b7903a057" dependencies = [ "egui", "egui_extras", "indexmap", "natord", "once_cell", "parking_lot", "puffin 0.19.1 (git+https://github.com/jb55/puffin?rev=70ff86d5503815219b01a009afd3669b7903a057)", "time", "vec1", "web-time 0.2.4", ] [[package]] name = "qoi" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" dependencies = [ "bytemuck", ] [[package]] name = "quick-error" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" dependencies = [ "memchr", ] [[package]] name = "quote" version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom", ] [[package]] name = "rav1e" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" dependencies = [ "arbitrary", "arg_enum_proc_macro", "arrayvec", "av1-grain", "bitstream-io", "built", "cfg-if", "interpolate_name", "itertools 0.12.1", "libc", "libfuzzer-sys", "log", "maybe-rayon", "new_debug_unreachable", "noop_proc_macro", "num-derive", "num-traits", "once_cell", "paste", "profiling", "rand", "rand_chacha", "simd_helpers", "system-deps", "thiserror 1.0.69", "v_frame", "wasm-bindgen", ] [[package]] name = "ravif" version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2413fd96bd0ea5cdeeb37eaf446a22e6ed7b981d792828721e74ded1980a45c6" dependencies = [ "avif-serialize", "imgref", "loop9", "quick-error", "rav1e", "rayon", "rgb", ] [[package]] name = "raw-window-handle" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "rayon" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" dependencies = [ "either", "rayon-core", ] [[package]] name = "rayon-core" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] name = "rctree" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b42e27ef78c35d3998403c1d26f3efd9e135d3e5121b0a4845cc5cc27547f4f" [[package]] name = "redox_syscall" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ "bitflags 1.3.2", ] [[package]] name = "redox_syscall" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ "bitflags 2.6.0", ] [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom", "libredox", "thiserror 1.0.69", ] [[package]] name = "regex" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", "regex-automata 0.4.9", "regex-syntax 0.8.5", ] [[package]] name = "regex-automata" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" dependencies = [ "regex-syntax 0.6.29", ] [[package]] name = "regex-automata" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", "regex-syntax 0.8.5", ] [[package]] name = "regex-syntax" version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "renderdoc-sys" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "resvg" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cadccb3d99a9efb8e5e00c16fbb732cbe400db2ec7fc004697ee7d97d86cf1f4" dependencies = [ "log", "pico-args", "rgb", "svgtypes", "tiny-skia", "usvg", ] [[package]] name = "rfd" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a24763657bff09769a8ccf12c8b8a50416fb035fe199263b4c5071e4e3f006f" dependencies = [ "ashpd", "block2", "core-foundation 0.10.0", "core-foundation-sys", "js-sys", "log", "objc2", "objc2-app-kit", "objc2-foundation", "pollster 0.4.0", "raw-window-handle 0.6.2", "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "windows-sys 0.59.0", ] [[package]] name = "rgb" version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" dependencies = [ "bytemuck", ] [[package]] name = "ring" version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", "getrandom", "libc", "spin", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "roxmltree" version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] [[package]] name = "rustix" version = "0.38.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" dependencies = [ "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.59.0", ] [[package]] name = "rustls" version = "0.23.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" dependencies = [ "log", "once_cell", "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-pki-types" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" [[package]] name = "rustls-webpki" version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring", "rustls-pki-types", "untrusted", ] [[package]] name = "rustversion" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" [[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "salsa20" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ "cipher", ] [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "scoped-tls" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scrypt" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "password-hash", "pbkdf2", "salsa20", "sha2", ] [[package]] name = "sctk-adwaita" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "ab_glyph", "log", "memmap2", "smithay-client-toolkit", "tiny-skia", ] [[package]] name = "secp256k1" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes 0.14.0", "rand", "secp256k1-sys", "serde", ] [[package]] name = "secp256k1-sys" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" dependencies = [ "cc", ] [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.6.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", "security-framework-sys", ] [[package]] name = "security-framework-sys" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "semver" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" [[package]] name = "serde" version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "serde_json" version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "indexmap", "itoa", "memchr", "ryu", "serde", ] [[package]] name = "serde_repr" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "serde_spanned" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] [[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "sha2" version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "sharded-slab" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ "lazy_static", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] [[package]] name = "simd-adler32" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" [[package]] name = "simd_helpers" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" dependencies = [ "quote", ] [[package]] name = "simplecss" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a11be7c62927d9427e9f40f3444d5499d868648e2edbc4e2116de69e7ec0e89d" dependencies = [ "log", ] [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "slab" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" dependencies = [ "autocfg", ] [[package]] name = "slotmap" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" dependencies = [ "version_check", ] [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smithay-client-toolkit" version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.6.0", "calloop", "calloop-wayland-source", "cursor-icon", "libc", "log", "memmap2", "rustix", "thiserror 1.0.69", "wayland-backend", "wayland-client", "wayland-csd-frame", "wayland-cursor", "wayland-protocols", "wayland-protocols-wlr", "wayland-scanner", "xkeysym", ] [[package]] name = "smithay-clipboard" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc8216eec463674a0e90f29e0ae41a4db573ec5b56b1c6c1c71615d249b6d846" dependencies = [ "libc", "smithay-client-toolkit", "wayland-backend", ] [[package]] name = "smol_str" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" dependencies = [ "serde", ] [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ "bitflags 2.6.0", ] [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" dependencies = [ "float-cmp", ] [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" [[package]] name = "strum_macros" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ "heck", "proc-macro2", "quote", "rustversion", "syn 2.0.90", ] [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "svgtypes" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e44e288cd960318917cbd540340968b90becc8bc81f171345d706e7a89d9d70" dependencies = [ "kurbo", "siphasher", ] [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "synstructure" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "system-deps" version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ "cfg-expr", "heck", "pkg-config", "toml", "version-compare", ] [[package]] name = "target-lexicon" version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if", "fastrand", "once_cell", "rustix", "windows-sys 0.59.0", ] [[package]] name = "termcolor" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ "winapi-util", ] [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl 1.0.69", ] [[package]] name = "thiserror" version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767" dependencies = [ "thiserror-impl 2.0.7", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "thiserror-impl" version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "thread_local" version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", "once_cell", ] [[package]] name = "tiff" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" dependencies = [ "flate2", "jpeg-decoder", "weezl", ] [[package]] name = "time" version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", "serde", "time-core", "time-macros", ] [[package]] name = "time-core" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" dependencies = [ "num-conv", "time-core", ] [[package]] name = "tiny-skia" version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" dependencies = [ "arrayref", "arrayvec", "bytemuck", "cfg-if", "log", "png", "tiny-skia-path", ] [[package]] name = "tiny-skia-path" version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" dependencies = [ "arrayref", "bytemuck", "strict-num", ] [[package]] name = "tinystr" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "tinyvec" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokenator" version = "0.1.0" dependencies = [ "hex", ] [[package]] name = "tokio" version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "pin-project-lite", "tokio-macros", ] [[package]] name = "tokio-macros" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "toml" version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", "serde_spanned", "toml_datetime", "toml_edit 0.22.22", ] [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap", "toml_datetime", "winnow 0.5.40", ] [[package]] name = "toml_edit" version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", "winnow 0.6.20", ] [[package]] name = "tracing" version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-appender" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" dependencies = [ "crossbeam-channel", "thiserror 1.0.69", "time", "tracing-subscriber", ] [[package]] name = "tracing-attributes" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "tracing-core" version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", ] [[package]] name = "tracing-log" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ "log", "once_cell", "tracing-core", ] [[package]] name = "tracing-logcat" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "678d561ce3d4b33dfcfb1c6bd13c422aad89be5bf0e66e20d518a5a254345b54" dependencies = [ "rustix", "tracing", "tracing-subscriber", ] [[package]] name = "tracing-subscriber" version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", ] [[package]] name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" [[package]] name = "tungstenite" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" dependencies = [ "byteorder", "bytes", "data-encoding", "http", "httparse", "log", "rand", "rustls", "rustls-pki-types", "sha1", "thiserror 1.0.69", "utf-8", "webpki-roots", ] [[package]] name = "type-map" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "deb68604048ff8fa93347f02441e4487594adc20bb8a084f9e564d2b827a0a9f" dependencies = [ "rustc-hash", ] [[package]] name = "typenum" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "uds_windows" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ "memoffset", "tempfile", "winapi", ] [[package]] name = "unicase" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" [[package]] name = "unicode-ident" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-normalization" version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "universal-hash" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ "crypto-common", "subtle", ] [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ "base64 0.22.1", "flate2", "log", "once_cell", "rustls", "rustls-pki-types", "url", "webpki-roots", ] [[package]] name = "url" version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", ] [[package]] name = "urlencoding" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "usvg" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b0a51b72ab80ca511d126b77feeeb4fb1e972764653e61feac30adc161a756" dependencies = [ "base64 0.21.7", "log", "pico-args", "usvg-parser", "usvg-tree", "xmlwriter", ] [[package]] name = "usvg-parser" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bd4e3c291f45d152929a31f0f6c819245e2921bfd01e7bd91201a9af39a2bdc" dependencies = [ "data-url", "flate2", "imagesize", "kurbo", "log", "roxmltree", "simplecss", "siphasher", "svgtypes", "usvg-tree", ] [[package]] name = "usvg-tree" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee3d202ebdb97a6215604b8f5b4d6ef9024efd623cf2e373a6416ba976ec7d3" dependencies = [ "rctree", "strict-num", "svgtypes", "tiny-skia-path", ] [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf16_iter" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ "getrandom", ] [[package]] name = "v_frame" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b" dependencies = [ "aligned-vec", "num-traits", "wasm-bindgen", ] [[package]] name = "valuable" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" [[package]] name = "vec1" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eab68b56840f69efb0fefbe3ab6661499217ffdc58e2eef7c3f6f69835386322" [[package]] name = "version-compare" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" dependencies = [ "cfg-if", "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", "syn 2.0.90", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" dependencies = [ "cfg-if", "js-sys", "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" [[package]] name = "wayland-backend" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6" dependencies = [ "cc", "downcast-rs", "rustix", "scoped-tls", "smallvec", "wayland-sys", ] [[package]] name = "wayland-client" version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280" dependencies = [ "bitflags 2.6.0", "rustix", "wayland-backend", "wayland-scanner", ] [[package]] name = "wayland-csd-frame" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ "bitflags 2.6.0", "cursor-icon", "wayland-backend", ] [[package]] name = "wayland-cursor" version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b08bc3aafdb0035e7fe0fdf17ba0c09c268732707dca4ae098f60cb28c9e4c" dependencies = [ "rustix", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" version = "0.32.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd0ade57c4e6e9a8952741325c30bf82f4246885dca8bf561898b86d0c1f58e" dependencies = [ "bitflags 2.6.0", "wayland-backend", "wayland-client", "wayland-scanner", ] [[package]] name = "wayland-protocols-plasma" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b31cab548ee68c7eb155517f2212049dc151f7cd7910c2b66abfd31c3ee12bd" dependencies = [ "bitflags 2.6.0", "wayland-backend", "wayland-client", "wayland-protocols", "wayland-scanner", ] [[package]] name = "wayland-protocols-wlr" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "782e12f6cd923c3c316130d56205ebab53f55d6666b7faddfad36cecaeeb4022" dependencies = [ "bitflags 2.6.0", "wayland-backend", "wayland-client", "wayland-protocols", "wayland-scanner", ] [[package]] name = "wayland-scanner" version = "0.31.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597f2001b2e5fc1121e3d5b9791d3e78f05ba6bfa4641053846248e3a13661c3" dependencies = [ "proc-macro2", "quick-xml", "quote", ] [[package]] name = "wayland-sys" version = "0.31.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa8ac0d8e8ed3e3b5c9fc92c7881406a268e11555abe36493efabe649a29e09" dependencies = [ "dlib", "log", "once_cell", "pkg-config", ] [[package]] name = "web-sys" version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "web-time" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "web-time" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "webbrowser" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea9fe1ebb156110ff855242c1101df158b822487e4957b0556d9ffce9db0f535" dependencies = [ "block2", "core-foundation 0.10.0", "home", "jni", "log", "ndk-context", "objc2", "objc2-foundation", "url", "web-sys", ] [[package]] name = "webpki-roots" version = "0.26.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" dependencies = [ "rustls-pki-types", ] [[package]] name = "weezl" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" [[package]] name = "wgpu" version = "22.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" dependencies = [ "arrayvec", "cfg_aliases 0.1.1", "document-features", "js-sys", "log", "naga", "parking_lot", "profiling", "raw-window-handle 0.6.2", "smallvec", "static_assertions", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "wgpu-core", "wgpu-hal", "wgpu-types", ] [[package]] name = "wgpu-core" version = "22.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" dependencies = [ "arrayvec", "bit-vec", "bitflags 2.6.0", "cfg_aliases 0.1.1", "document-features", "indexmap", "log", "naga", "once_cell", "parking_lot", "profiling", "raw-window-handle 0.6.2", "rustc-hash", "smallvec", "thiserror 1.0.69", "wgpu-hal", "wgpu-types", ] [[package]] name = "wgpu-hal" version = "22.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" dependencies = [ "android_system_properties", "arrayvec", "ash", "bitflags 2.6.0", "block", "cfg_aliases 0.1.1", "core-graphics-types", "glow 0.13.1", "glutin_wgl_sys", "gpu-alloc", "gpu-allocator", "gpu-descriptor", "hassle-rs", "js-sys", "khronos-egl", "libc", "libloading", "log", "metal", "naga", "ndk-sys 0.5.0+25.2.9519653", "objc", "once_cell", "parking_lot", "profiling", "raw-window-handle 0.6.2", "renderdoc-sys", "rustc-hash", "smallvec", "thiserror 1.0.69", "wasm-bindgen", "web-sys", "wgpu-types", "winapi", ] [[package]] name = "wgpu-types" version = "22.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" dependencies = [ "bitflags 2.6.0", "js-sys", "web-sys", ] [[package]] name = "which" version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" dependencies = [ "either", "home", "once_cell", "rustix", ] [[package]] name = "widestring" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ "windows-core", "windows-targets 0.52.6", ] [[package]] name = "windows-core" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ "windows-targets 0.42.2", ] [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ "windows-targets 0.48.5", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-targets" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ "windows_aarch64_gnullvm 0.42.2", "windows_aarch64_msvc 0.42.2", "windows_i686_gnu 0.42.2", "windows_i686_msvc 0.42.2", "windows_x86_64_gnu 0.42.2", "windows_x86_64_gnullvm 0.42.2", "windows_x86_64_msvc 0.42.2", ] [[package]] name = "windows-targets" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ "windows_aarch64_gnullvm 0.48.5", "windows_aarch64_msvc 0.48.5", "windows_i686_gnu 0.48.5", "windows_i686_msvc 0.48.5", "windows_x86_64_gnu 0.48.5", "windows_x86_64_gnullvm 0.48.5", "windows_x86_64_msvc 0.48.5", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0be9e76a1f1077e04a411f0b989cbd3c93339e1771cb41e71ac4aee95bfd2c67" dependencies = [ "ahash", "android-activity 0.6.0", "atomic-waker", "bitflags 2.6.0", "block2", "bytemuck", "calloop", "cfg_aliases 0.2.1", "concurrent-queue", "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", "js-sys", "libc", "memmap2", "ndk 0.9.0", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", "orbclient", "percent-encoding", "pin-project", "raw-window-handle 0.6.2", "redox_syscall 0.4.1", "rustix", "sctk-adwaita", "smithay-client-toolkit", "smol_str", "tracing", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", "wayland-backend", "wayland-client", "wayland-protocols", "wayland-protocols-plasma", "web-sys", "web-time 1.1.0", "windows-sys 0.52.0", "x11-dl", "x11rb", "xkbcommon-dl", ] [[package]] name = "winnow" version = "0.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" dependencies = [ "memchr", ] [[package]] name = "winnow" version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] [[package]] name = "write16" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "x11-dl" version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" dependencies = [ "libc", "once_cell", "pkg-config", ] [[package]] name = "x11rb" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", "libloading", "once_cell", "rustix", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" [[package]] name = "xcursor" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" [[package]] name = "xdg-home" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" dependencies = [ "libc", "windows-sys 0.59.0", ] [[package]] name = "xkbcommon-dl" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ "bitflags 2.6.0", "dlib", "log", "once_cell", "xkeysym", ] [[package]] name = "xkeysym" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" version = "0.8.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea8b391c9a790b496184c29f7f93b9ed5b16abb306c05415b68bcc16e4d06432" [[package]] name = "xmlwriter" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", "synstructure", ] [[package]] name = "zbus" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "192a0d989036cd60a1e91a54c9851fb9ad5bd96125d41803eed79d2e2ef74bd7" dependencies = [ "async-broadcast", "async-executor", "async-fs", "async-io", "async-lock", "async-process", "async-recursion", "async-task", "async-trait", "blocking", "enumflags2", "event-listener", "futures-core", "futures-util", "hex", "nix", "ordered-stream", "serde", "serde_repr", "static_assertions", "tracing", "uds_windows", "windows-sys 0.59.0", "winnow 0.6.20", "xdg-home", "zbus_macros", "zbus_names", "zvariant", ] [[package]] name = "zbus_macros" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3685b5c81fce630efc3e143a4ded235b107f1b1cdf186c3f115529e5e5ae4265" dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.90", "zbus_names", "zvariant", "zvariant_utils", ] [[package]] name = "zbus_names" version = "4.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519629a3f80976d89c575895b05677cbc45eaf9f70d62a364d819ba646409cc8" dependencies = [ "serde", "static_assertions", "winnow 0.6.20", "zvariant", ] [[package]] name = "zerocopy" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "zerofrom" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", "synstructure", ] [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] name = "zerovec" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] [[package]] name = "zune-core" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-inflate" version = "0.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" dependencies = [ "simd-adler32", ] [[package]] name = "zune-jpeg" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" dependencies = [ "zune-core", ] [[package]] name = "zvariant" version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55e6b9b5f1361de2d5e7d9fd1ee5f6f7fcb6060618a1f82f3472f58f2b8d4be9" dependencies = [ "endi", "enumflags2", "serde", "static_assertions", "url", "winnow 0.6.20", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "573a8dd76961957108b10f7a45bac6ab1ea3e9b7fe01aff88325dc57bb8f5c8b" dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.90", "zvariant_utils", ] [[package]] name = "zvariant_utils" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd46446ea2a1f353bfda53e35f17633afa79f4fe290a611c94645c69fe96a50" dependencies = [ "proc-macro2", "quote", "serde", "static_assertions", "syn 2.0.90", "winnow 0.6.20", ] ``` `/Users/jb55/dev/notedeck/prompt.md`: ```md [✓] Copied to clipboard successfully. ``` `/Users/jb55/dev/notedeck/README.md`: ```md # Damus Notedeck [![CI](https://github.com/damus-io/notedeck/actions/workflows/rust.yml/badge.svg)](https://github.com/damus-io/notedeck/actions/workflows/rust.yml) A multiplatform nostr client. Works on android and desktop The desktop client is called notedeck: ![notedeck](https://cdn.jb55.com/s/6130555f03db55b2.png) ## Android Look it actually runs on android! ## Usage ```bash $ ./target/release/notedeck ``` # Developer Setup ## 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 $ cargo run --release ``` ## Android 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: ``` $ rustup target add aarch64-linux-android ``` To run on a real device, just type: ```bash $ cargo apk run --release -p notedeck_chrome ``` ## Android Emulator - 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 -p notedeck_chrome ``` 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 will add the pre-commit hook to your local repository to suggest proper formatting before commits. [rustup]: https://rustup.rs/ ``` `/Users/jb55/dev/notedeck/scripts/svg_to_icns.sh`: ```sh #!/bin/bash # Exit on error set -e # Check dependencies if ! command -v inkscape &> /dev/null; then echo "Error: Inkscape is required but not installed. Install it and try again." exit 1 fi if ! command -v iconutil &> /dev/null; then echo "Error: iconutil is required but not installed. This tool is available only on macOS." exit 1 fi # Check input arguments if [ "$#" -ne 2 ]; then echo "Usage: $0 input.svg output.icns" exit 1 fi INPUT_SVG=$1 OUTPUT_ICNS=$2 TEMP_DIR=$(mktemp -d) # Create the iconset directory ICONSET_DIR="$TEMP_DIR/icon.iconset" mkdir "$ICONSET_DIR" # Define sizes and export PNGs SIZES=( "16 icon_16x16.png" "32 icon_16x16@2x.png" "32 icon_32x32.png" "64 icon_32x32@2x.png" "128 icon_128x128.png" "256 icon_128x128@2x.png" "256 icon_256x256.png" "512 icon_256x256@2x.png" "512 icon_512x512.png" "1024 icon_512x512@2x.png" ) echo "Converting SVG to PNGs..." for size_entry in "${SIZES[@]}"; do size=${size_entry%% *} filename=${size_entry#* } inkscape -w $size -h $size "$INPUT_SVG" -o "$ICONSET_DIR/$filename" done # Convert to ICNS echo "Generating ICNS file..." iconutil -c icns -o "$OUTPUT_ICNS" "$ICONSET_DIR" # Clean up rm -rf "$TEMP_DIR" echo "Done! ICNS file saved to $OUTPUT_ICNS" ``` `/Users/jb55/dev/notedeck/scripts/windows-installer.iss`: ```iss [Setup] AppName=Damus Notedeck AppVersion=0.1 DefaultDirName={pf}\Notedeck DefaultGroupName=Damus Notedeck OutputDir=..\packages OutputBaseFilename=DamusNotedeckInstaller Compression=lzma SolidCompression=yes [Files] Source: "..\target\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 ``` `/Users/jb55/dev/notedeck/scripts/dev_setup.sh`: ```sh #!/usr/bin/env bash HOOK_SCRIPTS_DIR="scripts" GIT_HOOKS_DIR=".git/hooks" # Ensure the necessary directories exist and are accessible if [ ! -d "$HOOK_SCRIPTS_DIR" ] || [ ! -d "$GIT_HOOKS_DIR" ]; then echo "Error: Required directories are missing. Please ensure you are in the project's root directory." exit 1 fi # Copy the pre-commit hook script cp -p "$HOOK_SCRIPTS_DIR/pre_commit_hook.sh" "$GIT_HOOKS_DIR/pre-commit" # Make the hook script executable chmod +x "$GIT_HOOKS_DIR/pre-commit" echo "Pre-commit hook has been set up successfully." ``` `/Users/jb55/dev/notedeck/scripts/pre_commit_hook.sh`: ```sh #!/usr/bin/env bash cargo fmt --all --check ``` `/Users/jb55/dev/notedeck/scripts/macos_build.sh`: ```sh #!/bin/bash set -e # Exit immediately if a command exits with a non-zero status set -u # Treat unset variables as an error set -o pipefail # Catch errors in pipelines # Ensure the script is running in the correct directory NAME="Notedeck" REQUIRED_DIR="notedeck" ARCH=${ARCH:-"aarch64"} TARGET=${TARGET:-${ARCH}-apple-darwin} CURRENT_DIR=$(basename "$PWD") if [ "$CURRENT_DIR" != "$REQUIRED_DIR" ]; then echo "Error: This script must be run from the '$REQUIRED_DIR' directory." exit 1 fi # Ensure all required variables are set REQUIRED_VARS=(NOTEDECK_APPLE_RELEASE_CERT_ID NOTEDECK_RELEASE_APPLE_ID NOTEDECK_APPLE_APP_SPECIFIC_PW NOTEDECK_APPLE_TEAM_ID) for VAR in "${REQUIRED_VARS[@]}"; do if [ -z "${!VAR:-}" ]; then echo "Error: Required variable '$VAR' is not set." >&2 exit 1 fi done # Ensure required tools are installed if ! command -v cargo &> /dev/null; then echo "Error: cargo is not installed." >&2 exit 1 fi if ! command -v xcrun &> /dev/null; then echo "Error: xcrun is not installed." >&2 exit 1 fi if ! command -v create-dmg &> /dev/null; then echo "Error: create-dmg is not installed." >&2 exit 1 fi # Build the .app bundle echo "Building .app bundle..." cargo bundle --release --target $TARGET # Sign the app echo "Codesigning the app..." codesign \ --deep \ --force \ --verify \ --options runtime \ --entitlements entitlements.plist \ --sign "$NOTEDECK_APPLE_RELEASE_CERT_ID" \ target/${TARGET}/release/bundle/osx/$NAME.app # Create a zip for notarization echo "Creating zip for notarization..." zip -r notedeck.zip target/${TARGET}/release/bundle/osx/$NAME.app # Submit for notarization echo "Submitting for notarization..." xcrun notarytool submit \ --apple-id "$NOTEDECK_RELEASE_APPLE_ID" \ --password "$NOTEDECK_APPLE_APP_SPECIFIC_PW" \ --team-id "$NOTEDECK_APPLE_TEAM_ID" \ --wait \ notedeck.zip # Staple the notarization echo "Stapling notarization to the app..." xcrun stapler staple target/${TARGET}/release/bundle/osx/$NAME.app echo "Removing notedeck.zip" rm notedeck.zip # Create the .dmg package echo "Creating .dmg package..." mkdir -p packages create-dmg \ --window-size 600 400 \ --app-drop-link 400 100 \ packages/$NAME-${ARCH}.dmg \ target/${TARGET}/release/bundle/osx/$NAME.app echo "Build and signing process completed successfully." ``` `/Users/jb55/dev/notedeck/preview`: ``` #!/usr/bin/env bash # pass --mobile for mobile previews #RUST_LOG=info cargo run --bin ui_preview --features profiling --release -- "$@" cargo run --bin ui_preview --release -- "$@" ``` `/Users/jb55/dev/notedeck/assets/damus_rounded.svg`: ```svg ``` `/Users/jb55/dev/notedeck/assets/icons/reply.svg`: ```svg ``` `/Users/jb55/dev/notedeck/assets/icons/sparkle.svg`: ```svg ``` `/Users/jb55/dev/notedeck/assets/icons/algo.svg`: ```svg ``` `/Users/jb55/dev/notedeck/assets/damus-app-icon.svg`: ```svg ``` `/Users/jb55/dev/notedeck/assets/manifest.json`: ```json { "name": "egui Template PWA", "short_name": "egui-template-pwa", "icons": [ { "src": "./icon-256.png", "sizes": "256x256", "type": "image/png" }, { "src": "./maskable_icon_x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }, { "src": "./icon-1024.png", "sizes": "1024x1024", "type": "image/png" } ], "lang": "en-US", "id": "/index.html", "start_url": "./index.html", "display": "standalone", "background_color": "white", "theme_color": "white" } ``` `/Users/jb55/dev/notedeck/assets/damus.svg`: ```svg ``` `/Users/jb55/dev/notedeck/assets/sw.js`: ```js var cacheName = 'egui-template-pwa'; var filesToCache = [ './', './index.html', './eframe_template.js', './eframe_template_bg.wasm', ]; /* Start the service worker and cache all of the app's content */ self.addEventListener('install', function (e) { e.waitUntil( caches.open(cacheName).then(function (cache) { return cache.addAll(filesToCache); }) ); }); /* Serve cached content when offline */ self.addEventListener('fetch', function (e) { e.respondWith( caches.match(e.request).then(function (response) { return response || fetch(e.request); }) ); }); ``` `/Users/jb55/dev/notedeck/check`: ``` #!/usr/bin/env bash # This scripts runs various CI-like checks in a convenient way. set -eux cargo check --workspace --all-targets #cargo check --workspace --all-features --lib --target wasm32-unknown-unknown cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings -W clippy::all cargo test --workspace --all-targets --all-features cargo test --workspace --doc #trunk build ``` `/Users/jb55/dev/notedeck/tmp/settings/decks_cache.json`: ```json {"decks_cache":{"aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe":{"active_deck":0,"decks":[{"metadata":["icon:🇩","name:Demo Deck"],"columns":[["contact:aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe"]]}]}}} ``` `/Users/jb55/dev/notedeck/tmp/settings/app_size.json`: ```json {"x":1290.0,"y":944.0} ``` `/Users/jb55/dev/notedeck/tmp/settings/zoom_level.json`: ```json 1.0 ``` `/Users/jb55/dev/notedeck/tmp/storage/selected_account/selected_pubkey`: ``` "aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe" ``` `/Users/jb55/dev/notedeck/tmp/storage/accounts/aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe`: ``` {"pubkey":"aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe","encrypted_secret_key":null} ``` `/Users/jb55/dev/notedeck/Trunk.toml`: ```toml [build] filehash = true public_url = "/notedeck/" ``` `/Users/jb55/dev/notedeck/SECURITY.md`: ```md # Security Policy ## Reporting a Vulnerability Please report security issues to `jb55@jb55.com` ```