├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── rust.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── contrib ├── scripts │ ├── check-crate.sh │ └── check-fmt.sh ├── verify-commits │ └── trusted-keys └── woff2otf.py ├── justfile ├── rustfmt.toml ├── src ├── component │ ├── button.rs │ ├── circle.rs │ ├── icon.rs │ ├── mod.rs │ └── version.rs ├── error.rs ├── main.rs ├── message.rs ├── stage │ ├── auth │ │ ├── context.rs │ │ ├── mod.rs │ │ └── screen │ │ │ ├── login.rs │ │ │ └── mod.rs │ ├── dashboard │ │ ├── component │ │ │ ├── contact.rs │ │ │ ├── dashboard │ │ │ │ ├── mod.rs │ │ │ │ ├── navbar.rs │ │ │ │ └── sidebar │ │ │ │ │ ├── button.rs │ │ │ │ │ └── mod.rs │ │ │ ├── mod.rs │ │ │ └── post │ │ │ │ └── mod.rs │ │ ├── context.rs │ │ ├── mod.rs │ │ └── screen │ │ │ ├── chat.rs │ │ │ ├── contacts.rs │ │ │ ├── explore.rs │ │ │ ├── home.rs │ │ │ ├── mod.rs │ │ │ ├── notifications.rs │ │ │ ├── profile.rs │ │ │ └── setting │ │ │ ├── mod.rs │ │ │ └── relays.rs │ └── mod.rs ├── sync.rs ├── theme │ ├── color.rs │ ├── font.rs │ ├── icon.rs │ └── mod.rs └── util │ ├── dir.rs │ └── mod.rs └── static ├── icons ├── LICENSE.md ├── README.md └── bootstrap-icons.otf └── imgs └── unknown-img-profile.png /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | 11 | 12 | **To Reproduce** 13 | 14 | 15 | **Expected behavior** 16 | 17 | 18 | **Build environment** 19 | - Tag/commit: 20 | - OS+version: 21 | 22 | **Additional context** 23 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Description 4 | 5 | 6 | 7 | ### Notes to the reviewers 8 | 9 | 11 | 12 | ### Changelog notice 13 | 14 | 15 | 16 | 17 | ### Checklists 18 | 19 | #### All Submissions: 20 | 21 | * [ ] I followed the [contribution guidelines](https://github.com/rust-nostr/nostr/blob/master/CONTRIBUTING.md) 22 | * [ ] I ran `just precommit` or `just check` before committing -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "**" ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | fmt: 14 | name: Format 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Install just 20 | run: 21 | curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash && cargo binstall just 22 | cargo-binstall --no-confirm just 23 | 24 | - name: Check 25 | run: just check-fmt 26 | 27 | check: 28 | needs: fmt 29 | name: Check crate 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | - name: Install just 35 | run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash && cargo binstall just 36 | cargo-binstall --no-confirm just 37 | - name: Check 38 | run: just check-crate 39 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "**" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .DS_Store 4 | .idea/ 5 | .vscode/ 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Check `CONTRIBUTING.md` and `CODE_STYLE.md` at https://github.com/rust-nostr/nostr 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nostr-desktop" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Nostr Desktop" 6 | authors = ["Yuki Kishimoto "] 7 | homepage = "https://github.com/yukibtc/nostr-desktop" 8 | repository = "https://github.com/yukibtc/nostr-desktop.git" 9 | license = "MIT" 10 | keywords = ["nostr", "nostr-sdk", "client", "gui"] 11 | categories = ["gui"] 12 | 13 | [dependencies] 14 | async-stream = "0.3.3" 15 | chrono = "0.4.23" 16 | dirs = "4.0.0" 17 | env_logger = "0.8.2" 18 | iced = { version = "0.12.1", default-features = false, features = ["advanced", "image", "tokio"] } 19 | iced_futures = "0.12.0" 20 | log = "0.4.17" 21 | # nostr-sdk = "0.12" 22 | nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "7496698c2e040e34b70e804665b8b28aa3a989e7", features = ["sqlite"] } 23 | # nostr-sdk = { path = "../nostr-rs-sdk/crates/nostr-sdk", features = ["sqlite"] } 24 | # notify-rust = "4.6.0" 25 | once_cell = "1" 26 | reqwest = { version = "0.11.13", default-features = false, features = ["json", "rustls-tls-webpki-roots"] } 27 | tokio = { version = "1", features = ["full"] } 28 | 29 | [profile.release] 30 | lto = true 31 | codegen-units = 1 32 | strip = "debuginfo" 33 | panic = "abort" 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Yuki Kishimoto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nostr-desktop [![CI](https://github.com/RandyMcMillan/nostr-desktop/actions/workflows/ci.yml/badge.svg)](https://github.com/RandyMcMillan/nostr-desktop/actions/workflows/ci.yml) 2 | 3 | **Looking for a better name** 4 | 5 | ## Description 6 | 7 | Nostr desktop client 8 | 9 | ## State 10 | 11 | **This project is in an ALPHA state**. 12 | 13 | ## License 14 | 15 | This project is distributed under the MIT software license - see the [LICENSE](LICENSE) file for details 16 | -------------------------------------------------------------------------------- /contrib/scripts/check-crate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | cargo check 6 | cargo test 7 | cargo clippy -- -D warnings -------------------------------------------------------------------------------- /contrib/scripts/check-fmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | flags="" 6 | 7 | # Check if "check" is passed as an argument 8 | if [[ "$#" -gt 0 && "$1" == "check" ]]; then 9 | flags="--check" 10 | fi 11 | 12 | # Install toolchain 13 | rustup install nightly 14 | rustup component add rustfmt --toolchain nightly 15 | 16 | # Check workspace crates 17 | cargo +nightly fmt --all -- --config format_code_in_doc_comments=true $flags 18 | -------------------------------------------------------------------------------- /contrib/verify-commits/trusted-keys: -------------------------------------------------------------------------------- 1 | 86F3105ADFA8AB587268DCD78D3DCD04249619D1 -------------------------------------------------------------------------------- /contrib/woff2otf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2012, Steffen Hanikel (https://github.com/hanikesn) 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # A tool to convert a WOFF back to a TTF/OTF font file, in pure Python 18 | 19 | import struct 20 | import sys 21 | import zlib 22 | 23 | 24 | def convert_streams(infile, outfile): 25 | WOFFHeader = {'signature': struct.unpack(">I", infile.read(4))[0], 26 | 'flavor': struct.unpack(">I", infile.read(4))[0], 27 | 'length': struct.unpack(">I", infile.read(4))[0], 28 | 'numTables': struct.unpack(">H", infile.read(2))[0], 29 | 'reserved': struct.unpack(">H", infile.read(2))[0], 30 | 'totalSfntSize': struct.unpack(">I", infile.read(4))[0], 31 | 'majorVersion': struct.unpack(">H", infile.read(2))[0], 32 | 'minorVersion': struct.unpack(">H", infile.read(2))[0], 33 | 'metaOffset': struct.unpack(">I", infile.read(4))[0], 34 | 'metaLength': struct.unpack(">I", infile.read(4))[0], 35 | 'metaOrigLength': struct.unpack(">I", infile.read(4))[0], 36 | 'privOffset': struct.unpack(">I", infile.read(4))[0], 37 | 'privLength': struct.unpack(">I", infile.read(4))[0]} 38 | 39 | outfile.write(struct.pack(">I", WOFFHeader['flavor'])); 40 | outfile.write(struct.pack(">H", WOFFHeader['numTables'])); 41 | maximum = list(filter(lambda x: x[1] <= WOFFHeader['numTables'], [(n, 2**n) for n in range(64)]))[-1]; 42 | searchRange = maximum[1] * 16 43 | outfile.write(struct.pack(">H", searchRange)); 44 | entrySelector = maximum[0] 45 | outfile.write(struct.pack(">H", entrySelector)); 46 | rangeShift = WOFFHeader['numTables'] * 16 - searchRange; 47 | outfile.write(struct.pack(">H", rangeShift)); 48 | 49 | offset = outfile.tell() 50 | 51 | TableDirectoryEntries = [] 52 | for i in range(0, WOFFHeader['numTables']): 53 | TableDirectoryEntries.append({'tag': struct.unpack(">I", infile.read(4))[0], 54 | 'offset': struct.unpack(">I", infile.read(4))[0], 55 | 'compLength': struct.unpack(">I", infile.read(4))[0], 56 | 'origLength': struct.unpack(">I", infile.read(4))[0], 57 | 'origChecksum': struct.unpack(">I", infile.read(4))[0]}) 58 | offset += 4*4 59 | 60 | for TableDirectoryEntry in TableDirectoryEntries: 61 | outfile.write(struct.pack(">I", TableDirectoryEntry['tag'])) 62 | outfile.write(struct.pack(">I", TableDirectoryEntry['origChecksum'])) 63 | outfile.write(struct.pack(">I", offset)) 64 | outfile.write(struct.pack(">I", TableDirectoryEntry['origLength'])) 65 | TableDirectoryEntry['outOffset'] = offset 66 | offset += TableDirectoryEntry['origLength'] 67 | if (offset % 4) != 0: 68 | offset += 4 - (offset % 4) 69 | 70 | for TableDirectoryEntry in TableDirectoryEntries: 71 | infile.seek(TableDirectoryEntry['offset']) 72 | compressedData = infile.read(TableDirectoryEntry['compLength']) 73 | if TableDirectoryEntry['compLength'] != TableDirectoryEntry['origLength']: 74 | uncompressedData = zlib.decompress(compressedData) 75 | else: 76 | uncompressedData = compressedData 77 | outfile.seek(TableDirectoryEntry['outOffset']) 78 | outfile.write(uncompressedData) 79 | offset = TableDirectoryEntry['outOffset'] + TableDirectoryEntry['origLength']; 80 | padding = 0 81 | if (offset % 4) != 0: 82 | padding = 4 - (offset % 4) 83 | outfile.write(bytearray(padding)); 84 | 85 | 86 | def convert(infilename, outfilename): 87 | with open(infilename , mode='rb') as infile: 88 | with open(outfilename, mode='wb') as outfile: 89 | convert_streams(infile, outfile) 90 | 91 | 92 | def main(argv): 93 | if len(argv) == 1 or len(argv) > 3: 94 | print('I convert *.woff files to *.otf files. (one at a time :)\n' 95 | 'Usage: woff2otf.py web_font.woff [converted_filename.otf]\n' 96 | 'If the target file name is omitted, it will be guessed. Have fun!\n') 97 | return 98 | 99 | source_file_name = argv[1] 100 | if len(argv) == 3: 101 | target_file_name = argv[2] 102 | else: 103 | target_file_name = source_file_name.rsplit('.', 1)[0] + '.otf' 104 | 105 | convert(source_file_name, target_file_name) 106 | return 0 107 | 108 | 109 | if __name__ == '__main__': 110 | sys.exit(main(sys.argv)) 111 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env just --justfile 2 | 3 | default: build 4 | 5 | # Build nostr CLI (release) 6 | build: 7 | cargo build --release --all-features 8 | 9 | # Check format and crates 10 | check: check-fmt check-crate 11 | 12 | # Format the code and execute some checks 13 | precommit: fmt 14 | cargo check 15 | cargo test 16 | cargo clippy 17 | 18 | # Format the entire Rust code 19 | fmt: 20 | @bash contrib/scripts/check-fmt.sh 21 | 22 | # Check if the Rust code is formatted 23 | check-fmt: 24 | @bash contrib/scripts/check-fmt.sh check 25 | 26 | # Check crate 27 | check-crate: 28 | @bash contrib/scripts/check-crate.sh 29 | 30 | # Remove artifacts that cargo has generated 31 | clean: 32 | cargo clean 33 | 34 | # Count the lines of codes of this project 35 | loc: 36 | @echo "--- Counting lines of .rs files (LOC):" && find crates/ bindings/ -type f -name "*.rs" -not -path "*/target/*" -exec cat {} \; | wc -l -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 4 2 | newline_style = "Auto" 3 | reorder_imports = true 4 | reorder_modules = true 5 | reorder_impl_items = false 6 | indent_style = "Block" 7 | normalize_comments = false 8 | imports_granularity = "Module" 9 | group_imports = "StdExternalCrate" -------------------------------------------------------------------------------- /src/component/button.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | -------------------------------------------------------------------------------- /src/component/circle.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::advanced::layout::{self, Layout}; 5 | use iced::advanced::renderer; 6 | use iced::advanced::widget::{self, Widget}; 7 | use iced::{mouse, Border, Color, Element, Length, Rectangle, Size}; 8 | 9 | use crate::theme::color::BLACK; 10 | 11 | pub struct Circle { 12 | radius: f32, 13 | color: Color, 14 | } 15 | 16 | impl Circle { 17 | pub fn new(radius: f32) -> Self { 18 | Self { 19 | radius, 20 | color: BLACK, 21 | } 22 | } 23 | 24 | pub fn color(self, color: Color) -> Self { 25 | Self { color, ..self } 26 | } 27 | } 28 | 29 | /* pub fn circle(radius: f32) -> Circle { 30 | Circle::new(radius) 31 | } */ 32 | 33 | impl Widget for Circle 34 | where 35 | Renderer: renderer::Renderer, 36 | { 37 | fn size(&self) -> Size { 38 | Size { 39 | width: Length::Shrink, 40 | height: Length::Shrink, 41 | } 42 | } 43 | 44 | fn layout( 45 | &self, 46 | _tree: &mut widget::Tree, 47 | _renderer: &Renderer, 48 | _limits: &layout::Limits, 49 | ) -> layout::Node { 50 | layout::Node::new(Size::new(self.radius * 2.0, self.radius * 2.0)) 51 | } 52 | 53 | fn draw( 54 | &self, 55 | _state: &widget::Tree, 56 | renderer: &mut Renderer, 57 | _theme: &Theme, 58 | _style: &renderer::Style, 59 | layout: Layout<'_>, 60 | _cursor: mouse::Cursor, 61 | _viewport: &Rectangle, 62 | ) { 63 | renderer.fill_quad( 64 | renderer::Quad { 65 | bounds: layout.bounds(), 66 | border: Border { 67 | width: 0.0, 68 | color: Color::TRANSPARENT, 69 | radius: self.radius.into(), 70 | }, 71 | ..renderer::Quad::default() 72 | }, 73 | self.color, 74 | ); 75 | } 76 | } 77 | #[allow(clippy::needless_lifetimes)] 78 | impl<'a, Message, Theme, Renderer> From for Element<'a, Message, Theme, Renderer> 79 | where 80 | Renderer: renderer::Renderer, 81 | { 82 | fn from(circle: Circle) -> Self { 83 | Self::new(circle) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/component/icon.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::alignment::Horizontal; 5 | use iced::widget::Text; 6 | use iced::{Font, Length}; 7 | 8 | pub struct Icon; 9 | 10 | impl Icon { 11 | pub fn view(unicode: &'static char) -> Text<'static> { 12 | Text::new(unicode.to_string()) 13 | .font(Font::with_name("bootstrap-icons")) 14 | .width(Length::Fixed(20.0)) 15 | .horizontal_alignment(Horizontal::Center) 16 | .size(20) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/component/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | mod button; 5 | mod circle; 6 | mod icon; 7 | 8 | pub use self::circle::Circle; 9 | pub use self::icon::Icon; 10 | -------------------------------------------------------------------------------- /src/component/version.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::{executor, Application, Command, Element, Settings, Size, Subscription, Theme}; 5 | use once_cell::sync::Lazy; 6 | use tokio::runtime::Runtime; 7 | 8 | mod component; 9 | mod error; 10 | mod message; 11 | mod stage; 12 | mod sync; 13 | mod theme; 14 | mod util; 15 | 16 | use theme::font::BOOTSTRAP_ICONS_BYTES; 17 | 18 | use self::message::Message; 19 | 20 | static RUNTIME: Lazy = Lazy::new(|| Runtime::new().expect("Can't start Tokio runtime")); 21 | 22 | pub fn main() -> iced::Result { 23 | env_logger::init(); 24 | let mut settings = Settings { 25 | fonts: vec![BOOTSTRAP_ICONS_BYTES.into()], 26 | ..Settings::default() 27 | }; 28 | settings.window.min_size = Some(Size { 29 | width: 600.0, 30 | height: 600.0, 31 | }); 32 | NostrDesktop::run(settings) 33 | } 34 | 35 | pub enum NostrDesktop { 36 | Auth(stage::Auth), 37 | Dashboard(stage::App), 38 | } 39 | 40 | impl Application for NostrDesktop { 41 | type Executor = executor::Default; 42 | type Flags = (); 43 | type Message = Message; 44 | type Theme = Theme; 45 | 46 | fn new(_flags: ()) -> (Self, Command) { 47 | // read local db 48 | // if key exists, load main app 49 | // else load login/register view 50 | let stage = stage::Auth::new(); 51 | (Self::Auth(stage.0), stage.1) 52 | } 53 | 54 | fn title(&self) -> String { 55 | match self { 56 | Self::Auth(auth) => auth.title(), 57 | Self::Dashboard(app) => app.title(), 58 | } 59 | } 60 | 61 | fn theme(&self) -> Theme { 62 | Theme::Dark 63 | } 64 | 65 | fn subscription(&self) -> Subscription { 66 | match self { 67 | Self::Auth(auth) => auth.subscription(), 68 | Self::Dashboard(app) => app.subscription(), 69 | } 70 | } 71 | 72 | fn update(&mut self, message: Message) -> Command { 73 | match self { 74 | Self::Auth(auth) => { 75 | let (command, stage_to_move) = auth.update(message); 76 | if let Some(stage) = stage_to_move { 77 | *self = stage; 78 | } 79 | command 80 | } 81 | Self::Dashboard(app) => match message { 82 | Message::Lock => { 83 | let client = app.context.client.clone(); 84 | *self = Self::Auth(stage::Auth::new().0); 85 | Command::perform( 86 | async move { 87 | if let Err(e) = client.shutdown().await { 88 | log::error!("Impossible to shutdown client: {}", e); 89 | } 90 | }, 91 | |_| Message::Tick, 92 | ) 93 | } 94 | _ => app.update(message), 95 | }, 96 | } 97 | } 98 | 99 | fn view(&self) -> Element { 100 | match self { 101 | Self::Auth(auth) => auth.view(), 102 | Self::Dashboard(app) => app.view(), 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/message.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::scrollable; 5 | use nostr_sdk::nostr::Event; 6 | use nostr_sdk::Client; 7 | 8 | use crate::stage::auth::screen::LoginMessage; 9 | use crate::stage::dashboard::screen::{ 10 | ChatMessage, ContactsMessage, ExploreMessage, HomeMessage, NotificationsMessage, 11 | ProfileMessage, SettingMessage, 12 | }; 13 | use crate::stage::{auth, dashboard}; 14 | 15 | #[derive(Debug, Clone)] 16 | pub enum DashboardMessage { 17 | Home(HomeMessage), 18 | Explore(ExploreMessage), 19 | Chat(ChatMessage), 20 | Contacts(ContactsMessage), 21 | Notifications(NotificationsMessage), 22 | Profile(ProfileMessage), 23 | Setting(SettingMessage), 24 | } 25 | 26 | #[derive(Debug, Clone)] 27 | pub enum Message { 28 | Tick, 29 | Sync(Event), 30 | Scrolled(scrollable::Viewport), 31 | SetAuthStage(auth::Stage), 32 | SetDashboardStage(dashboard::Stage), 33 | LoginResult(Client), 34 | Lock, 35 | Clipboard(String), 36 | Login(LoginMessage), 37 | Dashboard(DashboardMessage), 38 | } 39 | -------------------------------------------------------------------------------- /src/stage/auth/context.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 5 | pub enum Stage { 6 | Login, 7 | Register, 8 | } 9 | 10 | impl Default for Stage { 11 | fn default() -> Self { 12 | Self::Login 13 | } 14 | } 15 | 16 | pub struct Context { 17 | pub stage: Stage, 18 | } 19 | 20 | impl Context { 21 | pub fn new(stage: Stage) -> Self { 22 | Self { stage } 23 | } 24 | 25 | pub fn set_stage(&mut self, stage: Stage) { 26 | self.stage = stage; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/stage/auth/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::{Command, Element, Subscription}; 5 | 6 | mod context; 7 | pub mod screen; 8 | 9 | pub use self::context::{Context, Stage}; 10 | use self::screen::LoginState; 11 | use super::App; 12 | use crate::message::Message; 13 | use crate::NostrDesktop; 14 | 15 | pub struct Auth { 16 | state: Box, 17 | context: Context, 18 | } 19 | 20 | pub fn new_state(context: &Context) -> Box { 21 | match &context.stage { 22 | Stage::Login => LoginState::new().into(), 23 | Stage::Register => todo!(), 24 | } 25 | } 26 | 27 | pub trait State { 28 | fn title(&self) -> String; 29 | fn update(&mut self, ctx: &mut Context, message: Message) -> Command; 30 | fn view(&self, ctx: &Context) -> Element; 31 | fn subscription(&self) -> Subscription { 32 | Subscription::none() 33 | } 34 | fn load(&self, _ctx: &Context) -> Command { 35 | Command::none() 36 | } 37 | } 38 | 39 | impl Auth { 40 | pub fn new() -> (Self, Command) { 41 | // read local db 42 | // if key exists, load main app 43 | // else load login/register view 44 | let context = Context::new(Stage::default()); 45 | let app = Self { 46 | state: new_state(&context), 47 | context, 48 | }; 49 | (app, Command::none()) 50 | } 51 | 52 | pub fn title(&self) -> String { 53 | self.state.title() 54 | } 55 | 56 | pub fn subscription(&self) -> Subscription { 57 | Subscription::batch(vec![self.state.subscription()]) 58 | } 59 | 60 | pub fn update(&mut self, message: Message) -> (Command, Option) { 61 | match message { 62 | Message::SetAuthStage(stage) => { 63 | self.context.set_stage(stage); 64 | self.state = new_state(&self.context); 65 | (self.state.update(&mut self.context, message), None) 66 | } 67 | Message::LoginResult(client) => { 68 | let app = App::new(client); 69 | (app.1, Some(NostrDesktop::Dashboard(app.0))) 70 | } 71 | _ => (self.state.update(&mut self.context, message), None), 72 | } 73 | } 74 | 75 | pub fn view(&self) -> Element { 76 | self.state.view(&self.context) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/stage/auth/screen/login.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use std::path::Path; 5 | 6 | use iced::widget::{button, column, container, row, text, text_input}; 7 | use iced::{Command, Element, Length}; 8 | use nostr_sdk::nostr::key::{FromSkStr, Keys}; 9 | use nostr_sdk::Client; 10 | 11 | use crate::message::Message; 12 | use crate::stage::auth::context::Context; 13 | use crate::stage::auth::State; 14 | use crate::util::dir; 15 | 16 | #[derive(Debug, Clone)] 17 | pub enum LoginMessage { 18 | SecretKeyChanged(String), 19 | ButtonPressed, 20 | } 21 | 22 | #[derive(Debug, Default)] 23 | pub struct LoginState { 24 | secret_key: String, 25 | error: Option, 26 | } 27 | 28 | impl LoginState { 29 | pub fn new() -> Self { 30 | Self::default() 31 | } 32 | 33 | pub fn clear(&mut self) { 34 | self.secret_key = String::new(); 35 | self.error = None; 36 | } 37 | } 38 | 39 | impl State for LoginState { 40 | fn title(&self) -> String { 41 | String::from("Nostr - Login") 42 | } 43 | 44 | fn update(&mut self, _ctx: &mut Context, message: Message) -> Command { 45 | if let Message::Login(msg) = message { 46 | match msg { 47 | LoginMessage::SecretKeyChanged(secret_key) => self.secret_key = secret_key, 48 | LoginMessage::ButtonPressed => match Keys::from_sk_str(&self.secret_key) { 49 | Ok(keys) => match Client::new_with_store( 50 | &keys, 51 | dir::default_dir().unwrap_or_else(|_| Path::new("./").to_path_buf()), 52 | ) { 53 | Ok(client) => { 54 | return Command::perform(async {}, move |_| { 55 | Message::LoginResult(client) 56 | }) 57 | } 58 | Err(e) => self.error = Some(e.to_string()), 59 | }, 60 | Err(e) => self.error = Some(e.to_string()), 61 | }, 62 | } 63 | }; 64 | 65 | Command::none() 66 | } 67 | 68 | fn view(&self, _ctx: &Context) -> Element { 69 | let text_input = text_input("Secret key", &self.secret_key) 70 | .on_input(|secret_key| Message::Login(LoginMessage::SecretKeyChanged(secret_key))) 71 | .on_submit(Message::Login(LoginMessage::ButtonPressed)) 72 | .padding(10) 73 | .size(20); 74 | 75 | let button = button("Login") 76 | .padding(10) 77 | .on_press(Message::Login(LoginMessage::ButtonPressed)); 78 | 79 | let content = column![ 80 | row![text_input, button].spacing(10), 81 | if let Some(error) = &self.error { 82 | row![text(error)] 83 | } else { 84 | row![] 85 | } 86 | ] 87 | .spacing(20) 88 | .padding(20) 89 | .max_width(600); 90 | 91 | container(content) 92 | .width(Length::Fill) 93 | .height(Length::Fill) 94 | .center_x() 95 | .center_y() 96 | .into() 97 | } 98 | } 99 | 100 | impl From for Box { 101 | fn from(s: LoginState) -> Box { 102 | Box::new(s) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/stage/auth/screen/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | mod login; 5 | 6 | pub use self::login::{LoginMessage, LoginState}; 7 | -------------------------------------------------------------------------------- /src/stage/dashboard/component/contact.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::{image, Column, Container, Row, Text}; 5 | use iced::{Alignment, Length}; 6 | use nostr_sdk::sqlite::model::Profile; 7 | use once_cell::sync::Lazy; 8 | 9 | static UNKNOWN_IMG_PROFILE: Lazy = Lazy::new(|| { 10 | image::Handle::from_memory( 11 | include_bytes!("../../../../static/imgs/unknown-img-profile.png").to_vec(), 12 | ) 13 | }); 14 | 15 | #[derive(Debug)] 16 | pub struct Contact { 17 | pub profile: Profile, 18 | pub image: Option, 19 | } 20 | 21 | impl Contact { 22 | pub fn new(profile: Profile) -> Self { 23 | Self { 24 | profile, 25 | image: None, 26 | } 27 | } 28 | 29 | pub fn view<'a, T: 'a>(&'a self) -> Container<'a, T> { 30 | let image = self 31 | .image 32 | .clone() 33 | .unwrap_or_else(|| UNKNOWN_IMG_PROFILE.to_owned()); 34 | let image = Column::new().push( 35 | image::viewer(image) 36 | .height(Length::Fixed(40.0)) 37 | .width(Length::Fixed(40.0)), 38 | ); 39 | 40 | let mut info = Column::new(); 41 | 42 | if let Some(display_name) = self.profile.display_name.clone() { 43 | info = info.push(Row::new().push(Text::new(display_name))); 44 | } else { 45 | let pk = self.profile.pubkey.to_string(); 46 | info = info.push(Row::new().push(Text::new(format!( 47 | "{}:{}", 48 | &pk[0..8], 49 | &pk[pk.len() - 8..] 50 | )))); 51 | } 52 | 53 | if let Some(name) = self.profile.name.clone() { 54 | info = info.push(Row::new().push(Text::new(format!("@{}", name)).size(16))); 55 | } else { 56 | info = info.push(Row::new()); 57 | } 58 | 59 | let row = Row::new() 60 | .push(image) 61 | .push(info) 62 | .align_items(Alignment::Center) 63 | .spacing(20); 64 | Container::new(row) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/stage/dashboard/component/dashboard/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::{Column, Container, Row, Rule, Scrollable}; 5 | use iced::{Element, Length}; 6 | 7 | use crate::stage::dashboard::Context; 8 | use crate::Message; 9 | 10 | mod navbar; 11 | mod sidebar; 12 | 13 | use self::navbar::Navbar; 14 | use self::sidebar::Sidebar; 15 | 16 | #[derive(Clone, Default)] 17 | pub struct Dashboard; 18 | 19 | impl Dashboard { 20 | pub fn new() -> Self { 21 | Self 22 | } 23 | 24 | pub fn view<'a, T>(&self, ctx: &Context, content: T) -> Element<'a, Message> 25 | where 26 | T: Into>, 27 | { 28 | Column::new() 29 | .push( 30 | Row::new() 31 | .push( 32 | Sidebar::new() 33 | .view(ctx) 34 | .width(Length::Shrink) 35 | .height(Length::Fill), 36 | ) 37 | .push(Rule::vertical(1)) 38 | .push( 39 | Column::new() 40 | .push(Navbar::view()) 41 | .push(Rule::horizontal(1)) 42 | .push( 43 | Container::new( 44 | Scrollable::new(content).on_scroll(Message::Scrolled), 45 | ) 46 | //.max_width(600) 47 | .width(Length::Fill) 48 | .height(Length::Fill) 49 | .center_x(), 50 | ), 51 | ), 52 | ) 53 | //.max_width(1200) 54 | .width(iced::Length::Fill) 55 | .height(iced::Length::Fill) 56 | .into() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/stage/dashboard/component/dashboard/navbar.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::alignment::Horizontal; 5 | use iced::widget::{Button, Container, Row}; 6 | use iced::{theme, Length}; 7 | 8 | use crate::component::Icon; 9 | use crate::message::Message; 10 | use crate::stage::dashboard::component::post::TransparentStyle; 11 | use crate::stage::dashboard::Stage; 12 | use crate::theme::icon::{LOCK, NOTIFICATION}; 13 | 14 | pub struct Navbar; 15 | 16 | impl Navbar { 17 | pub fn view<'a>() -> Container<'a, Message> { 18 | let content = Row::new() 19 | .push( 20 | Button::new(Icon::view(&NOTIFICATION)) 21 | .on_press(Message::SetDashboardStage(Stage::Notifications)) 22 | .style(>::into( 23 | TransparentStyle, 24 | )), 25 | ) 26 | .push( 27 | Button::new(Icon::view(&LOCK)) 28 | .on_press(Message::Lock) 29 | .style(>::into( 30 | TransparentStyle, 31 | )), 32 | ) 33 | .spacing(15); 34 | Container::new(content) 35 | .width(Length::Fill) 36 | .padding(20) 37 | .center_y() 38 | .align_x(Horizontal::Right) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/stage/dashboard/component/dashboard/sidebar/button.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::border::Radius; 5 | use iced::widget::{button, Button, Container, Row, Text}; 6 | use iced::{theme, Alignment, Background, Border, Length, Shadow, Theme, Vector}; 7 | 8 | use crate::message::Message; 9 | use crate::stage::dashboard::{Context, Stage}; 10 | use crate::theme::color::{PRIMARY, TRANSPARENT, WHITE}; 11 | 12 | pub const BUTTON_SIZE: f32 = 180.0; 13 | 14 | pub struct ActiveStyle; 15 | 16 | impl button::StyleSheet for ActiveStyle { 17 | type Style = Theme; 18 | 19 | fn active(&self, _style: &Self::Style) -> button::Appearance { 20 | button::Appearance { 21 | shadow_offset: Vector::default(), 22 | background: Some(Background::Color(PRIMARY)), 23 | text_color: WHITE, 24 | border: Border { 25 | width: 1.0, 26 | color: WHITE, 27 | radius: Radius::from(10), 28 | }, 29 | shadow: Shadow::default(), 30 | } 31 | } 32 | } 33 | 34 | impl From for theme::Button { 35 | fn from(style: ActiveStyle) -> Self { 36 | theme::Button::Custom(Box::new(style)) 37 | } 38 | } 39 | 40 | pub struct TransparentStyle; 41 | 42 | impl button::StyleSheet for TransparentStyle { 43 | type Style = Theme; 44 | 45 | fn active(&self, _style: &Self::Style) -> button::Appearance { 46 | button::Appearance { 47 | shadow_offset: Vector::default(), 48 | background: Some(Background::Color(TRANSPARENT)), 49 | text_color: WHITE, 50 | border: Border { 51 | width: 1.0, 52 | color: WHITE, 53 | radius: Radius::from(10), 54 | }, 55 | shadow: Shadow::default(), 56 | } 57 | } 58 | } 59 | 60 | impl From for theme::Button { 61 | fn from(style: TransparentStyle) -> Self { 62 | theme::Button::Custom(Box::new(style)) 63 | } 64 | } 65 | 66 | #[derive(Clone)] 67 | pub struct SidebarButton<'a> { 68 | text: &'a str, 69 | icon: Text<'a>, 70 | } 71 | 72 | impl<'a> SidebarButton<'a> { 73 | pub fn new(text: &'a str, icon: Text<'a>) -> Self { 74 | Self { text, icon } 75 | } 76 | 77 | pub fn view(&self, ctx: &Context, stage: Stage) -> Container<'a, Message> { 78 | let style: theme::Button = if ctx.stage.eq(&stage) { 79 | ActiveStyle.into() 80 | } else { 81 | TransparentStyle.into() 82 | }; 83 | 84 | let content = Container::new( 85 | Row::new() 86 | .push(self.icon.clone()) 87 | .push(Text::new(self.text)) 88 | .spacing(10) 89 | .width(Length::Fill) 90 | .align_items(Alignment::Center), 91 | ) 92 | .width(Length::Fill) 93 | .center_x() 94 | .padding(5); 95 | 96 | Container::new( 97 | Button::new(content) 98 | .on_press(Message::SetDashboardStage(stage)) 99 | .width(Length::Fixed(BUTTON_SIZE)) 100 | .style(style), 101 | ) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/stage/dashboard/component/dashboard/sidebar/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::{Column, Container, Space, Text}; 5 | use iced::Length; 6 | 7 | mod button; 8 | 9 | use self::button::{SidebarButton, BUTTON_SIZE}; 10 | use crate::component::Icon; 11 | use crate::stage::dashboard::{Context, Setting, Stage}; 12 | use crate::theme::icon::{CHAT, CONTACT, EXPLORE, HOME, PERSON, SETTING}; 13 | use crate::Message; 14 | 15 | #[derive(Clone, Default)] 16 | pub struct Sidebar; 17 | 18 | impl Sidebar { 19 | pub fn new() -> Self { 20 | Self 21 | } 22 | 23 | pub fn view<'a>(&self, ctx: &Context) -> Container<'a, Message> { 24 | let title = Text::new("Noppler").size(38); 25 | let home_button = SidebarButton::new("Home", Icon::view(&HOME)).view(ctx, Stage::Home); 26 | let explore_button = 27 | SidebarButton::new("Explore", Icon::view(&EXPLORE)).view(ctx, Stage::Explore); 28 | let chat_button = SidebarButton::new("Chats", Icon::view(&CHAT)).view(ctx, Stage::Chats); 29 | let contacts_button = 30 | SidebarButton::new("Contacts", Icon::view(&CONTACT)).view(ctx, Stage::Contacts); 31 | let profile_button = 32 | SidebarButton::new("Profile", Icon::view(&PERSON)).view(ctx, Stage::Profile); 33 | let setting_button = SidebarButton::new("Settings", Icon::view(&SETTING)) 34 | .view(ctx, Stage::Setting(Setting::Main)); 35 | 36 | let version = Text::new(format!( 37 | "{} v{}", 38 | env!("CARGO_PKG_NAME"), 39 | env!("CARGO_PKG_VERSION") 40 | )) 41 | .size(16); 42 | 43 | sidebar( 44 | Container::new( 45 | Column::new() 46 | .push(Space::with_height(Length::Fixed(30.0))) 47 | .push(title) 48 | .push(Space::with_height(Length::Fixed(30.0))) 49 | .padding(15), 50 | ) 51 | .width(Length::Fixed(BUTTON_SIZE)) 52 | .center_x(), 53 | sidebar_menu(vec![ 54 | home_button, 55 | explore_button, 56 | chat_button, 57 | contacts_button, 58 | profile_button, 59 | setting_button, 60 | ]), 61 | sidebar_menu(vec![Container::new(version) 62 | .width(Length::Fixed(BUTTON_SIZE)) 63 | .center_x()]), 64 | ) 65 | } 66 | } 67 | 68 | pub fn sidebar<'a, T: 'a>( 69 | title: Container<'a, T>, 70 | menu: Container<'a, T>, 71 | footer: Container<'a, T>, 72 | ) -> Container<'a, T> { 73 | Container::new( 74 | Column::new() 75 | .padding(10) 76 | .push(title) 77 | .push(menu.height(Length::Fill)) 78 | .push(footer.height(Length::Shrink)), 79 | ) 80 | } 81 | 82 | pub fn sidebar_menu<'a, T: 'a>(items: Vec>) -> Container<'a, T> { 83 | let mut col = Column::new().padding(15).spacing(15); 84 | for i in items { 85 | col = col.push(i) 86 | } 87 | Container::new(col) 88 | } 89 | -------------------------------------------------------------------------------- /src/stage/dashboard/component/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | mod contact; 5 | mod dashboard; 6 | pub mod post; 7 | 8 | pub use self::contact::Contact; 9 | pub use self::dashboard::Dashboard; 10 | -------------------------------------------------------------------------------- /src/stage/dashboard/component/post/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use chrono::{DateTime, Utc}; 5 | use iced::border::Radius; 6 | use iced::widget::{button, Button, Column, Container, Row, Rule, Space, Text}; 7 | use iced::{theme, Background, Border, Length, Shadow, Theme, Vector}; 8 | use nostr_sdk::nostr::Event; 9 | 10 | use crate::component::Icon; 11 | use crate::message::Message; 12 | use crate::stage::dashboard::Context; 13 | use crate::theme::color::{TRANSPARENT, WHITE}; 14 | use crate::theme::icon::{CHAT, HEART, REPEAT}; 15 | 16 | pub struct TransparentStyle; 17 | 18 | impl button::StyleSheet for TransparentStyle { 19 | type Style = Theme; 20 | 21 | fn active(&self, _style: &Self::Style) -> button::Appearance { 22 | button::Appearance { 23 | shadow_offset: Vector::default(), 24 | background: Some(Background::Color(TRANSPARENT)), 25 | text_color: WHITE, 26 | border: Border { 27 | width: 0.0, 28 | color: TRANSPARENT, 29 | radius: Radius::from(0.0), 30 | }, 31 | shadow: Shadow::default(), 32 | } 33 | } 34 | } 35 | 36 | impl From for theme::Button { 37 | fn from(style: TransparentStyle) -> Self { 38 | theme::Button::Custom(Box::new(style)) 39 | } 40 | } 41 | 42 | pub struct Post { 43 | event: Event, 44 | } 45 | 46 | impl Post { 47 | pub fn new(event: Event) -> Self { 48 | Self { event } 49 | } 50 | 51 | fn format_pubkey(&self) -> String { 52 | let pk = self.event.pubkey.to_string(); 53 | format!("{}:{}", &pk[0..8], &pk[pk.len() - 8..]) 54 | } 55 | 56 | pub fn view<'a>(&self, ctx: &Context) -> Container<'a, Message> { 57 | let mut display_name = self.format_pubkey(); 58 | 59 | if let Ok(Ok(profile)) = ctx 60 | .client 61 | .store() 62 | .map(|store| store.get_profile(self.event.pubkey)) 63 | { 64 | if let Some(dn) = profile.display_name { 65 | display_name = dn; 66 | } 67 | } 68 | let buttons = Row::new() 69 | .push( 70 | Button::new(Icon::view(&CHAT).size(18)).style(>::into( 73 | TransparentStyle 74 | )), 75 | ) 76 | .push( 77 | Button::new(Icon::view(&REPEAT).size(18)).style(>::into( 80 | TransparentStyle 81 | )), 82 | ) 83 | .push( 84 | Button::new(Icon::view(&HEART).size(18)).style(>::into( 87 | TransparentStyle 88 | )), 89 | ) 90 | .spacing(20); 91 | 92 | let dt: DateTime = DateTime::from_timestamp(self.event.created_at as i64, 0) 93 | .unwrap_or(DateTime::::MIN_UTC); 94 | 95 | let post = Column::new() 96 | .push(Row::new().push(Text::new(display_name))) 97 | .push(Row::new().push(Text::new(self.event.content.clone()))) 98 | .push(Space::with_height(Length::Fixed(15.0))) 99 | .push(Row::new().push(Text::new(dt.format("%Y-%m-%d %H:%M:%S").to_string()).size(14))) 100 | .push(buttons) 101 | .push(Rule::horizontal(1)) 102 | .spacing(10); 103 | 104 | Container::new(post).padding(15) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/stage/dashboard/context.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use nostr_sdk::Client; 5 | 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 7 | pub enum Setting { 8 | Main, 9 | Relays, 10 | } 11 | 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 13 | pub enum Stage { 14 | Home, 15 | Explore, 16 | Chats, 17 | Contacts, 18 | Notifications, 19 | Profile, 20 | Setting(Setting), 21 | } 22 | 23 | impl Default for Stage { 24 | fn default() -> Self { 25 | Self::Home 26 | } 27 | } 28 | 29 | #[derive(Clone)] 30 | pub struct Context { 31 | //pub config: ConfigContext, 32 | pub stage: Stage, 33 | pub client: Client, 34 | } 35 | 36 | impl Context { 37 | pub fn new(stage: Stage, client: Client) -> Self { 38 | Self { stage, client } 39 | } 40 | 41 | pub fn set_stage(&mut self, stage: Stage) { 42 | self.stage = stage; 43 | } 44 | 45 | pub fn set_client(&mut self, client: Client) { 46 | self.client = client; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/stage/dashboard/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::{Command, Element, Subscription}; 5 | use nostr_sdk::Client; 6 | 7 | pub mod component; 8 | mod context; 9 | pub mod screen; 10 | 11 | pub use self::context::{Context, Setting, Stage}; 12 | use self::screen::{ 13 | ChatState, ContactsState, ExploreState, HomeMessage, HomeState, NotificationsState, 14 | ProfileState, RelaysState, SettingState, 15 | }; 16 | use crate::message::Message; 17 | use crate::sync::NostrSync; 18 | 19 | pub struct App { 20 | pub state: Box, 21 | pub context: Context, 22 | } 23 | 24 | pub fn new_state(context: &Context) -> Box { 25 | match &context.stage { 26 | Stage::Home => HomeState::new().into(), 27 | Stage::Explore => ExploreState::new().into(), 28 | Stage::Chats => ChatState::new().into(), 29 | Stage::Contacts => ContactsState::new().into(), 30 | Stage::Notifications => NotificationsState::new().into(), 31 | Stage::Profile => ProfileState::new().into(), 32 | Stage::Setting(s) => match s { 33 | Setting::Main => SettingState::new().into(), 34 | Setting::Relays => RelaysState::new().into(), 35 | }, 36 | } 37 | } 38 | 39 | pub trait State { 40 | fn title(&self) -> String; 41 | fn update(&mut self, ctx: &mut Context, message: Message) -> Command; 42 | fn view(&self, ctx: &Context) -> Element; 43 | fn subscription(&self) -> Subscription { 44 | Subscription::none() 45 | } 46 | fn load(&mut self, _ctx: &Context) -> Command { 47 | Command::none() 48 | } 49 | } 50 | 51 | impl App { 52 | pub fn new(client: Client) -> (Self, Command) { 53 | let context = Context::new(Stage::default(), client.clone()); 54 | let app = Self { 55 | state: new_state(&context), 56 | context, 57 | }; 58 | ( 59 | app, 60 | Command::perform( 61 | async move { 62 | if let Err(e) = client.restore_relays().await { 63 | log::error!("Impossible to load relays: {}", e); 64 | } 65 | client.connect().await; 66 | }, 67 | |_| Message::Tick, 68 | ), 69 | ) 70 | } 71 | 72 | pub fn title(&self) -> String { 73 | self.state.title() 74 | } 75 | 76 | pub fn subscription(&self) -> Subscription { 77 | let sync = NostrSync::subscription(self.context.client.clone()).map(Message::Sync); 78 | Subscription::batch(vec![sync, self.state.subscription()]) 79 | } 80 | 81 | pub fn update(&mut self, message: Message) -> Command { 82 | match message { 83 | Message::SetDashboardStage(stage) => { 84 | self.context.set_stage(stage); 85 | self.state = new_state(&self.context); 86 | self.state.update(&mut self.context, message) 87 | } 88 | Message::Sync(event) => match self.context.stage { 89 | Stage::Home => self 90 | .state 91 | .update(&mut self.context, HomeMessage::PushTextNote(event).into()), 92 | _ => Command::none(), 93 | }, 94 | _ => self.state.update(&mut self.context, message), 95 | } 96 | } 97 | 98 | pub fn view(&self) -> Element { 99 | self.state.view(&self.context) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/stage/dashboard/screen/chat.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::{Column, Row, Text}; 5 | use iced::{Command, Element}; 6 | 7 | use crate::message::Message; 8 | use crate::stage::dashboard::component::Dashboard; 9 | use crate::stage::dashboard::{Context, State}; 10 | 11 | #[derive(Debug, Clone)] 12 | pub enum ChatMessage {} 13 | 14 | #[derive(Debug, Default)] 15 | pub struct ChatState { 16 | error: Option, 17 | } 18 | 19 | impl ChatState { 20 | pub fn new() -> Self { 21 | Self { error: None } 22 | } 23 | 24 | pub fn clear(&mut self) { 25 | self.error = None; 26 | } 27 | } 28 | 29 | impl State for ChatState { 30 | fn title(&self) -> String { 31 | String::from("Nostr - Chat") 32 | } 33 | 34 | fn update(&mut self, _ctx: &mut Context, _message: Message) -> Command { 35 | Command::none() 36 | } 37 | 38 | fn view(&self, ctx: &Context) -> Element { 39 | /* let mut messages = Column::new().spacing(10); 40 | 41 | for event in self.events.iter() { 42 | messages = messages.push(Row::new().push(Text::new(&event.content))); 43 | } */ 44 | 45 | let content = Column::new().push(Row::new().push(if let Some(error) = &self.error { 46 | Row::new().push(Text::new(error)) 47 | } else { 48 | Row::new() 49 | })); 50 | //.push(messages); 51 | 52 | Dashboard::new().view(ctx, content.spacing(20).padding(20)) 53 | } 54 | } 55 | 56 | impl From for Box { 57 | fn from(s: ChatState) -> Box { 58 | Box::new(s) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/stage/dashboard/screen/contacts.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use std::collections::HashMap; 5 | 6 | use iced::widget::{image, Column, Row, Text}; 7 | use iced::{Command, Element}; 8 | use nostr_sdk::nostr::secp256k1::XOnlyPublicKey; 9 | 10 | use crate::message::{DashboardMessage, Message}; 11 | use crate::stage::dashboard::component::{Contact, Dashboard}; 12 | use crate::stage::dashboard::{Context, State}; 13 | 14 | #[derive(Debug, Clone)] 15 | pub enum ContactsMessage { 16 | SearchImage(XOnlyPublicKey, String), 17 | MaybeFoundImage(XOnlyPublicKey, Option), 18 | } 19 | 20 | #[derive(Debug, Default)] 21 | pub struct ContactsState { 22 | contacts: HashMap, 23 | error: Option, 24 | } 25 | 26 | impl ContactsState { 27 | pub fn new() -> Self { 28 | Self { 29 | contacts: HashMap::new(), 30 | error: None, 31 | } 32 | } 33 | } 34 | 35 | impl State for ContactsState { 36 | fn title(&self) -> String { 37 | String::from("Nostr - Contacts") 38 | } 39 | 40 | fn update(&mut self, ctx: &mut Context, message: Message) -> Command { 41 | let mut commands = Vec::new(); 42 | 43 | if let Ok(store) = ctx.client.store() { 44 | for profile in store.get_contacts().unwrap_or_default().into_iter() { 45 | self.contacts 46 | .entry(profile.pubkey) 47 | .and_modify(|c| c.profile = profile.clone()) 48 | .or_insert_with(|| Contact::new(profile)); 49 | } 50 | } 51 | 52 | if let Message::Dashboard(DashboardMessage::Contacts(msg)) = message { 53 | match msg { 54 | ContactsMessage::SearchImage(pk, url) => { 55 | return Command::perform(async { fetch_image(url).await }, move |image| { 56 | ContactsMessage::MaybeFoundImage(pk, image).into() 57 | }) 58 | } 59 | ContactsMessage::MaybeFoundImage(pk, image) => { 60 | self.contacts.entry(pk).and_modify(|c| c.image = image); 61 | return Command::perform(async {}, |_| Message::Tick); 62 | } 63 | } 64 | } 65 | 66 | for (pk, contact) in self.contacts.iter() { 67 | if contact.image.is_none() { 68 | if let Some(url) = contact.profile.picture.clone() { 69 | let pk = *pk; 70 | commands.push(Command::perform(async {}, move |_| { 71 | ContactsMessage::SearchImage(pk, url).into() 72 | })) 73 | } 74 | } 75 | } 76 | 77 | Command::batch(commands) 78 | } 79 | 80 | fn view(&self, ctx: &Context) -> Element { 81 | let mut contacts = Column::new().spacing(10); 82 | 83 | let mut contacts_vec: Vec<(&XOnlyPublicKey, &Contact)> = self.contacts.iter().collect(); 84 | contacts_vec.sort_by(|a, b| a.1.profile.name.cmp(&b.1.profile.name)); 85 | for (_, contact) in contacts_vec.iter() { 86 | contacts = contacts.push(Row::new().push(contact.view())); 87 | } 88 | 89 | let content = Column::new() 90 | .push(Row::new().push(if let Some(error) = &self.error { 91 | Row::new().push(Text::new(error)) 92 | } else { 93 | Row::new() 94 | })) 95 | .push(contacts); 96 | 97 | Dashboard::new().view(ctx, content.spacing(20).padding(20)) 98 | } 99 | } 100 | 101 | impl From for Box { 102 | fn from(s: ContactsState) -> Box { 103 | Box::new(s) 104 | } 105 | } 106 | 107 | impl From for Message { 108 | fn from(msg: ContactsMessage) -> Self { 109 | Self::Dashboard(DashboardMessage::Contacts(msg)) 110 | } 111 | } 112 | 113 | pub async fn fetch_image(url: String) -> Option { 114 | match reqwest::get(url).await { 115 | Ok(res) => match res.bytes().await { 116 | Ok(bytes) => return Some(image::Handle::from_memory(bytes.as_ref().to_vec())), 117 | Err(e) => log::error!("Impossible to fetch image bytes: {}", e), 118 | }, 119 | Err(e) => log::error!("Impossible to fetch image: {}", e), 120 | } 121 | None 122 | } 123 | -------------------------------------------------------------------------------- /src/stage/dashboard/screen/explore.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::Column; 5 | use iced::{Command, Element}; 6 | 7 | use crate::message::{DashboardMessage, Message}; 8 | use crate::stage::dashboard::component::Dashboard; 9 | use crate::stage::dashboard::{Context, State}; 10 | 11 | #[derive(Debug, Clone)] 12 | pub enum ExploreMessage {} 13 | 14 | #[derive(Debug, Default)] 15 | pub struct ExploreState {} 16 | 17 | impl ExploreState { 18 | pub fn new() -> Self { 19 | Self {} 20 | } 21 | } 22 | 23 | impl State for ExploreState { 24 | fn title(&self) -> String { 25 | String::from("Nostr - Explore") 26 | } 27 | 28 | fn update(&mut self, _ctx: &mut Context, message: Message) -> Command { 29 | if let Message::Dashboard(DashboardMessage::Explore(_msg)) = message { 30 | Command::none() 31 | } else { 32 | Command::none() 33 | } 34 | } 35 | 36 | fn view(&self, ctx: &Context) -> Element { 37 | let content = Column::new(); 38 | Dashboard::new().view(ctx, content.spacing(20).padding(20)) 39 | } 40 | } 41 | 42 | impl From for Box { 43 | fn from(s: ExploreState) -> Box { 44 | Box::new(s) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/stage/dashboard/screen/home.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::{scrollable, Column}; 5 | use iced::{Command, Element}; 6 | use nostr_sdk::nostr::Event; 7 | 8 | use crate::message::{DashboardMessage, Message}; 9 | use crate::stage::dashboard::component::post::Post; 10 | use crate::stage::dashboard::component::Dashboard; 11 | use crate::stage::dashboard::{Context, State}; 12 | 13 | const FEED_LIMIT: usize = 40; 14 | 15 | #[derive(Debug, Clone)] 16 | pub enum HomeMessage { 17 | PushTextNote(Event), 18 | Like(Event), 19 | } 20 | 21 | #[derive(Clone, Default)] 22 | pub struct HomeState { 23 | loaded: bool, 24 | latest_offset: scrollable::RelativeOffset, 25 | page: usize, 26 | } 27 | 28 | impl HomeState { 29 | pub fn new() -> Self { 30 | Self { 31 | loaded: false, 32 | latest_offset: scrollable::RelativeOffset { x: 0.0, y: 0.0 }, 33 | page: 1, 34 | } 35 | } 36 | } 37 | 38 | impl State for HomeState { 39 | fn title(&self) -> String { 40 | String::from("Nostr - Home") 41 | } 42 | 43 | fn load(&mut self, _ctx: &Context) -> Command { 44 | self.loaded = true; 45 | Command::perform(async {}, |_| Message::Tick) 46 | } 47 | 48 | fn update(&mut self, ctx: &mut Context, message: Message) -> Command { 49 | if !self.loaded { 50 | return self.load(ctx); 51 | } 52 | 53 | match message { 54 | Message::Scrolled(offset) => { 55 | self.latest_offset = offset.relative_offset(); 56 | if offset.relative_offset().y > 0.9 { 57 | self.page += 1; 58 | } 59 | } 60 | Message::Dashboard(DashboardMessage::Home(msg)) => match msg { 61 | HomeMessage::PushTextNote(_) => {} 62 | HomeMessage::Like(event) => { 63 | let client = ctx.client.clone(); 64 | return Command::perform(async move { client.like(&event).await }, |_| { 65 | Message::Tick 66 | }); 67 | } 68 | }, 69 | _ => (), 70 | } 71 | 72 | Command::none() 73 | } 74 | 75 | fn view(&self, ctx: &Context) -> Element { 76 | let mut content: Column = Column::new(); 77 | 78 | if let Ok(store) = ctx.client.store() { 79 | for event in store 80 | .get_feed(FEED_LIMIT, self.page) 81 | .unwrap_or_default() 82 | .into_iter() 83 | { 84 | content = content.push(Post::new(event).view(ctx)); 85 | } 86 | } 87 | 88 | Dashboard::new().view(ctx, content.spacing(10).padding(20)) 89 | } 90 | } 91 | 92 | impl From for Box { 93 | fn from(s: HomeState) -> Box { 94 | Box::new(s) 95 | } 96 | } 97 | 98 | impl From for Message { 99 | fn from(msg: HomeMessage) -> Self { 100 | Self::Dashboard(DashboardMessage::Home(msg)) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/stage/dashboard/screen/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | mod chat; 5 | mod contacts; 6 | mod explore; 7 | mod home; 8 | mod notifications; 9 | mod profile; 10 | mod setting; 11 | 12 | pub use self::chat::{ChatMessage, ChatState}; 13 | pub use self::contacts::{ContactsMessage, ContactsState}; 14 | pub use self::explore::{ExploreMessage, ExploreState}; 15 | pub use self::home::{HomeMessage, HomeState}; 16 | pub use self::notifications::{NotificationsMessage, NotificationsState}; 17 | pub use self::profile::{ProfileMessage, ProfileState}; 18 | pub use self::setting::{RelaysState, SettingMessage, SettingState}; 19 | -------------------------------------------------------------------------------- /src/stage/dashboard/screen/notifications.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::Column; 5 | use iced::{Command, Element}; 6 | 7 | use crate::message::{DashboardMessage, Message}; 8 | use crate::stage::dashboard::component::Dashboard; 9 | use crate::stage::dashboard::{Context, State}; 10 | 11 | #[derive(Debug, Clone)] 12 | pub enum NotificationsMessage {} 13 | 14 | #[derive(Debug, Default)] 15 | pub struct NotificationsState {} 16 | 17 | impl NotificationsState { 18 | pub fn new() -> Self { 19 | Self {} 20 | } 21 | } 22 | 23 | impl State for NotificationsState { 24 | fn title(&self) -> String { 25 | String::from("Nostr - Notifications") 26 | } 27 | 28 | fn update(&mut self, _ctx: &mut Context, message: Message) -> Command { 29 | if let Message::Dashboard(DashboardMessage::Notifications(_msg)) = message { 30 | Command::none() 31 | } else { 32 | Command::none() 33 | } 34 | } 35 | 36 | fn view(&self, ctx: &Context) -> Element { 37 | let content = Column::new(); 38 | Dashboard::new().view(ctx, content.spacing(20).padding(20)) 39 | } 40 | } 41 | 42 | impl From for Box { 43 | fn from(s: NotificationsState) -> Box { 44 | Box::new(s) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/stage/dashboard/screen/profile.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::Column; 5 | use iced::{Command, Element}; 6 | 7 | use crate::message::{DashboardMessage, Message}; 8 | use crate::stage::dashboard::component::Dashboard; 9 | use crate::stage::dashboard::context::Context; 10 | use crate::stage::dashboard::State; 11 | 12 | #[derive(Debug, Clone)] 13 | pub enum ProfileMessage {} 14 | 15 | #[derive(Debug, Default)] 16 | pub struct ProfileState {} 17 | 18 | impl ProfileState { 19 | pub fn new() -> Self { 20 | Self {} 21 | } 22 | } 23 | 24 | impl State for ProfileState { 25 | fn title(&self) -> String { 26 | String::from("Nostr - Profile") 27 | } 28 | 29 | fn update(&mut self, _ctx: &mut Context, message: Message) -> Command { 30 | if let Message::Dashboard(DashboardMessage::Profile(_msg)) = message { 31 | Command::none() 32 | } else { 33 | Command::none() 34 | } 35 | } 36 | 37 | fn view(&self, ctx: &Context) -> Element { 38 | let content = Column::new(); 39 | Dashboard::new().view(ctx, content.spacing(20).padding(20)) 40 | } 41 | } 42 | 43 | impl From for Box { 44 | fn from(s: ProfileState) -> Box { 45 | Box::new(s) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/stage/dashboard/screen/setting/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::widget::{Button, Column}; 5 | use iced::{Command, Element}; 6 | 7 | use crate::message::{DashboardMessage, Message}; 8 | use crate::stage::dashboard::component::Dashboard; 9 | use crate::stage::dashboard::{Context, Setting, Stage, State}; 10 | 11 | pub mod relays; 12 | 13 | pub use self::relays::{RelaysMessage, RelaysState}; 14 | 15 | #[derive(Debug, Clone)] 16 | pub enum SettingMessage { 17 | GoToRelays, 18 | Relays(RelaysMessage), 19 | } 20 | 21 | #[derive(Debug, Default)] 22 | pub struct SettingState {} 23 | 24 | impl SettingState { 25 | pub fn new() -> Self { 26 | Self {} 27 | } 28 | } 29 | 30 | impl State for SettingState { 31 | fn title(&self) -> String { 32 | String::from("Nostr - Setting") 33 | } 34 | 35 | fn update(&mut self, _ctx: &mut Context, message: Message) -> Command { 36 | if let Message::Dashboard(DashboardMessage::Setting(msg)) = message { 37 | match msg { 38 | SettingMessage::GoToRelays => Command::perform(async move {}, |_| { 39 | Message::SetDashboardStage(Stage::Setting(Setting::Relays)) 40 | }), 41 | _ => Command::none(), 42 | } 43 | } else { 44 | Command::none() 45 | } 46 | } 47 | 48 | fn view(&self, ctx: &Context) -> Element { 49 | let button = Button::new("Relays") 50 | .padding(10) 51 | .on_press(Message::Dashboard(DashboardMessage::Setting( 52 | SettingMessage::GoToRelays, 53 | ))); 54 | let content = Column::new().push(button); 55 | Dashboard::new().view(ctx, content.spacing(20).padding(20)) 56 | } 57 | } 58 | 59 | impl From for Box { 60 | fn from(s: SettingState) -> Box { 61 | Box::new(s) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/stage/dashboard/screen/setting/relays.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use std::net::SocketAddr; 5 | use std::time::Duration; 6 | 7 | use iced::widget::{text, Button, Checkbox, Column, Row, Rule, Text, TextInput}; 8 | use iced::{time, Alignment, Command, Element, Length, Subscription}; 9 | use nostr_sdk::nostr::url::Url; 10 | use nostr_sdk::RelayStatus; 11 | 12 | use super::SettingMessage; 13 | use crate::component::{Circle, Icon}; 14 | use crate::message::{DashboardMessage, Message}; 15 | use crate::stage::dashboard::component::Dashboard; 16 | use crate::stage::dashboard::{Context, State}; 17 | use crate::theme::color::{GREEN, GREY, RED, YELLOW}; 18 | use crate::theme::icon::TRASH; 19 | use crate::RUNTIME; 20 | 21 | #[derive(Debug, Clone)] 22 | pub enum RelaysMessage { 23 | RelayUrlChanged(String), 24 | ProxyChanged(String), 25 | ProxyToggled(bool), 26 | AddRelay, 27 | AddRelayFromStore(Url, Option), 28 | RemoveRelay(String), 29 | DisconnectRelay(String), 30 | UpdateRelays, 31 | SetRelays(Vec<(RelayStatus, Url, Option)>), 32 | } 33 | 34 | #[derive(Debug, Default)] 35 | pub struct RelaysState { 36 | loaded: bool, 37 | relay_url: String, 38 | proxy: String, 39 | use_proxy: bool, 40 | relays: Vec<(RelayStatus, Url, Option)>, 41 | error: Option, 42 | } 43 | 44 | impl RelaysState { 45 | pub fn new() -> Self { 46 | Self::default() 47 | } 48 | 49 | pub fn clear(&mut self) { 50 | self.loaded = false; 51 | self.relay_url = String::new(); 52 | self.proxy = String::new(); 53 | self.use_proxy = false; 54 | self.relays = Vec::new(); 55 | self.error = None; 56 | } 57 | 58 | async fn add_relay(&mut self, ctx: &Context, proxy: Option) { 59 | match ctx.client.add_relay(&self.relay_url, proxy).await { 60 | Ok(_) => { 61 | ctx.client.connect().await; 62 | self.relay_url.clear(); 63 | self.error = None; 64 | } 65 | Err(e) => self.error = Some(e.to_string()), 66 | } 67 | } 68 | } 69 | 70 | impl State for RelaysState { 71 | fn title(&self) -> String { 72 | String::from("Nostr - Relays") 73 | } 74 | 75 | fn subscription(&self) -> Subscription { 76 | Subscription::batch(vec![ 77 | time::every(Duration::from_secs(10)).map(|_| RelaysMessage::UpdateRelays.into()) 78 | ]) 79 | } 80 | 81 | fn load(&mut self, _ctx: &Context) -> Command { 82 | self.loaded = true; 83 | Command::perform(async {}, |_| RelaysMessage::UpdateRelays.into()) 84 | } 85 | 86 | fn update(&mut self, ctx: &mut Context, message: Message) -> Command { 87 | let client = ctx.client.clone(); 88 | if let Message::Dashboard(DashboardMessage::Setting(SettingMessage::Relays(msg))) = message 89 | { 90 | match msg { 91 | RelaysMessage::RelayUrlChanged(url) => self.relay_url = url, 92 | RelaysMessage::ProxyChanged(proxy) => self.proxy = proxy, 93 | RelaysMessage::ProxyToggled(value) => self.use_proxy = value, 94 | RelaysMessage::AddRelay => { 95 | if self.use_proxy { 96 | match self.proxy.parse() { 97 | Ok(proxy) => { 98 | RUNTIME.block_on(async { self.add_relay(ctx, Some(proxy)).await }) 99 | } 100 | Err(e) => self.error = Some(e.to_string()), 101 | } 102 | } else { 103 | RUNTIME.block_on(async { self.add_relay(ctx, None).await }); 104 | }; 105 | return self.load(ctx); 106 | } 107 | RelaysMessage::RemoveRelay(url) => { 108 | return Command::perform( 109 | async move { 110 | if let Err(e) = client.remove_relay(&url).await { 111 | log::error!("Impossible to remove {}: {}", url, e); 112 | } 113 | }, 114 | |_| RelaysMessage::UpdateRelays.into(), 115 | ) 116 | } 117 | RelaysMessage::AddRelayFromStore(url, proxy) => { 118 | return Command::perform( 119 | async move { 120 | if let Err(e) = client.add_relay(url.clone(), proxy).await { 121 | log::error!("Impossible to add {}: {}", url, e); 122 | } 123 | client.connect().await; 124 | }, 125 | |_| RelaysMessage::UpdateRelays.into(), 126 | ) 127 | } 128 | RelaysMessage::DisconnectRelay(url) => { 129 | return Command::perform( 130 | async move { 131 | if let Err(e) = client.disconnect_relay(&url).await { 132 | log::error!("Impossible to disconnect {}: {}", url, e); 133 | } 134 | }, 135 | |_| RelaysMessage::UpdateRelays.into(), 136 | ) 137 | } 138 | RelaysMessage::SetRelays(relays) => self.relays = relays, 139 | RelaysMessage::UpdateRelays => { 140 | return Command::perform( 141 | async move { 142 | let mut relays = Vec::new(); 143 | for (url, relay) in client.relays().await.into_iter() { 144 | relays.push((relay.status().await, url, relay.proxy())); 145 | } 146 | relays 147 | }, 148 | |relays| RelaysMessage::SetRelays(relays).into(), 149 | ) 150 | } 151 | } 152 | } 153 | 154 | if self.loaded { 155 | Command::none() 156 | } else { 157 | self.load(ctx) 158 | } 159 | } 160 | 161 | fn view(&self, ctx: &Context) -> Element { 162 | let heading = Text::new("Relays").size(30); 163 | 164 | let on_submit = Message::Dashboard(DashboardMessage::Setting(SettingMessage::Relays( 165 | RelaysMessage::AddRelay, 166 | ))); 167 | 168 | let relay_url_input = TextInput::new("Relay url", &self.relay_url) 169 | .on_input(|s| { 170 | Message::Dashboard(DashboardMessage::Setting(SettingMessage::Relays( 171 | RelaysMessage::RelayUrlChanged(s), 172 | ))) 173 | }) 174 | .on_submit(on_submit.clone()) 175 | .padding(10) 176 | .size(20); 177 | 178 | let use_proxy_checkbox = Checkbox::new("Use proxy", self.use_proxy).on_toggle(|value| { 179 | Message::Dashboard(DashboardMessage::Setting(SettingMessage::Relays( 180 | RelaysMessage::ProxyToggled(value), 181 | ))) 182 | }); 183 | 184 | let proxy_input = TextInput::new("Socks5 proxy (ex. 127.0.0.1:9050)", &self.proxy) 185 | .on_input(|s| { 186 | Message::Dashboard(DashboardMessage::Setting(SettingMessage::Relays( 187 | RelaysMessage::ProxyChanged(s), 188 | ))) 189 | }) 190 | .on_submit(on_submit.clone()) 191 | .padding(10) 192 | .size(20); 193 | 194 | let button = Button::new("Add").padding(10).on_press(on_submit); 195 | 196 | let mut relays = Column::new().spacing(10); 197 | 198 | if !self.relays.is_empty() { 199 | relays = relays.push(Text::new("Relays:")); 200 | } 201 | 202 | for (status, url, proxy) in self.relays.iter() { 203 | let status = match status { 204 | RelayStatus::Initialized => Circle::new(7.0).color(GREY), 205 | RelayStatus::Connecting => Circle::new(7.0).color(YELLOW), 206 | RelayStatus::Connected => Circle::new(7.0).color(GREEN), 207 | RelayStatus::Disconnected => Circle::new(7.0).color(RED), 208 | RelayStatus::Terminated => continue, 209 | }; 210 | 211 | let button = Button::new(Text::new("Disconnect")) 212 | .padding(10) 213 | .style(iced::theme::Button::Destructive) 214 | .on_press(Message::Dashboard(DashboardMessage::Setting( 215 | SettingMessage::Relays(RelaysMessage::DisconnectRelay(url.to_string())), 216 | ))); 217 | 218 | let button_remove = Button::new(Icon::view(&TRASH)) 219 | .padding(10) 220 | .style(iced::theme::Button::Destructive) 221 | .on_press(Message::Dashboard(DashboardMessage::Setting( 222 | SettingMessage::Relays(RelaysMessage::RemoveRelay(url.to_string())), 223 | ))); 224 | 225 | let info = Row::new() 226 | .push(status) 227 | .push(Text::new(url.to_string())) 228 | .push(Text::new(format!("Proxy: {}", proxy.is_some()))) 229 | .spacing(20) 230 | .align_items(Alignment::Center) 231 | .width(Length::Fill); 232 | 233 | relays = relays.push( 234 | Row::new() 235 | .push(info) 236 | .push(button) 237 | .push(button_remove) 238 | .spacing(20) 239 | .align_items(Alignment::Center), 240 | ); 241 | } 242 | 243 | let mut saved_relays = Column::new().spacing(10); 244 | 245 | if let Ok(store) = ctx.client.store() { 246 | let relays = store.get_relays(false).unwrap_or_default(); 247 | if !relays.is_empty() { 248 | saved_relays = saved_relays 249 | .push(Rule::horizontal(1)) 250 | .push(Text::new("Saved relays")); 251 | } 252 | 253 | for (url, proxy) in relays.into_iter() { 254 | let button_connect = 255 | Button::new(Text::new("Add")) 256 | .padding(10) 257 | .on_press(Message::Dashboard(DashboardMessage::Setting( 258 | SettingMessage::Relays(RelaysMessage::AddRelayFromStore( 259 | url.clone(), 260 | proxy, 261 | )), 262 | ))); 263 | let button_remove = Button::new(Icon::view(&TRASH)) 264 | .padding(10) 265 | .style(iced::theme::Button::Destructive) 266 | .on_press(Message::Dashboard(DashboardMessage::Setting( 267 | SettingMessage::Relays(RelaysMessage::RemoveRelay(url.to_string())), 268 | ))); 269 | 270 | let info = Row::new() 271 | .push(Text::new(url.to_string())) 272 | .push(Text::new(format!("Proxy: {}", proxy.is_some()))) 273 | .spacing(20) 274 | .align_items(Alignment::Center) 275 | .width(Length::Fill); 276 | 277 | saved_relays = saved_relays.push( 278 | Row::new() 279 | .push(info) 280 | .push(button_connect) 281 | .push(button_remove) 282 | .spacing(20) 283 | .align_items(Alignment::Center), 284 | ); 285 | } 286 | } 287 | 288 | let content = Column::new() 289 | .push(Row::new().push(heading)) 290 | .push(Row::new().push(relay_url_input).push(button).spacing(10)) 291 | .push(if self.use_proxy { 292 | Column::new() 293 | .push(Row::new().push(proxy_input)) 294 | .push(Row::new().push(use_proxy_checkbox)) 295 | .spacing(20) 296 | } else { 297 | Column::new().push(Row::new().push(use_proxy_checkbox)) 298 | }) 299 | .push(if let Some(error) = &self.error { 300 | Row::new().push(text(error)) 301 | } else { 302 | Row::new() 303 | }) 304 | .push(relays) 305 | .push(saved_relays); 306 | 307 | Dashboard::new().view(ctx, content.spacing(20).padding([20, 30])) 308 | } 309 | } 310 | 311 | impl From for Box { 312 | fn from(s: RelaysState) -> Box { 313 | Box::new(s) 314 | } 315 | } 316 | 317 | impl From for Message { 318 | fn from(msg: RelaysMessage) -> Self { 319 | Self::Dashboard(DashboardMessage::Setting(SettingMessage::Relays(msg))) 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/stage/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | pub mod auth; 5 | pub mod dashboard; 6 | 7 | pub use self::auth::Auth; 8 | pub use self::dashboard::App; 9 | -------------------------------------------------------------------------------- /src/sync.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use async_stream::stream; 5 | use iced::advanced::subscription::{EventStream, Recipe}; 6 | use iced::advanced::Hasher; 7 | use iced::Subscription; 8 | use iced_futures::BoxStream; 9 | use nostr_sdk::nostr::Event; 10 | use nostr_sdk::{Client, RelayPoolNotifications}; 11 | use tokio::sync::mpsc; 12 | 13 | use crate::RUNTIME; 14 | 15 | pub struct NostrSync { 16 | client: Client, 17 | join: Option>, 18 | } 19 | 20 | impl Recipe for NostrSync { 21 | type Output = Event; 22 | 23 | fn hash(&self, state: &mut Hasher) { 24 | use std::hash::Hash; 25 | std::any::TypeId::of::().hash(state); 26 | } 27 | 28 | fn stream(mut self: Box, _input: EventStream) -> BoxStream { 29 | let (sender, mut receiver) = mpsc::unbounded_channel(); 30 | 31 | let client = self.client.clone(); 32 | RUNTIME.block_on(async move { 33 | if let Err(e) = client.sync().await { 34 | panic!("Impossible to start sync thread: {}", e); 35 | } 36 | }); 37 | 38 | let client = self.client.clone(); 39 | let join = tokio::task::spawn(async move { 40 | let mut notifications = client.notifications(); 41 | while let Ok(notification) = notifications.recv().await { 42 | match notification { 43 | RelayPoolNotifications::ReceivedEvent(event) => { 44 | // TODO: Send desktop notification 45 | sender.send(event).ok(); 46 | } 47 | RelayPoolNotifications::Shutdown => break, 48 | _ => (), 49 | } 50 | } 51 | log::debug!("Exited from notification thread"); 52 | }); 53 | self.join = Some(join); 54 | let stream = stream! { 55 | while let Some(item) = receiver.recv().await { 56 | yield item; 57 | } 58 | }; 59 | Box::pin(stream) 60 | } 61 | } 62 | 63 | impl NostrSync { 64 | pub fn subscription(client: Client) -> Subscription { 65 | Subscription::from_recipe(Self { client, join: None }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/theme/color.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use iced::Color; 5 | 6 | pub const TRANSPARENT: Color = Color::TRANSPARENT; 7 | pub const BLACK: Color = Color::BLACK; 8 | pub const WHITE: Color = Color::WHITE; 9 | pub const GREY: Color = Color::from_rgba(0.62, 0.62, 0.62, 1.0); // rgb8(160, 160, 160) 10 | pub const GREEN: Color = Color::from_rgba(0.0, 0.8, 0.0, 1.0); // rgb8(0, 204, 0) 11 | pub const RED: Color = Color::from_rgba(1.0, 0.0, 0.0, 1.0); // rgb8(255, 0, 0) 12 | pub const YELLOW: Color = Color::from_rgba(1.0, 1.0, 0.0, 1.0); // rgb8(255, 255, 0) 13 | 14 | pub const PRIMARY: Color = Color::from_rgba(0.49, 0.0, 1.0, 1.0); // rgb8(127, 0, 255) 15 | -------------------------------------------------------------------------------- /src/theme/font.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | pub const BOOTSTRAP_ICONS_BYTES: &[u8] = 5 | include_bytes!("../../static/icons/bootstrap-icons.otf").as_slice(); 6 | -------------------------------------------------------------------------------- /src/theme/icon.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | // pub const BITCOIN: char = '\u{F635}'; 5 | pub const HOME: char = '\u{F425}'; 6 | pub const EXPLORE: char = '\u{F3EE}'; 7 | pub const CHAT: char = '\u{F268}'; 8 | pub const CONTACT: char = '\u{F4DB}'; 9 | pub const NOTIFICATION: char = '\u{F18A}'; 10 | pub const PERSON: char = '\u{F4DA}'; 11 | pub const SETTING: char = '\u{F3E5}'; 12 | pub const TRASH: char = '\u{F78B}'; 13 | pub const HEART: char = '\u{F417}'; 14 | // pub const LIGHTNING: char = '\u{F46D}'; 15 | pub const LOCK: char = '\u{F47B}'; 16 | pub const REPEAT: char = '\u{F813}'; 17 | -------------------------------------------------------------------------------- /src/theme/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | pub mod color; 5 | pub mod font; 6 | pub mod icon; 7 | 8 | /* #[derive(Debug, PartialEq, Eq, Clone, Copy)] 9 | enum Theme { 10 | Light, 11 | Dark, 12 | } */ 13 | -------------------------------------------------------------------------------- /src/util/dir.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | use std::fs; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use nostr_sdk::Result; 8 | 9 | pub fn home() -> PathBuf { 10 | match dirs::home_dir() { 11 | Some(path) => path, 12 | None => Path::new("./").to_path_buf(), 13 | } 14 | } 15 | 16 | pub fn default_dir() -> Result { 17 | let path: PathBuf = home().join(".nostr-desktop"); 18 | fs::create_dir_all(path.as_path())?; 19 | Ok(path) 20 | } 21 | -------------------------------------------------------------------------------- /src/util/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Yuki Kishimoto 2 | // Distributed under the MIT software license 3 | 4 | pub mod dir; 5 | -------------------------------------------------------------------------------- /static/icons/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-2021 The Bootstrap Authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /static/icons/README.md: -------------------------------------------------------------------------------- 1 | # Icons 2 | 3 | From the getbootstrap.com [repository](https://github.com/twbs/icons) 4 | Converted from .woff to ttf with https://raw.githubusercontent.com/hanikesn/woff2otf/master/woff2otf.py 5 | -------------------------------------------------------------------------------- /static/icons/bootstrap-icons.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-nostr/nostr-desktop/75b0e8166aace01f2f303d68efbcc3adef901477/static/icons/bootstrap-icons.otf -------------------------------------------------------------------------------- /static/imgs/unknown-img-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-nostr/nostr-desktop/75b0e8166aace01f2f303d68efbcc3adef901477/static/imgs/unknown-img-profile.png --------------------------------------------------------------------------------