Compare commits

...

40 Commits

Author SHA1 Message Date
c8c5485581 feat: setup notedeck 2024-12-17 10:45:53 +00:00
6efd99018a chore: bump nostrdb-rs 2024-12-13 10:22:17 +00:00
0470f47460 fix: app dirs 2024-11-21 15:38:10 +00:00
e4cceef0cf feat: Enter to send msg 2024-11-21 14:39:27 +00:00
b65acf01c8 fix: ci 2024-11-21 12:40:34 +00:00
cdafa1d786 fix: ci 2024-11-20 16:35:32 +00:00
4a11bad6d9 fix: ci 2024-11-20 16:20:13 +00:00
faf18f17eb fix: ci 2024-11-20 16:12:07 +00:00
e54491c019 fix: ci 2024-11-20 16:11:43 +00:00
45636a3721 fix: ci 2024-11-20 15:34:20 +00:00
58b2d0aef2 fix: ci 2024-11-20 15:32:37 +00:00
7cc222b92b [CI] fix: install deps 2024-11-20 15:28:36 +00:00
20e3159c60 [CI] fix: install deps 2024-11-20 15:28:10 +00:00
e88f809958 fix: typo 2024-11-20 15:20:06 +00:00
caed29915c feat: drone build 2024-11-20 15:14:20 +00:00
35aa3c1c91 feat: add font
fix: filter unplayable streams
2024-11-20 15:08:40 +00:00
56b73b879e feat: single task image loader 2024-11-20 10:43:29 +00:00
93e412a07c chore: bump egui-video 2024-11-11 16:16:54 +00:00
621686564d feat: defer image load 2024-11-06 20:19:25 +00:00
10cd15d942 fix: video placeholder 2024-11-05 13:35:31 +00:00
e718b8e322 fix: designs 2024-11-05 13:24:10 +00:00
ce5f206f6a chore: bump egui-video 2024-11-05 12:16:18 +00:00
095d69534d fix: macos startup 2024-11-02 16:15:36 +00:00
7e67c3d8e7 feat: responsive design 2024-11-02 11:22:42 +00:00
58132d2cf5 feat: native layer code
feat: login kinds (nsec)
feat: write chat messages
2024-10-31 15:56:29 +00:00
a98eb6f4ce fix: load svgs with resvg 2024-10-30 21:54:41 +00:00
c62fbfe510 feat: ffmpeg image loader 2024-10-22 23:03:49 +01:00
d21a45c941 feat: use new player 2024-10-22 16:33:59 +01:00
1e487f89a5 fix: android 2024-10-18 13:14:54 +01:00
e6b606e8fb chore: clippy fix 2024-10-18 13:11:21 +01:00
b4a6991007 feat: UI progress 2024-10-18 13:10:47 +01:00
5bed3fa86f fix: profile loading 2024-10-17 22:54:20 +01:00
948276eb65 fix: profile loading 2024-10-17 22:31:12 +01:00
117968bd17 fix: various bugs for android 2024-10-17 22:05:53 +01:00
91f0baf75c feat: android build 2024-10-17 20:06:37 +01:00
6017ce18d4 feat: chat
feat: android setup
2024-10-16 20:23:52 +01:00
f0a50918a8 feat: query system 2024-10-16 11:02:39 +01:00
9a8bb54e08 refactor: widget state 2024-10-11 16:42:05 +01:00
67d6381123 setup routes 2024-10-10 20:40:25 +01:00
abfcbc8954 Fix stream list 2024-10-09 21:54:56 +01:00
54 changed files with 9916 additions and 327 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
**/target
.data/
ffmpeg-kit/

12
.drone.yaml Normal file
View 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
View File

@ -1 +1,4 @@
/target
/.data
/.idea
/ffmpeg-kit

6966
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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 = []
notedeck = ["dep:notedeck", "dep:notedeck-chrome"]
[dependencies]
tokio = { version = "1.40.0", features = ["fs", "rt-multi-thread", "rt"] }
egui = { version = "0.29.1", default-features = false, features = [] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "3deb94aef3f436469158c4424650d81be26f9315" }
nostr-sdk = { version = "0.37", features = ["all-nips"] }
log = "0.4.22"
pretty_env_logger = "0.5.0"
egui_inbox = "0.6.0"
bech32 = "0.11.0"
libc = "0.2.158"
uuid = { version = "1.11.0", features = ["v4"] }
chrono = "0.4.38"
anyhow = "^1.0.91"
async-trait = "0.1.83"
sha2 = "0.10.8"
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls-native-roots"] }
itertools = "0.13.0"
lru = "0.12.5"
resvg = { version = "0.44.0", default-features = false }
serde = { version = "1.0.214", features = ["derive"] }
serde_with = { version = "3.11.0", features = ["hex"] }
directories = "5.0.1"
egui-video = { git = "https://github.com/v0l/egui-video.git", rev = "d2ea3b4db21eb870a207db19e4cd21c7d1d24836" }
notedeck-chrome = { git = "https://git.v0l.io/nostr/notedeck.git", branch = "master", package = "notedeck_chrome", optional = true }
notedeck = { git = "https://git.v0l.io/nostr/notedeck.git", branch = "master", package = "notedeck", optional = true }
[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
View File

@ -0,0 +1,4 @@
FROM rust:bookworm AS builder
WORKDIR /src
COPY . .
RUN ./debian.sh

4
TODO.md Normal file
View File

@ -0,0 +1,4 @@
- [Login] Proper key storage
- [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
View 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

Binary file not shown.

BIN
assets/Outfit-Regular.ttf Normal file

Binary file not shown.

BIN
assets/Outfit.ttf Normal file

Binary file not shown.

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

36
debian.sh Executable file
View 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
View 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

90
src/android.rs Normal file
View File

@ -0,0 +1,90 @@
use crate::app::{NativeLayerOps, ZapStreamApp};
use eframe::Renderer;
use egui::{Margin, ViewportBuilder};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::ops::Div;
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| Ok(Box::new(ZapStreamApp::new(cc, data_path, app)))),
) {
eprintln!("{}", e);
}
}
impl NativeLayerOps for AndroidApp {
fn frame_margin(&self) -> Margin {
if let Some(wd) = self.native_window() {
let (w, h) = (wd.width(), wd.height());
let c_rect = self.content_rect();
let dpi = self.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,
}
.div(dpi_scale)
} else {
Margin::ZERO
}
}
fn show_keyboard(&self) {
self.show_soft_input(true);
}
fn hide_keyboard(&self) {
self.hide_soft_input(true);
}
fn get(&self, k: &str) -> Option<String> {
None
}
fn set(&mut self, k: &str, v: &str) -> bool {
false
}
fn remove(&mut self, k: &str) -> bool {
false
}
fn get_obj<T: DeserializeOwned>(&self, k: &str) -> Option<T> {
None
}
fn set_obj<T: Serialize>(&mut self, k: &str, v: &T) -> bool {
false
}
}

View File

@ -1,85 +1,133 @@
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::route::Router;
use eframe::epaint::FontFamily;
use eframe::CreationContext;
use egui::{Color32, FontData, FontDefinitions, Margin};
use nostr_sdk::prelude::MemoryDatabase;
use nostr_sdk::Client;
use nostrdb::{Config, Ndb};
use notedeck::AppContext;
use std::path::PathBuf;
pub struct ZapStreamApp {
ndb: Ndb,
pub struct ZapStreamApp<T: NativeLayerOps> {
client: Client,
notifications: broadcast::Receiver<RelayPoolNotification>,
events: Vec<Event>,
services: Services,
router: Router<T>,
native_layer: T,
}
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();
pub trait NativeLayerOps {
/// Get any display layout margins
fn frame_margin(&self) -> Margin;
/// Show the keyboard on the screen
fn show_keyboard(&self);
/// Hide on screen keyboard
fn hide_keyboard(&self);
fn get(&self, k: &str) -> Option<String>;
fn set(&mut self, k: &str, v: &str) -> bool;
fn remove(&mut self, k: &str) -> bool;
fn get_obj<T: serde::de::DeserializeOwned>(&self, k: &str) -> Option<T>;
fn set_obj<T: serde::Serialize>(&mut self, k: &str, v: &T) -> bool;
}
impl<T> ZapStreamApp<T>
where
T: NativeLayerOps + Clone,
{
pub fn new(cc: &CreationContext, data_path: PathBuf, config: T) -> Self {
let client = Client::builder()
.database(MemoryDatabase::with_opts(Default::default()))
.build();
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
.add_relay("wss://nos.lol")
.await
.expect("Failed to add relay");
client_clone
.add_relay("wss://relay.damus.io")
.await
.expect("Failed to add relay");
client_clone
.add_relay("wss://relay.snort.social")
.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();
}
});
egui_extras::install_image_loaders(&cc.egui_ctx);
let ndb_path = data_path.join("ndb");
std::fs::create_dir_all(&ndb_path).expect("Failed to create ndb directory");
let mut ndb_config = Config::default();
ndb_config.set_ingester_threads(4);
let ndb = Ndb::new(ndb_path.to_str().unwrap(), &ndb_config).unwrap();
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 cfg = config.clone();
Self {
ndb: Ndb::new(".", &Config::default()).unwrap(),
client: client.clone(),
notifications,
services: Services::new(client, cc.egui_ctx.clone()),
events: vec![],
}
}
fn process_nostr(&mut self) {
while let Ok(msg) = self.notifications.try_recv() {
match msg {
RelayPoolNotification::Event { event, .. } => {
self.events.push(*event);
}
_ => {
// dont care
}
}
router: Router::new(
data_path,
cc.egui_ctx.clone(),
client.clone(),
ndb.clone(),
cfg,
),
native_layer: config,
}
}
}
impl App for ZapStreamApp {
#[cfg(not(feature = "notedeck"))]
impl<T> App for ZapStreamApp<T>
where
T: NativeLayerOps,
{
fn update(&mut self, ctx: &Context, frame: &mut Frame) {
let txn = Transaction::new(&self.ndb).expect("txn");
self.process_nostr();
let mut app_frame = egui::containers::Frame::default();
let margin = self.native_layer.frame_margin();
app_frame.inner_margin = margin;
app_frame.stroke.color = Color32::BLACK;
ctx.set_debug_on_hover(true);
//ctx.set_debug_on_hover(true);
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));
});
ui.visuals_mut().override_text_color = Some(Color32::WHITE);
self.router.show(ui);
});
}
}
}
#[cfg(feature = "notedeck")]
impl<T> notedeck::App for ZapStreamApp<T>
where
T: NativeLayerOps,
{
fn update(&mut self, ctx: &mut AppContext<'_>) {
let mut app_frame = egui::containers::Frame::default();
let margin = self.native_layer.frame_margin();
app_frame.inner_margin = margin;
app_frame.stroke.color = Color32::BLACK;
//ctx.set_debug_on_hover(true);
egui::CentralPanel::default()
.frame(app_frame)
.show(ctx.egui, |ui| {
ui.visuals_mut().override_text_color = Some(Color32::WHITE);
self.router.show(ui);
});
}
}

136
src/bin/zap_stream_app.rs Normal file
View File

@ -0,0 +1,136 @@
use anyhow::Result;
use directories::ProjectDirs;
use eframe::Renderer;
use egui::{Margin, Vec2, ViewportBuilder};
use log::error;
use nostr_sdk::serde_json;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::ops::Deref;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use zap_stream_app::app::{NativeLayerOps, 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", "app")
.unwrap()
.config_dir()
.to_path_buf();
let config = DesktopApp::new(data_path.clone());
#[cfg(feature = "notedeck")]
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, data_path, config);
notedeck.add_app(app);
Ok(Box::new(notedeck))
}),
) {
error!("{}", e);
}
#[cfg(not(feature = "notedeck"))]
if let Err(e) = eframe::run_native("zap.stream", options, Box::new(move |cc| Ok(Box::new()))) {
error!("{}", e);
}
Ok(())
}
#[derive(Clone)]
pub struct DesktopApp {
data_path: PathBuf,
data: Arc<RwLock<HashMap<String, String>>>,
}
impl DesktopApp {
pub fn new(data_path: PathBuf) -> Self {
let mut r = Self {
data_path,
data: Arc::new(RwLock::new(HashMap::new())),
};
r.load();
r
}
fn storage_file_path(&self) -> PathBuf {
self.data_path.join("kv.json")
}
fn load(&mut self) {
let path = self.storage_file_path();
if path.exists() {
let mut file = std::fs::File::open(path).unwrap();
let mut data = Vec::new();
file.read_to_end(&mut data).unwrap();
if let Ok(d) = serde_json::from_slice(data.as_slice()) {
self.data = Arc::new(RwLock::new(d));
}
}
}
fn save(&self) {
let path = self.storage_file_path();
let mut file = std::fs::File::create(path).unwrap();
let json = serde_json::to_string_pretty(self.data.read().unwrap().deref()).unwrap();
file.write_all(json.as_bytes()).unwrap();
}
}
impl NativeLayerOps for DesktopApp {
fn frame_margin(&self) -> Margin {
Margin::ZERO
}
fn show_keyboard(&self) {
// nothing to do
}
fn hide_keyboard(&self) {
// nothing to do
}
fn get(&self, k: &str) -> Option<String> {
self.data.read().unwrap().get(k).cloned()
}
fn set(&mut self, k: &str, v: &str) -> bool {
self.data
.write()
.unwrap()
.insert(k.to_owned(), v.to_owned())
.is_none()
}
fn remove(&mut self, k: &str) -> bool {
self.data.write().unwrap().remove(k).is_some()
}
fn get_obj<T: DeserializeOwned>(&self, k: &str) -> Option<T> {
serde_json::from_str(self.get(k)?.as_str()).ok()
}
fn set_obj<T: Serialize>(&mut self, k: &str, v: &T) -> bool {
self.set(k, serde_json::to_string(v).unwrap().as_str())
}
}
impl Drop for DesktopApp {
fn drop(&mut self) {
self.save();
}
}

22
src/lib.rs Normal file
View File

@ -0,0 +1,22 @@
#[cfg(target_os = "android")]
mod android;
pub mod app;
mod link;
mod login;
mod note_store;
mod note_util;
mod route;
mod services;
mod stream_info;
mod theme;
mod widgets;
#[cfg(target_os = "android")]
use android_activity::AndroidApp;
#[cfg(target_os = "android")]
#[no_mangle]
#[tokio::main]
pub async fn android_main(app: AndroidApp) {
android::start_android(app);
}

174
src/link.rs Normal file
View File

@ -0,0 +1,174 @@
use crate::note_util::NoteUtil;
use bech32::{Hrp, NoChecksum};
use egui::TextBuffer;
use nostr_sdk::util::hex;
use nostrdb::{Filter, Note};
use std::fmt::{Display, Formatter};
#[derive(Clone, Eq, PartialEq)]
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)]
pub enum IdOrStr {
Id([u8; 32]),
Str(String),
}
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)]
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
&& note
.get_tag_value("d")
.and_then(|v| v.variant().str())
.is_some()
{
Self {
hrp: NostrLinkType::Coordinate,
id: IdOrStr::Str(
note.get_tag_value("d")
.unwrap()
.variant()
.str()
.unwrap()
.to_string(),
),
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 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!(),
}
}
}

89
src/login.rs Normal file
View File

@ -0,0 +1,89 @@
use crate::app::NativeLayerOps;
use crate::link::NostrLink;
use anyhow::Error;
use nostr_sdk::secp256k1::{Keypair, XOnlyPublicKey};
use nostr_sdk::{Event, EventBuilder, Keys, Kind, SecretKey, Tag, UnsignedEvent};
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use std::ops::Deref;
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum LoginKind {
PublicKey {
#[serde_as(as = "serde_with::hex::Hex")]
key: [u8; 32],
},
PrivateKey {
#[serde_as(as = "serde_with::hex::Hex")]
key: [u8; 32],
},
LoggedOut,
}
pub struct Login {
kind: LoginKind,
}
impl Login {
pub fn new() -> Self {
Self {
kind: LoginKind::LoggedOut,
}
}
pub fn load<T: NativeLayerOps>(&mut self, storage: &T) {
if let Some(k) = storage.get_obj("login") {
self.kind = k;
}
}
pub fn save<T: NativeLayerOps>(&mut self, storage: &mut T) {
storage.set_obj("login", &self.kind);
}
pub fn login(&mut self, kind: LoginKind) {
self.kind = kind;
}
pub fn is_logged_in(&self) -> bool {
!matches!(self.kind, LoginKind::LoggedOut)
}
pub fn public_key(&self) -> Option<[u8; 32]> {
match self.kind {
LoginKind::PublicKey { key } => Some(key),
LoginKind::PrivateKey { key } => {
// TODO: wow this is annoying
let sk = Keypair::from_seckey_slice(nostr_sdk::SECP256K1.deref(), key.as_slice())
.unwrap();
Some(XOnlyPublicKey::from_keypair(&sk).0.serialize())
}
_ => None,
}
}
fn secret_key(&self) -> Result<Keys, Error> {
if let LoginKind::PrivateKey { key } = self.kind {
Ok(Keys::new(SecretKey::from_slice(key.as_slice())?))
} else {
anyhow::bail!("No private key");
}
}
pub fn sign_event(&self, ev: UnsignedEvent) -> Result<Event, Error> {
let secret = self.secret_key()?;
ev.sign_with_keys(&secret).map_err(Error::new)
}
pub fn write_live_chat_msg(&self, link: &NostrLink, msg: &str) -> Result<Event, Error> {
if msg.len() == 0 {
return Err(anyhow::anyhow!("Empty message"));
}
let secret = self.secret_key()?;
EventBuilder::new(Kind::LiveEventMessage, msg)
.tag(Tag::parse(&link.to_tag())?)
.sign_with_keys(&secret)
.map_err(Error::new)
}
}

View File

@ -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)))),
);
}

49
src/note_store.rs Normal file
View File

@ -0,0 +1,49 @@
use crate::link::NostrLink;
use nostrdb::Note;
use std::collections::HashMap;
pub struct NoteStore<'a> {
events: HashMap<String, &'a Note<'a>>,
}
impl<'a> NoteStore<'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()
}
}

69
src/note_util.rs Normal file
View File

@ -0,0 +1,69 @@
use nostr_sdk::util::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<'a> NoteUtil for Note<'a> {
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
}
}
}
#[derive(Eq, PartialEq)]
pub struct OwnedNote(pub u64);

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View 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

96
src/route/home.rs Normal file
View File

@ -0,0 +1,96 @@
use crate::note_store::NoteStore;
use crate::note_util::OwnedNote;
use crate::route::RouteServices;
use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper};
use crate::stream_info::{StreamInfo, StreamStatus};
use crate::widgets;
use crate::widgets::NostrWidget;
use egui::{Id, Response, RichText, ScrollArea, Ui, Widget};
use nostrdb::{Filter, Note, NoteKey, Transaction};
pub struct HomePage {
sub: SubWrapper,
events: Vec<OwnedNote>,
}
impl HomePage {
pub fn new(ndb: &NDBWrapper, tx: &Transaction) -> Self {
let filter = [Filter::new().kinds([30_311]).limit(100).build()];
let (sub, events) = ndb.subscribe_with_results("home-page", &filter, tx, 1000);
Self {
sub,
events: events
.iter()
.map(|e| OwnedNote(e.note_key.as_u64()))
.collect(),
}
}
}
impl NostrWidget for HomePage {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
let new_notes = services.ndb.poll(&self.sub, 100);
new_notes
.iter()
.for_each(|n| self.events.push(OwnedNote(n.as_u64())));
let events: Vec<Note<'_>> = self
.events
.iter()
.map(|n| services.ndb.get_note_by_key(services.tx, NoteKey::new(n.0)))
.map_while(|f| f.ok())
.filter(|f| f.can_play())
.collect();
ScrollArea::vertical()
.show(ui, |ui| {
let events_live = NoteStore::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,
services,
Some(RichText::new("Live").size(32.0)),
)
.ui(ui);
}
let events_planned = NoteStore::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,
services,
Some(RichText::new("Planned").size(32.0)),
)
.ui(ui);
}
let events_ended = NoteStore::from_vec(
events
.iter()
.filter(|r| matches!(r.status(), StreamStatus::Ended))
.collect(),
);
if events_ended.len() > 0 {
widgets::StreamList::new(
Id::new("ended-streams"),
&events_ended,
services,
Some(RichText::new("Ended").size(32.0)),
)
.ui(ui);
}
ui.response()
})
.inner
}
}

67
src/route/login.rs Normal file
View File

@ -0,0 +1,67 @@
use crate::login::LoginKind;
use crate::route::{RouteServices, Routes};
use crate::widgets::{Button, NativeTextInput, NostrWidget};
use egui::{Color32, Frame, Margin, Response, RichText, Ui};
use nostr_sdk::util::hex;
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 mut input = NativeTextInput::new(&mut self.key).with_hint_text("npub/nsec");
input.render(ui, services);
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" => {
services.login.login(LoginKind::PrivateKey {
key: key.as_slice().try_into().unwrap(),
});
services.navigate(Routes::HomePage);
}
"npub" | "nprofile" => {
services.login.login(LoginKind::PublicKey {
key: key.as_slice().try_into().unwrap(),
});
services.navigate(Routes::HomePage);
}
_ => {}
}
} else if let Ok(pk) = hex::decode(&self.key) {
if let Ok(pk) = pk.as_slice().try_into() {
services.login.login(LoginKind::PublicKey { key: pk });
services.navigate(Routes::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
}
}

192
src/route/mod.rs Normal file
View File

@ -0,0 +1,192 @@
use crate::app::NativeLayerOps;
use crate::link::NostrLink;
use crate::login::Login;
use crate::note_util::OwnedNote;
use crate::route::home::HomePage;
use crate::route::login::LoginPage;
use crate::route::stream::StreamPage;
use crate::services::image_cache::ImageCache;
use crate::services::ndb_wrapper::NDBWrapper;
use crate::widgets::{Header, NostrWidget};
use egui::{Context, Response, Ui};
use egui_inbox::{UiInbox, UiInboxSender};
use log::{info, warn};
use nostr_sdk::{Client, Event, JsonUtil};
use nostrdb::{Ndb, Transaction};
use std::path::PathBuf;
mod home;
mod login;
mod stream;
#[derive(PartialEq)]
pub enum Routes {
HomePage,
EventPage {
link: NostrLink,
event: Option<OwnedNote>,
},
ProfilePage {
link: NostrLink,
profile: Option<OwnedNote>,
},
LoginPage,
// special kind for modifying route state
Action(RouteAction),
}
#[derive(PartialEq)]
pub enum RouteAction {
ShowKeyboard,
HideKeyboard,
}
pub struct Router<T: NativeLayerOps> {
current: Routes,
current_widget: Option<Box<dyn NostrWidget>>,
router: UiInbox<Routes>,
ctx: Context,
ndb: NDBWrapper,
login: Login,
client: Client,
image_cache: ImageCache,
native_layer: T,
}
impl<T: NativeLayerOps> Drop for Router<T> {
fn drop(&mut self) {
self.login.save(&mut self.native_layer)
}
}
impl<T: NativeLayerOps> Router<T> {
pub fn new(
data_path: PathBuf,
ctx: Context,
client: Client,
ndb: Ndb,
native_layer: T,
) -> Self {
let mut login = Login::new();
login.load(&native_layer);
Self {
current: Routes::HomePage,
current_widget: None,
router: UiInbox::new(),
ctx: ctx.clone(),
ndb: NDBWrapper::new(ctx.clone(), ndb.clone(), client.clone()),
client,
login,
image_cache: ImageCache::new(data_path, ctx.clone()),
native_layer,
}
}
fn load_widget(&mut self, route: Routes, tx: &Transaction) {
match &route {
Routes::HomePage => {
let w = HomePage::new(&self.ndb, tx);
self.current_widget = Some(Box::new(w));
}
Routes::EventPage { link, .. } => {
let w = StreamPage::new_from_link(&self.ndb, tx, link.clone());
self.current_widget = Some(Box::new(w));
}
Routes::LoginPage => {
let w = LoginPage::new();
self.current_widget = Some(Box::new(w));
}
_ => warn!("Not implemented"),
}
self.current = route;
}
pub fn show(&mut self, ui: &mut Ui) -> Response {
let tx = self.ndb.start_transaction();
// handle app state changes
let q = self.router.read(ui);
for r in q {
if let Routes::Action(a) = r {
match a {
RouteAction::ShowKeyboard => self.native_layer.show_keyboard(),
RouteAction::HideKeyboard => self.native_layer.hide_keyboard(),
_ => info!("Not implemented"),
}
} else {
self.load_widget(r, &tx);
}
}
// load homepage on start
if self.current_widget.is_none() {
self.load_widget(Routes::HomePage, &tx);
}
let mut svc = RouteServices {
context: self.ctx.clone(),
router: self.router.sender(),
client: self.client.clone(),
ndb: &self.ndb,
tx: &tx,
login: &mut self.login,
img_cache: &self.image_cache,
};
// display app
ui.vertical(|ui| {
Header::new().render(ui, &mut svc);
if let Some(w) = self.current_widget.as_mut() {
w.render(ui, &mut svc)
} else {
ui.label("No widget")
}
})
.response
}
}
pub struct RouteServices<'a> {
pub context: Context, //cloned
pub router: UiInboxSender<Routes>, //cloned
pub client: Client,
pub ndb: &'a NDBWrapper, //ref
pub tx: &'a Transaction, //ref
pub login: &'a mut Login, //ref
pub img_cache: &'a ImageCache, //ref
}
impl<'a> RouteServices<'a> {
pub fn navigate(&self, route: Routes) {
if let Err(e) = self.router.send(route) {
warn!("Failed to navigate");
}
}
pub fn action(&self, route: RouteAction) {
if let Err(e) = self.router.send(Routes::Action(route)) {
warn!("Failed to navigate");
}
}
pub fn broadcast_event(&self, event: Event) {
let client = self.client.clone();
let ev_json = event.as_json();
if let Err(e) = self.ndb.submit_event(&ev_json) {
warn!("Failed to submit event {}", e);
}
tokio::spawn(async move {
match client.send_event(event).await {
Ok(e) => {
info!("Broadcast event: {:?}", e)
}
Err(e) => warn!("Failed to broadcast event: {:?}", e),
}
});
}
}

176
src/route/stream.rs Normal file
View File

@ -0,0 +1,176 @@
use crate::link::NostrLink;
use crate::note_util::OwnedNote;
use crate::route::RouteServices;
use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper};
use crate::stream_info::StreamInfo;
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_800, ROUNDING_DEFAULT};
use crate::widgets::{Chat, NostrWidget, PlaceholderRect, StreamPlayer, StreamTitle, WriteChat};
use egui::{vec2, Align, Frame, Layout, Response, Stroke, Ui, Vec2, Widget};
use nostrdb::{Filter, Note, NoteKey, Transaction};
use std::borrow::Borrow;
pub struct StreamPage {
link: NostrLink,
event: Option<OwnedNote>,
player: Option<StreamPlayer>,
chat: Option<Chat>,
sub: SubWrapper,
new_msg: WriteChat,
}
impl StreamPage {
pub fn new_from_link(ndb: &NDBWrapper, tx: &Transaction, link: NostrLink) -> Self {
let f: Filter = link.borrow().try_into().unwrap();
let f = [f.limit_mut(1)];
let (sub, events) = ndb.subscribe_with_results("streams", &f, tx, 1);
Self {
link: link.clone(),
sub,
event: events.first().map(|n| OwnedNote(n.note_key.as_u64())),
chat: None,
player: None,
new_msg: WriteChat::new(link),
}
}
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.with_layout(
Layout::left_to_right(Align::TOP).with_main_justify(true),
|ui| {
ui.vertical(|ui| {
ui.allocate_ui(vec2(video_width, video_height), |ui| {
if let Some(player) = &mut self.player {
player.ui(ui)
} else {
ui.add(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 poll = services.ndb.poll(&self.sub, 1);
if let Some(k) = poll.first() {
self.event = Some(OwnedNote(k.as_u64()))
}
let event = if let Some(k) = &self.event {
services
.ndb
.get_note_by_key(services.tx, NoteKey::new(k.0))
.ok()
} else {
None
};
if let Some(event) = event {
if let Some(stream) = event.stream() {
if self.player.is_none() {
let p = StreamPlayer::new(ui.ctx(), &stream.to_string());
self.player = Some(p);
}
}
if self.chat.is_none() {
let ok = OwnedNote(event.key().unwrap().as_u64());
let chat = Chat::new(self.link.clone(), ok, services.ndb, services.tx);
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..")
}
}
}

View File

@ -0,0 +1,73 @@
use anyhow::Error;
use egui::ColorImage;
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) -> Result<ColorImage, Error> {
let demux = Demuxer::new(path.to_str().unwrap())?;
Self::load_image_from_demuxer(demux)
}
pub fn load_image_bytes<'a>(
&self,
key: &str,
data: &'static [u8],
) -> Result<ColorImage, Error> {
let demux = Demuxer::new_custom_io(data, Some(key.to_string()))?;
Self::load_image_from_demuxer(demux)
}
fn load_image_from_demuxer(mut demuxer: Demuxer) -> 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,
(*frame).width as u16,
(*frame).height as u16,
rgb,
)?;
av_frame_free(&mut frame);
let image = video_frame_to_image(frame_rgb);
return Ok(image);
}
}
av_packet_free(&mut pkt);
n_pkt += 1;
if n_pkt > 10 {
break;
}
}
anyhow::bail!("No image found");
}
}
}

186
src/services/image_cache.rs Normal file
View File

@ -0,0 +1,186 @@
use crate::services::ffmpeg_loader::FfmpegLoader;
use crate::theme::NEUTRAL_800;
use anyhow::{Error, Result};
use egui::{ColorImage, Context, Image, ImageData, TextureHandle, TextureOptions};
use itertools::Itertools;
use log::{info, warn};
use lru::LruCache;
use nostr_sdk::util::hex;
use resvg::usvg::Transform;
use sha2::{Digest, Sha256};
use std::collections::VecDeque;
use std::fs;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
type ImageCacheStore = Arc<Mutex<LruCache<String, TextureHandle>>>;
#[derive(PartialEq, Eq, Hash, Clone)]
struct LoadRequest(String);
pub struct ImageCache {
ctx: Context,
dir: PathBuf,
placeholder: TextureHandle,
cache: ImageCacheStore,
fetch_queue: Arc<Mutex<VecDeque<LoadRequest>>>,
}
impl ImageCache {
pub fn new(data_path: PathBuf, ctx: Context) -> Self {
let out = data_path.join("cache/images");
fs::create_dir_all(&out).unwrap();
let placeholder = ctx.load_texture(
"placeholder",
ImageData::from(ColorImage::new([1, 1], NEUTRAL_800)),
TextureOptions::default(),
);
let cache = Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1_000).unwrap())));
let fetch_queue = Arc::new(Mutex::new(VecDeque::<LoadRequest>::new()));
let cc = cache.clone();
let fq = fetch_queue.clone();
let out_dir = out.clone();
let ctx_clone = ctx.clone();
let placeholder_clone = placeholder.clone();
tokio::spawn(async move {
loop {
let next = fq.lock().unwrap().pop_front();
if let Some(next) = next {
let path = Self::find(&out_dir, &next.0);
if path.exists() {
let th = Self::load_image_texture(&ctx_clone, path, &next.0)
.unwrap_or(placeholder_clone.clone());
cc.lock().unwrap().put(next.0, th);
ctx_clone.request_repaint();
} else {
match Self::download_image_to_disk(&path, &next.0).await {
Ok(()) => {
let th = Self::load_image_texture(&ctx_clone, path, &next.0)
.unwrap_or(placeholder_clone.clone());
cc.lock().unwrap().put(next.0, th);
ctx_clone.request_repaint();
}
Err(e) => {
warn!("Failed to download image {}: {}", next.0, e);
cc.lock().unwrap().put(next.0, placeholder_clone.clone());
ctx_clone.request_repaint();
}
}
}
} else {
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
}
});
Self {
ctx,
dir: out,
placeholder,
cache,
fetch_queue,
}
}
pub fn find<U>(dir: &PathBuf, url: U) -> PathBuf
where
U: Into<String>,
{
let mut sha = Sha256::new();
sha2::digest::Update::update(&mut sha, url.into().as_bytes());
let hash = hex::encode(sha.finalize());
dir.join(PathBuf::from(hash[0..2].to_string()))
.join(PathBuf::from(hash))
}
fn load_bytes_impl(url: &str, bytes: &'static [u8]) -> Result<ColorImage, Error> {
if url.ends_with(".svg") {
Self::load_svg(bytes)
} else {
let loader = FfmpegLoader::new();
loader.load_image_bytes(url, bytes)
}
}
pub fn load_bytes<'a, U>(&self, url: U, bytes: &'static [u8]) -> Image<'a>
where
U: Into<String>,
{
let url = url.into();
match Self::load_bytes_impl(&url, bytes) {
Ok(i) => {
let tex = self
.ctx
.load_texture(url, ImageData::from(i), TextureOptions::default());
Image::from_texture(&tex)
}
Err(e) => {
panic!("Failed to load image: {}", e);
}
}
}
pub fn load<'a, U>(&self, url: U) -> Image<'a>
where
U: Into<String>,
{
let u = url.into();
if let Ok(mut c) = self.cache.lock() {
if let Some(i) = c.get(&u) {
return Image::from_texture(i);
}
}
if let Ok(mut ql) = self.fetch_queue.lock() {
let lr = LoadRequest(u.clone());
if !ql.contains(&lr) {
ql.push_back(lr);
}
}
Image::from_texture(&self.placeholder)
}
/// Download an image to disk
async fn download_image_to_disk(dst: &PathBuf, u: &str) -> Result<()> {
info!("Fetching image: {}", &u);
tokio::fs::create_dir_all(dst.parent().unwrap()).await?;
let data = reqwest::get(u).await?;
let img_data = data.bytes().await?;
tokio::fs::write(dst, img_data).await?;
Ok(())
}
/// Load an image from disk into an egui texture handle
fn load_image_texture(ctx: &Context, path: PathBuf, key: &str) -> Option<TextureHandle> {
let loader = FfmpegLoader::new();
match loader.load_image(path) {
Ok(i) => Some(ctx.load_texture(key, ImageData::from(i), TextureOptions::default())),
Err(e) => {
println!("Failed to load image: {}", e);
None
}
}
}
fn load_svg(svg: &[u8]) -> Result<ColorImage, Error> {
use resvg::tiny_skia::Pixmap;
use resvg::usvg::{Options, Tree};
let opt = Options::default();
let rtree = Tree::from_data(svg, &opt)
.map_err(|err| err.to_string())
.map_err(|e| Error::msg(e))?;
let size = rtree.size().to_int_size();
let (w, h) = (size.width(), size.height());
let mut pixmap = Pixmap::new(w, h)
.ok_or_else(|| Error::msg(format!("Failed to create SVG Pixmap of size {w}x{h}")))?;
resvg::render(&rtree, Transform::default(), &mut pixmap.as_mut());
let image = ColorImage::from_rgba_unmultiplied([w as _, h as _], pixmap.data());
Ok(image)
}
}

View File

@ -1,19 +1,5 @@
use crate::services::profile::ProfileService;
use egui::Context;
use nostr_sdk::Client;
pub mod image_cache;
pub mod ndb_wrapper;
pub mod query;
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()),
}
}
}
mod ffmpeg_loader;

161
src/services/ndb_wrapper.rs Normal file
View File

@ -0,0 +1,161 @@
use crate::services::query::QueryManager;
use log::warn;
use nostr_sdk::{nostr, Client, JsonUtil, Kind, PublicKey, RelayPoolNotification};
use nostrdb::{
Error, Filter, Ndb, NdbProfile, Note, NoteKey, ProfileRecord, QueryResult, Subscription,
Transaction,
};
use std::collections::HashSet;
use std::sync::Mutex;
pub struct NDBWrapper {
ctx: egui::Context,
ndb: Ndb,
client: Client,
query_manager: QueryManager<Client>,
profiles: Mutex<HashSet<[u8; 32]>>,
}
/// Automatic cleanup for subscriptions
pub struct SubWrapper {
ndb: Ndb,
subscription: Subscription,
}
impl SubWrapper {
pub fn new(ndb: Ndb, subscription: Subscription) -> Self {
Self { ndb, subscription }
}
}
impl From<&SubWrapper> for u64 {
fn from(val: &SubWrapper) -> Self {
val.subscription.id()
}
}
impl Drop for SubWrapper {
fn drop(&mut self) {
self.ndb.unsubscribe(self.subscription).unwrap()
}
}
impl NDBWrapper {
pub fn new(ctx: egui::Context, ndb: Ndb, client: Client) -> Self {
let client_clone = client.clone();
let ndb_clone = ndb.clone();
let ctx_clone = ctx.clone();
tokio::spawn(async move {
let mut notifications = client_clone.notifications();
while let Ok(e) = notifications.recv().await {
match e {
RelayPoolNotification::Event { event, .. } => {
if let Err(e) = ndb_clone.process_event(event.as_json().as_str()) {
warn!("Failed to process event: {:?}", e);
} else {
ctx_clone.request_repaint();
}
}
_ => {
// dont care
}
}
}
});
let qm = QueryManager::new(client.clone());
Self {
ctx,
ndb,
client,
query_manager: qm,
profiles: Mutex::new(HashSet::new()),
}
}
pub fn start_transaction(&self) -> Transaction {
Transaction::new(&self.ndb).unwrap()
}
pub fn subscribe(&self, id: &str, filters: &[Filter]) -> SubWrapper {
let sub = self.ndb.subscribe(filters).unwrap();
// very lazy conversion
let filters: Vec<nostr_sdk::Filter> = filters
.iter()
.map(|f| nostr_sdk::Filter::from_json(f.json().unwrap()).unwrap())
.collect();
self.query_manager.queue_query(id, filters);
SubWrapper::new(self.ndb.clone(), sub)
}
pub fn unsubscribe(&self, sub: &SubWrapper) {
self.ndb.unsubscribe(sub.subscription).unwrap()
}
pub fn subscribe_with_results<'a>(
&self,
id: &str,
filters: &[Filter],
tx: &'a Transaction,
max_results: i32,
) -> (SubWrapper, Vec<QueryResult<'a>>) {
let sub = self.subscribe(id, filters);
let q = self.query(tx, filters, max_results);
(sub, q)
}
pub fn query<'a>(
&self,
tx: &'a Transaction,
filters: &[Filter],
max_results: i32,
) -> Vec<QueryResult<'a>> {
self.ndb.query(tx, filters, max_results).unwrap()
}
pub fn poll(&self, sub: &SubWrapper, max_results: u32) -> Vec<NoteKey> {
self.ndb.poll_for_notes(sub.subscription, max_results)
}
pub fn get_note_by_key<'a>(
&self,
tx: &'a Transaction,
key: NoteKey,
) -> Result<Note<'a>, Error> {
self.ndb.get_note_by_key(tx, key)
}
pub fn get_profile_by_pubkey<'a>(
&self,
tx: &'a Transaction,
pubkey: &[u8; 32],
) -> Result<ProfileRecord<'a>, Error> {
self.ndb.get_profile_by_pubkey(tx, pubkey)
}
pub fn fetch_profile<'a>(
&self,
tx: &'a Transaction,
pubkey: &[u8; 32],
) -> (Option<NdbProfile<'a>>, Option<SubWrapper>) {
let p = self
.get_profile_by_pubkey(tx, pubkey)
.map_or(None, |p| p.record().profile());
// TODO: fix this shit
if p.is_none() && self.profiles.lock().unwrap().insert(*pubkey) {
self.query_manager.queue_query(
"profile",
&[nostr::Filter::new()
.kinds([Kind::Metadata])
.authors([PublicKey::from_slice(pubkey).unwrap()])],
)
}
let sub = None;
(p, sub)
}
pub fn submit_event(&self, ev: &str) -> Result<(), Error> {
self.ndb.process_event(ev)
}
}

View File

@ -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
}
}

192
src/services/query.rs Normal file
View File

@ -0,0 +1,192 @@
use anyhow::Error;
use chrono::Utc;
use log::{error, info};
use nostr_sdk::prelude::StreamExt;
use nostr_sdk::Kind::Metadata;
use nostr_sdk::{Client, Filter, SubscribeAutoCloseOptions, SubscriptionId};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use uuid::Uuid;
#[async_trait::async_trait]
pub trait QueryClient {
async fn subscribe(&self, id: &str, filters: &[QueryFilter]) -> Result<(), Error>;
}
pub type QueryFilter = Filter;
pub struct Query {
pub id: String,
queue: HashSet<QueryFilter>,
traces: HashSet<QueryTrace>,
}
#[derive(Hash, Eq, PartialEq, Debug)]
pub struct QueryTrace {
/// Subscription id on the relay
pub id: Uuid,
/// Filters associated with this subscription
pub filters: Vec<QueryFilter>,
/// When the query was created
pub queued: u64,
/// When the query was sent to the relay
pub sent: Option<u64>,
/// When EOSE was received
pub eose: Option<u64>,
}
impl Query {
pub fn new(id: &str) -> Self {
Self {
id: id.to_string(),
queue: HashSet::new(),
traces: HashSet::new(),
}
}
/// Add filters to query
pub fn add(&mut self, filter: Vec<QueryFilter>) {
for f in filter {
self.queue.insert(f);
}
}
/// Return next query batch
pub fn next(&mut self) -> Option<QueryTrace> {
let mut next: Vec<QueryFilter> = self.queue.drain().collect();
if next.is_empty() {
return None;
}
let now = Utc::now();
let id = Uuid::new_v4();
// remove filters already sent
next.retain(|f| {
self.traces.is_empty() || !self.traces.iter().all(|y| y.filters.iter().any(|z| z == f))
});
// force profile queries into single filter
if next.iter().all(|f| {
if let Some(k) = &f.kinds {
k.len() == 1 && k.first().unwrap().as_u16() == 0
} else {
false
}
}) {
next = vec![Filter::new().kinds([Metadata]).authors(
next.iter()
.flat_map(|f| f.authors.as_ref().unwrap().clone()),
)]
}
if next.is_empty() {
return None;
}
Some(QueryTrace {
id,
filters: next,
queued: now.timestamp() as u64,
sent: None,
eose: None,
})
}
}
struct QueueDefer {
id: String,
filters: Vec<QueryFilter>,
}
pub struct QueryManager<C> {
client: C,
queries: Arc<RwLock<HashMap<String, Query>>>,
queue_into_queries: UnboundedSender<QueueDefer>,
sender: JoinHandle<()>,
}
impl<C> QueryManager<C>
where
C: QueryClient + Clone + Send + Sync + 'static,
{
pub(crate) fn new(client: C) -> Self {
let queries = Arc::new(RwLock::new(HashMap::new()));
let (tx, mut rx) = unbounded_channel::<QueueDefer>();
Self {
client: client.clone(),
queries: queries.clone(),
queue_into_queries: tx,
sender: tokio::spawn(async move {
loop {
{
let mut q = queries.write().await;
while let Ok(x) = rx.try_recv() {
Self::push_filters(&mut q, &x.id, x.filters);
}
for (k, v) in q.iter_mut() {
if let Some(qt) = v.next() {
info!("Sending trace: {:?}", qt);
match client
.subscribe(&qt.id.to_string(), qt.filters.as_slice())
.await
{
Ok(_) => {}
Err(e) => {
error!("Failed to subscribe to query filters: {}", e);
}
}
}
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}),
}
}
pub async fn query<F>(&mut self, id: &str, filters: F)
where
F: Into<Vec<QueryFilter>>,
{
let mut qq = self.queries.write().await;
Self::push_filters(&mut qq, id, filters.into());
}
fn push_filters(qq: &mut HashMap<String, Query>, id: &str, filters: Vec<QueryFilter>) {
if let Some(q) = qq.get_mut(id) {
q.add(filters);
} else {
let mut q = Query::new(id);
q.add(filters);
qq.insert(id.to_string(), q);
}
}
pub fn queue_query<F>(&self, id: &str, filters: F)
where
F: Into<Vec<QueryFilter>>,
{
self.queue_into_queries
.send(QueueDefer {
id: id.to_string(),
filters: filters.into(),
})
.unwrap()
}
}
#[async_trait::async_trait]
impl QueryClient for Client {
async fn subscribe(&self, id: &str, filters: &[QueryFilter]) -> Result<(), Error> {
self.subscribe_with_id(
SubscriptionId::new(id),
filters.into(),
Some(SubscribeAutoCloseOptions::default()),
)
.await?;
Ok(())
}
}

125
src/stream_info.rs Normal file
View File

@ -0,0 +1,125 @@
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 stream(&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<'a> StreamInfo for Note<'a> {
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 stream(&self) -> Option<&str> {
if let Some(s) = self.get_tag_value("streaming") {
s.variant().str()
} else {
None
}
}
/// Is the stream playable by this app
fn can_play(&self) -> bool {
if let Some(stream) = self.stream() {
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
}
}
}

9
src/theme.rs Normal file
View File

@ -0,0 +1,9 @@
use egui::{Color32, Margin};
pub const FONT_SIZE: f32 = 13.0;
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_800: Color32 = Color32::from_rgb(38, 38, 38);
pub const NEUTRAL_900: Color32 = Color32::from_rgb(23, 23, 23);

View File

@ -1,5 +0,0 @@
use nostr_sdk::Filter;
pub trait NostrWidget {
fn subscribe(&self) -> Vec<Filter>;
}

View File

@ -1,55 +1,78 @@
use crate::services::Services;
use egui::{Color32, Image, Rect, Response, Rounding, Sense, Ui, Vec2, Widget};
use nostr_sdk::PublicKey;
use crate::route::RouteServices;
use crate::services::ndb_wrapper::SubWrapper;
use egui::{vec2, Color32, Pos2, Response, Rounding, Sense, Ui, Vec2, Widget};
use nostrdb::NdbProfile;
pub struct Avatar<'a> {
image: Option<Image<'a>>,
image: Option<&'a str>,
sub: Option<SubWrapper>,
size: Option<f32>,
services: &'a RouteServices<'a>,
}
impl<'a> Avatar<'a> {
pub fn new(img: Image<'a>) -> Self {
Self { image: Some(img) }
}
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 new_optional(img: Option<&'a str>, services: &'a RouteServices<'a>) -> Self {
Self {
image: img,
sub: None,
size: None,
services,
}
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: &'a Option<NdbProfile<'a>>, services: &'a RouteServices<'a>) -> Self {
let img = p.map(|f| f.picture()).unwrap_or(None);
Self {
image: img,
sub: None,
size: None,
services,
}
}
pub fn pubkey(pk: &[u8; 32], services: &'a RouteServices<'a>) -> Self {
let (p, sub) = services.ndb.fetch_profile(services.tx, pk);
Self {
image: p.map(|f| f.picture()).unwrap_or(None),
sub,
size: None,
services,
}
}
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
}
fn placeholder(ui: &mut Ui, size: f32) -> Response {
let (response, painter) = ui.allocate_painter(vec2(size, size), Sense::click());
painter.circle_filled(
Pos2::new(size / 2., size / 2.),
size / 2.,
Color32::from_rgb(200, 200, 200),
);
response
}
}
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));
response
}
let size_v = self.size.unwrap_or(40.);
let size = Vec2::new(size_v, size_v);
if !ui.is_visible() {
return Self::placeholder(ui, size_v);
}
match self
.image
.as_ref()
.map(|i| self.services.img_cache.load(*i))
{
Some(img) => img
.fit_to_exact_size(size)
.rounding(Rounding::same(size_v))
.ui(ui),
None => Self::placeholder(ui, size_v),
}
}
}
}

32
src/widgets/button.rs Normal file
View File

@ -0,0 +1,32 @@
use crate::theme::{NEUTRAL_800, ROUNDING_DEFAULT};
use egui::{Color32, CursorIcon, Frame, Margin, Response, Sense, Ui};
pub struct Button {
color: Color32,
}
impl Button {
pub fn new() -> Self {
Self { color: NEUTRAL_800 }
}
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::symmetric(12., 8.))
.fill(self.color)
.rounding(ROUNDING_DEFAULT)
.show(ui, add_contents);
let id = r.response.id;
ui.interact(
r.response
.on_hover_and_drag_cursor(CursorIcon::PointingHand)
.rect,
id,
Sense::click(),
)
}
}

83
src/widgets/chat.rs Normal file
View File

@ -0,0 +1,83 @@
use crate::link::NostrLink;
use crate::note_util::OwnedNote;
use crate::route::RouteServices;
use crate::services::ndb_wrapper::{NDBWrapper, SubWrapper};
use crate::widgets::chat_message::ChatMessage;
use crate::widgets::NostrWidget;
use egui::{Frame, Margin, Response, ScrollArea, Ui};
use itertools::Itertools;
use nostrdb::{Filter, Note, NoteKey, Transaction};
pub struct Chat {
link: NostrLink,
stream: OwnedNote,
events: Vec<OwnedNote>,
sub: SubWrapper,
}
impl Chat {
pub fn new(link: NostrLink, stream: OwnedNote, ndb: &NDBWrapper, tx: &Transaction) -> Self {
let filter = Filter::new()
.kinds([1_311])
.tags([link.to_tag_value()], 'a')
.build();
let filter = [filter];
let (sub, events) = ndb.subscribe_with_results("live-chat", &filter, tx, 500);
Self {
link,
sub,
stream,
events: events
.iter()
.map(|n| OwnedNote(n.note_key.as_u64()))
.collect(),
}
}
}
impl NostrWidget for Chat {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
let poll = services.ndb.poll(&self.sub, 500);
poll.iter()
.for_each(|n| self.events.push(OwnedNote(n.as_u64())));
let events: Vec<Note> = self
.events
.iter()
.map_while(|n| {
services
.ndb
.get_note_by_key(services.tx, NoteKey::new(n.0))
.ok()
})
.collect();
let stream = services
.ndb
.get_note_by_key(services.tx, NoteKey::new(self.stream.0))
.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 events
.into_iter()
.sorted_by(|a, b| a.created_at().cmp(&b.created_at()))
{
let c = ChatMessage::new(&stream, &ev, services);
ui.add(c);
}
})
})
.response
})
.inner
}
}

View File

@ -0,0 +1,65 @@
use crate::route::RouteServices;
use crate::services::ndb_wrapper::SubWrapper;
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, Widget};
use nostrdb::{NdbProfile, Note};
pub struct ChatMessage<'a> {
stream: &'a Note<'a>,
ev: &'a Note<'a>,
services: &'a RouteServices<'a>,
profile: (Option<NdbProfile<'a>>, Option<SubWrapper>),
}
impl<'a> ChatMessage<'a> {
pub fn new(
stream: &'a Note<'a>,
ev: &'a Note<'a>,
services: &'a RouteServices<'a>,
) -> ChatMessage<'a> {
ChatMessage {
stream,
ev,
services,
profile: services.ndb.fetch_profile(services.tx, ev.pubkey()),
}
}
}
impl<'a> Widget for ChatMessage<'a> {
fn ui(self, ui: &mut Ui) -> 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 profile = self
.services
.ndb
.get_profile_by_pubkey(self.services.tx, self.ev.pubkey())
.map_or(None, |p| p.record().profile());
let name = 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());
ui.add(Avatar::from_profile(&profile, self.services).size(24.));
ui.add(Label::new(job).wrap_mode(TextWrapMode::Wrap));
})
.response
}
}

View File

@ -1,34 +1,51 @@
use crate::services::Services;
use crate::route::{RouteServices, Routes};
use crate::widgets::avatar::Avatar;
use crate::widgets::{Button, NostrWidget};
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, Layout, Margin, Response, Sense, Ui, Widget};
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");
impl NostrWidget for Header {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> 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));
})
}).response
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 services
.img_cache
.load_bytes("logo.svg", logo_bytes)
.max_height(24.)
.sense(Sense::click())
.ui(ui)
.on_hover_and_drag_cursor(CursorIcon::PointingHand)
.clicked()
{
services.navigate(Routes::HomePage);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if let Some(pk) = services.login.public_key() {
ui.add(Avatar::pubkey(&pk, services));
} else if Button::new().show(ui, |ui| ui.label("Login")).clicked() {
services.navigate(Routes::LoginPage);
}
});
},
)
})
.response
}
}
}

View File

@ -1,4 +1,34 @@
pub mod header;
pub mod stream;
pub mod stream_list;
mod avatar;
mod avatar;
mod button;
mod chat;
mod chat_message;
mod header;
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;
use crate::route::RouteServices;
use egui::{Response, Ui};
pub trait NostrWidget {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response;
}
pub use self::avatar::Avatar;
pub use self::button::Button;
pub use self::chat::Chat;
pub use self::header::Header;
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;

View 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
}
}

47
src/widgets/profile.rs Normal file
View File

@ -0,0 +1,47 @@
use crate::route::RouteServices;
use crate::services::image_cache::ImageCache;
use crate::services::ndb_wrapper::SubWrapper;
use crate::theme::FONT_SIZE;
use crate::widgets::{Avatar, Username};
use egui::{Response, Ui, Widget};
use nostrdb::NdbProfile;
pub struct Profile<'a> {
size: f32,
pubkey: &'a [u8; 32],
profile: Option<NdbProfile<'a>>,
sub: Option<SubWrapper>,
img_cache: &'a ImageCache,
services: &'a RouteServices<'a>,
}
impl<'a> Profile<'a> {
pub fn new(pubkey: &'a [u8; 32], services: &'a RouteServices<'a>) -> Self {
let (p, sub) = services.ndb.fetch_profile(services.tx, pubkey);
Self {
pubkey,
size: 40.,
profile: p,
img_cache: services.img_cache,
sub,
services,
}
}
pub fn size(self, size: f32) -> Self {
Self { size, ..self }
}
}
impl<'a> Widget for Profile<'a> {
fn ui(self, ui: &mut Ui) -> Response {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.;
ui.add(Avatar::from_profile(&self.profile, self.services).size(self.size));
ui.add(Username::new(&self.profile, FONT_SIZE))
})
.response
}
}

View File

@ -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
}
}

View File

@ -1,30 +1,74 @@
use crate::services::Services;
use crate::widgets::stream::StreamEvent;
use egui::{Frame, Margin, Response, Ui, Widget};
use nostr_sdk::Event;
use crate::note_store::NoteStore;
use crate::route::RouteServices;
use crate::stream_info::StreamInfo;
use crate::widgets::stream_tile::StreamEvent;
use egui::{vec2, Frame, Grid, Margin, Response, Ui, Widget, WidgetText};
use itertools::Itertools;
pub struct StreamList<'a> {
streams: &'a Vec<Event>,
services: &'a Services,
id: egui::Id,
streams: &'a NoteStore<'a>,
services: &'a RouteServices<'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: &'a NoteStore<'a>,
services: &'a RouteServices<'a>,
heading: Option<impl Into<WidgetText>>,
) -> Self {
Self {
id,
streams,
services,
heading: heading.map(Into::into),
}
}
}
impl Widget for StreamList<'_> {
fn ui(self, ui: &mut Ui) -> 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.;
let frame_margin = 16.0;
Frame::none()
.inner_margin(Margin::symmetric(16., 8.))
.inner_margin(Margin::symmetric(frame_margin, 0.))
.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));
}
})
}).response
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 {
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.add_sized(
vec2(g_w, (g_w / 16.0) * 9.0),
StreamEvent::new(event, self.services),
);
ctr += 1;
if ctr % cols == 0 {
ui.end_row();
}
}
})
})
.response
}
}
}

View 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)
}
}
}

128
src/widgets/stream_tile.rs Normal file
View File

@ -0,0 +1,128 @@
use crate::link::NostrLink;
use crate::route::{RouteServices, Routes};
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::load::TexturePoll;
use egui::{
vec2, Color32, CursorIcon, FontId, Label, Pos2, Rect, Response, RichText, Sense, TextWrapMode,
Ui, Widget,
};
use nostrdb::Note;
pub struct StreamEvent<'a> {
event: &'a Note<'a>,
services: &'a RouteServices<'a>,
}
impl<'a> StreamEvent<'a> {
pub fn new(event: &'a Note<'a>, services: &'a RouteServices) -> Self {
Self { event, 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 = self.event.host();
let (host_profile, _sub) = self.services.ndb.fetch_profile(self.services.tx, 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_visible() {
self.event.image().map(|p| self.services.img_cache.load(p))
} else {
None
};
if let Some(cover) = cover.map(|c| {
c.rounding(Rounding::same(12.))
.load_for_size(painter.ctx(), Vec2::new(w, h))
}) {
match cover {
Ok(TexturePoll::Ready { texture }) => {
painter.add(RectShape {
rect: response.rect,
rounding: Rounding::same(ROUNDING_DEFAULT),
fill: Color32::WHITE,
stroke: Default::default(),
blur_width: 0.0,
fill_texture_id: texture.id,
uv: Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)),
});
}
_ => {
painter.rect_filled(response.rect, ROUNDING_DEFAULT, NEUTRAL_800);
}
}
} 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() {
self.services.navigate(Routes::EventPage {
link: NostrLink::from_note(self.event),
event: None,
});
}
ui.horizontal(|ui| {
ui.add(Avatar::from_profile(&host_profile, self.services).size(40.));
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
}
}

View File

@ -0,0 +1,44 @@
use crate::note_util::NoteUtil;
use crate::route::RouteServices;
use crate::stream_info::StreamInfo;
use crate::widgets::{NostrWidget, Profile};
use egui::{Color32, Frame, Label, Margin, 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 {
StreamTitle { event }
}
}
impl<'a> NostrWidget for StreamTitle<'a> {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> Response {
Frame::none()
.outer_margin(Margin::symmetric(12., 8.))
.show(ui, |ui| {
ui.style_mut().spacing.item_spacing.y = 8.;
let title = RichText::new(self.event.title().unwrap_or("Untitled"))
.size(20.)
.color(Color32::WHITE);
ui.add(Label::new(title.strong()).wrap_mode(TextWrapMode::Truncate));
ui.add(Profile::new(self.event.host(), services).size(32.));
if let Some(summary) = self
.event
.get_tag_value("summary")
.and_then(|r| r.variant().str())
{
if summary.len() > 0 {
let summary = RichText::new(summary).color(Color32::WHITE);
ui.add(Label::new(summary).wrap_mode(TextWrapMode::Truncate));
}
}
})
.response
}
}

60
src/widgets/text_input.rs Normal file
View File

@ -0,0 +1,60 @@
use crate::route::{RouteAction, RouteServices};
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_500, NEUTRAL_900, ROUNDING_DEFAULT};
use crate::widgets::NostrWidget;
use egui::{Frame, Response, TextEdit, Ui};
/// 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<'a> NostrWidget for NativeTextInput<'a> {
fn render(&mut self, ui: &mut Ui, services: &mut RouteServices<'_>) -> 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));
}
let response = 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)
};
if response.lost_focus() {
services.action(RouteAction::HideKeyboard);
}
if response.gained_focus() {
services.action(RouteAction::ShowKeyboard);
}
response
}
}

23
src/widgets/username.rs Normal file
View 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<'a> Widget for Username<'a> {
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))
}
}

58
src/widgets/write_chat.rs Normal file
View File

@ -0,0 +1,58 @@
use crate::link::NostrLink;
use crate::route::RouteServices;
use crate::theme::{MARGIN_DEFAULT, NEUTRAL_900, ROUNDING_DEFAULT};
use crate::widgets::{NativeTextInput, NostrWidget};
use eframe::emath::Align;
use egui::{Frame, 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(),
}
}
}
impl NostrWidget for WriteChat {
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 services
.img_cache
.load_bytes("send-03.svg", logo_bytes)
.sense(Sense::click())
.ui(ui)
.clicked()
|| self.msg.ends_with('\n')
{
if let Ok(ev) = services
.login
.write_live_chat_msg(&self.link, &self.msg.trim())
{
info!("Sending: {:?}", ev);
services.broadcast_event(ev);
}
self.msg.clear();
}
let mut editor =
NativeTextInput::new(&mut self.msg).with_hint_text("Message..");
editor.render(ui, services)
});
})
.response
}
}