Compare commits
50 Commits
master
...
e66e7e938e
Author | SHA1 | Date | |
---|---|---|---|
e66e7e938e
|
|||
62a7933de0
|
|||
7e9e61d14c
|
|||
73d03ca0f1
|
|||
93e7ee18c1
|
|||
785e8dbd22
|
|||
85ef186048
|
|||
dc7fee4151
|
|||
f7021094bc
|
|||
0e19c1a8f3
|
|||
c8c5485581
|
|||
6efd99018a
|
|||
0470f47460
|
|||
e4cceef0cf
|
|||
b65acf01c8
|
|||
cdafa1d786
|
|||
4a11bad6d9
|
|||
faf18f17eb
|
|||
e54491c019
|
|||
45636a3721
|
|||
58b2d0aef2
|
|||
7cc222b92b
|
|||
20e3159c60
|
|||
e88f809958
|
|||
caed29915c
|
|||
35aa3c1c91
|
|||
56b73b879e
|
|||
93e412a07c
|
|||
621686564d
|
|||
10cd15d942
|
|||
e718b8e322
|
|||
ce5f206f6a
|
|||
095d69534d
|
|||
7e67c3d8e7
|
|||
58132d2cf5
|
|||
a98eb6f4ce
|
|||
c62fbfe510
|
|||
d21a45c941
|
|||
1e487f89a5
|
|||
e6b606e8fb
|
|||
b4a6991007
|
|||
5bed3fa86f
|
|||
948276eb65
|
|||
117968bd17
|
|||
91f0baf75c
|
|||
6017ce18d4
|
|||
f0a50918a8
|
|||
9a8bb54e08
|
|||
67d6381123
|
|||
abfcbc8954
|
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
**/target
|
||||
.data/
|
||||
ffmpeg-kit/
|
12
.drone.yaml
Normal file
12
.drone.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
name: default
|
||||
concurrency:
|
||||
limit: 1
|
||||
metadata:
|
||||
namespace: git
|
||||
steps:
|
||||
- name: build_debian
|
||||
image: rust:bookworm
|
||||
commands:
|
||||
- ./debian.sh
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -1 +1,4 @@
|
||||
/target
|
||||
/.data
|
||||
/.idea
|
||||
/ffmpeg-kit
|
6910
Cargo.lock
generated
Normal file
6910
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
Cargo.toml
48
Cargo.toml
@ -3,15 +3,45 @@ name = "zap_stream_app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = "1.40.0"
|
||||
[lib]
|
||||
crate-type = ["lib", "cdylib"]
|
||||
|
||||
egui = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb" }
|
||||
eframe = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "eframe", default-features = false, features = ["wgpu", "wayland", "x11", "android-native-activity"] }
|
||||
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "6d22af6d5159be4c9e4579f8c9d3af836e0d470a" }
|
||||
nostr-sdk = { version = "0.34.0", features = ["all-nips"] }
|
||||
egui_nav = { git = "https://github.com/damus-io/egui-nav", branch = "egui-0.28" }
|
||||
egui_extras = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "egui_extras", features = ["all_loaders"] }
|
||||
image = { version = "0.25", features = ["jpeg", "png", "webp"] }
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.40.0", features = ["fs", "rt-multi-thread", "rt"] }
|
||||
egui = { version = "0.29.1", default-features = false, features = [] }
|
||||
log = "0.4.22"
|
||||
pretty_env_logger = "0.5.0"
|
||||
bech32 = "0.11.0"
|
||||
anyhow = "^1.0.91"
|
||||
itertools = "0.14.0"
|
||||
serde = { version = "1.0.214", features = ["derive"] }
|
||||
directories = "6.0.0"
|
||||
egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "11db7d0c30070529a36bfb050844cdb75c32902b" }
|
||||
egui_qr = { git = "https://git.v0l.io/Kieran/egui_qr.git", rev = "f9cf52b7eae353fa9e59ed0358151211d48824d1" }
|
||||
|
||||
# notedeck stuff
|
||||
nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49", "nip57"] }
|
||||
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "2111948b078b24a1659d0bd5d8570f370269c99b" }
|
||||
notedeck-chrome = { git = "https://github.com/damus-io/notedeck", rev = "06417ff69e772f24ffd7fb2b025f879463d8c51f", package = "notedeck_chrome" }
|
||||
notedeck = { git = "https://github.com/damus-io/notedeck", rev = "06417ff69e772f24ffd7fb2b025f879463d8c51f", package = "notedeck" }
|
||||
enostr = { git = "https://github.com/damus-io/notedeck", rev = "06417ff69e772f24ffd7fb2b025f879463d8c51f", package = "enostr" }
|
||||
poll-promise = "0.3.0"
|
||||
ehttp = "0.5.0"
|
||||
egui-modal = "0.5.0"
|
||||
icu = "1.5.0"
|
||||
icu_decimal = "1.5.0"
|
||||
fixed_decimal = "0.5.6"
|
||||
lnurl-rs = { version = "0.9.0", default-features = false }
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
eframe = { version = "0.29.1" }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
eframe = { version = "0.29.1", features = ["android-native-activity"] }
|
||||
android_logger = "0.14.1"
|
||||
android-activity = { version = "0.6.0" }
|
||||
winit = { version = "0.30.5" }
|
||||
android-ndk-sys = "0.2.0"
|
||||
|
4
Dockerfile
Normal file
4
Dockerfile
Normal file
@ -0,0 +1,4 @@
|
||||
FROM rust:bookworm AS builder
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN ./debian.sh
|
9
README.md
Normal file
9
README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# zap.stream
|
||||
|
||||
A native [zap.stream](https://zap.stream) app built with [notedeck](https://github.com/damus-io/notedeck).
|
||||
|
||||
This is **ALPHA** stage software, use at your own risk!
|
||||
|
||||

|
||||

|
||||
|
3
TODO.md
Normal file
3
TODO.md
Normal file
@ -0,0 +1,3 @@
|
||||
- [NDB] Handle PRE's
|
||||
- [UX] Render non-ascii chars with better font
|
||||
- [egui-video] Handle mobile DPI for video player
|
34
android.sh
Executable file
34
android.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
#git clone https://github.com/v0l/ffmpeg-kit.git
|
||||
#export ANDROID_SDK_ROOT=$ANDROID_HOME
|
||||
#cd ffmpeg-kit && ./android.sh \
|
||||
# --disable-x86 \
|
||||
# --disable-x86-64 \
|
||||
# --disable-arm-v7a \
|
||||
# --disable-arm-v7a-neon \
|
||||
# --enable-gpl \
|
||||
# --enable-openssl \
|
||||
# --enable-x264 \
|
||||
# --enable-libwebp \
|
||||
# --enable-libpng \
|
||||
# --api-level=28 \
|
||||
# --no-ffmpeg-kit-protocols \
|
||||
# --no-archive
|
||||
|
||||
NDK_VER="28.0.12433566"
|
||||
ARCH="arm64"
|
||||
PLATFORM="android"
|
||||
TRIPLET="aarch64-linux-android"
|
||||
export FFMPEG_DIR="$(pwd)/ffmpeg-kit/prebuilt/$PLATFORM-$ARCH/ffmpeg"
|
||||
export PKG_CONFIG_SYSROOT_DIR="$(pwd)/ffmpeg-kit/prebuilt/$PLATFORM-$ARCH/pkgconfig"
|
||||
|
||||
# DIRTY HACK !!
|
||||
mkdir -p ./target/x/debug/android/$ARCH/cargo/$TRIPLET/release/deps
|
||||
mkdir -p ./target/x/release/android/$ARCH/cargo/$TRIPLET/release/deps
|
||||
cp "$ANDROID_HOME/ndk/$NDK_VER/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/$TRIPLET/35/libcamera2ndk.so" \
|
||||
./target/x/debug/android/$ARCH/cargo/$TRIPLET/debug/deps
|
||||
cp "$ANDROID_HOME/ndk/$NDK_VER/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/$TRIPLET/35/libcamera2ndk.so" \
|
||||
./target/x/release/android/$ARCH/cargo/$TRIPLET/release/deps
|
||||
|
||||
x build --arch $ARCH --platform $PLATFORM --format apk --verbose --release
|
BIN
assets/Outfit-Light.ttf
Normal file
BIN
assets/Outfit-Light.ttf
Normal file
Binary file not shown.
BIN
assets/Outfit-Regular.ttf
Normal file
BIN
assets/Outfit-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/Outfit.ttf
Normal file
BIN
assets/Outfit.ttf
Normal file
Binary file not shown.
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.0 KiB |
36
debian.sh
Executable file
36
debian.sh
Executable file
@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
apt update && \
|
||||
apt install -y \
|
||||
build-essential \
|
||||
libx264-dev \
|
||||
libx265-dev \
|
||||
libwebp-dev \
|
||||
libvpx-dev \
|
||||
libopus-dev \
|
||||
libdav1d-dev \
|
||||
libpipewire-0.3-dev \
|
||||
libasound2-dev \
|
||||
nasm \
|
||||
libclang-dev \
|
||||
squashfs-tools
|
||||
git clone --single-branch --branch release/7.1 https://git.v0l.io/ffmpeg/FFmpeg.git && \
|
||||
cd FFmpeg && \
|
||||
./configure \
|
||||
--prefix=${FFMPEG_DIR} \
|
||||
--disable-programs \
|
||||
--disable-doc \
|
||||
--disable-network \
|
||||
--enable-gpl \
|
||||
--enable-libx264 \
|
||||
--enable-libx265 \
|
||||
--enable-libwebp \
|
||||
--enable-libvpx \
|
||||
--enable-libopus \
|
||||
--enable-libdav1d \
|
||||
--disable-static \
|
||||
--disable-postproc \
|
||||
--enable-shared && \
|
||||
make -j$(nproc) install
|
||||
|
||||
cargo install xbuild
|
||||
x build --release --format appimage
|
18
manifest.yaml
Normal file
18
manifest.yaml
Normal file
@ -0,0 +1,18 @@
|
||||
|
||||
icon: "./assets/logo.png"
|
||||
android:
|
||||
manifest:
|
||||
package: stream.zap.app
|
||||
version_code: 1
|
||||
version_name: "0.1.0"
|
||||
application:
|
||||
label: "zap.stream"
|
||||
activities:
|
||||
- hardware_accelerated: true
|
||||
uses_permission:
|
||||
- name: "android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
- name: "android.permission.READ_EXTERNAL_STORAGE"
|
||||
- name: "android.permission.INTERNET"
|
||||
uses_feature:
|
||||
- name: "android.hardware.vulkan.level"
|
||||
required: true
|
47
src/android.rs
Normal file
47
src/android.rs
Normal file
@ -0,0 +1,47 @@
|
||||
use crate::app::ZapStreamApp;
|
||||
use eframe::Renderer;
|
||||
use egui::ViewportBuilder;
|
||||
use winit::platform::android::activity::AndroidApp;
|
||||
use winit::platform::android::EventLoopBuilderExtAndroid;
|
||||
|
||||
pub fn start_android(app: AndroidApp) {
|
||||
std::env::set_var("RUST_BACKTRACE", "full");
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default().with_max_level(log::LevelFilter::Info),
|
||||
);
|
||||
|
||||
let mut options = eframe::NativeOptions::default();
|
||||
options.renderer = Renderer::Glow;
|
||||
|
||||
options.viewport = ViewportBuilder::default()
|
||||
.with_active(true)
|
||||
.with_always_on_top()
|
||||
.with_fullscreen(true);
|
||||
|
||||
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 data_path = app
|
||||
.external_data_path()
|
||||
.expect("external data path")
|
||||
.to_path_buf();
|
||||
|
||||
if let Err(e) = eframe::run_native(
|
||||
"zap.stream",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut notedeck =
|
||||
notedeck_chrome::Notedeck::new(&cc.egui_ctx, data_path.clone(), &args);
|
||||
|
||||
let zs_app = ZapStreamApp::new(cc, app.clone());
|
||||
notedeck.add_app(zs_app);
|
||||
|
||||
Ok(Box::new(notedeck))
|
||||
}),
|
||||
) {
|
||||
eprintln!("{}", e);
|
||||
}
|
||||
}
|
239
src/app.rs
239
src/app.rs
@ -1,85 +1,206 @@
|
||||
use crate::services::Services;
|
||||
use crate::widget::NostrWidget;
|
||||
use crate::widgets::header::Header;
|
||||
use crate::widgets::stream_list::StreamList;
|
||||
use eframe::{App, CreationContext, Frame};
|
||||
use egui::{Color32, Context, ScrollArea};
|
||||
use nostr_sdk::database::{MemoryDatabase, MemoryDatabaseOptions};
|
||||
use nostr_sdk::{Client, Event, Filter, Kind, RelayPoolNotification};
|
||||
use nostrdb::{Config, Ndb, Transaction};
|
||||
use tokio::sync::broadcast;
|
||||
use crate::profiles::ProfileLoader;
|
||||
use crate::route::{page, RouteAction, RouteServices, RouteType};
|
||||
use crate::theme::MARGIN_DEFAULT;
|
||||
use crate::widgets::{Header, NostrWidget};
|
||||
use eframe::epaint::{FontFamily, Margin};
|
||||
use eframe::CreationContext;
|
||||
use egui::{Color32, FontData, FontDefinitions, Theme, Ui, Visuals};
|
||||
use enostr::{PoolEvent, RelayEvent, RelayMessage};
|
||||
use log::{error, info, warn};
|
||||
use nostrdb::{Filter, Transaction};
|
||||
use notedeck::AppContext;
|
||||
use poll_promise::Promise;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub struct ZapStreamApp {
|
||||
ndb: Ndb,
|
||||
client: Client,
|
||||
notifications: broadcast::Receiver<RelayPoolNotification>,
|
||||
events: Vec<Event>,
|
||||
services: Services,
|
||||
current: RouteType,
|
||||
routes_rx: mpsc::Receiver<RouteType>,
|
||||
routes_tx: mpsc::Sender<RouteType>,
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app: android_activity::AndroidApp,
|
||||
|
||||
widget: Box<dyn NostrWidget>,
|
||||
profiles: ProfileLoader,
|
||||
fetch: HashMap<String, Promise<ehttp::Result<ehttp::Response>>>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl ZapStreamApp {
|
||||
pub fn new(cc: &CreationContext, app: android_activity::AndroidApp) -> Self {
|
||||
let mut fd = FontDefinitions::default();
|
||||
fd.font_data.insert(
|
||||
"Outfit".to_string(),
|
||||
FontData::from_static(include_bytes!("../assets/Outfit-Light.ttf")),
|
||||
);
|
||||
fd.families
|
||||
.insert(FontFamily::Proportional, vec!["Outfit".to_string()]);
|
||||
cc.egui_ctx.set_fonts(fd);
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
Self {
|
||||
current: RouteType::HomePage,
|
||||
widget: Box::new(page::HomePage::new()),
|
||||
profiles: ProfileLoader::new(),
|
||||
routes_tx: tx,
|
||||
routes_rx: rx,
|
||||
app,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl ZapStreamApp {
|
||||
pub fn new(cc: &CreationContext) -> Self {
|
||||
let client = Client::builder().database(MemoryDatabase::with_opts(MemoryDatabaseOptions {
|
||||
events: true,
|
||||
..Default::default()
|
||||
})).build();
|
||||
let notifications = client.notifications();
|
||||
let mut fd = FontDefinitions::default();
|
||||
fd.font_data.insert(
|
||||
"Outfit".to_string(),
|
||||
FontData::from_static(include_bytes!("../assets/Outfit-Light.ttf")),
|
||||
);
|
||||
fd.families
|
||||
.insert(FontFamily::Proportional, vec!["Outfit".to_string()]);
|
||||
cc.egui_ctx.set_fonts(fd);
|
||||
|
||||
let ctx_clone = cc.egui_ctx.clone();
|
||||
let client_clone = client.clone();
|
||||
tokio::spawn(async move {
|
||||
client_clone.add_relay("wss://nos.lol").await.expect("Failed to add relay");
|
||||
client_clone.connect().await;
|
||||
client_clone.subscribe(vec![
|
||||
Filter::new()
|
||||
.kind(Kind::LiveEvent)
|
||||
], None).await.expect("Failed to subscribe");
|
||||
let mut notifications = client_clone.notifications();
|
||||
while let Ok(_) = notifications.recv().await {
|
||||
ctx_clone.request_repaint();
|
||||
// ffmpeg log redirect
|
||||
unsafe {
|
||||
egui_video::ffmpeg_sys_the_third::av_log_set_callback(Some(
|
||||
egui_video::ffmpeg_rs_raw::av_log_redirect,
|
||||
));
|
||||
}
|
||||
});
|
||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
Self {
|
||||
ndb: Ndb::new(".", &Config::default()).unwrap(),
|
||||
client: client.clone(),
|
||||
notifications,
|
||||
services: Services::new(client, cc.egui_ctx.clone()),
|
||||
events: vec![],
|
||||
current: RouteType::HomePage,
|
||||
widget: Box::new(page::HomePage::new()),
|
||||
profiles: ProfileLoader::new(),
|
||||
routes_tx: tx,
|
||||
routes_rx: rx,
|
||||
fetch: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_nostr(&mut self) {
|
||||
while let Ok(msg) = self.notifications.try_recv() {
|
||||
impl notedeck::App for ZapStreamApp {
|
||||
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut Ui) {
|
||||
ctx.accounts.update(ctx.ndb, ctx.pool, ui.ctx());
|
||||
while let Some(PoolEvent { event, relay }) = ctx.pool.try_recv() {
|
||||
if let RelayEvent::Message(msg) = (&event).into() {
|
||||
match msg {
|
||||
RelayPoolNotification::Event { event, .. } => {
|
||||
self.events.push(*event);
|
||||
}
|
||||
_ => {
|
||||
// dont care
|
||||
RelayMessage::OK(_) => {}
|
||||
RelayMessage::Eose(_) => {}
|
||||
RelayMessage::Event(_sub, ev) => {
|
||||
if let Err(e) = ctx.ndb.process_event(ev) {
|
||||
error!("Error processing event: {:?}", e);
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
RelayMessage::Notice(m) => warn!("Notice from {}: {}", relay, m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App for ZapStreamApp {
|
||||
fn update(&mut self, ctx: &Context, frame: &mut Frame) {
|
||||
let txn = Transaction::new(&self.ndb).expect("txn");
|
||||
self.process_nostr();
|
||||
// reset theme
|
||||
ui.ctx().set_visuals_of(
|
||||
Theme::Dark,
|
||||
Visuals {
|
||||
panel_fill: Color32::BLACK,
|
||||
override_text_color: Some(Color32::WHITE),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let mut app_frame = egui::containers::Frame::default();
|
||||
app_frame.stroke.color = Color32::BLACK;
|
||||
//ui.ctx().set_debug_on_hover(true);
|
||||
|
||||
ctx.set_debug_on_hover(true);
|
||||
let app_frame = egui::containers::Frame::default().outer_margin(self.frame_margin());
|
||||
|
||||
// handle app state changes
|
||||
while let Ok(r) = self.routes_rx.try_recv() {
|
||||
if let RouteType::Action(a) = r {
|
||||
match a {
|
||||
RouteAction::DemandProfile(p) => {
|
||||
self.profiles.demand(p);
|
||||
}
|
||||
_ => info!("Not implemented"),
|
||||
}
|
||||
} else {
|
||||
self.current = r;
|
||||
match &self.current {
|
||||
RouteType::HomePage => {
|
||||
self.widget = Box::new(page::HomePage::new());
|
||||
}
|
||||
RouteType::EventPage { link, .. } => {
|
||||
self.widget = Box::new(page::StreamPage::new_from_link(link.clone()));
|
||||
}
|
||||
RouteType::LoginPage => {
|
||||
self.widget = Box::new(page::LoginPage::new());
|
||||
}
|
||||
RouteType::ProfilePage { link } => {
|
||||
self.widget = Box::new(page::ProfilePage::new(
|
||||
link.id.as_bytes().try_into().unwrap(),
|
||||
));
|
||||
}
|
||||
RouteType::Action { .. } => panic!("Actions!"),
|
||||
_ => panic!("Not implemented"),
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::CentralPanel::default()
|
||||
.frame(app_frame)
|
||||
.show(ctx, |ui| {
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.add(Header::new(&self.services));
|
||||
ui.add(StreamList::new(&self.events, &self.services));
|
||||
});
|
||||
.show(ui.ctx(), |ui| {
|
||||
let tx = Transaction::new(ctx.ndb).expect("transaction");
|
||||
// display app
|
||||
ui.vertical(|ui| {
|
||||
let mut svc = RouteServices::new(
|
||||
ui.ctx().clone(),
|
||||
&tx,
|
||||
ctx,
|
||||
self.routes_tx.clone(),
|
||||
&mut self.fetch,
|
||||
);
|
||||
Header::new().render(ui, &mut svc, &tx);
|
||||
if let Err(e) = self.widget.update(&mut svc) {
|
||||
error!("{}", e);
|
||||
}
|
||||
self.widget.render(ui, &mut svc);
|
||||
})
|
||||
.response
|
||||
});
|
||||
|
||||
let profiles = self.profiles.next();
|
||||
if !profiles.is_empty() {
|
||||
info!("Profiles: {:?}", profiles);
|
||||
ctx.pool.subscribe(
|
||||
"profiles".to_string(),
|
||||
vec![Filter::new().kinds([0]).authors(&profiles).build()],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl ZapStreamApp {
|
||||
fn frame_margin(&self) -> Margin {
|
||||
Margin::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl ZapStreamApp {
|
||||
fn frame_margin(&self) -> Margin {
|
||||
if let Some(wd) = self.app.native_window() {
|
||||
let (w, h) = (wd.width(), wd.height());
|
||||
let c_rect = self.app.content_rect();
|
||||
let dpi = self.app.config().density().unwrap_or(160);
|
||||
let dpi_scale = dpi as f32 / 160.0;
|
||||
// TODO: this calc is weird but seems to work on my phone
|
||||
Margin {
|
||||
bottom: (h - c_rect.bottom) as f32,
|
||||
left: c_rect.left as f32,
|
||||
right: (w - c_rect.right) as f32,
|
||||
top: (c_rect.top - (h - c_rect.bottom)) as f32,
|
||||
} / dpi_scale
|
||||
} else {
|
||||
Margin::ZERO
|
||||
}
|
||||
}
|
||||
}
|
39
src/bin/zap_stream_app.rs
Normal file
39
src/bin/zap_stream_app.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use anyhow::Result;
|
||||
use directories::ProjectDirs;
|
||||
use eframe::Renderer;
|
||||
use egui::{Vec2, ViewportBuilder};
|
||||
use log::{error, info};
|
||||
use zap_stream_app::app::ZapStreamApp;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
pretty_env_logger::init();
|
||||
|
||||
let mut options = eframe::NativeOptions::default();
|
||||
options.viewport = ViewportBuilder::default().with_inner_size(Vec2::new(1300., 900.));
|
||||
options.renderer = Renderer::Glow;
|
||||
|
||||
let data_path = ProjectDirs::from("stream", "zap", "zap_stream_app")
|
||||
.unwrap()
|
||||
.data_dir()
|
||||
.to_path_buf();
|
||||
|
||||
info!("Data path: {}", data_path.display());
|
||||
if let Err(e) = eframe::run_native(
|
||||
"zap.stream",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut notedeck =
|
||||
notedeck_chrome::Notedeck::new(&cc.egui_ctx, data_path.clone(), &args);
|
||||
|
||||
let app = ZapStreamApp::new(cc);
|
||||
notedeck.add_app(app);
|
||||
|
||||
Ok(Box::new(notedeck))
|
||||
}),
|
||||
) {
|
||||
error!("{}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
22
src/lib.rs
Normal file
22
src/lib.rs
Normal file
@ -0,0 +1,22 @@
|
||||
#[cfg(target_os = "android")]
|
||||
mod android;
|
||||
pub mod app;
|
||||
mod link;
|
||||
mod note_ref;
|
||||
mod note_util;
|
||||
mod note_view;
|
||||
mod profiles;
|
||||
mod route;
|
||||
mod services;
|
||||
mod stream_info;
|
||||
mod sub;
|
||||
mod theme;
|
||||
mod widgets;
|
||||
mod zap;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[tokio::main]
|
||||
pub async fn android_main(app: android_activity::AndroidApp) {
|
||||
android::start_android(app);
|
||||
}
|
202
src/link.rs
Normal file
202
src/link.rs
Normal file
@ -0,0 +1,202 @@
|
||||
use crate::note_util::NoteUtil;
|
||||
use bech32::{Hrp, NoChecksum};
|
||||
use nostr::prelude::{hex, Coordinate};
|
||||
use nostr::{Kind, PublicKey};
|
||||
use nostrdb::{Filter, NdbStrVariant, Note};
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||
pub struct NostrLink {
|
||||
pub hrp: NostrLinkType,
|
||||
pub id: IdOrStr,
|
||||
pub kind: Option<u32>,
|
||||
pub author: Option<[u8; 32]>,
|
||||
pub relays: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||
pub enum IdOrStr {
|
||||
Id([u8; 32]),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
impl IdOrStr {
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
match self {
|
||||
IdOrStr::Id(i) => i,
|
||||
IdOrStr::Str(s) => s.as_bytes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for IdOrStr {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
IdOrStr::Id(id) => write!(f, "{}", hex::encode(id)),
|
||||
IdOrStr::Str(str) => write!(f, "{}", str),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||
pub enum NostrLinkType {
|
||||
Note,
|
||||
PublicKey,
|
||||
PrivateKey,
|
||||
|
||||
// TLV kinds
|
||||
Event,
|
||||
Profile,
|
||||
Coordinate,
|
||||
}
|
||||
|
||||
impl NostrLink {
|
||||
pub fn new(
|
||||
hrp: NostrLinkType,
|
||||
id: IdOrStr,
|
||||
kind: Option<u32>,
|
||||
author: Option<[u8; 32]>,
|
||||
relays: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
hrp,
|
||||
id,
|
||||
kind,
|
||||
author,
|
||||
relays,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_note(note: &Note<'_>) -> Self {
|
||||
if note.kind() >= 30_000 && note.kind() < 40_000 {
|
||||
Self {
|
||||
hrp: NostrLinkType::Coordinate,
|
||||
id: IdOrStr::Str(
|
||||
note.get_tag_value("d")
|
||||
.map(|t| match t.variant() {
|
||||
NdbStrVariant::Id(s) => hex::encode(s),
|
||||
NdbStrVariant::Str(s) => s.to_owned(),
|
||||
})
|
||||
.unwrap_or(String::from("")),
|
||||
),
|
||||
kind: Some(note.kind()),
|
||||
author: Some(*note.pubkey()),
|
||||
relays: vec![],
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
hrp: NostrLinkType::Event,
|
||||
id: IdOrStr::Id(*note.id()),
|
||||
kind: Some(note.kind()),
|
||||
author: Some(*note.pubkey()),
|
||||
relays: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile(pubkey: &[u8; 32]) -> Self {
|
||||
Self {
|
||||
hrp: NostrLinkType::Profile,
|
||||
id: IdOrStr::Id(*pubkey),
|
||||
kind: None,
|
||||
author: None,
|
||||
relays: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_tag(&self) -> Vec<String> {
|
||||
if self.hrp == NostrLinkType::Coordinate {
|
||||
vec!["a".to_string(), self.to_tag_value()]
|
||||
} else {
|
||||
vec!["e".to_string(), self.to_tag_value()]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_tag_value(&self) -> String {
|
||||
if self.hrp == NostrLinkType::Coordinate {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
self.kind.unwrap(),
|
||||
hex::encode(self.author.unwrap()),
|
||||
self.id
|
||||
)
|
||||
} else {
|
||||
self.id.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<Filter> for &NostrLink {
|
||||
type Error = ();
|
||||
fn try_into(self) -> Result<Filter, Self::Error> {
|
||||
match self.hrp {
|
||||
NostrLinkType::Coordinate => Ok(Filter::new()
|
||||
.kinds([self.kind.unwrap() as u64])
|
||||
.authors([&self.author.unwrap()])
|
||||
.tags(
|
||||
[match self.id {
|
||||
IdOrStr::Str(ref s) => s.to_owned(),
|
||||
IdOrStr::Id(ref i) => hex::encode(i),
|
||||
}],
|
||||
'd',
|
||||
)
|
||||
.build()),
|
||||
NostrLinkType::Event | NostrLinkType::Note => Ok(Filter::new().build()),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for NostrLinkType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Note => write!(f, "note"),
|
||||
Self::PublicKey => write!(f, "npub"),
|
||||
Self::PrivateKey => write!(f, "nsec"),
|
||||
Self::Event => write!(f, "nevent"),
|
||||
Self::Profile => write!(f, "nprofile"),
|
||||
Self::Coordinate => write!(f, "naddr"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrLinkType {
|
||||
pub fn to_hrp(&self) -> Hrp {
|
||||
let str = self.to_string();
|
||||
Hrp::parse(str.as_str()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for NostrLink {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self.hrp {
|
||||
NostrLinkType::Note | NostrLinkType::PrivateKey | NostrLinkType::PublicKey => {
|
||||
Ok(bech32::encode_to_fmt::<NoChecksum, Formatter>(
|
||||
f,
|
||||
self.hrp.to_hrp(),
|
||||
match &self.id {
|
||||
IdOrStr::Str(s) => s.as_bytes(),
|
||||
IdOrStr::Id(i) => i,
|
||||
},
|
||||
)
|
||||
.map_err(|e| std::fmt::Error)?)
|
||||
}
|
||||
NostrLinkType::Event | NostrLinkType::Profile | NostrLinkType::Coordinate => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<Coordinate> for NostrLink {
|
||||
type Error = ();
|
||||
|
||||
fn try_into(self) -> Result<Coordinate, Self::Error> {
|
||||
match self.hrp {
|
||||
NostrLinkType::Coordinate => Ok(Coordinate::new(
|
||||
Kind::from_u16(self.kind.unwrap() as u16),
|
||||
PublicKey::from_slice(&self.author.unwrap()).unwrap(),
|
||||
)
|
||||
.identifier(self.id.to_string())),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
13
src/logo.svg
13
src/logo.svg
@ -1,13 +0,0 @@
|
||||
<svg width="33" height="24" viewBox="0 0 33 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4303_1419)">
|
||||
<path d="M4.45977 2.75527L4.44727 2.74902C4.44727 2.74902 4.45352 2.74902 4.45977 2.75527Z" fill="white"/>
|
||||
<path d="M8.97119 1.70361L9.00244 1.71611C8.98994 1.70986 8.98369 1.70361 8.97119 1.70361Z" fill="white"/>
|
||||
<path d="M9.01006 1.71762C9.00381 1.71137 9.00381 1.71762 9.01006 1.71762V1.71762Z" fill="white"/>
|
||||
<path d="M32.7877 2.41234C32.3558 1.36818 31.3439 0.691406 30.216 0.691406H10.6802C10.6738 0.691406 10.6673 0.691406 10.6609 0.691406C10.6545 0.691406 10.648 0.691406 10.6416 0.691406C6.54235 0.691406 3.21012 4.02369 3.21012 8.11654C3.21012 10.2693 4.13825 12.2738 5.70446 13.6661L0.812466 18.5581C0.0132476 19.3574 -0.225229 20.5498 0.206607 21.5875C0.638443 22.6316 1.65036 23.3084 2.77829 23.3084H22.314C22.3205 23.3084 22.3269 23.3084 22.3398 23.3084C22.3463 23.3084 22.3527 23.3084 22.3656 23.3084C26.4584 23.3084 29.7906 19.9761 29.7906 15.8833C29.7906 13.7305 28.8625 11.726 27.2963 10.3338L32.1882 5.44169C32.981 4.64245 33.2195 3.45005 32.7877 2.41234ZM2.71383 20.5498C2.6945 20.5047 2.70739 20.4918 2.73317 20.466L8.10856 15.0905L22.3914 20.1437C22.4043 20.1502 22.4236 20.1566 22.4365 20.1566C22.4558 20.163 22.4752 20.1695 22.4945 20.1824C22.5267 20.2017 22.5525 20.2339 22.5718 20.2726C22.5783 20.2791 22.5783 20.2855 22.5847 20.292C22.5912 20.3048 22.5912 20.3177 22.5912 20.3306C22.5912 20.3435 22.5976 20.3564 22.5976 20.3693C22.5976 20.4853 22.4687 20.5884 22.3269 20.5884H2.78473C2.75251 20.6013 2.73317 20.6013 2.71383 20.5498ZM25.208 19.6474C25.208 19.641 25.2015 19.6281 25.2015 19.6216C25.1757 19.5185 25.1435 19.4218 25.1048 19.3251C25.0984 19.3122 25.0984 19.2994 25.092 19.2865C25.0533 19.1833 25.0017 19.0867 24.9502 18.99C24.9437 18.9771 24.9308 18.9577 24.9244 18.9449C24.8148 18.7515 24.6859 18.571 24.5377 18.4034C24.5248 18.3905 24.5119 18.3777 24.499 18.3648C24.4216 18.2874 24.3443 18.2101 24.2605 18.1392C24.2476 18.1327 24.2347 18.1198 24.2283 18.1134C24.1509 18.0489 24.0672 17.9909 23.9769 17.9329C23.964 17.9265 23.9511 17.9136 23.9382 17.9071C23.848 17.8491 23.7513 17.7976 23.6547 17.746C23.6353 17.7331 23.6095 17.7267 23.5902 17.7138C23.4935 17.6687 23.3904 17.6235 23.2808 17.5913L16.6744 15.2516L9.03668 12.551L9.0109 12.5445C9.00446 12.5445 9.00446 12.5445 8.99801 12.5381C8.8111 12.4672 8.62418 12.3834 8.43727 12.2867C6.88395 11.4682 5.9236 9.86969 5.9236 8.11654C5.9236 6.58253 6.65836 5.2161 7.79918 4.35241C7.79918 4.35885 7.80563 4.37175 7.80563 4.37819C7.83141 4.47487 7.86364 4.57155 7.90231 4.66823C7.9152 4.70046 7.92809 4.73269 7.94098 4.76492C7.97321 4.83582 8.00543 4.90027 8.03766 4.97117C8.05055 5.0034 8.06989 5.03562 8.08278 5.06141C8.1279 5.1452 8.17946 5.22254 8.23747 5.29989C8.26325 5.33211 8.28258 5.36434 8.30836 5.39657C8.35348 5.45458 8.3986 5.51259 8.45016 5.56415C8.4695 5.58993 8.48883 5.61571 8.51462 5.63505C8.57907 5.70595 8.65641 5.7704 8.72731 5.83486C8.75309 5.86064 8.77887 5.87998 8.8111 5.89931C8.882 5.95732 8.9529 6.00888 9.03024 6.06045C9.04313 6.07334 9.05602 6.07978 9.07536 6.09267C9.16559 6.15068 9.26227 6.20225 9.35895 6.24736C9.38473 6.26026 9.41051 6.27315 9.43629 6.28604C9.53942 6.33116 9.64254 6.37627 9.74567 6.4085L24.0091 11.4553C24.0156 11.4553 24.022 11.4617 24.022 11.4617C24.209 11.5326 24.3894 11.61 24.5634 11.7066C26.1168 12.5252 27.0771 14.1237 27.0771 15.8768C27.0836 17.4173 26.3423 18.7837 25.208 19.6474ZM30.2675 3.52739L24.8922 8.90288C24.8793 8.89643 24.8599 8.88999 24.847 8.88354L10.6222 3.85611C10.6029 3.84967 10.5836 3.84322 10.5707 3.83677H10.5642C10.5449 3.83033 10.532 3.82388 10.5127 3.81099C10.5062 3.80455 10.4998 3.7981 10.4933 3.79166C10.4804 3.78521 10.474 3.77232 10.4675 3.76588C10.4611 3.75943 10.4547 3.74654 10.4547 3.74009C10.4482 3.7272 10.4418 3.71431 10.4353 3.70142C10.4353 3.69498 10.4289 3.68208 10.4289 3.66919C10.4289 3.6563 10.4224 3.63697 10.4224 3.61763C10.4224 3.60474 10.4289 3.59829 10.4289 3.5854C10.4482 3.48872 10.5642 3.40493 10.6867 3.40493H30.2289C30.2611 3.40493 30.2804 3.40493 30.2998 3.45005C30.3062 3.48872 30.2933 3.50806 30.2675 3.52739Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4303_1419">
|
||||
<rect width="33" height="22.617" fill="white" transform="translate(0 0.691406)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
Before Width: | Height: | Size: 4.3 KiB |
22
src/main.rs
22
src/main.rs
@ -1,22 +0,0 @@
|
||||
use crate::app::ZapStreamApp;
|
||||
use egui::Vec2;
|
||||
|
||||
mod app;
|
||||
mod widget;
|
||||
mod widgets;
|
||||
mod services;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
pretty_env_logger::init();
|
||||
|
||||
let mut options = eframe::NativeOptions::default();
|
||||
options.viewport = options.viewport
|
||||
.with_inner_size(Vec2::new(360., 720.));
|
||||
|
||||
let _res = eframe::run_native(
|
||||
"zap.stream",
|
||||
options,
|
||||
Box::new(move |cc| Ok(Box::new(ZapStreamApp::new(cc)))),
|
||||
);
|
||||
}
|
43
src/note_ref.rs
Normal file
43
src/note_ref.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use nostrdb::{Note, NoteKey, QueryResult};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash)]
|
||||
pub struct NoteRef {
|
||||
pub key: NoteKey,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl NoteRef {
|
||||
pub fn new(key: NoteKey, created_at: u64) -> Self {
|
||||
NoteRef { key, created_at }
|
||||
}
|
||||
|
||||
pub fn from_note(note: &Note<'_>) -> Self {
|
||||
let created_at = note.created_at();
|
||||
let key = note.key().expect("todo: implement NoteBuf");
|
||||
NoteRef::new(key, created_at)
|
||||
}
|
||||
|
||||
pub fn from_query_result(qr: QueryResult<'_>) -> Self {
|
||||
NoteRef {
|
||||
key: qr.note_key,
|
||||
created_at: qr.note.created_at(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for NoteRef {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match self.created_at.cmp(&other.created_at) {
|
||||
Ordering::Equal => self.key.cmp(&other.key),
|
||||
Ordering::Less => Ordering::Greater,
|
||||
Ordering::Greater => Ordering::Less,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for NoteRef {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
66
src/note_util.rs
Normal file
66
src/note_util.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use nostr::prelude::hex;
|
||||
use nostrdb::{NdbStr, Note, Tag};
|
||||
|
||||
pub trait NoteUtil {
|
||||
fn id_hex(&self) -> String;
|
||||
fn get_tag_value(&self, key: &str) -> Option<NdbStr>;
|
||||
fn find_tag_value<F>(&self, fx: F) -> Option<NdbStr>
|
||||
where
|
||||
F: Fn(Vec<NdbStr>) -> bool;
|
||||
}
|
||||
|
||||
impl NoteUtil for Note<'_> {
|
||||
fn id_hex(&self) -> String {
|
||||
hex::encode(self.id())
|
||||
}
|
||||
|
||||
fn get_tag_value(&self, key: &str) -> Option<NdbStr> {
|
||||
self.find_tag_value(|t| t[0].variant().str() == Some(key))
|
||||
}
|
||||
|
||||
fn find_tag_value<F>(&self, fx: F) -> Option<NdbStr>
|
||||
where
|
||||
F: Fn(Vec<NdbStr>) -> bool,
|
||||
{
|
||||
let tag = self.tags().iter().find(|t| {
|
||||
let tag_vec = TagIterBorrow::new(t).collect();
|
||||
fx(tag_vec)
|
||||
});
|
||||
if let Some(t) = tag {
|
||||
t.get(1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagIterBorrow<'a> {
|
||||
tag: &'a Tag<'a>,
|
||||
index: u16,
|
||||
}
|
||||
|
||||
impl<'a> TagIterBorrow<'a> {
|
||||
pub fn new(tag: &'a Tag<'a>) -> Self {
|
||||
let index = 0;
|
||||
TagIterBorrow { tag, index }
|
||||
}
|
||||
|
||||
pub fn done(&self) -> bool {
|
||||
self.index >= self.tag.count()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for TagIterBorrow<'a> {
|
||||
type Item = NdbStr<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<NdbStr<'a>> {
|
||||
let tag = self.tag.get(self.index);
|
||||
if tag.is_some() {
|
||||
self.index += 1;
|
||||
tag
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
49
src/note_view.rs
Normal file
49
src/note_view.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use crate::link::NostrLink;
|
||||
use nostrdb::Note;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct NotesView<'a> {
|
||||
events: HashMap<String, &'a Note<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> NotesView<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
pub fn from_vec(events: Vec<&'a Note<'a>>) -> Self {
|
||||
let mut store = Self::new();
|
||||
for note in events {
|
||||
store.add(note);
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
pub fn add(&mut self, note: &'a Note<'a>) -> Option<&'a Note<'a>> {
|
||||
let k = Self::key(note);
|
||||
if let Some(v) = self.events.get(&k) {
|
||||
if v.created_at() < note.created_at() {
|
||||
return self.events.insert(k, note);
|
||||
}
|
||||
}
|
||||
self.events.insert(k, note)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, note: &Note<'a>) -> Option<&'a Note<'a>> {
|
||||
self.events.remove(&Self::key(note))
|
||||
}
|
||||
|
||||
pub fn key(note: &Note<'a>) -> String {
|
||||
NostrLink::from_note(note).to_tag_value()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &&Note<'a>> {
|
||||
self.events.values()
|
||||
}
|
||||
}
|
30
src/profiles.rs
Normal file
30
src/profiles.rs
Normal file
@ -0,0 +1,30 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct ProfileLoader {
|
||||
queue: HashSet<[u8; 32]>,
|
||||
fetched: HashSet<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl ProfileLoader {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
queue: HashSet::new(),
|
||||
fetched: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn demand(&mut self, pubkey: [u8; 32]) {
|
||||
if self.fetched.contains(&pubkey) {
|
||||
return;
|
||||
}
|
||||
self.queue.insert(pubkey);
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> Vec<[u8; 32]> {
|
||||
let ret: Vec<[u8; 32]> = self.queue.drain().collect();
|
||||
for p in ret.iter() {
|
||||
self.fetched.insert(*p);
|
||||
}
|
||||
ret
|
||||
}
|
||||
}
|
4
src/resources/logo.svg
Normal file
4
src/resources/logo.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="33" height="24" viewBox="0 0 33 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.7877 2.41234C32.3558 1.36818 31.3439 0.691406 30.216 0.691406H10.6802C10.6738 0.691406 10.6673 0.691406 10.6609 0.691406C10.6545 0.691406 10.648 0.691406 10.6416 0.691406C6.54235 0.691406 3.21012 4.02369 3.21012 8.11654C3.21012 10.2693 4.13825 12.2738 5.70446 13.6661L0.812466 18.5581C0.0132476 19.3574 -0.225229 20.5498 0.206607 21.5875C0.638443 22.6316 1.65036 23.3084 2.77829 23.3084H22.314C22.3205 23.3084 22.3269 23.3084 22.3398 23.3084C22.3463 23.3084 22.3527 23.3084 22.3656 23.3084C26.4584 23.3084 29.7906 19.9761 29.7906 15.8833C29.7906 13.7305 28.8625 11.726 27.2963 10.3338L32.1882 5.44169C32.981 4.64245 33.2195 3.45005 32.7877 2.41234ZM2.71383 20.5498C2.6945 20.5047 2.70739 20.4918 2.73317 20.466L8.10856 15.0905L22.3914 20.1437C22.4043 20.1502 22.4236 20.1566 22.4365 20.1566C22.4558 20.163 22.4752 20.1695 22.4945 20.1824C22.5267 20.2017 22.5525 20.2339 22.5718 20.2726C22.5783 20.2791 22.5783 20.2855 22.5847 20.292C22.5912 20.3048 22.5912 20.3177 22.5912 20.3306C22.5912 20.3435 22.5976 20.3564 22.5976 20.3693C22.5976 20.4853 22.4687 20.5884 22.3269 20.5884H2.78473C2.75251 20.6013 2.73317 20.6013 2.71383 20.5498ZM25.208 19.6474C25.208 19.641 25.2015 19.6281 25.2015 19.6216C25.1757 19.5185 25.1435 19.4218 25.1048 19.3251C25.0984 19.3122 25.0984 19.2994 25.092 19.2865C25.0533 19.1833 25.0017 19.0867 24.9502 18.99C24.9437 18.9771 24.9308 18.9577 24.9244 18.9449C24.8148 18.7515 24.6859 18.571 24.5377 18.4034C24.5248 18.3905 24.5119 18.3777 24.499 18.3648C24.4216 18.2874 24.3443 18.2101 24.2605 18.1392C24.2476 18.1327 24.2347 18.1198 24.2283 18.1134C24.1509 18.0489 24.0672 17.9909 23.9769 17.9329C23.964 17.9265 23.9511 17.9136 23.9382 17.9071C23.848 17.8491 23.7513 17.7976 23.6547 17.746C23.6353 17.7331 23.6095 17.7267 23.5902 17.7138C23.4935 17.6687 23.3904 17.6235 23.2808 17.5913L16.6744 15.2516L9.03668 12.551L9.0109 12.5445C9.00446 12.5445 9.00446 12.5445 8.99801 12.5381C8.8111 12.4672 8.62418 12.3834 8.43727 12.2867C6.88395 11.4682 5.9236 9.86969 5.9236 8.11654C5.9236 6.58253 6.65836 5.2161 7.79918 4.35241C7.79918 4.35885 7.80563 4.37175 7.80563 4.37819C7.83141 4.47487 7.86364 4.57155 7.90231 4.66823C7.9152 4.70046 7.92809 4.73269 7.94098 4.76492C7.97321 4.83582 8.00543 4.90027 8.03766 4.97117C8.05055 5.0034 8.06989 5.03562 8.08278 5.06141C8.1279 5.1452 8.17946 5.22254 8.23747 5.29989C8.26325 5.33211 8.28258 5.36434 8.30836 5.39657C8.35348 5.45458 8.3986 5.51259 8.45016 5.56415C8.4695 5.58993 8.48883 5.61571 8.51462 5.63505C8.57907 5.70595 8.65641 5.7704 8.72731 5.83486C8.75309 5.86064 8.77887 5.87998 8.8111 5.89931C8.882 5.95732 8.9529 6.00888 9.03024 6.06045C9.04313 6.07334 9.05602 6.07978 9.07536 6.09267C9.16559 6.15068 9.26227 6.20225 9.35895 6.24736C9.38473 6.26026 9.41051 6.27315 9.43629 6.28604C9.53942 6.33116 9.64254 6.37627 9.74567 6.4085L24.0091 11.4553C24.0156 11.4553 24.022 11.4617 24.022 11.4617C24.209 11.5326 24.3894 11.61 24.5634 11.7066C26.1168 12.5252 27.0771 14.1237 27.0771 15.8768C27.0836 17.4173 26.3423 18.7837 25.208 19.6474ZM30.2675 3.52739L24.8922 8.90288C24.8793 8.89643 24.8599 8.88999 24.847 8.88354L10.6222 3.85611C10.6029 3.84967 10.5836 3.84322 10.5707 3.83677H10.5642C10.5449 3.83033 10.532 3.82388 10.5127 3.81099C10.5062 3.80455 10.4998 3.7981 10.4933 3.79166C10.4804 3.78521 10.474 3.77232 10.4675 3.76588C10.4611 3.75943 10.4547 3.74654 10.4547 3.74009C10.4482 3.7272 10.4418 3.71431 10.4353 3.70142C10.4353 3.69498 10.4289 3.68208 10.4289 3.66919C10.4289 3.6563 10.4224 3.63697 10.4224 3.61763C10.4224 3.60474 10.4289 3.59829 10.4289 3.5854C10.4482 3.48872 10.5642 3.40493 10.6867 3.40493H30.2289C30.2611 3.40493 30.2804 3.40493 30.2998 3.45005C30.3062 3.48872 30.2933 3.50806 30.2675 3.52739Z"
|
||||
fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3.7 KiB |
3
src/resources/send-03.svg
Normal file
3
src/resources/send-03.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.5004 11.9998H5.00043M4.91577 12.2913L2.58085 19.266C2.39742 19.8139 2.3057 20.0879 2.37152 20.2566C2.42868 20.4031 2.55144 20.5142 2.70292 20.5565C2.87736 20.6052 3.14083 20.4866 3.66776 20.2495L20.3792 12.7293C20.8936 12.4979 21.1507 12.3822 21.2302 12.2214C21.2993 12.0817 21.2993 11.9179 21.2302 11.7782C21.1507 11.6174 20.8936 11.5017 20.3792 11.2703L3.66193 3.74751C3.13659 3.51111 2.87392 3.39291 2.69966 3.4414C2.54832 3.48351 2.42556 3.59429 2.36821 3.74054C2.30216 3.90893 2.3929 4.18231 2.57437 4.72906L4.91642 11.7853C4.94759 11.8792 4.96317 11.9262 4.96933 11.9742C4.97479 12.0168 4.97473 12.0599 4.96916 12.1025C4.96289 12.1506 4.94718 12.1975 4.91577 12.2913Z" stroke="#737373" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 878 B |
105
src/route/home.rs
Normal file
105
src/route/home.rs
Normal file
@ -0,0 +1,105 @@
|
||||
use crate::note_ref::NoteRef;
|
||||
use crate::note_view::NotesView;
|
||||
use crate::route::RouteServices;
|
||||
use crate::stream_info::{StreamInfo, StreamStatus};
|
||||
use crate::sub::SubRef;
|
||||
use crate::widgets;
|
||||
use crate::widgets::{sub_or_poll, NostrWidget};
|
||||
use egui::{Id, Response, RichText, ScrollArea, Ui};
|
||||
use nostrdb::{Filter, Note};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct HomePage {
|
||||
events: HashSet<NoteRef>,
|
||||
sub: Option<SubRef>,
|
||||
}
|
||||
|
||||
impl HomePage {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: HashSet::new(),
|
||||
sub: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filters() -> Vec<Filter> {
|
||||
vec![Filter::new().kinds([30_311, 30_313]).limit(100).build()]
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrWidget for HomePage {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
ScrollArea::vertical()
|
||||
.show(ui, |ui| {
|
||||
let events: Vec<Note> = self
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|n| services.ctx.ndb.get_note_by_key(services.tx, n.key).ok())
|
||||
.filter(|e| e.can_play())
|
||||
.collect();
|
||||
|
||||
let events_live = NotesView::from_vec(
|
||||
events
|
||||
.iter()
|
||||
.filter(|r| matches!(r.status(), StreamStatus::Live))
|
||||
.collect(),
|
||||
);
|
||||
if events_live.len() > 0 {
|
||||
widgets::StreamList::new(
|
||||
Id::new("live-streams"),
|
||||
events_live,
|
||||
Some(RichText::new("Live").size(32.0)),
|
||||
)
|
||||
.render(ui, services);
|
||||
}
|
||||
let events_planned = NotesView::from_vec(
|
||||
events
|
||||
.iter()
|
||||
.filter(|r| matches!(r.status(), StreamStatus::Planned))
|
||||
.collect(),
|
||||
);
|
||||
if events_planned.len() > 0 {
|
||||
widgets::StreamList::new(
|
||||
Id::new("planned-streams"),
|
||||
events_planned,
|
||||
Some(RichText::new("Planned").size(32.0)),
|
||||
)
|
||||
.render(ui, services);
|
||||
}
|
||||
let events_ended = NotesView::from_vec(
|
||||
events
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
matches!(r.status(), StreamStatus::Ended)
|
||||
&& if let Some(r) = r.recording() {
|
||||
r.len() > 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if events_ended.len() > 0 {
|
||||
widgets::StreamList::new(
|
||||
Id::new("ended-streams"),
|
||||
events_ended,
|
||||
Some(RichText::new("Ended").size(32.0)),
|
||||
)
|
||||
.render(ui, services);
|
||||
}
|
||||
ui.response()
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
sub_or_poll(
|
||||
services.ctx.ndb,
|
||||
services.tx,
|
||||
services.ctx.pool,
|
||||
&mut self.events,
|
||||
&mut self.sub,
|
||||
Self::get_filters(),
|
||||
)
|
||||
}
|
||||
}
|
94
src/route/login.rs
Normal file
94
src/route/login.rs
Normal file
@ -0,0 +1,94 @@
|
||||
use crate::route::{RouteServices, RouteType};
|
||||
use crate::widgets::{Button, NativeTextInput, NostrWidget};
|
||||
use egui::{Color32, Frame, Margin, Response, RichText, Ui};
|
||||
use nostr::prelude::hex;
|
||||
use nostr::SecretKey;
|
||||
|
||||
pub struct LoginPage {
|
||||
key: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl LoginPage {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
key: String::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrWidget for LoginPage {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
Frame::none()
|
||||
.inner_margin(Margin::same(12.))
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.;
|
||||
|
||||
ui.label(RichText::new("Login").size(32.));
|
||||
let input = NativeTextInput::new(&mut self.key).with_hint_text("npub/nsec");
|
||||
ui.add(input);
|
||||
|
||||
if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
|
||||
if let Ok((hrp, key)) = bech32::decode(&self.key) {
|
||||
match hrp.to_lowercase().as_str() {
|
||||
"nsec" => {
|
||||
let mut ids = services.ctx.accounts.add_account(
|
||||
enostr::Keypair::from_secret(
|
||||
SecretKey::from_slice(key.as_slice()).unwrap(),
|
||||
),
|
||||
);
|
||||
ids.process_action(
|
||||
services.ctx.unknown_ids,
|
||||
services.ctx.ndb,
|
||||
services.tx,
|
||||
);
|
||||
services.ctx.accounts.select_account(0);
|
||||
services.navigate(RouteType::HomePage);
|
||||
}
|
||||
"npub" | "nprofile" => {
|
||||
let mut ids =
|
||||
services.ctx.accounts.add_account(enostr::Keypair::new(
|
||||
enostr::Pubkey::new(key.as_slice().try_into().unwrap()),
|
||||
None,
|
||||
));
|
||||
ids.process_action(
|
||||
services.ctx.unknown_ids,
|
||||
services.ctx.ndb,
|
||||
services.tx,
|
||||
);
|
||||
services.ctx.accounts.select_account(0);
|
||||
services.navigate(RouteType::HomePage);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if let Ok(pk) = hex::decode(&self.key) {
|
||||
if let Ok(pk) = pk.as_slice().try_into() {
|
||||
let mut ids = services.ctx.accounts.add_account(
|
||||
enostr::Keypair::new(enostr::Pubkey::new(pk), None),
|
||||
);
|
||||
ids.process_action(
|
||||
services.ctx.unknown_ids,
|
||||
services.ctx.ndb,
|
||||
services.tx,
|
||||
);
|
||||
services.navigate(RouteType::HomePage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.error = Some("Invalid pubkey".to_string());
|
||||
}
|
||||
if let Some(e) = &self.error {
|
||||
ui.label(RichText::new(e).color(Color32::RED));
|
||||
}
|
||||
})
|
||||
.response
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
fn update(&mut self, _services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
272
src/route/mod.rs
Normal file
272
src/route/mod.rs
Normal file
@ -0,0 +1,272 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::services::ffmpeg_loader::FfmpegLoader;
|
||||
use crate::widgets::PlaceholderRect;
|
||||
use anyhow::{anyhow, bail};
|
||||
use egui::load::SizedTexture;
|
||||
use egui::{vec2, Context, Id, Image, ImageSource, TextureHandle, Ui, Vec2};
|
||||
use egui_video::ffmpeg_rs_raw::Transcoder;
|
||||
use ehttp::Response;
|
||||
use enostr::EventClientMessage;
|
||||
use lnurl::lightning_address::LightningAddress;
|
||||
use lnurl::pay::PayResponse;
|
||||
use lnurl::LnUrlResponse;
|
||||
use log::{info, warn};
|
||||
use nostr::{serde_json, Event, EventBuilder, JsonUtil, Keys, Kind, SecretKey, Tag};
|
||||
use nostrdb::{NdbProfile, NoteKey, Transaction};
|
||||
use notedeck::{AppContext, ImageCache};
|
||||
use poll_promise::Promise;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::task::Poll;
|
||||
|
||||
mod home;
|
||||
mod login;
|
||||
mod profile;
|
||||
mod stream;
|
||||
|
||||
pub mod page {
|
||||
pub use super::home::HomePage;
|
||||
pub use super::login::LoginPage;
|
||||
pub use super::profile::ProfilePage;
|
||||
pub use super::stream::StreamPage;
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum RouteType {
|
||||
HomePage,
|
||||
EventPage {
|
||||
link: NostrLink,
|
||||
event: Option<NoteKey>,
|
||||
},
|
||||
ProfilePage {
|
||||
link: NostrLink,
|
||||
},
|
||||
LoginPage,
|
||||
|
||||
// special kind for modifying route state
|
||||
Action(RouteAction),
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum RouteAction {
|
||||
DemandProfile([u8; 32]),
|
||||
}
|
||||
|
||||
pub struct RouteServices<'a, 'ctx> {
|
||||
pub egui: Context,
|
||||
pub tx: &'a Transaction,
|
||||
pub ctx: &'a mut AppContext<'ctx>,
|
||||
|
||||
router: mpsc::Sender<RouteType>,
|
||||
fetch: &'a mut HashMap<String, Promise<ehttp::Result<Response>>>,
|
||||
}
|
||||
|
||||
impl<'a, 'ctx> RouteServices<'a, 'ctx> {
|
||||
pub fn new(
|
||||
egui: Context,
|
||||
tx: &'a Transaction,
|
||||
ctx: &'a mut AppContext<'ctx>,
|
||||
router: mpsc::Sender<RouteType>,
|
||||
fetch: &'a mut HashMap<String, Promise<ehttp::Result<Response>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
egui,
|
||||
tx,
|
||||
ctx,
|
||||
router,
|
||||
fetch,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigate(&self, route: RouteType) {
|
||||
self.router.send(route).expect("route send failed");
|
||||
self.egui.request_repaint();
|
||||
}
|
||||
|
||||
pub fn action(&self, route: RouteAction) {
|
||||
self.router
|
||||
.send(RouteType::Action(route))
|
||||
.expect("route send failed");
|
||||
self.egui.request_repaint();
|
||||
}
|
||||
|
||||
pub fn broadcast_event(&mut self, event: Event) {
|
||||
let ev_json = event.as_json();
|
||||
if let Err(e) = self.ctx.ndb.process_event(&ev_json) {
|
||||
warn!("Failed to submit event {}", e);
|
||||
}
|
||||
self.ctx
|
||||
.pool
|
||||
.send(&enostr::ClientMessage::Event(EventClientMessage {
|
||||
note_json: ev_json,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Load/Fetch profiles
|
||||
pub fn profile(&self, pk: &[u8; 32]) -> Option<NdbProfile<'a>> {
|
||||
let p = self
|
||||
.ctx
|
||||
.ndb
|
||||
.get_profile_by_pubkey(self.tx, pk)
|
||||
.map(|p| p.record().profile())
|
||||
.ok()
|
||||
.flatten();
|
||||
if p.is_none() {
|
||||
self.action(RouteAction::DemandProfile(*pk));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Create a poll_promise fetch
|
||||
pub fn fetch(&mut self, url: &str) -> Poll<&ehttp::Result<Response>> {
|
||||
if !self.fetch.contains_key(url) {
|
||||
let (sender, promise) = Promise::new();
|
||||
let request = ehttp::Request::get(url);
|
||||
let ctx = self.egui.clone();
|
||||
ehttp::fetch(request, move |response| {
|
||||
sender.send(response);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
info!("Fetching {}", url);
|
||||
self.fetch.insert(url.to_string(), promise);
|
||||
}
|
||||
self.fetch.get(url).expect("fetch").poll()
|
||||
}
|
||||
|
||||
pub fn fetch_lnurlp(&mut self, pubkey: &[u8; 32]) -> anyhow::Result<Poll<PayResponse>> {
|
||||
let target = self
|
||||
.profile(pubkey)
|
||||
.and_then(|p| p.lud16())
|
||||
.ok_or(anyhow!("No lightning address found"))?;
|
||||
|
||||
let addr = LightningAddress::new(target)?;
|
||||
match self.fetch(&addr.lnurlp_url()) {
|
||||
Poll::Ready(Ok(r)) => {
|
||||
if r.ok {
|
||||
let rsp: PayResponse = serde_json::from_slice(&r.bytes)?;
|
||||
Ok(Poll::Ready(rsp))
|
||||
} else {
|
||||
bail!("Invalid response code {}", r.status);
|
||||
}
|
||||
}
|
||||
Poll::Ready(Err(e)) => Err(anyhow!("{}", e)),
|
||||
Poll::Pending => Ok(Poll::Pending),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_live_chat_msg(&self, link: &NostrLink, msg: &str) -> Option<Event> {
|
||||
if msg.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(key) = self.current_account_keys() {
|
||||
EventBuilder::new(Kind::LiveEventMessage, msg)
|
||||
.tag(Tag::parse(link.to_tag()).unwrap())
|
||||
.sign_with_keys(&key)
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_account_keys(&self) -> Option<Keys> {
|
||||
self.ctx
|
||||
.accounts
|
||||
.get_selected_account()
|
||||
.and_then(|acc| acc.secret_key.as_ref().map(|k| Keys::new(k.clone())))
|
||||
}
|
||||
|
||||
/// Simple wrapper around egui temp data
|
||||
pub fn get<T: Clone + 'static>(&self, k: &str) -> Option<T> {
|
||||
let id = Id::new(k);
|
||||
self.egui.data(|d| d.get_temp(id))
|
||||
}
|
||||
|
||||
/// Simple wrapper around egui temp data
|
||||
pub fn set<T: Clone + Send + Sync + 'static>(&mut self, k: &str, v: T) {
|
||||
self.egui.data_mut(|d| d.insert_temp(Id::new(k), v));
|
||||
}
|
||||
}
|
||||
|
||||
const LOGO_BYTES: &[u8] = include_bytes!("../resources/logo.svg");
|
||||
|
||||
pub fn image_from_cache<'a>(
|
||||
img_cache: &mut ImageCache,
|
||||
ui: &Ui,
|
||||
url: &str,
|
||||
size: Option<Vec2>,
|
||||
) -> Option<Image<'a>> {
|
||||
if url.len() == 0 {
|
||||
return None;
|
||||
}
|
||||
let cache_key = if let Some(s) = size {
|
||||
format!("{}:{}", url, s)
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
if let Some(promise) = img_cache.map().get(&cache_key) {
|
||||
match promise.poll() {
|
||||
Poll::Ready(Ok(t)) => Some(Image::new(SizedTexture::from_handle(t))),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
let fetch = fetch_img(img_cache, ui.ctx(), url, size);
|
||||
img_cache.map_mut().insert(cache_key.clone(), fetch);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_img(
|
||||
img_cache: &ImageCache,
|
||||
ctx: &Context,
|
||||
url: &str,
|
||||
size: Option<Vec2>,
|
||||
) -> Promise<notedeck::Result<TextureHandle>> {
|
||||
let name = ImageCache::key(url);
|
||||
let dst_path = img_cache.cache_dir.join(&name);
|
||||
if dst_path.exists() {
|
||||
let ctx = ctx.clone();
|
||||
Promise::spawn_thread("load_from_disk", move || {
|
||||
info!("Loading image from disk: {}", dst_path.display());
|
||||
match FfmpegLoader::new().load_image(dst_path, size) {
|
||||
Ok(img) => {
|
||||
ctx.request_repaint();
|
||||
ctx.forget_image(&name);
|
||||
Ok(ctx.load_texture(&name, img, Default::default()))
|
||||
}
|
||||
Err(e) => Err(notedeck::Error::Generic(e.to_string())),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
let url = url.to_string();
|
||||
let ctx = ctx.clone();
|
||||
Promise::spawn_thread("load_from_net", move || {
|
||||
let img = match fetch_img_from_net(&url).block_and_take() {
|
||||
Ok(img) => img,
|
||||
Err(e) => return Err(notedeck::Error::Generic(e.to_string())),
|
||||
};
|
||||
std::fs::create_dir_all(&dst_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&dst_path, &img.bytes).unwrap();
|
||||
|
||||
info!("Loading image from net: {}", &url);
|
||||
match FfmpegLoader::new().load_image(dst_path, size) {
|
||||
Ok(img) => {
|
||||
ctx.request_repaint();
|
||||
ctx.forget_image(&name);
|
||||
Ok(ctx.load_texture(&name, img, Default::default()))
|
||||
}
|
||||
Err(e) => Err(notedeck::Error::Generic(e.to_string())),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_img_from_net(url: &str) -> Promise<ehttp::Result<Response>> {
|
||||
let (sender, promise) = Promise::new();
|
||||
let request = ehttp::Request::get(url);
|
||||
info!("Downloaded image: {}", url);
|
||||
ehttp::fetch(request, move |response| {
|
||||
sender.send(response);
|
||||
});
|
||||
promise
|
||||
}
|
91
src/route/profile.rs
Normal file
91
src/route/profile.rs
Normal file
@ -0,0 +1,91 @@
|
||||
use crate::note_ref::NoteRef;
|
||||
use crate::note_view::NotesView;
|
||||
use crate::route::{image_from_cache, RouteServices};
|
||||
use crate::sub::SubRef;
|
||||
use crate::theme::{MARGIN_DEFAULT, ROUNDING_DEFAULT};
|
||||
use crate::widgets::{sub_or_poll, NostrWidget, PlaceholderRect, Profile, StreamList};
|
||||
use egui::{vec2, Frame, Id, Response, ScrollArea, Ui, Widget};
|
||||
use nostrdb::{Filter, Note};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct ProfilePage {
|
||||
pubkey: [u8; 32],
|
||||
events: HashSet<NoteRef>,
|
||||
sub: Option<SubRef>,
|
||||
}
|
||||
|
||||
impl ProfilePage {
|
||||
pub fn new(pubkey: [u8; 32]) -> Self {
|
||||
Self {
|
||||
pubkey,
|
||||
events: HashSet::new(),
|
||||
sub: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrWidget for ProfilePage {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
let profile = services.profile(&self.pubkey);
|
||||
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
Frame::default()
|
||||
.inner_margin(MARGIN_DEFAULT)
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
|
||||
if let Some(banner) = profile.map(|p| p.banner()).flatten() {
|
||||
if let Some(img) = image_from_cache(
|
||||
&mut services.ctx.img_cache,
|
||||
ui,
|
||||
banner,
|
||||
Some(vec2(ui.available_width(), 360.0)),
|
||||
) {
|
||||
img.rounding(ROUNDING_DEFAULT).ui(ui);
|
||||
} else {
|
||||
PlaceholderRect.ui(ui);
|
||||
}
|
||||
} else {
|
||||
ui.add(PlaceholderRect);
|
||||
}
|
||||
Profile::from_profile(&self.pubkey, &profile)
|
||||
.size(88.0)
|
||||
.render(ui, services);
|
||||
});
|
||||
|
||||
let events: Vec<Note> = self
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| services.ctx.ndb.get_note_by_key(services.tx, e.key).ok())
|
||||
.collect();
|
||||
|
||||
StreamList::new(
|
||||
Id::from("profile-streams"),
|
||||
NotesView::from_vec(events.iter().collect()),
|
||||
Some("Past Streams"),
|
||||
)
|
||||
.render(ui, services);
|
||||
});
|
||||
ui.response()
|
||||
}
|
||||
|
||||
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
sub_or_poll(
|
||||
services.ctx.ndb,
|
||||
services.tx,
|
||||
services.ctx.pool,
|
||||
&mut self.events,
|
||||
&mut self.sub,
|
||||
vec![
|
||||
Filter::new()
|
||||
.kinds([30_311])
|
||||
.authors(&[self.pubkey])
|
||||
.build(),
|
||||
Filter::new()
|
||||
.kinds([30_311])
|
||||
.pubkeys(&[self.pubkey])
|
||||
.build(),
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
200
src/route/stream.rs
Normal file
200
src/route/stream.rs
Normal file
@ -0,0 +1,200 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::route::RouteServices;
|
||||
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_800, ROUNDING_DEFAULT};
|
||||
use crate::widgets::{
|
||||
sub_or_poll, Chat, NostrWidget, PlaceholderRect, StreamPlayer, StreamTitle, WriteChat,
|
||||
};
|
||||
use egui::{vec2, Align, Frame, Layout, Response, ScrollArea, Stroke, Ui, Vec2, Widget};
|
||||
use nostrdb::{Filter, Note};
|
||||
|
||||
use crate::note_ref::NoteRef;
|
||||
use crate::stream_info::StreamInfo;
|
||||
use crate::sub::SubRef;
|
||||
use std::borrow::Borrow;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct StreamPage {
|
||||
link: NostrLink,
|
||||
player: Option<StreamPlayer>,
|
||||
chat: Option<Chat>,
|
||||
new_msg: WriteChat,
|
||||
|
||||
events: HashSet<NoteRef>,
|
||||
sub: Option<SubRef>,
|
||||
}
|
||||
|
||||
impl StreamPage {
|
||||
pub fn new_from_link(link: NostrLink) -> Self {
|
||||
Self {
|
||||
new_msg: WriteChat::new(link.clone()),
|
||||
link,
|
||||
chat: None,
|
||||
player: None,
|
||||
events: HashSet::new(),
|
||||
sub: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filters(&self) -> Vec<Filter> {
|
||||
let f: Filter = self.link.borrow().try_into().unwrap();
|
||||
vec![f.limit_mut(1)]
|
||||
}
|
||||
|
||||
fn render_mobile(
|
||||
&mut self,
|
||||
event: &Note<'_>,
|
||||
ui: &mut Ui,
|
||||
services: &mut RouteServices<'_, '_>,
|
||||
) -> Response {
|
||||
let chat_h = 60.0;
|
||||
let w = ui.available_width();
|
||||
let h = ui
|
||||
.available_height()
|
||||
.max(ui.available_rect_before_wrap().height());
|
||||
ui.allocate_ui_with_layout(
|
||||
Vec2::new(w, h),
|
||||
Layout::top_down_justified(Align::Min),
|
||||
|ui| {
|
||||
let video_h =
|
||||
((ui.available_width() / 16.0) * 9.0).min(ui.available_height() * 0.33);
|
||||
ui.allocate_ui(vec2(ui.available_width(), video_h), |ui| {
|
||||
if let Some(player) = &mut self.player {
|
||||
player.ui(ui)
|
||||
} else {
|
||||
ui.add(PlaceholderRect)
|
||||
}
|
||||
});
|
||||
StreamTitle::new(event).render(ui, services);
|
||||
|
||||
if let Some(c) = self.chat.as_mut() {
|
||||
ui.allocate_ui(
|
||||
vec2(ui.available_width(), ui.available_height() - chat_h),
|
||||
|ui| c.render(ui, services),
|
||||
);
|
||||
} else {
|
||||
ui.label("Loading..");
|
||||
}
|
||||
// consume rest of space
|
||||
if ui.available_height().is_finite() {
|
||||
ui.add_space(ui.available_height() - chat_h);
|
||||
}
|
||||
self.new_msg.render(ui, services);
|
||||
},
|
||||
);
|
||||
ui.response()
|
||||
}
|
||||
|
||||
fn render_desktop(
|
||||
&mut self,
|
||||
event: &Note<'_>,
|
||||
ui: &mut Ui,
|
||||
services: &mut RouteServices<'_, '_>,
|
||||
) -> Response {
|
||||
let max_h = ui.available_height();
|
||||
let chat_w = 450.0;
|
||||
let video_width = ui.available_width() - chat_w;
|
||||
let video_height = max_h.min((video_width / 16.0) * 9.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
vec2(video_width, max_h),
|
||||
Layout::top_down_justified(Align::Min),
|
||||
|ui| {
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
if let Some(player) = &mut self.player {
|
||||
ui.add_sized(vec2(video_width, video_height), player);
|
||||
} else {
|
||||
ui.add_sized(vec2(video_width, video_height), PlaceholderRect);
|
||||
}
|
||||
|
||||
ui.add_space(10.);
|
||||
StreamTitle::new(event).render(ui, services);
|
||||
});
|
||||
},
|
||||
);
|
||||
ui.allocate_ui_with_layout(
|
||||
vec2(chat_w, max_h),
|
||||
Layout::top_down_justified(Align::Min),
|
||||
|ui| {
|
||||
Frame::none()
|
||||
.stroke(Stroke::new(1.0, NEUTRAL_800))
|
||||
.outer_margin(MARGIN_DEFAULT)
|
||||
.rounding(ROUNDING_DEFAULT)
|
||||
.show(ui, |ui| {
|
||||
let chat_h = 60.0;
|
||||
if let Some(c) = self.chat.as_mut() {
|
||||
ui.allocate_ui(
|
||||
vec2(ui.available_width(), ui.available_height() - chat_h),
|
||||
|ui| {
|
||||
c.render(ui, services);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ui.label("Loading..");
|
||||
}
|
||||
if ui.available_height().is_finite() {
|
||||
ui.add_space(ui.available_height() - chat_h);
|
||||
}
|
||||
self.new_msg.render(ui, services);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
ui.response()
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrWidget for StreamPage {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
let events: Vec<Note> = self
|
||||
.events
|
||||
.iter()
|
||||
.map_while(|e| services.ctx.ndb.get_note_by_key(services.tx, e.key).ok())
|
||||
.collect();
|
||||
|
||||
if let Some(event) = events.first() {
|
||||
if self.player.is_none() {
|
||||
match event.kind() {
|
||||
30_311 => {
|
||||
if let Some(u) = event.streaming().or(event.recording()) {
|
||||
let p = StreamPlayer::new(ui.ctx(), &u.to_string());
|
||||
self.player = Some(p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
if self.chat.is_none() {
|
||||
let ok = event.key().unwrap();
|
||||
let chat = Chat::new(self.link.clone(), ok);
|
||||
self.chat = Some(chat);
|
||||
}
|
||||
|
||||
if ui.available_width() < 720.0 {
|
||||
self.render_mobile(event, ui, services)
|
||||
} else {
|
||||
self.render_desktop(event, ui, services)
|
||||
}
|
||||
} else {
|
||||
ui.label("Loading..")
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
let filters = self.get_filters();
|
||||
sub_or_poll(
|
||||
services.ctx.ndb,
|
||||
services.tx,
|
||||
services.ctx.pool,
|
||||
&mut self.events,
|
||||
&mut self.sub,
|
||||
filters,
|
||||
)?;
|
||||
if let Some(c) = self.chat.as_mut() {
|
||||
c.update(services)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
78
src/services/ffmpeg_loader.rs
Normal file
78
src/services/ffmpeg_loader.rs
Normal file
@ -0,0 +1,78 @@
|
||||
use anyhow::Error;
|
||||
use egui::{ColorImage, Vec2};
|
||||
use egui_video::ffmpeg_rs_raw::{get_frame_from_hw, Decoder, Demuxer, Scaler};
|
||||
use egui_video::ffmpeg_sys_the_third::{av_frame_free, av_packet_free, AVPixelFormat};
|
||||
use egui_video::media_player::video_frame_to_image;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct FfmpegLoader {}
|
||||
|
||||
impl FfmpegLoader {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn load_image(&self, path: PathBuf, size: Option<Vec2>) -> Result<ColorImage, Error> {
|
||||
let demux = Demuxer::new(path.to_str().unwrap())?;
|
||||
Self::load_image_from_demuxer(demux, size)
|
||||
}
|
||||
|
||||
pub fn load_image_bytes(
|
||||
&self,
|
||||
key: &str,
|
||||
data: &'static [u8],
|
||||
size: Option<Vec2>,
|
||||
) -> Result<ColorImage, Error> {
|
||||
let demux = Demuxer::new_custom_io(data, Some(key.to_string()))?;
|
||||
Self::load_image_from_demuxer(demux, size)
|
||||
}
|
||||
|
||||
fn load_image_from_demuxer(
|
||||
mut demuxer: Demuxer,
|
||||
size: Option<Vec2>,
|
||||
) -> Result<ColorImage, Error> {
|
||||
unsafe {
|
||||
let info = demuxer.probe_input()?;
|
||||
|
||||
let bv = if let Some(v) = info.best_video() {
|
||||
v
|
||||
} else {
|
||||
anyhow::bail!("Not a video/image");
|
||||
};
|
||||
let mut decode = Decoder::new();
|
||||
let rgb = AVPixelFormat::AV_PIX_FMT_RGBA;
|
||||
let mut scaler = Scaler::new();
|
||||
|
||||
decode.setup_decoder(bv, None)?;
|
||||
|
||||
let mut n_pkt = 0;
|
||||
loop {
|
||||
let (mut pkt, stream) = demuxer.get_packet()?;
|
||||
if (*stream).index as usize == bv.index {
|
||||
let frames = decode.decode_pkt(pkt)?;
|
||||
if let Some(frame) = frames.first() {
|
||||
let mut frame = get_frame_from_hw(*frame)?;
|
||||
let frame_rgb = scaler.process_frame(
|
||||
frame,
|
||||
size.map(|s| s.x as u16).unwrap_or((*frame).width as u16),
|
||||
size.map(|s| s.y as u16).unwrap_or((*frame).height as u16),
|
||||
rgb,
|
||||
)?;
|
||||
av_frame_free(&mut frame);
|
||||
|
||||
let image = video_frame_to_image(frame_rgb);
|
||||
av_packet_free(&mut pkt);
|
||||
return Ok(image);
|
||||
}
|
||||
}
|
||||
av_packet_free(&mut pkt);
|
||||
|
||||
n_pkt += 1;
|
||||
if n_pkt > 10 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
anyhow::bail!("No image found");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,19 +1 @@
|
||||
use crate::services::profile::ProfileService;
|
||||
use egui::Context;
|
||||
use nostr_sdk::Client;
|
||||
|
||||
pub mod profile;
|
||||
|
||||
pub struct Services {
|
||||
pub context: Context,
|
||||
pub profile: ProfileService,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
pub fn new(client: Client, context: Context) -> Self {
|
||||
Self {
|
||||
context: context.clone(),
|
||||
profile: ProfileService::new(client.clone(), context.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub mod ffmpeg_loader;
|
||||
|
@ -1,82 +0,0 @@
|
||||
use egui::load::BytesLoader;
|
||||
use log::{info, warn};
|
||||
use nostr_sdk::{Client, Metadata, PublicKey};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub struct ProfileService {
|
||||
client: Client,
|
||||
pub profiles: Arc<Mutex<HashMap<PublicKey, Option<Metadata>>>>,
|
||||
ctx: egui::Context,
|
||||
request_profile: UnboundedSender<PublicKey>,
|
||||
}
|
||||
|
||||
struct Loader {
|
||||
client: Client,
|
||||
ctx: egui::Context,
|
||||
profiles: Arc<Mutex<HashMap<PublicKey, Option<Metadata>>>>,
|
||||
queue: UnboundedReceiver<PublicKey>,
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
pub async fn run(&mut self) {
|
||||
while let Some(key) = self.queue.recv().await {
|
||||
let mut profiles = self.profiles.lock().await;
|
||||
if !profiles.contains_key(&key) {
|
||||
info!("Requesting profile {}", key);
|
||||
match self.client.metadata(key).await {
|
||||
Ok(meta) => {
|
||||
profiles.insert(key, Some(meta));
|
||||
self.ctx.request_repaint();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error getting metadata: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfileService {
|
||||
pub fn new(client: Client, ctx: egui::Context) -> ProfileService
|
||||
{
|
||||
let profiles = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (tx, rx) = unbounded_channel();
|
||||
let mut loader = Loader {
|
||||
client: client.clone(),
|
||||
ctx: ctx.clone(),
|
||||
profiles: profiles.clone(),
|
||||
queue: rx,
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
loader.run().await;
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
ctx,
|
||||
profiles,
|
||||
request_profile: tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_profile(&self, public_key: &PublicKey) -> Option<Metadata> {
|
||||
if let Ok(profiles) = self.profiles.try_lock() {
|
||||
return if let Some(p) = profiles.get(public_key) {
|
||||
if let Some(p) = p {
|
||||
Some(p.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
self.request_profile.send(*public_key).expect("Failed request");
|
||||
None
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
139
src/stream_info.rs
Normal file
139
src/stream_info.rs
Normal file
@ -0,0 +1,139 @@
|
||||
use crate::note_util::NoteUtil;
|
||||
use nostrdb::{NdbStrVariant, Note};
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
#[derive(Debug, Ord, PartialOrd, Eq, PartialEq, Clone)]
|
||||
pub enum StreamStatus {
|
||||
Live,
|
||||
Ended,
|
||||
Planned,
|
||||
}
|
||||
|
||||
impl Display for StreamStatus {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
StreamStatus::Live => write!(f, "Live"),
|
||||
StreamStatus::Ended => write!(f, "Ended"),
|
||||
StreamStatus::Planned => write!(f, "Planned"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait StreamInfo {
|
||||
fn title(&self) -> Option<&str>;
|
||||
|
||||
fn summary(&self) -> Option<&str>;
|
||||
|
||||
fn host(&self) -> &[u8; 32];
|
||||
|
||||
fn streaming(&self) -> Option<&str>;
|
||||
|
||||
fn recording(&self) -> Option<&str>;
|
||||
|
||||
/// Is the stream playable by this app
|
||||
fn can_play(&self) -> bool;
|
||||
|
||||
fn starts(&self) -> u64;
|
||||
|
||||
fn image(&self) -> Option<&str>;
|
||||
|
||||
fn status(&self) -> StreamStatus;
|
||||
|
||||
fn viewers(&self) -> Option<u32>;
|
||||
}
|
||||
|
||||
impl StreamInfo for Note<'_> {
|
||||
fn title(&self) -> Option<&str> {
|
||||
if let Some(s) = self.get_tag_value("title") {
|
||||
s.variant().str()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(&self) -> Option<&str> {
|
||||
if let Some(s) = self.get_tag_value("summary") {
|
||||
s.variant().str()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn host(&self) -> &[u8; 32] {
|
||||
match self.find_tag_value(|t| {
|
||||
t[0].variant().str() == Some("p") && t[3].variant().str() == Some("host")
|
||||
}) {
|
||||
Some(t) => match t.variant() {
|
||||
NdbStrVariant::Id(i) => i,
|
||||
NdbStrVariant::Str(s) => self.pubkey(),
|
||||
},
|
||||
None => self.pubkey(),
|
||||
}
|
||||
}
|
||||
|
||||
fn streaming(&self) -> Option<&str> {
|
||||
if let Some(s) = self.get_tag_value("streaming") {
|
||||
s.variant().str()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn recording(&self) -> Option<&str> {
|
||||
if let Some(s) = self.get_tag_value("recording") {
|
||||
s.variant().str()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the stream playable by this app
|
||||
fn can_play(&self) -> bool {
|
||||
if self.kind() == 30_313 {
|
||||
return true; // n94-stream can always be played
|
||||
}
|
||||
if let Some(stream) = self.streaming() {
|
||||
stream.contains(".m3u8")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn starts(&self) -> u64 {
|
||||
if let Some(s) = self.get_tag_value("starts") {
|
||||
s.variant().str().map_or(self.created_at(), |v| {
|
||||
v.parse::<u64>().unwrap_or(self.created_at())
|
||||
})
|
||||
} else {
|
||||
self.created_at()
|
||||
}
|
||||
}
|
||||
|
||||
fn image(&self) -> Option<&str> {
|
||||
if let Some(s) = self.get_tag_value("image") {
|
||||
s.variant().str()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> StreamStatus {
|
||||
if let Some(s) = self.get_tag_value("status") {
|
||||
match s.variant().str() {
|
||||
Some("live") => StreamStatus::Live,
|
||||
Some("planned") => StreamStatus::Planned,
|
||||
_ => StreamStatus::Ended,
|
||||
}
|
||||
} else {
|
||||
StreamStatus::Ended
|
||||
}
|
||||
}
|
||||
|
||||
fn viewers(&self) -> Option<u32> {
|
||||
if let Some(s) = self.get_tag_value("current_participants") {
|
||||
s.variant().str().map(|v| v.parse::<u32>().unwrap_or(0))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
21
src/sub.rs
Normal file
21
src/sub.rs
Normal file
@ -0,0 +1,21 @@
|
||||
use log::info;
|
||||
use nostrdb::{Ndb, Subscription};
|
||||
|
||||
pub struct SubRef {
|
||||
pub sub: Subscription,
|
||||
ndb: Ndb,
|
||||
}
|
||||
|
||||
impl SubRef {
|
||||
pub fn new(sub: Subscription, ndb: Ndb) -> Self {
|
||||
info!("Creating sub: {}", sub.id());
|
||||
SubRef { sub, ndb }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SubRef {
|
||||
fn drop(&mut self) {
|
||||
self.ndb.unsubscribe(self.sub).expect("unsubscribe failed");
|
||||
info!("Closing sub: {}", self.sub.id());
|
||||
}
|
||||
}
|
13
src/theme.rs
Normal file
13
src/theme.rs
Normal file
@ -0,0 +1,13 @@
|
||||
use egui::{Color32, Margin};
|
||||
|
||||
pub const FONT_SIZE: f32 = 13.0;
|
||||
pub const FONT_SIZE_SM: f32 = FONT_SIZE * 0.8;
|
||||
pub const FONT_SIZE_LG: f32 = FONT_SIZE * 1.5;
|
||||
pub const ROUNDING_DEFAULT: f32 = 12.0;
|
||||
pub const MARGIN_DEFAULT: Margin = Margin::symmetric(12., 6.);
|
||||
pub const PRIMARY: Color32 = Color32::from_rgb(248, 56, 217);
|
||||
pub const NEUTRAL_500: Color32 = Color32::from_rgb(115, 115, 115);
|
||||
pub const NEUTRAL_700: Color32 = Color32::from_rgb(64, 64, 64);
|
||||
pub const NEUTRAL_800: Color32 = Color32::from_rgb(38, 38, 38);
|
||||
pub const NEUTRAL_900: Color32 = Color32::from_rgb(23, 23, 23);
|
||||
pub const ZAP: Color32 = Color32::from_rgb(255, 141, 43);
|
@ -1,5 +0,0 @@
|
||||
use nostr_sdk::Filter;
|
||||
|
||||
pub trait NostrWidget {
|
||||
fn subscribe(&self) -> Vec<Filter>;
|
||||
}
|
@ -1,55 +1,68 @@
|
||||
use crate::services::Services;
|
||||
use egui::{Color32, Image, Rect, Response, Rounding, Sense, Ui, Vec2, Widget};
|
||||
use nostr_sdk::PublicKey;
|
||||
use crate::route::image_from_cache;
|
||||
use crate::theme::NEUTRAL_800;
|
||||
use egui::{vec2, Response, Rounding, Sense, Ui, Vec2, Widget};
|
||||
use nostrdb::{Ndb, NdbProfile, Transaction};
|
||||
use notedeck::ImageCache;
|
||||
|
||||
pub struct Avatar<'a> {
|
||||
image: Option<Image<'a>>,
|
||||
pub struct Avatar {
|
||||
image: Option<String>,
|
||||
size: Option<f32>,
|
||||
}
|
||||
|
||||
impl<'a> Avatar<'a> {
|
||||
pub fn new(img: Image<'a>) -> Self {
|
||||
Self { image: Some(img) }
|
||||
impl Avatar {
|
||||
pub fn new_optional(img: Option<&str>) -> Self {
|
||||
Self {
|
||||
image: img.map(String::from),
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(svc: &'a Services, pk: &PublicKey) -> Self {
|
||||
if let Some(meta) = svc.profile.get_profile(pk) {
|
||||
if let Some(img) = &meta.picture {
|
||||
return Self { image: Some(Image::from_uri(img.clone())) };
|
||||
pub fn pubkey(pk: &[u8; 32], ndb: &Ndb, tx: &Transaction) -> Self {
|
||||
let picture = ndb
|
||||
.get_profile_by_pubkey(tx, pk)
|
||||
.map(|p| p.record().profile().map(|p| p.picture()).unwrap_or(None))
|
||||
.unwrap_or(None);
|
||||
Self {
|
||||
image: picture.map(|s| s.to_string()),
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
Self { image: None }
|
||||
}
|
||||
|
||||
pub fn max_size(mut self, size: f32) -> Self {
|
||||
self.image = if let Some(i) = self.image {
|
||||
Some(i.max_height(size))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self
|
||||
pub fn from_profile(p: &Option<NdbProfile<'_>>) -> Self {
|
||||
let img = p.map(|f| f.picture()).unwrap_or(None);
|
||||
Self {
|
||||
image: img.map(String::from),
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: f32) -> Self {
|
||||
self.image = if let Some(i) = self.image {
|
||||
Some(i.fit_to_exact_size(Vec2::new(size, size)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.size = Some(size);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for Avatar<'a> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
match self.image {
|
||||
Some(img) => {
|
||||
img.rounding(Rounding::same(ui.available_height())).ui(ui)
|
||||
}
|
||||
None => {
|
||||
let (response, painter) = ui.allocate_painter(Vec2::new(32., 32.), Sense::hover());
|
||||
painter.rect_filled(Rect::EVERYTHING, Rounding::same(32.), Color32::from_rgb(200, 200, 200));
|
||||
fn placeholder(ui: &mut Ui, size: f32) -> Response {
|
||||
let (response, painter) = ui.allocate_painter(vec2(size, size), Sense::click());
|
||||
let pos = response.rect.min + vec2(size / 2., size / 2.);
|
||||
painter.circle_filled(pos, size / 2., NEUTRAL_800);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn render(self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response {
|
||||
let size_v = self.size.unwrap_or(40.);
|
||||
let size = Vec2::new(size_v, size_v);
|
||||
if !ui.is_rect_visible(ui.cursor()) {
|
||||
return Self::placeholder(ui, size_v);
|
||||
}
|
||||
match self
|
||||
.image
|
||||
.and_then(|i| image_from_cache(img_cache, ui, &i, Some(size)))
|
||||
{
|
||||
Some(img) => img
|
||||
.rounding(Rounding::same(size_v))
|
||||
.sense(Sense::click())
|
||||
.ui(ui),
|
||||
None => Self::placeholder(ui, size_v),
|
||||
}
|
||||
}
|
||||
}
|
59
src/widgets/button.rs
Normal file
59
src/widgets/button.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_800, ROUNDING_DEFAULT};
|
||||
use egui::{Color32, CursorIcon, Frame, Response, Sense, Ui, WidgetText};
|
||||
|
||||
pub struct Button {
|
||||
color: Color32,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Button {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
color: NEUTRAL_800,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_color(mut self, color: impl Into<Color32>) -> Self {
|
||||
self.color = color.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, v: bool) -> Self {
|
||||
self.disabled = v;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn simple(ui: &mut Ui, content: &str) -> Response {
|
||||
Button::new().show(ui, |ui| ui.label(content))
|
||||
}
|
||||
|
||||
pub fn text(self, ui: &mut Ui, text: impl Into<WidgetText>) -> Response {
|
||||
self.show(ui, |ui| ui.label(text))
|
||||
}
|
||||
|
||||
pub fn show<F>(self, ui: &mut Ui, add_contents: F) -> Response
|
||||
where
|
||||
F: FnOnce(&mut Ui) -> Response,
|
||||
{
|
||||
let r = Frame::none()
|
||||
.inner_margin(MARGIN_DEFAULT)
|
||||
.fill(self.color)
|
||||
.rounding(ROUNDING_DEFAULT)
|
||||
.multiply_with_opacity(if self.disabled { 0.5 } else { 1.0 })
|
||||
.show(ui, add_contents);
|
||||
|
||||
let id = r.response.id;
|
||||
ui.interact(
|
||||
r.response
|
||||
.on_hover_and_drag_cursor(if self.disabled {
|
||||
CursorIcon::NotAllowed
|
||||
} else {
|
||||
CursorIcon::PointingHand
|
||||
})
|
||||
.rect,
|
||||
id,
|
||||
Sense::click(),
|
||||
)
|
||||
}
|
||||
}
|
98
src/widgets/chat.rs
Normal file
98
src/widgets/chat.rs
Normal file
@ -0,0 +1,98 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::note_ref::NoteRef;
|
||||
use crate::route::RouteServices;
|
||||
use crate::sub::SubRef;
|
||||
use crate::widgets::chat_message::ChatMessage;
|
||||
use crate::widgets::chat_zap::ChatZap;
|
||||
use crate::widgets::{sub_or_poll, NostrWidget};
|
||||
use crate::zap::Zap;
|
||||
use egui::{Frame, Margin, Response, ScrollArea, Ui};
|
||||
use itertools::Itertools;
|
||||
use nostrdb::{Filter, NoteKey};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct Chat {
|
||||
link: NostrLink,
|
||||
stream: NoteKey,
|
||||
events: HashSet<NoteRef>,
|
||||
sub: Option<SubRef>,
|
||||
}
|
||||
|
||||
impl Chat {
|
||||
pub fn new<'a>(link: NostrLink, stream: NoteKey) -> Self {
|
||||
Self {
|
||||
link,
|
||||
stream,
|
||||
events: HashSet::new(),
|
||||
sub: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([1_311, 9_735])
|
||||
.tags([self.link.to_tag_value()], 'a')
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrWidget for Chat {
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
let stream = services
|
||||
.ctx
|
||||
.ndb
|
||||
.get_note_by_key(services.tx, self.stream)
|
||||
.unwrap();
|
||||
|
||||
ScrollArea::vertical()
|
||||
.stick_to_bottom(true)
|
||||
.show(ui, |ui| {
|
||||
Frame::none()
|
||||
.outer_margin(Margin::symmetric(12., 8.))
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
for ev in self
|
||||
.events
|
||||
.iter()
|
||||
.sorted_by(|a, b| a.created_at.cmp(&b.created_at))
|
||||
{
|
||||
if let Ok(ev) =
|
||||
services.ctx.ndb.get_note_by_key(services.tx, ev.key)
|
||||
{
|
||||
match ev.kind() {
|
||||
1311 => {
|
||||
let profile = services.profile(ev.pubkey());
|
||||
ChatMessage::new(&stream, &ev, &profile)
|
||||
.render(ui, services);
|
||||
}
|
||||
9735 => {
|
||||
if let Ok(zap) = Zap::from_receipt(ev) {
|
||||
let profile = services.profile(&zap.sender);
|
||||
ChatZap::new(&zap, &profile)
|
||||
.render(ui, services.ctx.img_cache);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.response
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()> {
|
||||
let filters = vec![self.get_filter()];
|
||||
sub_or_poll(
|
||||
services.ctx.ndb,
|
||||
services.tx,
|
||||
services.ctx.pool,
|
||||
&mut self.events,
|
||||
&mut self.sub,
|
||||
filters,
|
||||
)
|
||||
}
|
||||
}
|
68
src/widgets/chat_message.rs
Normal file
68
src/widgets/chat_message.rs
Normal file
@ -0,0 +1,68 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::route::{RouteServices, RouteType};
|
||||
use crate::stream_info::StreamInfo;
|
||||
use crate::theme::{NEUTRAL_500, PRIMARY};
|
||||
use crate::widgets::Avatar;
|
||||
use eframe::epaint::text::TextWrapMode;
|
||||
use egui::text::LayoutJob;
|
||||
use egui::{Align, Color32, Label, Response, TextFormat, Ui};
|
||||
use nostrdb::{NdbProfile, Note};
|
||||
|
||||
pub struct ChatMessage<'a> {
|
||||
stream: &'a Note<'a>,
|
||||
ev: &'a Note<'a>,
|
||||
profile: &'a Option<NdbProfile<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> ChatMessage<'a> {
|
||||
pub fn new(
|
||||
stream: &'a Note<'a>,
|
||||
ev: &'a Note<'a>,
|
||||
profile: &'a Option<NdbProfile<'a>>,
|
||||
) -> ChatMessage<'a> {
|
||||
ChatMessage {
|
||||
stream,
|
||||
ev,
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(self, ui: &mut Ui, services: &mut RouteServices) -> Response {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let mut job = LayoutJob::default();
|
||||
// TODO: avoid this somehow
|
||||
job.wrap.break_anywhere = true;
|
||||
|
||||
let is_host = self.stream.host().eq(self.ev.pubkey());
|
||||
let name = self
|
||||
.profile
|
||||
.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
|
||||
|
||||
let name_color = if is_host { PRIMARY } else { NEUTRAL_500 };
|
||||
|
||||
let mut format = TextFormat::default();
|
||||
format.line_height = Some(24.0);
|
||||
format.valign = Align::Center;
|
||||
|
||||
format.color = name_color;
|
||||
job.append(name, 0.0, format.clone());
|
||||
format.color = Color32::WHITE;
|
||||
job.append(self.ev.content(), 5.0, format.clone());
|
||||
|
||||
if Avatar::from_profile(self.profile)
|
||||
.size(24.)
|
||||
.render(ui, services.ctx.img_cache)
|
||||
.clicked()
|
||||
{
|
||||
services.navigate(RouteType::ProfilePage {
|
||||
link: NostrLink::profile(self.ev.pubkey()),
|
||||
})
|
||||
}
|
||||
ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap));
|
||||
|
||||
// consume reset of space
|
||||
ui.add_space(ui.available_size_before_wrap().x);
|
||||
})
|
||||
.response
|
||||
}
|
||||
}
|
68
src/widgets/chat_zap.rs
Normal file
68
src/widgets/chat_zap.rs
Normal file
@ -0,0 +1,68 @@
|
||||
use crate::theme::{MARGIN_DEFAULT, ROUNDING_DEFAULT, ZAP};
|
||||
use crate::widgets::Avatar;
|
||||
use crate::zap::{format_sats, Zap};
|
||||
use eframe::emath::Align;
|
||||
use eframe::epaint::text::{LayoutJob, TextFormat, TextWrapMode};
|
||||
use eframe::epaint::Color32;
|
||||
use egui::{Frame, Label, Response, Stroke, Ui};
|
||||
use nostrdb::NdbProfile;
|
||||
use notedeck::ImageCache;
|
||||
|
||||
pub struct ChatZap<'a> {
|
||||
zap: &'a Zap<'a>,
|
||||
profile: &'a Option<NdbProfile<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> ChatZap<'a> {
|
||||
pub fn new(zap: &'a Zap, profile: &'a Option<NdbProfile<'a>>) -> Self {
|
||||
Self { zap, profile }
|
||||
}
|
||||
|
||||
pub fn render(self, ui: &mut Ui, img_cache: &mut ImageCache) -> Response {
|
||||
Frame::default()
|
||||
.rounding(ROUNDING_DEFAULT)
|
||||
.inner_margin(MARGIN_DEFAULT)
|
||||
.stroke(Stroke::new(1., ZAP))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let mut job = LayoutJob::default();
|
||||
// TODO: avoid this somehow
|
||||
job.wrap.break_anywhere = true;
|
||||
|
||||
let name = self
|
||||
.profile
|
||||
.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
|
||||
|
||||
let mut format = TextFormat::default();
|
||||
format.line_height = Some(24.0);
|
||||
format.valign = Align::Center;
|
||||
|
||||
format.color = ZAP;
|
||||
job.append(name, 0.0, format.clone());
|
||||
format.color = Color32::WHITE;
|
||||
job.append("zapped", 5.0, format.clone());
|
||||
format.color = ZAP;
|
||||
job.append(
|
||||
&format_sats((self.zap.amount / 1000) as f32),
|
||||
5.0,
|
||||
format.clone(),
|
||||
);
|
||||
format.color = Color32::WHITE;
|
||||
job.append("sats", 5.0, format.clone());
|
||||
|
||||
if !self.zap.message.is_empty() {
|
||||
job.append(&format!("\n{}", self.zap.message), 0.0, format.clone());
|
||||
}
|
||||
|
||||
Avatar::from_profile(self.profile)
|
||||
.size(24.)
|
||||
.render(ui, img_cache);
|
||||
ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap));
|
||||
|
||||
// consume reset of space
|
||||
ui.add_space(ui.available_size_before_wrap().x);
|
||||
});
|
||||
})
|
||||
.response
|
||||
}
|
||||
}
|
@ -1,34 +1,60 @@
|
||||
use crate::services::Services;
|
||||
use crate::link::NostrLink;
|
||||
use crate::route::{RouteServices, RouteType};
|
||||
use crate::widgets::avatar::Avatar;
|
||||
use crate::widgets::Button;
|
||||
use eframe::emath::Align;
|
||||
use eframe::epaint::Vec2;
|
||||
use egui::{Frame, Image, Layout, Margin, Response, Ui, Widget};
|
||||
use nostr_sdk::PublicKey;
|
||||
use egui::{CursorIcon, Frame, Image, Layout, Margin, Response, Sense, Ui, Widget};
|
||||
use nostrdb::Transaction;
|
||||
|
||||
pub struct Header<'a> {
|
||||
services: &'a Services,
|
||||
}
|
||||
pub struct Header;
|
||||
|
||||
impl<'a> Header<'a> {
|
||||
pub fn new(services: &'a Services) -> Self {
|
||||
Self { services }
|
||||
impl Header {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Header<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let login = PublicKey::from_hex("63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed").unwrap();
|
||||
let logo_bytes = include_bytes!("../logo.svg");
|
||||
pub fn render(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
services: &mut RouteServices<'_, '_>,
|
||||
tx: &Transaction,
|
||||
) -> Response {
|
||||
let logo_bytes = include_bytes!("../resources/logo.svg");
|
||||
Frame::none()
|
||||
.outer_margin(Margin::symmetric(16., 8.))
|
||||
.show(ui, |ui| {
|
||||
ui.allocate_ui_with_layout(Vec2::new(ui.available_width(), 32.), Layout::left_to_right(Align::Center), |ui| {
|
||||
ui.style_mut()
|
||||
.spacing.item_spacing.x = 16.;
|
||||
Image::from_bytes("logo.svg", logo_bytes)
|
||||
.max_height(22.62).ui(ui);
|
||||
ui.add(Avatar::public_key(&self.services, &login));
|
||||
ui.allocate_ui_with_layout(
|
||||
Vec2::new(ui.available_width(), 32.),
|
||||
Layout::left_to_right(Align::Center),
|
||||
|ui| {
|
||||
ui.style_mut().spacing.item_spacing.x = 16.;
|
||||
if Image::from_bytes("header_logo.svg", logo_bytes)
|
||||
.max_height(24.)
|
||||
.sense(Sense::click())
|
||||
.ui(ui)
|
||||
.on_hover_and_drag_cursor(CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
{
|
||||
services.navigate(RouteType::HomePage);
|
||||
}
|
||||
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if let Some(acc) = services.ctx.accounts.get_selected_account() {
|
||||
if Avatar::pubkey(&acc.pubkey, services.ctx.ndb, tx)
|
||||
.render(ui, services.ctx.img_cache)
|
||||
.clicked()
|
||||
{
|
||||
services.navigate(RouteType::ProfilePage {
|
||||
link: NostrLink::profile(acc.pubkey.bytes()),
|
||||
})
|
||||
}).response
|
||||
}
|
||||
} else if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
|
||||
services.navigate(RouteType::LoginPage);
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
})
|
||||
.response
|
||||
}
|
||||
}
|
@ -1,4 +1,76 @@
|
||||
pub mod header;
|
||||
pub mod stream;
|
||||
pub mod stream_list;
|
||||
mod avatar;
|
||||
mod button;
|
||||
mod chat;
|
||||
mod chat_message;
|
||||
mod chat_zap;
|
||||
mod header;
|
||||
mod pill;
|
||||
mod placeholder_rect;
|
||||
mod profile;
|
||||
mod stream_list;
|
||||
mod stream_player;
|
||||
mod stream_tile;
|
||||
mod stream_title;
|
||||
mod text_input;
|
||||
mod username;
|
||||
mod write_chat;
|
||||
mod zap;
|
||||
|
||||
use crate::note_ref::NoteRef;
|
||||
use crate::route::RouteServices;
|
||||
use crate::sub::SubRef;
|
||||
use egui::{Response, Ui};
|
||||
use enostr::RelayPool;
|
||||
use nostrdb::{Filter, Ndb, Transaction};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A stateful widget which requests nostr data
|
||||
pub trait NostrWidget {
|
||||
/// Render with widget on the UI
|
||||
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response;
|
||||
|
||||
/// Update widget on draw
|
||||
fn update(&mut self, services: &mut RouteServices<'_, '_>) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
/// On widget update call this to update NDB data
|
||||
pub fn sub_or_poll(
|
||||
ndb: &Ndb,
|
||||
tx: &Transaction,
|
||||
pool: &mut RelayPool,
|
||||
store: &mut HashSet<NoteRef>,
|
||||
sub: &mut Option<SubRef>,
|
||||
filters: Vec<Filter>,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(sub) = sub {
|
||||
ndb.poll_for_notes(sub.sub, 500).into_iter().for_each(|e| {
|
||||
if let Ok(note) = ndb.get_note_by_key(tx, e) {
|
||||
store.insert(NoteRef::from_note(¬e));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let s = ndb.subscribe(filters.as_slice())?;
|
||||
sub.replace(SubRef::new(s, ndb.clone()));
|
||||
ndb.query(tx, filters.as_slice(), 500)?
|
||||
.into_iter()
|
||||
.for_each(|e| {
|
||||
store.insert(NoteRef::from_query_result(e));
|
||||
});
|
||||
pool.subscribe(format!("ndb-{}", s.id()), filters);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub use self::avatar::Avatar;
|
||||
pub use self::button::Button;
|
||||
pub use self::chat::Chat;
|
||||
pub use self::header::Header;
|
||||
pub use self::pill::Pill;
|
||||
pub use self::placeholder_rect::PlaceholderRect;
|
||||
pub use self::profile::Profile;
|
||||
pub use self::stream_list::StreamList;
|
||||
pub use self::stream_player::StreamPlayer;
|
||||
pub use self::stream_title::StreamTitle;
|
||||
pub use self::text_input::NativeTextInput;
|
||||
pub use self::username::Username;
|
||||
pub use self::write_chat::WriteChat;
|
||||
|
35
src/widgets/pill.rs
Normal file
35
src/widgets/pill.rs
Normal file
@ -0,0 +1,35 @@
|
||||
use crate::theme::{FONT_SIZE, NEUTRAL_800};
|
||||
use eframe::epaint::Margin;
|
||||
use egui::{Color32, Frame, Response, RichText, Ui, Widget};
|
||||
|
||||
pub struct Pill {
|
||||
text: String,
|
||||
color: Color32,
|
||||
}
|
||||
|
||||
impl Pill {
|
||||
pub fn new(text: &str) -> Self {
|
||||
Self {
|
||||
text: String::from(text),
|
||||
color: NEUTRAL_800,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: Color32) -> Self {
|
||||
self.color = color;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Pill {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
Frame::default()
|
||||
.inner_margin(Margin::symmetric(5.0, 3.0))
|
||||
.rounding(5.0)
|
||||
.fill(self.color)
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new(&self.text).size(FONT_SIZE));
|
||||
})
|
||||
.response
|
||||
}
|
||||
}
|
13
src/widgets/placeholder_rect.rs
Normal file
13
src/widgets/placeholder_rect.rs
Normal file
@ -0,0 +1,13 @@
|
||||
use crate::theme::{NEUTRAL_800, ROUNDING_DEFAULT};
|
||||
use egui::{Response, Sense, Ui, Widget};
|
||||
|
||||
pub struct PlaceholderRect;
|
||||
|
||||
impl Widget for PlaceholderRect {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let img_size = ui.available_size();
|
||||
let (response, painter) = ui.allocate_painter(img_size, Sense::click());
|
||||
painter.rect_filled(response.rect, ROUNDING_DEFAULT, NEUTRAL_800);
|
||||
response
|
||||
}
|
||||
}
|
50
src/widgets/profile.rs
Normal file
50
src/widgets/profile.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use crate::route::RouteServices;
|
||||
use crate::theme::FONT_SIZE;
|
||||
use crate::widgets::{Avatar, Username};
|
||||
use egui::{Response, Ui};
|
||||
use nostrdb::NdbProfile;
|
||||
|
||||
pub struct Profile<'a> {
|
||||
size: f32,
|
||||
pubkey: &'a [u8; 32],
|
||||
profile: &'a Option<NdbProfile<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> Profile<'a> {
|
||||
pub fn new(pubkey: &'a [u8; 32]) -> Self {
|
||||
Self {
|
||||
pubkey,
|
||||
size: 40.,
|
||||
profile: &None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_profile(pubkey: &'a [u8; 32], profile: &'a Option<NdbProfile<'a>>) -> Self {
|
||||
Self {
|
||||
pubkey,
|
||||
profile,
|
||||
size: 40.,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(self, size: f32) -> Self {
|
||||
Self { size, ..self }
|
||||
}
|
||||
|
||||
pub fn render(self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.;
|
||||
|
||||
let profile = if let Some(profile) = self.profile {
|
||||
Some(*profile)
|
||||
} else {
|
||||
services.profile(self.pubkey)
|
||||
};
|
||||
Avatar::from_profile(&profile)
|
||||
.size(self.size)
|
||||
.render(ui, services.ctx.img_cache);
|
||||
ui.add(Username::new(&profile, FONT_SIZE))
|
||||
})
|
||||
.response
|
||||
}
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
use crate::services::Services;
|
||||
use crate::widgets::avatar::Avatar;
|
||||
use eframe::epaint::Vec2;
|
||||
use egui::{Color32, Image, Rect, Response, RichText, Rounding, Sense, Ui, Widget};
|
||||
use nostr_sdk::{Alphabet, Event, PublicKey, SingleLetterTag, TagKind};
|
||||
|
||||
pub struct StreamEvent<'a> {
|
||||
event: &'a Event,
|
||||
picture: Option<Image<'a>>,
|
||||
services: &'a Services,
|
||||
}
|
||||
|
||||
impl<'a> StreamEvent<'a> {
|
||||
pub fn new(event: &'a Event, services: &'a Services) -> Self {
|
||||
let image = event.get_tag_content(TagKind::Image);
|
||||
let cover = match image {
|
||||
Some(i) => Some(Image::from_uri(i)),
|
||||
None => None,
|
||||
};
|
||||
Self { event, picture: cover, services }
|
||||
}
|
||||
}
|
||||
impl Widget for StreamEvent<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
ui.vertical(|ui| {
|
||||
ui.style_mut()
|
||||
.spacing.item_spacing = Vec2::new(12., 16.);
|
||||
|
||||
let host = match self.event.tags.iter().find(|t| t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)) && t.as_vec()[3] == "host") {
|
||||
Some(t) => PublicKey::from_hex(t.as_vec().get(1).unwrap()).unwrap(),
|
||||
None => self.event.author()
|
||||
};
|
||||
match self.picture {
|
||||
Some(picture) => picture.rounding(Rounding::same(12.)).ui(ui),
|
||||
None => {
|
||||
let w = ui.available_width();
|
||||
let h = (w / 16.0) * 9.0;
|
||||
let (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::hover());
|
||||
painter.rect_filled(Rect::EVERYTHING, Rounding::same(12.), Color32::from_rgb(200, 200, 200));
|
||||
response
|
||||
}
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(Avatar::public_key(self.services, &host).size(40.));
|
||||
ui.label(RichText::new(self.event.get_tag_content(TagKind::Title).unwrap_or("Unknown"))
|
||||
.size(16.)
|
||||
.color(Color32::WHITE)
|
||||
);
|
||||
})
|
||||
}).response
|
||||
}
|
||||
}
|
@ -1,30 +1,68 @@
|
||||
use crate::services::Services;
|
||||
use crate::widgets::stream::StreamEvent;
|
||||
use egui::{Frame, Margin, Response, Ui, Widget};
|
||||
use nostr_sdk::Event;
|
||||
use crate::note_view::NotesView;
|
||||
use crate::route::RouteServices;
|
||||
use crate::stream_info::StreamInfo;
|
||||
use crate::theme::MARGIN_DEFAULT;
|
||||
use crate::widgets::stream_tile::StreamEvent;
|
||||
use egui::{vec2, Frame, Grid, Margin, Response, Ui, WidgetText};
|
||||
use itertools::Itertools;
|
||||
|
||||
pub struct StreamList<'a> {
|
||||
streams: &'a Vec<Event>,
|
||||
services: &'a Services,
|
||||
id: egui::Id,
|
||||
streams: NotesView<'a>,
|
||||
heading: Option<WidgetText>,
|
||||
}
|
||||
|
||||
impl<'a> StreamList<'a> {
|
||||
pub fn new(streams: &'a Vec<Event>, services: &'a Services) -> Self {
|
||||
Self { streams, services }
|
||||
pub fn new(
|
||||
id: egui::Id,
|
||||
streams: NotesView<'a>,
|
||||
heading: Option<impl Into<WidgetText>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
streams,
|
||||
heading: heading.map(Into::into),
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for StreamList<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
pub fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
let cols = match ui.available_width() as u16 {
|
||||
720..1080 => 2,
|
||||
1080..1300 => 3,
|
||||
1300..1500 => 4,
|
||||
1500..2000 => 5,
|
||||
2000.. => 6,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
let grid_padding = 20.;
|
||||
Frame::none()
|
||||
.inner_margin(Margin::symmetric(16., 8.))
|
||||
.inner_margin(MARGIN_DEFAULT)
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.style_mut().spacing.item_spacing = egui::vec2(0., 20.0);
|
||||
for event in self.streams.iter().take(5) {
|
||||
ui.add(StreamEvent::new(event, self.services));
|
||||
let grid_spacing_consumed = (cols - 1) as f32 * grid_padding;
|
||||
let g_w = (ui.available_width() - grid_spacing_consumed) / cols as f32;
|
||||
if let Some(heading) = self.heading.take() {
|
||||
ui.label(heading);
|
||||
}
|
||||
Grid::new(self.id)
|
||||
.spacing(vec2(grid_padding, grid_padding))
|
||||
.show(ui, |ui| {
|
||||
let mut ctr = 0;
|
||||
for event in self.streams.iter().sorted_by(|a, b| {
|
||||
a.status()
|
||||
.cmp(&b.status())
|
||||
.then(a.starts().cmp(&b.starts()).reverse())
|
||||
}) {
|
||||
ui.allocate_ui(vec2(g_w, (g_w / 16.0) * 9.0), |ui| {
|
||||
StreamEvent::new(event).render(ui, services)
|
||||
});
|
||||
ctr += 1;
|
||||
if ctr % cols == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
})
|
||||
}).response
|
||||
})
|
||||
.response
|
||||
}
|
||||
}
|
30
src/widgets/stream_player.rs
Normal file
30
src/widgets/stream_player.rs
Normal file
@ -0,0 +1,30 @@
|
||||
use crate::widgets::PlaceholderRect;
|
||||
use egui::{Context, Response, Ui, Widget};
|
||||
use egui_video::{Player, PlayerControls};
|
||||
|
||||
pub struct StreamPlayer {
|
||||
player: Option<Player>,
|
||||
}
|
||||
|
||||
impl StreamPlayer {
|
||||
pub fn new(ctx: &Context, url: &String) -> Self {
|
||||
let mut p = Player::new(ctx, url);
|
||||
#[cfg(debug_assertions)]
|
||||
p.set_debug(true);
|
||||
|
||||
p.start();
|
||||
Self { player: Some(p) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &mut StreamPlayer {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let size = ui.available_size();
|
||||
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
ui.add_sized(size, p)
|
||||
} else {
|
||||
PlaceholderRect.ui(ui)
|
||||
}
|
||||
}
|
||||
}
|
125
src/widgets/stream_tile.rs
Normal file
125
src/widgets/stream_tile.rs
Normal file
@ -0,0 +1,125 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::route::{image_from_cache, RouteServices, RouteType};
|
||||
use crate::stream_info::{StreamInfo, StreamStatus};
|
||||
use crate::theme::{NEUTRAL_800, NEUTRAL_900, PRIMARY, ROUNDING_DEFAULT};
|
||||
use crate::widgets::avatar::Avatar;
|
||||
use eframe::epaint::{Rounding, Vec2};
|
||||
use egui::epaint::RectShape;
|
||||
use egui::{
|
||||
vec2, Color32, CursorIcon, FontId, ImageSource, Label, Pos2, Rect, Response, RichText, Sense,
|
||||
TextWrapMode, Ui,
|
||||
};
|
||||
use nostrdb::Note;
|
||||
|
||||
pub struct StreamEvent<'a> {
|
||||
event: &'a Note<'a>,
|
||||
}
|
||||
|
||||
impl<'a> StreamEvent<'a> {
|
||||
pub fn new(event: &'a Note<'a>) -> Self {
|
||||
Self { event }
|
||||
}
|
||||
|
||||
pub fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
ui.vertical(|ui| {
|
||||
ui.style_mut().spacing.item_spacing = Vec2::new(12., 16.);
|
||||
|
||||
let host = self.event.host();
|
||||
let host_profile = services.profile(host);
|
||||
|
||||
let w = ui.available_width();
|
||||
let h = (w / 16.0) * 9.0;
|
||||
|
||||
let (response, painter) = ui.allocate_painter(Vec2::new(w, h), Sense::click());
|
||||
|
||||
let cover = if ui.is_rect_visible(response.rect) {
|
||||
self.event
|
||||
.image()
|
||||
.and_then(|p| {
|
||||
image_from_cache(services.ctx.img_cache, ui, p, Some(Vec2::new(w, h)))
|
||||
})
|
||||
.map(|i| i.rounding(ROUNDING_DEFAULT))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(cover) = cover {
|
||||
painter.add(RectShape {
|
||||
rect: response.rect,
|
||||
rounding: Rounding::same(ROUNDING_DEFAULT),
|
||||
fill: Color32::WHITE,
|
||||
stroke: Default::default(),
|
||||
blur_width: 0.0,
|
||||
fill_texture_id: match cover.source(ui.ctx()) {
|
||||
ImageSource::Texture(t) => t.id,
|
||||
_ => Default::default(),
|
||||
},
|
||||
uv: Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)),
|
||||
});
|
||||
} else {
|
||||
painter.rect_filled(response.rect, ROUNDING_DEFAULT, NEUTRAL_800);
|
||||
}
|
||||
|
||||
let overlay_label_pad = Vec2::new(5., 5.);
|
||||
let live_label_text = self.event.status().to_string().to_uppercase();
|
||||
let live_label_color = if self.event.status() == StreamStatus::Live {
|
||||
PRIMARY
|
||||
} else {
|
||||
NEUTRAL_900
|
||||
};
|
||||
let live_label =
|
||||
painter.layout_no_wrap(live_label_text, FontId::default(), Color32::WHITE);
|
||||
|
||||
let overlay_react = response.rect.shrink(8.0);
|
||||
let live_label_pos = overlay_react.min
|
||||
+ vec2(
|
||||
overlay_react.width() - live_label.rect.width() - (overlay_label_pad.x * 2.),
|
||||
0.0,
|
||||
);
|
||||
let live_label_background = Rect::from_two_pos(
|
||||
live_label_pos,
|
||||
live_label_pos + live_label.size() + (overlay_label_pad * 2.),
|
||||
);
|
||||
painter.rect_filled(live_label_background, 8., live_label_color);
|
||||
painter.galley(
|
||||
live_label_pos + overlay_label_pad,
|
||||
live_label,
|
||||
Color32::PLACEHOLDER,
|
||||
);
|
||||
|
||||
if let Some(viewers) = self.event.viewers() {
|
||||
let viewers_label = painter.layout_no_wrap(
|
||||
format!("{} viewers", viewers),
|
||||
FontId::default(),
|
||||
Color32::WHITE,
|
||||
);
|
||||
let rect_start =
|
||||
overlay_react.max - viewers_label.size() - (overlay_label_pad * 2.0);
|
||||
let pos = Rect::from_two_pos(rect_start, overlay_react.max);
|
||||
painter.rect_filled(pos, 8., NEUTRAL_900);
|
||||
painter.galley(
|
||||
rect_start + overlay_label_pad,
|
||||
viewers_label,
|
||||
Color32::PLACEHOLDER,
|
||||
);
|
||||
}
|
||||
let response = response.on_hover_and_drag_cursor(CursorIcon::PointingHand);
|
||||
if response.clicked() {
|
||||
services.navigate(RouteType::EventPage {
|
||||
link: NostrLink::from_note(self.event),
|
||||
event: None,
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
Avatar::from_profile(&host_profile)
|
||||
.size(40.)
|
||||
.render(ui, services.ctx.img_cache);
|
||||
let title = RichText::new(self.event.title().unwrap_or("Untitled"))
|
||||
.size(16.)
|
||||
.color(Color32::WHITE);
|
||||
ui.add(Label::new(title).wrap_mode(TextWrapMode::Truncate));
|
||||
})
|
||||
})
|
||||
.response
|
||||
}
|
||||
}
|
64
src/widgets/stream_title.rs
Normal file
64
src/widgets/stream_title.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use crate::note_util::NoteUtil;
|
||||
use crate::route::RouteServices;
|
||||
use crate::stream_info::{StreamInfo, StreamStatus};
|
||||
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, PRIMARY};
|
||||
use crate::widgets::zap::ZapButton;
|
||||
use crate::widgets::Pill;
|
||||
use crate::widgets::Profile;
|
||||
use egui::{vec2, Color32, Frame, Label, Response, RichText, TextWrapMode, Ui};
|
||||
use nostrdb::Note;
|
||||
|
||||
pub struct StreamTitle<'a> {
|
||||
event: &'a Note<'a>,
|
||||
}
|
||||
|
||||
impl<'a> StreamTitle<'a> {
|
||||
pub fn new(event: &'a Note<'a>) -> StreamTitle<'a> {
|
||||
StreamTitle { event }
|
||||
}
|
||||
pub fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
Frame::none()
|
||||
.outer_margin(MARGIN_DEFAULT)
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(5., 8.0);
|
||||
|
||||
let title = RichText::new(self.event.title().unwrap_or("Untitled"))
|
||||
.size(20.)
|
||||
.color(Color32::WHITE);
|
||||
ui.add(Label::new(title.strong()).wrap_mode(TextWrapMode::Wrap));
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
Profile::new(self.event.host())
|
||||
.size(32.)
|
||||
.render(ui, services);
|
||||
ZapButton::event(self.event).render(ui, services);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let status = self.event.status().to_string().to_uppercase();
|
||||
let live_label_color = if self.event.status() == StreamStatus::Live {
|
||||
PRIMARY
|
||||
} else {
|
||||
NEUTRAL_900
|
||||
};
|
||||
ui.add(Pill::new(&status).color(live_label_color));
|
||||
|
||||
ui.add(Pill::new(&format!(
|
||||
"{} viewers",
|
||||
self.event.viewers().unwrap_or(0)
|
||||
)));
|
||||
});
|
||||
if let Some(summary) = self
|
||||
.event
|
||||
.get_tag_value("summary")
|
||||
.and_then(|r| r.variant().str())
|
||||
{
|
||||
if !summary.is_empty() {
|
||||
let summary = RichText::new(summary).color(Color32::WHITE);
|
||||
ui.add(Label::new(summary).wrap_mode(TextWrapMode::Wrap));
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
}
|
||||
}
|
52
src/widgets/text_input.rs
Normal file
52
src/widgets/text_input.rs
Normal file
@ -0,0 +1,52 @@
|
||||
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_500, NEUTRAL_900, ROUNDING_DEFAULT};
|
||||
use egui::{Frame, Response, TextEdit, Ui, Widget};
|
||||
|
||||
/// Wrap the [TextEdit] widget to handle native keyboard
|
||||
pub struct NativeTextInput<'a> {
|
||||
pub text: &'a mut String,
|
||||
hint_text: Option<&'a str>,
|
||||
frame: bool,
|
||||
}
|
||||
|
||||
impl<'a> NativeTextInput<'a> {
|
||||
pub fn new(text: &'a mut String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
hint_text: None,
|
||||
frame: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_hint_text(mut self, hint_text: &'a str) -> Self {
|
||||
self.hint_text = Some(hint_text);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_frame(mut self, frame: bool) -> Self {
|
||||
self.frame = frame;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for NativeTextInput<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let mut editor = TextEdit::multiline(self.text)
|
||||
.frame(false)
|
||||
.desired_rows(1)
|
||||
.desired_width(f32::INFINITY);
|
||||
if let Some(hint_text) = self.hint_text {
|
||||
editor = editor.hint_text(egui::RichText::new(hint_text).color(NEUTRAL_500));
|
||||
}
|
||||
|
||||
if self.frame {
|
||||
Frame::none()
|
||||
.inner_margin(MARGIN_DEFAULT)
|
||||
.fill(NEUTRAL_900)
|
||||
.rounding(ROUNDING_DEFAULT)
|
||||
.show(ui, |ui| ui.add(editor))
|
||||
.inner
|
||||
} else {
|
||||
ui.add(editor)
|
||||
}
|
||||
}
|
||||
}
|
23
src/widgets/username.rs
Normal file
23
src/widgets/username.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use egui::{Color32, Label, Response, RichText, TextWrapMode, Ui, Widget};
|
||||
use nostrdb::NdbProfile;
|
||||
|
||||
pub struct Username<'a> {
|
||||
profile: &'a Option<NdbProfile<'a>>,
|
||||
size: f32,
|
||||
}
|
||||
|
||||
impl<'a> Username<'a> {
|
||||
pub fn new(profile: &'a Option<NdbProfile<'a>>, size: f32) -> Self {
|
||||
Self { profile, size }
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Username<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let name = self
|
||||
.profile
|
||||
.map_or("Nostrich", |f| f.name().map_or("Nostrich", |f| f));
|
||||
let name = RichText::new(name).size(self.size).color(Color32::WHITE);
|
||||
ui.add(Label::new(name).wrap_mode(TextWrapMode::Truncate))
|
||||
}
|
||||
}
|
50
src/widgets/write_chat.rs
Normal file
50
src/widgets/write_chat.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::route::RouteServices;
|
||||
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, ROUNDING_DEFAULT};
|
||||
use crate::widgets::NativeTextInput;
|
||||
use eframe::emath::Align;
|
||||
use egui::{Frame, Image, Layout, Response, Sense, Ui, Widget};
|
||||
use log::info;
|
||||
|
||||
pub struct WriteChat {
|
||||
link: NostrLink,
|
||||
msg: String,
|
||||
}
|
||||
|
||||
impl WriteChat {
|
||||
pub fn new(link: NostrLink) -> Self {
|
||||
Self {
|
||||
link,
|
||||
msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_, '_>) -> Response {
|
||||
let logo_bytes = include_bytes!("../resources/send-03.svg");
|
||||
Frame::none()
|
||||
.inner_margin(MARGIN_DEFAULT)
|
||||
.outer_margin(MARGIN_DEFAULT)
|
||||
.fill(NEUTRAL_900)
|
||||
.rounding(ROUNDING_DEFAULT)
|
||||
.show(ui, |ui| {
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if Image::from_bytes("send-03.svg", logo_bytes)
|
||||
.sense(Sense::click())
|
||||
.ui(ui)
|
||||
.clicked()
|
||||
|| self.msg.ends_with('\n')
|
||||
{
|
||||
if let Some(ev) = services.write_live_chat_msg(&self.link, self.msg.trim())
|
||||
{
|
||||
info!("Sending: {:?}", ev);
|
||||
services.broadcast_event(ev);
|
||||
}
|
||||
self.msg.clear();
|
||||
}
|
||||
|
||||
ui.add(NativeTextInput::new(&mut self.msg).with_hint_text("Message.."));
|
||||
});
|
||||
})
|
||||
.response
|
||||
}
|
||||
}
|
294
src/widgets/zap.rs
Normal file
294
src/widgets/zap.rs
Normal file
@ -0,0 +1,294 @@
|
||||
use crate::link::NostrLink;
|
||||
use crate::route::RouteServices;
|
||||
use crate::stream_info::StreamInfo;
|
||||
use crate::theme::{
|
||||
FONT_SIZE_LG, MARGIN_DEFAULT, NEUTRAL_700, NEUTRAL_800, NEUTRAL_900, PRIMARY, ROUNDING_DEFAULT,
|
||||
};
|
||||
use crate::widgets::{Button, NativeTextInput};
|
||||
use crate::zap::format_sats;
|
||||
use anyhow::{anyhow, bail};
|
||||
use egui::text::{LayoutJob, TextWrapping};
|
||||
use egui::{vec2, Frame, Grid, Response, RichText, Stroke, TextFormat, TextWrapMode, Ui, Widget};
|
||||
use egui_modal::Modal;
|
||||
use egui_qr::QrCodeWidget;
|
||||
use enostr::PoolRelay;
|
||||
use itertools::Itertools;
|
||||
use lnurl::pay::{LnURLPayInvoice, PayResponse};
|
||||
use nostr::prelude::{hex, ZapRequestData};
|
||||
use nostr::{serde_json, EventBuilder, JsonUtil, Kind, PublicKey, Tag, Url};
|
||||
use nostrdb::Note;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::task::Poll;
|
||||
|
||||
pub enum ZapTarget<'a> {
|
||||
PublicKey { pubkey: [u8; 32] },
|
||||
Event { event: &'a Note<'a> },
|
||||
}
|
||||
|
||||
impl Display for ZapTarget<'_> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ZapTarget::PublicKey { pubkey } => write!(f, "{}", hex::encode(pubkey)),
|
||||
ZapTarget::Event { event } => write!(f, "{}", hex::encode(event.id())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ZapState {
|
||||
NotStarted,
|
||||
Ready { service: PayResponse },
|
||||
FetchingInvoice { callback: String },
|
||||
Invoice { invoice: LnURLPayInvoice },
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct ZapButton<'a> {
|
||||
target: ZapTarget<'a>,
|
||||
}
|
||||
|
||||
impl<'a> ZapButton<'a> {
|
||||
pub fn pubkey(pubkey: [u8; 32]) -> Self {
|
||||
Self {
|
||||
target: ZapTarget::PublicKey { pubkey },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event(event: &'a Note<'a>) -> Self {
|
||||
Self {
|
||||
target: ZapTarget::Event { event },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(self, ui: &mut Ui, services: &mut RouteServices) -> Response {
|
||||
// TODO: fix id
|
||||
let modal = Modal::new(ui.ctx(), format!("zapper-{}", 0)).with_close_on_outside_click(true);
|
||||
|
||||
let resp = Button::new().show(ui, |ui| ui.label("ZAP"));
|
||||
if resp.clicked() {
|
||||
modal.open();
|
||||
}
|
||||
ui.visuals_mut().window_rounding = ROUNDING_DEFAULT.into();
|
||||
ui.visuals_mut().window_stroke = Stroke::NONE;
|
||||
ui.visuals_mut().window_fill = NEUTRAL_900;
|
||||
|
||||
modal.show(|ui| {
|
||||
Frame::none().inner_margin(MARGIN_DEFAULT).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
|
||||
|
||||
let pubkey = match &self.target {
|
||||
ZapTarget::PublicKey { pubkey } => pubkey,
|
||||
ZapTarget::Event { event } => event.host(),
|
||||
};
|
||||
|
||||
// zapping state machine
|
||||
let zap_state = services.get("zap_state").unwrap_or(ZapState::NotStarted);
|
||||
match &zap_state {
|
||||
ZapState::NotStarted => match services.fetch_lnurlp(pubkey) {
|
||||
Ok(Poll::Ready(r)) => {
|
||||
services.set("zap_state", ZapState::Ready { service: r })
|
||||
}
|
||||
Err(e) => services.set("zap_state", ZapState::Error(e.to_string())),
|
||||
_ => {}
|
||||
},
|
||||
ZapState::FetchingInvoice { callback } => {
|
||||
match self.zap_get_invoice(callback, services) {
|
||||
Ok(Poll::Ready(s)) => {
|
||||
services.set("zap_state", ZapState::Invoice { invoice: s })
|
||||
}
|
||||
Err(e) => services.set("zap_state", ZapState::Error(e.to_string())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// when ready state, show zap button
|
||||
match &zap_state {
|
||||
ZapState::Ready { service } => {
|
||||
self.render_input(ui, services, pubkey, service);
|
||||
}
|
||||
ZapState::Invoice { invoice } => {
|
||||
if let Ok(q) = QrCodeWidget::from_data(invoice.pr.as_bytes()) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_sized(vec2(256., 256.), q);
|
||||
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap = TextWrapping::from_wrap_mode_and_width(
|
||||
TextWrapMode::Truncate,
|
||||
ui.available_width(),
|
||||
);
|
||||
job.append(&invoice.pr, 0.0, TextFormat::default());
|
||||
ui.label(job);
|
||||
});
|
||||
}
|
||||
}
|
||||
ZapState::Error(e) => {
|
||||
ui.label(e);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if modal.was_outside_clicked() {
|
||||
services.set("zap_state", ZapState::NotStarted)
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
fn zap_get_invoice(
|
||||
&self,
|
||||
callback: &str,
|
||||
services: &mut RouteServices,
|
||||
) -> anyhow::Result<Poll<LnURLPayInvoice>> {
|
||||
match services.fetch(callback) {
|
||||
Poll::Ready(Ok(r)) => {
|
||||
if r.ok {
|
||||
let inv: LnURLPayInvoice = serde_json::from_slice(&r.bytes)?;
|
||||
Ok(Poll::Ready(inv))
|
||||
} else {
|
||||
bail!("Invalid response code {}", r.status);
|
||||
}
|
||||
}
|
||||
Poll::Ready(Err(e)) => Err(anyhow!("{}", e)),
|
||||
Poll::Pending => Ok(Poll::Pending),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_input(
|
||||
&self,
|
||||
ui: &mut Ui,
|
||||
services: &mut RouteServices,
|
||||
pubkey: &[u8; 32],
|
||||
service: &PayResponse,
|
||||
) {
|
||||
let target_name = match self.target {
|
||||
ZapTarget::PublicKey { pubkey } => services.profile(&pubkey).and_then(|p| p.name()),
|
||||
ZapTarget::Event { event } => {
|
||||
let host = event.host();
|
||||
services.profile(host).and_then(|p| p.name())
|
||||
}
|
||||
};
|
||||
let fallback_name = self.target.to_string();
|
||||
let target_name = target_name.unwrap_or(&fallback_name);
|
||||
ui.label(RichText::new(format!("Zap {}", target_name)).size(FONT_SIZE_LG));
|
||||
|
||||
ui.label("Zap amount in sats");
|
||||
|
||||
// amount buttons
|
||||
const SATS_AMOUNTS: &[u64] = &[
|
||||
21, 69, 121, 420, 1_000, 2_100, 4_200, 10_000, 21_000, 42_000, 69_000, 100_000,
|
||||
210_000, 500_000, 1_000_000,
|
||||
];
|
||||
const COLS: u32 = 5;
|
||||
let selected_amount = services.get("zap_amount").unwrap_or(0);
|
||||
Grid::new("zap_amounts_grid").show(ui, |ui| {
|
||||
let mut ctr = 0;
|
||||
for x in SATS_AMOUNTS {
|
||||
if Button::new()
|
||||
.with_color(if selected_amount == *x {
|
||||
NEUTRAL_700
|
||||
} else {
|
||||
NEUTRAL_800
|
||||
})
|
||||
.text(ui, &format_sats(*x as f32))
|
||||
.clicked()
|
||||
{
|
||||
services.set("zap_amount", *x);
|
||||
}
|
||||
ctr += 1;
|
||||
if ctr % COLS == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// comment section
|
||||
let mut zap_comment = services.get("zap_comment").unwrap_or(String::new());
|
||||
ui.label(format!("Your comment for {}", target_name));
|
||||
let old_len = zap_comment.len();
|
||||
NativeTextInput::new(&mut zap_comment)
|
||||
.with_frame(true)
|
||||
.ui(ui);
|
||||
|
||||
if Button::new().with_color(PRIMARY).text(ui, "Zap!").clicked() {
|
||||
// on-click setup callback URL and transition state
|
||||
match self.zap_callback(
|
||||
services,
|
||||
pubkey,
|
||||
&zap_comment,
|
||||
selected_amount * 1_000,
|
||||
&service,
|
||||
) {
|
||||
Ok(callback) => services.set(
|
||||
"zap_state",
|
||||
ZapState::FetchingInvoice {
|
||||
callback: callback.to_string(),
|
||||
},
|
||||
),
|
||||
Err(e) => services.set("zap_state", ZapState::Error(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
if zap_comment.len() != old_len {
|
||||
services.set("zap_comment", zap_comment);
|
||||
}
|
||||
}
|
||||
|
||||
fn zap_callback(
|
||||
&self,
|
||||
services: &mut RouteServices,
|
||||
pubkey: &[u8; 32],
|
||||
zap_comment: &str,
|
||||
amount: u64,
|
||||
lnurlp: &PayResponse,
|
||||
) -> anyhow::Result<Url> {
|
||||
let relays: Vec<Url> = services
|
||||
.ctx
|
||||
.pool
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
PoolRelay::Websocket(w) => Url::parse(&w.relay.url).ok(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if relays.is_empty() {
|
||||
bail!("No relays found");
|
||||
}
|
||||
let mut req = ZapRequestData::new(PublicKey::from_slice(pubkey)?, relays)
|
||||
.message(zap_comment)
|
||||
.amount(amount);
|
||||
match &self.target {
|
||||
ZapTarget::Event { event } => {
|
||||
req.event_coordinate = Some(
|
||||
NostrLink::from_note(event)
|
||||
.try_into()
|
||||
.map_err(|e| anyhow!("{:?}", e))?,
|
||||
)
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
let req_tags: Vec<Tag> = req.into();
|
||||
let keys = if let Some(k) = services.current_account_keys() {
|
||||
k
|
||||
} else {
|
||||
bail!("Not logged in")
|
||||
};
|
||||
|
||||
let req_ev = EventBuilder::new(Kind::ZapRequest, zap_comment)
|
||||
.tags(req_tags)
|
||||
.sign_with_keys(&keys)?;
|
||||
|
||||
let mut url = Url::parse(&lnurlp.callback)?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("amount", amount.to_string().as_str());
|
||||
if lnurlp.nostr_pubkey.is_some() {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("nostr", req_ev.as_json().as_str());
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
}
|
73
src/zap.rs
Normal file
73
src/zap.rs
Normal file
@ -0,0 +1,73 @@
|
||||
use crate::note_util::NoteUtil;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use fixed_decimal::FixedDecimal;
|
||||
use icu::decimal::FixedDecimalFormatter;
|
||||
use icu::locid::Locale;
|
||||
use nostr::{Event, JsonUtil, Kind, TagStandard};
|
||||
use nostrdb::Note;
|
||||
|
||||
pub struct Zap<'a> {
|
||||
pub sender: [u8; 32],
|
||||
pub receiver: [u8; 32],
|
||||
pub zapper_service: &'a [u8; 32],
|
||||
pub amount: u64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl<'a> Zap<'a> {
|
||||
pub fn from_receipt(event: Note<'a>) -> Result<Zap> {
|
||||
if event.kind() != 9735 {
|
||||
bail!("not a zap receipt");
|
||||
}
|
||||
|
||||
let req_json = event
|
||||
.get_tag_value("description")
|
||||
.ok_or(anyhow!("missing description"))?;
|
||||
let req = Event::from_json(
|
||||
req_json
|
||||
.variant()
|
||||
.str()
|
||||
.ok_or(anyhow!("empty description"))?,
|
||||
)?;
|
||||
|
||||
if req.kind != Kind::ZapRequest {
|
||||
bail!("not a zap request");
|
||||
}
|
||||
|
||||
let dest = req
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|t| match t.as_standardized() {
|
||||
Some(TagStandard::PublicKey { public_key, .. }) => Some(public_key.to_bytes()),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or(anyhow!("missing p tag in zap request"))?;
|
||||
|
||||
let amount = req.tags.iter().find_map(|t| match t.as_standardized() {
|
||||
Some(TagStandard::Amount { millisats, .. }) => Some(*millisats),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
Ok(Zap {
|
||||
sender: req.pubkey.to_bytes(),
|
||||
receiver: dest,
|
||||
zapper_service: event.pubkey(),
|
||||
amount: amount.unwrap_or(0u64),
|
||||
message: req.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_sats(n: f32) -> String {
|
||||
let (div_n, suffix) = if n >= 1_000. && n < 1_000_000. {
|
||||
(1_000., "K")
|
||||
} else if n >= 1_000_000. {
|
||||
(1_000_000., "M")
|
||||
} else {
|
||||
(1., "")
|
||||
};
|
||||
|
||||
let fmt = FixedDecimalFormatter::try_new(&Locale::UND.into(), Default::default()).expect("icu");
|
||||
let d: FixedDecimal = (n / div_n).to_string().parse().expect("fixed decimal");
|
||||
format!("{}{}", fmt.format_to_string(&d), suffix)
|
||||
}
|
Reference in New Issue
Block a user