├── .cargo ├── .config-release.toml └── config.toml ├── .github ├── dependabot.yml ├── upx ├── upx.exe └── workflows │ ├── flatpak.yml │ ├── nix.yml │ └── rust.yml ├── .gitignore ├── ARCHITECTURE.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── Trunk.toml ├── build-aux └── cargo.sh ├── client ├── Cargo.toml └── src │ ├── channel.rs │ ├── content.rs │ ├── emotes.rs │ ├── error.rs │ ├── guild.rs │ ├── lib.rs │ ├── member.rs │ ├── message.rs │ └── role.rs ├── com.github.HarmonyDevelopment.Loqui.yml ├── data ├── com.github.HarmonyDevelopment.Loqui.desktop ├── com.github.HarmonyDevelopment.Loqui.metainfo.xml ├── com.github.HarmonyDevelopment.Loqui.svg ├── loqui.ico ├── loqui.svg ├── loqui.webp ├── lotus.png ├── manifest.json └── meson.build ├── flake.lock ├── flake.nix ├── image_worker ├── Cargo.toml └── src │ ├── lib.rs │ └── main.rs ├── index.html ├── meson.build ├── nix ├── default.nix └── shell.nix ├── rust-toolchain.toml ├── rustfmt.toml ├── screenshots ├── guilds.jpg └── main.jpg ├── shell.nix ├── src ├── app.rs ├── config.rs ├── fonts │ ├── Hack-Regular.ttf │ ├── Hack-Regular_LICENSE.txt │ ├── Inter.otf │ ├── Inter_LICENSE.txt │ ├── emoji-icon-font.ttf │ └── emoji-icon-font_LICENSE.txt ├── futures.rs ├── image_cache.rs ├── lib.rs ├── main.rs ├── meson.build ├── screen │ ├── auth.rs │ ├── guild_settings.rs │ ├── main.rs │ ├── mod.rs │ ├── settings.rs │ └── template.rs ├── state.rs ├── style.rs ├── utils.rs └── widgets │ ├── bg_image.rs │ ├── easy_mark │ ├── easy_mark_editor.rs │ ├── easy_mark_highlighter.rs │ ├── easy_mark_parser.rs │ ├── easy_mark_viewer.rs │ └── mod.rs │ └── mod.rs ├── sw.js └── write-cache-files.sh /.cargo/.config-release.toml: -------------------------------------------------------------------------------- 1 | # Add release options 2 | [unstable] 3 | build-std = ["std", "panic_abort"] 4 | build-std-features = ["panic_immediate_abort"] -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.wasm32-unknown-unknown] 2 | rustflags = ["--cfg=web_sys_unstable_apis"] -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "cargo" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" -------------------------------------------------------------------------------- /.github/upx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harmony-development/Loqui/00ef033580884c86f189fc1b97cb0ed70e1aa157/.github/upx -------------------------------------------------------------------------------- /.github/upx.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harmony-development/Loqui/00ef033580884c86f189fc1b97cb0ed70e1aa157/.github/upx.exe -------------------------------------------------------------------------------- /.github/workflows/flatpak.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ master ] 4 | paths-ignore: 5 | - 'README.md' 6 | - '**/*.nix' 7 | - 'nix/envrc' 8 | - 'flake.lock' 9 | pull_request: 10 | branches: [ master ] 11 | paths-ignore: 12 | - 'README.md' 13 | - '**/*.nix' 14 | - 'nix/envrc' 15 | - 'flake.lock' 16 | 17 | name: Flatpak CI 18 | jobs: 19 | flatpak: 20 | name: "Flatpak" 21 | runs-on: ubuntu-latest 22 | container: 23 | image: bilelmoussaoui/flatpak-github-actions:gnome-40 24 | options: --privileged 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v4 28 | with: 29 | bundle: loqui.flatpak 30 | manifest-path: com.github.HarmonyDevelopment.Loqui.yml 31 | cache-key: flatpak-builder-${{ github.sha }} 32 | -------------------------------------------------------------------------------- /.github/workflows/nix.yml: -------------------------------------------------------------------------------- 1 | name: "Nix" 2 | on: 3 | push: 4 | branches: [ master ] 5 | paths-ignore: 6 | - 'README.md' 7 | - '**/*.nix' 8 | - 'nix/envrc' 9 | - 'flake.lock' 10 | pull_request: 11 | branches: [ master ] 12 | paths-ignore: 13 | - 'README.md' 14 | - '**/*.nix' 15 | - 'nix/envrc' 16 | - 'flake.lock' 17 | jobs: 18 | nix-build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout repo 22 | uses: actions/checkout@v3 23 | - name: Install nix 24 | uses: cachix/install-nix-action@v16 25 | with: 26 | extra_nix_config: | 27 | experimental-features = nix-command flakes 28 | nix_path: nixpkgs=channel:nixos-unstable 29 | - name: Setup cachix 30 | uses: cachix/cachix-action@v10 31 | with: 32 | name: harmony 33 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 34 | - name: Tests 35 | run: nix build .#loqui 36 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths-ignore: 7 | - 'README.md' 8 | - '**/*.nix' 9 | - 'nix/envrc' 10 | - 'flake.lock' 11 | pull_request: 12 | branches: [ master ] 13 | paths-ignore: 14 | - 'README.md' 15 | - '**/*.nix' 16 | - 'nix/envrc' 17 | - 'flake.lock' 18 | 19 | env: 20 | CARGO_TERM_COLOR: always 21 | CARGO_NET_RETRY: 10 22 | RUST_BACKTRACE: short 23 | 24 | jobs: 25 | tagref: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout repo 29 | uses: actions/checkout@v3 30 | 31 | - name: Install tagref 32 | run: curl -L https://github.com/stepchowfun/tagref/releases/download/v1.5.0/tagref-x86_64-unknown-linux-gnu > tagref && chmod +x tagref 33 | 34 | - name: Check tagref 35 | run: ./tagref && ./tagref list-unused 36 | 37 | check: 38 | runs-on: ${{ matrix.os }} 39 | strategy: 40 | matrix: 41 | os: [ubuntu-latest, windows-2019, macOS-latest] 42 | continue-on-error: ${{ matrix.os == 'windows-2019' }} 43 | needs: tagref 44 | steps: 45 | - name: Checkout repo 46 | uses: actions/checkout@v3 47 | 48 | - name: Install dependencies 49 | if: ${{ matrix.os == 'ubuntu-latest' }} 50 | run: | 51 | sudo apt update -yy 52 | sudo apt install -yy --no-install-recommends clang libgtk-3-0 libgtk-3-dev protobuf-compiler libpango1.0-0 libpango1.0-dev libglib2.0-0 libglib2.0-dev python3 pkg-config cmake openssl libx11-dev libxcb1-dev libfreetype6 libfreetype6-dev fontconfig libfontconfig-dev expat libcairo2-dev libcairo2 libatk1.0-0 libatk1.0-dev libgdk-pixbuf2.0-0 libgdk-pixbuf2.0-dev libxcb-shape0 libxcb-shape0-dev libxcb-xfixes0 libxcb-xfixes0-dev clang lld unzip 53 | 54 | - name: Install rust 55 | uses: actions-rs/toolchain@v1 56 | with: 57 | toolchain: nightly-2022-01-17 58 | override: true 59 | profile: minimal 60 | components: rustfmt, clippy 61 | 62 | - name: Cache rust 63 | uses: Swatinem/rust-cache@v1 64 | with: 65 | key: cache-debug-3 66 | 67 | - name: Clippy 68 | uses: actions-rs/clippy-check@v1 69 | with: 70 | token: ${{ secrets.GITHUB_TOKEN }} 71 | name: 'clippy (${{ matrix.os }})' 72 | 73 | build-web: 74 | runs-on: ubuntu-latest 75 | needs: check 76 | if: github.event_name == 'push' 77 | env: 78 | RUSTFLAGS: --cfg=web_sys_unstable_apis 79 | steps: 80 | - name: Checkout repo 81 | uses: actions/checkout@v3 82 | 83 | - name: Install rust 84 | uses: actions-rs/toolchain@v1 85 | with: 86 | toolchain: nightly-2022-01-17 87 | override: true 88 | target: wasm32-unknown-unknown 89 | profile: minimal 90 | components: rustfmt, clippy 91 | 92 | - name: Cache rust 93 | uses: Swatinem/rust-cache@v1 94 | with: 95 | key: cache-release-web-2 96 | 97 | - name: Install trunk 98 | run: cargo install --locked --git "https://github.com/kristoff3r/trunk.git" --branch rust_worker trunk 99 | 100 | - name: Write release cargo config 101 | run: cat .cargo/.config-release.toml >> .cargo/config.toml 102 | 103 | - name: Build 104 | run: trunk build --release 105 | 106 | - uses: montudor/action-zip@v1 107 | with: 108 | args: zip -r dist.zip dist/ 109 | 110 | - name: Artifact web 111 | uses: actions/upload-artifact@v3 112 | with: 113 | name: build-web 114 | path: dist.zip 115 | 116 | build: 117 | runs-on: ${{ matrix.os }} 118 | strategy: 119 | matrix: 120 | os: [ubuntu-latest, windows-2019, macOS-latest] 121 | needs: check 122 | if: github.event_name == 'push' 123 | continue-on-error: ${{ matrix.os == 'windows-2019' }} 124 | steps: 125 | - name: Checkout repo 126 | uses: actions/checkout@v3 127 | 128 | - name: Install dependencies 129 | if: ${{ matrix.os == 'ubuntu-latest' }} 130 | run: | 131 | sudo apt update -yy 132 | sudo apt install -yy --no-install-recommends clang libgtk-3-0 libgtk-3-dev protobuf-compiler libpango1.0-0 libpango1.0-dev libglib2.0-0 libglib2.0-dev python3 pkg-config cmake openssl libx11-dev libxcb1-dev libfreetype6 libfreetype6-dev fontconfig libfontconfig-dev expat libcairo2-dev libcairo2 libatk1.0-0 libatk1.0-dev libgdk-pixbuf2.0-0 libgdk-pixbuf2.0-dev libxcb-shape0 libxcb-shape0-dev libxcb-xfixes0 libxcb-xfixes0-dev clang lld unzip 133 | 134 | - name: Install rust 135 | uses: actions-rs/toolchain@v1 136 | with: 137 | toolchain: nightly-2022-01-17 138 | override: true 139 | profile: minimal 140 | components: rustfmt, clippy 141 | 142 | - name: Cache rust 143 | uses: Swatinem/rust-cache@v1 144 | with: 145 | key: cache-release-3 146 | 147 | - name: Build 148 | run: cargo build --locked --release 149 | 150 | - name: UPX windows 151 | if: ${{ matrix.os == 'windows-2019' }} 152 | run: .github/upx.exe target/release/loqui.exe 153 | 154 | - name: UPX linux 155 | if: ${{ matrix.os == 'ubuntu-latest' }} 156 | run: .github/upx target/release/loqui 157 | 158 | - name: Artifact macOS 159 | if: ${{ matrix.os == 'macOS-latest' }} 160 | uses: actions/upload-artifact@v3 161 | with: 162 | name: build-macos 163 | path: target/release/loqui 164 | 165 | - name: Artifact Linux 166 | if: ${{ matrix.os == 'ubuntu-latest' }} 167 | uses: actions/upload-artifact@v3 168 | with: 169 | name: build-linux 170 | path: target/release/loqui 171 | 172 | - name: Artifact Windows 173 | if: ${{ matrix.os == 'windows-2019' }} 174 | uses: actions/upload-artifact@v3 175 | with: 176 | name: build-windows 177 | path: target/release/loqui.exe 178 | 179 | upload-release: 180 | if: github.event_name == 'push' 181 | needs: [ build, build-web ] 182 | runs-on: ubuntu-latest 183 | steps: 184 | - uses: actions/download-artifact@v2 185 | 186 | - name: Display structure of downloaded files 187 | run: ls -R 188 | 189 | - name: Rename artifacts 190 | run: | 191 | #mv build-windows/loqui.exe build-windows/loqui-windows.exe 192 | mv build-linux/loqui build-linux/loqui-linux 193 | mv build-macos/loqui build-macos/loqui-macos 194 | mv build-web/dist.zip build-web/web-dist.zip 195 | chmod +x build-{linux,macos}/* 196 | 197 | - name: Upload release 198 | env: 199 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 200 | run: | 201 | wget -q https://github.com/TheAssassin/pyuploadtool/releases/download/continuous/pyuploadtool-x86_64.AppImage 202 | chmod +x pyuploadtool-x86_64.AppImage 203 | ./pyuploadtool-x86_64.AppImage build-linux/loqui-linux build-macos/loqui-macos build-web/web-dist.zip 204 | 205 | deploy-web: 206 | if: github.event_name == 'push' 207 | needs: upload-release 208 | runs-on: ubuntu-latest 209 | steps: 210 | - name: Checkout repo 211 | uses: actions/checkout@v3 212 | with: 213 | repository: 'harmony-development/ansible' 214 | - run: 'echo "$SSH_KEY" > key && chmod 600 key' 215 | shell: bash 216 | env: 217 | SSH_KEY: ${{secrets.ACTIONS_SSH_KEY}} 218 | - run: 'echo "$KNOWN_HOSTS" > known_hosts && chmod 600 known_hosts' 219 | shell: bash 220 | env: 221 | KNOWN_HOSTS: ${{secrets.ACTIONS_SSH_KNOWN_HOSTS}} 222 | - run: 'ansible-playbook only-loqui.yml --key-file key' 223 | shell: bash 224 | env: 225 | SSH_HOST: ${{secrets.ACTIONS_SSH_HOST}} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust 2 | /target/ 3 | **/*.rs.bk 4 | 5 | # Nix 6 | /result* 7 | /nix/result* 8 | /shell.nix 9 | 10 | # Direnv 11 | /.direnv 12 | /.envrc 13 | 14 | /.vscode 15 | 16 | /.idea 17 | /dist -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | This file outlines the high-level architecture of `loqui`. 4 | 5 | Since `loqui` is an `egui` application, it is recommended to get familiar with 6 | `egui` first before delving in the codebase. 7 | 8 | ## `client` 9 | 10 | This crate handles all communication with Harmony servers. This is where all 11 | endpoint methods and socket event handlers should go. 12 | 13 | ## `src/app` 14 | 15 | This module contains the main app loop and app initialization for loqui. 16 | 17 | It also implements "general" UI seperate from screens, such as the status bar 18 | and the global menu. 19 | 20 | ## `src/state` 21 | 22 | This module contains the monolithic state structure. It does everything ranging 23 | from managing sockets, handling events and storing important data. Since it is 24 | kept over the course of the lifetime of the app, this is where anything that should 25 | live beyond a "screen" should go. 26 | 27 | ## `src/screen` 28 | 29 | This module contains the "screen"s for loqui, aka the main content we will be 30 | displaying at once. Any major UI should go here, more minor UIs can be made a 31 | window. 32 | 33 | ## `src/config` 34 | 35 | This module contains configuration structures for loqui. This is where 36 | all configuration related stuff should go (new config options, config parsing, 37 | anyting configuration related). 38 | 39 | ## `src/widgets` 40 | 41 | This module contains widgets used by loqui. -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "loqui" 3 | version = "0.1.0" 4 | authors = ["Yusuf Bera Ertan "] 5 | edition = "2021" 6 | description = "Rust client for the Harmony protocol." 7 | license = "GPLv3" 8 | repository = "https://github.com/harmony-development/loqui" 9 | homepage = "https://github.com/harmony-development/loqui" 10 | resolver = "2" 11 | 12 | [workspace] 13 | members = ["client", "image_worker"] 14 | 15 | [package.metadata.bundle] 16 | name = "Loqui" 17 | identifier = "nodomain.yusdacra.loqui" 18 | short_description = "Rust client for the Harmony protocol." 19 | icon = ["./resources/loqui.ico"] 20 | 21 | [profile.dev] 22 | opt-level = 0 23 | overflow-checks = true 24 | debug-assertions = true 25 | debug = false 26 | codegen-units = 256 27 | lto = false 28 | incremental = true 29 | 30 | [profile.release] 31 | opt-level = 3 32 | lto = "fat" 33 | overflow-checks = false 34 | debug-assertions = false 35 | debug = false 36 | codegen-units = 1 37 | panic = 'abort' 38 | 39 | [dependencies] 40 | eframe = { git = "https://github.com/yusdacra/egui.git", branch = "loqui", default-features = false, features = [ 41 | "egui_glow", 42 | ] } 43 | egui = { git = "https://github.com/yusdacra/egui.git", branch = "loqui", default-features = false, features = [ 44 | "serde", 45 | "single_threaded", 46 | ] } 47 | epaint = { git = "https://github.com/yusdacra/egui.git", branch = "loqui", default-features = false, features = [ 48 | "serde", 49 | ] } 50 | emath = { git = "https://github.com/yusdacra/egui.git", branch = "loqui", default-features = false, features = [ 51 | "serde", 52 | ] } 53 | ahash = { version = "0.7", default-features = false, features = ["serde"] } 54 | client = { path = "./client" } 55 | image_worker = { path = "./image_worker" } 56 | 57 | urlencoding = "2" 58 | webbrowser = "0.5" 59 | rfd = { version = "0.7", default-features = false, features = ["parent"] } 60 | serde = { version = "1.0", features = ["derive"] } 61 | serde_json = "1" 62 | reqwest = { version = "0.11", default-features = false } 63 | 64 | anyhow = "1" 65 | 66 | itertools = "0.10" 67 | instant = { version = "0.1", features = ["wasm-bindgen"] } 68 | 69 | [target.'cfg(target_arch = "wasm32")'.dependencies] 70 | wasm-bindgen-futures = "0.4" 71 | wasm-bindgen = "0.2" 72 | js-sys = { version = "0.3" } 73 | web-sys = { version = "0.3", features = [ 74 | "Worker", 75 | "MessageEvent", 76 | "Notification", 77 | "NotificationOptions", 78 | ] } 79 | rkyv = "0.7" 80 | tokio = { version = "1.9", features = ["sync", "macros"] } 81 | tracing-wasm = "0.2" 82 | console_error_panic_hook = "0.1" 83 | image = { git = "https://github.com/image-rs/image.git", branch = "master", default-features = false, features = [ 84 | "gif", 85 | "jpeg", 86 | "ico", 87 | "png", 88 | "tiff", 89 | "webp", 90 | ] } 91 | 92 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 93 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 94 | tracing-appender = "0.2" 95 | tokio = { version = "1.9", features = ["sync", "rt-multi-thread", "macros"] } 96 | open = "2.0" 97 | image = { git = "https://github.com/image-rs/image.git", branch = "master", default-features = false, features = [ 98 | "gif", 99 | "jpeg", 100 | "ico", 101 | "png", 102 | "tiff", 103 | "webp", 104 | "jpeg_rayon", 105 | ] } 106 | notify-rust = "4" 107 | 108 | [package.metadata.nix] 109 | longDescription = """ 110 | Loqui is a Harmony client written in Rust using the iced GUI library. 111 | 112 | It aims to be lightweight with a good out-of-the-box experience. 113 | """ 114 | systems = ["x86_64-linux"] 115 | app = true 116 | build = true 117 | runtimeLibs = [ 118 | "wayland", 119 | "wayland-protocols", 120 | "libxkbcommon", 121 | "xorg.libX11", 122 | "xorg.libXrandr", 123 | "xorg.libXi", 124 | "libGL", 125 | ] 126 | 127 | [workspace.metadata.nix] 128 | buildInputs = ["libxkbcommon"] 129 | devshell.packages = ["cargo-deny", "wasm-bindgen-cli"] 130 | devshell.name = "loqui-shell" 131 | devshell.commands = [{ package = "tagref" }] 132 | 133 | [package.metadata.nix.desktopFile] 134 | name = "Loqui" 135 | genericName = "Harmony Client" 136 | categories = "Network;" 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

Loqui

3 | 4 | Loqui is a [Harmony] client written in Rust using the [egui] GUI library. 5 | It uses [harmony-rust-sdk] to communicate with Harmony servers. 6 | 7 | ![Loqui](./screenshots/main.jpg) 8 | 9 | [See more screenshots](./screenshots) 10 | 11 | Use it on your browser via https://loqui.harmonyapp.io 12 | 13 | ## Roadmap 14 | 15 | - Voice channels (needs implementation in `scherzo` server first) 16 | - Video / audio files embedding 17 | - Animated emotes / pfp / images support 18 | - Instant (website) view (ala Telegram) 19 | - UI & UX polish 20 | 21 | ## Features 22 | 23 | - All essential chat functions Harmony provides 24 | - User theming support 25 | - Partial rich messages support (code, mentions, emotes, URLs) 26 | - Website embeds (previews) 27 | 28 | ## Running 29 | 30 | - Get a binary from one of the links below 31 | - [For Linux systems](https://github.com/harmony-development/Loqui/releases/download/continuous/loqui-linux) 32 | - [For Windows systems](https://github.com/harmony-development/Loqui/releases/download/continuous/loqui-windows.exe) 33 | - [For macOS systems](https://github.com/harmony-development/Loqui/releases/download/continuous/loqui-macos) 34 | - Note: you might need to **mark the binary as executable** on macOS and Linux systems. 35 | 36 | ## Building 37 | 38 | - Clone the repo, and switch the working directory to it: `git clone https://github.com/harmony-development/loqui.git && cd loqui` 39 | - To build and run the project with debug info / checks use `cargo run`. Use `cargo run --release` for an optimized release build. 40 | 41 | ### Requirements 42 | - Rust toolchain specified in the [rust-toolchain.toml](./rust-toolchain.toml) file. 43 | This will be managed for you automatically if you have `rustup` setup. 44 | - gcc, python3, pkg-config, cmake; protobuf, protoc, openssl, x11, xcb, freetype, fontconfig, expat, glib, gtk3, cairo, pango, atk, gdk_pixbuf libraries and development files. 45 | - Above list may be incomplete, please find out what you need by looking at compiler errors. 46 | 47 | ### Nix 48 | - `nix develop` to get a dev shell. (or `nix-shell nix/shell.nix` if you don't have flakes enabled) 49 | - `nix build .#loqui-debug` to compile a debug build. 50 | - `nix build .#loqui` to compile a release build. 51 | - If you don't have flakes enabled, `nix-build` will give you a release build. 52 | 53 | ## Installing 54 | 55 | ### Nix 56 | - For flakes: `nix profile install github:harmony-development/loqui` 57 | - For non-flakes: `nix-env -i -f "https://github.com/harmony-development/loqui/tarball/master"` 58 | 59 | [Harmony]: https://github.com/harmony-development 60 | [harmony-rust-sdk]: https://github.com/harmony-development/harmony_rust_sdk 61 | [egui]: https://github.com/emilk/egui -------------------------------------------------------------------------------- /Trunk.toml: -------------------------------------------------------------------------------- 1 | [[hooks]] 2 | stage = "post_build" 3 | command = "./write-cache-files.sh" -------------------------------------------------------------------------------- /build-aux/cargo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export MESON_BUILD_ROOT="$1" 4 | export MESON_SOURCE_ROOT="$2" 5 | export CARGO_TARGET_DIR="$MESON_BUILD_ROOT"/target 6 | export CARGO_HOME="$CARGO_TARGET_DIR"/cargo-home 7 | export OUTPUT="$3" 8 | export BUILDTYPE="$4" 9 | export APP_BIN="$5" 10 | 11 | 12 | if [[ $BUILDTYPE = "release" ]] 13 | then 14 | echo "RELEASE MODE" 15 | cargo build --manifest-path "$MESON_SOURCE_ROOT"/Cargo.toml --release 16 | cp "$CARGO_TARGET_DIR"/release/"$APP_BIN" "$OUTPUT" 17 | else 18 | echo "DEBUG MODE" 19 | cargo build --manifest-path "$MESON_SOURCE_ROOT"/Cargo.toml --verbose 20 | cp "$CARGO_TARGET_DIR"/debug/"$APP_BIN" "$OUTPUT" 21 | fi 22 | -------------------------------------------------------------------------------- /client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "client" 3 | version = "0.1.0" 4 | authors = ["Yusuf Bera Ertan "] 5 | edition = "2021" 6 | license = "GPLv3" 7 | repository = "https://github.com/harmony-development/loqui" 8 | homepage = "https://github.com/harmony-development/loqui" 9 | 10 | [dependencies] 11 | infer = "0.6.0" 12 | 13 | tokio = { version = "1.9", features = ["sync"] } 14 | ahash = "0.7" 15 | indexmap = "1.7.0" 16 | urlencoding = "2.0.0" 17 | 18 | serde = { version = "1.0", features = ["derive"] } 19 | serde_json = "1.0" 20 | chrono = "0.4.19" 21 | tracing = "0.1" 22 | smol_str = { version = "0.1.20", features = ["serde"] } 23 | 24 | instant = { version = "0.1", features = ["wasm-bindgen"] } 25 | lazy_static = "1" 26 | itertools = "0.10" 27 | 28 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 29 | directories-next = "2.0.0" 30 | harmony_rust_sdk = { git = "https://github.com/harmony-development/harmony_rust_sdk.git", branch = "master", features = [ 31 | "client_native", 32 | "client_backoff", 33 | "client_recommended", 34 | "all_permissions", 35 | ] } 36 | getrandom = "0.2" 37 | 38 | [target.'cfg(target_arch = "wasm32")'.dependencies] 39 | harmony_rust_sdk = { git = "https://github.com/harmony-development/harmony_rust_sdk.git", branch = "master", features = [ 40 | "client_web", 41 | "client_backoff", 42 | "client_recommended", 43 | "all_permissions", 44 | ] } 45 | gloo-storage = "0.2" 46 | getrandom = { version = "0.2", features = ["js"] } 47 | -------------------------------------------------------------------------------- /client/src/channel.rs: -------------------------------------------------------------------------------- 1 | use indexmap::IndexSet; 2 | 3 | use crate::role::RolePerms; 4 | 5 | use super::message::Messages; 6 | use harmony_rust_sdk::api::chat::{permission::has_permission, Permission}; 7 | use smol_str::SmolStr; 8 | 9 | #[derive(Default, Debug, Clone)] 10 | pub struct Channel { 11 | pub name: SmolStr, 12 | pub is_category: bool, 13 | pub messages: Messages, 14 | pub pinned_messages: IndexSet, 15 | pub reached_top: bool, 16 | pub perms: Vec, 17 | pub role_perms: RolePerms, 18 | pub fetched_msgs_pins: bool, 19 | } 20 | 21 | impl Channel { 22 | pub fn has_perm(&self, query: &str) -> bool { 23 | has_permission(self.perms.iter().map(|p| (p.matches.as_str(), p.ok)), query).unwrap_or(false) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/content.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | pub const MAX_THUMB_SIZE: u64 = 1000 * 500; // 500kb 4 | 5 | pub fn infer_type_from_bytes(data: &[u8]) -> String { 6 | infer::get(data) 7 | .map(|filetype| filetype.mime_type().to_string()) 8 | .unwrap_or_else(|| String::from("application/octet-stream")) 9 | } 10 | 11 | pub fn get_filename>(path: P) -> String { 12 | path.as_ref() 13 | .file_name() 14 | .map(|s| s.to_string_lossy().to_string()) 15 | .unwrap_or_else(|| String::from("unknown")) 16 | } 17 | 18 | #[cfg(not(target_arch = "wasm32"))] 19 | pub use native::*; 20 | #[cfg(target_arch = "wasm32")] 21 | pub use web::*; 22 | 23 | #[cfg(target_arch = "wasm32")] 24 | pub mod web { 25 | use gloo_storage::{LocalStorage, Storage}; 26 | use serde::{de::DeserializeOwned, Serialize}; 27 | 28 | use crate::Session; 29 | 30 | pub fn set_local_config(name: &str, val: &T) { 31 | let _ = ::set(name, val); 32 | } 33 | 34 | pub fn get_local_config(name: &str) -> Option { 35 | ::get(name).ok() 36 | } 37 | 38 | pub fn get_latest_session() -> Option { 39 | ::get("latest_session").ok() 40 | } 41 | 42 | pub fn put_session(session: Session) { 43 | let _ = ::set("latest_session", session); 44 | } 45 | 46 | pub fn delete_latest_session() { 47 | ::delete("latest_session") 48 | } 49 | } 50 | 51 | #[cfg(not(target_arch = "wasm32"))] 52 | pub mod native { 53 | use crate::{error::ClientError, Session}; 54 | use harmony_rust_sdk::api::rest::FileId; 55 | use serde::{de::DeserializeOwned, Serialize}; 56 | use std::path::{Path, PathBuf}; 57 | 58 | lazy_static::lazy_static! { 59 | static ref STORE: ContentStore = ContentStore::default(); 60 | } 61 | 62 | pub fn set_local_config(name: &str, val: &T) { 63 | let config_path = STORE.config_dir().join(name); 64 | let raw = serde_json::to_vec_pretty(val).expect("must be valid serde struct"); 65 | std::fs::write(config_path, raw).expect("failed to write"); 66 | } 67 | 68 | pub fn get_local_config(name: &str) -> Option { 69 | let config_path = STORE.config_dir().join(name); 70 | let raw = std::fs::read(config_path).ok()?; 71 | serde_json::from_slice(&raw).ok() 72 | } 73 | 74 | pub fn get_latest_session() -> Option { 75 | let session_raw = std::fs::read(STORE.latest_session_file()).ok()?; 76 | let session = serde_json::from_slice::(&session_raw) 77 | .map_err(|err| ClientError::Custom(err.to_string())) 78 | .ok()?; 79 | Some(session) 80 | } 81 | 82 | pub fn put_session(session: Session) { 83 | let serialized = serde_json::to_string_pretty(&session).expect("failed to serialize"); 84 | let _ = std::fs::write(STORE.latest_session_file(), serialized.into_bytes()); 85 | } 86 | 87 | pub fn delete_latest_session() { 88 | let _ = std::fs::remove_file(STORE.latest_session_file()); 89 | } 90 | 91 | pub const SESSIONS_DIR_NAME: &str = "sessions"; 92 | pub const LOG_FILENAME: &str = "log"; 93 | pub const CONTENT_DIR_NAME: &str = "content"; 94 | pub const CONFIG_DIR_NAME: &str = "config"; 95 | 96 | #[derive(Debug, Clone)] 97 | pub struct ContentStore { 98 | latest_session_file: PathBuf, 99 | sessions_dir: PathBuf, 100 | log_file: PathBuf, 101 | content_dir: PathBuf, 102 | config_dir: PathBuf, 103 | } 104 | 105 | impl Default for ContentStore { 106 | fn default() -> Self { 107 | let (sessions_dir, log_file, content_dir, config_dir) = 108 | match directories_next::ProjectDirs::from("nodomain", "yusdacra", "loqui") { 109 | Some(app_dirs) => ( 110 | app_dirs.data_dir().join(SESSIONS_DIR_NAME), 111 | app_dirs.data_dir().join(LOG_FILENAME), 112 | app_dirs.cache_dir().join(CONTENT_DIR_NAME), 113 | app_dirs.config_dir().to_path_buf(), 114 | ), 115 | // Fallback to current working directory if no HOME is present 116 | None => ( 117 | SESSIONS_DIR_NAME.into(), 118 | LOG_FILENAME.into(), 119 | CONTENT_DIR_NAME.into(), 120 | CONFIG_DIR_NAME.into(), 121 | ), 122 | }; 123 | 124 | Self { 125 | latest_session_file: sessions_dir.join("latest"), 126 | sessions_dir, 127 | log_file, 128 | content_dir, 129 | config_dir, 130 | } 131 | } 132 | } 133 | 134 | impl ContentStore { 135 | pub fn content_path(&self, id: &FileId) -> PathBuf { 136 | let id = id.to_string(); 137 | let normalized_id = urlencoding::encode(id.as_str()); 138 | self.content_dir().join(normalized_id.as_ref()) 139 | } 140 | 141 | pub fn content_mimetype(&self, id: &FileId) -> String { 142 | infer::get_from_path(self.content_path(id)) 143 | .ok() 144 | .flatten() 145 | .map(|filetype| filetype.mime_type().to_string()) 146 | .unwrap_or_else(|| String::from("application/octet-stream")) 147 | } 148 | 149 | pub fn content_exists(&self, id: &FileId) -> bool { 150 | self.content_path(id).exists() 151 | } 152 | 153 | pub fn create_req_dirs(&self) -> Result<(), ClientError> { 154 | use std::fs::create_dir_all; 155 | 156 | create_dir_all(self.content_dir())?; 157 | create_dir_all(self.sessions_dir())?; 158 | create_dir_all(self.log_file().parent().unwrap_or_else(|| Path::new(".")))?; 159 | create_dir_all(self.config_dir())?; 160 | 161 | Ok(()) 162 | } 163 | 164 | #[inline(always)] 165 | pub fn latest_session_file(&self) -> &Path { 166 | self.latest_session_file.as_path() 167 | } 168 | 169 | #[inline(always)] 170 | pub fn content_dir(&self) -> &Path { 171 | self.content_dir.as_path() 172 | } 173 | 174 | #[inline(always)] 175 | pub fn sessions_dir(&self) -> &Path { 176 | self.sessions_dir.as_path() 177 | } 178 | 179 | #[inline(always)] 180 | pub fn log_file(&self) -> &Path { 181 | self.log_file.as_path() 182 | } 183 | 184 | #[inline(always)] 185 | pub fn config_dir(&self) -> &Path { 186 | self.config_dir.as_path() 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /client/src/emotes.rs: -------------------------------------------------------------------------------- 1 | use ahash::AHashMap; 2 | use smol_str::SmolStr; 3 | 4 | #[derive(Debug, Clone, Default)] 5 | pub struct EmotePack { 6 | pub pack_owner: u64, 7 | pub pack_name: SmolStr, 8 | pub emotes: AHashMap, 9 | } 10 | -------------------------------------------------------------------------------- /client/src/error.rs: -------------------------------------------------------------------------------- 1 | use harmony_rust_sdk::{ 2 | api::exports::hrpc::exports::http::uri::{InvalidUri as UrlParseError, Uri}, 3 | client::error::{ClientError as InnerClientError, HmcParseError, InternalClientError}, 4 | }; 5 | use std::{ 6 | error::Error, 7 | fmt::{self, Display}, 8 | }; 9 | 10 | pub type ClientResult = Result; 11 | 12 | #[derive(Debug)] 13 | pub enum ClientError { 14 | /// Error occurred during an IO operation. 15 | IoError(std::io::Error), 16 | /// Error occurred while parsing a string as URL. 17 | UrlParse(String, UrlParseError), 18 | /// Error occurred while parsing an URL as HMC. 19 | HmcParse(Uri, HmcParseError), 20 | /// Error occurred in the Harmony client library. 21 | Internal(InnerClientError), 22 | /// The user is already logged in. 23 | AlreadyLoggedIn, 24 | /// Not all required login information was provided. 25 | MissingLoginInfo, 26 | /// Custom error 27 | Custom(String), 28 | } 29 | 30 | impl ClientError { 31 | pub fn is_error_code(&self, code: &str) -> bool { 32 | if let ClientError::Internal(InnerClientError::Internal(InternalClientError::EndpointError { 33 | hrpc_error, 34 | .. 35 | })) = self 36 | { 37 | hrpc_error.identifier == code 38 | } else { 39 | false 40 | } 41 | } 42 | } 43 | 44 | impl Clone for ClientError { 45 | fn clone(&self) -> Self { 46 | use ClientError::*; 47 | 48 | match self { 49 | AlreadyLoggedIn => AlreadyLoggedIn, 50 | MissingLoginInfo => MissingLoginInfo, 51 | Custom(err) => Custom(err.clone()), 52 | _ => Custom(self.to_string()), 53 | } 54 | } 55 | } 56 | 57 | impl From for ClientError { 58 | fn from(other: std::io::Error) -> Self { 59 | Self::IoError(other) 60 | } 61 | } 62 | 63 | impl From for ClientError { 64 | fn from(other: InnerClientError) -> Self { 65 | Self::Internal(other) 66 | } 67 | } 68 | 69 | impl From for ClientError { 70 | fn from(other: InternalClientError) -> Self { 71 | Self::Internal(InnerClientError::Internal(other)) 72 | } 73 | } 74 | 75 | impl Display for ClientError { 76 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 77 | match self { 78 | ClientError::HmcParse(url, err) => { 79 | write!(fmt, "Could not parse URL '{}' as HMC: {}", url, err) 80 | } 81 | ClientError::UrlParse(string, err) => { 82 | write!(fmt, "Could not parse string '{}' as URL: {}", string, err) 83 | } 84 | ClientError::Internal(err) => { 85 | if let InnerClientError::Internal( 86 | harmony_rust_sdk::api::exports::hrpc::client::error::ClientError::EndpointError { 87 | hrpc_error: err, 88 | endpoint, 89 | }, 90 | ) = err 91 | { 92 | write!( 93 | fmt, 94 | "(`{}`) API error: {} | {}", 95 | endpoint, 96 | err.identifier.replace('\n', " "), 97 | err.human_message.replace('\n', " ") 98 | ) 99 | } else { 100 | write!(fmt, "{}", err) 101 | } 102 | } 103 | ClientError::IoError(err) => write!(fmt, "An IO error occurred: {}", err), 104 | ClientError::AlreadyLoggedIn => write!(fmt, "Already logged in with another user."), 105 | ClientError::MissingLoginInfo => { 106 | write!(fmt, "Missing required login information, can't login.") 107 | } 108 | ClientError::Custom(msg) => write!(fmt, "{}", msg), 109 | } 110 | } 111 | } 112 | 113 | impl Error for ClientError { 114 | fn source(&self) -> Option<&(dyn Error + 'static)> { 115 | match self { 116 | ClientError::Internal(err) => Some(err), 117 | ClientError::IoError(err) => Some(err), 118 | _ => None, 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /client/src/guild.rs: -------------------------------------------------------------------------------- 1 | use ahash::AHashMap; 2 | use harmony_rust_sdk::api::{ 3 | chat::{permission::has_permission, Invite, Permission}, 4 | harmonytypes::{item_position::Position, ItemPosition}, 5 | rest::FileId, 6 | }; 7 | use smol_str::SmolStr; 8 | 9 | use crate::role::{Role, RolePerms, Roles}; 10 | 11 | #[derive(Debug, Clone, Default)] 12 | pub struct Guild { 13 | pub name: SmolStr, 14 | pub owners: Vec, 15 | pub picture: Option, 16 | pub channels: Vec, 17 | pub roles: Roles, 18 | pub role_perms: RolePerms, 19 | pub members: AHashMap>, 20 | pub homeserver: SmolStr, 21 | pub perms: Vec, 22 | pub invites: AHashMap, 23 | pub fetched: bool, 24 | pub fetched_invites: bool, 25 | } 26 | 27 | impl Guild { 28 | pub fn has_perm(&self, query: &str) -> bool { 29 | has_permission(self.perms.iter().map(|p| (p.matches.as_str(), p.ok)), query).unwrap_or(false) 30 | } 31 | 32 | pub fn update_channel_order(&mut self, position: ItemPosition, id: u64) { 33 | let ordering = &mut self.channels; 34 | 35 | let maybe_ord_index = |id: u64| ordering.iter().position(|oid| id.eq(oid)); 36 | let maybe_replace_with = |ordering: &mut Vec, index| { 37 | ordering.insert(index, 0); 38 | if let Some(channel_index) = ordering.iter().position(|oid| id.eq(oid)) { 39 | ordering.remove(channel_index); 40 | } 41 | *ordering.iter_mut().find(|oid| 0.eq(*oid)).unwrap() = id; 42 | }; 43 | 44 | let item_id = position.item_id; 45 | match position.position() { 46 | Position::After => { 47 | if let Some(index) = maybe_ord_index(item_id) { 48 | maybe_replace_with(ordering, index.saturating_add(1)); 49 | } 50 | } 51 | Position::BeforeUnspecified => { 52 | if let Some(index) = maybe_ord_index(item_id) { 53 | maybe_replace_with(ordering, index); 54 | } 55 | } 56 | } 57 | } 58 | 59 | pub fn update_role_order(&mut self, position: ItemPosition, role_id: u64) { 60 | let map = &mut self.roles; 61 | if let (Some(item_pos), Some(pos)) = (map.get_index_of(&role_id), map.get_index_of(&position.item_id)) { 62 | match position.position() { 63 | Position::BeforeUnspecified => { 64 | let pos = pos + 1; 65 | if pos != item_pos && pos < map.len() { 66 | map.swap_indices(pos, item_pos); 67 | } 68 | } 69 | Position::After => { 70 | if pos != 0 { 71 | map.swap_indices(pos - 1, item_pos); 72 | } else { 73 | let (k, v) = map.pop().unwrap(); 74 | map.reverse(); 75 | map.insert(k, v); 76 | map.reverse(); 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | pub fn highest_role_for_member(&self, user_id: u64) -> Option<(&u64, &Role)> { 84 | self.members 85 | .get(&user_id) 86 | .and_then(|role_ids| self.roles.iter().find(|(id, _)| role_ids.contains(id))) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /client/src/member.rs: -------------------------------------------------------------------------------- 1 | use instant::Instant; 2 | 3 | use harmony_rust_sdk::api::{ 4 | profile::{AccountKind, UserStatus}, 5 | rest::FileId, 6 | }; 7 | use smol_str::SmolStr; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct Member { 11 | pub avatar_url: Option, 12 | pub username: SmolStr, 13 | pub display_user: bool, 14 | pub typing_in_channel: Option<(u64, u64, Instant)>, 15 | pub status: UserStatus, 16 | pub fetched: bool, 17 | pub kind: AccountKind, 18 | } 19 | 20 | impl Default for Member { 21 | fn default() -> Self { 22 | Self { 23 | avatar_url: None, 24 | username: SmolStr::default(), 25 | display_user: true, 26 | typing_in_channel: None, 27 | status: UserStatus::OfflineUnspecified, 28 | fetched: false, 29 | kind: AccountKind::FullUnspecified, 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/role.rs: -------------------------------------------------------------------------------- 1 | use ahash::AHashMap; 2 | use harmony_rust_sdk::api::chat::{color, Permission, Role as HarmonyRole}; 3 | use smol_str::SmolStr; 4 | 5 | use crate::IndexMap; 6 | 7 | pub type Roles = IndexMap; 8 | pub type RolePerms = AHashMap>; 9 | 10 | #[derive(Debug, Default, Clone)] 11 | pub struct Role { 12 | pub name: SmolStr, 13 | pub color: [u8; 3], 14 | pub hoist: bool, 15 | pub pingable: bool, 16 | } 17 | 18 | impl From for HarmonyRole { 19 | fn from(r: Role) -> Self { 20 | HarmonyRole { 21 | name: r.name.into(), 22 | hoist: r.hoist, 23 | pingable: r.pingable, 24 | color: color::encode_rgb(r.color), 25 | } 26 | } 27 | } 28 | 29 | impl From for Role { 30 | fn from(role: HarmonyRole) -> Self { 31 | Self { 32 | name: role.name.into(), 33 | hoist: role.hoist, 34 | pingable: role.pingable, 35 | color: color::decode_rgb(role.color), 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /com.github.HarmonyDevelopment.Loqui.yml: -------------------------------------------------------------------------------- 1 | app-id: com.github.HarmonyDevelopment.Loqui 2 | runtime: org.freedesktop.Platform 3 | runtime-version: '20.08' 4 | sdk: org.freedesktop.Sdk 5 | sdk-extensions: 6 | - org.freedesktop.Sdk.Extension.rust-nightly 7 | command: loqui 8 | finish-args: 9 | - --share=ipc 10 | - --share=network 11 | - --socket=fallback-x11 12 | - --socket=wayland 13 | - --device=dri 14 | build-options: 15 | append-path: /usr/lib/sdk/rust-nightly/bin 16 | build-args: 17 | - --share=network 18 | env: 19 | CARGO_HOME: /run/build/rust-flatpak/cargo 20 | modules: 21 | - name: loqui 22 | buildsystem: meson 23 | sources: 24 | - type: git 25 | url: https://github.com/harmony-development/Loqui.git 26 | branch: master 27 | -------------------------------------------------------------------------------- /data/com.github.HarmonyDevelopment.Loqui.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Loqui 3 | Comment=Desktop client for Harmony 4 | Type=Application 5 | Exec=loqui 6 | Terminal=Application 7 | Terminal=false 8 | Categories=Network 9 | Keywords=Harmony;Chat 10 | Icon=com.github.HarmonyDevelopment.Loqui 11 | -------------------------------------------------------------------------------- /data/com.github.HarmonyDevelopment.Loqui.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | com.github.HarmonyDevelopment.Loqui 5 | CC0 6 | GPL-3.0 7 | Loqui 8 | Desktop client for Harmony chat 9 | 10 |

Loqui is a Rust desktop client for the Harmony chat protocol.

11 |
12 | https://github.com/harmony-development/loqui 13 | 14 | intense 15 | mild 16 | intense 17 | intense 18 | 19 | 20 | ModernToolkit 21 | HiDpiIcon 22 | 23 | Yusuf Bera Ertan 24 | y.bera003.06@protonmail.com 25 | com.github.HarmonyDevelopment.Loqui.desktop 26 |
27 | -------------------------------------------------------------------------------- /data/com.github.HarmonyDevelopment.Loqui.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /data/loqui.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harmony-development/Loqui/00ef033580884c86f189fc1b97cb0ed70e1aa157/data/loqui.ico -------------------------------------------------------------------------------- /data/loqui.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /data/loqui.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harmony-development/Loqui/00ef033580884c86f189fc1b97cb0ed70e1aa157/data/loqui.webp -------------------------------------------------------------------------------- /data/lotus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harmony-development/Loqui/00ef033580884c86f189fc1b97cb0ed70e1aa157/data/lotus.png -------------------------------------------------------------------------------- /data/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Loqui", 3 | "short_name": "loqui", 4 | "theme_color": "#ec943c", 5 | "background_color": "#151613", 6 | "display": "standalone", 7 | "orientation": "landscape", 8 | "scope": "/", 9 | "start_url": "/", 10 | "icons": [ 11 | { 12 | "src": "/loqui.webp", 13 | "sizes": "144x144" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | datadir = get_option('prefix') / get_option('datadir') 2 | 3 | app_id = 'com.github.HarmonyDevelopment.Loqui' 4 | 5 | install_data( 6 | '@0@.desktop'.format(app_id), 7 | install_dir: datadir / 'applications' 8 | ) 9 | 10 | install_data( 11 | '@0@.metainfo.xml'.format(app_id), 12 | install_dir: datadir / 'metainfo' 13 | ) 14 | 15 | install_data( 16 | '@0@.svg'.format(app_id), 17 | install_dir: datadir / 'icons' / 'hicolor' / 'scalable' / 'apps' 18 | ) 19 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devshell": { 4 | "inputs": { 5 | "flake-utils": "flake-utils", 6 | "nixpkgs": [ 7 | "nixCargoIntegration", 8 | "nixpkgs" 9 | ] 10 | }, 11 | "locked": { 12 | "lastModified": 1644227066, 13 | "narHash": "sha256-FHcFZtpZEWnUh62xlyY3jfXAXHzJNEDLDzLsJxn+ve0=", 14 | "owner": "numtide", 15 | "repo": "devshell", 16 | "rev": "7033f64dd9ef8d9d8644c5030c73913351d2b660", 17 | "type": "github" 18 | }, 19 | "original": { 20 | "owner": "numtide", 21 | "repo": "devshell", 22 | "type": "github" 23 | } 24 | }, 25 | "flake-utils": { 26 | "locked": { 27 | "lastModified": 1642700792, 28 | "narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=", 29 | "owner": "numtide", 30 | "repo": "flake-utils", 31 | "rev": "846b2ae0fc4cc943637d3d1def4454213e203cba", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "numtide", 36 | "repo": "flake-utils", 37 | "type": "github" 38 | } 39 | }, 40 | "flakeCompat": { 41 | "flake": false, 42 | "locked": { 43 | "lastModified": 1641205782, 44 | "narHash": "sha256-4jY7RCWUoZ9cKD8co0/4tFARpWB+57+r1bLLvXNJliY=", 45 | "owner": "edolstra", 46 | "repo": "flake-compat", 47 | "rev": "b7547d3eed6f32d06102ead8991ec52ab0a4f1a7", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "edolstra", 52 | "repo": "flake-compat", 53 | "type": "github" 54 | } 55 | }, 56 | "nixCargoIntegration": { 57 | "inputs": { 58 | "devshell": "devshell", 59 | "nixpkgs": [ 60 | "nixpkgs" 61 | ], 62 | "rustOverlay": "rustOverlay" 63 | }, 64 | "locked": { 65 | "lastModified": 1645769477, 66 | "narHash": "sha256-fha6sgL7qezlAWD7e9HpGH7oPphYvOQ4fhWxfSyryA0=", 67 | "owner": "yusdacra", 68 | "repo": "nix-cargo-integration", 69 | "rev": "00e5378c73c0594304195882ab6eeb5118252484", 70 | "type": "github" 71 | }, 72 | "original": { 73 | "owner": "yusdacra", 74 | "repo": "nix-cargo-integration", 75 | "type": "github" 76 | } 77 | }, 78 | "nixpkgs": { 79 | "locked": { 80 | "lastModified": 1645433236, 81 | "narHash": "sha256-4va4MvJ076XyPp5h8sm5eMQvCrJ6yZAbBmyw95dGyw4=", 82 | "owner": "NixOS", 83 | "repo": "nixpkgs", 84 | "rev": "7f9b6e2babf232412682c09e57ed666d8f84ac2d", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "NixOS", 89 | "ref": "nixos-unstable", 90 | "repo": "nixpkgs", 91 | "type": "github" 92 | } 93 | }, 94 | "root": { 95 | "inputs": { 96 | "flakeCompat": "flakeCompat", 97 | "nixCargoIntegration": "nixCargoIntegration", 98 | "nixpkgs": "nixpkgs" 99 | } 100 | }, 101 | "rustOverlay": { 102 | "flake": false, 103 | "locked": { 104 | "lastModified": 1645755566, 105 | "narHash": "sha256-BwjpcywzB+4hHuStgYcOWRomI8I2PCtORUbNEL6qMBk=", 106 | "owner": "oxalica", 107 | "repo": "rust-overlay", 108 | "rev": "46d8d20fce510c6a25fa66f36e31f207f6ea49e4", 109 | "type": "github" 110 | }, 111 | "original": { 112 | "owner": "oxalica", 113 | "repo": "rust-overlay", 114 | "type": "github" 115 | } 116 | } 117 | }, 118 | "root": "root", 119 | "version": 7 120 | } 121 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | flakeCompat = { 4 | url = "github:edolstra/flake-compat"; 5 | flake = false; 6 | }; 7 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 8 | nixCargoIntegration = { 9 | url = "github:yusdacra/nix-cargo-integration"; 10 | inputs.nixpkgs.follows = "nixpkgs"; 11 | }; 12 | /*androidPkgs = { 13 | url = "github:tadfisher/android-nixpkgs"; 14 | inputs.nixpkgs.follows = "nixpkgs"; 15 | };*/ 16 | }; 17 | 18 | outputs = inputs: 19 | let 20 | outputs = inputs.nixCargoIntegration.lib.makeOutputs { 21 | root = ./.; 22 | buildPlatform = "crate2nix"; 23 | overrides = { 24 | pkgs = common: prev: { 25 | overlays = prev.overlays ++ [ 26 | (_: prev: { 27 | android-sdk = inputs.androidPkgs.sdk.${prev.system} (sdkPkgs: with sdkPkgs; [ 28 | cmdline-tools-latest 29 | build-tools-32-0-0 30 | platform-tools 31 | platforms-android-32 32 | emulator 33 | ndk-bundle 34 | ]); 35 | }) 36 | (_: prev: { 37 | trunk = prev.nciUtils.buildCrate { 38 | root = builtins.fetchGit { 39 | url = "https://github.com/kristoff3r/trunk.git"; 40 | ref = "rust_worker"; 41 | rev = "0ff1842640553dfefcf0e0b13aee619b17916844"; 42 | }; 43 | release = true; 44 | }; 45 | twiggy = prev.nciUtils.buildCrate { 46 | root = builtins.fetchGit { 47 | url = "https://github.com/rustwasm/twiggy.git"; 48 | ref = "master"; 49 | rev = "195feee4045f0b89d7cba7492900131ac89803dd"; 50 | }; 51 | memberName = "twiggy"; 52 | release = true; 53 | }; 54 | }) 55 | ]; 56 | }; 57 | crateOverrides = common: _: { 58 | loqui = prev: { 59 | nativeBuildInputs = (prev.nativeBuildInputs or [ ]) ++ (with common.pkgs; [ makeWrapper wrapGAppsHook ]); 60 | postInstall = with common.pkgs; '' 61 | if [ -f $out/bin/loqui ]; then 62 | wrapProgram $out/bin/loqui\ 63 | --set LD_LIBRARY_PATH ${lib.makeLibraryPath common.runtimeLibs}\ 64 | --set XDG_DATA_DIRS ${hicolor-icon-theme}/share:${gnome3.adwaita-icon-theme}/share 65 | fi 66 | ''; 67 | }; 68 | }; 69 | shell = common: prev: with common.pkgs; { 70 | #packages = [ android-sdk ]; 71 | env = prev.env ++ [ 72 | { 73 | name = "XDG_DATA_DIRS"; 74 | eval = "$GSETTINGS_SCHEMAS_PATH:$XDG_DATA_DIRS:${hicolor-icon-theme}/share:${gnome3.adwaita-icon-theme}/share"; 75 | } 76 | /*{ 77 | name = "ANDROID_HOME"; 78 | value = "${android-sdk}/share/android-sdk"; 79 | } 80 | { 81 | name = "ANDROID_SDK_ROOT"; 82 | value = "${android-sdk}/share/android-sdk"; 83 | } 84 | { 85 | name = "JAVA_HOME"; 86 | value = jdk11.home; 87 | }*/ 88 | ]; 89 | commands = prev.commands ++ [ 90 | { 91 | name = "local-dev"; 92 | command = "SSL_CERT_FILE=~/.local/share/mkcert/rootCA.pem cargo r"; 93 | } 94 | { 95 | help = "Build for the web."; 96 | package = trunk; 97 | } 98 | { 99 | help = "Profile binary size."; 100 | package = twiggy; 101 | } 102 | { 103 | name = "cargo-mobile"; 104 | help = "Build for mobile."; 105 | command = "$HOME/.cargo/bin/cargo-mobile $@"; 106 | } 107 | ]; 108 | }; 109 | }; 110 | }; 111 | in 112 | outputs // { 113 | apps = outputs.apps // { 114 | x86_64-linux = outputs.apps.x86_64-linux // { 115 | run-latest = 116 | let 117 | pkgs = import inputs.nixpkgs { system = "x86_64-linux"; config = { allowUnfree = true; }; }; 118 | cmd = 119 | pkgs.writeScriptBin "run-loqui-latest" '' 120 | #!${pkgs.stdenv.shell} 121 | mkdir -p /tmp/loqui-binary 122 | cd /tmp/loqui-binary 123 | ${pkgs.curl}/bin/curl -L https://github.com/harmony-development/Loqui/releases/download/continuous/loqui-linux > loqui 124 | chmod +x loqui 125 | ${pkgs.steam-run}/bin/steam-run ./loqui 126 | ''; 127 | in 128 | { 129 | type = "app"; 130 | program = "${cmd}/bin/run-loqui-latest"; 131 | }; 132 | }; 133 | }; 134 | }; 135 | } 136 | -------------------------------------------------------------------------------- /image_worker/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "image_worker" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tracing = "0.1" 8 | 9 | [target.'cfg(target_arch = "wasm32")'.dependencies] 10 | wasm-bindgen = "0.2" 11 | js-sys = { version = "0.3" } 12 | web-sys = { version = "0.3", features = [ 13 | "WorkerGlobalScope", 14 | "DedicatedWorkerGlobalScope", 15 | "MessageEvent", 16 | ] } 17 | rkyv = "0.7" 18 | image = { git = "https://github.com/image-rs/image.git", branch = "master", default-features = false, features = [ 19 | "gif", 20 | "jpeg", 21 | "ico", 22 | "png", 23 | "tiff", 24 | "webp", 25 | ] } 26 | 27 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 28 | image = { git = "https://github.com/image-rs/image.git", branch = "master", default-features = false, features = [ 29 | "gif", 30 | "jpeg", 31 | "ico", 32 | "png", 33 | "tiff", 34 | "webp", 35 | "jpeg_rayon", 36 | ] } 37 | -------------------------------------------------------------------------------- /image_worker/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(let_else)] 2 | 3 | use image::{DynamicImage, GenericImageView}; 4 | #[cfg(target_arch = "wasm32")] 5 | use rkyv::{Archive, Deserialize, Serialize}; 6 | 7 | #[cfg_attr(target_arch = "wasm32", derive(Archive, Deserialize, Serialize))] 8 | pub struct ImageLoaded { 9 | pub pixels: Vec, 10 | pub dimensions: [usize; 2], 11 | pub kind: String, 12 | pub id: String, 13 | } 14 | 15 | #[cfg(target_arch = "wasm32")] 16 | #[derive(Archive, Deserialize, Serialize)] 17 | pub struct ImageData { 18 | pub data: Vec, 19 | pub kind: String, 20 | pub id: String, 21 | } 22 | 23 | #[cfg(target_arch = "wasm32")] 24 | pub fn load_image(data: Vec) -> Vec { 25 | #[allow(unsafe_code)] 26 | let image_data = unsafe { rkyv::archived_root::(&data) }; 27 | let Some(mut loaded) = load_image_logic(image_data.data.as_ref(), image_data.kind.as_str()) else { 28 | tracing::error!( 29 | "could not load an image (id {}); most likely unsupported format", 30 | image_data.id 31 | ); 32 | return Vec::new(); 33 | }; 34 | loaded.kind = image_data.kind.to_string(); 35 | loaded.id = image_data.id.to_string(); 36 | 37 | rkyv::to_bytes::<_, 2048>(&loaded).unwrap().into() 38 | } 39 | 40 | pub fn load_image_logic(data: &[u8], kind: &str) -> Option { 41 | let modify = match kind { 42 | "minithumbnail" => |image: DynamicImage| image.blur(4.0), 43 | "guild" | "avatar" => |image: DynamicImage| image.resize(96, 96, image::imageops::FilterType::Lanczos3), 44 | _ => |image: DynamicImage| { 45 | if image.dimensions().0 > 1280 || image.dimensions().1 > 720 { 46 | image.resize(1280, 720, image::imageops::FilterType::Triangle) 47 | } else { 48 | image 49 | } 50 | }, 51 | }; 52 | 53 | let image = image::load_from_memory(data).ok()?; 54 | let image = modify(image); 55 | let image = image.to_rgba8(); 56 | 57 | let dimensions = [image.width() as usize, image.height() as usize]; 58 | 59 | Some(ImageLoaded { 60 | pixels: image.into_vec(), 61 | dimensions, 62 | id: String::new(), 63 | kind: String::new(), 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /image_worker/src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_arch = "wasm32")] 2 | mod op { 3 | use js_sys::Uint8Array; 4 | use wasm_bindgen::{prelude::*, JsCast}; 5 | use web_sys::{DedicatedWorkerGlobalScope, MessageEvent}; 6 | 7 | use image_worker::load_image; 8 | 9 | pub fn main() { 10 | let worker_scope: DedicatedWorkerGlobalScope = 11 | js_sys::eval("self").expect_throw("cant get self").unchecked_into(); 12 | 13 | let handler = { 14 | let worker_scope = worker_scope.clone(); 15 | 16 | Closure::wrap(Box::new(move |event: MessageEvent| { 17 | let data: Uint8Array = event.data().unchecked_into(); 18 | let data: Vec = load_image(data.to_vec()); 19 | let result = Uint8Array::new_with_length(data.len() as u32); 20 | unsafe { 21 | result.set(&Uint8Array::view(&data), 0); 22 | } 23 | 24 | worker_scope.post_message(&result).expect_throw("can't send message"); 25 | }) as Box) 26 | }; 27 | 28 | worker_scope.set_onmessage(Some(handler.as_ref().unchecked_ref())); 29 | 30 | handler.forget(); 31 | } 32 | } 33 | 34 | #[cfg(not(target_arch = "wasm32"))] 35 | mod op { 36 | pub fn main() {} 37 | } 38 | 39 | fn main() { 40 | op::main(); 41 | } 42 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | loqui 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 55 | 56 | 57 | 58 | 59 | 60 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('loqui', 'rust', version: '0.1.0') 2 | 3 | subdir('data') 4 | subdir('src') 5 | -------------------------------------------------------------------------------- /nix/default.nix: -------------------------------------------------------------------------------- 1 | # Flake's default package for non-flake-enabled nix instances 2 | (import 3 | ( 4 | let lock = builtins.fromJSON (builtins.readFile ../flake.lock); 5 | in 6 | fetchTarball { 7 | url = 8 | "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flakeCompat.locked.rev}.tar.gz"; 9 | sha256 = lock.nodes.flakeCompat.locked.narHash; 10 | } 11 | ) 12 | { src = ./..; }).defaultNix.default 13 | -------------------------------------------------------------------------------- /nix/shell.nix: -------------------------------------------------------------------------------- 1 | # Flake's devShell for non-flake-enabled nix instances 2 | (import 3 | ( 4 | let lock = builtins.fromJSON (builtins.readFile ../flake.lock); 5 | in 6 | fetchTarball { 7 | url = 8 | "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flakeCompat.locked.rev}.tar.gz"; 9 | sha256 = lock.nodes.flakeCompat.locked.narHash; 10 | } 11 | ) 12 | { src = ./..; }).shellNix.default 13 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly-2022-01-17" 3 | targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] 4 | components = ["rust-src"] -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | reorder_imports = true 2 | reorder_modules = true 3 | merge_derives = true 4 | max_width = 120 -------------------------------------------------------------------------------- /screenshots/guilds.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harmony-development/Loqui/00ef033580884c86f189fc1b97cb0ed70e1aa157/screenshots/guilds.jpg -------------------------------------------------------------------------------- /screenshots/main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harmony-development/Loqui/00ef033580884c86f189fc1b97cb0ed70e1aa157/screenshots/main.jpg -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | nix/shell.nix -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Not; 2 | 3 | use client::{content, Client}; 4 | use eframe::{ 5 | egui::{self, vec2, Color32, FontData, FontDefinitions, Style, Ui}, 6 | epi, 7 | }; 8 | use egui::Margin; 9 | 10 | use super::utils::*; 11 | 12 | use crate::{ 13 | screen::{auth, ScreenStack}, 14 | state::State, 15 | style as loqui_style, 16 | widgets::{view_egui_settings, About}, 17 | }; 18 | 19 | pub struct App { 20 | state: State, 21 | screens: ScreenStack, 22 | show_errors_window: bool, 23 | show_about_window: bool, 24 | show_egui_debug: bool, 25 | } 26 | 27 | impl App { 28 | #[must_use] 29 | #[allow(clippy::missing_panics_doc)] 30 | #[allow(clippy::new_without_default)] 31 | pub fn new() -> Self { 32 | Self { 33 | state: State::new(), 34 | screens: ScreenStack::new(auth::Screen::new()), 35 | show_errors_window: false, 36 | show_about_window: false, 37 | show_egui_debug: false, 38 | } 39 | } 40 | 41 | fn view_connection_status(&mut self, ui: &mut Ui) { 42 | let is_connected = self.state.is_connected; 43 | let is_reconnecting = self.state.connecting_socket; 44 | 45 | let (connection_status_color, text_color) = if is_connected { 46 | (Color32::GREEN, Color32::BLACK) 47 | } else if is_reconnecting { 48 | (Color32::YELLOW, Color32::BLACK) 49 | } else { 50 | (Color32::RED, Color32::WHITE) 51 | }; 52 | 53 | egui::Frame::none().fill(connection_status_color).show(ui, |ui| { 54 | ui.style_mut().visuals.override_text_color = Some(text_color); 55 | ui.style_mut().visuals.widgets.active.fg_stroke.color = text_color; 56 | 57 | if is_connected { 58 | ui.label("✓ connected"); 59 | } else if is_reconnecting { 60 | ui.add(egui::Spinner::new().size(12.0)); 61 | ui.label("reconnecting"); 62 | } else { 63 | let resp = ui.label("X disconnected"); 64 | let last_retry_passed = self 65 | .state 66 | .last_socket_retry 67 | .map(|ins| format!("retrying in {}", ins.elapsed().as_secs())); 68 | if let Some(text) = last_retry_passed { 69 | resp.on_hover_text(text); 70 | } 71 | } 72 | }); 73 | } 74 | 75 | #[inline(always)] 76 | fn view_bottom_panel(&mut self, ui: &mut Ui, _frame: &epi::Frame) { 77 | ui.horizontal_top(|ui| { 78 | ui.style_mut().spacing.item_spacing = vec2(2.0, 0.0); 79 | 80 | self.view_connection_status(ui); 81 | 82 | let is_mobile = ui.ctx().is_mobile(); 83 | 84 | if is_mobile.not() { 85 | if cfg!(debug_assertions) { 86 | egui::Frame::none().fill(Color32::RED).show(ui, |ui| { 87 | ui.colored_label(Color32::BLACK, "⚠ Debug build ⚠") 88 | .on_hover_text("egui was compiled with debug assertions enabled."); 89 | }); 90 | } 91 | 92 | if self.state.latest_errors.is_empty().not() { 93 | let new_errors_but = ui 94 | .add(egui::Button::new(dangerous_text("new errors")).small()) 95 | .on_hover_text("show errors"); 96 | if new_errors_but.clicked() { 97 | self.show_errors_window = true; 98 | } 99 | } else { 100 | ui.label("no errors"); 101 | } 102 | } 103 | 104 | let show_back_button = matches!(self.screens.current().id(), "main" | "auth").not(); 105 | if show_back_button { 106 | ui.offsetw(140.0); 107 | if ui.button("<- back").on_hover_text("go back").clicked() { 108 | self.state.pop_screen(); 109 | } 110 | } 111 | 112 | if show_back_button.not() { 113 | ui.offsetw(80.0); 114 | } 115 | 116 | ui.vertical_centered_justified(|ui| { 117 | ui.menu_button("▼ menu", |ui| { 118 | if ui.button("about server").clicked() { 119 | self.show_about_window = true; 120 | ui.close_menu(); 121 | } 122 | 123 | if ui.ctx().is_mobile().not() && ui.button("settings").clicked() { 124 | self.state 125 | .push_screen(super::screen::settings::Screen::new(ui.ctx(), &self.state)); 126 | ui.close_menu(); 127 | } 128 | 129 | if ui.button("logout").clicked() { 130 | self.screens.clear(super::screen::auth::Screen::new()); 131 | let client = self.state.client.take().expect("no logout"); 132 | self.state.reset_socket_state(); 133 | self.state.futures.spawn(async move { client.logout().await }); 134 | ui.close_menu(); 135 | } 136 | 137 | #[cfg(not(target_arch = "wasm32"))] 138 | if ui.button("exit loqui").clicked() { 139 | _frame.quit(); 140 | ui.close_menu(); 141 | } 142 | 143 | if ui.button("egui debug").clicked() { 144 | self.show_egui_debug = true; 145 | ui.close_menu(); 146 | } 147 | }); 148 | }); 149 | }); 150 | } 151 | 152 | #[inline(always)] 153 | fn view_errors_window(&mut self, ctx: &egui::Context) { 154 | let latest_errors = &mut self.state.latest_errors; 155 | egui::Window::new("last error") 156 | .open(&mut self.show_errors_window) 157 | .show(ctx, |ui| { 158 | ui.horizontal(|ui| { 159 | if ui.button("clear").clicked() { 160 | latest_errors.clear(); 161 | } 162 | if ui.button("copy all").clicked() { 163 | let errors_concatted = latest_errors.iter().fold(String::new(), |mut all, error| { 164 | all.push('\n'); 165 | all.push_str(error); 166 | all 167 | }); 168 | ui.output().copied_text = errors_concatted; 169 | } 170 | }); 171 | egui::ScrollArea::vertical().show(ui, |ui| { 172 | let errors_len = latest_errors.len(); 173 | for (index, error) in latest_errors.iter().enumerate() { 174 | ui.label(error); 175 | if index != errors_len - 1 { 176 | ui.separator(); 177 | } 178 | } 179 | }); 180 | }); 181 | } 182 | 183 | #[inline(always)] 184 | fn view_about_window(&mut self, ctx: &egui::Context) { 185 | let Some(about) = self.state.about.as_ref() else { return }; 186 | 187 | egui::Window::new("about server") 188 | .open(&mut self.show_about_window) 189 | .show(ctx, |ui| { 190 | egui::ScrollArea::vertical().show(ui, |ui| { 191 | ui.add(About::new(about.clone())); 192 | }); 193 | }); 194 | } 195 | 196 | #[inline(always)] 197 | fn view_egui_debug_window(&mut self, ctx: &egui::Context) { 198 | egui::Window::new("egui debug") 199 | .open(&mut self.show_egui_debug) 200 | .show(ctx, |ui| { 201 | egui::ScrollArea::vertical().show(ui, |ui| { 202 | view_egui_settings(ctx, ui); 203 | }); 204 | }); 205 | } 206 | } 207 | 208 | impl epi::App for App { 209 | fn name(&self) -> &str { 210 | "loqui" 211 | } 212 | 213 | fn setup(&mut self, ctx: &egui::Context, frame: &epi::Frame, _storage: Option<&dyn epi::Storage>) { 214 | self.state.init(ctx, frame); 215 | ctx.set_pixels_per_point(self.state.local_config.scale_factor); 216 | 217 | self.state.futures.spawn(async move { 218 | let Some(session) = Client::read_latest_session().await else { return Ok(None) }; 219 | 220 | Client::new(session.homeserver.parse().unwrap(), Some(session.into())) 221 | .await 222 | .map(Some) 223 | }); 224 | 225 | let mut font_defs = FontDefinitions::default(); 226 | font_defs.font_data.insert( 227 | "inter".to_string(), 228 | FontData::from_static(include_bytes!("fonts/Inter.otf")), 229 | ); 230 | font_defs.font_data.insert( 231 | "hack".to_string(), 232 | FontData::from_static(include_bytes!("fonts/Hack-Regular.ttf")), 233 | ); 234 | font_defs.font_data.insert( 235 | "emoji-icon-font".to_string(), 236 | FontData::from_static(include_bytes!("fonts/emoji-icon-font.ttf")), 237 | ); 238 | 239 | font_defs.families.insert( 240 | egui::FontFamily::Proportional, 241 | vec!["inter".to_string(), "emoji-icon-font".to_string()], 242 | ); 243 | font_defs 244 | .families 245 | .insert(egui::FontFamily::Monospace, vec!["hack".to_string()]); 246 | 247 | ctx.set_fonts(font_defs); 248 | 249 | if let Some(style) = content::get_local_config::