Compare commits
205 Commits
0.1.4-alph
...
8242f9c1de
| Author | SHA1 | Date | |
|---|---|---|---|
| 8242f9c1de | |||
| f6ce53ef9c | |||
| e327178161 | |||
| ecd7f6aa9b | |||
| 32201554ec | |||
| 014757cfc9 | |||
| ac9afb1790 | |||
| 75c3783522 | |||
|
|
bb455871e5 | ||
| 0507fa7ac5 | |||
| af115321b4 | |||
|
|
34e026751b | ||
| e9e662dccc | |||
| 5b7780ec9b | |||
| 782efd7498 | |||
| 8192023479 | |||
| 4637478a0b | |||
| 6b5adb0a56 | |||
| 9fd55cf3ff | |||
|
|
14c36e4731 | ||
|
|
a6e00b47d8 | ||
| 0784a20be5 | |||
|
|
6023063cf4 | ||
| 67c92cb319 | |||
|
|
122299f548 | ||
| d87bcfbd65 | |||
| de5134676d | |||
|
|
512834b640 | ||
| a1a0a7ecd4 | |||
|
|
a4067d2c00 | ||
| 4ebe590f8a | |||
|
|
9da624dd0c | ||
|
|
7091fa1cab | ||
| a1bd4954eb | |||
| fde1499796 | |||
|
|
649cdff49c | ||
|
|
b0fa98831d | ||
|
|
b9297d3a01 | ||
|
|
b5ed079a0e | ||
| 6017eebaed | |||
|
|
15bbe82a87 | ||
|
|
83687e5448 | ||
|
|
48c90f5bb0 | ||
|
|
47abd2909b | ||
|
|
ac0b233089 | ||
|
|
a1e0934fc3 | ||
| 32a0401907 | |||
| 1742031901 | |||
|
|
2415374567 | ||
|
|
7fc727461e | ||
|
|
68a8ec7a69 | ||
| b7693444e6 | |||
| 6e7f63d79a | |||
| ee693aa503 | |||
|
|
ebcc60cd92 | ||
|
|
0db48bc003 | ||
| 880ba30d20 | |||
|
|
d889f9b25d | ||
|
|
0de1b20951 | ||
|
|
338a947b57 | ||
|
|
98ce928f0c | ||
|
|
61cad5dd96 | ||
| a87184214f | |||
| fff3a44f62 | |||
|
|
9abcc25f32 | ||
|
|
fb3da096f8 | ||
| 1de3045505 | |||
|
|
9f369bf57f | ||
|
|
4164651342 | ||
| c12856cda0 | |||
|
|
c67b223a53 | ||
|
|
9880a3ed3d | ||
|
|
d13ffd5a54 | ||
| cc79f0ed1c | |||
|
|
5127eaadbb | ||
| 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]
|
||||||
|
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
|
# Useless stuffs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
# Added by goreleaser init:
|
||||||
|
.intentionally-empty-file.o
|
||||||
|
|||||||
5273
Cargo.lock
generated
54
Cargo.toml
@@ -1,40 +1,44 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/*"]
|
|
||||||
default-members = ["crates/app"]
|
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
members = ["crates/*"]
|
||||||
|
default-members = ["crates/coop"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.3.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
coop = { path = "crates/*" }
|
|
||||||
|
|
||||||
# UI
|
# GPUI
|
||||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
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" }
|
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
|
||||||
# Nostr
|
# Nostr
|
||||||
nostr-relay-builder = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
||||||
nostr-connect = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sdk = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17", features = [
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
"lmdb",
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
"nip96",
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
"nip59",
|
|
||||||
"nip49",
|
|
||||||
"nip44",
|
|
||||||
"nip05",
|
|
||||||
] }
|
|
||||||
|
|
||||||
smol = "2"
|
# Others
|
||||||
|
anyhow = "1.0.44"
|
||||||
|
chrono = "0.4.38"
|
||||||
|
futures = "0.3"
|
||||||
|
itertools = "0.13.0"
|
||||||
|
log = "0.4"
|
||||||
oneshot = "0.1.10"
|
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"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
dirs = "5.0"
|
schemars = "1"
|
||||||
itertools = "0.13.0"
|
|
||||||
futures = "0.3.30"
|
|
||||||
chrono = "0.4.38"
|
|
||||||
tracing = "0.1.40"
|
|
||||||
anyhow = "1.0.44"
|
|
||||||
smallvec = "1.14.0"
|
smallvec = "1.14.0"
|
||||||
rust-embed = "8.5.0"
|
smol = "2"
|
||||||
log = "0.4"
|
tracing = "0.1.40"
|
||||||
|
webbrowser = "1.0.4"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
@@ -42,3 +46,7 @@ opt-level = "z"
|
|||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.profiling]
|
||||||
|
inherits = "release"
|
||||||
|
debug = true
|
||||||
|
|||||||
@@ -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>
|
<p>
|
||||||
<a href="https://github.com/lumehq/coop/actions/workflows/main.yml">
|
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml">
|
||||||
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/main.yml/badge.svg">
|
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg">
|
||||||
</a>
|
</a>
|
||||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop">
|
<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 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">
|
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop">
|
||||||
</p>
|
</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
|
### Installation
|
||||||
|
|
||||||
@@ -28,9 +43,7 @@ To install Coop, follow these steps:
|
|||||||
|
|
||||||
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
|
- **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.
|
- **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.
|
- **Linux**: Run the downloaded `.flatpak` or `.snap` 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.
|
|
||||||
|
|
||||||
3. **Run Coop**:
|
3. **Run Coop**:
|
||||||
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
|
- 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
|
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
|
```bash
|
||||||
cargo build
|
cargo build
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Run the app:
|
4. Run the app:
|
||||||
```bash
|
```bash
|
||||||
cargo run
|
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.
|
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
|
#### Additional Resources
|
||||||
|
|
||||||
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
- [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/brand/system.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/fonts/Inter/Inter-Bold.ttf
Normal file
BIN
assets/fonts/Inter/Inter-BoldItalic.ttf
Normal file
BIN
assets/fonts/Inter/Inter-Italic.ttf
Normal file
BIN
assets/fonts/Inter/Inter-Medium.ttf
Normal file
BIN
assets/fonts/Inter/Inter-MediumItalic.ttf
Normal file
BIN
assets/fonts/Inter/Inter-Regular.ttf
Normal file
BIN
assets/fonts/Inter/Inter-SemiBold.ttf
Normal file
BIN
assets/fonts/Inter/Inter-SemiBoldItalic.ttf
Normal file
16
assets/icons/arrow-left.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M10 5.75L3.75 12L10 18.25"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.5 12H20.25"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 418 B |
3
assets/icons/arrow-right.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M14 5.75L20.25 12L14 18.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.5 12H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 320 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 |
3
assets/icons/boom.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M17.25 14C17.25 18.0041 14.0041 21.25 10 21.25C5.99594 21.25 2.75 18.0041 2.75 14C2.75 9.99594 5.99594 6.75 10 6.75C14.0041 6.75 17.25 9.99594 17.25 14Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.5 8.5L17.5 6.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M16.75 1.75V3.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.75 7.25H22.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20 4L21.25 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 800 B |
3
assets/icons/caret-down.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M5.75 9.5L12 15.75L18.25 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 209 B |
3
assets/icons/caret-right.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M9.5 18.25L15.75 12L9.5 5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 209 B |
3
assets/icons/caret-up.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M5.75 14.5L12 8.25L18.25 14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 210 B |
3
assets/icons/check-circle.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.9231L10.5625 15.75L15.25 8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
3
assets/icons/check.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M6.75 13.0625L9.9 16.25L17.25 7.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 215 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="m8 10 3.293 3.293a1 1 0 0 0 1.414 0L16 10"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 247 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<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"/>
|
<path d="M9.8007 10.25C8.74816 10.25 8.16683 11.4713 8.83056 12.2882L11.0301 14.9953C11.5303 15.611 12.4701 15.611 12.9704 14.9953L15.1699 12.2882C15.8336 11.4713 15.2523 10.25 14.1997 10.25H9.8007Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 257 B After Width: | Height: | Size: 302 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 |
@@ -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 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>
|
|
||||||
|
Before Width: | Height: | Size: 429 B |
3
assets/icons/close-circle-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM9.53033 8.46967C9.23744 8.17678 8.76256 8.17678 8.46967 8.46967C8.17678 8.76256 8.17678 9.23744 8.46967 9.53033L10.9393 12L8.46967 14.4697C8.17678 14.7626 8.17678 15.2374 8.46967 15.5303C8.76256 15.8232 9.23744 15.8232 9.53033 15.5303L12 13.0607L14.4697 15.5303C14.7626 15.8232 15.2374 15.8232 15.5303 15.5303C15.8232 15.2374 15.8232 14.7626 15.5303 14.4697L13.0607 12L15.5303 9.53033C15.8232 9.23744 15.8232 8.76256 15.5303 8.46967C15.2374 8.17678 14.7626 8.17678 14.4697 8.46967L12 10.9393L9.53033 8.46967Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 774 B |
3
assets/icons/close-circle.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M15 9L9 15M15 15L9 9M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<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"/>
|
<path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 201 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" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.10352 4C7.42998 2.84575 8.49122 2 9.75 2H14.25C15.5088 2 16.57 2.84575 16.8965 4H18.25C19.7688 4 21 5.23122 21 6.75V19.25C21 20.7688 19.7688 22 18.25 22H5.75C4.23122 22 3 20.7688 3 19.25V6.75C3 5.23122 4.23122 4 5.75 4H7.10352ZM8.5 4.75V6.25C8.5 6.38807 8.61193 6.5 8.75 6.5H15.25C15.3881 6.5 15.5 6.38807 15.5 6.25V4.75C15.5 4.05964 14.9404 3.5 14.25 3.5H9.75C9.05964 3.5 8.5 4.05964 8.5 4.75Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 550 B |
3
assets/icons/door.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2.75 21.25L21.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.75 21.25V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 522 B |
@@ -1,4 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path fill="currentColor" d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8.25 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm-16.5 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"/>
|
<path d="M3.75 10.25C2.7835 10.25 2 11.0335 2 12C2 12.9665 2.7835 13.75 3.75 13.75C4.7165 13.75 5.5 12.9665 5.5 12C5.5 11.0335 4.7165 10.25 3.75 10.25Z" fill="currentColor"/><path d="M12 10.25C11.0335 10.25 10.25 11.0335 10.25 12C10.25 12.9665 11.0335 13.75 12 13.75C12.9665 13.75 13.75 12.9665 13.75 12C13.75 11.0335 12.9665 10.25 12 10.25Z" fill="currentColor"/><path d="M20.25 10.25C19.2835 10.25 18.5 11.0335 18.5 12C18.5 12.9665 19.2835 13.75 20.25 13.75C21.2165 13.75 22 12.9665 22 12C22 11.0335 21.2165 10.25 20.25 10.25Z" fill="currentColor"/>
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8.25 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm-16.5 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 457 B After Width: | Height: | Size: 632 B |
3
assets/icons/emoji.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M19 1.75V8.25M15.75 5H22.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10.75 9.9C10.75 11.0046 10.0784 11.75 9.25 11.75C8.42157 11.75 7.75 11.0046 7.75 9.9C7.75 8.79543 8.42157 8 9.25 8C10.0784 8 10.75 8.79543 10.75 9.9Z" fill="currentColor"/><path d="M16.25 9.9C16.25 11.0046 15.5784 11.75 14.75 11.75C13.9216 11.75 13.25 11.0046 13.25 9.9C13.25 8.79543 13.9216 8 14.75 8C15.5784 8 16.25 8.79543 16.25 9.9Z" fill="currentColor"/><path d="M16.1123 14.8493C16.1942 14.7105 16.2249 14.545 16.192 14.3857C16.1592 14.2263 16.0665 14.0867 15.933 13.9968C15.7996 13.9069 15.6354 13.8733 15.4754 13.9028C15.3154 13.9321 15.1736 14.0226 15.0757 14.1507C15.0008 14.2469 14.9237 14.3367 14.8415 14.4241C14.1096 15.2083 13.061 15.628 12.0035 15.625C10.946 15.6265 9.8972 15.2055 9.16254 14.4222C9.08002 14.3348 9.00261 14.2451 8.92738 14.1491C8.8291 14.0214 8.68699 13.9313 8.52686 13.9024C8.36679 13.8735 8.20268 13.9076 8.06954 13.9979C7.9364 14.0882 7.84406 14.2281 7.81174 14.3875C7.77938 14.547 7.81054 14.7123 7.89293 14.8509C7.97731 14.99 8.06686 15.1223 8.16553 15.2526C9.04297 16.4311 10.5292 17.1343 12.0024 17.125C13.4754 17.1367 14.965 16.4342 15.8405 15.2521C15.939 15.1215 16.0282 14.9888 16.1123 14.8493Z" fill="currentColor"/><path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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 |
3
assets/icons/eye.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M3.75 13.0199C8.54029 18.1132 15.4597 18.1132 20.25 13.0199" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 7.62257C6.14516 5.07587 9.0726 3.80251 12 3.80249C14.9274 3.80247 17.8549 5.07576 20.25 7.62238" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 17V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.25 16.5L6.75 18.9821" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.5 16.5L17.25 18.9821" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 800 B |
3
assets/icons/fistbump-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12.7507 3.75C12.7507 3.33579 12.4149 3 12.0007 3C11.5865 3 11.2507 3.33579 11.2507 3.75V6.25C11.2507 6.66421 11.5865 7 12.0007 7C12.4149 7 12.7507 6.66421 12.7507 6.25V3.75Z" fill="currentColor"/><path d="M7.26594 5.19799C6.99969 4.88068 6.52662 4.8393 6.20932 5.10555C5.89201 5.3718 5.85062 5.84487 6.11687 6.16217L7.72384 8.07728C7.99009 8.39459 8.46316 8.43598 8.78047 8.16973C9.09777 7.90347 9.13916 7.43041 8.87291 7.1131L7.26594 5.19799Z" fill="currentColor"/><path d="M17.8726 6.16227C18.1389 5.84496 18.0975 5.37189 17.7802 5.10564C17.4629 4.83939 16.9898 4.88078 16.7235 5.19809L15.1166 7.1132C14.8503 7.4305 14.8917 7.90357 15.209 8.16982C15.5263 8.43607 15.9994 8.39468 16.2656 8.07738L17.8726 6.16227Z" fill="currentColor"/><path d="M5.22073 9C4.33013 9 3.52687 9.5355 3.18434 10.3576L2.78846 11.3077C2.61378 11.7269 2.20416 12 1.75 12C1.33579 12 1 12.3358 1 12.75V19.25C1 19.6642 1.33579 20 1.75 20H7.38937C9.39779 20 11.0763 18.4715 11.2637 16.4719L11.4255 14.746C11.6391 12.468 9.84697 10.5 7.559 10.5C7.46053 10.5 7.36858 10.4508 7.31396 10.3689L7.0563 9.98237C6.64715 9.36864 5.95834 9 5.22073 9Z" fill="currentColor"/><path d="M18.722 9C17.9844 9 17.2956 9.36864 16.8865 9.98237L16.6288 10.3689C16.5742 10.4508 16.4822 10.5 16.3838 10.5C14.0958 10.5 12.3037 12.468 12.5172 14.746L12.679 16.4719C12.8665 18.4715 14.545 20 16.5534 20H22.1928C22.607 20 22.9428 19.6642 22.9428 19.25V12.75C22.9428 12.3358 22.607 12 22.1928 12C21.7386 12 21.329 11.7269 21.1543 11.3077L20.7584 10.3576C20.4159 9.5355 19.6126 9 18.722 9Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
3
assets/icons/fistbump.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M1.75 19.25H7.38937C9.0107 19.25 10.3657 18.0161 10.517 16.4019L10.6788 14.676C10.8511 12.8379 9.40511 11.25 7.559 11.25C7.20977 11.25 6.88364 11.0755 6.68992 10.7849L6.43226 10.3984C6.16221 9.99331 5.70757 9.75 5.22073 9.75C4.6329 9.75 4.10273 10.1034 3.87664 10.6461L3.48077 11.5962C3.18964 12.2949 2.50694 12.75 1.75 12.75M22.1928 19.25H16.5534C14.9321 19.25 13.5771 18.0161 13.4258 16.4019L13.264 14.676C13.0916 12.8379 14.5377 11.25 16.3838 11.25C16.733 11.25 17.0591 11.0755 17.2528 10.7849L17.5105 10.3984C17.7806 9.99331 18.2352 9.75 18.722 9.75C19.3099 9.75 19.84 10.1034 20.0661 10.6461L20.462 11.5962C20.7531 12.2949 21.4358 12.75 22.1928 12.75M12.0007 3.75V6.25M6.69141 5.68008L8.29838 7.59519M17.2981 5.68018L15.6911 7.59529" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 918 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/inbox-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.00001 13.7453L3.00004 5.75C3.00004 4.23122 4.23126 3.00001 5.75004 3L18.25 3C19.7688 3 21 4.23122 21 5.75V13.7466C21 13.7477 21 13.7489 21 13.75L21 18.25C21 19.7688 19.7688 21 18.25 21H5.75C4.32614 21 3.15502 19.9179 3.0142 18.5312C3.00481 18.4387 3 18.3449 3 18.25M5.75004 4.5L18.25 4.5C18.9403 4.5 19.5 5.05964 19.5 5.75V13L15.9298 13C15.5695 13 15.2601 13.2562 15.1929 13.6102C14.9078 15.1135 13.5858 16.25 12 16.25C10.4142 16.25 9.09221 15.1135 8.80706 13.6102C8.73991 13.2562 8.43051 13 8.0702 13H4.50002L4.50004 5.75C4.50004 5.05965 5.05968 4.50001 5.75004 4.5Z" fill="currentColor"/><path d="M3 18.25V13.75C3 13.7484 3 13.7469 3.00001 13.7453" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 805 B |
3
assets/icons/inbox.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M3.75 12.75H8.0702C8.42126 14.6006 10.0472 16 12 16C13.9528 16 15.5787 14.6006 15.9298 12.75L20.25 12.75M18.25 20.25H5.75001C4.64543 20.25 3.75 19.3546 3.75001 18.25L3.75005 5.75C3.75005 4.64543 4.64548 3.75001 5.75005 3.75L18.25 3.75C19.3546 3.75 20.25 4.64543 20.25 5.75V18.25C20.25 19.3546 19.3546 20.25 18.25 20.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<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 2Zm-2 9a.75.75 0 0 1 .75-.75H12a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 1-1.5 0v-4.5h-.5A.75.75 0 0 1 10 11Zm2-3.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" clip-rule="evenodd"/>
|
<path d="M10.75 11H12L12 16.25M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 7.375C11.6548 7.375 11.375 7.65482 11.375 8C11.375 8.34518 11.6548 8.625 12 8.625C12.3452 8.625 12.625 8.34518 12.625 8C12.625 7.65482 12.3452 7.375 12 7.375Z" fill="currentColor" stroke="currentColor" stroke-width="0.25"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 590 B |
3
assets/icons/invite.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M4.75 10.9853V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V10.9853M9.75 7.75H14.25M12.617 13.5499L19.9415 11.1744C20.5875 10.9649 21.25 11.4465 21.25 12.1256V18.25C21.25 19.3546 20.3546 20.25 19.25 20.25H4.75C3.64543 20.25 2.75 19.3546 2.75 18.25V12.1256C2.75 11.4465 3.41249 10.9649 4.0585 11.1744L11.383 13.5499C11.784 13.68 12.216 13.68 12.617 13.5499Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 576 B |
3
assets/icons/link.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M9.75027 5.52371L10.7168 4.55722C13.1264 2.14759 17.0332 2.14759 19.4428 4.55722C21.8524 6.96684 21.8524 10.8736 19.4428 13.2832L18.4742 14.2519M5.52886 9.74513L4.55722 10.7168C2.14759 13.1264 2.1476 17.0332 4.55722 19.4428C6.96684 21.8524 10.8736 21.8524 13.2832 19.4428L14.2478 18.4782M9.5 14.5L14.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 462 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 stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.75 3.75v6.5m0 0h6.5m-6.5 0 6.5-6.5m-10 16.5v-6.5m0 0h-6.5m6.5 0-6.5 6.5"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 281 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21.248 11.811a6.5 6.5 0 0 1-9.06-9.06 9.25 9.25 0 1 0 9.06 9.06Z"/>
|
<path d="M21.2481 11.8112C20.1889 12.56 18.8958 13 17.5 13C13.9101 13 11 10.0899 11 6.5C11 5.10416 11.44 3.81108 12.1888 2.75189C12.126 2.75063 12.0631 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 11.9369 21.2494 11.874 21.2481 11.8112Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 271 B After Width: | Height: | Size: 489 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 |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path fill="#000" fill-rule="evenodd" d="M9 4.5v15h9.25c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25H9ZM3 5.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.25V5.75Z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.25 4C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75C2 5.23122 3.23122 4 4.75 4H19.25ZM6.25 7.5C5.83579 7.5 5.5 7.83579 5.5 8.25V15.75C5.5 16.1642 5.83579 16.5 6.25 16.5C6.66421 16.5 7 16.1642 7 15.75V8.25C7 7.83579 6.66421 7.5 6.25 7.5Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 451 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 5.75a2 2 0 0 1 2-2h12.5a2 2 0 0 1 2 2v12.5a2 2 0 0 1-2 2H5.75a2 2 0 0 1-2-2V5.75Zm5-1.75v16"/>
|
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M6.25 8.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 303 B After Width: | Height: | Size: 435 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path fill="#000" fill-rule="evenodd" d="M15 4.5v15H5.75c-.69 0-1.25-.56-1.25-1.25V5.75c0-.69.56-1.25 1.25-1.25H15Zm6 1.25A2.75 2.75 0 0 0 18.25 3H5.75A2.75 2.75 0 0 0 3 5.75v12.5A2.75 2.75 0 0 0 5.75 21h12.5A2.75 2.75 0 0 0 21 18.25V5.75Z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75 4C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75C22 5.23122 20.7688 4 19.25 4H4.75ZM17.75 7.5C18.1642 7.5 18.5 7.83579 18.5 8.25V15.75C18.5 16.1642 18.1642 16.5 17.75 16.5C17.3358 16.5 17 16.1642 17 15.75V8.25C17 7.83579 17.3358 7.5 17.75 7.5Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 376 B After Width: | Height: | Size: 459 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.25 4v16M3.75 5.75a2 2 0 0 1 2-2h12.5a2 2 0 0 1 2 2v12.5a2 2 0 0 1-2 2H5.75a2 2 0 0 1-2-2V5.75Z"/>
|
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M17.75 8.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 436 B |
3
assets/icons/paper-plane-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2.66936 5.12886C2.12122 3.64104 3.6759 2.24953 5.09409 2.95862L20.0468 10.435C21.3366 11.0799 21.3366 12.9205 20.0468 13.5655L5.09409 21.0418C3.67589 21.7509 2.12122 20.3594 2.66936 18.8715L4.92467 12.75H9.25021C9.66442 12.75 10.0002 12.4142 10.0002 12C10.0002 11.5858 9.66442 11.25 9.25021 11.25H4.92452L2.66936 5.12886Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
3
assets/icons/plus-circle.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M16.2426 12.0005H7.75736M12 16.2431V7.75781M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 352 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3.75V12m0 0v8.25M12 12H3.75M12 12h8.25"/>
|
<path d="M12 6.75V12M12 12V17.25M12 12H6.75M12 12H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 248 B After Width: | Height: | Size: 203 B |
3
assets/icons/profile.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M17.75 2.75H6.25C5.14543 2.75 4.25 3.64543 4.25 4.75V19.25C4.25 20.3546 5.14543 21.25 6.25 21.25H17.75C18.8546 21.25 19.75 20.3546 19.75 19.25V4.75C19.75 3.64543 18.8546 2.75 17.75 2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12.25" r="2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M16 21C16 18.7909 14.2091 17 12 17C9.79086 17 8 18.7909 8 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M9.75 6.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 696 B |
3
assets/icons/relay.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="9.25" r="1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 21.25L11.75 9.25H12.25L16.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.5 17.75H14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75693 12.7501C6.08102 10.7234 6.08103 7.77679 7.75693 5.75M16.2431 5.75C17.919 7.77679 17.919 10.7234 16.2431 12.7501" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.06494 2.7574C1.64285 6.40823 1.64502 12.1018 5.07145 15.75M18.9281 2.75C22.3572 6.40053 22.3573 12.0993 18.9285 15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 899 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="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"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 317 B |
3
assets/icons/reply.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M1.84521 11.4494L9.99071 3.91478C10.471 3.47055 11.25 3.81116 11.25 4.46535V7.99994C11.25 8.27608 11.478 8.49949 11.7541 8.50388C19.8394 8.63247 22 11.9205 22 20.2499C20.5303 17.3105 19.7806 15.5711 11.7551 15.5021C11.4789 15.4997 11.25 15.7238 11.25 15.9999V19.5345C11.25 20.1887 10.471 20.5293 9.99071 20.0851L1.84521 12.5505C1.52425 12.2536 1.52425 11.7463 1.84521 11.4494Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 534 B |
@@ -1,9 +0,0 @@
|
|||||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="list-filter" transform="translate(16.142767, 16.107233) rotate(-45.000000) translate(-16.142767, -16.107233) translate(3.642767, 10.491117)" stroke="#000000" stroke-width="2">
|
|
||||||
<line x1="0.454058454" y1="0.48959236" x2="24.1421356" y2="0.843145751" stroke-linecap="square"></line>
|
|
||||||
<line x1="4.69669914" y1="6.14644661" x2="20.1188954" y2="5.79289322"></line>
|
|
||||||
<line x1="9.06066017" y1="10.732233" x2="15.3033009" y2="10.3890873"></line>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 730 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<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 d="M20 20L16.1265 16.1265M16.1265 16.1265C17.4385 14.8145 18.25 13.002 18.25 11C18.25 6.99594 15.0041 3.75 11 3.75C6.99594 3.75 3.75 6.99594 3.75 11C3.75 15.0041 6.99594 18.25 11 18.25C13.002 18.25 14.8145 17.4385 16.1265 16.1265Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 408 B |
3
assets/icons/settings.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M7.878 5.21415L7.17474 5.05186C6.58003 4.91462 5.95657 5.09343 5.525 5.525C5.09343 5.95657 4.91462 6.58003 5.05186 7.17474L5.21415 7.878C5.40122 8.6886 5.06696 9.53036 4.37477 9.99182L3.51965 10.5619C3.03881 10.8825 2.75 11.4221 2.75 12C2.75 12.5779 3.03881 13.1175 3.51965 13.4381L4.37477 14.0082C5.06696 14.4696 5.40122 15.3114 5.21415 16.122L5.05186 16.8253C4.91462 17.42 5.09343 18.0434 5.525 18.475C5.95657 18.9066 6.58003 19.0854 7.17474 18.9481L7.878 18.7858C8.6886 18.5988 9.53036 18.933 9.99182 19.6252L10.5619 20.4804C10.8825 20.9612 11.4221 21.25 12 21.25C12.5779 21.25 13.1175 20.9612 13.4381 20.4804L14.0082 19.6252C14.4696 18.933 15.3114 18.5988 16.122 18.7858L16.8253 18.9481C17.42 19.0854 18.0434 18.9066 18.475 18.475C18.9066 18.0434 19.0854 17.42 18.9481 16.8253L18.7858 16.122C18.5988 15.3114 18.933 14.4696 19.6252 14.0082L20.4804 13.4381C20.9612 13.1175 21.25 12.5779 21.25 12C21.25 11.4221 20.9612 10.8825 20.4804 10.5619L19.6252 9.99182C18.933 9.53036 18.5988 8.6886 18.7858 7.878L18.9481 7.17473C19.0854 6.58003 18.9066 5.95657 18.475 5.525C18.0434 5.09343 17.42 4.91462 16.8253 5.05186L16.122 5.21415C15.3114 5.40122 14.4696 5.06696 14.0082 4.37477L13.4381 3.51965C13.1175 3.03881 12.5779 2.75 12 2.75C11.4221 2.75 10.8825 3.03881 10.5619 3.51965L9.99182 4.37477C9.53036 5.06696 8.6886 5.40122 7.878 5.21415Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M14.75 12C14.75 13.5188 13.5188 14.75 12 14.75C10.4812 14.75 9.25 13.5188 9.25 12C9.25 10.4812 10.4812 9.25 12 9.25C13.5188 9.25 14.75 10.4812 14.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
3
assets/icons/shield.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20.25 6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124V6.94155Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 446 B |
3
assets/icons/ship.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2.75 20.25L6.26353 19.4903M6.26353 19.4903L6.95233 19.3414C7.23089 19.2812 7.51911 19.2812 7.79767 19.3414L11.5773 20.1586C11.8559 20.2188 12.1441 20.2188 12.4227 20.1586L16.2023 19.3414C16.4809 19.2812 16.7691 19.2812 17.0477 19.3414L17.7365 19.4903M6.26353 19.4903C5.08645 17.9188 4.46034 16.5675 4.08992 15.0117C3.8539 14.0205 4.52677 13.0678 5.51689 12.827L11.5273 11.365C11.8379 11.2894 12.1621 11.2894 12.4727 11.365L18.4831 12.827C19.4732 13.0678 20.1461 14.0205 19.9101 15.0117C19.5397 16.5675 18.9136 17.9188 17.7365 19.4903M17.7365 19.4903L21.25 20.25M5.75 12.75V7.75C5.75 7.19772 6.19772 6.75 6.75 6.75H17.25C17.8023 6.75 18.25 7.19772 18.25 7.75V12.75M9.75 6.75V3.75C9.75 3.19772 10.1977 2.75 10.75 2.75H13.25C13.8023 2.75 14.25 3.19772 14.25 3.75V6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 946 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.998 3.29V1.769M5.84 18.158l-1.077 1.078m7.235 2.997v-1.524m7.235-15.944-1.077 1.077M20.707 12h1.523m-4.074 6.159 1.077 1.077M1.766 12h1.523m1.474-7.235L5.84 5.842m9.87 2.446a5.25 5.25 0 1 1-7.424 7.424 5.25 5.25 0 0 1 7.424-7.424Z"/>
|
<path d="M11.9982 3.29083V1.76758M5.83985 18.1586L4.76275 19.2357M11.9982 22.2327V20.7094M19.2334 4.76468L18.1562 5.84179M20.707 12.0001H22.2303M18.1562 18.1586L19.2334 19.2357M1.76562 12.0001H3.28888M4.76267 4.76462L5.83977 5.84173M15.7104 8.28781C17.7606 10.3381 17.7606 13.6622 15.7104 15.7124C13.6601 17.7627 10.336 17.7627 8.28574 15.7124C6.23548 13.6622 6.23548 10.3381 8.28574 8.28781C10.336 6.23756 13.6601 6.23756 15.7104 8.28781Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 440 B After Width: | Height: | Size: 611 B |
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
<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 d="M12 19.25V13L14.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 13L9.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.375 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H8.92963C9.59834 3.75 10.2228 4.0842 10.5937 4.6406L11.7031 6.3047C11.8886 6.5829 12.2008 6.75 12.5352 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.625" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 394 B After Width: | Height: | Size: 718 B |
3
assets/icons/usb.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M10 5.75V7.25M14 5.75V7.25M3.75 10.25H20.25V19.25C20.25 20.3546 19.3546 21.25 18.25 21.25H5.75C4.64543 21.25 3.75 20.3546 3.75 19.25V10.25ZM5.75 2.75H18.25V10.25H5.75V2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 353 B |
3
assets/icons/warning.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M11.3121 12.3511L11.0582 7.9983C11.0266 7.45662 11.4574 7 12 7C12.5426 7 12.9734 7.45662 12.9418 7.9983L12.6879 12.3511C12.6666 12.7154 12.365 13 12 13C11.635 13 11.3334 12.7154 11.3121 12.3511Z" fill="currentColor"/><circle cx="11.9999" cy="15.8998" r="1.1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 445 B |
58
assets/icons/zoom.svg
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M4.75 9.25V4.75H9.25"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19.25 9.25V4.75H14.75"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19.25 14.75V19.25H14.75"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.75 14.75V19.25H9.25"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5 5L9.5 9.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19 5L14.5 9.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19 19L14.5 14.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5 19L9.5 14.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
use gpui::{
|
|
||||||
div, svg, 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,
|
|
||||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
|
||||||
StyledExt,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
|
||||||
Welcome::new(window, cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Welcome {
|
|
||||||
name: SharedString,
|
|
||||||
closable: bool,
|
|
||||||
zoomable: bool,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Welcome {
|
|
||||||
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 {
|
|
||||||
Self {
|
|
||||||
name: "Welcome".into(),
|
|
||||||
closable: true,
|
|
||||||
zoomable: true,
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for Welcome {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
"WelcomePanel".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 Welcome {}
|
|
||||||
|
|
||||||
impl Focusable for Welcome {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Welcome {
|
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
div()
|
|
||||||
.size_full()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.items_center()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
svg()
|
|
||||||
.path("brand/coop.svg")
|
|
||||||
.size_12()
|
|
||||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.child("coop on nostr.")
|
|
||||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::FOUR))
|
|
||||||
.font_black()
|
|
||||||
.text_sm(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
||||||
51
crates/assets/src/lib.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use gpui::{App, AssetSource, Result, SharedString};
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "../../assets"]
|
||||||
|
#[include = "fonts/**/*"]
|
||||||
|
#[include = "brand/**/*"]
|
||||||
|
#[include = "icons/**/*"]
|
||||||
|
#[include = "themes/**/*"]
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/auto_update/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "auto_update"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
gpui_tokio.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
semver = "1.0.27"
|
||||||
|
tempfile = "3.23.0"
|
||||||
|
futures.workspace = true
|
||||||
555
crates/auto_update/src/lib.rs
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||||
|
};
|
||||||
|
use semver::Version;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use smol::fs::File;
|
||||||
|
use smol::process::Command;
|
||||||
|
|
||||||
|
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||||
|
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
|
||||||
|
|
||||||
|
fn get_github_repo_owner() -> String {
|
||||||
|
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "your-username".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_github_repo_name() -> String {
|
||||||
|
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "your-repo".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_flatpak_installation() -> bool {
|
||||||
|
// Check if app is installed via Flatpak
|
||||||
|
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(cx: &mut App) {
|
||||||
|
// Skip auto-update initialization if installed via Flatpak
|
||||||
|
if is_flatpak_installation() {
|
||||||
|
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||||
|
|
||||||
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
struct InstallerDir(tempfile::TempDir);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
impl InstallerDir {
|
||||||
|
async fn new() -> Result<Self, Error> {
|
||||||
|
Ok(Self(
|
||||||
|
tempfile::Builder::new()
|
||||||
|
.prefix("coop-auto-update")
|
||||||
|
.tempdir()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
self.0.path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
struct InstallerDir(PathBuf);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
impl InstallerDir {
|
||||||
|
async fn new() -> Result<Self, Error> {
|
||||||
|
let installer_dir = std::env::current_exe()?
|
||||||
|
.parent()
|
||||||
|
.context("No parent dir for Coop.exe")?
|
||||||
|
.join("updates");
|
||||||
|
|
||||||
|
if smol::fs::metadata(&installer_dir).await.is_ok() {
|
||||||
|
smol::fs::remove_dir_all(&installer_dir).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
smol::fs::create_dir(&installer_dir).await?;
|
||||||
|
|
||||||
|
Ok(Self(installer_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
self.0.as_path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MacOsUnmounter<'a> {
|
||||||
|
mount_path: PathBuf,
|
||||||
|
background_executor: &'a BackgroundExecutor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MacOsUnmounter<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mount_path = std::mem::take(&mut self.mount_path);
|
||||||
|
|
||||||
|
self.background_executor
|
||||||
|
.spawn(async move {
|
||||||
|
let unmount_output = Command::new("hdiutil")
|
||||||
|
.args(["detach", "-force"])
|
||||||
|
.arg(&mount_path)
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match unmount_output {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
log::info!("Successfully unmounted the disk image");
|
||||||
|
}
|
||||||
|
Ok(output) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to unmount disk image: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("Error while trying to unmount disk image: {:?}", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum AutoUpdateStatus {
|
||||||
|
Idle,
|
||||||
|
Checking,
|
||||||
|
Checked { download_url: String },
|
||||||
|
Installing,
|
||||||
|
Updated,
|
||||||
|
Errored { msg: Box<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
|
||||||
|
fn as_ref(&self) -> &AutoUpdateStatus {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(download_url: String) -> Self {
|
||||||
|
Self::Checked { download_url }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(e: String) -> Self {
|
||||||
|
Self::Errored { msg: Box::new(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GitHubRelease {
|
||||||
|
pub tag_name: String,
|
||||||
|
pub assets: Vec<GitHubAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GitHubAsset {
|
||||||
|
pub name: String,
|
||||||
|
pub browser_download_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AutoUpdater {
|
||||||
|
/// Current status of the auto updater
|
||||||
|
pub status: AutoUpdateStatus,
|
||||||
|
|
||||||
|
/// Current version of the application
|
||||||
|
pub version: Version,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
|
/// Background tasks
|
||||||
|
_tasks: SmallVec<[Task<()>; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutoUpdater {
|
||||||
|
/// Retrieve the global auto updater instance
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global auto updater instance
|
||||||
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalAutoUpdater(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||||
|
let async_version = version.clone();
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Check for updates after 2 minutes
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(Duration::from_secs(120))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Update the status to checking
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Checking, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
match Self::check_for_updates(async_version, cx).await {
|
||||||
|
Ok(download_url) => {
|
||||||
|
// Update the status to checked with download URL
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::checked(download_url), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to check for updates: {e}");
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the status
|
||||||
|
cx.observe_self(|this, cx| {
|
||||||
|
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
|
||||||
|
this.download_and_install(&download_url, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
status: AutoUpdateStatus::Idle,
|
||||||
|
version,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||||
|
self.status = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_for_updates(version: Version, cx: &AsyncApp) -> Task<Result<String, Error>> {
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let repo_owner = get_github_repo_owner();
|
||||||
|
let repo_name = get_github_repo_name();
|
||||||
|
let url = format!(
|
||||||
|
"{}/repos/{}/{}/releases/latest",
|
||||||
|
GITHUB_API_URL, repo_owner, repo_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "Coop-Auto-Updater")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch GitHub releases")?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow!("GitHub API returned error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let release: GitHubRelease = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse GitHub release")?;
|
||||||
|
|
||||||
|
// Parse version from tag (remove 'v' prefix if present)
|
||||||
|
let tag_version = release.tag_name.trim_start_matches('v');
|
||||||
|
let new_version = Version::parse(tag_version).context(format!(
|
||||||
|
"Failed to parse version from tag: {}",
|
||||||
|
release.tag_name
|
||||||
|
))?;
|
||||||
|
|
||||||
|
if new_version > version {
|
||||||
|
// Find the appropriate asset for the current platform
|
||||||
|
let current_os = std::env::consts::OS;
|
||||||
|
let asset_name = match current_os {
|
||||||
|
"macos" => "Coop.dmg",
|
||||||
|
"linux" => "coop.tar.gz",
|
||||||
|
"windows" => "Coop.exe",
|
||||||
|
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let download_url = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.name == asset_name)
|
||||||
|
.map(|asset| asset.browser_download_url.clone())
|
||||||
|
.context(format!(
|
||||||
|
"No {} asset found in release {}",
|
||||||
|
asset_name, release.tag_name
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(download_url)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!(
|
||||||
|
"No update available. Current: {}, Latest: {}",
|
||||||
|
version,
|
||||||
|
new_version
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
|
||||||
|
let http_client = cx.http_client();
|
||||||
|
let download_url = download_url.to_string();
|
||||||
|
|
||||||
|
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||||
|
let installer_dir = InstallerDir::new().await?;
|
||||||
|
let target_path = Self::target_path(&installer_dir).await?;
|
||||||
|
|
||||||
|
// Download the release
|
||||||
|
download(&download_url, &target_path, http_client).await?;
|
||||||
|
|
||||||
|
Ok((installer_dir, target_path))
|
||||||
|
});
|
||||||
|
|
||||||
|
self._tasks.push(
|
||||||
|
// Install the new release
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Installing, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
match task.await {
|
||||||
|
Ok((installer_dir, target_path)) => {
|
||||||
|
if Self::install(installer_dir, target_path, cx).await.is_ok() {
|
||||||
|
// Update the status to updated
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Updated, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Update the status to error including the error message
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
|
||||||
|
let filename = match std::env::consts::OS {
|
||||||
|
"macos" => anyhow::Ok("Coop.dmg"),
|
||||||
|
"linux" => Ok("coop.tar.gz"),
|
||||||
|
"windows" => Ok("Coop.exe"),
|
||||||
|
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Ok(installer_dir.path().join(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install(
|
||||||
|
installer_dir: InstallerDir,
|
||||||
|
target_path: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match std::env::consts::OS {
|
||||||
|
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
|
||||||
|
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
|
||||||
|
"windows" => install_release_windows(target_path).await,
|
||||||
|
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download(
|
||||||
|
url: &str,
|
||||||
|
target_path: &std::path::Path,
|
||||||
|
client: Arc<dyn HttpClient>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let body = AsyncBody::default();
|
||||||
|
let mut target_file = File::create(&target_path).await?;
|
||||||
|
let mut response = client.get(url, body, true).await?;
|
||||||
|
|
||||||
|
// Copy the response body to the target file
|
||||||
|
smol::io::copy(response.body_mut(), &mut target_file).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_release_macos(
|
||||||
|
temp_dir: &InstallerDir,
|
||||||
|
downloaded_dmg: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||||
|
let running_app_filename = running_app_path
|
||||||
|
.file_name()
|
||||||
|
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
|
||||||
|
|
||||||
|
let mount_path = temp_dir.path().join("Coop");
|
||||||
|
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
|
||||||
|
|
||||||
|
mounted_app_path.push("/");
|
||||||
|
|
||||||
|
let output = Command::new("hdiutil")
|
||||||
|
.args(["attach", "-nobrowse"])
|
||||||
|
.arg(&downloaded_dmg)
|
||||||
|
.arg("-mountroot")
|
||||||
|
.arg(temp_dir.path())
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to mount: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
|
||||||
|
let _unmounter = MacOsUnmounter {
|
||||||
|
mount_path: mount_path.clone(),
|
||||||
|
background_executor: cx.background_executor(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = Command::new("rsync")
|
||||||
|
.args(["-av", "--delete"])
|
||||||
|
.arg(&mounted_app_path)
|
||||||
|
.arg(&running_app_path)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to copy app: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_release_linux(
|
||||||
|
temp_dir: &InstallerDir,
|
||||||
|
downloaded_tar_gz: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||||
|
|
||||||
|
// Extract the tar.gz file
|
||||||
|
let extracted = temp_dir.path().join("coop");
|
||||||
|
smol::fs::create_dir_all(&extracted)
|
||||||
|
.await
|
||||||
|
.context("failed to create directory to extract update")?;
|
||||||
|
|
||||||
|
let output = Command::new("tar")
|
||||||
|
.arg("-xzf")
|
||||||
|
.arg(&downloaded_tar_gz)
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&extracted)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to extract {:?} to {:?}: {:?}",
|
||||||
|
downloaded_tar_gz,
|
||||||
|
extracted,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the extracted app directory
|
||||||
|
let mut entries = smol::fs::read_dir(&extracted).await?;
|
||||||
|
let mut app_dir = None;
|
||||||
|
|
||||||
|
use smol::stream::StreamExt;
|
||||||
|
|
||||||
|
while let Some(entry) = entries.next().await {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
app_dir = Some(path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = app_dir.context("No app directory found in archive")?;
|
||||||
|
|
||||||
|
// Copy to the current installation directory
|
||||||
|
let output = Command::new("rsync")
|
||||||
|
.args(["-av", "--delete"])
|
||||||
|
.arg(&from)
|
||||||
|
.arg(
|
||||||
|
running_app_path
|
||||||
|
.parent()
|
||||||
|
.context("No parent directory for app")?,
|
||||||
|
)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to copy app from {:?} to {:?}: {:?}",
|
||||||
|
from,
|
||||||
|
running_app_path.parent(),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
|
||||||
|
//const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let system_root = std::env::var("SYSTEMROOT");
|
||||||
|
let powershell_path = system_root.as_ref().map_or_else(
|
||||||
|
|_| "powershell.exe".to_string(),
|
||||||
|
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut installer_path = std::ffi::OsString::new();
|
||||||
|
installer_path.push("\"");
|
||||||
|
installer_path.push(&downloaded_installer);
|
||||||
|
installer_path.push("\"");
|
||||||
|
|
||||||
|
let output = Command::new(powershell_path)
|
||||||
|
//.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["-NoProfile", "-WindowStyle", "Hidden"])
|
||||||
|
.args(["Start-Process"])
|
||||||
|
.arg(installer_path)
|
||||||
|
.arg("-ArgumentList")
|
||||||
|
.args(["/P", "/R"])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to start installer: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
27
crates/chat/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "chat"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
state = { path = "../state" }
|
||||||
|
device = { path = "../device" }
|
||||||
|
person = { path = "../person" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
flume.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
fuzzy-matcher = "0.3.7"
|
||||||
627
crates/chat/src/lib.rs
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
use std::cmp::Reverse;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
|
use common::EventUtils;
|
||||||
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||||
|
use fuzzy_matcher::FuzzyMatcher;
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::{NostrRegistry, DEVICE_GIFTWRAP, USER_GIFTWRAP};
|
||||||
|
|
||||||
|
mod message;
|
||||||
|
mod room;
|
||||||
|
|
||||||
|
pub use message::*;
|
||||||
|
pub use room::*;
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
|
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||||
|
|
||||||
|
impl Global for GlobalChatRegistry {}
|
||||||
|
|
||||||
|
/// Chat event.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum ChatEvent {
|
||||||
|
/// An event to open a room by its ID
|
||||||
|
OpenRoom(u64),
|
||||||
|
/// An event to close a room by its ID
|
||||||
|
CloseRoom(u64),
|
||||||
|
/// An event to notify UI about a new chat request
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Channel signal.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum Signal {
|
||||||
|
/// Message received from relay pool
|
||||||
|
Message(NewMessage),
|
||||||
|
/// Eose received from relay pool
|
||||||
|
Eose,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chat Registry
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ChatRegistry {
|
||||||
|
/// Collection of all chat rooms
|
||||||
|
rooms: Vec<Entity<Room>>,
|
||||||
|
|
||||||
|
/// Tracking the status of unwrapping gift wrap events.
|
||||||
|
tracking_flag: Arc<AtomicBool>,
|
||||||
|
|
||||||
|
/// Async tasks
|
||||||
|
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||||
|
|
||||||
|
/// Subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<ChatEvent> for ChatRegistry {}
|
||||||
|
|
||||||
|
impl ChatRegistry {
|
||||||
|
/// Retrieve the global chat registry state
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalChatRegistry>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global chat registry instance
|
||||||
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalChatRegistry(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new chat registry instance
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let nip65 = nostr.read(cx).nip65_state();
|
||||||
|
let nip17 = nostr.read(cx).nip17_state();
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the nip65 state and load chat rooms on every state change
|
||||||
|
cx.observe(&nip65, |this, state, cx| {
|
||||||
|
if state.read(cx).idle() {
|
||||||
|
this.reset(cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the nip17 state and load chat rooms on every state change
|
||||||
|
cx.observe(&nip17, |this, _state, cx| {
|
||||||
|
this.get_rooms(cx);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
|
this.handle_notifications(cx);
|
||||||
|
this.tracking(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
rooms: vec![],
|
||||||
|
tracking_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
tasks: smallvec![],
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle nostr notifications
|
||||||
|
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
let status = self.tracking_flag.clone();
|
||||||
|
|
||||||
|
let initialized_at = Timestamp::now();
|
||||||
|
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||||
|
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
|
||||||
|
|
||||||
|
// Channel for communication between nostr and gpui
|
||||||
|
let (tx, rx) = flume::bounded::<Signal>(1024);
|
||||||
|
|
||||||
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
|
let device_signer = signer.get_encryption_signer().await;
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
|
while let Some(notification) = notifications.next().await {
|
||||||
|
let ClientNotification::Message { message, .. } = notification else {
|
||||||
|
// Skip non-message notifications
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match message {
|
||||||
|
RelayMessage::Event { event, .. } => {
|
||||||
|
if !processed_events.insert(event.id) {
|
||||||
|
// Skip if the event has already been processed
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.kind != Kind::GiftWrap {
|
||||||
|
// Skip non-gift wrap events
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Received gift wrap event: {:?}", event);
|
||||||
|
|
||||||
|
// Extract the rumor from the gift wrap event
|
||||||
|
match Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
|
||||||
|
Ok(rumor) => match rumor.created_at >= initialized_at {
|
||||||
|
true => {
|
||||||
|
let new_message = NewMessage::new(event.id, rumor);
|
||||||
|
let signal = Signal::Message(new_message);
|
||||||
|
|
||||||
|
tx.send_async(signal).await?;
|
||||||
|
}
|
||||||
|
false => {
|
||||||
|
status.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to unwrap the gift wrap event: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayMessage::EndOfStoredEvents(id) => {
|
||||||
|
if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 {
|
||||||
|
tx.send_async(Signal::Eose).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
while let Ok(message) = rx.recv_async().await {
|
||||||
|
match message {
|
||||||
|
Signal::Message(message) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.new_message(message, cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Signal::Eose => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.get_rooms(cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracking the status of unwrapping gift wrap events.
|
||||||
|
fn tracking(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let status = self.tracking_flag.clone();
|
||||||
|
|
||||||
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
|
let loop_duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if status.load(Ordering::Acquire) {
|
||||||
|
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
smol::Timer::after(loop_duration).await;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the loading status of the chat registry
|
||||||
|
pub fn loading(&self) -> bool {
|
||||||
|
self.tracking_flag.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a weak reference to a room by its ID.
|
||||||
|
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.find(|this| &this.read(cx).id == id)
|
||||||
|
.map(|this| this.downgrade())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all rooms based on the filter.
|
||||||
|
pub fn rooms(&self, filter: &RoomKind, cx: &App) -> Vec<Entity<Room>> {
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.filter(|room| &room.read(cx).kind == filter)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count the number of rooms based on the filter.
|
||||||
|
pub fn count(&self, filter: &RoomKind, cx: &App) -> usize {
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.filter(|room| &room.read(cx).kind == filter)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new room to the start of list.
|
||||||
|
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
I: Into<Room> + 'static,
|
||||||
|
{
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let signer = client.signer()?;
|
||||||
|
let public_key = signer.get_public_key().await.ok()?;
|
||||||
|
let room: Room = room.into().organize(&public_key);
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.rooms.insert(0, cx.new(|_| room));
|
||||||
|
cx.emit(ChatEvent::Ping);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit an open room event.
|
||||||
|
///
|
||||||
|
/// If the room is new, add it to the registry.
|
||||||
|
pub fn emit_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
|
||||||
|
// Get the room's ID.
|
||||||
|
let id = room.read(cx).id;
|
||||||
|
|
||||||
|
// If the room is new, add it to the registry.
|
||||||
|
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||||
|
self.rooms.insert(0, room.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit the open room event.
|
||||||
|
cx.emit(ChatEvent::OpenRoom(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a room.
|
||||||
|
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||||
|
if self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||||
|
cx.emit(ChatEvent::CloseRoom(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort rooms by their created at.
|
||||||
|
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finding rooms based on a query.
|
||||||
|
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
||||||
|
let matcher = SkimMatcherV2::default();
|
||||||
|
|
||||||
|
if let Ok(public_key) = PublicKey::parse(query) {
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.filter(|room| room.read(cx).members.contains(&public_key))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.filter(|room| {
|
||||||
|
matcher
|
||||||
|
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
|
||||||
|
.is_some()
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the registry.
|
||||||
|
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.rooms.clear();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extend the registry with new rooms.
|
||||||
|
fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
|
||||||
|
let mut room_map: HashMap<u64, usize> = self
|
||||||
|
.rooms
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, room)| (room.read(cx).id, idx))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for new_room in rooms.into_iter() {
|
||||||
|
// Check if we already have a room with this ID
|
||||||
|
if let Some(&index) = room_map.get(&new_room.id) {
|
||||||
|
self.rooms[index].update(cx, |this, cx| {
|
||||||
|
if new_room.created_at > this.created_at {
|
||||||
|
*this = new_room;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let new_room_id = new_room.id;
|
||||||
|
self.rooms.push(cx.new(|_| new_room));
|
||||||
|
|
||||||
|
let new_index = self.rooms.len();
|
||||||
|
room_map.insert(new_room_id, new_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all rooms from the database.
|
||||||
|
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let task = self.get_rooms_from_database(cx);
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let rooms = task.await.ok()?;
|
||||||
|
|
||||||
|
this.update(cx, move |this, cx| {
|
||||||
|
this.extend_rooms(rooms, cx);
|
||||||
|
this.sort(cx);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a task to load rooms from the database
|
||||||
|
fn get_rooms_from_database(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// Get contacts
|
||||||
|
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||||
|
|
||||||
|
// Construct authored filter
|
||||||
|
let authored_filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
|
||||||
|
|
||||||
|
// Get all authored events
|
||||||
|
let authored = client.database().query(authored_filter).await?;
|
||||||
|
|
||||||
|
// Construct addressed filter
|
||||||
|
let addressed_filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
|
||||||
|
|
||||||
|
// Get all addressed events
|
||||||
|
let addressed = client.database().query(addressed_filter).await?;
|
||||||
|
|
||||||
|
// Merge authored and addressed events
|
||||||
|
let events = authored.merge(addressed);
|
||||||
|
|
||||||
|
// Collect results
|
||||||
|
let mut rooms: HashSet<Room> = HashSet::new();
|
||||||
|
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
// Process each event and group by room hash
|
||||||
|
for raw in events.into_iter() {
|
||||||
|
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) {
|
||||||
|
if rumor.tags.public_keys().peekable().peek().is_some() {
|
||||||
|
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_id, mut messages) in grouped.into_iter() {
|
||||||
|
messages.sort_by_key(|m| Reverse(m.created_at));
|
||||||
|
|
||||||
|
// Always use the latest message
|
||||||
|
let Some(latest) = messages.first() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct the room from the latest message.
|
||||||
|
//
|
||||||
|
// Call `.organize` to ensure the current user is at the end of the list.
|
||||||
|
let mut room = Room::from(latest).organize(&public_key);
|
||||||
|
|
||||||
|
// Check if the user has responded to the room
|
||||||
|
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
||||||
|
|
||||||
|
// Check if public keys are from the user's contacts
|
||||||
|
let is_contact = room.members.iter().any(|k| contacts.contains(k));
|
||||||
|
|
||||||
|
// Set the room's kind based on status
|
||||||
|
if user_sent || is_contact {
|
||||||
|
room = room.kind(RoomKind::Ongoing);
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms.insert(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(rooms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a nostr event into a message and push it to the belonging room
|
||||||
|
///
|
||||||
|
/// If the room doesn't exist, it will be created.
|
||||||
|
/// Updates room ordering based on the most recent messages.
|
||||||
|
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||||
|
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
|
||||||
|
Some(room) => {
|
||||||
|
room.update(cx, |this, cx| {
|
||||||
|
this.push_message(message, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Push the new room to the front of the list
|
||||||
|
self.add_room(message.rumor, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger a refresh of the opened chat rooms by their IDs
|
||||||
|
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
|
||||||
|
if let Some(ids) = ids {
|
||||||
|
for room in self.rooms.iter() {
|
||||||
|
if ids.contains(&room.read(cx).id) {
|
||||||
|
room.update(cx, |this, cx| {
|
||||||
|
this.emit_refresh(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwraps a gift-wrapped event and processes its contents.
|
||||||
|
async fn extract_rumor(
|
||||||
|
client: &Client,
|
||||||
|
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||||
|
gift_wrap: &Event,
|
||||||
|
) -> Result<UnsignedEvent, Error> {
|
||||||
|
// Try to get cached rumor first
|
||||||
|
if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await {
|
||||||
|
return Ok(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to unwrap with the available signer
|
||||||
|
let unwrapped = Self::try_unwrap(client, device_signer, gift_wrap).await?;
|
||||||
|
let mut rumor_unsigned = unwrapped.rumor;
|
||||||
|
|
||||||
|
// Generate event id for the rumor if it doesn't have one
|
||||||
|
rumor_unsigned.ensure_id();
|
||||||
|
|
||||||
|
// Cache the rumor
|
||||||
|
Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?;
|
||||||
|
|
||||||
|
Ok(rumor_unsigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to try unwrapping with different signers
|
||||||
|
async fn try_unwrap(
|
||||||
|
client: &Client,
|
||||||
|
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||||
|
gift_wrap: &Event,
|
||||||
|
) -> Result<UnwrappedGift, Error> {
|
||||||
|
// Try with the device signer first
|
||||||
|
if let Some(signer) = device_signer {
|
||||||
|
if let Ok(unwrapped) = Self::try_unwrap_with(gift_wrap, signer).await {
|
||||||
|
return Ok(unwrapped);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try with the user's signer
|
||||||
|
let user_signer = client.signer().context("Signer not found")?;
|
||||||
|
let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
|
||||||
|
|
||||||
|
Ok(unwrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to unwrap a gift wrap event with a given signer.
|
||||||
|
async fn try_unwrap_with(
|
||||||
|
gift_wrap: &Event,
|
||||||
|
signer: &Arc<dyn NostrSigner>,
|
||||||
|
) -> Result<UnwrappedGift, Error> {
|
||||||
|
// Get the sealed event
|
||||||
|
let seal = signer
|
||||||
|
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Verify the sealed event
|
||||||
|
let seal: Event = Event::from_json(seal)?;
|
||||||
|
seal.verify_with_ctx(&SECP256K1)?;
|
||||||
|
|
||||||
|
// Get the rumor event
|
||||||
|
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
|
||||||
|
let rumor = UnsignedEvent::from_json(rumor)?;
|
||||||
|
|
||||||
|
Ok(UnwrappedGift {
|
||||||
|
sender: seal.pubkey,
|
||||||
|
rumor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores an unwrapped event in local database with reference to original
|
||||||
|
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||||
|
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
|
||||||
|
let author = rumor.pubkey;
|
||||||
|
let conversation = Self::conversation_id(rumor);
|
||||||
|
|
||||||
|
let mut tags = rumor.tags.clone().to_vec();
|
||||||
|
|
||||||
|
// Add a unique identifier
|
||||||
|
tags.push(Tag::identifier(id));
|
||||||
|
|
||||||
|
// Add a reference to the rumor's author
|
||||||
|
tags.push(Tag::custom(
|
||||||
|
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
|
||||||
|
[author],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add a conversation id
|
||||||
|
tags.push(Tag::custom(
|
||||||
|
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
|
||||||
|
[conversation.to_string()],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add a reference to the rumor's id
|
||||||
|
tags.push(Tag::event(rumor_id));
|
||||||
|
|
||||||
|
// Add references to the rumor's participants
|
||||||
|
for receiver in rumor.tags.public_keys().copied() {
|
||||||
|
tags.push(Tag::custom(
|
||||||
|
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
|
||||||
|
[receiver],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert rumor to json
|
||||||
|
let content = rumor.as_json();
|
||||||
|
|
||||||
|
// Construct the event
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||||
|
.tags(tags)
|
||||||
|
.sign(&Keys::generate())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Save the event to the database
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a previously unwrapped event from local database
|
||||||
|
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(gift_wrap)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
|
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Event is not cached yet."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the conversation ID for a given rumor (message).
|
||||||
|
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
|
||||||
|
pubkeys.push(rumor.pubkey);
|
||||||
|
pubkeys.sort();
|
||||||
|
pubkeys.dedup();
|
||||||
|
pubkeys.hash(&mut hasher);
|
||||||
|
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||