Compare commits
131 Commits
0.1.4-alph
...
v0.2.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 853ab7a60e | |||
| d38e70ecbf | |||
|
|
b142982ab1 | ||
|
|
2ea2519e8b | ||
|
|
2ea5feaf4b | ||
| 4ec7530b91 | |||
| df82861101 | |||
|
|
fc99ef4dfe | ||
|
|
d0f7a1abd3 | ||
|
|
71140beb52 | ||
|
|
e177facef4 | ||
| 60bca49200 | |||
|
|
ede41c41c3 | ||
|
|
70e235dcc2 | ||
| b11b0e0115 | |||
|
|
d8edac0bb9 | ||
|
|
d392602ed6 | ||
|
|
5a36354cc8 | ||
| a1df66e176 | |||
|
|
78d913ae38 | ||
| b4691aa689 | |||
| c49530b030 | |||
|
|
e7ffe7627c | ||
| 6a5304514f | |||
|
|
f2be8fca08 | ||
|
|
807851518a | ||
| 49a3dedd9c | |||
|
|
b19bb01003 | ||
| 3a6fc2bcc5 | |||
|
|
5edcc97ada | ||
| a8ccda259c | |||
|
|
23ad28e96e | ||
| 07a2f6980e | |||
|
|
c2b276f3f3 | ||
| 5bef1a2c6c | |||
| cd26244538 | |||
|
|
ca622d1262 | ||
|
|
5011becacb | ||
|
|
17f92d767e | ||
| be660cb14b | |||
|
|
8fca202c05 | ||
| 7b20131e3b | |||
|
|
9127696517 | ||
|
|
af74a4ed23 | ||
|
|
bd2b72a57a | ||
| d6edc8b546 | |||
| 053ecc6a15 | |||
|
|
fe864e4a7f | ||
| 871bbdac78 | |||
| 0ea919901e | |||
| 3772853141 | |||
| ab4597cb6f | |||
| ed6e4f2082 | |||
| 493223276c | |||
| c8c5a6668d | |||
| 86d24ccbd5 | |||
| 80c649f9a0 | |||
|
|
c188f12993 | ||
|
|
3cf9dde882 | ||
|
|
91cca37d69 | ||
| 12168c6084 | |||
|
|
a631dd90d2 | ||
|
|
00b40db82c | ||
| 59cfdb9ae2 | |||
| 73a2678278 | |||
|
|
c7ab75d310 | ||
|
|
8195eedaf6 | ||
|
|
9f02942d87 | ||
|
|
2e3a4b3634 | ||
|
|
8bfad30a99 | ||
| 122dbaf693 | |||
| 9bb784652d | |||
|
|
c1d5c7e719 | ||
| f9bf29df09 | |||
| 2e046ec5d7 | |||
| abb1474300 | |||
|
|
b212095334 | ||
|
|
2dfb48b538 | ||
|
|
14076054c0 | ||
| 3c2eaabab2 | |||
|
|
edee9305cc | ||
|
|
c7e3331eb0 | ||
| 1d77fd443e | |||
| 5f5bb33654 | |||
| 052b0163cb | |||
| 5f8e886a34 | |||
|
|
440f17af18 | ||
|
|
cc36adeafe | ||
|
|
e687204361 | ||
|
|
50beaebd2c | ||
|
|
7cc512331b | ||
| 63191c16bd | |||
|
|
a674ac898a | ||
|
|
557ff18714 | ||
| 7a447da447 | |||
| 92d862e1fa | |||
|
|
0f884f8142 | ||
|
|
45564c7722 | ||
| b0a6b73801 | |||
| e851063de9 | |||
|
|
3fd236de73 | ||
| ba42bafc3a | |||
| 71fbd97bad | |||
|
|
443dbc82a6 | ||
|
|
4f066b7c00 | ||
|
|
4e24061817 | ||
| 2f83b5091e | |||
|
|
97e66fbeb7 | ||
| 3fea18f038 | |||
| 3bd8592f86 | |||
|
|
8c211be11a | ||
| 2c2aeb915e | |||
| 44f0650617 | |||
|
|
107fedeafd | ||
| 17251be3fd | |||
| 73b2eac080 | |||
| 86eca5803f | |||
| 52a79dca08 | |||
| 87f038248c | |||
|
|
a30f2dcc8a | ||
| 5c5748a80c | |||
|
|
b667dd3f1c | ||
|
|
3246abace1 | ||
|
|
f7610cc9c9 | ||
| 16530a3804 | |||
| b778bb13e4 | |||
|
|
cfc2300c0c | ||
| 42d6328d82 | |||
| 4c9533bfe4 | |||
|
|
00cf7792e5 | ||
| e15cbcc22c |
81
.github/workflows/main.yml
vendored
@@ -1,81 +0,0 @@
|
||||
name: Packager Release Process
|
||||
|
||||
run-name: Triggered by ${{ github.actor }}.
|
||||
on: workflow_dispatch
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CN_APPLICATION: lume/coop
|
||||
|
||||
jobs:
|
||||
draft:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Create draft release
|
||||
uses: crabnebula-dev/cloud-release@v0
|
||||
with:
|
||||
command: release draft ${{ env.CN_APPLICATION }} --framework packager
|
||||
api-key: ${{ secrets.CN_API_KEY }}
|
||||
|
||||
build:
|
||||
needs: draft
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Install stable toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc g++ libasound2-dev libfontconfig-dev libwayland-dev libxkbcommon-x11-dev libssl-dev libzstd-dev libvulkan1 libgit2-dev make cmake clang jq netcat-openbsd git curl gettext-base elfutils libsqlite3-dev musl-tools musl-dev build-essential
|
||||
|
||||
- name: install cargo packager
|
||||
run: |
|
||||
cargo install cargo-packager --locked
|
||||
|
||||
- name: Build packager app
|
||||
run: |
|
||||
cargo packager --release
|
||||
|
||||
- name: Move assets to workdir
|
||||
run: |
|
||||
mv target/release/* .
|
||||
|
||||
- name: Upload assets
|
||||
uses: crabnebula-dev/cloud-release@v0
|
||||
with:
|
||||
command: release upload ${{ env.CN_APPLICATION }} --framework packager
|
||||
api-key: ${{ secrets.CN_API_KEY }}
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish release
|
||||
uses: crabnebula-dev/cloud-release@v0
|
||||
with:
|
||||
command: release publish ${{ env.CN_APPLICATION }} --framework packager
|
||||
api-key: ${{ secrets.CN_API_KEY }}
|
||||
172
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: windows-x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
- platform: windows-arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- platform: macos-x64
|
||||
os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
- platform: macos-arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- platform: linux-x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- platform: linux-arm64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# Windows and macOS builds using cargo-packager
|
||||
- name: Build with cargo-packager (Windows/macOS)
|
||||
if: runner.os != 'Linux'
|
||||
working-directory: crates/coop
|
||||
run: |
|
||||
cargo install cargo-packager --locked
|
||||
cargo packager --release
|
||||
|
||||
- name: Upload Windows/macOS artifacts
|
||||
if: runner.os != 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.platform }}-artifacts
|
||||
path: |
|
||||
dist/*.dmg
|
||||
dist/*.msi
|
||||
dist/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# Linux builds using custom scripts
|
||||
- name: Install Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder snapd squashfs-tools jq gettext-base
|
||||
|
||||
- name: Install Snapcraft
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
- name: Make scripts executable
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
chmod +x script/get-crate-version
|
||||
chmod +x script/linux
|
||||
chmod +x script/bundle-snap
|
||||
chmod +x script/bundle-linux
|
||||
chmod +x script/flatpak/deps
|
||||
chmod +x script/flatpak/bundle-flatpak
|
||||
|
||||
- name: Install required dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: ./script/linux
|
||||
|
||||
# Only build Flatpak and Snap for x86_64 (most common use case)
|
||||
- name: Build Flatpak
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
./script/bundle-linux --flatpak
|
||||
./script/flatpak/deps
|
||||
./script/flatpak/bundle-flatpak
|
||||
|
||||
- name: Build Snap
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
VERSION=$(script/get-crate-version coop)
|
||||
./script/bundle-linux
|
||||
./script/bundle-snap $VERSION
|
||||
|
||||
- name: Collect Linux artifacts
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
mkdir -p linux-artifacts
|
||||
# Copy the tarball created by bundle-linux
|
||||
find target/release -name "*.tar.gz" -exec cp {} linux-artifacts/ \;
|
||||
# Find and copy flatpak files (if they exist)
|
||||
find . -name "*.flatpak" -exec cp {} linux-artifacts/ \; || true
|
||||
# Find and copy snap files (if they exist)
|
||||
find . -name "*.snap" -exec cp {} linux-artifacts/ \; || true
|
||||
ls -la linux-artifacts/
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.platform }}-artifacts
|
||||
path: linux-artifacts/**/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Make get-crate-version executable
|
||||
run: chmod +x script/get-crate-version
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(script/get-crate-version coop)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Display artifacts structure
|
||||
run: |
|
||||
echo "Artifacts structure:"
|
||||
find artifacts -type f -exec ls -la {} \;
|
||||
|
||||
- name: Create draft release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: ${{ steps.version.outputs.tag }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/**/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Output release info
|
||||
run: |
|
||||
echo "Created draft release: ${{ steps.create_release.outputs.url }}"
|
||||
echo "Release ID: ${{ steps.create_release.outputs.id }}"
|
||||
32
.github/workflows/rust.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["m**"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
rustup: [stable]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: script/linux
|
||||
run: chmod +x ./script/linux && ./script/linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
2
.gitignore
vendored
@@ -19,3 +19,5 @@ dist/
|
||||
|
||||
# Useless stuffs
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
.intentionally-empty-file.o
|
||||
|
||||
3914
Cargo.lock
generated
104
Cargo.toml
@@ -1,44 +1,60 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/app"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
coop = { path = "crates/*" }
|
||||
|
||||
# UI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-relay-builder = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
|
||||
nostr-connect = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
|
||||
nostr-sdk = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17", features = [
|
||||
"lmdb",
|
||||
"nip96",
|
||||
"nip59",
|
||||
"nip49",
|
||||
"nip44",
|
||||
"nip05",
|
||||
] }
|
||||
|
||||
smol = "2"
|
||||
oneshot = "0.1.10"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dirs = "5.0"
|
||||
itertools = "0.13.0"
|
||||
futures = "0.3.30"
|
||||
chrono = "0.4.38"
|
||||
tracing = "0.1.40"
|
||||
anyhow = "1.0.44"
|
||||
smallvec = "1.14.0"
|
||||
rust-embed = "8.5.0"
|
||||
log = "0.4"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/coop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.7"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace.metadata.i18n]
|
||||
available-locales = ["en"]
|
||||
default-locale = "en"
|
||||
load-path = "locales"
|
||||
|
||||
[workspace.dependencies]
|
||||
i18n = { path = "crates/i18n" }
|
||||
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
|
||||
"lmdb",
|
||||
"nip96",
|
||||
"nip59",
|
||||
"nip49",
|
||||
"nip44",
|
||||
] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = "0.4.38"
|
||||
dirs = "5.0"
|
||||
emojis = "0.6.4"
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = "8.5.0"
|
||||
rust-i18n = "3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
tracing = "0.1.40"
|
||||
webbrowser = "1.0.4"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name = "coop"
|
||||
description = "Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability."
|
||||
product-name = "Coop"
|
||||
identifier = "su.reya.coop"
|
||||
version = "0.1.4"
|
||||
resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"]
|
||||
icons = [
|
||||
"assets/brand/32x32.png",
|
||||
"assets/brand/128x128.png",
|
||||
"assets/brand/128x128@2x.png",
|
||||
"assets/brand/icon.icns",
|
||||
"assets/brand/icon.ico",
|
||||
]
|
||||
before-packaging-command = "cargo build --release"
|
||||
out-dir = "./target/release"
|
||||
binaries = [
|
||||
{ path = "coop", main = true },
|
||||
]
|
||||
55
README.md
@@ -1,19 +1,34 @@
|
||||

|
||||

|
||||
|
||||
<p>
|
||||
<a href="https://github.com/lumehq/coop/actions/workflows/main.yml">
|
||||
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/main.yml/badge.svg">
|
||||
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml">
|
||||
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg">
|
||||
</a>
|
||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/lumehq/coop">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop">
|
||||
</p>
|
||||
|
||||
Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability.
|
||||
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
||||
|
||||
**New**✨: A blog post introducing Coop in details has been posted [here](#).
|
||||
### Screenshots
|
||||
|
||||
> Coop is currently in the **alpha stage** of development. This means the app may contain bugs, incomplete features, or unexpected behavior. We recommend using it for testing purposes only and not for critical or sensitive communications. Your feedback is invaluable in helping us improve Coop, so please report any issues or suggestions via the [GitHub Issue Tracker](https://github.com/lumehq/coop/issues). Thank you for your understanding and support!
|
||||
<p float="left">
|
||||
<img src="/docs/mac_01.png" width="250" />
|
||||
<img src="/docs/mac_02.png" width="250" />
|
||||
<img src="/docs/mac_03.png" width="250" />
|
||||
<img src="/docs/mac_04.png" width="250" />
|
||||
<img src="/docs/mac_05.png" width="250" />
|
||||
<img src="/docs/mac_06.png" width="250" />
|
||||
<img src="/docs/mac_07.png" width="250" />
|
||||
<img src="/docs/mac_08.png" width="250" />
|
||||
<img src="/docs/mac_09.png" width="250" />
|
||||
<img src="/docs/linux_01.png" width="250" />
|
||||
<img src="/docs/linux_02.png" width="250" />
|
||||
<img src="/docs/linux_03.png" width="250" />
|
||||
<img src="/docs/linux_04.png" width="250" />
|
||||
<img src="/docs/linux_05.png" width="250" />
|
||||
</p>
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -28,9 +43,7 @@ To install Coop, follow these steps:
|
||||
|
||||
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
|
||||
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
|
||||
- **Ubuntu**: Run the downloaded `.deb` or `.AppImage` installer and follow the on-screen instructions.
|
||||
- **Arch Linux**: For `.tar.gz` packages, extract and install manually. For PKGBUILD, use `makepkg -si` to build and install.
|
||||
- **Flatpak**: Coming soon.
|
||||
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
|
||||
|
||||
3. **Run Coop**:
|
||||
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
|
||||
@@ -56,13 +69,25 @@ Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nos
|
||||
cd coop
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
2.1 Install Linux dependencies:
|
||||
|
||||
```bash
|
||||
./script/linux
|
||||
```
|
||||
|
||||
2.2 Install FreeBSD dependencies:
|
||||
|
||||
```bash
|
||||
./script/freebsd
|
||||
```
|
||||
|
||||
3. Install Rust dependencies:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
3. Run the app:
|
||||
4. Run the app:
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
@@ -88,14 +113,6 @@ If you'd like to contribute to Coop, please follow these steps:
|
||||
|
||||
For more information, see the [Contributing](#contributing) section.
|
||||
|
||||
#### Debugging
|
||||
|
||||
To debug Coop, you can use `cargo`'s built-in debugging tools or attach a debugger like `gdb` or `lldb`. For example:
|
||||
|
||||
```bash
|
||||
cargo run -- --debug
|
||||
```
|
||||
|
||||
#### Additional Resources
|
||||
|
||||
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB |
BIN
assets/brand/avatar.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
assets/brand/group.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
assets/fonts/plex-mono/ZedPlexMono-Bold.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-Italic.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-Regular.ttf
Normal file
92
assets/fonts/plex-mono/license.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
assets/fonts/plex-sans/ZedPlexSans-Bold.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-Italic.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-Regular.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-SemiBold.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-SemiBoldItalic.ttf
Normal file
92
assets/fonts/plex-sans/license.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m7.5 3.25 4.5 3.5 4.5-3.5m-11.75 17h14.5a2 2 0 0 0 2-2v-9.5a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2v9.5a2 2 0 0 0 2 2Z"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10 5.75 3.75 12 10 18.25M4.5 12h15.75"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 244 B |
3
assets/icons/arrow-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 5.75 20.25 12 14 18.25M19.5 12H3.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.22-1.53 3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 1 1-1.06 1.06l-1.97-1.97v6.69a.75.75 0 0 1-1.5 0V9.56l-1.97 1.97a.75.75 0 0 1-1.06-1.06Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 397 B |
1
assets/icons/arrows-in.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,53.66,163.31,104H192a8,8,0,0,1,0,16H144a8,8,0,0,1-8-8V64a8,8,0,0,1,16,0V92.69l50.34-50.35a8,8,0,0,1,11.32,11.32ZM112,136H64a8,8,0,0,0,0,16H92.69L42.34,202.34a8,8,0,0,0,11.32,11.32L104,163.31V192a8,8,0,0,0,16,0V144A8,8,0,0,0,112,136Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 370 B |
1
assets/icons/caret-down-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 218 B |
|
Before Width: | Height: | Size: 247 B After Width: | Height: | Size: 247 B |
1
assets/icons/caret-right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 249 B |
1
assets/icons/caret-up.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,165.66a8,8,0,0,1-11.32,0L128,91.31,53.66,165.66a8,8,0,0,1-11.32-11.32l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,213.66,165.66Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 262 B |
1
assets/icons/check-circle-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 298 B |
1
assets/icons/check-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 369 B |
1
assets/icons/check.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9.8 10.25c-1.052 0-1.633 1.221-.97 2.038l2.2 2.707c.5.616 1.44.616 1.94 0l2.2-2.707c.664-.817.082-2.038-.97-2.038H9.8Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 257 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill="#000" fill-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm3.58 7.975a.75.75 0 0 0-1.16-.95l-3.976 4.859L9.03 12.47a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.11-.055l4.5-5.5Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 429 B |
3
assets/icons/close-circle.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm7.53-3.53a.75.75 0 0 0-1.06 1.06L10.94 12l-2.47 2.47a.75.75 0 1 0 1.06 1.06L12 13.06l2.47 2.47a.75.75 0 1 0 1.06-1.06L13.06 12l2.47-2.47a.75.75 0 0 0-1.06-1.06L12 10.94 9.53 8.47Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M4.116 4.116a1.25 1.25 0 0 1 1.768 0L12 10.232l6.116-6.116a1.25 1.25 0 0 1 1.768 1.768L13.768 12l6.116 6.116a1.25 1.25 0 0 1-1.768 1.768L12 13.768l-6.116 6.116a1.25 1.25 0 0 1-1.768-1.768L10.232 12 4.116 5.884a1.25 1.25 0 0 1 0-1.768Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"></path></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 314 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill="#000" fill-rule="evenodd" d="M19.432 2.738c.505.54.728 1.327.443 2.133-.606 1.713-1.798 3.124-2.797 4.087a15.74 15.74 0 0 1-1.045.921l.137.1c.93.684 1.416 1.975.757 3.118-1.221 2.12-4.356 5.803-11.192 5.803a.753.753 0 0 1-.15-.015A32.702 32.702 0 0 0 5.5 21.25a.75.75 0 0 1-1.5 0c0-4.43.821-8.93 2.909-12.485 2.106-3.587 5.49-6.182 10.492-6.749a2.404 2.404 0 0 1 2.031.722Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 522 B |
3
assets/icons/copy.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8v.75m0-3.75v-.25a2 2 0 0 1 2-2H11m8 0h.25a2 2 0 0 1 2 2V5M14 2.75h2M21.25 8v2m0 3v.25a2 2 0 0 1-2 2H19m-3 0h-.75M14 8.75H4c-.69 0-1.25.56-1.25 1.25v10c0 .69.56 1.25 1.25 1.25h10c.69 0 1.25-.56 1.25-1.25V10c0-.69-.56-1.25-1.25-1.25Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
4
assets/icons/edit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.75 21.25h-4a2 2 0 0 1-2-2V4.75a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v7"/>
|
||||
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M13.75 21.25v-2.333l3.75-3.75a1.65 1.65 0 0 1 2.333 2.333l-3.75 3.75H13.75Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 454 B |
3
assets/icons/emoji-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm7.75-5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 9.75 7Zm4.5 0a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75Zm-6.143 7.864a.75.75 0 0 1 1.029-.257c1.04.624 1.97.905 2.864.905.894 0 1.824-.281 2.864-.905a.75.75 0 1 1 .772 1.286c-1.21.726-2.405 1.12-3.636 1.12-1.23 0-2.426-.394-3.636-1.12a.75.75 0 0 1-.257-1.029Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10ZM8.405 10.2a.75.75 0 0 1-1.12.263l-2.428-1.82a.707.707 0 0 1-.204-.92 8.496 8.496 0 0 1 8.675-4.12c.409.064.653.475.553.877l-.77 3.084a.75.75 0 0 1-.545.546l-3.226.81a.75.75 0 0 0-.487.39l-.448.889Zm6.805 6.385a.75.75 0 0 1-.671.415h-2.135a.75.75 0 0 1-.624-.334l-1.436-2.153a.75.75 0 0 1 .095-.948l.433-.431a.75.75 0 0 1 .577-.217l1.403.09a.75.75 0 0 1 .37.125l2.233 1.5a.75.75 0 0 1 .252.958l-.498.995Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 654 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill="#000" d="M3.999 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm9.499.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0ZM7.999 12c-1.765 0-3.236.635-4.365 1.72-1.117 1.074-1.868 2.557-2.282 4.225C.932 19.64 2.351 21 3.9 21h8.197c1.55 0 2.968-1.361 2.548-3.055-.413-1.668-1.164-3.151-2.281-4.225-1.13-1.085-2.6-1.72-4.365-1.72Zm6.174.715c1.21 1.337 1.983 3.011 2.414 4.749.231.934.167 1.79-.103 2.536h3.86c1.538 0 2.996-1.365 2.51-3.075C22.06 14.14 20.103 12 16.997 12c-1.08 0-2.023.26-2.825.715Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 595 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.75 19.25h2.596c1.163 0 2.106-1.001 1.788-2.12-.733-2.573-2.465-4.38-5.134-4.38-.446 0-.866.05-1.26.147M11.25 7a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Zm8.5.5a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0ZM2.08 18.126c.78-3.14 2.78-5.376 5.92-5.376s5.14 2.237 5.918 5.376c.28 1.128-.658 2.124-1.82 2.124H3.901c-1.162 0-2.1-.996-1.82-2.124Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 550 B |
3
assets/icons/logout.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.25 12H9m11.25 0-4.5 4.5m4.5-4.5-4.5-4.5m-4.5 12.75h-5.5a2 2 0 0 1-2-2V5.75a2 2 0 0 1 2-2h5.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M3 13.745V5.75A2.75 2.75 0 0 1 5.75 3h12.5A2.75 2.75 0 0 1 21 5.75v12.5A2.75 2.75 0 0 1 18.25 21H5.75A2.75 2.75 0 0 1 3 18.25M5.75 4.5h12.5c.69 0 1.25.56 1.25 1.25V13h-3.57a.75.75 0 0 0-.737.61 3.251 3.251 0 0 1-6.386 0A.75.75 0 0 0 8.07 13H4.5V5.75c0-.69.56-1.25 1.25-1.25Z" clip-rule="evenodd"/>
|
||||
<path fill="currentColor" d="M3 18.25v-4.505"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 502 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 2a7.795 7.795 0 0 0-7.696 6.554l-1.17 7.258A2.75 2.75 0 0 0 5.848 19h1.66c.849 1.75 2.512 3 4.492 3s3.643-1.25 4.492-3h1.66a2.75 2.75 0 0 0 2.714-3.188l-1.17-7.258A7.795 7.795 0 0 0 12 2Zm2.754 17H9.245c.678.937 1.68 1.5 2.754 1.5s2.076-.563 2.754-1.5Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 434 B |
3
assets/icons/open-url.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18.25 14v3.05c0 1.12 0 1.68-.218 2.108a2 2 0 0 1-.874.874c-.428.218-.988.218-2.108.218h-8.1c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874c-.218-.428-.218-.988-.218-2.108V8.875c0-1.05 0-1.574.192-1.98a2 2 0 0 1 .953-.953c.406-.192.93-.192 1.98-.192H9.25m4.5-2h6.5m0 0v6.5m0-6.5L11 13"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
1
assets/icons/plus-circle-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.13,104.13,0,0,0,128,24Zm40,112H136v32a8,8,0,0,1-16,0V136H88a8,8,0,0,1,0-16h32V88a8,8,0,0,1,16,0v32h32a8,8,0,0,1,0,16Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
3
assets/icons/plus-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 272 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3.75V12m0 0v8.25M12 12H3.75M12 12h8.25"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 4v8m0 0v8m0-8H4m8 0h8"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 248 B After Width: | Height: | Size: 205 B |
4
assets/icons/refresh.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M13 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8-10a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm-1.07 3.268a1 1 0 1 1-1 1.732 1 1 0 0 1 1-1.732Zm-2.562 5.026a1 1 0 1 0-1-1.732 1 1 0 0 0 1 1.732ZM18.927 8a1 1 0 1 1-1-1.732 1 1 0 0 1 1 1.732Z"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.25 14.75v5.5h-5.5M9 19.688a8.25 8.25 0 1 1 6.25-15.273"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
3
assets/icons/reply.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m1.845 11.45 8.146-7.535a.75.75 0 0 1 1.259.55V8c0 .276.228.5.504.504C19.84 8.632 22 11.92 22 20.25c-1.47-2.94-2.22-4.679-10.245-4.748a.501.501 0 0 0-.505.498v3.535a.75.75 0 0 1-1.26.55L1.846 12.55a.75.75 0 0 1 0-1.1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
3
assets/icons/report.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20.001 16-2 2m0 0-2 2m2-2-2-2m2 2 2 2m-8.147-6.749c-3.319.058-5.832 2.055-6.87 4.862-.41 1.105.535 2.137 1.713 2.137h5.554m-.397-6.999L12 13.25c.52 0 1.021.047 1.5.138m-1.647-.137A7.89 7.89 0 0 0 10 13.5m5.75-7a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20.25 20.25-4.123-4.123m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20 20-3.873-3.873m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 287 B |
4
assets/icons/settings.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m7.878 5.214-.703-.162a1.77 1.77 0 0 0-2.123 2.123l.162.703a2 2 0 0 1-.84 2.114l-.854.57a1.728 1.728 0 0 0 0 2.876l.855.57a2 2 0 0 1 .84 2.114l-.163.703a1.77 1.77 0 0 0 2.123 2.123l.703-.162a2 2 0 0 1 2.114.84l.57.854a1.728 1.728 0 0 0 2.876 0l.57-.855a2 2 0 0 1 2.114-.84l.703.163a1.77 1.77 0 0 0 2.123-2.123l-.162-.703a2 2 0 0 1 .84-2.114l.854-.57a1.728 1.728 0 0 0 0-2.876l-.855-.57a2 2 0 0 1-.84-2.114l.163-.703a1.77 1.77 0 0 0-2.123-2.123l-.703.162a2 2 0 0 1-2.114-.84l-.57-.854a1.728 1.728 0 0 0-2.876 0l-.57.855a2 2 0 0 1-2.114.84Z"/>
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="M14.75 12a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
3
assets/icons/signal.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 17v2m6-6v6m6-10v10m6-14v14"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 233 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 19.25V13m0 0 2.5 2.5M12 13l-2.5 2.5m-2.125 3.75H4.75a2 2 0 0 1-2-2V5.75a2 2 0 0 1 2-2h4.18a2 2 0 0 1 1.664.89l1.11 1.665a1 1 0 0 0 .831.445h6.715a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2h-2.625"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 4.75A2.75 2.75 0 0 1 4.75 2h8.5A2.75 2.75 0 0 1 16 4.75V8h3.25A2.75 2.75 0 0 1 22 10.75v8.5A2.75 2.75 0 0 1 19.25 22h-8.5A2.75 2.75 0 0 1 8 19.25V16H4.75A2.75 2.75 0 0 1 2 13.25v-8.5ZM14.5 8V4.75c0-.69-.56-1.25-1.25-1.25h-8.5c-.69 0-1.25.56-1.25 1.25v5.991l.983-.644a2.75 2.75 0 0 1 3.033.012l.5.334A2.75 2.75 0 0 1 10.75 8h3.75ZM5 6.25a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0Zm8.39 6.292a.75.75 0 0 1 .766.027l2.8 1.8a.75.75 0 0 1 0 1.262l-2.8 1.8A.75.75 0 0 1 13 16.8v-3.6a.75.75 0 0 1 .39-.658Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 394 B After Width: | Height: | Size: 682 B |
3
assets/icons/warning.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 9.02v2.993M12 15h.01M10.277 3.99 3.275 15.998C2.499 17.328 3.458 19 4.998 19h14.004c1.54 0 2.5-1.671 1.723-3.002L13.723 3.99c-.77-1.32-2.677-1.32-3.447 0ZM12.25 15a.25.25 0 1 1-.5 0 .25.25 0 0 1 .5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -1,35 +0,0 @@
|
||||
[package]
|
||||
name = "coop"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "coop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ui = { path = "../ui" }
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
chats = { path = "../chats" }
|
||||
|
||||
gpui.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
itertools.workspace = true
|
||||
dirs.workspace = true
|
||||
rust-embed.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
oneshot.workspace = true
|
||||
|
||||
rustls = "0.23.23"
|
||||
futures= "0.3"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
@@ -1,27 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../assets"]
|
||||
pub struct Assets;
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
|
||||
Self::get(path)
|
||||
.map(|f| Some(f.data))
|
||||
.ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path))
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
Ok(Self::iter()
|
||||
.filter_map(|p| {
|
||||
if p.starts_with(path) {
|
||||
Some(p.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -1,916 +0,0 @@
|
||||
use std::{collections::HashSet, str::FromStr, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::profile::NostrProfile;
|
||||
use global::{
|
||||
constants::{
|
||||
ALL_MESSAGES_SUB_ID, CLIENT_KEYRING, DEVICE_ANNOUNCEMENT_KIND, DEVICE_REQUEST_KIND,
|
||||
DEVICE_RESPONSE_KIND, DEVICE_SUB_ID, MASTER_KEYRING, NEW_MESSAGE_SUB_ID,
|
||||
},
|
||||
get_app_name, get_client, get_device_keys, get_device_name, set_device_keys,
|
||||
};
|
||||
use gpui::{
|
||||
div, px, relative, App, AppContext, Context, Entity, Global, ParentElement, Styled, Task,
|
||||
Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
indicator::Indicator,
|
||||
notification::Notification,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Root, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use crate::views::{app, onboarding, relays};
|
||||
|
||||
struct GlobalDevice(Entity<Device>);
|
||||
|
||||
impl Global for GlobalDevice {}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub enum DeviceState {
|
||||
Master,
|
||||
Minion,
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
impl DeviceState {
|
||||
pub fn subscribe(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match self {
|
||||
Self::Master => {
|
||||
let client = get_client();
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let opts =
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_REQUEST_KIND))
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Subscribe for the latest request
|
||||
client.subscribe(filter, Some(opts)).await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_REQUEST_KIND))
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
// Subscribe for new device requests
|
||||
client.subscribe(filter, None).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |_, _cx| async move {
|
||||
if let Err(err) = task.await {
|
||||
log::error!("Failed to subscribe for device requests: {}", err);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Self::Minion => {
|
||||
let client = get_client();
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let opts =
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_RESPONSE_KIND))
|
||||
.author(public_key);
|
||||
|
||||
// Getting all previous approvals
|
||||
client.subscribe(filter.clone(), Some(opts)).await?;
|
||||
|
||||
// Continously receive the request approval
|
||||
client
|
||||
.subscribe(filter.since(Timestamp::now()), None)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |_, _cx| async move {
|
||||
if let Err(err) = task.await {
|
||||
log::error!("Failed to subscribe for device approval: {}", err);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current Device (Client)
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
#[derive(Debug)]
|
||||
pub struct Device {
|
||||
/// Profile (Metadata) of current user
|
||||
profile: Option<NostrProfile>,
|
||||
/// Client Keys
|
||||
client_keys: Arc<Keys>,
|
||||
/// Device State
|
||||
state: Entity<DeviceState>,
|
||||
requesters: Entity<HashSet<PublicKey>>,
|
||||
is_processing: bool,
|
||||
}
|
||||
|
||||
pub fn init(window: &mut Window, cx: &App) {
|
||||
// Initialize client keys
|
||||
let read_keys = cx.read_credentials(CLIENT_KEYRING);
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
let client_keys = if let Ok(Some((_, secret))) = read_keys.await {
|
||||
let secret_key = SecretKey::from_slice(&secret).unwrap();
|
||||
|
||||
Arc::new(Keys::new(secret_key))
|
||||
} else {
|
||||
// Generate new keys and save them to keyring
|
||||
let keys = Keys::generate();
|
||||
|
||||
if let Ok(write_keys) = cx.update(|cx| {
|
||||
cx.write_credentials(
|
||||
CLIENT_KEYRING,
|
||||
keys.public_key.to_hex().as_str(),
|
||||
keys.secret_key().as_secret_bytes(),
|
||||
)
|
||||
}) {
|
||||
_ = write_keys.await;
|
||||
};
|
||||
|
||||
Arc::new(keys)
|
||||
};
|
||||
|
||||
cx.update(|cx| {
|
||||
let state = cx.new(|_| DeviceState::None);
|
||||
let weak_state = state.downgrade();
|
||||
let requesters = cx.new(|_| HashSet::new());
|
||||
let entity = cx.new(|_| Device {
|
||||
profile: None,
|
||||
is_processing: false,
|
||||
state,
|
||||
client_keys,
|
||||
requesters,
|
||||
});
|
||||
|
||||
window_handle
|
||||
.update(cx, |_, window, cx| {
|
||||
// Open the onboarding view
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(onboarding::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Observe the DeviceState changes
|
||||
if let Some(state) = weak_state.upgrade() {
|
||||
window
|
||||
.observe(&state, cx, |this, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.subscribe(window, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
};
|
||||
|
||||
// Observe the Device changes
|
||||
window
|
||||
.observe(&entity, cx, |this, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.on_device_change(window, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
|
||||
Device::set_global(entity, cx)
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalDevice>().map(|model| model.0.clone())
|
||||
}
|
||||
|
||||
pub fn set_global(device: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalDevice(device));
|
||||
}
|
||||
|
||||
pub fn client_keys(&self) -> Arc<Keys> {
|
||||
self.client_keys.clone()
|
||||
}
|
||||
|
||||
pub fn profile(&self) -> Option<&NostrProfile> {
|
||||
self.profile.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_profile(&mut self, profile: NostrProfile, cx: &mut Context<Self>) {
|
||||
self.profile = Some(profile);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_state(&mut self, state: DeviceState, cx: &mut Context<Self>) {
|
||||
self.state.update(cx, |this, cx| {
|
||||
*this = state;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_processing(&mut self, is_processing: bool, cx: &mut Context<Self>) {
|
||||
self.is_processing = is_processing;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn add_requester(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
self.requesters.update(cx, |this, cx| {
|
||||
this.insert(public_key);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Login and set user signer
|
||||
pub fn login<T>(&self, signer: T, cx: &mut Context<Self>) -> Task<Result<(), Error>>
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
let client = get_client();
|
||||
|
||||
// Set the user's signer as the main signer
|
||||
let login: Task<Result<NostrProfile, Error>> = cx.background_spawn(async move {
|
||||
// Use user's signer for main signer
|
||||
_ = client.set_signer(signer).await;
|
||||
|
||||
// Verify nostr signer and get public key
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Fetch user's metadata
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get user's inbox relays
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let relays = if let Some(event) = client
|
||||
.fetch_events(filter, Duration::from_secs(2))
|
||||
.await?
|
||||
.first_owned()
|
||||
{
|
||||
let relays = event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| {
|
||||
if let TagStandard::Relay(url) = t {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<SmallVec<[RelayUrl; 3]>>();
|
||||
|
||||
Some(relays)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let profile = NostrProfile::new(public_key, metadata).relays(relays);
|
||||
|
||||
Ok(profile)
|
||||
});
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
match login.await {
|
||||
Ok(user) => {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.profile = Some(user);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// This function is called whenever the device is changed
|
||||
fn on_device_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(profile) = self.profile.as_ref() else {
|
||||
// User not logged in, render the Onboarding View
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(onboarding::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
// Replace the Onboarding View with the Dock View
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(app::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Get the user's messaging relays
|
||||
// If it is empty, user must setup relays
|
||||
let ready = profile.messaging_relays.is_some();
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
cx.update(|window, cx| {
|
||||
if !ready {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_setup_relays(window, cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.start_subscription(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Initialize subscription for current user
|
||||
pub fn start_subscription(&self, cx: &Context<Self>) {
|
||||
if self.is_processing {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(profile) = self.profile() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let user = profile.public_key;
|
||||
let client = get_client();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let device_kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
|
||||
|
||||
// Create a device announcement filter
|
||||
let device = Filter::new().kind(device_kind).author(user).limit(1);
|
||||
|
||||
// Create a contact list filter
|
||||
let contacts = Filter::new().kind(Kind::ContactList).author(user).limit(1);
|
||||
|
||||
// Create a user's data filter
|
||||
let data = Filter::new()
|
||||
.author(user)
|
||||
.since(Timestamp::now())
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::InboxRelays,
|
||||
Kind::RelayList,
|
||||
device_kind,
|
||||
]);
|
||||
|
||||
// Create a filter for getting all gift wrapped events send to current user
|
||||
let msg = Filter::new().kind(Kind::GiftWrap).pubkey(user);
|
||||
|
||||
// Create a filter to continuously receive new messages.
|
||||
let new_msg = Filter::new().kind(Kind::GiftWrap).pubkey(user).limit(0);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
// Only subscribe to the latest device announcement
|
||||
let sub_id = SubscriptionId::new(DEVICE_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, device, Some(opts)).await?;
|
||||
|
||||
// Only subscribe to the latest contact list
|
||||
client.subscribe(contacts, Some(opts)).await?;
|
||||
|
||||
// Continuously receive new user's data since now
|
||||
client.subscribe(data, None).await?;
|
||||
|
||||
let sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, msg, Some(opts)).await?;
|
||||
|
||||
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, new_msg, None).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn(|_, _| async move {
|
||||
if let Err(e) = task.await {
|
||||
log::error!("Subscription error: {}", e);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Setup Device
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn setup_device(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(profile) = self.profile().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// If processing, return early
|
||||
if self.is_processing {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process if device keys are not set
|
||||
self.set_processing(true, cx);
|
||||
|
||||
let client = get_client();
|
||||
let public_key = profile.public_key;
|
||||
let kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
|
||||
let filter = Filter::new().kind(kind).author(public_key).limit(1);
|
||||
|
||||
// Fetch device announcement events
|
||||
let fetch_announcement = cx.background_spawn(async move {
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
Ok(event)
|
||||
} else {
|
||||
Err(anyhow!("Device Announcement not found."))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
// Device Keys has been set, no need to retrieve device announcement again
|
||||
if get_device_keys().await.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
match fetch_announcement.await {
|
||||
Ok(event) => {
|
||||
log::info!("Found a device announcement: {:?}", event);
|
||||
|
||||
let n_tag = event
|
||||
.tags
|
||||
.find(TagKind::custom("n"))
|
||||
.and_then(|t| t.content())
|
||||
.map(|hex| hex.to_owned());
|
||||
|
||||
let credentials_task =
|
||||
match cx.update(|_, cx| cx.read_credentials(MASTER_KEYRING)) {
|
||||
Ok(task) => task,
|
||||
Err(err) => {
|
||||
log::error!("Failed to read credentials: {:?}", err);
|
||||
log::info!("Trying to request keys from Master Device...");
|
||||
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.request_master_keys(window, cx);
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match credentials_task.await {
|
||||
Ok(Some((pubkey, secret))) if n_tag.as_deref() == Some(&pubkey) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_master_keys(secret, window, cx);
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
_ => {
|
||||
log::info!("This device is not the Master Device.");
|
||||
log::info!("Trying to request keys from Master Device...");
|
||||
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.request_master_keys(window, cx);
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
log::info!("Device Announcement not found.");
|
||||
log::info!("Appoint this device as Master Device.");
|
||||
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_new_master_keys(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Create a new Master Keys, appointing this device as Master Device.
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn set_new_master_keys(&self, window: &mut Window, cx: &Context<Self>) {
|
||||
let client = get_client();
|
||||
let app_name = get_app_name();
|
||||
|
||||
let task: Task<Result<Arc<Keys>, Error>> = cx.background_spawn(async move {
|
||||
let keys = Keys::generate();
|
||||
let kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
|
||||
let client_tag = Tag::client(app_name);
|
||||
let pubkey_tag = Tag::custom(TagKind::custom("n"), vec![keys.public_key().to_hex()]);
|
||||
|
||||
let event = EventBuilder::new(kind, "").tags(vec![client_tag, pubkey_tag]);
|
||||
|
||||
if let Err(e) = client.send_event_builder(event).await {
|
||||
log::error!("Failed to send Device Announcement: {}", e);
|
||||
} else {
|
||||
log::info!("Device Announcement has been sent");
|
||||
}
|
||||
|
||||
Ok(Arc::new(keys))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
if get_device_keys().await.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(keys) = task.await {
|
||||
// Update global state
|
||||
set_device_keys(keys.clone()).await;
|
||||
|
||||
// Save keys
|
||||
if let Ok(task) = cx.update(|_, cx| {
|
||||
cx.write_credentials(
|
||||
MASTER_KEYRING,
|
||||
keys.public_key().to_hex().as_str(),
|
||||
keys.secret_key().as_secret_bytes(),
|
||||
)
|
||||
}) {
|
||||
if let Err(e) = task.await {
|
||||
log::error!("Failed to write device keys to keyring: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Master, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Device already has Master Keys, re-appointing this device as Master Device.
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn set_master_keys(&self, secret: Vec<u8>, window: &mut Window, cx: &Context<Self>) {
|
||||
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
|
||||
log::error!("Failed to parse secret key");
|
||||
return;
|
||||
};
|
||||
let keys = Arc::new(Keys::new(secret_key));
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
log::info!("Re-appointing this device as Master Device.");
|
||||
set_device_keys(keys).await;
|
||||
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Master, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Send a request to ask for device keys from the other Nostr client
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn request_master_keys(&self, window: &mut Window, cx: &Context<Self>) {
|
||||
let client = get_client();
|
||||
let app_name = get_app_name();
|
||||
let client_keys = self.client_keys.clone();
|
||||
|
||||
let kind = Kind::Custom(DEVICE_REQUEST_KIND);
|
||||
let client_tag = Tag::client(app_name);
|
||||
let pubkey_tag = Tag::custom(
|
||||
TagKind::custom("pubkey"),
|
||||
vec![client_keys.public_key().to_hex()],
|
||||
);
|
||||
|
||||
// Create a request event builder
|
||||
let builder = EventBuilder::new(kind, "").tags(vec![client_tag, pubkey_tag]);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
log::info!("Sent a request to ask for device keys from the other Nostr client");
|
||||
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send device keys request: {}", e);
|
||||
} else {
|
||||
log::info!("Waiting for response...");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_state(DeviceState::Minion, cx);
|
||||
this.render_waiting_modal(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Received Device Keys approval from Master Device,
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn recv_approval(&self, event: Event, window: &mut Window, cx: &Context<Self>) {
|
||||
let local_signer = self.client_keys.clone();
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
if let Some(tag) = event
|
||||
.tags
|
||||
.find(TagKind::custom("P"))
|
||||
.and_then(|tag| tag.content())
|
||||
{
|
||||
if let Ok(public_key) = PublicKey::from_str(tag) {
|
||||
let secret = local_signer
|
||||
.nip44_decrypt(&public_key, &event.content)
|
||||
.await?;
|
||||
|
||||
let keys = Arc::new(Keys::parse(&secret)?);
|
||||
|
||||
// Update global state with new device keys
|
||||
set_device_keys(keys).await;
|
||||
log::info!("Received master keys");
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Public Key is invalid"))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Failed to decrypt the Master Keys"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |_, mut cx| async move {
|
||||
// No need to update if device keys are already available
|
||||
if get_device_keys().await.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(format!("Failed to decrypt: {}", e)),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
cx.update(|window, cx| {
|
||||
window.close_all_modals(cx);
|
||||
window.push_notification(
|
||||
Notification::success("Device Keys request has been approved"),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Received Master Keys request from other Nostr client
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn recv_request(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(target_pubkey) = event
|
||||
.tags
|
||||
.find(TagKind::custom("pubkey"))
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
else {
|
||||
log::error!("Invalid public key.");
|
||||
return;
|
||||
};
|
||||
|
||||
// Prevent processing duplicate requests
|
||||
if self.requesters.read(cx).contains(&target_pubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.add_requester(target_pubkey, cx);
|
||||
|
||||
let client = get_client();
|
||||
let read_keys = cx.read_credentials(MASTER_KEYRING);
|
||||
let local_signer = self.client_keys.clone();
|
||||
|
||||
let device_name = event
|
||||
.tags
|
||||
.find(TagKind::Client)
|
||||
.and_then(|tag| tag.content())
|
||||
.unwrap_or("Other Device")
|
||||
.to_owned();
|
||||
|
||||
let response = window.prompt(
|
||||
gpui::PromptLevel::Info,
|
||||
"Requesting Device Keys",
|
||||
Some(
|
||||
format!(
|
||||
"{} is requesting shared device keys stored in this device",
|
||||
device_name
|
||||
)
|
||||
.as_str(),
|
||||
),
|
||||
&["Approve", "Deny"],
|
||||
cx,
|
||||
);
|
||||
|
||||
cx.spawn_in(window, |_, cx| async move {
|
||||
match response.await {
|
||||
Ok(0) => {
|
||||
if let Ok(Some((_, secret))) = read_keys.await {
|
||||
let local_pubkey = local_signer.get_public_key().await?;
|
||||
|
||||
// Get device's secret key
|
||||
let device_secret = SecretKey::from_slice(&secret)?;
|
||||
let device_secret_hex = device_secret.to_secret_hex();
|
||||
|
||||
// Encrypt device's secret key by using NIP-44
|
||||
let content = local_signer
|
||||
.nip44_encrypt(&target_pubkey, &device_secret_hex)
|
||||
.await?;
|
||||
|
||||
// Create pubkey tag for other device (lowercase p)
|
||||
let other_tag = Tag::public_key(target_pubkey);
|
||||
|
||||
// Create pubkey tag for this device (uppercase P)
|
||||
let local_tag = Tag::custom(
|
||||
TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)),
|
||||
vec![local_pubkey.to_hex()],
|
||||
);
|
||||
|
||||
// Create event builder
|
||||
let kind = Kind::Custom(DEVICE_RESPONSE_KIND);
|
||||
let tags = vec![other_tag, local_tag];
|
||||
let builder = EventBuilder::new(kind, content).tags(tags);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(err) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send device keys to other client: {}", err);
|
||||
} else {
|
||||
log::info!("Sent device keys to other client");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Device Keys not found"))
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Show setup relays modal
|
||||
///
|
||||
/// NIP-17: <https://github.com/nostr-protocol/nips/blob/master/17.md>
|
||||
pub fn render_setup_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, window, cx| {
|
||||
let is_loading = relays.read(cx).loading();
|
||||
|
||||
this.keyboard(false)
|
||||
.closable(false)
|
||||
.width(px(430.))
|
||||
.title("Your Messaging Relays are not configured")
|
||||
.child(relays.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("update_inbox_relays_btn")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_loading)
|
||||
.on_click(window.listener_for(&relays, |this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Show waiting modal
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn render_waiting_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
let msg = format!(
|
||||
"Please open {} and approve sharing device keys request.",
|
||||
get_device_name()
|
||||
);
|
||||
|
||||
this.keyboard(false)
|
||||
.closable(false)
|
||||
.width(px(430.))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.child("You're using a new device."),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.line_height(relative(1.3))
|
||||
.child(msg),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.footer(
|
||||
div()
|
||||
.p_4()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.w_full()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(Indicator::new().small())
|
||||
.child("Waiting for approval ..."),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use asset::Assets;
|
||||
use chats::registry::ChatRegistry;
|
||||
use device::Device;
|
||||
use futures::{select, FutureExt};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use global::constants::APP_NAME;
|
||||
use global::{
|
||||
constants::{
|
||||
ALL_MESSAGES_SUB_ID, APP_ID, BOOTSTRAP_RELAYS, DEVICE_ANNOUNCEMENT_KIND,
|
||||
DEVICE_REQUEST_KIND, DEVICE_RESPONSE_KIND, DEVICE_SUB_ID, NEW_MESSAGE_SUB_ID,
|
||||
},
|
||||
get_client, get_device_keys, set_device_name,
|
||||
};
|
||||
use gpui::{
|
||||
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||
WindowBounds, WindowKind, WindowOptions,
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use gpui::{point, SharedString, TitlebarOptions};
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
||||
use nostr_sdk::{
|
||||
nips::nip59::UnwrappedGift, pool::prelude::ReqExitPolicy, Event, Filter, Keys, Kind, PublicKey,
|
||||
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, TagKind,
|
||||
};
|
||||
use smol::Timer;
|
||||
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
|
||||
use ui::{theme::Theme, Root};
|
||||
use views::startup;
|
||||
|
||||
pub(crate) mod asset;
|
||||
pub(crate) mod device;
|
||||
pub(crate) mod views;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Signal {
|
||||
/// Receive event
|
||||
Event(Event),
|
||||
/// Receive request master key event
|
||||
RequestMasterKey(Event),
|
||||
/// Receive approve master key event
|
||||
ReceiveMasterKey(Event),
|
||||
/// Receive announcement event
|
||||
ReceiveAnnouncement,
|
||||
/// Receive EOSE
|
||||
Eose,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Enable logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(1024);
|
||||
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
|
||||
|
||||
// Initialize nostr client
|
||||
let client = get_client();
|
||||
|
||||
// Initialize application
|
||||
let app = Application::new()
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
|
||||
|
||||
// Connect to default relays
|
||||
app.background_executor()
|
||||
.spawn(async {
|
||||
// Fix crash on startup
|
||||
// TODO: why this is needed?
|
||||
_ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
||||
_ = client.add_relay(relay).await;
|
||||
}
|
||||
|
||||
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
|
||||
_ = client.add_discovery_relay("wss://user.kindpag.es").await;
|
||||
|
||||
_ = client.connect().await
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle batch metadata
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
const BATCH_SIZE: usize = 20;
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
let mut timeout = Box::pin(Timer::after(BATCH_TIMEOUT).fuse());
|
||||
|
||||
select! {
|
||||
pubkeys = batch_rx.recv().fuse() => {
|
||||
match pubkeys {
|
||||
Ok(keys) => {
|
||||
batch.extend(keys);
|
||||
if batch.len() >= BATCH_SIZE {
|
||||
handle_metadata(mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
_ = timeout => {
|
||||
if !batch.is_empty() {
|
||||
handle_metadata(mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle notifications
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
let rng_keys = Keys::generate();
|
||||
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
let device_id = SubscriptionId::new(DEVICE_SUB_ID);
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
if let RelayPoolNotification::Message { message, .. } = notification {
|
||||
match message {
|
||||
RelayMessage::Event {
|
||||
event,
|
||||
subscription_id,
|
||||
} => {
|
||||
match event.kind {
|
||||
Kind::GiftWrap => {
|
||||
if let Ok(gift) = handle_gift_wrap(&event).await {
|
||||
// Sign the rumor with the generated keys,
|
||||
// this event will be used for internal only,
|
||||
// and NEVER send to relays.
|
||||
if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) {
|
||||
let mut pubkeys = vec![];
|
||||
pubkeys.extend(event.tags.public_keys());
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
// Save the event to the database, use for query directly.
|
||||
_ = client.database().save_event(&event).await;
|
||||
|
||||
// Send this event to the GPUI
|
||||
if new_id == *subscription_id {
|
||||
_ = event_tx.send(Signal::Event(event)).await;
|
||||
}
|
||||
|
||||
// Send all pubkeys to the batch
|
||||
_ = batch_tx.send(pubkeys).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let pubkeys =
|
||||
event.tags.public_keys().copied().collect::<HashSet<_>>();
|
||||
|
||||
handle_metadata(pubkeys).await;
|
||||
}
|
||||
Kind::Custom(DEVICE_REQUEST_KIND) => {
|
||||
log::info!("Received device keys request");
|
||||
|
||||
_ = event_tx
|
||||
.send(Signal::RequestMasterKey(event.into_owned()))
|
||||
.await;
|
||||
}
|
||||
Kind::Custom(DEVICE_RESPONSE_KIND) => {
|
||||
log::info!("Received device keys approval");
|
||||
|
||||
_ = event_tx
|
||||
.send(Signal::ReceiveMasterKey(event.into_owned()))
|
||||
.await;
|
||||
}
|
||||
Kind::Custom(DEVICE_ANNOUNCEMENT_KIND) => {
|
||||
log::info!("Device Announcement received");
|
||||
|
||||
if let Ok(signer) = client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
if event.pubkey == public_key {
|
||||
if let Some(tag) = event
|
||||
.tags
|
||||
.find(TagKind::custom("client"))
|
||||
.and_then(|tag| tag.content())
|
||||
{
|
||||
set_device_name(tag).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
||||
if all_id == *subscription_id {
|
||||
_ = event_tx.send(Signal::Eose).await;
|
||||
} else if device_id == *subscription_id {
|
||||
_ = event_tx.send(Signal::ReceiveAnnouncement).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
app.run(move |cx| {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
|
||||
// Register the `quit` function with CMD+Q
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
|
||||
// Set up the window options
|
||||
let opts = WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
None,
|
||||
size(px(900.0), px(680.0)),
|
||||
cx,
|
||||
))),
|
||||
#[cfg(target_os = "linux")]
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
#[cfg(target_os = "linux")]
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
kind: WindowKind::Normal,
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Automatically sync theme with system appearance
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize chat global state
|
||||
chats::registry::init(cx);
|
||||
|
||||
// Initialize device
|
||||
device::init(window, cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let root = Root::new(startup::init(window, cx).into(), window, cx);
|
||||
|
||||
// Spawn a task to handle events from nostr channel
|
||||
cx.spawn_in(window, |_, mut cx| async move {
|
||||
while let Ok(signal) = event_rx.recv().await {
|
||||
cx.update(|window, cx| {
|
||||
match signal {
|
||||
Signal::Eose => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
|
||||
}
|
||||
}
|
||||
Signal::Event(event) => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.push_message(event, cx))
|
||||
}
|
||||
}
|
||||
Signal::ReceiveAnnouncement => {
|
||||
if let Some(device) = Device::global(cx) {
|
||||
device.update(cx, |this, cx| {
|
||||
this.setup_device(window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Signal::ReceiveMasterKey(event) => {
|
||||
if let Some(device) = Device::global(cx) {
|
||||
device.update(cx, |this, cx| {
|
||||
this.recv_approval(event, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Signal::RequestMasterKey(event) => {
|
||||
if let Some(device) = Device::global(cx) {
|
||||
device.update(cx, |this, cx| {
|
||||
this.recv_request(event, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
root
|
||||
})
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
});
|
||||
}
|
||||
|
||||
async fn handle_gift_wrap(gift_wrap: &Event) -> Result<UnwrappedGift, anyhow::Error> {
|
||||
let client = get_client();
|
||||
|
||||
if let Some(device) = get_device_keys().await {
|
||||
// Try to unwrap with the device keys first
|
||||
match UnwrappedGift::from_gift_wrap(&device, gift_wrap).await {
|
||||
Ok(event) => Ok(event),
|
||||
Err(_) => {
|
||||
// Try to unwrap again with the user's signer
|
||||
let signer = client.signer().await?;
|
||||
let event = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Signer not found"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_metadata(buffer: HashSet<PublicKey>) {
|
||||
let client = get_client();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.idle_timeout(Some(Duration::from_secs(2)));
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(buffer.iter().cloned())
|
||||
.limit(buffer.len() * 2)
|
||||
.kinds(vec![Kind::Metadata, Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)]);
|
||||
|
||||
if let Err(e) = client.subscribe(filter, Some(opts)).await {
|
||||
log::error!("Failed to sync metadata: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
|
||||
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
|
||||
StyledImage, Window,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::{dock::DockPlacement, DockArea, DockItem},
|
||||
popup_menu::PopupMenuExt,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme, Appearance, Theme},
|
||||
ContextModal, Icon, IconName, Root, Sizable, TitleBar,
|
||||
};
|
||||
|
||||
use super::{chat, contacts, onboarding, profile, relays, settings, sidebar, welcome};
|
||||
use crate::device::Device;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum PanelKind {
|
||||
Room(u64),
|
||||
Profile,
|
||||
Contacts,
|
||||
Settings,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct AddPanel {
|
||||
panel: PanelKind,
|
||||
position: DockPlacement,
|
||||
}
|
||||
|
||||
impl AddPanel {
|
||||
pub fn new(panel: PanelKind, position: DockPlacement) -> Self {
|
||||
Self { panel, position }
|
||||
}
|
||||
}
|
||||
|
||||
// Dock actions
|
||||
impl_internal_actions!(dock, [AddPanel]);
|
||||
|
||||
// Account actions
|
||||
actions!(account, [Logout]);
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AppView> {
|
||||
AppView::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct AppView {
|
||||
dock: Entity<DockArea>,
|
||||
}
|
||||
|
||||
impl AppView {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
// Initialize dock layout
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
let weak_dock = dock.downgrade();
|
||||
|
||||
// Initialize left dock
|
||||
let left_panel = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
|
||||
// Initial central dock
|
||||
let center_panel = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(
|
||||
vec![Arc::new(welcome::init(window, cx))],
|
||||
None,
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Set default dock layout with left and central docks
|
||||
_ = weak_dock.update(cx, |view, cx| {
|
||||
view.set_left_dock(left_panel, Some(px(240.)), true, window, cx);
|
||||
view.set_center(center_panel, window, cx);
|
||||
});
|
||||
|
||||
cx.new(|_| Self { dock })
|
||||
}
|
||||
|
||||
fn render_mode_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("appearance")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.map(|this| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
this.icon(IconName::Sun)
|
||||
} else {
|
||||
this.icon(IconName::Moon)
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
Theme::change(Appearance::Light, Some(window), cx);
|
||||
} else {
|
||||
Theme::change(Appearance::Dark, Some(window), cx);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_account_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("account")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.reverse()
|
||||
.icon(Icon::new(IconName::ChevronDownSmall))
|
||||
.when_some(Device::global(cx), |this, account| {
|
||||
this.when_some(account.read(cx).profile(), |this, profile| {
|
||||
this.child(
|
||||
img(profile.avatar.clone())
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
})
|
||||
})
|
||||
.popup_menu(move |this, _, _cx| {
|
||||
this.menu(
|
||||
"Profile",
|
||||
Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Contacts",
|
||||
Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Settings",
|
||||
Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)),
|
||||
)
|
||||
.separator()
|
||||
.menu("Change account", Box::new(Logout))
|
||||
})
|
||||
}
|
||||
|
||||
fn render_relays_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("relays")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::Relays)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.render_edit_relays(window, cx);
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_edit_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, window, cx| {
|
||||
let is_loading = relays.read(cx).loading();
|
||||
|
||||
this.width(px(420.))
|
||||
.title("Edit your Messaging Relays")
|
||||
.child(relays.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("update_inbox_relays_btn")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_loading)
|
||||
.on_click(window.listener_for(&relays, |this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match &action.panel {
|
||||
PanelKind::Room(id) => {
|
||||
// User must be logged in to open a room
|
||||
match chat::init(id, window, cx) {
|
||||
Ok(panel) => {
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => window.push_notification(e.to_string(), cx),
|
||||
}
|
||||
}
|
||||
PanelKind::Profile => {
|
||||
let panel = profile::init(window, cx);
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
PanelKind::Contacts => {
|
||||
let panel = Arc::new(contacts::init(window, cx));
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
PanelKind::Settings => {
|
||||
let panel = Arc::new(settings::init(window, cx));
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Reset nostr client
|
||||
client.reset().await
|
||||
})
|
||||
.detach();
|
||||
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(onboarding::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AppView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(
|
||||
TitleBar::new()
|
||||
// Left side
|
||||
.child(div())
|
||||
// Right side
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(self.render_mode_btn(cx))
|
||||
.child(self.render_relays_btn(cx))
|
||||
.child(self.render_account_btn(cx)),
|
||||
),
|
||||
)
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.child(div().absolute().top_8().children(notification_layer))
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
// Actions
|
||||
.on_action(cx.listener(Self::on_panel_action))
|
||||
.on_action(cx.listener(Self::on_logout_action))
|
||||
}
|
||||
}
|
||||
@@ -1,721 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use async_utility::task::spawn;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{
|
||||
last_seen::LastSeen,
|
||||
profile::NostrProfile,
|
||||
utils::{compare, nip96_upload},
|
||||
};
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext,
|
||||
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render,
|
||||
SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, WeakEntity,
|
||||
Window,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::{InputEvent, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, ContextModal, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages.";
|
||||
const DESCRIPTION: &str =
|
||||
"This conversation is private. Only members of this chat can see each other's messages.";
|
||||
|
||||
pub fn init(
|
||||
id: &u64,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Result<Arc<Entity<Chat>>, anyhow::Error> {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
if let Some(room) = chats.read(cx).get(id, cx) {
|
||||
Ok(Arc::new(Chat::new(id, room, window, cx)))
|
||||
} else {
|
||||
Err(anyhow!("Chat room is not exist"))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Chat Registry is not initialized"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct ParsedMessage {
|
||||
avatar: SharedString,
|
||||
display_name: SharedString,
|
||||
created_at: SharedString,
|
||||
content: SharedString,
|
||||
}
|
||||
|
||||
impl ParsedMessage {
|
||||
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
|
||||
let content = SharedString::new(content);
|
||||
let created_at = LastSeen(created_at).human_readable();
|
||||
|
||||
Self {
|
||||
avatar: profile.avatar.clone(),
|
||||
display_name: profile.name.clone(),
|
||||
created_at,
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum Message {
|
||||
User(Box<ParsedMessage>),
|
||||
System(SharedString),
|
||||
Placeholder,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn new(message: ParsedMessage) -> Self {
|
||||
Self::User(Box::new(message))
|
||||
}
|
||||
|
||||
pub fn system(content: SharedString) -> Self {
|
||||
Self::System(content)
|
||||
}
|
||||
|
||||
pub fn placeholder() -> Self {
|
||||
Self::Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Chat {
|
||||
// Panel
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
// Chat Room
|
||||
room: WeakEntity<Room>,
|
||||
messages: Entity<Vec<Message>>,
|
||||
seens: Entity<Vec<EventId>>,
|
||||
list_state: ListState,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: Vec<Subscription>,
|
||||
// New Message
|
||||
input: Entity<TextInput>,
|
||||
// Media
|
||||
attaches: Entity<Option<Vec<Url>>>,
|
||||
is_uploading: bool,
|
||||
}
|
||||
|
||||
impl Chat {
|
||||
pub fn new(
|
||||
id: &u64,
|
||||
room: WeakEntity<Room>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let messages = cx.new(|_| vec![Message::placeholder()]);
|
||||
let seens = cx.new(|_| vec![]);
|
||||
let attaches = cx.new(|_| None);
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.text_size(ui::Size::Small)
|
||||
.placeholder("Message...")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = Vec::with_capacity(2);
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
this.send_message(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
if let Some(room) = room.upgrade() {
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
this.push_message(&event.event, window, cx);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// Initialize list state
|
||||
// [item_count] always equal to 1 at the beginning
|
||||
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.), {
|
||||
let this = cx.entity().downgrade();
|
||||
move |ix, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_message(ix, window, cx).into_any_element()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
});
|
||||
|
||||
let this = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
is_uploading: false,
|
||||
id: id.to_string().into(),
|
||||
room,
|
||||
messages,
|
||||
seens,
|
||||
list_state,
|
||||
input,
|
||||
attaches,
|
||||
subscriptions,
|
||||
};
|
||||
|
||||
// Verify messaging relays of all members
|
||||
this.verify_messaging_relays(cx);
|
||||
|
||||
// Load all messages from database
|
||||
this.load_messages(cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_messaging_relays(&self, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let task = room.verify_inbox_relays(cx);
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(result) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
result.into_iter().for_each(|item| {
|
||||
if !item.1 {
|
||||
if let Ok(Some(member)) =
|
||||
this.room.read_with(cx, |this, _| this.member(&item.0))
|
||||
{
|
||||
this.push_system_message(
|
||||
format!("{} {}", member.name, ALERT),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn load_messages(&self, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let task = room.load_messages(cx);
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(events) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.push_messages(events, cx);
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = Message::system(content.into());
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn push_message(&mut self, event: &Event, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Prevent duplicate messages
|
||||
if self.seens.read(cx).iter().any(|id| id == &event.id) {
|
||||
return;
|
||||
}
|
||||
// Add ID to seen list
|
||||
self.seen(event.id, cx);
|
||||
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let room = model.read(cx);
|
||||
|
||||
let profile = room
|
||||
.member(&event.pubkey)
|
||||
.unwrap_or(NostrProfile::new(event.pubkey, Metadata::default()));
|
||||
|
||||
let message = Message::new(ParsedMessage::new(
|
||||
&profile,
|
||||
&event.content,
|
||||
Timestamp::now(),
|
||||
));
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn push_messages(&self, events: Events, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let pubkeys = room.public_keys();
|
||||
let old_len = self.messages.read(cx).len();
|
||||
|
||||
let (messages, new_len) = {
|
||||
let items: Vec<Message> = events
|
||||
.into_iter()
|
||||
.sorted_by_key(|ev| ev.created_at)
|
||||
.filter_map(|ev| {
|
||||
let mut other_pubkeys = ev.tags.public_keys().copied().collect::<Vec<_>>();
|
||||
other_pubkeys.push(ev.pubkey);
|
||||
|
||||
if !compare(&other_pubkeys, &pubkeys) {
|
||||
return None;
|
||||
}
|
||||
|
||||
room.members
|
||||
.iter()
|
||||
.find(|m| m.public_key == ev.pubkey)
|
||||
.map(|member| {
|
||||
Message::new(ParsedMessage::new(member, &ev.content, ev.created_at))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Used for update list state
|
||||
let new_len = items.len();
|
||||
|
||||
(items, new_len)
|
||||
};
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(messages);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, new_len);
|
||||
}
|
||||
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get message
|
||||
let mut content = self.input.read(cx).text().to_string();
|
||||
|
||||
// Get all attaches and merge its with message
|
||||
if let Some(attaches) = self.attaches.read(cx).as_ref() {
|
||||
let merged = attaches
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
content = format!("{}\n{}", content, merged)
|
||||
}
|
||||
|
||||
if content.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable input when sending message
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, window, cx);
|
||||
this.set_disabled(true, window, cx);
|
||||
});
|
||||
|
||||
let room = model.read(cx);
|
||||
let task = room.send_message(content, cx);
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(msgs) = task.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
// Reset message input
|
||||
cx.update_entity(&this.input, |this, cx| {
|
||||
this.set_loading(false, window, cx);
|
||||
this.set_disabled(false, window, cx);
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
|
||||
for item in msgs.into_iter() {
|
||||
window.push_notification(
|
||||
Notification::error(item).title("Message Failed to Send"),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
// TODO: support multiple upload
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let path = paths.pop().unwrap();
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
spawn(async move {
|
||||
let client = get_client();
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, _, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
// Stop loading spinner
|
||||
this.set_loading(false, cx);
|
||||
|
||||
this.attaches.update(cx, |this, cx| {
|
||||
if let Some(model) = this.as_mut() {
|
||||
model.push(url);
|
||||
} else {
|
||||
*this = Some(vec![url]);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Stop loading spinner
|
||||
if let Some(view) = this.upgrade() {
|
||||
cx.update_entity(&view, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.attaches.update(cx, |model, cx| {
|
||||
if let Some(urls) = model.as_mut() {
|
||||
let ix = urls.iter().position(|x| x == url).unwrap();
|
||||
urls.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn seen(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||
self.seens.update(cx, |this, cx| {
|
||||
this.push(id);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
if let Some(message) = self.messages.read(cx).get(ix) {
|
||||
div()
|
||||
.group("")
|
||||
.relative()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.map(|this| match message {
|
||||
Message::User(item) => this
|
||||
.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().transparent)
|
||||
.group_hover("", |this| {
|
||||
this.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
img(item.avatar.clone())
|
||||
.size_8()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div().font_semibold().child(item.display_name.clone()),
|
||||
)
|
||||
.child(div().child(item.created_at.clone()).text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)),
|
||||
)
|
||||
.child(div().text_sm().child(item.content.clone())),
|
||||
),
|
||||
Message::System(content) => this
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().transparent)
|
||||
.group_hover("", |this| this.bg(cx.theme().danger)),
|
||||
)
|
||||
.child(
|
||||
img("brand/avatar.jpg")
|
||||
.size_8()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(content.clone()),
|
||||
Message::Placeholder => this
|
||||
.w_full()
|
||||
.h_32()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.line_height(relative(1.2))
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_8()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(DESCRIPTION),
|
||||
})
|
||||
} else {
|
||||
div()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Chat {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.room
|
||||
.read_with(cx, |this, _| {
|
||||
let facepill: Vec<SharedString> = this
|
||||
.members
|
||||
.iter()
|
||||
.map(|member| member.avatar.clone())
|
||||
.collect();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_row_reverse()
|
||||
.items_center()
|
||||
.justify_start()
|
||||
.children(facepill.into_iter().enumerate().rev().map(|(ix, face)| {
|
||||
div().when(ix > 0, |div| div.ml_neg_1()).child(
|
||||
img(face)
|
||||
.size_4()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
})),
|
||||
)
|
||||
.when_some(this.name(), |this, name| this.child(name))
|
||||
.into_any()
|
||||
})
|
||||
.unwrap_or("Unnamed".into_any())
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Chat {}
|
||||
|
||||
impl Focusable for Chat {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Chat {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.child(list(self.list_state.clone()).flex_1())
|
||||
.child(
|
||||
div().flex_shrink_0().p_2().child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.when_some(self.attaches.read(cx).as_ref(), |this, attaches| {
|
||||
this.gap_1p5().children(attaches.iter().map(|url| {
|
||||
let url = url.clone();
|
||||
let path: SharedString = url.to_string().into();
|
||||
|
||||
div()
|
||||
.id(path.clone())
|
||||
.relative()
|
||||
.w_16()
|
||||
.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=128&h=128&fit=cover&n=-1",
|
||||
IMAGE_SERVICE, path
|
||||
))
|
||||
.size_16()
|
||||
.shadow_lg()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.object_fit(ObjectFit::ScaleDown),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_neg_2()
|
||||
.right_neg_2()
|
||||
.size_4()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().danger)
|
||||
.child(
|
||||
Icon::new(IconName::Close)
|
||||
.size_2()
|
||||
.text_color(white()),
|
||||
),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove_media(&url, window, cx);
|
||||
}))
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(Icon::new(IconName::Upload))
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload_media(window, cx);
|
||||
}))
|
||||
.loading(self.is_uploading),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.rounded(px(cx.theme().radius))
|
||||
.pl_2()
|
||||
.pr_1()
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
Button::new("send")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Medium)
|
||||
.label("SEND")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.send_message(window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
use common::profile::NostrProfile;
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
indicator::Indicator,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Sizable,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Contacts> {
|
||||
Contacts::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Contacts {
|
||||
contacts: Entity<Option<Vec<NostrProfile>>>,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Contacts {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let contacts = cx.new(|_| None);
|
||||
let async_contact = contacts.clone();
|
||||
|
||||
cx.spawn(|mut cx| async move {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
|
||||
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
let signer = client.signer().await.unwrap();
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| {
|
||||
NostrProfile::new(profile.public_key(), profile.metadata())
|
||||
})
|
||||
.collect();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Ok(contacts) = rx.await {
|
||||
_ = cx.update_entity(&async_contact, |this, cx| {
|
||||
*this = Some(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| Self {
|
||||
contacts,
|
||||
name: "Contacts".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Contacts {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"ContactPanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Contacts {}
|
||||
|
||||
impl Focusable for Contacts {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Contacts {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().pt_2().px_2().map(|this| {
|
||||
if let Some(contacts) = self.contacts.read(cx).clone() {
|
||||
this.child(
|
||||
uniform_list(
|
||||
cx.entity().clone(),
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
move |_, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = contacts.get(ix).unwrap().clone();
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.child(img(item.avatar).size_6()),
|
||||
)
|
||||
.child(item.name),
|
||||
)
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.h_full(),
|
||||
)
|
||||
} else {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_16()
|
||||
.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
mod chat;
|
||||
mod contacts;
|
||||
mod profile;
|
||||
mod settings;
|
||||
mod sidebar;
|
||||
mod welcome;
|
||||
|
||||
pub mod app;
|
||||
pub mod onboarding;
|
||||
pub mod relays;
|
||||
pub mod startup;
|
||||
@@ -1,399 +0,0 @@
|
||||
use common::qr::create_qr;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, Context, Entity, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonCustomVariant, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Disableable, Size, StyledExt,
|
||||
};
|
||||
|
||||
use crate::device::Device;
|
||||
|
||||
const LOGO_URL: &str = "brand/coop.svg";
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const SUBTITLE: &str = "A Nostr client for secure communication.";
|
||||
// TODO: Replace it with Persona Mobile App
|
||||
const NSTART_URL: &str =
|
||||
"https://start.njump.me?an=Coop&at=ios&ac=coop&afb=yes&asf=yes&aan=null&aac=null&arr=wss://relay.damus.io&awr=wss://relay.primal.net,wss://purplerelay.com,wss://offchain.pub";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
Onboarding::new(window, cx)
|
||||
}
|
||||
|
||||
enum PageKind {
|
||||
Bunker,
|
||||
Connect,
|
||||
Selection,
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
bunker_input: Entity<TextInput>,
|
||||
connect_url: Entity<Option<PathBuf>>,
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
open: PageKind,
|
||||
is_loading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let connect_url = cx.new(|_| None);
|
||||
let error_message = cx.new(|_| None);
|
||||
let bunker_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.placeholder("bunker://<pubkey>?relay=wss://relay.example.com")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&bunker_input,
|
||||
window,
|
||||
move |this: &mut Self, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.connect(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
bunker_input,
|
||||
connect_url,
|
||||
error_message,
|
||||
subscriptions,
|
||||
open: PageKind::Selection,
|
||||
is_loading: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn login(&self, signer: NostrConnect, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(device) = Device::global(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let entity = cx.weak_entity();
|
||||
|
||||
device.update(cx, |this, cx| {
|
||||
let login = this.login(signer, cx);
|
||||
|
||||
cx.spawn(|_, cx| async move {
|
||||
if let Err(e) = login.await {
|
||||
cx.update(|cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = Device::global(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let text = self.bunker_input.read(cx).text().to_string();
|
||||
let keys = Arc::unwrap_or_clone(model.read(cx).client_keys());
|
||||
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let Ok(uri) = NostrConnectURI::parse(text) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error("Bunker URL is invalid".to_owned(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(signer) = NostrConnect::new(uri, keys, Duration::from_secs(300), None) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error("Failed to establish connection".to_owned(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
self.login(signer, window, cx);
|
||||
}
|
||||
|
||||
fn wait_for_connection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let app_keys = Keys::generate();
|
||||
let url = NostrConnectURI::client(
|
||||
app_keys.public_key(),
|
||||
vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()],
|
||||
"Coop",
|
||||
);
|
||||
|
||||
// Create QR code and save it to a app directory
|
||||
let qr_path = create_qr(url.to_string().as_str()).ok();
|
||||
|
||||
// Update QR code path
|
||||
self.connect_url.update(cx, |this, cx| {
|
||||
*this = qr_path;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Open Connect page
|
||||
self.open(PageKind::Connect, window, cx);
|
||||
|
||||
// Wait for connection
|
||||
if let Ok(signer) = NostrConnect::new(url, app_keys, Duration::from_secs(300), None) {
|
||||
self.login(signer, window, cx);
|
||||
} else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error("Failed to establish connection".to_owned(), cx);
|
||||
self.open(PageKind::Selection, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_error(&mut self, msg: String, cx: &mut Context<Self>) {
|
||||
self.error_message.update(cx, |this, cx| {
|
||||
*this = Some(msg.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Dismiss error message after 3 seconds
|
||||
cx.spawn(|this, cx| async move {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
_ = cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.error_message.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn open(&mut self, kind: PageKind, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open = kind;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_8()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path(LOGO_URL)
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_lg()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child(TITLE),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child(SUBTITLE),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(div().w_72().w_full().flex().flex_col().gap_2().map(|this| {
|
||||
match self.open {
|
||||
PageKind::Connect => this
|
||||
.when_some(self.connect_url.read(cx).as_ref(), |this, path| {
|
||||
this.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.p_2()
|
||||
.size_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.rounded_lg()
|
||||
.shadow_md()
|
||||
.when(cx.theme().appearance.is_dark(), |this| {
|
||||
this.shadow_none().border_1().border_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::SIX),
|
||||
)
|
||||
})
|
||||
.bg(cx.theme().background)
|
||||
.child(img(path.as_path()).h_64()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("Scan this QR to connect"),
|
||||
)
|
||||
.child(
|
||||
Button::new("wait_for_connection")
|
||||
.label("Waiting for connection")
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(true)
|
||||
.disabled(true),
|
||||
)
|
||||
.child(
|
||||
Button::new("use_url")
|
||||
.label("Use Bunker URL")
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
||||
)
|
||||
.border(
|
||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
||||
)
|
||||
.hover(
|
||||
cx.theme().base.step(cx, ColorScaleStep::FOUR),
|
||||
)
|
||||
.active(
|
||||
cx.theme().base.step(cx, ColorScaleStep::FIVE),
|
||||
)
|
||||
.foreground(
|
||||
cx.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::TWELVE),
|
||||
),
|
||||
)
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open(PageKind::Bunker, window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.w_full()
|
||||
.h_px()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open(PageKind::Selection, window, cx);
|
||||
})),
|
||||
),
|
||||
PageKind::Bunker => this
|
||||
.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Bunker URL:")
|
||||
.child(self.bunker_input.clone())
|
||||
.when_some(
|
||||
self.error_message.read(cx).as_ref(),
|
||||
|this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.my_1()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Login")
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(self.is_loading)
|
||||
.disabled(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.connect(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.w_full()
|
||||
.h_px()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open(PageKind::Selection, window, cx);
|
||||
})),
|
||||
),
|
||||
PageKind::Selection => this
|
||||
.child(
|
||||
Button::new("login_connect_btn")
|
||||
.label("Login with Nostr Connect")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.wait_for_connection(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("join_btn")
|
||||
.label("Are you new? Join here!")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(|_, _, cx| {
|
||||
cx.open_url(NSTART_URL);
|
||||
}),
|
||||
),
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
use async_utility::task::spawn;
|
||||
use common::utils::nip96_upload;
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render,
|
||||
SharedString, Styled, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::TextInput,
|
||||
popup_menu::PopupMenu,
|
||||
ContextModal, Disableable, Sizable, Size,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Arc<Entity<Profile>> {
|
||||
Arc::new(Profile::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct Profile {
|
||||
profile: Option<Metadata>,
|
||||
// Form
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
website_input: Entity<TextInput>,
|
||||
is_loading: bool,
|
||||
is_submitting: bool,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
let name_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.placeholder("Alice")
|
||||
});
|
||||
|
||||
let avatar_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.small()
|
||||
.placeholder("https://example.com/avatar.jpg")
|
||||
});
|
||||
|
||||
let website_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.placeholder("https://your-website.com")
|
||||
});
|
||||
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let this = Self {
|
||||
name_input,
|
||||
avatar_input,
|
||||
bio_input,
|
||||
website_input,
|
||||
profile: None,
|
||||
is_loading: false,
|
||||
is_submitting: false,
|
||||
name: "Profile".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
};
|
||||
|
||||
let client = get_client();
|
||||
let task: Task<Result<Option<Metadata>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?;
|
||||
|
||||
Ok(metadata)
|
||||
});
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(Some(metadata)) = task.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this: &mut Profile, cx| {
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
if let Some(avatar) = metadata.picture.as_ref() {
|
||||
this.set_text(avatar, window, cx);
|
||||
}
|
||||
});
|
||||
this.bio_input.update(cx, |this, cx| {
|
||||
if let Some(bio) = metadata.about.as_ref() {
|
||||
this.set_text(bio, window, cx);
|
||||
}
|
||||
});
|
||||
this.name_input.update(cx, |this, cx| {
|
||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||
this.set_text(display_name, window, cx);
|
||||
}
|
||||
});
|
||||
this.website_input.update(cx, |this, cx| {
|
||||
if let Some(website) = metadata.website.as_ref() {
|
||||
this.set_text(website, window, cx);
|
||||
}
|
||||
});
|
||||
this.profile = Some(metadata);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let avatar_input = self.avatar_input.downgrade();
|
||||
let window_handle = window.window_handle();
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let path = paths.pop().unwrap();
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
spawn(async move {
|
||||
let client = get_client();
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
cx.update_window(window_handle, |_, window, cx| {
|
||||
// Stop loading spinner
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
})
|
||||
.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Stop loading spinner
|
||||
if let Some(view) = this.upgrade() {
|
||||
cx.update_entity(&view, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
let avatar = self.avatar_input.read(cx).text().to_string();
|
||||
let name = self.name_input.read(cx).text().to_string();
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
let website = self.website_input.read(cx).text().to_string();
|
||||
|
||||
let old_metadata = if let Some(metadata) = self.profile.as_ref() {
|
||||
metadata.clone()
|
||||
} else {
|
||||
Metadata::default()
|
||||
};
|
||||
|
||||
let mut new_metadata = old_metadata.display_name(name).about(bio);
|
||||
|
||||
if let Ok(url) = Url::from_str(&avatar) {
|
||||
new_metadata = new_metadata.picture(url);
|
||||
};
|
||||
|
||||
if let Ok(url) = Url::from_str(&website) {
|
||||
new_metadata = new_metadata.website(url);
|
||||
}
|
||||
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<EventId>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(output) = client.set_metadata(&new_metadata).await {
|
||||
_ = tx.send(output.val);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if rx.await.is_ok() {
|
||||
cx.update_window(window_handle, |_, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
window.push_notification("Your profile has been updated successfully", cx);
|
||||
})
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Profile {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"ProfilePanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Profile {}
|
||||
|
||||
impl Focusable for Profile {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Profile {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.px_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h_24()
|
||||
.map(|this| {
|
||||
let picture = self.avatar_input.read(cx).text();
|
||||
|
||||
if picture.is_empty() {
|
||||
this.child(
|
||||
img("brand/avatar.jpg")
|
||||
.size_10()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE,
|
||||
self.avatar_input.read(cx).text()
|
||||
))
|
||||
.size_10()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.child(self.avatar_input.clone())
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.label("Upload")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.is_submitting)
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Name:")
|
||||
.child(self.name_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Website:")
|
||||
.child(self.website_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div().flex().items_center().justify_end().child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.small()
|
||||
.disabled(self.is_loading)
|
||||
.loading(self.is_submitting)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use global::{constants::NEW_MESSAGE_SUB_ID, get_client};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, uniform_list, App, AppContext, Context, Entity, FocusHandle,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, Task, TextAlign,
|
||||
Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, IconName, Sizable,
|
||||
};
|
||||
|
||||
use crate::device::Device;
|
||||
|
||||
const MESSAGE: &str = "In order to receive messages from others, you need to setup Messaging Relays. You can use the recommend relays or add more.";
|
||||
const HELP_TEXT: &str = "Please add some relays.";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
|
||||
Relays::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Relays {
|
||||
relays: Entity<Vec<RelayUrl>>,
|
||||
input: Entity<TextInput>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Relays {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let client = get_client();
|
||||
|
||||
let relays = cx.new(|cx| {
|
||||
let relays = vec![
|
||||
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
|
||||
RelayUrl::parse("wss://relay.0xchat.com").unwrap(),
|
||||
];
|
||||
|
||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let relays = event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| match t {
|
||||
TagStandard::Relay(url) => Some(url.to_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(relays)
|
||||
} else {
|
||||
Err(anyhow!("Messaging Relays not found."))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(relays) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this: &mut Vec<RelayUrl>, cx| {
|
||||
*this = relays;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
relays
|
||||
});
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::XSmall)
|
||||
.small()
|
||||
.placeholder("wss://example.com")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Relays, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
relays,
|
||||
input,
|
||||
subscriptions,
|
||||
is_loading: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// If user didn't have any NIP-65 relays, add default ones
|
||||
if client.database().relay_list(public_key).await?.is_empty() {
|
||||
let builder = EventBuilder::relay_list(vec![
|
||||
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
|
||||
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
|
||||
]);
|
||||
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send relay list event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let tags: Vec<Tag> = relays
|
||||
.iter()
|
||||
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
|
||||
.collect();
|
||||
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||
let output = client.send_event_builder(builder).await?;
|
||||
|
||||
// Connect to messaging relays
|
||||
for relay in relays.into_iter() {
|
||||
_ = client.add_relay(&relay).await;
|
||||
_ = client.connect_relay(&relay).await;
|
||||
}
|
||||
|
||||
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
|
||||
// Close old subscription
|
||||
client.unsubscribe(&sub_id).await;
|
||||
|
||||
// Subscribe to new messages
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id(
|
||||
sub_id,
|
||||
Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(public_key)
|
||||
.limit(0),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe to new messages: {}", e);
|
||||
}
|
||||
|
||||
Ok(output.val)
|
||||
});
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if task.await.is_ok() {
|
||||
cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
if let Some(device) = Device::global(cx) {
|
||||
let relays = this
|
||||
.read_with(cx, |this, cx| this.relays.read(cx).clone())
|
||||
.unwrap();
|
||||
|
||||
device.update(cx, |this, cx| {
|
||||
if let Some(profile) = this.profile() {
|
||||
let new_profile = profile.clone().relays(Some(relays.into()));
|
||||
this.set_profile(new_profile, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.close_modal(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn loading(&self) -> bool {
|
||||
self.is_loading
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).text().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
self.relays.update(cx, |this, cx| {
|
||||
if !this.contains(&url) {
|
||||
this.push(url);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.relays.update(cx, |this, cx| {
|
||||
this.remove(ix);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Relays {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(MESSAGE),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
Button::new("add_relay_btn")
|
||||
.icon(IconName::Plus)
|
||||
.small()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.add(window, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
let view = cx.entity();
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let total = relays.len();
|
||||
|
||||
if !relays.is_empty() {
|
||||
this.child(
|
||||
uniform_list(
|
||||
view,
|
||||
"relays",
|
||||
total,
|
||||
move |_, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = relays.get(ix).unwrap().clone().to_string();
|
||||
|
||||
items.push(
|
||||
div().group("").w_full().h_9().py_0p5().child(
|
||||
div()
|
||||
.px_2()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx
|
||||
.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::THREE))
|
||||
.text_xs()
|
||||
.child(item)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| {
|
||||
this.visible()
|
||||
})
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.remove(ix, window, cx)
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.w_full()
|
||||
.min_h(px(120.)),
|
||||
)
|
||||
} else {
|
||||
this.h_20()
|
||||
.mb_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(HELP_TEXT)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
use gpui::{
|
||||
div, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Settings> {
|
||||
Settings::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Settings {
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self {
|
||||
name: "Settings".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Settings {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"SettingsPanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Settings {}
|
||||
|
||||
impl Focusable for Settings {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Settings {
|
||||
fn render(&mut self, _window: &mut gpui::Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child("Settings")
|
||||
}
|
||||
}
|
||||
@@ -1,515 +0,0 @@
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{profile::NostrProfile, utils::random_name};
|
||||
use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client};
|
||||
use gpui::{
|
||||
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
|
||||
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextAlign,
|
||||
Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::Timer;
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Icon, IconName, Sizable, Size, StyledExt,
|
||||
};
|
||||
|
||||
const DESCRIPTION: &str =
|
||||
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
struct SelectContact(PublicKey);
|
||||
|
||||
impl_internal_actions!(contacts, [SelectContact]);
|
||||
|
||||
pub struct Compose {
|
||||
title_input: Entity<TextInput>,
|
||||
user_input: Entity<TextInput>,
|
||||
contacts: Entity<Vec<NostrProfile>>,
|
||||
selected: Entity<HashSet<PublicKey>>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
is_submitting: bool,
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Compose {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
|
||||
let contacts = cx.new(|_| Vec::new());
|
||||
let selected = cx.new(|_| HashSet::new());
|
||||
let error_message = cx.new(|_| None);
|
||||
|
||||
let title_input = cx.new(|cx| {
|
||||
let name = random_name(2);
|
||||
let mut input = TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.text_size(Size::XSmall);
|
||||
|
||||
input.set_placeholder("Family... . (Optional)");
|
||||
input.set_text(name, window, cx);
|
||||
input
|
||||
});
|
||||
|
||||
let user_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::Small)
|
||||
.small()
|
||||
.placeholder("npub1...")
|
||||
});
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
// Handle Enter event for user input
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&user_input,
|
||||
window,
|
||||
move |this, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await.unwrap();
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
|
||||
.collect();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(contacts) = rx.await {
|
||||
_ = cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.extend(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
title_input,
|
||||
user_input,
|
||||
contacts,
|
||||
selected,
|
||||
error_message,
|
||||
is_loading: false,
|
||||
is_submitting: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected.read(cx).is_empty() {
|
||||
self.set_error(Some("You need to add at least 1 receiver".into()), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
// Get all pubkeys
|
||||
let pubkeys: Vec<PublicKey> = self.selected.read(cx).iter().copied().collect();
|
||||
|
||||
// Convert selected pubkeys into Nostr tags
|
||||
let mut tag_list: Vec<Tag> = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect();
|
||||
|
||||
// Add subject if it is present
|
||||
if !self.title_input.read(cx).text().is_empty() {
|
||||
tag_list.push(Tag::custom(
|
||||
TagKind::Subject,
|
||||
vec![self.title_input.read(cx).text().to_string()],
|
||||
));
|
||||
}
|
||||
|
||||
let tags = Tags::from_list(tag_list);
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
let event: Task<Result<Event, anyhow::Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
// [IMPORTANT]
|
||||
// Make sure this event is never send,
|
||||
// this event existed just use for convert to Coop's Chat Room later.
|
||||
let event = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "")
|
||||
.tags(tags)
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
Ok(event)
|
||||
});
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(event) = event.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
// Stop loading spinner
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
});
|
||||
|
||||
let Some(chats) = ChatRegistry::global(cx) else {
|
||||
return;
|
||||
};
|
||||
let room = Room::new(&event, cx);
|
||||
|
||||
chats.update(cx, |state, cx| {
|
||||
match state.push_room(room, cx) {
|
||||
Ok(_) => {
|
||||
// TODO: automatically open newly created chat panel
|
||||
window.close_modal(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn label(&self, _window: &Window, cx: &App) -> SharedString {
|
||||
if self.selected.read(cx).len() > 1 {
|
||||
"Create Group DM".into()
|
||||
} else {
|
||||
"Create DM".into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_submitting(&self) -> bool {
|
||||
self.is_submitting
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
let window_handle = window.window_handle();
|
||||
let content = self.user_input.read(cx).text().to_string();
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let task: Task<Result<NostrProfile, anyhow::Error>> = if content.contains("@") {
|
||||
cx.background_spawn(async move {
|
||||
let profile = nip05::profile(&content, None).await?;
|
||||
let public_key = profile.public_key;
|
||||
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(NostrProfile::new(public_key, metadata))
|
||||
})
|
||||
} else {
|
||||
let Ok(public_key) = PublicKey::parse(&content) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error(Some("Public Key is not valid".into()), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(NostrProfile::new(public_key, metadata))
|
||||
})
|
||||
};
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
match task.await {
|
||||
Ok(profile) => {
|
||||
let public_key = profile.public_key;
|
||||
|
||||
_ = cx
|
||||
.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
// Create a device announcement filter
|
||||
let device = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND))
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Only subscribe to the latest device announcement
|
||||
client.subscribe(device, Some(opts)).await
|
||||
})
|
||||
.await;
|
||||
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
let public_key = profile.public_key;
|
||||
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, profile);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
this.selected.update(cx, |this, cx| {
|
||||
this.insert(public_key);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Stop loading indicator
|
||||
this.set_loading(false, cx);
|
||||
|
||||
// Clear input
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
_ = cx.update_window(window_handle, |_, _, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
|
||||
self.error_message.update(cx, |this, cx| {
|
||||
*this = error;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Dismiss error after 2 seconds
|
||||
cx.spawn(|this, cx| async move {
|
||||
Timer::after(Duration::from_secs(2)).await;
|
||||
|
||||
_ = cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(None, cx);
|
||||
})
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_action_select(
|
||||
&mut self,
|
||||
action: &SelectContact,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.selected.update(cx, |this, cx| {
|
||||
if this.contains(&action.0) {
|
||||
this.remove(&action.0);
|
||||
} else {
|
||||
this.insert(action.0);
|
||||
};
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Compose {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_action_select))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(DESCRIPTION),
|
||||
)
|
||||
.when_some(self.error_message.read(cx).as_ref(), |this, msg| {
|
||||
this.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(msg.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div().flex().flex_col().child(
|
||||
div()
|
||||
.h_10()
|
||||
.px_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(div().text_xs().font_semibold().child("Title:"))
|
||||
.child(self.title_input.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(div().px_2().text_xs().font_semibold().child("To:"))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(
|
||||
Button::new("add_user_to_compose_btn")
|
||||
.icon(IconName::Plus)
|
||||
.small()
|
||||
.rounded(ButtonRounded::Size(px(9999.)))
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.add(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(self.user_input.clone()),
|
||||
)
|
||||
.map(|this| {
|
||||
let contacts = self.contacts.read(cx).clone();
|
||||
let view = cx.entity();
|
||||
|
||||
if contacts.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_24()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("No contacts"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child("Your recently contacts will appear here."),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
uniform_list(
|
||||
view,
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
move |this, range, _window, cx| {
|
||||
let selected = this.selected.read(cx);
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = contacts.get(ix).unwrap().clone();
|
||||
let is_select = selected.contains(&item.public_key);
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.h_9()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div().flex_shrink_0().child(
|
||||
img(item.avatar).size_6(),
|
||||
),
|
||||
)
|
||||
.child(item.name),
|
||||
)
|
||||
.when(is_select, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CircleCheck)
|
||||
.size_3()
|
||||
.text_color(cx.theme().base.step(
|
||||
cx,
|
||||
ColorScaleStep::TWELVE,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.hover(|this| {
|
||||
this.bg(cx
|
||||
.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::THREE))
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(SelectContact(
|
||||
item.public_key,
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.min_h(px(250.)),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use compose::Compose;
|
||||
use gpui::{
|
||||
div, img, percentage, prelude::FluentBuilder, px, relative, uniform_list, AnyElement, App,
|
||||
AppContext, Context, Div, Empty, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Stateful,
|
||||
StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
skeleton::Skeleton,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use super::app::AddPanel;
|
||||
|
||||
mod compose;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||
Sidebar::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Sidebar {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
label: SharedString,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let label = SharedString::from("Inbox");
|
||||
|
||||
Self {
|
||||
name: "Sidebar".into(),
|
||||
is_collapsed: false,
|
||||
focus_handle,
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |modal, window, cx| {
|
||||
let label = compose.read(cx).label(window, cx);
|
||||
let is_submitting = compose.read(cx).is_submitting();
|
||||
|
||||
modal
|
||||
.title("Direct Messages")
|
||||
.width(px(420.))
|
||||
.child(compose.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("create_dm_btn")
|
||||
.label(label)
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_submitting)
|
||||
.disabled(is_submitting)
|
||||
.on_click(window.listener_for(&compose, |this, _, window, cx| {
|
||||
this.compose(window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_room(&self, ix: usize, room: &Entity<Room>, cx: &Context<Self>) -> Stateful<Div> {
|
||||
let room = room.read(cx);
|
||||
|
||||
div()
|
||||
.id(ix)
|
||||
.px_1()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_xs()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
|
||||
.child(div().flex_1().truncate().font_medium().map(|this| {
|
||||
if room.is_group() {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
|
||||
.child(Icon::new(IconName::GroupFill).size_3().text_color(
|
||||
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
|
||||
)),
|
||||
)
|
||||
.when_some(room.name(), |this, name| this.child(name))
|
||||
} else {
|
||||
this.when_some(room.first_member(), |this, member| {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
img(member.avatar.clone())
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(member.name.clone())
|
||||
})
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(room.ago()),
|
||||
)
|
||||
.on_click({
|
||||
let id = room.id;
|
||||
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.open(id, window, cx);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
(0..total).map(|_| {
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
|
||||
.child(Skeleton::new().w_20().h_3().rounded_sm())
|
||||
})
|
||||
}
|
||||
|
||||
fn open(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(
|
||||
super::app::PanelKind::Room(id),
|
||||
ui::dock_area::dock::DockPlacement::Center,
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Sidebar {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"Sidebar".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Sidebar {}
|
||||
|
||||
impl Focusable for Sidebar {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let entity = cx.entity();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.py_3()
|
||||
.w_full()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("new_message")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.h_7()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.size_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
.child(
|
||||
Icon::new(IconName::ComposeFill)
|
||||
.small()
|
||||
.text_color(cx.theme().base.darken(cx)),
|
||||
),
|
||||
)
|
||||
.child("New Message")
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
// Open compose modal
|
||||
this.render_compose(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(Empty),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("inbox_header")
|
||||
.px_1()
|
||||
.h_7()
|
||||
.flex()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.size_6()
|
||||
.when(self.is_collapsed, |this| {
|
||||
this.rotate(percentage(270. / 360.))
|
||||
}),
|
||||
)
|
||||
.child(self.label.clone())
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(move |view, _event, _window, cx| {
|
||||
view.is_collapsed = !view.is_collapsed;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.when(!self.is_collapsed, |this| {
|
||||
this.flex_1()
|
||||
.w_full()
|
||||
.when_some(ChatRegistry::global(cx), |this, state| {
|
||||
let is_loading = state.read(cx).is_loading();
|
||||
let len = state.read(cx).rooms().len();
|
||||
|
||||
if is_loading {
|
||||
this.children(self.render_skeleton(5))
|
||||
} else if state.read(cx).rooms().is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.px_1()
|
||||
.w_full()
|
||||
.h_20()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("No chats"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(
|
||||
cx.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child("Recent chats will appear here."),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
uniform_list(
|
||||
entity,
|
||||
"rooms",
|
||||
len,
|
||||
move |this, range, _, cx| {
|
||||
let mut items = vec![];
|
||||
|
||||
for ix in range {
|
||||
if let Some(room) =
|
||||
state.read(cx).rooms().get(ix)
|
||||
{
|
||||
items.push(this.render_room(ix, room, cx));
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use gpui::{
|
||||
div, svg, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window,
|
||||
};
|
||||
use ui::theme::{scale::ColorScaleStep, ActiveTheme};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
|
||||
Startup::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Startup {}
|
||||
|
||||
impl Startup {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Startup {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
}
|
||||
}
|
||||
11
crates/assets/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "assets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
rust-embed.workspace = true
|
||||
59
crates/assets/src/lib.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../assets"]
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
|
||||
Self::get(path)
|
||||
.map(|f| Some(f.data))
|
||||
.with_context(|| format!("loading asset at path {path:?}"))
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
Ok(Self::iter()
|
||||
.filter_map(|p| {
|
||||
if p.starts_with(path) {
|
||||
Some(p.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
|
||||
pub fn load_test_fonts(&self, cx: &App) {
|
||||
cx.text_system()
|
||||
.add_fonts(vec![self
|
||||
.load("fonts/plex-mono/ZedPlexMono-Regular.ttf")
|
||||
.unwrap()
|
||||
.unwrap()])
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
[package]
|
||||
name = "chats"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
oneshot.workspace = true
|
||||
log.workspace = true
|
||||
[package]
|
||||
name = "auto_update"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
cargo-packager-updater = "0.2.3"
|
||||
157
crates/auto_update/src/lib.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use anyhow::Error;
|
||||
use cargo_packager_updater::semver::Version;
|
||||
use cargo_packager_updater::{check_update, Config, Update};
|
||||
use global::constants::{APP_PUBKEY, APP_UPDATER_ENDPOINT};
|
||||
use gpui::http_client::Url;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdater {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AutoUpdateStatus {
|
||||
Idle,
|
||||
Checking,
|
||||
Checked { update: Box<Update> },
|
||||
Installing,
|
||||
Updated,
|
||||
Errored { msg: Box<String> },
|
||||
}
|
||||
|
||||
impl AutoUpdateStatus {
|
||||
pub fn is_updating(&self) -> bool {
|
||||
matches!(self, Self::Checked { .. } | Self::Installing)
|
||||
}
|
||||
|
||||
pub fn is_updated(&self) -> bool {
|
||||
matches!(self, Self::Updated)
|
||||
}
|
||||
|
||||
pub fn checked(update: Update) -> Self {
|
||||
Self::Checked {
|
||||
update: Box::new(update),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(e: String) -> Self {
|
||||
Self::Errored { msg: Box::new(e) }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AutoUpdater {
|
||||
pub status: AutoUpdateStatus,
|
||||
config: Config,
|
||||
version: Version,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Retrieve the Global Auto Updater instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||
}
|
||||
|
||||
/// Retrieve the Auto Updater instance
|
||||
pub fn read_global(cx: &App) -> &Self {
|
||||
cx.global::<GlobalAutoUpdater>().0.read(cx)
|
||||
}
|
||||
|
||||
/// Set the Global Auto Updater instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAutoUpdater(state));
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let config = cargo_packager_updater::Config {
|
||||
endpoints: vec![Url::parse(APP_UPDATER_ENDPOINT).expect("Endpoint is not valid")],
|
||||
pubkey: String::from(APP_PUBKEY),
|
||||
..Default::default()
|
||||
};
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).expect("Failed to parse version");
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.check_for_updates(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
status: AutoUpdateStatus::Idle,
|
||||
version,
|
||||
config,
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_for_updates(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let config = self.config.clone();
|
||||
let current_version = self.version.clone();
|
||||
|
||||
log::info!("Checking for updates...");
|
||||
self.set_status(AutoUpdateStatus::Checking, cx);
|
||||
|
||||
let checking: Task<Result<Option<Update>, Error>> = cx.background_spawn(async move {
|
||||
if let Some(update) = check_update(current_version, config)? {
|
||||
Ok(Some(update))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Some(update)) = checking.await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_status(AutoUpdateStatus::checked(update), cx);
|
||||
this.install_update(window, cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn install_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_status(AutoUpdateStatus::Installing, cx);
|
||||
|
||||
if let AutoUpdateStatus::Checked { update } = self.status.clone() {
|
||||
let install: Task<Result<(), Error>> =
|
||||
cx.background_spawn(async move { Ok(update.download_and_install()?) });
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match install.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Updated, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod registry;
|
||||
pub mod room;
|
||||
@@ -1,174 +0,0 @@
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use common::{last_seen::LastSeen, utils::room_hash};
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, WeakEntity};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::room::{IncomingEvent, Room};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::register(cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
|
||||
impl Global for GlobalChatRegistry {}
|
||||
|
||||
pub struct ChatRegistry {
|
||||
rooms: Vec<Entity<Room>>,
|
||||
is_loading: bool,
|
||||
}
|
||||
|
||||
impl ChatRegistry {
|
||||
pub fn global(cx: &mut App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalChatRegistry>()
|
||||
.map(|global| global.0.clone())
|
||||
}
|
||||
|
||||
pub fn register(cx: &mut App) -> Entity<Self> {
|
||||
Self::global(cx).unwrap_or_else(|| {
|
||||
let entity = cx.new(Self::new);
|
||||
|
||||
// Set global state
|
||||
cx.set_global(GlobalChatRegistry(entity.clone()));
|
||||
|
||||
entity
|
||||
})
|
||||
}
|
||||
|
||||
fn new(_cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
rooms: vec![],
|
||||
is_loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_rooms_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
|
||||
self.rooms.iter().map(|room| room.read(cx).id).collect()
|
||||
}
|
||||
|
||||
pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
|
||||
let task: Task<Result<Vec<Event>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let send = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.author(public_key);
|
||||
|
||||
let recv = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.pubkey(public_key);
|
||||
|
||||
let send_events = client.database().query(send).await?;
|
||||
let recv_events = client.database().query(recv).await?;
|
||||
let events = send_events.merge(recv_events);
|
||||
|
||||
let result: Vec<Event> = events
|
||||
.into_iter()
|
||||
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
|
||||
.unique_by(room_hash)
|
||||
.sorted_by_key(|ev| Reverse(ev.created_at))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
});
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(events) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
if !events.is_empty() {
|
||||
let current_ids = this.current_rooms_ids(cx);
|
||||
let items: Vec<Entity<Room>> = events
|
||||
.into_iter()
|
||||
.filter_map(|ev| {
|
||||
let new = room_hash(&ev);
|
||||
// Filter all seen rooms
|
||||
if !current_ids.iter().any(|this| this == &new) {
|
||||
Some(Room::new(&ev, cx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
this.is_loading = false;
|
||||
|
||||
this.rooms.extend(items);
|
||||
this.rooms
|
||||
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
} else {
|
||||
this.is_loading = false;
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn rooms(&self) -> &[Entity<Room>] {
|
||||
&self.rooms
|
||||
}
|
||||
|
||||
pub fn is_loading(&self) -> bool {
|
||||
self.is_loading
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.find(|model| model.read(cx).id == *id)
|
||||
.map(|room| room.downgrade())
|
||||
}
|
||||
|
||||
pub fn push_room(
|
||||
&mut self,
|
||||
room: Entity<Room>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
if !self
|
||||
.rooms
|
||||
.iter()
|
||||
.any(|current| current.read(cx) == room.read(cx))
|
||||
{
|
||||
self.rooms.insert(0, room);
|
||||
cx.notify();
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Room already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let id = room_hash(&event);
|
||||
|
||||
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.last_seen = LastSeen(event.created_at);
|
||||
cx.emit(IncomingEvent { event });
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Re-sort rooms by last seen
|
||||
self.rooms
|
||||
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
} else {
|
||||
let new_room = Room::new(&event, cx);
|
||||
|
||||
// Push the new room to the front of the list
|
||||
self.rooms.insert(0, new_room);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{
|
||||
last_seen::LastSeen,
|
||||
profile::NostrProfile,
|
||||
utils::{device_pubkey, room_hash},
|
||||
};
|
||||
use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client, get_device_keys};
|
||||
use gpui::{App, AppContext, Entity, EventEmitter, SharedString, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingEvent {
|
||||
pub event: Event,
|
||||
}
|
||||
|
||||
pub struct Room {
|
||||
pub id: u64,
|
||||
pub last_seen: LastSeen,
|
||||
/// Subject of the room
|
||||
pub name: Option<SharedString>,
|
||||
/// All members of the room
|
||||
pub members: SmallVec<[NostrProfile; 2]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<IncomingEvent> for Room {}
|
||||
|
||||
impl PartialEq for Room {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub fn new(event: &Event, cx: &mut App) -> Entity<Self> {
|
||||
let id = room_hash(event);
|
||||
let last_seen = LastSeen(event.created_at);
|
||||
|
||||
// Get the subject from the event's tags
|
||||
let name = if let Some(tag) = event.tags.find(TagKind::Subject) {
|
||||
tag.content().map(|s| s.to_owned().into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create a task for loading metadata
|
||||
let load_metadata = Self::load_metadata(event, cx);
|
||||
|
||||
let room = cx.new(|cx| {
|
||||
let this = Self {
|
||||
id,
|
||||
last_seen,
|
||||
name,
|
||||
members: smallvec![],
|
||||
};
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(profiles) = load_metadata.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this: &mut Room, cx| {
|
||||
// Update the room's name if it's not already set
|
||||
if this.name.is_none() {
|
||||
let mut name = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|profile| profile.name.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
if profiles.len() > 2 {
|
||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||
}
|
||||
|
||||
this.name = Some(name.into())
|
||||
};
|
||||
// Update the room's members
|
||||
this.members.extend(profiles);
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
this
|
||||
});
|
||||
|
||||
room
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Get room's member by public key
|
||||
pub fn member(&self, public_key: &PublicKey) -> Option<NostrProfile> {
|
||||
self.members
|
||||
.iter()
|
||||
.find(|m| &m.public_key == public_key)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get room's first member's public key
|
||||
pub fn first_member(&self) -> Option<&NostrProfile> {
|
||||
self.members.first()
|
||||
}
|
||||
|
||||
/// Collect room's member's public keys
|
||||
pub fn public_keys(&self) -> Vec<PublicKey> {
|
||||
self.members.iter().map(|m| m.public_key).collect()
|
||||
}
|
||||
|
||||
/// Get room's display name
|
||||
pub fn name(&self) -> Option<SharedString> {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
/// Determine if room is a group
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.members.len() > 2
|
||||
}
|
||||
|
||||
/// Get room's last seen
|
||||
pub fn last_seen(&self) -> LastSeen {
|
||||
self.last_seen
|
||||
}
|
||||
|
||||
/// Get room's last seen as ago format
|
||||
pub fn ago(&self) -> SharedString {
|
||||
self.last_seen.ago()
|
||||
}
|
||||
|
||||
/// Sync inbox relays for all room's members
|
||||
pub fn verify_inbox_relays(&self, cx: &App) -> Task<Result<Vec<(PublicKey, bool)>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = self.public_keys();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut result = Vec::with_capacity(pubkeys.len());
|
||||
|
||||
for pubkey in pubkeys.into_iter() {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(pubkey)
|
||||
.limit(1);
|
||||
|
||||
let is_ready = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|events| events.first_owned())
|
||||
.is_some();
|
||||
|
||||
result.push((pubkey, is_ready));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
/// Send message to all room's members
|
||||
///
|
||||
/// NIP-4e: Message will be signed by the device signer
|
||||
pub fn send_message(&self, content: String, cx: &App) -> Task<Result<Vec<String>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = self.public_keys();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let Some(device) = get_device_keys().await else {
|
||||
return Err(anyhow!("Device not found. Please restart the application."));
|
||||
};
|
||||
|
||||
let user_signer = client.signer().await?;
|
||||
let user_pubkey = user_signer.get_public_key().await?;
|
||||
|
||||
let mut report = Vec::with_capacity(pubkeys.len());
|
||||
|
||||
let tags: Vec<Tag> = pubkeys
|
||||
.iter()
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &user_pubkey {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for pubkey in pubkeys.iter() {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND))
|
||||
.author(*pubkey)
|
||||
.limit(1);
|
||||
|
||||
// Check if the pubkey has a device announcement,
|
||||
// then choose the appropriate signer based on device presence
|
||||
let event = match client.database().query(filter).await?.first() {
|
||||
Some(event) => {
|
||||
log::info!("Use device signer to send message");
|
||||
let signer = &device;
|
||||
// Get the device's public key of other user
|
||||
let device_pubkey = device_pubkey(event)?;
|
||||
|
||||
let rumor = EventBuilder::private_msg_rumor(*pubkey, &content)
|
||||
.tags(tags.clone())
|
||||
.build(user_pubkey);
|
||||
|
||||
EventBuilder::gift_wrap(
|
||||
signer,
|
||||
&device_pubkey,
|
||||
rumor,
|
||||
vec![Tag::public_key(*pubkey)],
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
log::info!("Use user signer to send message");
|
||||
let signer = client.signer().await?;
|
||||
|
||||
EventBuilder::private_msg(&signer, *pubkey, &content, tags.clone()).await?
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = client.send_event(&event).await {
|
||||
report.push(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
})
|
||||
}
|
||||
|
||||
/// Load metadata for all members
|
||||
pub fn load_messages(&self, cx: &App) -> Task<Result<Events, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = self.public_keys();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.authors(pubkeys.iter().copied())
|
||||
.pubkeys(pubkeys);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let query = client.database().query(filter).await?;
|
||||
Ok(query)
|
||||
})
|
||||
}
|
||||
|
||||
/// Load metadata for all members
|
||||
fn load_metadata(event: &Event, cx: &App) -> Task<Result<Vec<NostrProfile>, Error>> {
|
||||
let client = get_client();
|
||||
let mut pubkeys = vec![];
|
||||
|
||||
// Get all pubkeys from event's tags
|
||||
pubkeys.extend(event.tags.public_keys().collect::<HashSet<_>>());
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let signer_pubkey = signer.get_public_key().await?;
|
||||
let mut profiles = vec![];
|
||||
|
||||
for public_key in pubkeys.into_iter() {
|
||||
if let Ok(result) = client.database().metadata(public_key).await {
|
||||
let metadata = result.unwrap_or_default();
|
||||
let profile = NostrProfile::new(public_key, metadata);
|
||||
|
||||
if public_key == signer_pubkey {
|
||||
profiles.push(profile);
|
||||
} else {
|
||||
profiles.insert(0, profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
})
|
||||
}
|
||||
}
|
||||
14
crates/client_keys/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "client_keys"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
global = { path = "../global" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
139
crates/client_keys/src/lib.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use global::constants::KEYRING_URL;
|
||||
use global::first_run;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ClientKeys::set_global(cx.new(ClientKeys::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalClientKeys(Entity<ClientKeys>);
|
||||
|
||||
impl Global for GlobalClientKeys {}
|
||||
|
||||
pub struct ClientKeys {
|
||||
keys: Option<Keys>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl ClientKeys {
|
||||
/// Retrieve the Global Client Keys instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalClientKeys>().0.clone()
|
||||
}
|
||||
|
||||
/// Retrieve the Client Keys instance
|
||||
pub fn read_global(cx: &App) -> &Self {
|
||||
cx.global::<GlobalClientKeys>().0.read(cx)
|
||||
}
|
||||
|
||||
/// Set the Global Client Keys instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalClientKeys(state));
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
keys: None,
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Prevent macOS from asking for password every time
|
||||
// Only for debug builds
|
||||
if cfg!(debug_assertions) && cfg!(target_os = "macos") {
|
||||
log::warn!("Running debug build on macOS");
|
||||
log::warn!("Skipping keychain access, generating new client keys");
|
||||
self.new_keys(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let read_client_keys = cx.read_credentials(KEYRING_URL);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Some((_, secret))) = read_client_keys.await {
|
||||
// Update the client keys with the stored secret key from the keychain
|
||||
this.update(cx, |this, cx| {
|
||||
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
|
||||
this.set_keys(None, false, true, cx);
|
||||
return;
|
||||
};
|
||||
let keys = Keys::new(secret_key);
|
||||
this.set_keys(Some(keys), false, true, cx);
|
||||
})
|
||||
.ok();
|
||||
} else if *first_run() {
|
||||
// If this is the first run, generate new keys and use them for the client keys
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_keys(cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_keys(None, false, true, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn set_keys(
|
||||
&mut self,
|
||||
keys: Option<Keys>,
|
||||
persist: bool,
|
||||
notify: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if persist {
|
||||
if let Some(keys) = keys.as_ref() {
|
||||
let username = keys.public_key().to_hex();
|
||||
let password = keys.secret_key().secret_bytes();
|
||||
let write_keys = cx.write_credentials(KEYRING_URL, &username, &password);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = write_keys.await {
|
||||
log::error!("Failed to save the client keys: {e}")
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
self.keys = keys;
|
||||
|
||||
// Notify GPUI to reload UI
|
||||
if notify {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_keys(&mut self, cx: &mut Context<Self>) {
|
||||
self.set_keys(Some(Keys::generate()), true, true, cx);
|
||||
}
|
||||
|
||||
pub fn force_new_keys(&mut self, cx: &mut Context<Self>) {
|
||||
self.set_keys(Some(Keys::generate()), true, false, cx);
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> Keys {
|
||||
self.keys
|
||||
.clone()
|
||||
.expect("Keys should always be initialized")
|
||||
}
|
||||
|
||||
pub fn has_keys(&self) -> bool {
|
||||
self.keys.is_some()
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
reqwest.workspace = true
|
||||
log.workspace = true
|
||||
webbrowser.workspace = true
|
||||
|
||||
random_name_generator = "0.3.6"
|
||||
qrcode-generator = "5.0.0"
|
||||
qrcode = "0.14.1"
|
||||
|
||||
66
crates/common/src/debounced_delay.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::FutureExt;
|
||||
use gpui::{Context, Task, Window};
|
||||
|
||||
pub struct DebouncedDelay<E: 'static> {
|
||||
task: Option<Task<()>>,
|
||||
cancel_channel: Option<oneshot::Sender<()>>,
|
||||
_phantom_data: PhantomData<E>,
|
||||
}
|
||||
|
||||
impl<E: 'static> Default for DebouncedDelay<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: 'static> DebouncedDelay<E> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
task: None,
|
||||
cancel_channel: None,
|
||||
_phantom_data: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fire_new<F>(
|
||||
&mut self,
|
||||
delay: Duration,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<E>,
|
||||
func: F,
|
||||
) where
|
||||
F: 'static + Send + FnOnce(&mut E, &mut Window, &mut Context<E>) -> Task<()>,
|
||||
{
|
||||
if let Some(channel) = self.cancel_channel.take() {
|
||||
_ = channel.send(());
|
||||
}
|
||||
|
||||
let (sender, mut receiver) = oneshot::channel::<()>();
|
||||
self.cancel_channel = Some(sender);
|
||||
|
||||
let previous_task = self.task.take();
|
||||
|
||||
self.task = Some(cx.spawn_in(window, async move |entity, cx| {
|
||||
let mut timer = cx.background_executor().timer(delay).fuse();
|
||||
|
||||
if let Some(previous_task) = previous_task {
|
||||
previous_task.await;
|
||||
}
|
||||
|
||||
futures::select_biased! {
|
||||
_ = receiver => return,
|
||||
_ = timer => {}
|
||||
}
|
||||
|
||||
if let Ok(Ok(task)) =
|
||||
cx.update(|window, cx| entity.update(cx, |project, cx| (func)(project, window, cx)))
|
||||
{
|
||||
task.await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
145
crates/common/src/display.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use chrono::{Local, TimeZone};
|
||||
use global::constants::IMAGE_RESIZE_SERVICE;
|
||||
use gpui::{Image, ImageFormat};
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode::render::svg;
|
||||
use qrcode::QrCode;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
const MINUTES_IN_HOUR: i64 = 60;
|
||||
const HOURS_IN_DAY: i64 = 24;
|
||||
const DAYS_IN_MONTH: i64 = 30;
|
||||
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
|
||||
|
||||
pub trait ReadableProfile {
|
||||
fn avatar_url(&self, proxy: bool) -> String;
|
||||
fn display_name(&self) -> String;
|
||||
}
|
||||
|
||||
impl ReadableProfile for Profile {
|
||||
fn avatar_url(&self, proxy: bool) -> String {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
if proxy {
|
||||
format!(
|
||||
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
|
||||
)
|
||||
} else {
|
||||
picture.into()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
||||
if !display_name.is_empty() {
|
||||
return display_name.into();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata().name.as_ref() {
|
||||
if !name.is_empty() {
|
||||
return name.into();
|
||||
}
|
||||
}
|
||||
|
||||
shorten_pubkey(self.public_key(), 4)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ReadableTimestamp {
|
||||
fn to_human_time(&self) -> String;
|
||||
fn to_ago(&self) -> String;
|
||||
}
|
||||
|
||||
impl ReadableTimestamp for Timestamp {
|
||||
fn to_human_time(&self) -> String {
|
||||
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "9999".into(),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let input_date = input_time.date_naive();
|
||||
let now_date = now.date_naive();
|
||||
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
|
||||
|
||||
let time_format = input_time.format("%H:%M %p");
|
||||
|
||||
match input_date {
|
||||
date if date == now_date => format!("Today at {time_format}"),
|
||||
date if date == yesterday_date => format!("Yesterday at {time_format}"),
|
||||
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_ago(&self) -> String {
|
||||
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "1m".into(),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let duration = now.signed_duration_since(input_time);
|
||||
|
||||
match duration {
|
||||
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
|
||||
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
|
||||
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
|
||||
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
|
||||
_ => input_time.format("%b %d").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TextUtils {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error>;
|
||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> TextUtils for T {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error> {
|
||||
let s = self.as_ref();
|
||||
if s.starts_with("nprofile1") {
|
||||
Ok(Nip19Profile::from_bech32(s)?.public_key)
|
||||
} else if s.starts_with("npub1") {
|
||||
Ok(PublicKey::parse(s)?)
|
||||
} else {
|
||||
Err(anyhow!("Invalid public key"))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||
let s = self.as_ref();
|
||||
let code = QrCode::new(s).unwrap();
|
||||
let svg = code
|
||||
.render()
|
||||
.min_dimensions(256, 256)
|
||||
.dark_color(svg::Color("#000000"))
|
||||
.light_color(svg::Color("#FFFFFF"))
|
||||
.build();
|
||||
|
||||
Some(Arc::new(Image::from_bytes(
|
||||
ImageFormat::Svg,
|
||||
svg.into_bytes(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
||||
let Ok(pubkey) = public_key.to_bech32();
|
||||
|
||||
format!(
|
||||
"{}:{}",
|
||||
&pubkey[0..(len + 1)],
|
||||
&pubkey[pubkey.len() - len..]
|
||||
)
|
||||
}
|
||||
47
crates/common/src/event.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::collections::HashSet;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub trait EventUtils {
|
||||
fn uniq_id(&self) -> u64;
|
||||
fn all_pubkeys(&self) -> Vec<PublicKey>;
|
||||
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool;
|
||||
}
|
||||
|
||||
impl EventUtils for Event {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = vec![];
|
||||
|
||||
// Add all public keys from event
|
||||
pubkeys.push(self.pubkey);
|
||||
pubkeys.extend(self.tags.public_keys().collect::<Vec<_>>());
|
||||
|
||||
// Generate unique hash
|
||||
pubkeys
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>()
|
||||
.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn all_pubkeys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
|
||||
public_keys
|
||||
}
|
||||
|
||||
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool {
|
||||
let pubkeys = self.all_pubkeys();
|
||||
let a: HashSet<_> = pubkeys.iter().collect();
|
||||
let b: HashSet<_> = other.iter().collect();
|
||||
|
||||
a == b
|
||||
}
|
||||
}
|
||||
14
crates/common/src/handle_auth.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use nostr_connect::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
||||
Box::pin(async move {
|
||||
log::info!("Received Auth URL: {auth_url}");
|
||||
webbrowser::open(auth_url.as_str())?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
const MINUTES_IN_HOUR: i64 = 60;
|
||||
const HOURS_IN_DAY: i64 = 24;
|
||||
const DAYS_IN_MONTH: i64 = 30;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct LastSeen(pub Timestamp);
|
||||
|
||||
impl LastSeen {
|
||||
pub fn ago(&self) -> SharedString {
|
||||
let now = Local::now();
|
||||
let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "Invalid timestamp".into(),
|
||||
};
|
||||
let duration = now.signed_duration_since(input_time);
|
||||
|
||||
match duration {
|
||||
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
|
||||
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
|
||||
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
|
||||
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
|
||||
_ => input_time.format("%b %d").to_string(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn human_readable(&self) -> SharedString {
|
||||
let now = Local::now();
|
||||
let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "Invalid timestamp".into(),
|
||||
};
|
||||
|
||||
let input_date = input_time.date_naive();
|
||||
let now_date = now.date_naive();
|
||||
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
|
||||
|
||||
let time_format = input_time.format("%H:%M %p");
|
||||
|
||||
match input_date {
|
||||
date if date == now_date => format!("Today at {time_format}"),
|
||||
date if date == yesterday_date => format!("Yesterday at {time_format}"),
|
||||
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn set(&mut self, created_at: Timestamp) {
|
||||
self.0 = created_at
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod last_seen;
|
||||
pub mod profile;
|
||||
pub mod qr;
|
||||
pub mod utils;
|
||||
pub mod debounced_delay;
|
||||
pub mod display;
|
||||
pub mod event;
|
||||
pub mod handle_auth;
|
||||
pub mod nip05;
|
||||
pub mod nip96;
|
||||
|
||||
31
crates/common/src/nip05.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use anyhow::anyhow;
|
||||
use nostr::prelude::*;
|
||||
use reqwest::Client as ReqClient;
|
||||
|
||||
pub async fn nip05_verify(public_key: PublicKey, address: &str) -> Result<bool, anyhow::Error> {
|
||||
let req_client = ReqClient::new();
|
||||
let address = Nip05Address::parse(address)?;
|
||||
|
||||
// Get NIP-05 response
|
||||
let res = req_client.get(address.url().to_string()).send().await?;
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
let verify = nip05::verify_from_json(&public_key, &address, &json);
|
||||
|
||||
Ok(verify)
|
||||
}
|
||||
|
||||
pub async fn nip05_profile(address: &str) -> Result<Nip05Profile, anyhow::Error> {
|
||||
let req_client = ReqClient::new();
|
||||
let address = Nip05Address::parse(address)?;
|
||||
|
||||
// Get NIP-05 response
|
||||
let res = req_client.get(address.url().to_string()).send().await?;
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
if let Ok(profile) = Nip05Profile::from_json(&address, &json) {
|
||||
Ok(profile)
|
||||
} else {
|
||||
Err(anyhow!("Failed to get NIP-05 profile"))
|
||||
}
|
||||
}
|
||||
84
crates/common/src/nip96.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use anyhow::anyhow;
|
||||
use nostr::hashes::sha256::Hash as Sha256Hash;
|
||||
use nostr::hashes::Hash;
|
||||
use nostr::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use reqwest::{multipart, Client as ReqClient, Response};
|
||||
|
||||
pub(crate) fn make_multipart_form(
|
||||
file_data: Vec<u8>,
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<multipart::Form, anyhow::Error> {
|
||||
let form_file_part = multipart::Part::bytes(file_data).file_name("filename");
|
||||
|
||||
// Set the part's MIME type, or leave it as is if mime_type is None
|
||||
|
||||
let part = match mime_type {
|
||||
Some(mime) => form_file_part.mime_str(mime)?,
|
||||
None => form_file_part,
|
||||
};
|
||||
|
||||
Ok(multipart::Form::new().part("file", part))
|
||||
}
|
||||
|
||||
pub(crate) async fn upload<T>(
|
||||
signer: &T,
|
||||
desc: &ServerConfig,
|
||||
file_data: Vec<u8>,
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<Url, anyhow::Error>
|
||||
where
|
||||
T: NostrSigner,
|
||||
{
|
||||
let payload: Sha256Hash = Sha256Hash::hash(&file_data);
|
||||
let data: HttpData = HttpData::new(desc.api_url.clone(), HttpMethod::POST).payload(payload);
|
||||
let nip98_auth: String = data.to_authorization(signer).await?;
|
||||
|
||||
// Make form
|
||||
let form: multipart::Form = make_multipart_form(file_data, mime_type)?;
|
||||
|
||||
// Make req client
|
||||
let req_client = ReqClient::new();
|
||||
|
||||
// Send
|
||||
let response: Response = req_client
|
||||
.post(desc.api_url.clone())
|
||||
.header("Authorization", nip98_auth)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Parse response
|
||||
let json: Value = response.json().await?;
|
||||
let upload_response = nip96::UploadResponse::from_json(json.to_string())?;
|
||||
|
||||
if upload_response.status == UploadResponseStatus::Error {
|
||||
return Err(anyhow!(upload_response.message));
|
||||
}
|
||||
|
||||
Ok(upload_response.download_url()?.to_owned())
|
||||
}
|
||||
|
||||
pub async fn nip96_upload(
|
||||
client: &Client,
|
||||
server: &Url,
|
||||
file: Vec<u8>,
|
||||
) -> Result<Url, anyhow::Error> {
|
||||
let req_client = ReqClient::new();
|
||||
let config_url = nip96::get_server_config_url(server)?;
|
||||
|
||||
// Get
|
||||
let res = req_client.get(config_url.to_string()).send().await?;
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
let config = nip96::ServerConfig::from_json(json.to_string())?;
|
||||
let signer = if client.has_signer().await {
|
||||
client.signer().await?
|
||||
} else {
|
||||
Keys::generate().into_nostr_signer()
|
||||
};
|
||||
|
||||
let url = upload(&signer, &config, file, None).await?;
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
use global::constants::IMAGE_SERVICE;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NostrProfile {
|
||||
pub public_key: PublicKey,
|
||||
pub avatar: SharedString,
|
||||
pub name: SharedString,
|
||||
pub messaging_relays: Option<SmallVec<[RelayUrl; 3]>>,
|
||||
}
|
||||
|
||||
impl NostrProfile {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
let name = Self::extract_name(&public_key, &metadata);
|
||||
let avatar = Self::extract_avatar(&metadata);
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
name,
|
||||
avatar,
|
||||
messaging_relays: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set contact's relays
|
||||
pub fn relays(mut self, relays: Option<SmallVec<[RelayUrl; 3]>>) -> Self {
|
||||
self.messaging_relays = relays;
|
||||
self
|
||||
}
|
||||
|
||||
fn extract_avatar(metadata: &Metadata) -> SharedString {
|
||||
metadata
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE, picture
|
||||
)
|
||||
.into()
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.jpg".into())
|
||||
}
|
||||
|
||||
fn extract_name(public_key: &PublicKey, metadata: &Metadata) -> SharedString {
|
||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||
if !display_name.is_empty() {
|
||||
return display_name.into();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = metadata.name.as_ref() {
|
||||
if !name.is_empty() {
|
||||
return name.into();
|
||||
}
|
||||
}
|
||||
|
||||
let pubkey = public_key.to_hex();
|
||||
|
||||
format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dirs::config_dir;
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
|
||||
pub fn create_qr(data: &str) -> Result<PathBuf, anyhow::Error> {
|
||||
let config_dir = config_dir().expect("Config directory not found");
|
||||
let path = config_dir.join("Coop/nostr_connect.png");
|
||||
|
||||
qrcode_generator::to_png_to_file(data, QrCodeEcc::Low, 512, &path)?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use global::constants::NIP96_SERVER;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use rnglib::{Language, RNG};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
};
|
||||
|
||||
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> {
|
||||
let signer = client.signer().await?;
|
||||
let server_url = Url::parse(NIP96_SERVER)?;
|
||||
|
||||
let config: ServerConfig = nip96::get_server_config(server_url, None).await?;
|
||||
let url = nip96::upload_data(&signer, &config, file, None, None).await?;
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn room_hash(event: &Event) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<&PublicKey> = vec![];
|
||||
|
||||
// Add all public keys from event
|
||||
pubkeys.push(&event.pubkey);
|
||||
pubkeys.extend(
|
||||
event
|
||||
.tags
|
||||
.public_keys()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// Generate unique hash
|
||||
pubkeys
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>()
|
||||
.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn device_pubkey(event: &Event) -> Result<PublicKey, anyhow::Error> {
|
||||
let n_tag = event.tags.find(TagKind::custom("n")).context("Invalid")?;
|
||||
let hex = n_tag.content().context("Invalid")?;
|
||||
let pubkey = PublicKey::parse(hex)?;
|
||||
|
||||
Ok(pubkey)
|
||||
}
|
||||
|
||||
pub fn random_name(length: usize) -> String {
|
||||
let rng = RNG::from(&Language::Roman);
|
||||
rng.generate_names(length, true).join("-").to_lowercase()
|
||||
}
|
||||
|
||||
pub fn compare<T>(a: &[T], b: &[T]) -> bool
|
||||
where
|
||||
T: Eq + Hash,
|
||||
{
|
||||
let a: HashSet<_> = a.iter().collect();
|
||||
let b: HashSet<_> = b.iter().collect();
|
||||
|
||||
a == b
|
||||
}
|
||||
66
crates/coop/Cargo.toml
Normal file
@@ -0,0 +1,66 @@
|
||||
[package]
|
||||
name = "coop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "coop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[package.metadata.packager]
|
||||
name = "Coop"
|
||||
product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "0.2.7"
|
||||
out-dir = "../../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
icons = [
|
||||
"resources/32x32.png",
|
||||
"resources/128x128.png",
|
||||
"resources/128x128@2x.png",
|
||||
"resources/icon.icns",
|
||||
"resources/icon.ico",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
ui = { path = "../ui" }
|
||||
title_bar = { path = "../title_bar" }
|
||||
theme = { path = "../theme" }
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
registry = { path = "../registry" }
|
||||
settings = { path = "../settings" }
|
||||
client_keys = { path = "../client_keys" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
signer_proxy = { path = "../signer_proxy" }
|
||||
|
||||
rust-i18n.workspace = true
|
||||
i18n.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
itertools.workspace = true
|
||||
dirs.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
oneshot.workspace = true
|
||||
flume.workspace = true
|
||||
webbrowser.workspace = true
|
||||
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
indexset = "0.12.3"
|
||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |