Compare commits
171 Commits
0.1.5-alph
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
81
.github/workflows/main.yml
vendored
@@ -1,81 +0,0 @@
|
||||
name: Packager Release Process
|
||||
|
||||
run-name: Triggered by ${{ github.actor }}.
|
||||
on: workflow_dispatch
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CN_APPLICATION: lume/coop
|
||||
|
||||
jobs:
|
||||
draft:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Create draft release
|
||||
uses: crabnebula-dev/cloud-release@v0
|
||||
with:
|
||||
command: release draft ${{ env.CN_APPLICATION }} --framework packager
|
||||
api-key: ${{ secrets.CN_API_KEY }}
|
||||
|
||||
build:
|
||||
needs: draft
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Install stable toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc g++ libasound2-dev libfontconfig-dev libwayland-dev libxkbcommon-x11-dev libssl-dev libzstd-dev libvulkan1 libgit2-dev make cmake clang jq netcat-openbsd git curl gettext-base elfutils libsqlite3-dev musl-tools musl-dev build-essential
|
||||
|
||||
- name: install cargo packager
|
||||
run: |
|
||||
cargo install cargo-packager --locked
|
||||
|
||||
- name: Build packager app
|
||||
run: |
|
||||
cargo packager --release
|
||||
|
||||
- name: Move assets to workdir
|
||||
run: |
|
||||
mv target/release/* .
|
||||
|
||||
- name: Upload assets
|
||||
uses: crabnebula-dev/cloud-release@v0
|
||||
with:
|
||||
command: release upload ${{ env.CN_APPLICATION }} --framework packager
|
||||
api-key: ${{ secrets.CN_API_KEY }}
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish release
|
||||
uses: crabnebula-dev/cloud-release@v0
|
||||
with:
|
||||
command: release publish ${{ env.CN_APPLICATION }} --framework packager
|
||||
api-key: ${{ secrets.CN_API_KEY }}
|
||||
172
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: windows-x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
- platform: windows-arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- platform: macos-x64
|
||||
os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
- platform: macos-arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- platform: linux-x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- platform: linux-arm64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# Windows and macOS builds using cargo-packager
|
||||
- name: Build with cargo-packager (Windows/macOS)
|
||||
if: runner.os != 'Linux'
|
||||
working-directory: crates/coop
|
||||
run: |
|
||||
cargo install cargo-packager --locked
|
||||
cargo packager --release
|
||||
|
||||
- name: Upload Windows/macOS artifacts
|
||||
if: runner.os != 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.platform }}-artifacts
|
||||
path: |
|
||||
dist/*.dmg
|
||||
dist/*.msi
|
||||
dist/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# Linux builds using custom scripts
|
||||
- name: Install Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder snapd squashfs-tools jq gettext-base
|
||||
|
||||
- name: Install Snapcraft
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
- name: Make scripts executable
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
chmod +x script/get-crate-version
|
||||
chmod +x script/linux
|
||||
chmod +x script/bundle-snap
|
||||
chmod +x script/bundle-linux
|
||||
chmod +x script/flatpak/deps
|
||||
chmod +x script/flatpak/bundle-flatpak
|
||||
|
||||
- name: Install required dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: ./script/linux
|
||||
|
||||
# Only build Flatpak and Snap for x86_64 (most common use case)
|
||||
- name: Build Flatpak
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
./script/bundle-linux --flatpak
|
||||
./script/flatpak/deps
|
||||
./script/flatpak/bundle-flatpak
|
||||
|
||||
- name: Build Snap
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
VERSION=$(script/get-crate-version coop)
|
||||
./script/bundle-linux
|
||||
./script/bundle-snap $VERSION
|
||||
|
||||
- name: Collect Linux artifacts
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
mkdir -p linux-artifacts
|
||||
# Copy the tarball created by bundle-linux
|
||||
find target/release -name "*.tar.gz" -exec cp {} linux-artifacts/ \;
|
||||
# Find and copy flatpak files (if they exist)
|
||||
find . -name "*.flatpak" -exec cp {} linux-artifacts/ \; || true
|
||||
# Find and copy snap files (if they exist)
|
||||
find . -name "*.snap" -exec cp {} linux-artifacts/ \; || true
|
||||
ls -la linux-artifacts/
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.platform }}-artifacts
|
||||
path: linux-artifacts/**/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Make get-crate-version executable
|
||||
run: chmod +x script/get-crate-version
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(script/get-crate-version coop)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Display artifacts structure
|
||||
run: |
|
||||
echo "Artifacts structure:"
|
||||
find artifacts -type f -exec ls -la {} \;
|
||||
|
||||
- name: Create draft release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: ${{ steps.version.outputs.tag }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/**/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Output release info
|
||||
run: |
|
||||
echo "Created draft release: ${{ steps.create_release.outputs.url }}"
|
||||
echo "Release ID: ${{ steps.create_release.outputs.id }}"
|
||||
32
.github/workflows/rust.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["m**"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
rustup: [stable]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: script/linux
|
||||
run: chmod +x ./script/linux && ./script/linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
2
.gitignore
vendored
@@ -19,3 +19,5 @@ dist/
|
||||
|
||||
# Useless stuffs
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
.intentionally-empty-file.o
|
||||
|
||||
4160
Cargo.lock
generated
109
Cargo.toml
@@ -1,53 +1,56 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/coop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.5"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
coop = { path = "crates/*" }
|
||||
|
||||
# UI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["parser"] }
|
||||
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-keyring = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
|
||||
"lmdb",
|
||||
"nip96",
|
||||
"nip59",
|
||||
"nip49",
|
||||
"nip44",
|
||||
"nip05",
|
||||
] }
|
||||
|
||||
# Others
|
||||
emojis = "0.6.4"
|
||||
smol = "2"
|
||||
oneshot = "0.1.10"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dirs = "5.0"
|
||||
itertools = "0.13.0"
|
||||
futures = "0.3.30"
|
||||
chrono = "0.4.38"
|
||||
tracing = "0.1.40"
|
||||
anyhow = "1.0.44"
|
||||
smallvec = "1.14.0"
|
||||
rust-embed = "8.5.0"
|
||||
log = "0.4"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/coop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace.metadata.i18n]
|
||||
available-locales = ["en"]
|
||||
default-locale = "en"
|
||||
load-path = "locales"
|
||||
|
||||
[workspace.dependencies]
|
||||
i18n = { path = "crates/i18n" }
|
||||
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = "0.4.38"
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = "8.5.0"
|
||||
rust-i18n = "3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
tracing = "0.1.40"
|
||||
webbrowser = "1.0.4"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[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"
|
||||
version = "0.1.5"
|
||||
category = "SocialNetworking"
|
||||
identifier = "su.reya.coop"
|
||||
resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"]
|
||||
binaries = [ { path = "coop", main = true } ]
|
||||
before-packaging-command = "cargo build --release"
|
||||
out-dir = "./target/release"
|
||||
icons = [
|
||||
"crates/coop/resources/32x32.png",
|
||||
"crates/coop/resources/128x128.png",
|
||||
"crates/coop/resources/128x128@2x.png",
|
||||
"crates/coop/resources/app-icon.icns",
|
||||
"crates/coop/resources/app-icon.png",
|
||||
"crates/coop/resources/app-icon.ico",
|
||||
]
|
||||
55
README.md
@@ -1,19 +1,34 @@
|
||||

|
||||

|
||||
|
||||
<p>
|
||||
<a href="https://github.com/lumehq/coop/actions/workflows/main.yml">
|
||||
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/main.yml/badge.svg">
|
||||
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml">
|
||||
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg">
|
||||
</a>
|
||||
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/lumehq/coop">
|
||||
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop">
|
||||
</p>
|
||||
|
||||
Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability.
|
||||
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
|
||||
|
||||
**New**✨: A blog post introducing Coop in details has been posted [here](#).
|
||||
### Screenshots
|
||||
|
||||
> Coop is currently in the **alpha stage** of development. This means the app may contain bugs, incomplete features, or unexpected behavior. We recommend using it for testing purposes only and not for critical or sensitive communications. Your feedback is invaluable in helping us improve Coop, so please report any issues or suggestions via the [GitHub Issue Tracker](https://github.com/lumehq/coop/issues). Thank you for your understanding and support!
|
||||
<p float="left">
|
||||
<img src="/docs/mac_01.png" width="250" />
|
||||
<img src="/docs/mac_02.png" width="250" />
|
||||
<img src="/docs/mac_03.png" width="250" />
|
||||
<img src="/docs/mac_04.png" width="250" />
|
||||
<img src="/docs/mac_05.png" width="250" />
|
||||
<img src="/docs/mac_06.png" width="250" />
|
||||
<img src="/docs/mac_07.png" width="250" />
|
||||
<img src="/docs/mac_08.png" width="250" />
|
||||
<img src="/docs/mac_09.png" width="250" />
|
||||
<img src="/docs/linux_01.png" width="250" />
|
||||
<img src="/docs/linux_02.png" width="250" />
|
||||
<img src="/docs/linux_03.png" width="250" />
|
||||
<img src="/docs/linux_04.png" width="250" />
|
||||
<img src="/docs/linux_05.png" width="250" />
|
||||
</p>
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -28,9 +43,7 @@ To install Coop, follow these steps:
|
||||
|
||||
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
|
||||
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
|
||||
- **Ubuntu**: Run the downloaded `.deb` or `.AppImage` installer and follow the on-screen instructions.
|
||||
- **Arch Linux**: For `.tar.gz` packages, extract and install manually. For PKGBUILD, use `makepkg -si` to build and install.
|
||||
- **Flatpak**: Coming soon.
|
||||
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
|
||||
|
||||
3. **Run Coop**:
|
||||
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
|
||||
@@ -56,13 +69,25 @@ Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nos
|
||||
cd coop
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
2.1 Install Linux dependencies:
|
||||
|
||||
```bash
|
||||
./script/linux
|
||||
```
|
||||
|
||||
2.2 Install FreeBSD dependencies:
|
||||
|
||||
```bash
|
||||
./script/freebsd
|
||||
```
|
||||
|
||||
3. Install Rust dependencies:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
3. Run the app:
|
||||
4. Run the app:
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
@@ -88,14 +113,6 @@ If you'd like to contribute to Coop, please follow these steps:
|
||||
|
||||
For more information, see the [Contributing](#contributing) section.
|
||||
|
||||
#### Debugging
|
||||
|
||||
To debug Coop, you can use `cargo`'s built-in debugging tools or attach a debugger like `gdb` or `lldb`. For example:
|
||||
|
||||
```bash
|
||||
cargo run -- --debug
|
||||
```
|
||||
|
||||
#### Additional Resources
|
||||
|
||||
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
|
||||
|
||||
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/plex-mono/ZedPlexMono-Bold.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-Italic.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-Regular.ttf
Normal file
92
assets/fonts/plex-mono/license.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
assets/fonts/plex-sans/ZedPlexSans-Bold.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-Italic.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-Regular.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-SemiBold.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-SemiBoldItalic.ttf
Normal file
92
assets/fonts/plex-sans/license.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,3 +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="M19.25 21.25H6c-.69 0-1.25-.56-1.25-1.25M11.5 9.25h1M4.75 20V4.75a2 2 0 0 1 2-2h12.5v16H6c-.69 0-1.25.56-1.25 1.25Zm5-6.25s0-1.5 2.25-1.5 2.25 1.5 2.25 1.5h-4.5ZM13 9.25a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 404 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.22-1.53 3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 1 1-1.06 1.06l-1.97-1.97v6.69a.75.75 0 0 1-1.5 0V9.56l-1.97 1.97a.75.75 0 0 1-1.06-1.06Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 397 B |
@@ -1,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="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10a9.967 9.967 0 0 1-4.098-.876.313.313 0 0 0-.195-.026l-3.471.78a1.75 1.75 0 0 1-2.084-2.12l.809-3.33a.313.313 0 0 0-.028-.204A9.965 9.965 0 0 1 2 12Zm4.5 0a1 1 0 1 0 2 0 1 1 0 0 0-2 0Zm4.5 0a1 1 0 1 0 2 0 1 1 0 0 0-2 0Zm5.5 1a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 488 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20 9-6.586 6.586a2 2 0 0 1-2.828 0L4 9"/>
|
||||
<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: 245 B After Width: | Height: | Size: 247 B |
3
assets/icons/copy.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8v.75m0-3.75v-.25a2 2 0 0 1 2-2H11m8 0h.25a2 2 0 0 1 2 2V5M14 2.75h2M21.25 8v2m0 3v.25a2 2 0 0 1-2 2H19m-3 0h-.75M14 8.75H4c-.69 0-1.25.56-1.25 1.25v10c0 .69.56 1.25 1.25 1.25h10c.69 0 1.25-.56 1.25-1.25V10c0-.69-.56-1.25-1.25-1.25Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -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" d="M4 4.75A2.75 2.75 0 0 1 6.75 2h10.5A2.75 2.75 0 0 1 20 4.75v7.917a3.9 3.9 0 0 0-4.091.909l-3.75 3.75a2.25 2.25 0 0 0-.659 1.59v2.334c0 .263.045.515.128.75H6.75A2.75 2.75 0 0 1 4 19.25V4.75Z"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M19.303 15.697a.9.9 0 0 0-1.273 0l-3.53 3.53V20.5h1.273l3.53-3.53a.9.9 0 0 0 0-1.273Zm-2.333-1.06a2.4 2.4 0 1 1 3.394 3.393l-3.75 3.75a.75.75 0 0 1-.53.22H13.75a.75.75 0 0 1-.75-.75v-2.333a.75.75 0 0 1 .22-.53l3.75-3.75Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 622 B |
4
assets/icons/edit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.75 21.25h-4a2 2 0 0 1-2-2V4.75a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v7"/>
|
||||
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M13.75 21.25v-2.333l3.75-3.75a1.65 1.65 0 0 1 2.333 2.333l-3.75 3.75H13.75Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 454 B |
3
assets/icons/encryption.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8.75a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm0 0v6m8.25-2.838v-4.97a2 2 0 0 0-1.367-1.898l-6.25-2.083a2 2 0 0 0-1.265 0l-6.25 2.083A2 2 0 0 0 3.75 6.942v4.97c0 4.973 4.25 7.338 8.25 9.496 4-2.158 8.25-4.523 8.25-9.496Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 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="M2.75 5.75v11.5a2 2 0 0 0 2 2h14.5a2 2 0 0 0 2-2v-8.5a2 2 0 0 0-2-2h-6.18a2 2 0 0 1-1.664-.89l-.812-1.22a2 2 0 0 0-1.664-.89H4.75a2 2 0 0 0-2 2Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 350 B |
3
assets/icons/group.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="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>
|
||||
|
After Width: | Height: | Size: 550 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m7.5 3.25 4.5 3.5 4.5-3.5m-11.75 17h14.5a2 2 0 0 0 2-2v-9.5a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2v9.5a2 2 0 0 0 2 2Z"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.25 12H9m11.25 0-4.5 4.5m4.5-4.5-4.5-4.5m-4.5 12.75h-5.5a2 2 0 0 1-2-2V5.75a2 2 0 0 1 2-2h5.5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 302 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M104,152a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H96A8,8,0,0,1,104,152ZM168,32h24a8,8,0,0,0,0-16H160a8,8,0,0,0-8,8V56h16Zm72,84v60a16,16,0,0,1-16,16H136v32a8,8,0,0,1-16,0V192H32a16,16,0,0,1-16-16V116A60.07,60.07,0,0,1,76,56h76v88a8,8,0,0,0,16,0V56h12A60.07,60.07,0,0,1,240,116Zm-120,0a44,44,0,0,0-88,0v60h88Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 429 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 2a7.795 7.795 0 0 0-7.696 6.554l-1.17 7.258A2.75 2.75 0 0 0 5.848 19h1.66c.849 1.75 2.512 3 4.492 3s3.643-1.25 4.492-3h1.66a2.75 2.75 0 0 0 2.714-3.188l-1.17-7.258A7.795 7.795 0 0 0 12 2Zm2.754 17H9.245c.678.937 1.68 1.5 2.754 1.5s2.076-.563 2.754-1.5Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 434 B |
3
assets/icons/open-url.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18.25 14v3.05c0 1.12 0 1.68-.218 2.108a2 2 0 0 1-.874.874c-.428.218-.988.218-2.108.218h-8.1c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874c-.218-.428-.218-.988-.218-2.108V8.875c0-1.05 0-1.574.192-1.98a2 2 0 0 1 .953-.953c.406-.192.93-.192 1.98-.192H9.25m4.5-2h6.5m0 0v6.5m0-6.5L11 13"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
3
assets/icons/plus-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 272 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3.75V12m0 0v8.25M12 12H3.75M12 12h8.25"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 4v8m0 0v8m0-8H4m8 0h8"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 248 B After Width: | Height: | Size: 205 B |
4
assets/icons/refresh.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M13 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8-10a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm-1.07 3.268a1 1 0 1 1-1 1.732 1 1 0 0 1 1-1.732Zm-2.562 5.026a1 1 0 1 0-1-1.732 1 1 0 0 0 1 1.732ZM18.927 8a1 1 0 1 1-1-1.732 1 1 0 0 1 1 1.732Z"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.25 14.75v5.5h-5.5M9 19.688a8.25 8.25 0 1 1 6.25-15.273"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
3
assets/icons/reply.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m1.845 11.45 8.146-7.535a.75.75 0 0 1 1.259.55V8c0 .276.228.5.504.504C19.84 8.632 22 11.92 22 20.25c-1.47-2.94-2.22-4.679-10.245-4.748a.501.501 0 0 0-.505.498v3.535a.75.75 0 0 1-1.26.55L1.846 12.55a.75.75 0 0 1 0-1.1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
3
assets/icons/report.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20.001 16-2 2m0 0-2 2m2-2-2-2m2 2 2 2m-8.147-6.749c-3.319.058-5.832 2.055-6.87 4.862-.41 1.105.535 2.137 1.713 2.137h5.554m-.397-6.999L12 13.25c.52 0 1.021.047 1.5.138m-1.647-.137A7.89 7.89 0 0 0 10 13.5m5.75-7a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20.25 20.25-4.123-4.123m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20 20-3.873-3.873m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 287 B |
4
assets/icons/server.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M21.25 12V6.75a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2V12m18.5 0H2.75m18.5 0v5.25a2 2 0 0 1-2 2H4.75a2 2 0 0 1-2-2V12"/>
|
||||
<path fill="currentColor" stroke="currentColor" stroke-width=".5" d="M6.5 14.875a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Zm0-7.25a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
4
assets/icons/settings.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m7.878 5.214-.703-.162a1.77 1.77 0 0 0-2.123 2.123l.162.703a2 2 0 0 1-.84 2.114l-.854.57a1.728 1.728 0 0 0 0 2.876l.855.57a2 2 0 0 1 .84 2.114l-.163.703a1.77 1.77 0 0 0 2.123 2.123l.703-.162a2 2 0 0 1 2.114.84l.57.854a1.728 1.728 0 0 0 2.876 0l.57-.855a2 2 0 0 1 2.114-.84l.703.163a1.77 1.77 0 0 0 2.123-2.123l-.162-.703a2 2 0 0 1 .84-2.114l.854-.57a1.728 1.728 0 0 0 0-2.876l-.855-.57a2 2 0 0 1-.84-2.114l.163-.703a1.77 1.77 0 0 0-2.123-2.123l-.703.162a2 2 0 0 1-2.114-.84l-.57-.854a1.728 1.728 0 0 0-2.876 0l-.57.855a2 2 0 0 1-2.114.84Z"/>
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="M14.75 12a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
3
assets/icons/signal.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 17v2m6-6v6m6-10v10m6-14v14"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 233 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M64.12,147.8a4,4,0,0,1-4,4.2H16a8,8,0,0,1-7.8-6.17,8.35,8.35,0,0,1,1.62-6.93A67.79,67.79,0,0,1,37,117.51a40,40,0,1,1,66.46-35.8,3.94,3.94,0,0,1-2.27,4.18A64.08,64.08,0,0,0,64,144C64,145.28,64,146.54,64.12,147.8Zm182-8.91A67.76,67.76,0,0,0,219,117.51a40,40,0,1,0-66.46-35.8,3.94,3.94,0,0,0,2.27,4.18A64.08,64.08,0,0,1,192,144c0,1.28,0,2.54-.12,3.8a4,4,0,0,0,4,4.2H240a8,8,0,0,0,7.8-6.17A8.33,8.33,0,0,0,246.17,138.89Zm-89,43.18a48,48,0,1,0-58.37,0A72.13,72.13,0,0,0,65.07,212,8,8,0,0,0,72,224H184a8,8,0,0,0,6.93-12A72.15,72.15,0,0,0,157.19,182.07Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 676 B |
3
assets/icons/warning.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 9.02v2.993M12 15h.01M10.277 3.99 3.275 15.998C2.499 17.328 3.458 19 4.998 19h14.004c1.54 0 2.5-1.671 1.723-3.002L13.723 3.99c-.77-1.32-2.677-1.32-3.447 0ZM12.25 15a.25.25 0 1 1-.5 0 .25.25 0 0 1 .5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -5,13 +5,18 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ui = { path = "../ui" }
|
||||
state = { path = "../state" }
|
||||
settings = { path = "../settings" }
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
theme = { path = "../theme" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -1,230 +1,208 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use global::{
|
||||
constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID},
|
||||
get_client,
|
||||
};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use common::BOOTSTRAP_RELAYS;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use ui::{notification::Notification, ContextModal};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
Account::set_global(cx.new(Account::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalAccount(Entity<Account>);
|
||||
|
||||
impl Global for GlobalAccount {}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
Account::set_global(cx.new(|_| Account { profile: None }), cx);
|
||||
pub struct Account {
|
||||
/// The public key of the account
|
||||
public_key: Option<PublicKey>,
|
||||
|
||||
/// Status of the current user NIP-65 relays
|
||||
pub nip65_status: Entity<RelayStatus>,
|
||||
|
||||
/// Status of the current user NIP-17 relays
|
||||
pub nip17_status: Entity<RelayStatus>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
_tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Account {
|
||||
pub profile: Option<Profile>,
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum RelayStatus {
|
||||
#[default]
|
||||
Initial,
|
||||
NotSet,
|
||||
Set,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
/// Retrieve the global account state
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAccount>().0.clone()
|
||||
}
|
||||
|
||||
pub fn set_global(account: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAccount(account));
|
||||
/// Check if the global account state exists
|
||||
pub fn has_global(cx: &App) -> bool {
|
||||
cx.has_global::<GlobalAccount>()
|
||||
}
|
||||
|
||||
/// Login to the account using the given signer.
|
||||
pub fn login<S>(&mut self, signer: S, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
S: NostrSigner + 'static,
|
||||
{
|
||||
let task: Task<Result<Profile, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
// Use user's signer for main signer
|
||||
_ = client.set_signer(signer).await;
|
||||
/// Remove the global account state
|
||||
pub fn remove_global(cx: &mut App) {
|
||||
cx.remove_global::<GlobalAccount>();
|
||||
}
|
||||
|
||||
// Verify nostr signer and get public key
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
log::info!("Logged in with public key: {:?}", public_key);
|
||||
/// Set the global account instance
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAccount(state));
|
||||
}
|
||||
|
||||
// Fetch user's metadata
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
/// Create a new account instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
});
|
||||
let nip65_status = cx.new(|_| RelayStatus::default());
|
||||
let nip17_status = cx.new(|_| RelayStatus::default());
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| match task.await {
|
||||
Ok(profile) => {
|
||||
cx.update(|window, cx| {
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Observe the nostr signer and set the public key when it sets
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn(async move { Self::observe_signer(&client).await })
|
||||
.await;
|
||||
|
||||
if let Some(public_key) = result {
|
||||
this.update(cx, |this, cx| {
|
||||
this.profile(profile, cx);
|
||||
|
||||
cx.defer_in(window, |this, _, cx| {
|
||||
this.subscribe(cx);
|
||||
});
|
||||
this.set_account(public_key, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
.expect("Entity has been released")
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
public_key: None,
|
||||
nip65_status,
|
||||
nip17_status,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new account with the given metadata.
|
||||
pub fn new_account(&mut self, metadata: Metadata, window: &mut Window, cx: &mut Context<Self>) {
|
||||
const DEFAULT_NIP_65_RELAYS: [&str; 4] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
/// Observe the signer and return the public key when it sets
|
||||
async fn observe_signer(client: &Client) -> Option<PublicKey> {
|
||||
let loop_duration = Duration::from_millis(800);
|
||||
|
||||
const DEFAULT_MESSAGING_RELAYS: [&str; 2] =
|
||||
["wss://auth.nostr1.com", "wss://relay.0xchat.com"];
|
||||
loop {
|
||||
if let Ok(signer) = client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
// Get current user's gossip relays
|
||||
Self::get_gossip_relays(client, public_key).await.ok()?;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let public_key = keys.public_key();
|
||||
|
||||
let task: Task<Result<Profile, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
|
||||
// Update signer
|
||||
client.set_signer(keys).await;
|
||||
|
||||
// Set metadata
|
||||
client.set_metadata(&metadata).await?;
|
||||
|
||||
// Create relay list
|
||||
let tags: Vec<Tag> = DEFAULT_NIP_65_RELAYS
|
||||
.into_iter()
|
||||
.filter_map(|url| {
|
||||
if let Ok(url) = RelayUrl::parse(url) {
|
||||
Some(Tag::relay_metadata(url, None))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let builder = EventBuilder::new(Kind::RelayList, "").tags(tags);
|
||||
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send relay list event: {}", e);
|
||||
};
|
||||
|
||||
// Create messaging relay list
|
||||
let tags: Vec<Tag> = DEFAULT_MESSAGING_RELAYS
|
||||
.into_iter()
|
||||
.filter_map(|url| {
|
||||
if let Ok(url) = RelayUrl::parse(url) {
|
||||
Some(Tag::relay(url))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send messaging relay list event: {}", e);
|
||||
};
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(profile) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.profile(profile, cx);
|
||||
|
||||
cx.defer_in(window, |this, _, cx| {
|
||||
this.subscribe(cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error("Failed to create account."), cx)
|
||||
})
|
||||
.ok();
|
||||
return Some(public_key);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
smol::Timer::after(loop_duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the profile for the account.
|
||||
pub fn profile(&mut self, profile: Profile, cx: &mut Context<Self>) {
|
||||
self.profile = Some(profile);
|
||||
/// Get gossip relays for a given public key
|
||||
async fn get_gossip_relays(client: &Client, public_key: PublicKey) -> Result<(), Error> {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Subscribe to events from the bootstrapping relays
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the user has NIP-65 relays
|
||||
async fn ensure_nip65_relays(client: &Client, public_key: PublicKey) -> Result<bool, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::RelayList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Count the number of nip65 relays event in the database
|
||||
let total = client.database().count(filter).await.unwrap_or(0);
|
||||
|
||||
Ok(total > 0)
|
||||
}
|
||||
|
||||
/// Ensure the user has NIP-17 relays
|
||||
async fn ensure_nip17_relays(client: &Client, public_key: PublicKey) -> Result<bool, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Count the number of nip17 relays event in the database
|
||||
let total = client.database().count(filter).await.unwrap_or(0);
|
||||
|
||||
Ok(total > 0)
|
||||
}
|
||||
|
||||
/// Set the public key of the account
|
||||
pub fn set_account(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Update account's public key
|
||||
self.public_key = Some(public_key);
|
||||
|
||||
// Add background task
|
||||
self._tasks.push(
|
||||
// Verify user's nip65 and nip17 relays
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||
|
||||
// Fetch the NIP-65 relays event in the local database
|
||||
let ensure_nip65 = Self::ensure_nip65_relays(&client, public_key).await;
|
||||
|
||||
// Fetch the NIP-17 relays event in the local database
|
||||
let ensure_nip17 = Self::ensure_nip17_relays(&client, public_key).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.nip65_status.update(cx, |this, cx| {
|
||||
*this = match ensure_nip65 {
|
||||
Ok(true) => RelayStatus::Set,
|
||||
_ => RelayStatus::NotSet,
|
||||
};
|
||||
cx.notify();
|
||||
});
|
||||
this.nip17_status.update(cx, |this, cx| {
|
||||
*this = match ensure_nip17 {
|
||||
Ok(true) => RelayStatus::Set,
|
||||
_ => RelayStatus::NotSet,
|
||||
};
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.expect("Entity has been released")
|
||||
}),
|
||||
);
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Subscribes to the current account's metadata.
|
||||
pub fn subscribe(&self, cx: &mut Context<Self>) {
|
||||
let Some(profile) = self.profile.as_ref() else {
|
||||
return;
|
||||
};
|
||||
/// Check if the account entity has a public key
|
||||
pub fn has_account(&self) -> bool {
|
||||
self.public_key.is_some()
|
||||
}
|
||||
|
||||
let user = profile.public_key();
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let metadata = Filter::new()
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::ContactList,
|
||||
Kind::InboxRelays,
|
||||
Kind::MuteList,
|
||||
Kind::SimpleGroups,
|
||||
])
|
||||
.author(user)
|
||||
.limit(10);
|
||||
|
||||
let data = Filter::new()
|
||||
.author(user)
|
||||
.since(Timestamp::now())
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::ContactList,
|
||||
Kind::MuteList,
|
||||
Kind::SimpleGroups,
|
||||
Kind::InboxRelays,
|
||||
Kind::RelayList,
|
||||
]);
|
||||
|
||||
let msg = Filter::new().kind(Kind::GiftWrap).pubkey(user);
|
||||
let new_msg = Filter::new().kind(Kind::GiftWrap).pubkey(user).limit(0);
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
client.subscribe(metadata, Some(opts)).await?;
|
||||
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!("Error: {}", e);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
/// Get the public key of the account
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
// This method is only called when user is logged in, so unwrap safely
|
||||
self.public_key.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
11
crates/assets/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "assets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
rust-embed.workspace = true
|
||||
59
crates/assets/src/lib.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../assets"]
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
|
||||
Self::get(path)
|
||||
.map(|f| Some(f.data))
|
||||
.with_context(|| format!("loading asset at path {path:?}"))
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
Ok(Self::iter()
|
||||
.filter_map(|p| {
|
||||
if p.starts_with(path) {
|
||||
Some(p.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
|
||||
pub fn load_test_fonts(&self, cx: &App) {
|
||||
cx.text_system()
|
||||
.add_fonts(vec![self
|
||||
.load("fonts/plex-mono/ZedPlexMono-Regular.ttf")
|
||||
.unwrap()
|
||||
.unwrap()])
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
[package]
|
||||
name = "auto_update"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
tempfile = "3.19.1"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
[package]
|
||||
name = "auto_update"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
semver = "1.0.27"
|
||||
tempfile = "3.23.0"
|
||||
|
||||
@@ -1,350 +1,496 @@
|
||||
use std::{
|
||||
env::{self, consts::OS},
|
||||
ffi::OsString,
|
||||
path::PathBuf,
|
||||
};
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as _, Error};
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SemanticVersion, Task};
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use common::BOOTSTRAP_RELAYS;
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::{
|
||||
fs::{self, File},
|
||||
io::AsyncWriteExt,
|
||||
process::Command,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
use semver::Version;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs::File;
|
||||
use smol::process::Command;
|
||||
use state::NostrRegistry;
|
||||
|
||||
struct GlobalAutoUpdate(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdate {}
|
||||
const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q";
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
let env = env!("CARGO_PKG_VERSION");
|
||||
let current_version: SemanticVersion = env.parse().expect("Invalid version in Cargo.toml");
|
||||
|
||||
AutoUpdater::set_global(
|
||||
cx.new(|_| AutoUpdater {
|
||||
current_version,
|
||||
status: AutoUpdateStatus::Idle,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
|
||||
}
|
||||
|
||||
struct MacOsUnmounter {
|
||||
mount_path: PathBuf,
|
||||
}
|
||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
|
||||
impl Drop for MacOsUnmounter {
|
||||
fn drop(&mut self) {
|
||||
let unmount_output = std::process::Command::new("hdiutil")
|
||||
.args(["detach", "-force"])
|
||||
.arg(&self.mount_path)
|
||||
.output();
|
||||
impl Global for GlobalAutoUpdater {}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[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,
|
||||
Downloading,
|
||||
Checked { files: Vec<EventId> },
|
||||
Installing,
|
||||
Updated { binary_path: PathBuf },
|
||||
Errored,
|
||||
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 { .. })
|
||||
matches!(self, Self::Updated)
|
||||
}
|
||||
|
||||
pub fn checked(files: Vec<EventId>) -> Self {
|
||||
Self::Checked { files }
|
||||
}
|
||||
|
||||
pub fn error(e: String) -> Self {
|
||||
Self::Errored { msg: Box::new(e) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AutoUpdater {
|
||||
status: AutoUpdateStatus,
|
||||
current_version: SemanticVersion,
|
||||
/// 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::<GlobalAutoUpdate>().0.clone()
|
||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||
}
|
||||
|
||||
pub fn set_global(auto_updater: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAutoUpdate(auto_updater));
|
||||
/// Set the global auto updater instance
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAutoUpdater(state));
|
||||
}
|
||||
|
||||
pub fn current_version(&self) -> SemanticVersion {
|
||||
self.current_version
|
||||
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(
|
||||
// Subscribe to get the new update event in the bootstrap relays
|
||||
Self::subscribe_to_updates(cx),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Subscribe to get the new update event in the bootstrap relays
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Check for updates after 2 minutes
|
||||
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);
|
||||
});
|
||||
|
||||
match Self::check_for_updates(async_version, cx).await {
|
||||
Ok(ids) => {
|
||||
// Update the status to downloading
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::checked(ids), cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||
});
|
||||
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the status
|
||||
cx.observe_self(|this, cx| {
|
||||
if let AutoUpdateStatus::Checked { files } = this.status.clone() {
|
||||
this.get_latest_release(&files, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
status: AutoUpdateStatus::Idle,
|
||||
version,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> AutoUpdateStatus {
|
||||
self.status.clone()
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn update(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
self.set_status(AutoUpdateStatus::Checking, cx);
|
||||
fn subscribe_to_updates(cx: &App) -> Task<()> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Extract the version from the identifier tag
|
||||
let ident = match event.tags.identifier() {
|
||||
Some(i) => match i.split('@').next_back() {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
},
|
||||
None => return,
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ReleaseArtifactSet)
|
||||
.author(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe to updates: {e}");
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
fn check_for_updates(version: Version, cx: &AsyncApp) -> Task<Result<Vec<EventId>, Error>> {
|
||||
let Ok(client) = cx.update(|cx| {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
nostr.read(cx).client()
|
||||
}) else {
|
||||
return Task::ready(Err(anyhow!("Entity has been released")));
|
||||
};
|
||||
|
||||
// Convert the version string to a SemanticVersion
|
||||
let new_version: SemanticVersion = ident.parse().expect("Invalid version");
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||
|
||||
// Check if the new version is the same as the current version
|
||||
if self.current_version == new_version {
|
||||
self.set_status(AutoUpdateStatus::Idle, cx);
|
||||
return;
|
||||
};
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ReleaseArtifactSet)
|
||||
.author(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
// Download the new version
|
||||
self.set_status(AutoUpdateStatus::Downloading, cx);
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let new_version: Version = event
|
||||
.tags
|
||||
.find(TagKind::d())
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| content.split("@").last())
|
||||
.and_then(|content| Version::parse(content).ok())
|
||||
.context("Failed to parse version")?;
|
||||
|
||||
let task: Task<Result<(TempDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let ids = event.tags.event_ids().copied();
|
||||
let filter = Filter::new().ids(ids).kind(Kind::FileMetadata);
|
||||
if new_version > version {
|
||||
// Get all file metadata event ids
|
||||
let ids: Vec<EventId> = event.tags.event_ids().copied().collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::FileMetadata)
|
||||
.author(app_pubkey)
|
||||
.ids(ids.clone());
|
||||
|
||||
// Get all files for this release
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(ids)
|
||||
} else {
|
||||
Err(anyhow!("No update available"))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("No update available"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let http_client = cx.http_client();
|
||||
let ids = ids.to_vec();
|
||||
|
||||
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||
let os = std::env::consts::OS;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::FileMetadata)
|
||||
.author(app_pubkey)
|
||||
.ids(ids);
|
||||
|
||||
// Get all urls for this release
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
if let Some(event) = events.into_iter().find(|event| event.content == OS) {
|
||||
let tag = event.tags.find(TagKind::Url).context("url not found")?;
|
||||
let url = Url::parse(tag.content().context("invalid")?)?;
|
||||
|
||||
let temp_dir = tempfile::Builder::new().prefix("coop-update").tempdir()?;
|
||||
let filename = match OS {
|
||||
"macos" => Ok("Coop.dmg"),
|
||||
"linux" => Ok("Coop.tar.gz"),
|
||||
"windows" => Ok("CoopUpdateInstaller.exe"),
|
||||
_ => Err(anyhow!("not supported: {:?}", OS)),
|
||||
}?;
|
||||
|
||||
let downloaded_asset = temp_dir.path().join(filename);
|
||||
let mut target_file = File::create(&downloaded_asset).await?;
|
||||
|
||||
let response = reqwest::get(url).await?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = item?;
|
||||
target_file.write_all(&chunk).await?;
|
||||
for event in events.into_iter() {
|
||||
// Only process events that match current platform
|
||||
if event.content != os {
|
||||
continue;
|
||||
}
|
||||
|
||||
log::info!("downloaded update. path:{:?}", downloaded_asset);
|
||||
// Parse the url
|
||||
let url = event
|
||||
.tags
|
||||
.find(TagKind::Url)
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| Url::parse(content).ok())
|
||||
.context("Failed to parse url")?;
|
||||
|
||||
Ok((temp_dir, downloaded_asset))
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
let installer_dir = InstallerDir::new().await?;
|
||||
let target_path = Self::target_path(&installer_dir).await?;
|
||||
|
||||
// Download the release
|
||||
download(url.as_str(), &target_path, http_client).await?;
|
||||
|
||||
return Ok((installer_dir, target_path));
|
||||
}
|
||||
|
||||
Err(anyhow!("Failed to get latest release"))
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok((temp_dir, downloaded_asset)) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Installing, cx);
|
||||
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 OS {
|
||||
"macos" => this.install_release_macos(temp_dir, downloaded_asset, cx),
|
||||
"linux" => this.install_release_linux(temp_dir, downloaded_asset, cx),
|
||||
"windows" => this.install_release_windows(downloaded_asset, 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);
|
||||
});
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn install_release_macos(&mut self, temp_dir: TempDir, asset: PathBuf, cx: &mut Context<Self>) {
|
||||
let running_app_path = cx.app_path().unwrap();
|
||||
let running_app_filename = running_app_path.file_name().unwrap();
|
||||
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
|
||||
let filename = match std::env::consts::OS {
|
||||
"macos" => anyhow::Ok("Coop.dmg"),
|
||||
"windows" => Ok("Coop.exe"),
|
||||
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
|
||||
}?;
|
||||
|
||||
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 task: Task<Result<PathBuf, Error>> = cx.background_spawn(async move {
|
||||
let output = Command::new("hdiutil")
|
||||
.args(["attach", "-nobrowse"])
|
||||
.arg(&asset)
|
||||
.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(),
|
||||
};
|
||||
|
||||
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(running_app_path)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(binary_path) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = AutoUpdateStatus::Updated { binary_path };
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
Ok(installer_dir.path().join(filename))
|
||||
}
|
||||
|
||||
fn install_release_linux(&mut self, temp_dir: TempDir, asset: PathBuf, cx: &mut Context<Self>) {
|
||||
let home_dir = PathBuf::from(env::var("HOME").unwrap());
|
||||
let running_app_path = cx.app_path().unwrap();
|
||||
let extracted = temp_dir.path().join("coop");
|
||||
|
||||
let task: Task<Result<PathBuf, Error>> = cx.background_spawn(async move {
|
||||
fs::create_dir_all(&extracted).await?;
|
||||
|
||||
let output = Command::new("tar")
|
||||
.arg("-xzf")
|
||||
.arg(&asset)
|
||||
.arg("-C")
|
||||
.arg(&extracted)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to extract {:?} to {:?}: {:?}",
|
||||
asset,
|
||||
extracted,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let app_folder_name: String = "coop.app".into();
|
||||
let from = extracted.join(&app_folder_name);
|
||||
let mut to = home_dir.join(".local");
|
||||
|
||||
let expected_suffix = format!("{}/libexec/coop", app_folder_name);
|
||||
|
||||
if let Some(prefix) = running_app_path
|
||||
.to_str()
|
||||
.and_then(|str| str.strip_suffix(&expected_suffix))
|
||||
{
|
||||
to = PathBuf::from(prefix);
|
||||
}
|
||||
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&from)
|
||||
.arg(&to)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy Coop update from {:?} to {:?}: {:?}",
|
||||
from,
|
||||
to,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(to.join(expected_suffix))
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(binary_path) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = AutoUpdateStatus::Updated { binary_path };
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn install_release_windows(&mut self, asset: PathBuf, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<PathBuf, Error>> = cx.background_spawn(async move {
|
||||
let output = Command::new(asset)
|
||||
.arg("/verysilent")
|
||||
.arg("/update=true")
|
||||
.arg("!desktopicon")
|
||||
.arg("!quicklaunchicon")
|
||||
.output()
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to start installer: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(std::env::current_exe()?)
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(binary_path) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = AutoUpdateStatus::Updated { binary_path };
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
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,
|
||||
"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_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(())
|
||||
}
|
||||
|
||||
28
crates/chat/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "chat"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
account = { path = "../account" }
|
||||
encryption = { path = "../encryption" }
|
||||
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"
|
||||
736
crates/chat/src/lib.rs
Normal file
@@ -0,0 +1,736 @@
|
||||
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 account::Account;
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use common::{EventUtils, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT};
|
||||
use encryption::Encryption;
|
||||
use flume::Sender;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
pub use message::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
pub use room::*;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{initialized_at, NostrRegistry, GIFTWRAP_SUBSCRIPTION};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
|
||||
impl Global for GlobalChatRegistry {}
|
||||
|
||||
/// Chat Registry
|
||||
#[derive(Debug)]
|
||||
pub struct ChatRegistry {
|
||||
/// Collection of all chat rooms
|
||||
pub rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// Loading status of the registry
|
||||
pub loading: bool,
|
||||
|
||||
/// Async task for handling notifications
|
||||
handle_notifications: Task<()>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
_tasks: SmallVec<[Task<()>; 4]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ChatEvent {
|
||||
OpenRoom(u64),
|
||||
CloseRoom(u64),
|
||||
NewChatRequest(RoomKind),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Signal {
|
||||
Loading(bool),
|
||||
Message(NewMessage),
|
||||
Eose,
|
||||
}
|
||||
|
||||
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(cx: &mut Context<Self>) -> Self {
|
||||
let encryption = Encryption::global(cx);
|
||||
let encryption_key = encryption.read(cx).encryption.clone();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let status = Arc::new(AtomicBool::new(true));
|
||||
let (tx, rx) = flume::bounded::<Signal>(2048);
|
||||
|
||||
let handle_notifications = cx.background_spawn({
|
||||
let client = nostr.read(cx).client();
|
||||
let status = Arc::clone(&status);
|
||||
let tx = tx.clone();
|
||||
let signer: Option<Arc<dyn NostrSigner>> = None;
|
||||
|
||||
async move { Self::handle_notifications(&client, &signer, &tx, &status).await }
|
||||
});
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the encryption global state
|
||||
cx.observe(&encryption_key, {
|
||||
let status = Arc::clone(&status);
|
||||
let tx = tx.clone();
|
||||
|
||||
move |this, state, cx| {
|
||||
if let Some(signer) = state.read(cx).clone() {
|
||||
this.handle_notifications = cx.background_spawn({
|
||||
let client = nostr.read(cx).client();
|
||||
let status = Arc::clone(&status);
|
||||
let tx = tx.clone();
|
||||
let signer = Some(signer);
|
||||
|
||||
async move {
|
||||
Self::handle_notifications(&client, &signer, &tx, &status).await
|
||||
}
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Handle unwrapping status
|
||||
cx.background_spawn(
|
||||
async move { Self::handle_unwrapping(&client, &status, &tx).await },
|
||||
),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Handle new messages
|
||||
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);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}
|
||||
Signal::Eose => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}
|
||||
Signal::Loading(status) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(status, cx);
|
||||
this.get_rooms(cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
rooms: vec![],
|
||||
loading: true,
|
||||
handle_notifications,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_notifications<T>(
|
||||
client: &Client,
|
||||
signer: &Option<T>,
|
||||
tx: &Sender<Signal>,
|
||||
status: &Arc<AtomicBool>,
|
||||
) where
|
||||
T: NostrSigner,
|
||||
{
|
||||
let initialized_at = initialized_at();
|
||||
let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
|
||||
|
||||
let mut notifications = client.notifications();
|
||||
let mut public_keys = HashSet::new();
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
let RelayPoolNotification::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;
|
||||
}
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match Self::extract_rumor(client, signer, event.as_ref()).await {
|
||||
Ok(rumor) => {
|
||||
// Get all public keys
|
||||
public_keys.extend(rumor.all_pubkeys());
|
||||
|
||||
let limit_reached = public_keys.len() >= METADATA_BATCH_LIMIT;
|
||||
let done = !status.load(Ordering::Acquire) && !public_keys.is_empty();
|
||||
|
||||
// Get metadata for all public keys if the limit is reached
|
||||
if limit_reached || done {
|
||||
let public_keys = std::mem::take(&mut public_keys);
|
||||
// Get metadata for the public keys
|
||||
Self::get_metadata(client, public_keys).await.ok();
|
||||
}
|
||||
|
||||
match &rumor.created_at >= initialized_at {
|
||||
true => {
|
||||
let new_message = NewMessage::new(event.id, rumor);
|
||||
let signal = Signal::Message(new_message);
|
||||
|
||||
if let Err(e) = tx.send_async(signal).await {
|
||||
log::error!("Failed to send signal: {}", e);
|
||||
}
|
||||
}
|
||||
false => {
|
||||
status.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to unwrap gift wrap event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id) => {
|
||||
if id.as_ref() == &subscription_id {
|
||||
if let Err(e) = tx.send_async(Signal::Eose).await {
|
||||
log::error!("Failed to send signal: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_unwrapping(client: &Client, status: &Arc<AtomicBool>, tx: &Sender<Signal>) {
|
||||
let loop_duration = Duration::from_secs(20);
|
||||
let mut is_start_processing = false;
|
||||
let mut total_loops = 0;
|
||||
|
||||
loop {
|
||||
if client.has_signer().await {
|
||||
total_loops += 1;
|
||||
|
||||
if status.load(Ordering::Acquire) {
|
||||
is_start_processing = true;
|
||||
|
||||
// Reset gift wrap processing flag
|
||||
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
|
||||
|
||||
// Send loading signal
|
||||
if let Err(e) = tx.send_async(Signal::Loading(true)).await {
|
||||
log::error!("Failed to send signal: {}", e);
|
||||
}
|
||||
} else {
|
||||
// Only run further if we are already processing
|
||||
// Wait until after 2 loops to prevent exiting early while events are still being processed
|
||||
if is_start_processing && total_loops >= 2 {
|
||||
// Send loading signal
|
||||
if let Err(e) = tx.send_async(Signal::Loading(false)).await {
|
||||
log::error!("Failed to send signal: {}", e);
|
||||
}
|
||||
// Reset the counter
|
||||
is_start_processing = false;
|
||||
total_loops = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
smol::Timer::after(loop_duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the loading status of the chat registry
|
||||
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
|
||||
self.loading = loading;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get a room by its ID.
|
||||
pub fn room(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.find(|model| model.read(cx).id == *id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get all ongoing rooms.
|
||||
pub fn ongoing_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).kind == RoomKind::Ongoing)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all request rooms.
|
||||
pub fn request_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).kind != RoomKind::Ongoing)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add a new room to the start of list.
|
||||
pub fn add_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) {
|
||||
self.rooms.insert(0, room);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// Search rooms by their name.
|
||||
pub fn search(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
||||
let matcher = SkimMatcherV2::default();
|
||||
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| {
|
||||
matcher
|
||||
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
|
||||
.is_some()
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Search rooms by public keys.
|
||||
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).members.contains(&public_key))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reset the registry.
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.rooms.clear();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Push a new room to the chat registry
|
||||
pub fn push_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) {
|
||||
let id = room.read(cx).id;
|
||||
|
||||
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||
self.add_room(room, cx);
|
||||
}
|
||||
|
||||
cx.emit(ChatEvent::OpenRoom(id));
|
||||
}
|
||||
|
||||
/// 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.create_get_rooms_task(cx);
|
||||
|
||||
self._tasks.push(
|
||||
// Run and finished in the background
|
||||
cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(rooms) => {
|
||||
this.update(cx, move |this, cx| {
|
||||
this.extend_rooms(rooms, cx);
|
||||
this.sort(cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load rooms: {e}")
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a task to load rooms from the database
|
||||
fn create_get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get the contact bypass setting
|
||||
let bypass_setting = AppSettings::get_contact_bypass(cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||
|
||||
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?;
|
||||
|
||||
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);
|
||||
|
||||
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));
|
||||
|
||||
let Some(latest) = messages.first() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut room = Room::from(latest);
|
||||
|
||||
if rooms.iter().any(|r| r.id == room.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut public_keys = room.members();
|
||||
public_keys.retain(|pk| pk != &public_key);
|
||||
|
||||
// Check if the user has responded to the room
|
||||
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
||||
|
||||
// Determine if the room is ongoing or not
|
||||
let mut bypassed = false;
|
||||
|
||||
// Check if public keys are from the user's contacts
|
||||
if bypass_setting {
|
||||
bypassed = public_keys.iter().any(|k| contacts.contains(k));
|
||||
}
|
||||
|
||||
// Set the room's kind based on status
|
||||
if user_sent || bypassed {
|
||||
room = room.kind(RoomKind::Ongoing);
|
||||
}
|
||||
|
||||
rooms.insert(room);
|
||||
}
|
||||
|
||||
Ok(rooms)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Nostr event into a Coop 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>) {
|
||||
let id = message.rumor.uniq_id();
|
||||
let author = message.rumor.pubkey;
|
||||
let account = Account::global(cx);
|
||||
|
||||
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
let is_new_event = message.rumor.created_at > room.read(cx).created_at;
|
||||
let created_at = message.rumor.created_at;
|
||||
let event_for_emit = message.rumor.clone();
|
||||
|
||||
// Update room
|
||||
room.update(cx, |this, cx| {
|
||||
if is_new_event {
|
||||
this.set_created_at(created_at, cx);
|
||||
}
|
||||
|
||||
// Set this room is ongoing if the new message is from current user
|
||||
if author == account.read(cx).public_key() {
|
||||
this.set_ongoing(cx);
|
||||
}
|
||||
|
||||
// Emit the new message to the room
|
||||
this.emit_message(message.gift_wrap, event_for_emit.clone(), cx);
|
||||
});
|
||||
|
||||
// Resort all rooms in the registry by their created at (after updated)
|
||||
if is_new_event {
|
||||
self.sort(cx);
|
||||
}
|
||||
} else {
|
||||
// Push the new room to the front of the list
|
||||
self.add_room(cx.new(|_| Room::from(&message.rumor)), cx);
|
||||
|
||||
// Notify the UI about the new room
|
||||
cx.emit(ChatEvent::NewChatRequest(RoomKind::default()));
|
||||
}
|
||||
}
|
||||
|
||||
// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor<T>(
|
||||
client: &Client,
|
||||
signer: &Option<T>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnsignedEvent, Error>
|
||||
where
|
||||
T: NostrSigner,
|
||||
{
|
||||
// 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, 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<T>(
|
||||
client: &Client,
|
||||
signer: &Option<T>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error>
|
||||
where
|
||||
T: NostrSigner,
|
||||
{
|
||||
if let Some(custom_signer) = signer.as_ref() {
|
||||
if let Ok(seal) = custom_signer
|
||||
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
.await
|
||||
{
|
||||
let seal: Event = Event::from_json(seal)?;
|
||||
seal.verify_with_ctx(SECP256K1)?;
|
||||
|
||||
// Decrypt the rumor
|
||||
// TODO: verify the sender
|
||||
let rumor = custom_signer
|
||||
.nip44_decrypt(&seal.pubkey, &seal.content)
|
||||
.await?;
|
||||
|
||||
// Construct the unsigned event
|
||||
let rumor = UnsignedEvent::from_json(rumor)?;
|
||||
|
||||
// Return the unwrapped gift
|
||||
return Ok(UnwrappedGift {
|
||||
sender: rumor.pubkey,
|
||||
rumor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let signer = client.signer().await?;
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
|
||||
|
||||
Ok(unwrapped)
|
||||
}
|
||||
|
||||
/// 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 metadata for a list of public keys
|
||||
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
|
||||
where
|
||||
I: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let kinds = vec![Kind::Metadata, Kind::ContactList];
|
||||
|
||||
// Return if the list is empty
|
||||
if authors.is_empty() {
|
||||
return Err(anyhow!("You need at least one public key".to_string(),));
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
.limit(authors.len() * kinds.len())
|
||||
.authors(authors)
|
||||
.kinds(kinds);
|
||||
|
||||
// Subscribe to filters to the bootstrap relays
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
184
crates/chat/src/message.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use std::hash::Hash;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
pub gift_wrap: EventId,
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
impl NewMessage {
|
||||
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
Self { gift_wrap, rumor }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
User(RenderedMessage),
|
||||
Warning(String, Timestamp),
|
||||
System(Timestamp),
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn user(user: impl Into<RenderedMessage>) -> Self {
|
||||
Self::User(user.into())
|
||||
}
|
||||
|
||||
pub fn warning(content: impl Into<String>) -> Self {
|
||||
Self::Warning(content.into(), Timestamp::now())
|
||||
}
|
||||
|
||||
pub fn system() -> Self {
|
||||
Self::System(Timestamp::default())
|
||||
}
|
||||
|
||||
fn timestamp(&self) -> &Timestamp {
|
||||
match self {
|
||||
Message::User(msg) => &msg.created_at,
|
||||
Message::Warning(_, ts) => ts,
|
||||
Message::System(ts) => ts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Message {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
match (self, other) {
|
||||
// System always comes first
|
||||
(Message::System(_), Message::System(_)) => self.timestamp().cmp(other.timestamp()),
|
||||
(Message::System(_), _) => std::cmp::Ordering::Less,
|
||||
(_, Message::System(_)) => std::cmp::Ordering::Greater,
|
||||
|
||||
// For non-system messages, compare by timestamp
|
||||
_ => self.timestamp().cmp(other.timestamp()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Message {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderedMessage {
|
||||
pub id: EventId,
|
||||
/// Author's public key
|
||||
pub author: PublicKey,
|
||||
/// The content/text of the message
|
||||
pub content: String,
|
||||
/// Message created time as unix timestamp
|
||||
pub created_at: Timestamp,
|
||||
/// List of mentioned public keys in the message
|
||||
pub mentions: Vec<PublicKey>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
}
|
||||
|
||||
impl From<Event> for RenderedMessage {
|
||||
fn from(inner: Event) -> Self {
|
||||
let mentions = extract_mentions(&inner.content);
|
||||
let replies_to = extract_reply_ids(&inner.tags);
|
||||
|
||||
Self {
|
||||
id: inner.id,
|
||||
author: inner.pubkey,
|
||||
content: inner.content,
|
||||
created_at: inner.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UnsignedEvent> for RenderedMessage {
|
||||
fn from(inner: UnsignedEvent) -> Self {
|
||||
let mentions = extract_mentions(&inner.content);
|
||||
let replies_to = extract_reply_ids(&inner.tags);
|
||||
|
||||
Self {
|
||||
// Event ID must be known
|
||||
id: inner.id.unwrap(),
|
||||
author: inner.pubkey,
|
||||
content: inner.content,
|
||||
created_at: inner.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Box<Event>> for RenderedMessage {
|
||||
fn from(inner: Box<Event>) -> Self {
|
||||
(*inner).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Box<Event>> for RenderedMessage {
|
||||
fn from(inner: &Box<Event>) -> Self {
|
||||
inner.to_owned().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for RenderedMessage {}
|
||||
|
||||
impl PartialEq for RenderedMessage {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for RenderedMessage {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.created_at.cmp(&other.created_at)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for RenderedMessage {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for RenderedMessage {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_mentions(content: &str) -> Vec<PublicKey> {
|
||||
let parser = NostrParser::new();
|
||||
let tokens = parser.parse(content);
|
||||
|
||||
tokens
|
||||
.filter_map(|token| match token {
|
||||
Token::Nostr(nip21) => match nip21 {
|
||||
Nip21::Pubkey(pubkey) => Some(pubkey),
|
||||
Nip21::Profile(profile) => Some(profile.public_key),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||
let mut replies_to = vec![];
|
||||
|
||||
for tag in inner.filter(TagKind::e()) {
|
||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||
replies_to.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for tag in inner.filter(TagKind::q()) {
|
||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||
replies_to.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
replies_to
|
||||
}
|
||||
712
crates/chat/src/room.rs
Normal file
@@ -0,0 +1,712 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
use account::Account;
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{EventUtils, RenderedProfile};
|
||||
use encryption::{Encryption, SignerKind};
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use state::NostrRegistry;
|
||||
|
||||
const SEND_RETRY: usize = 10;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SendOptions {
|
||||
pub backup: bool,
|
||||
pub signer_kind: SignerKind,
|
||||
}
|
||||
|
||||
impl SendOptions {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
backup: true,
|
||||
signer_kind: SignerKind::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backup(&self) -> bool {
|
||||
self.backup
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SendOptions {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SendReport {
|
||||
pub receiver: PublicKey,
|
||||
pub status: Option<Output<EventId>>,
|
||||
pub error: Option<SharedString>,
|
||||
pub on_hold: Option<Event>,
|
||||
pub encryption: bool,
|
||||
pub relays_not_found: bool,
|
||||
pub device_not_found: bool,
|
||||
}
|
||||
|
||||
impl SendReport {
|
||||
pub fn new(receiver: PublicKey) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
status: None,
|
||||
error: None,
|
||||
on_hold: None,
|
||||
encryption: false,
|
||||
relays_not_found: false,
|
||||
device_not_found: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(mut self, output: Output<EventId>) -> Self {
|
||||
self.status = Some(output);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
|
||||
self.error = Some(error.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_hold(mut self, event: Event) -> Self {
|
||||
self.on_hold = Some(event);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn encryption(mut self) -> Self {
|
||||
self.encryption = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn relays_not_found(mut self) -> Self {
|
||||
self.relays_not_found = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn device_not_found(mut self) -> Self {
|
||||
self.device_not_found = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_relay_error(&self) -> bool {
|
||||
self.error.is_some() || self.relays_not_found
|
||||
}
|
||||
|
||||
pub fn is_sent_success(&self) -> bool {
|
||||
if let Some(output) = self.status.as_ref() {
|
||||
!output.success.is_empty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RoomSignal {
|
||||
NewMessage((EventId, UnsignedEvent)),
|
||||
Refresh,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub enum RoomKind {
|
||||
Ongoing,
|
||||
#[default]
|
||||
Request,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Room {
|
||||
/// Conversation ID
|
||||
pub id: u64,
|
||||
/// The timestamp of the last message in the room
|
||||
pub created_at: Timestamp,
|
||||
/// Subject of the room
|
||||
pub subject: Option<SharedString>,
|
||||
/// All members of the room
|
||||
pub members: Vec<PublicKey>,
|
||||
/// Kind
|
||||
pub kind: RoomKind,
|
||||
}
|
||||
|
||||
impl Ord for Room {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.created_at.cmp(&other.created_at)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Room {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Room {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Room {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Room {}
|
||||
|
||||
impl EventEmitter<RoomSignal> for Room {}
|
||||
|
||||
impl From<&UnsignedEvent> for Room {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let id = val.uniq_id();
|
||||
let created_at = val.created_at;
|
||||
|
||||
// Get the members from the event's tags and event's pubkey
|
||||
let members = val.all_pubkeys();
|
||||
|
||||
// Get subject from tags
|
||||
let subject = val
|
||||
.tags
|
||||
.find(TagKind::Subject)
|
||||
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
|
||||
|
||||
Room {
|
||||
id,
|
||||
created_at,
|
||||
subject,
|
||||
members,
|
||||
kind: RoomKind::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Room {
|
||||
/// Constructs a new room with the given receiver and tags.
|
||||
pub fn new(subject: Option<String>, author: PublicKey, receivers: Vec<PublicKey>) -> Self {
|
||||
// Convert receiver's public keys into tags
|
||||
let mut tags: Tags = Tags::from_list(
|
||||
receivers
|
||||
.iter()
|
||||
.map(|pubkey| Tag::public_key(pubkey.to_owned()))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
// Add subject if it is present
|
||||
if let Some(subject) = subject {
|
||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||
subject,
|
||||
)));
|
||||
}
|
||||
|
||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
||||
.tags(tags)
|
||||
.build(author);
|
||||
|
||||
// Generate event ID
|
||||
event.ensure_id();
|
||||
|
||||
Room::from(&event)
|
||||
}
|
||||
|
||||
/// Sets the kind of the room and returns the modified room
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets this room is ongoing conversation
|
||||
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
if self.kind != RoomKind::Ongoing {
|
||||
self.kind = RoomKind::Ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the creation timestamp of the room
|
||||
pub fn set_created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
|
||||
self.created_at = created_at.into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the subject of the room
|
||||
pub fn set_subject<T>(&mut self, subject: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.subject = Some(subject.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Returns the members of the room
|
||||
pub fn members(&self) -> Vec<PublicKey> {
|
||||
self.members.clone()
|
||||
}
|
||||
|
||||
/// Checks if the room has more than two members (group)
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.members.len() > 2
|
||||
}
|
||||
|
||||
/// Gets the display name for the room
|
||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||
if let Some(value) = self.subject.clone() {
|
||||
value
|
||||
} else {
|
||||
self.merged_name(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display image for the room
|
||||
pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString {
|
||||
if !self.is_group() {
|
||||
self.display_member(cx).avatar(proxy)
|
||||
} else {
|
||||
SharedString::from("brand/group.png")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a member to represent the room
|
||||
///
|
||||
/// Display member is always different from the current user.
|
||||
pub fn display_member(&self, cx: &App) -> Profile {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let account = Account::global(cx);
|
||||
let public_key = account.read(cx).public_key();
|
||||
|
||||
let target_member = self
|
||||
.members
|
||||
.iter()
|
||||
.find(|&member| member != &public_key)
|
||||
.or_else(|| self.members.first())
|
||||
.expect("Room should have at least one member");
|
||||
|
||||
persons.read(cx).get_person(target_member, cx)
|
||||
}
|
||||
|
||||
/// Merge the names of the first two members of the room.
|
||||
fn merged_name(&self, cx: &App) -> SharedString {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
if self.is_group() {
|
||||
let profiles: Vec<Profile> = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|public_key| persons.read(cx).get_person(public_key, cx))
|
||||
.collect();
|
||||
|
||||
let mut name = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|p| p.name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
if profiles.len() > 2 {
|
||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||
}
|
||||
|
||||
SharedString::from(name)
|
||||
} else {
|
||||
self.display_member(cx).display_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a new message signal to the current room
|
||||
pub fn emit_message(&self, id: EventId, event: UnsignedEvent, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomSignal::NewMessage((id, event)));
|
||||
}
|
||||
|
||||
/// Emits a signal to refresh the current room's messages.
|
||||
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomSignal::Refresh);
|
||||
}
|
||||
|
||||
/// Get gossip relays for each member
|
||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let members = self.members();
|
||||
|
||||
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);
|
||||
|
||||
for member in members.into_iter() {
|
||||
if member == public_key {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Construct a filter for gossip relays
|
||||
let filter = Filter::new().kind(Kind::RelayList).author(member).limit(1);
|
||||
|
||||
// Subscribe to get member's gossip relays
|
||||
client.subscribe(filter, Some(opts)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all messages belonging to the room
|
||||
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let conversation_id = self.id.to_string();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
|
||||
|
||||
let messages = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
|
||||
.sorted_by_key(|message| message.created_at)
|
||||
.collect();
|
||||
|
||||
Ok(messages)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new message event (unsigned)
|
||||
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let gossip = nostr.read(cx).gossip();
|
||||
let read_gossip = gossip.read_blocking();
|
||||
|
||||
// Get current user
|
||||
let account = Account::global(cx);
|
||||
let public_key = account.read(cx).public_key();
|
||||
|
||||
// Get room's subject
|
||||
let subject = self.subject.clone();
|
||||
|
||||
let mut tags = vec![];
|
||||
|
||||
// Add receivers
|
||||
//
|
||||
// NOTE: current user will be removed from the list of receivers
|
||||
for member in self.members.iter() {
|
||||
// Get relay hint if available
|
||||
let relay_url = read_gossip.messaging_relays(member).first().cloned();
|
||||
|
||||
// Construct a public key tag with relay hint
|
||||
let tag = TagStandard::PublicKey {
|
||||
public_key: member.to_owned(),
|
||||
relay_url,
|
||||
alias: None,
|
||||
uppercase: false,
|
||||
};
|
||||
|
||||
tags.push(Tag::from_standardized_without_cell(tag));
|
||||
}
|
||||
|
||||
// Add subject tag if it's present
|
||||
if let Some(value) = subject {
|
||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||
value.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// Add reply/quote tag
|
||||
if replies.len() == 1 {
|
||||
tags.push(Tag::event(replies[0]))
|
||||
} else {
|
||||
for id in replies {
|
||||
let tag = TagStandard::Quote {
|
||||
event_id: id.to_owned(),
|
||||
relay_url: None,
|
||||
public_key: None,
|
||||
};
|
||||
tags.push(Tag::from_standardized_without_cell(tag))
|
||||
}
|
||||
}
|
||||
|
||||
// Construct a direct message event
|
||||
//
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
||||
.tags(tags)
|
||||
.build(public_key);
|
||||
|
||||
// Ensure the event id has been generated
|
||||
event.ensure_id();
|
||||
|
||||
event
|
||||
}
|
||||
|
||||
/// Create a task to send a message to all room members
|
||||
pub fn send_message(
|
||||
&self,
|
||||
rumor: &UnsignedEvent,
|
||||
opts: &SendOptions,
|
||||
cx: &App,
|
||||
) -> Task<Result<Vec<SendReport>, Error>> {
|
||||
let encryption = Encryption::global(cx);
|
||||
let encryption_key = encryption.read(cx).encryption_key(cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let gossip = nostr.read(cx).gossip();
|
||||
let tracker = nostr.read(cx).tracker();
|
||||
|
||||
let rumor = rumor.to_owned();
|
||||
let opts = opts.to_owned();
|
||||
|
||||
// Get all members
|
||||
let mut members = self.members();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer_kind = opts.signer_kind;
|
||||
let gossip = gossip.read().await;
|
||||
|
||||
// Get current user's signer and public key
|
||||
let user_signer = client.signer().await?;
|
||||
let user_pubkey = user_signer.get_public_key().await?;
|
||||
|
||||
// Get the encryption public key
|
||||
let encryption_pubkey = if let Some(signer) = encryption_key.as_ref() {
|
||||
signer.get_public_key().await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Remove the current user's public key from the list of receivers
|
||||
// the current user will be handled separately
|
||||
members.retain(|&pk| pk != user_pubkey);
|
||||
|
||||
// Determine the signer will be used based on the provided options
|
||||
let signer = Self::select_signer(&opts.signer_kind, user_signer, encryption_key)?;
|
||||
|
||||
// Collect the send reports
|
||||
let mut reports: Vec<SendReport> = vec![];
|
||||
|
||||
for member in members.into_iter() {
|
||||
// Get user's messaging relays
|
||||
let urls = gossip.messaging_relays(&member);
|
||||
// Get user's encryption public key if available
|
||||
let encryption = gossip.announcement(&member).map(|a| a.public_key());
|
||||
|
||||
// Check if there are any relays to send the message to
|
||||
if urls.is_empty() {
|
||||
reports.push(SendReport::new(member).relays_not_found());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip sending if using encryption signer but receiver's encryption keys not found
|
||||
if encryption.is_none() && matches!(signer_kind, SignerKind::Encryption) {
|
||||
reports.push(SendReport::new(member).device_not_found());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure connections to the relays
|
||||
gossip.ensure_connections(&client, &urls).await;
|
||||
|
||||
// Determine the receiver based on the signer kind
|
||||
let receiver = Self::select_receiver(&signer_kind, member, encryption)?;
|
||||
|
||||
// Construct the gift wrap event
|
||||
let event = EventBuilder::gift_wrap(
|
||||
&signer,
|
||||
&receiver,
|
||||
rumor.clone(),
|
||||
vec![Tag::public_key(member)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Send the gift wrap event to the messaging relays
|
||||
match client.send_event_to(urls, &event).await {
|
||||
Ok(output) => {
|
||||
let id = output.id().to_owned();
|
||||
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
|
||||
let report = SendReport::new(receiver).status(output);
|
||||
|
||||
if auth {
|
||||
// Wait for authenticated and resent event successfully
|
||||
for attempt in 0..=SEND_RETRY {
|
||||
let tracker = tracker.read().await;
|
||||
let ids = tracker.resent_ids();
|
||||
|
||||
// Check if event was successfully resent
|
||||
if let Some(output) = ids.iter().find(|e| e.id() == &id).cloned() {
|
||||
let output = SendReport::new(receiver).status(output);
|
||||
reports.push(output);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if retry limit exceeded
|
||||
if attempt == SEND_RETRY {
|
||||
reports.push(report);
|
||||
break;
|
||||
}
|
||||
|
||||
smol::Timer::after(Duration::from_millis(1200)).await;
|
||||
}
|
||||
} else {
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
reports.push(SendReport::new(receiver).error(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return early if the user disabled backup.
|
||||
//
|
||||
// Coop will not send a gift wrap event to the current user.
|
||||
if !opts.backup() {
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
// Skip sending if using encryption signer but receiver's encryption keys not found
|
||||
if encryption_pubkey.is_none() && matches!(signer_kind, SignerKind::Encryption) {
|
||||
reports.push(SendReport::new(user_pubkey).device_not_found());
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
// Determine the receiver based on the signer kind
|
||||
let receiver = Self::select_receiver(&signer_kind, user_pubkey, encryption_pubkey)?;
|
||||
|
||||
// Construct the gift-wrapped event
|
||||
let event = EventBuilder::gift_wrap(
|
||||
&signer,
|
||||
&receiver,
|
||||
rumor.clone(),
|
||||
vec![Tag::public_key(user_pubkey)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Only send a backup message to current user if sent successfully to others
|
||||
if reports.iter().all(|r| r.is_sent_success()) {
|
||||
let urls = gossip.messaging_relays(&user_pubkey);
|
||||
|
||||
// Check if there are any relays to send the event to
|
||||
if urls.is_empty() {
|
||||
reports.push(SendReport::new(user_pubkey).relays_not_found());
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
// Ensure connections to the relays
|
||||
gossip.ensure_connections(&client, &urls).await;
|
||||
|
||||
// Send the event to the messaging relays
|
||||
match client.send_event_to(urls, &event).await {
|
||||
Ok(output) => {
|
||||
reports.push(SendReport::new(user_pubkey).status(output));
|
||||
}
|
||||
Err(e) => {
|
||||
reports.push(SendReport::new(user_pubkey).error(e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reports.push(SendReport::new(user_pubkey).on_hold(event));
|
||||
}
|
||||
|
||||
Ok(reports)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a task to resend a failed message
|
||||
pub fn resend_message(
|
||||
&self,
|
||||
reports: Vec<SendReport>,
|
||||
cx: &App,
|
||||
) -> Task<Result<Vec<SendReport>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let gossip = nostr.read(cx).gossip();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let gossip = gossip.read().await;
|
||||
let mut resend_reports = vec![];
|
||||
|
||||
for report in reports.into_iter() {
|
||||
let receiver = report.receiver;
|
||||
|
||||
// Process failed events
|
||||
if let Some(output) = report.status {
|
||||
let id = output.id();
|
||||
let urls: Vec<&RelayUrl> = output.failed.keys().collect();
|
||||
|
||||
if let Some(event) = client.database().event_by_id(id).await? {
|
||||
for url in urls.into_iter() {
|
||||
let relay = client.pool().relay(url).await?;
|
||||
let id = relay.send_event(&event).await?;
|
||||
|
||||
let resent: Output<EventId> = Output {
|
||||
val: id,
|
||||
success: HashSet::from([url.to_owned()]),
|
||||
failed: HashMap::new(),
|
||||
};
|
||||
|
||||
resend_reports.push(SendReport::new(receiver).status(resent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the on hold event if it exists
|
||||
if let Some(event) = report.on_hold {
|
||||
let urls = gossip.messaging_relays(&receiver);
|
||||
|
||||
// Check if there are any relays to send the event to
|
||||
if urls.is_empty() {
|
||||
resend_reports.push(SendReport::new(receiver).relays_not_found());
|
||||
} else {
|
||||
// Ensure connections to the relays
|
||||
gossip.ensure_connections(&client, &urls).await;
|
||||
|
||||
// Send the event to the messaging relays
|
||||
match client.send_event_to(urls, &event).await {
|
||||
Ok(output) => {
|
||||
resend_reports.push(SendReport::new(receiver).status(output));
|
||||
}
|
||||
Err(e) => {
|
||||
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resend_reports)
|
||||
})
|
||||
}
|
||||
|
||||
fn select_signer<T>(kind: &SignerKind, user: T, encryption: Option<T>) -> Result<T, Error>
|
||||
where
|
||||
T: NostrSigner,
|
||||
{
|
||||
match kind {
|
||||
SignerKind::Encryption => {
|
||||
Ok(encryption.ok_or_else(|| anyhow!("No encryption key found"))?)
|
||||
}
|
||||
SignerKind::User => Ok(user),
|
||||
SignerKind::Auto => Ok(encryption.unwrap_or(user)),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_receiver(
|
||||
kind: &SignerKind,
|
||||
member: PublicKey,
|
||||
encryption: Option<PublicKey>,
|
||||
) -> Result<PublicKey, Error> {
|
||||
match kind {
|
||||
SignerKind::Encryption => {
|
||||
Ok(encryption.ok_or_else(|| anyhow!("Receiver's encryption key not found"))?)
|
||||
}
|
||||
SignerKind::User => Ok(member),
|
||||
SignerKind::Auto => Ok(encryption.unwrap_or(member)),
|
||||
}
|
||||
}
|
||||
}
|
||||
33
crates/chat_ui/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "chat_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
state = { path = "../state" }
|
||||
ui = { path = "../ui" }
|
||||
theme = { path = "../theme" }
|
||||
common = { path = "../common" }
|
||||
account = { path = "../account" }
|
||||
encryption = { path = "../encryption" }
|
||||
person = { path = "../person" }
|
||||
chat = { path = "../chat" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
indexset = "0.12.3"
|
||||
emojis = "0.6.4"
|
||||
once_cell = "1.19.0"
|
||||
regex = "1"
|
||||
22
crates/chat_ui/src/actions.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use encryption::SignerKind;
|
||||
use gpui::Action;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub struct SeenOn(pub EventId);
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub struct SetSigner(pub SignerKind);
|
||||
|
||||
/// Define a open public key action
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
||||
#[action(namespace = pubkey, no_json)]
|
||||
pub struct OpenPublicKey(pub PublicKey);
|
||||
|
||||
/// Define a copy inline public key action
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
||||
#[action(namespace = pubkey, no_json)]
|
||||
pub struct CopyPublicKey(pub PublicKey);
|
||||
@@ -1,35 +1,20 @@
|
||||
use std::rc::Rc;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Corner, Element,
|
||||
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, Styled, WeakEntity, Window,
|
||||
div, px, App, AppContext, Corner, Element, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::InputState;
|
||||
use ui::popover::{Popover, PopoverContent};
|
||||
use ui::{Icon, Sizable, Size};
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
popover::{Popover, PopoverContent},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Icon,
|
||||
};
|
||||
static EMOJIS: OnceLock<Vec<SharedString>> = OnceLock::new();
|
||||
|
||||
#[derive(PartialEq, Clone, Debug, Deserialize)]
|
||||
pub struct EmitEmoji(pub SharedString);
|
||||
|
||||
impl_internal_actions!(emoji, [EmitEmoji]);
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct EmojiPicker {
|
||||
icon: Option<Icon>,
|
||||
anchor: Option<Corner>,
|
||||
input: WeakEntity<TextInput>,
|
||||
emojis: Rc<Vec<SharedString>>,
|
||||
}
|
||||
|
||||
impl EmojiPicker {
|
||||
pub fn new(input: WeakEntity<TextInput>) -> Self {
|
||||
fn get_emojis() -> &'static Vec<SharedString> {
|
||||
EMOJIS.get_or_init(|| {
|
||||
let mut emojis: Vec<SharedString> = vec![];
|
||||
|
||||
emojis.extend(
|
||||
@@ -39,35 +24,55 @@ impl EmojiPicker {
|
||||
.collect::<Vec<SharedString>>(),
|
||||
);
|
||||
|
||||
emojis.extend(
|
||||
emojis::Group::Symbols
|
||||
.emojis()
|
||||
.map(|e| SharedString::from(e.as_str()))
|
||||
.collect::<Vec<SharedString>>(),
|
||||
);
|
||||
emojis
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct EmojiPicker {
|
||||
target: Option<WeakEntity<InputState>>,
|
||||
icon: Option<Icon>,
|
||||
anchor: Option<Corner>,
|
||||
size: Size,
|
||||
}
|
||||
|
||||
impl EmojiPicker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
input,
|
||||
emojis: emojis.into(),
|
||||
size: Size::default(),
|
||||
target: None,
|
||||
anchor: None,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target(mut self, target: WeakEntity<InputState>) -> Self {
|
||||
self.target = Some(target);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn anchor(mut self, corner: Corner) -> Self {
|
||||
self.anchor = Some(corner);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for EmojiPicker {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EmojiPicker {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
Popover::new("emoji-picker")
|
||||
Popover::new("emojis")
|
||||
.map(|this| {
|
||||
if let Some(corner) = self.anchor {
|
||||
this.anchor(corner)
|
||||
@@ -76,13 +81,13 @@ impl RenderOnce for EmojiPicker {
|
||||
}
|
||||
})
|
||||
.trigger(
|
||||
Button::new("emoji-trigger")
|
||||
Button::new("emojis-trigger")
|
||||
.when_some(self.icon, |this, icon| this.icon(icon))
|
||||
.ghost(),
|
||||
.ghost()
|
||||
.with_size(self.size),
|
||||
)
|
||||
.content(move |window, cx| {
|
||||
let emojis = self.emojis.clone();
|
||||
let input = self.input.clone();
|
||||
let input = self.target.clone();
|
||||
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, move |_window, cx| {
|
||||
@@ -91,7 +96,7 @@ impl RenderOnce for EmojiPicker {
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.children(emojis.iter().map(|e| {
|
||||
.children(get_emojis().iter().map(|e| {
|
||||
div()
|
||||
.id(e.clone())
|
||||
.flex_auto()
|
||||
@@ -99,27 +104,25 @@ impl RenderOnce for EmojiPicker {
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.rounded(cx.theme().radius)
|
||||
.child(e.clone())
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.on_click({
|
||||
let item = e.clone();
|
||||
let input = input.upgrade();
|
||||
let input = input.clone();
|
||||
|
||||
move |_, window, cx| {
|
||||
if let Some(input) = input.as_ref() {
|
||||
input.update(cx, |this, cx| {
|
||||
let current = this.text();
|
||||
let new_text = if current.is_empty() {
|
||||
format!("{}", item)
|
||||
} else if current.ends_with(" ") {
|
||||
format!("{}{}", current, item)
|
||||
_ = input.update(cx, |this, cx| {
|
||||
let value = this.value();
|
||||
let new_text = if value.is_empty() {
|
||||
format!("{item}")
|
||||
} else if value.ends_with(" ") {
|
||||
format!("{value}{item}")
|
||||
} else {
|
||||
format!("{} {}", current, item)
|
||||
format!("{value} {item}")
|
||||
};
|
||||
this.set_text(new_text, window, cx);
|
||||
this.set_value(new_text, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
1431
crates/chat_ui/src/lib.rs
Normal file
60
crates/chat_ui/src/subject.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use gpui::{
|
||||
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{v_flex, Sizable};
|
||||
|
||||
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
|
||||
cx.new(|cx| Subject::new(subject, window, cx))
|
||||
}
|
||||
|
||||
pub struct Subject {
|
||||
input: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Plan for holiday"));
|
||||
|
||||
if let Some(value) = subject {
|
||||
input.update(cx, |this, cx| {
|
||||
this.set_value(value, window, cx);
|
||||
});
|
||||
};
|
||||
|
||||
Self { input }
|
||||
}
|
||||
|
||||
pub fn new_subject(&self, cx: &App) -> SharedString {
|
||||
self.input.read(cx).value()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Subject {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Subject:")),
|
||||
)
|
||||
.child(TextInput::new(&self.input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.italic()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::from(
|
||||
"Subject will be updated when you send a new message.",
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
324
crates/chat_ui/src/text.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::RenderedProfile;
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
|
||||
StyledText, UnderlineStyle, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use person::PersonRegistry;
|
||||
use regex::Regex;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::actions::OpenPublicKey;
|
||||
|
||||
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
|
||||
});
|
||||
|
||||
static NOSTR_URI_REGEX: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Link,
|
||||
Nostr,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RenderedText {
|
||||
pub text: SharedString,
|
||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||
pub link_ranges: Vec<Range<usize>>,
|
||||
pub link_urls: Arc<[String]>,
|
||||
}
|
||||
|
||||
impl RenderedText {
|
||||
pub fn new(content: &str, cx: &App) -> Self {
|
||||
let mut text = String::new();
|
||||
let mut highlights = Vec::new();
|
||||
let mut link_ranges = Vec::new();
|
||||
let mut link_urls = Vec::new();
|
||||
|
||||
render_plain_text_mut(
|
||||
content,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
cx,
|
||||
);
|
||||
|
||||
text.truncate(text.trim_end().len());
|
||||
|
||||
RenderedText {
|
||||
text: SharedString::from(text),
|
||||
link_urls: link_urls.into(),
|
||||
link_ranges,
|
||||
highlights,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||
let link_color = cx.theme().text_accent;
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
StyledText::new(self.text.clone()).with_default_highlights(
|
||||
&window.text_style(),
|
||||
self.highlights.iter().map(|(range, highlight)| {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Link => HighlightStyle {
|
||||
color: Some(link_color),
|
||||
underline: Some(UnderlineStyle::default()),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Nostr => HighlightStyle {
|
||||
color: Some(link_color),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.on_click(self.link_ranges.clone(), {
|
||||
let link_urls = self.link_urls.clone();
|
||||
move |ix, window, cx| {
|
||||
let token = link_urls[ix].as_str();
|
||||
|
||||
if let Some(clean_url) = token.strip_prefix("nostr:") {
|
||||
if let Ok(public_key) = PublicKey::parse(clean_url) {
|
||||
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
|
||||
}
|
||||
} else if is_url(token) {
|
||||
let url = if token.starts_with("http") {
|
||||
token.to_string()
|
||||
} else {
|
||||
format!("https://{token}")
|
||||
};
|
||||
cx.open_url(&url);
|
||||
} else {
|
||||
log::warn!("Unrecognized token {token}")
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_plain_text_mut(
|
||||
content: &str,
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
cx: &App,
|
||||
) {
|
||||
// Copy the content directly
|
||||
text.push_str(content);
|
||||
|
||||
// Collect all URLs
|
||||
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||
|
||||
for link in URL_REGEX.find_iter(content) {
|
||||
let range = link.start()..link.end();
|
||||
let url = link.as_str().to_string();
|
||||
|
||||
url_matches.push((range, url));
|
||||
}
|
||||
|
||||
// Collect all nostr entities with nostr: prefix
|
||||
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||
|
||||
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
|
||||
let range = nostr_match.start()..nostr_match.end();
|
||||
let nostr_uri = nostr_match.as_str().to_string();
|
||||
|
||||
// Check if this nostr URI overlaps with any already processed URL
|
||||
if !url_matches
|
||||
.iter()
|
||||
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
|
||||
{
|
||||
nostr_matches.push((range, nostr_uri));
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all matches for processing from end to start
|
||||
let mut all_matches = Vec::new();
|
||||
all_matches.extend(url_matches);
|
||||
all_matches.extend(nostr_matches);
|
||||
|
||||
// Sort by position (end to start) to avoid changing positions when replacing text
|
||||
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
|
||||
|
||||
// Process all matches
|
||||
for (range, entity) in all_matches {
|
||||
// Handle URL token
|
||||
if is_url(&entity) {
|
||||
highlights.push((range.clone(), Highlight::Link));
|
||||
link_ranges.push(range);
|
||||
link_urls.push(entity);
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(nip21) = Nip21::parse(&entity) {
|
||||
match nip21 {
|
||||
Nip21::Pubkey(public_key) => {
|
||||
render_pubkey(
|
||||
public_key,
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Nip21::Profile(nip19_profile) => {
|
||||
render_pubkey(
|
||||
nip19_profile.public_key,
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Nip21::EventId(event_id) => {
|
||||
render_bech32(
|
||||
event_id.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
Nip21::Event(nip19_event) => {
|
||||
render_bech32(
|
||||
nip19_event.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
Nip21::Coordinate(nip19_coordinate) => {
|
||||
render_bech32(
|
||||
nip19_coordinate.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a string is a URL
|
||||
fn is_url(s: &str) -> bool {
|
||||
URL_REGEX.is_match(s)
|
||||
}
|
||||
|
||||
/// Format a bech32 entity with ellipsis and last 4 characters
|
||||
fn format_shortened_entity(entity: &str) -> String {
|
||||
let prefix_end = entity.find('1').unwrap_or(0);
|
||||
|
||||
if prefix_end > 0 && entity.len() > prefix_end + 5 {
|
||||
let prefix = &entity[0..=prefix_end]; // Include the '1'
|
||||
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
|
||||
|
||||
format!("{prefix}...{suffix}")
|
||||
} else {
|
||||
entity.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pubkey(
|
||||
public_key: PublicKey,
|
||||
text: &mut String,
|
||||
range: &Range<usize>,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
cx: &App,
|
||||
) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get_person(&public_key, cx);
|
||||
let display_name = format!("@{}", profile.display_name());
|
||||
|
||||
text.replace_range(range.clone(), &display_name);
|
||||
|
||||
let new_length = display_name.len();
|
||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||
let new_range = range.start..(range.start + new_length);
|
||||
|
||||
highlights.push((new_range.clone(), Highlight::Nostr));
|
||||
link_ranges.push(new_range);
|
||||
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
|
||||
|
||||
if length_diff != 0 {
|
||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_bech32(
|
||||
bech32: String,
|
||||
text: &mut String,
|
||||
range: &Range<usize>,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
let njump_url = format!("https://njump.me/{bech32}");
|
||||
let shortened_entity = format_shortened_entity(&bech32);
|
||||
let display_text = format!("https://njump.me/{shortened_entity}");
|
||||
|
||||
text.replace_range(range.clone(), &display_text);
|
||||
|
||||
let new_length = display_text.len();
|
||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||
let new_range = range.start..(range.start + new_length);
|
||||
|
||||
highlights.push((new_range.clone(), Highlight::Link));
|
||||
link_ranges.push(new_range);
|
||||
link_urls.push(njump_url);
|
||||
|
||||
if length_diff != 0 {
|
||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to adjust ranges when text length changes
|
||||
fn adjust_ranges(
|
||||
highlights: &mut [(Range<usize>, Highlight)],
|
||||
link_ranges: &mut [Range<usize>],
|
||||
position: usize,
|
||||
length_diff: isize,
|
||||
) {
|
||||
// Adjust highlight ranges
|
||||
for (range, _) in highlights.iter_mut() {
|
||||
if range.start > position {
|
||||
range.start = (range.start as isize + length_diff) as usize;
|
||||
range.end = (range.end as isize + length_diff) as usize;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust link ranges
|
||||
for range in link_ranges.iter_mut() {
|
||||
if range.start > position {
|
||||
range.start = (range.start as isize + length_diff) as usize;
|
||||
range.end = (range.end as isize + length_diff) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub(crate) const NOW: &str = "now";
|
||||
pub(crate) const SECONDS_IN_MINUTE: i64 = 60;
|
||||
pub(crate) const MINUTES_IN_HOUR: i64 = 60;
|
||||
pub(crate) const HOURS_IN_DAY: i64 = 24;
|
||||
pub(crate) const DAYS_IN_MONTH: i64 = 30;
|
||||
@@ -1,307 +0,0 @@
|
||||
use std::{cmp::Reverse, collections::HashMap};
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::room_hash;
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use room::RoomKind;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
|
||||
use crate::room::Room;
|
||||
|
||||
mod constants;
|
||||
pub mod message;
|
||||
pub mod room;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
|
||||
impl Global for GlobalChatRegistry {}
|
||||
|
||||
/// Main registry for managing chat rooms and user profiles
|
||||
///
|
||||
/// The ChatRegistry is responsible for:
|
||||
/// - Managing chat rooms and their states
|
||||
/// - Tracking user profiles
|
||||
/// - Loading room data from the lmdb
|
||||
/// - Handling messages and room creation
|
||||
pub struct ChatRegistry {
|
||||
/// Collection of all chat rooms
|
||||
rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// Map of user public keys to their profile metadata
|
||||
profiles: Entity<HashMap<PublicKey, Option<Metadata>>>,
|
||||
|
||||
/// Indicates if rooms are currently being loaded
|
||||
loading: bool,
|
||||
|
||||
/// Subscriptions for observing changes
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl ChatRegistry {
|
||||
/// Retrieve the global ChatRegistry instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalChatRegistry>().0.clone()
|
||||
}
|
||||
|
||||
/// Set the global ChatRegistry instance
|
||||
pub fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalChatRegistry(state));
|
||||
}
|
||||
|
||||
/// Create a new ChatRegistry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let profiles = cx.new(|_| HashMap::new());
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
// Observe new Room creations to collect profile metadata
|
||||
subscriptions.push(cx.observe_new::<Room>(|this, _, cx| {
|
||||
let task = this.metadata(cx);
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
if let Ok(data) = task.await {
|
||||
cx.update(|cx| {
|
||||
for (public_key, metadata) in data.into_iter() {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.add_profile(public_key, metadata, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}));
|
||||
|
||||
Self {
|
||||
rooms: vec![],
|
||||
loading: true,
|
||||
profiles,
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the global loading status
|
||||
pub fn loading(&self) -> bool {
|
||||
self.loading
|
||||
}
|
||||
|
||||
/// Get a room by its ID.
|
||||
pub fn room(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.find(|model| model.read(cx).id == *id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get all rooms grouped by their kind.
|
||||
pub fn rooms(&self, cx: &App) -> HashMap<RoomKind, Vec<&Entity<Room>>> {
|
||||
let mut groups = HashMap::new();
|
||||
groups.insert(RoomKind::Ongoing, Vec::new());
|
||||
groups.insert(RoomKind::Trusted, Vec::new());
|
||||
groups.insert(RoomKind::Unknown, Vec::new());
|
||||
|
||||
for room in self.rooms.iter() {
|
||||
let kind = room.read(cx).kind;
|
||||
groups.entry(kind).or_insert_with(Vec::new).push(room);
|
||||
}
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
/// Get rooms by their kind.
|
||||
pub fn rooms_by_kind(&self, kind: RoomKind, cx: &App) -> Vec<&Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).kind == kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the IDs of all rooms.
|
||||
pub fn room_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
|
||||
self.rooms.iter().map(|room| room.read(cx).id).collect()
|
||||
}
|
||||
|
||||
/// Load all rooms from the lmdb.
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Fetches all private direct messages from the lmdb
|
||||
/// 2. Groups them by ID
|
||||
/// 3. Determines each room's type based on message frequency and trust status
|
||||
/// 4. Creates Room entities for each unique room
|
||||
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
type Rooms = Vec<(Event, usize, bool)>;
|
||||
|
||||
let task: Task<Result<Rooms, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Get messages sent by the user
|
||||
let send = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.author(public_key);
|
||||
|
||||
// Get messages received by the user
|
||||
let recv = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.pubkey(public_key);
|
||||
|
||||
let send_events = client.database().query(send).await?;
|
||||
let recv_events = client.database().query(recv).await?;
|
||||
let events = send_events.merge(recv_events);
|
||||
|
||||
let mut room_map: HashMap<u64, (Event, usize, bool)> = HashMap::new();
|
||||
|
||||
// Process each event and group by room hash
|
||||
for event in events
|
||||
.into_iter()
|
||||
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
|
||||
{
|
||||
let hash = room_hash(&event);
|
||||
|
||||
let filter = Filter::new().kind(Kind::ContactList).pubkey(event.pubkey);
|
||||
let is_trust = client.database().count(filter).await? >= 1;
|
||||
|
||||
room_map
|
||||
.entry(hash)
|
||||
.and_modify(|(_, count, trusted)| {
|
||||
*count += 1;
|
||||
*trusted = is_trust;
|
||||
})
|
||||
.or_insert((event, 1, is_trust));
|
||||
}
|
||||
|
||||
// Sort rooms by creation date (newest first)
|
||||
let result: Vec<(Event, usize, bool)> = room_map
|
||||
.into_values()
|
||||
.sorted_by_key(|(ev, _, _)| Reverse(ev.created_at))
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(events) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
let ids = this.room_ids(cx);
|
||||
let rooms: Vec<Entity<Room>> = events
|
||||
.into_iter()
|
||||
.filter_map(|(event, count, trusted)| {
|
||||
let hash = room_hash(&event);
|
||||
if !ids.iter().any(|this| this == &hash) {
|
||||
let kind = if count > 2 {
|
||||
// If frequency count is greater than 2, mark this room as ongoing
|
||||
RoomKind::Ongoing
|
||||
} else if trusted {
|
||||
RoomKind::Trusted
|
||||
} else {
|
||||
RoomKind::Unknown
|
||||
};
|
||||
Some(cx.new(|_| Room::new(&event).kind(kind)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
this.rooms.extend(rooms);
|
||||
this.rooms.sort_by_key(|r| Reverse(r.read(cx).created_at));
|
||||
this.loading = false;
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Add a user profile to the registry
|
||||
///
|
||||
/// Only adds the profile if it doesn't already exist or is currently none
|
||||
pub fn add_profile(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
metadata: Option<Metadata>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.profiles.update(cx, |this, _cx| {
|
||||
this.entry(public_key)
|
||||
.and_modify(|entry| {
|
||||
if entry.is_none() {
|
||||
*entry = metadata.clone();
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| metadata);
|
||||
});
|
||||
}
|
||||
|
||||
/// Get a user profile by public key
|
||||
pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||
let metadata = if let Some(profile) = self.profiles.read(cx).get(public_key) {
|
||||
profile.clone().unwrap_or_default()
|
||||
} else {
|
||||
Metadata::default()
|
||||
};
|
||||
|
||||
Profile::new(*public_key, metadata)
|
||||
}
|
||||
|
||||
/// Add a new room to the registry
|
||||
///
|
||||
/// Returns an error if the room already exists
|
||||
pub fn push(&mut self, room: Room, cx: &mut Context<Self>) -> Result<(), anyhow::Error> {
|
||||
let room = cx.new(|_| room);
|
||||
|
||||
if !self
|
||||
.rooms
|
||||
.iter()
|
||||
.any(|current| current.read(cx) == room.read(cx))
|
||||
{
|
||||
self.rooms.insert(0, room);
|
||||
cx.notify();
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Room already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new message to a room
|
||||
///
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn push_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let id = room_hash(&event);
|
||||
|
||||
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.created_at(event.created_at, cx);
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.emit_message(event, window, cx);
|
||||
});
|
||||
});
|
||||
|
||||
cx.defer_in(window, |this, _, cx| {
|
||||
this.rooms
|
||||
.sort_by_key(|room| Reverse(room.read(cx).created_at));
|
||||
});
|
||||
} else {
|
||||
// Push the new room to the front of the list
|
||||
self.rooms.insert(0, cx.new(|_| Room::new(&event)));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// # Message
|
||||
///
|
||||
/// Represents a message in the application.
|
||||
///
|
||||
/// ## Fields
|
||||
///
|
||||
/// - `id`: The unique identifier for the message
|
||||
/// - `content`: The text content of the message
|
||||
/// - `author`: Profile information about who created the message
|
||||
/// - `mentions`: List of profiles mentioned in the message
|
||||
/// - `created_at`: Timestamp when the message was created
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
pub id: EventId,
|
||||
pub content: String,
|
||||
pub author: Profile,
|
||||
pub mentions: Vec<Profile>,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Creates a new message with the provided details
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - Unique event identifier
|
||||
/// * `content` - Message text content
|
||||
/// * `author` - Profile of the message author
|
||||
/// * `created_at` - When the message was created
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new `Message` instance
|
||||
pub fn new(id: EventId, content: String, author: Profile, created_at: Timestamp) -> Self {
|
||||
Self {
|
||||
id,
|
||||
content,
|
||||
author,
|
||||
created_at,
|
||||
mentions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or replaces mentions in the message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `mentions` - New list of mentioned profiles
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The same message with updated mentions
|
||||
pub fn with_mentions(mut self, mentions: impl IntoIterator<Item = Profile>) -> Self {
|
||||
self.mentions.extend(mentions);
|
||||
self
|
||||
}
|
||||
|
||||
/// Formats the message timestamp as a human-readable relative time
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A formatted string like "Today at 12:30 PM", "Yesterday at 3:45 PM",
|
||||
/// or a date and time for older messages
|
||||
pub fn ago(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "Invalid timestamp".into(),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let input_date = input_time.date_naive();
|
||||
let now_date = now.date_naive();
|
||||
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
|
||||
|
||||
let time_format = input_time.format("%H:%M %p");
|
||||
|
||||
match input_date {
|
||||
date if date == now_date => format!("Today at {time_format}"),
|
||||
date if date == yesterday_date => format!("Yesterday at {time_format}"),
|
||||
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// # RoomMessage
|
||||
///
|
||||
/// Represents different types of messages that can appear in a room.
|
||||
///
|
||||
/// ## Variants
|
||||
///
|
||||
/// - `User`: A message sent by a user
|
||||
/// - `System`: A message generated by the system
|
||||
/// - `Announcement`: A special message type used for room announcements
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RoomMessage {
|
||||
/// User message
|
||||
User(Box<Message>),
|
||||
/// System message
|
||||
System(SharedString),
|
||||
/// Only use for UI purposes.
|
||||
/// Placeholder will be used for display room announcement
|
||||
Announcement,
|
||||
}
|
||||
|
||||
impl RoomMessage {
|
||||
/// Creates a new user message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - The message content
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `RoomMessage::User` variant
|
||||
pub fn user(message: Message) -> Self {
|
||||
Self::User(Box::new(message))
|
||||
}
|
||||
|
||||
/// Creates a new system message
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The system message content
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `RoomMessage::System` variant
|
||||
pub fn system(content: SharedString) -> Self {
|
||||
Self::System(content)
|
||||
}
|
||||
|
||||
/// Creates a new announcement placeholder
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `RoomMessage::Announcement` variant
|
||||
pub fn announcement() -> Self {
|
||||
Self::Announcement
|
||||
}
|
||||
}
|
||||
@@ -1,617 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use account::Account;
|
||||
use anyhow::Error;
|
||||
use chrono::{Local, TimeZone};
|
||||
use common::{compare, profile::SharedProfile, room_hash};
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::channel::Receiver;
|
||||
|
||||
use crate::{
|
||||
constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE},
|
||||
message::{Message, RoomMessage},
|
||||
ChatRegistry,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingEvent {
|
||||
pub event: RoomMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SendStatus {
|
||||
Sent(EventId),
|
||||
Failed(Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RoomKind {
|
||||
Ongoing,
|
||||
Trusted,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub struct Room {
|
||||
pub id: u64,
|
||||
pub created_at: Timestamp,
|
||||
/// Subject of the room
|
||||
pub subject: Option<SharedString>,
|
||||
/// Picture of the room
|
||||
pub picture: Option<SharedString>,
|
||||
/// All members of the room
|
||||
pub members: Arc<Vec<PublicKey>>,
|
||||
/// Kind
|
||||
pub kind: RoomKind,
|
||||
}
|
||||
|
||||
impl EventEmitter<IncomingEvent> for Room {}
|
||||
|
||||
impl PartialEq for Room {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Room {
|
||||
/// Creates a new Room instance from a Nostr event
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `event` - The Nostr event containing chat information
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new Room instance with information extracted from the event
|
||||
pub fn new(event: &Event) -> Self {
|
||||
let id = room_hash(event);
|
||||
let created_at = event.created_at;
|
||||
|
||||
// Get all pubkeys from the event's tags
|
||||
let mut pubkeys: Vec<PublicKey> = event.tags.public_keys().cloned().collect();
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
// Convert pubkeys into members
|
||||
let members = Arc::new(pubkeys.into_iter().unique().sorted().collect());
|
||||
|
||||
// Get the subject from the event's tags
|
||||
let subject = if let Some(tag) = event.tags.find(TagKind::Subject) {
|
||||
tag.content().map(|s| s.to_owned().into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get the picture from the event's tags
|
||||
let picture = if let Some(tag) = event.tags.find(TagKind::custom("picture")) {
|
||||
tag.content().map(|s| s.to_owned().into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
id,
|
||||
created_at,
|
||||
subject,
|
||||
picture,
|
||||
members,
|
||||
kind: RoomKind::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the kind of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `kind` - The kind of room to set
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The room with the updated kind
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculates a human-readable representation of the time passed since room creation
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A SharedString representing the relative time since room creation:
|
||||
/// - "now" for less than a minute
|
||||
/// - "Xm" for minutes
|
||||
/// - "Xh" for hours
|
||||
/// - "Xd" for days
|
||||
/// - Month and day (e.g. "Jan 15") for older dates
|
||||
pub fn ago(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return "1m".into(),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let duration = now.signed_duration_since(input_time);
|
||||
|
||||
match duration {
|
||||
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
|
||||
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
|
||||
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
|
||||
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
|
||||
_ => input_time.format("%b %d").to_string(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Gets the profile for a specific public key
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `public_key` - The public key to get the profile for
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Profile associated with the given public key
|
||||
pub fn profile_by_pubkey(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||
ChatRegistry::global(cx).read(cx).profile(public_key, cx)
|
||||
}
|
||||
|
||||
/// Gets the first member in the room that isn't the current user
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Profile of the first member in the room
|
||||
pub fn first_member(&self, cx: &App) -> Profile {
|
||||
let account = Account::global(cx).read(cx);
|
||||
let Some(profile) = account.profile.clone() else {
|
||||
return self.profile_by_pubkey(&self.members[0], cx);
|
||||
};
|
||||
|
||||
if let Some(public_key) = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|&pubkey| pubkey != &profile.public_key())
|
||||
.collect::<Vec<_>>()
|
||||
.first()
|
||||
{
|
||||
self.profile_by_pubkey(public_key, cx)
|
||||
} else {
|
||||
profile
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all avatars for members in the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of SharedString containing all members' avatars
|
||||
pub fn avatars(&self, cx: &App) -> Vec<SharedString> {
|
||||
let profiles: Vec<Profile> = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
.collect();
|
||||
|
||||
profiles
|
||||
.iter()
|
||||
.map(|member| member.shared_avatar())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Gets a formatted string of member names
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A SharedString containing formatted member names:
|
||||
/// - For a group chat: "name1, name2, +X" where X is the number of additional members
|
||||
/// - For a direct message: just the name of the other person
|
||||
pub fn names(&self, cx: &App) -> SharedString {
|
||||
if self.is_group() {
|
||||
let profiles = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut name = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|profile| profile.shared_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
if profiles.len() > 2 {
|
||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||
}
|
||||
|
||||
name.into()
|
||||
} else {
|
||||
self.first_member(cx).shared_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display name for the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A SharedString representing the display name:
|
||||
/// - The subject of the room if it exists
|
||||
/// - Otherwise, the formatted names of the members
|
||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||
if let Some(subject) = self.subject.as_ref() {
|
||||
subject.clone()
|
||||
} else {
|
||||
self.names(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display image for the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An Option<SharedString> containing the avatar:
|
||||
/// - For a direct message: the other person's avatar
|
||||
/// - For a group chat: None
|
||||
pub fn display_image(&self, cx: &App) -> Option<SharedString> {
|
||||
if !self.is_group() {
|
||||
Some(self.first_member(cx).shared_avatar())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the room is a group chat
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// true if the room has more than 2 members, false otherwise
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.members.len() > 2
|
||||
}
|
||||
|
||||
/// Updates the creation timestamp of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `created_at` - The new Timestamp to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn created_at(&mut self, created_at: Timestamp, cx: &mut Context<Self>) {
|
||||
self.created_at = created_at;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the subject of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `subject` - The new subject to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn subject(&mut self, subject: String, cx: &mut Context<Self>) {
|
||||
self.subject = Some(subject.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the picture of the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `picture` - The new subject to set
|
||||
/// * `cx` - The context to notify about the update
|
||||
pub fn picture(&mut self, picture: String, cx: &mut Context<Self>) {
|
||||
self.picture = Some(picture.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Fetches metadata for all members in the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The context for the background task
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<(PublicKey, Option<Metadata>)>, Error>
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn metadata(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<(PublicKey, Option<Metadata>)>, Error>> {
|
||||
let client = get_client();
|
||||
let public_keys = self.members.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut output = vec![];
|
||||
|
||||
for public_key in public_keys.iter() {
|
||||
let metadata = client.database().metadata(*public_key).await?;
|
||||
output.push((*public_key, metadata));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks which members have inbox relays set up
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<(PublicKey, bool)>, Error> where
|
||||
/// the boolean indicates if the member has inbox relays configured
|
||||
pub fn messaging_relays(&self, cx: &App) -> Task<Result<Vec<(PublicKey, bool)>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = Arc::clone(&self.members);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut result = Vec::with_capacity(pubkeys.len());
|
||||
|
||||
for pubkey in pubkeys.iter() {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(*pubkey)
|
||||
.limit(1);
|
||||
|
||||
let is_ready = client.database().query(filter).await?.first().is_some();
|
||||
|
||||
result.push((*pubkey, is_ready));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a message to all members in the room
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The content of the message to send
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<String>, Error> where the
|
||||
/// strings contain error messages for any failed sends
|
||||
pub fn send_message(&self, content: String, cx: &App) -> Option<Receiver<SendStatus>> {
|
||||
let profile = Account::global(cx).read(cx).profile.clone()?;
|
||||
let public_key = profile.public_key();
|
||||
|
||||
let subject = self.subject.clone();
|
||||
let picture = self.picture.clone();
|
||||
let pubkeys = self.members.clone();
|
||||
|
||||
let (tx, rx) = smol::channel::bounded::<SendStatus>(pubkeys.len());
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
|
||||
let mut tags: Vec<Tag> = pubkeys
|
||||
.iter()
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &public_key {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Add subject tag if it's present
|
||||
if let Some(subject) = subject {
|
||||
tags.push(Tag::from_standardized(TagStandard::Subject(
|
||||
subject.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// Add picture tag if it's present
|
||||
if let Some(picture) = picture {
|
||||
tags.push(Tag::custom(TagKind::custom("picture"), vec![picture]));
|
||||
}
|
||||
|
||||
for pubkey in pubkeys.iter() {
|
||||
match client
|
||||
.send_private_msg(*pubkey, &content, tags.clone())
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
if let Err(e) = tx.send(SendStatus::Sent(output.val)).await {
|
||||
log::error!("Failed to send message to {}: {}", pubkey, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Err(e) = tx.send(SendStatus::Failed(e.into())).await {
|
||||
log::error!("Failed to send message to {}: {}", pubkey, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// Loads all messages for this room from the database
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cx` - The App context
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Task that resolves to Result<Vec<RoomMessage>, Error> containing
|
||||
/// all messages for this room
|
||||
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<RoomMessage>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = Arc::clone(&self.members);
|
||||
|
||||
let profiles: Vec<Profile> = pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
.collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.authors(pubkeys.to_vec())
|
||||
.pubkeys(pubkeys.to_vec());
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut messages = vec![];
|
||||
let parser = NostrParser::new();
|
||||
|
||||
// Get all events from database
|
||||
let events = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.sorted_by_key(|ev| ev.created_at)
|
||||
.filter(|ev| {
|
||||
let mut other_pubkeys = ev.tags.public_keys().copied().collect::<Vec<_>>();
|
||||
other_pubkeys.push(ev.pubkey);
|
||||
// Check if the event is from a member of the room
|
||||
compare(&other_pubkeys, &pubkeys)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for event in events.into_iter() {
|
||||
let mut mentions = vec![];
|
||||
let id = event.id;
|
||||
let created_at = event.created_at;
|
||||
let content = event.content.clone();
|
||||
let tokens = parser.parse(&content);
|
||||
|
||||
let author = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == event.pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(event.pubkey, Metadata::default()));
|
||||
|
||||
let pubkey_tokens = tokens
|
||||
.filter_map(|token| match token {
|
||||
Token::Nostr(nip21) => match nip21 {
|
||||
Nip21::Pubkey(pubkey) => Some(pubkey),
|
||||
Nip21::Profile(profile) => Some(profile.public_key),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for pubkey in pubkey_tokens {
|
||||
mentions.push(
|
||||
profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(pubkey, Metadata::default())),
|
||||
);
|
||||
}
|
||||
|
||||
let message = Message::new(id, content, author, created_at).with_mentions(mentions);
|
||||
let room_message = RoomMessage::user(message);
|
||||
|
||||
messages.push(room_message);
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
})
|
||||
}
|
||||
|
||||
/// Emits a message event to the GPUI
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `event` - The Nostr event to emit
|
||||
/// * `window` - The Window to emit the event to
|
||||
/// * `cx` - The context for the room
|
||||
///
|
||||
/// # Effects
|
||||
///
|
||||
/// Processes the event and emits an IncomingEvent to the UI when complete
|
||||
pub fn emit_message(&self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pubkeys = self.members.clone();
|
||||
let profiles: Vec<Profile> = pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx))
|
||||
.collect();
|
||||
|
||||
let task: Task<Result<RoomMessage, Error>> = cx.background_spawn(async move {
|
||||
let parser = NostrParser::new();
|
||||
let id = event.id;
|
||||
let created_at = event.created_at;
|
||||
let content = event.content.clone();
|
||||
let tokens = parser.parse(&content);
|
||||
let mut mentions = vec![];
|
||||
|
||||
let author = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == event.pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(event.pubkey, Metadata::default()));
|
||||
|
||||
let pubkey_tokens = tokens
|
||||
.filter_map(|token| match token {
|
||||
Token::Nostr(nip21) => match nip21 {
|
||||
Nip21::Pubkey(pubkey) => Some(pubkey),
|
||||
Nip21::Profile(profile) => Some(profile.public_key),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for pubkey in pubkey_tokens {
|
||||
mentions.push(
|
||||
profiles
|
||||
.iter()
|
||||
.find(|profile| profile.public_key() == pubkey)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Profile::new(pubkey, Metadata::default())),
|
||||
);
|
||||
}
|
||||
|
||||
let message = Message::new(id, content, author, created_at).with_mentions(mentions);
|
||||
let room_message = RoomMessage::user(message);
|
||||
|
||||
Ok(room_message)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(message) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(IncomingEvent { event: message });
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,19 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
reqwest.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
random_name_generator = "0.3.6"
|
||||
qrcode-generator = "5.0.0"
|
||||
dirs = "5.0"
|
||||
qrcode = "0.14.1"
|
||||
whoami = "1.6.1"
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
39
crates/common/src/constants.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
pub const CLIENT_NAME: &str = "Coop";
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
|
||||
/// Bootstrap Relays.
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nos.social",
|
||||
"wss://user.kindpag.es",
|
||||
"wss://purplepag.es",
|
||||
];
|
||||
|
||||
/// Search Relays.
|
||||
pub const SEARCH_RELAYS: [&str; 3] = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://search.nos.today",
|
||||
"wss://relay.noswhere.com",
|
||||
];
|
||||
|
||||
/// Default relay for Nostr Connect
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||
|
||||
/// Default retry count for fetching NIP-17 relays
|
||||
pub const RELAY_RETRY: u64 = 2;
|
||||
|
||||
/// Default retry count for sending messages
|
||||
pub const SEND_RETRY: u64 = 10;
|
||||
|
||||
/// Default timeout (in seconds) for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
||||
|
||||
/// Default timeout (in seconds) for Nostr Connect (Bunker)
|
||||
pub const BUNKER_TIMEOUT: u64 = 30;
|
||||
|
||||
/// Total metadata requests will be grouped.
|
||||
pub const METADATA_BATCH_LIMIT: usize = 20;
|
||||
|
||||
/// Default width of the sidebar.
|
||||
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;
|
||||
66
crates/common/src/debounced_delay.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use futures::FutureExt;
|
||||
use gpui::{Context, Task, Window};
|
||||
|
||||
pub struct DebouncedDelay<E: 'static> {
|
||||
task: Option<Task<()>>,
|
||||
cancel_channel: Option<oneshot::Sender<()>>,
|
||||
_phantom_data: PhantomData<E>,
|
||||
}
|
||||
|
||||
impl<E: 'static> Default for DebouncedDelay<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: 'static> DebouncedDelay<E> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
task: None,
|
||||
cancel_channel: None,
|
||||
_phantom_data: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fire_new<F>(
|
||||
&mut self,
|
||||
delay: Duration,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<E>,
|
||||
func: F,
|
||||
) where
|
||||
F: 'static + Send + FnOnce(&mut E, &mut Window, &mut Context<E>) -> Task<()>,
|
||||
{
|
||||
if let Some(channel) = self.cancel_channel.take() {
|
||||
_ = channel.send(());
|
||||
}
|
||||
|
||||
let (sender, mut receiver) = oneshot::channel::<()>();
|
||||
self.cancel_channel = Some(sender);
|
||||
|
||||
let previous_task = self.task.take();
|
||||
|
||||
self.task = Some(cx.spawn_in(window, async move |entity, cx| {
|
||||
let mut timer = cx.background_executor().timer(delay).fuse();
|
||||
|
||||
if let Some(previous_task) = previous_task {
|
||||
previous_task.await;
|
||||
}
|
||||
|
||||
futures::select_biased! {
|
||||
_ = receiver => return,
|
||||
_ = timer => {}
|
||||
}
|
||||
|
||||
if let Ok(Ok(task)) =
|
||||
cx.update(|window, cx| entity.update(cx, |project, cx| (func)(project, window, cx)))
|
||||
{
|
||||
task.await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
150
crates/common/src/display.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use chrono::{Local, TimeZone};
|
||||
use gpui::{Image, ImageFormat, SharedString};
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode::render::svg;
|
||||
use qrcode::QrCode;
|
||||
|
||||
const NOW: &str = "now";
|
||||
const SECONDS_IN_MINUTE: i64 = 60;
|
||||
const MINUTES_IN_HOUR: i64 = 60;
|
||||
const HOURS_IN_DAY: i64 = 24;
|
||||
const DAYS_IN_MONTH: i64 = 30;
|
||||
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
|
||||
const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl";
|
||||
|
||||
pub trait RenderedProfile {
|
||||
fn avatar(&self, proxy: bool) -> SharedString;
|
||||
fn display_name(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl RenderedProfile for Profile {
|
||||
fn avatar(&self, proxy: bool) -> SharedString {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
if proxy {
|
||||
let url = format!(
|
||||
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
|
||||
);
|
||||
|
||||
url.into()
|
||||
} else {
|
||||
picture.into()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
}
|
||||
|
||||
fn display_name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
||||
if !display_name.is_empty() {
|
||||
return SharedString::from(display_name);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata().name.as_ref() {
|
||||
if !name.is_empty() {
|
||||
return SharedString::from(name);
|
||||
}
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key(), 4))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait RenderedTimestamp {
|
||||
fn to_human_time(&self) -> SharedString;
|
||||
fn to_ago(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl RenderedTimestamp for Timestamp {
|
||||
fn to_human_time(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return SharedString::from("9999"),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let input_date = input_time.date_naive();
|
||||
let now_date = now.date_naive();
|
||||
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
|
||||
let time_format = input_time.format("%H:%M %p");
|
||||
|
||||
match input_date {
|
||||
date if date == now_date => SharedString::from(format!("Today at {time_format}")),
|
||||
date if date == yesterday_date => {
|
||||
SharedString::from(format!("Yesterday at {time_format}"))
|
||||
}
|
||||
_ => SharedString::from(format!("{}, {time_format}", input_time.format("%d/%m/%y"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_ago(&self) -> SharedString {
|
||||
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
|
||||
chrono::LocalResult::Single(time) => time,
|
||||
_ => return SharedString::from("1m"),
|
||||
};
|
||||
|
||||
let now = Local::now();
|
||||
let duration = now.signed_duration_since(input_time);
|
||||
|
||||
match duration {
|
||||
d if d.num_seconds() < SECONDS_IN_MINUTE => SharedString::from(NOW),
|
||||
d if d.num_minutes() < MINUTES_IN_HOUR => {
|
||||
SharedString::from(format!("{}m", d.num_minutes()))
|
||||
}
|
||||
d if d.num_hours() < HOURS_IN_DAY => SharedString::from(format!("{}h", d.num_hours())),
|
||||
d if d.num_days() < DAYS_IN_MONTH => SharedString::from(format!("{}d", d.num_days())),
|
||||
_ => SharedString::from(input_time.format("%b %d").to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TextUtils {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error>;
|
||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> TextUtils for T {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error> {
|
||||
let s = self.as_ref();
|
||||
if s.starts_with("nprofile1") {
|
||||
Ok(Nip19Profile::from_bech32(s)?.public_key)
|
||||
} else if s.starts_with("npub1") {
|
||||
Ok(PublicKey::parse(s)?)
|
||||
} else {
|
||||
Err(anyhow!("Invalid public key"))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||
let s = self.as_ref();
|
||||
let code = QrCode::new(s).unwrap();
|
||||
let svg = code
|
||||
.render()
|
||||
.min_dimensions(256, 256)
|
||||
.dark_color(svg::Color("#000000"))
|
||||
.light_color(svg::Color("#FFFFFF"))
|
||||
.build();
|
||||
|
||||
Some(Arc::new(Image::from_bytes(
|
||||
ImageFormat::Svg,
|
||||
svg.into_bytes(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
|
||||
let Ok(pubkey) = public_key.to_bech32();
|
||||
|
||||
format!(
|
||||
"{}:{}",
|
||||
&pubkey[0..(len + 1)],
|
||||
&pubkey[pubkey.len() - len..]
|
||||
)
|
||||
}
|
||||
53
crates/common/src/event.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub trait EventUtils {
|
||||
fn uniq_id(&self) -> u64;
|
||||
fn all_pubkeys(&self) -> Vec<PublicKey>;
|
||||
}
|
||||
|
||||
impl EventUtils for Event {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = self.all_pubkeys();
|
||||
pubkeys.sort();
|
||||
pubkeys.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn all_pubkeys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
|
||||
public_keys.into_iter().unique().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventUtils for UnsignedEvent {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = vec![];
|
||||
|
||||
// Add all public keys from event
|
||||
pubkeys.push(self.pubkey);
|
||||
pubkeys.extend(self.tags.public_keys().collect::<Vec<_>>());
|
||||
|
||||
// Generate unique hash
|
||||
pubkeys
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>()
|
||||
.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn all_pubkeys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
public_keys.into_iter().unique().sorted().collect()
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,68 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use global::constants::NIP96_SERVER;
|
||||
use gpui::Image;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
use rnglib::{Language, RNG};
|
||||
|
||||
pub mod profile;
|
||||
|
||||
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> {
|
||||
let signer = client.signer().await?;
|
||||
let server_url = Url::parse(NIP96_SERVER)?;
|
||||
|
||||
let config: ServerConfig = nip96::get_server_config(server_url, None).await?;
|
||||
let url = nip96::upload_data(&signer, &config, file, None, None).await?;
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn room_hash(event: &Event) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<&PublicKey> = vec![];
|
||||
|
||||
// Add all public keys from event
|
||||
pubkeys.push(&event.pubkey);
|
||||
pubkeys.extend(event.tags.public_keys().collect::<Vec<_>>());
|
||||
|
||||
// Generate unique hash
|
||||
pubkeys
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>()
|
||||
.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn device_pubkey(event: &Event) -> Result<PublicKey, anyhow::Error> {
|
||||
let n_tag = event.tags.find(TagKind::custom("n")).context("Invalid")?;
|
||||
let hex = n_tag.content().context("Invalid")?;
|
||||
let pubkey = PublicKey::parse(hex)?;
|
||||
|
||||
Ok(pubkey)
|
||||
}
|
||||
|
||||
pub fn random_name(length: usize) -> String {
|
||||
let rng = RNG::from(&Language::Roman);
|
||||
rng.generate_names(length, true).join("-").to_lowercase()
|
||||
}
|
||||
|
||||
pub fn create_qr(data: &str) -> Result<Arc<Image>, anyhow::Error> {
|
||||
let qr = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256)?;
|
||||
let img = Arc::new(Image {
|
||||
format: gpui::ImageFormat::Png,
|
||||
bytes: qr.clone(),
|
||||
id: 1,
|
||||
});
|
||||
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
pub fn compare<T>(a: &[T], b: &[T]) -> bool
|
||||
where
|
||||
T: Eq + Hash,
|
||||
{
|
||||
let a: HashSet<_> = a.iter().collect();
|
||||
let b: HashSet<_> = b.iter().collect();
|
||||
|
||||
a == b
|
||||
}
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use constants::*;
|
||||
pub use debounced_delay::*;
|
||||
pub use display::*;
|
||||
pub use event::*;
|
||||
pub use nip05::*;
|
||||
pub use nip96::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
pub use paths::*;
|
||||
|
||||
mod constants;
|
||||
mod debounced_delay;
|
||||
mod display;
|
||||
mod event;
|
||||
mod nip05;
|
||||
mod nip96;
|
||||
mod paths;
|
||||
|
||||
static APP_NAME: OnceLock<String> = OnceLock::new();
|
||||
static NIP65_RELAYS: OnceLock<Vec<(RelayUrl, Option<RelayMetadata>)>> = OnceLock::new();
|
||||
static NIP17_RELAYS: OnceLock<Vec<RelayUrl>> = OnceLock::new();
|
||||
|
||||
/// Get the app name
|
||||
pub fn app_name() -> &'static String {
|
||||
APP_NAME.get_or_init(|| {
|
||||
let devicename = whoami::devicename();
|
||||
let platform = whoami::platform();
|
||||
|
||||
format!("{CLIENT_NAME} on {platform} ({devicename})")
|
||||
})
|
||||
}
|
||||
|
||||
/// Default NIP-65 Relays. Used for new account
|
||||
pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option<RelayMetadata>)> {
|
||||
NIP65_RELAYS.get_or_init(|| {
|
||||
vec![
|
||||
(
|
||||
RelayUrl::parse("wss://nostr.mom").unwrap(),
|
||||
Some(RelayMetadata::Read),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(),
|
||||
Some(RelayMetadata::Read),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://nos.lol").unwrap(),
|
||||
Some(RelayMetadata::Write),
|
||||
),
|
||||
(
|
||||
RelayUrl::parse("wss://relay.snort.social").unwrap(),
|
||||
Some(RelayMetadata::Write),
|
||||
),
|
||||
(RelayUrl::parse("wss://relay.primal.net").unwrap(), None),
|
||||
(RelayUrl::parse("wss://relay.damus.io").unwrap(), None),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// Default NIP-17 Relays. Used for new account
|
||||
pub fn default_nip17_relays() -> &'static Vec<RelayUrl> {
|
||||
NIP17_RELAYS.get_or_init(|| {
|
||||
vec![
|
||||
RelayUrl::parse("wss://nip17.com").unwrap(),
|
||||
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
31
crates/common/src/nip05.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use anyhow::anyhow;
|
||||
use nostr::prelude::*;
|
||||
use reqwest::Client as ReqClient;
|
||||
|
||||
pub async fn nip05_verify(public_key: PublicKey, address: &str) -> Result<bool, anyhow::Error> {
|
||||
let req_client = ReqClient::new();
|
||||
let address = Nip05Address::parse(address)?;
|
||||
|
||||
// Get NIP-05 response
|
||||
let res = req_client.get(address.url().to_string()).send().await?;
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
let verify = nip05::verify_from_json(&public_key, &address, &json);
|
||||
|
||||
Ok(verify)
|
||||
}
|
||||
|
||||
pub async fn nip05_profile(address: &str) -> Result<Nip05Profile, anyhow::Error> {
|
||||
let req_client = ReqClient::new();
|
||||
let address = Nip05Address::parse(address)?;
|
||||
|
||||
// Get NIP-05 response
|
||||
let res = req_client.get(address.url().to_string()).send().await?;
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
if let Ok(profile) = Nip05Profile::from_json(&address, &json) {
|
||||
Ok(profile)
|
||||
} else {
|
||||
Err(anyhow!("Failed to get NIP-05 profile"))
|
||||
}
|
||||
}
|
||||
84
crates/common/src/nip96.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use anyhow::anyhow;
|
||||
use nostr::hashes::sha256::Hash as Sha256Hash;
|
||||
use nostr::hashes::Hash;
|
||||
use nostr::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use reqwest::{multipart, Client as ReqClient, Response};
|
||||
|
||||
pub(crate) fn make_multipart_form(
|
||||
file_data: Vec<u8>,
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<multipart::Form, anyhow::Error> {
|
||||
let form_file_part = multipart::Part::bytes(file_data).file_name("filename");
|
||||
|
||||
// Set the part's MIME type, or leave it as is if mime_type is None
|
||||
|
||||
let part = match mime_type {
|
||||
Some(mime) => form_file_part.mime_str(mime)?,
|
||||
None => form_file_part,
|
||||
};
|
||||
|
||||
Ok(multipart::Form::new().part("file", part))
|
||||
}
|
||||
|
||||
pub(crate) async fn upload<T>(
|
||||
signer: &T,
|
||||
desc: &ServerConfig,
|
||||
file_data: Vec<u8>,
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<Url, anyhow::Error>
|
||||
where
|
||||
T: NostrSigner,
|
||||
{
|
||||
let payload: Sha256Hash = Sha256Hash::hash(&file_data);
|
||||
let data: HttpData = HttpData::new(desc.api_url.clone(), HttpMethod::POST).payload(payload);
|
||||
let nip98_auth: String = data.to_authorization(signer).await?;
|
||||
|
||||
// Make form
|
||||
let form: multipart::Form = make_multipart_form(file_data, mime_type)?;
|
||||
|
||||
// Make req client
|
||||
let req_client = ReqClient::new();
|
||||
|
||||
// Send
|
||||
let response: Response = req_client
|
||||
.post(desc.api_url.clone())
|
||||
.header("Authorization", nip98_auth)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Parse response
|
||||
let json: Value = response.json().await?;
|
||||
let upload_response = nip96::UploadResponse::from_json(json.to_string())?;
|
||||
|
||||
if upload_response.status == UploadResponseStatus::Error {
|
||||
return Err(anyhow!(upload_response.message));
|
||||
}
|
||||
|
||||
Ok(upload_response.download_url()?.to_owned())
|
||||
}
|
||||
|
||||
pub async fn nip96_upload(
|
||||
client: &Client,
|
||||
server: &Url,
|
||||
file: Vec<u8>,
|
||||
) -> Result<Url, anyhow::Error> {
|
||||
let req_client = ReqClient::new();
|
||||
let config_url = nip96::get_server_config_url(server)?;
|
||||
|
||||
// Get
|
||||
let res = req_client.get(config_url.to_string()).send().await?;
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
let config = nip96::ServerConfig::from_json(json.to_string())?;
|
||||
let signer = if client.has_signer().await {
|
||||
client.signer().await?
|
||||
} else {
|
||||
Keys::generate().into_nostr_signer()
|
||||
};
|
||||
|
||||
let url = upload(&signer, &config, file, None).await?;
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
@@ -60,5 +60,5 @@ pub fn support_dir() -> &'static PathBuf {
|
||||
/// Returns the path to the `nostr` file.
|
||||
pub fn nostr_file() -> &'static PathBuf {
|
||||
static NOSTR_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
NOSTR_FILE.get_or_init(|| support_dir().join("nostr"))
|
||||
NOSTR_FILE.get_or_init(|| support_dir().join("nostr-db"))
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use global::constants::IMAGE_SERVICE;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub trait SharedProfile {
|
||||
fn shared_avatar(&self) -> SharedString;
|
||||
fn shared_name(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl SharedProfile for Profile {
|
||||
fn shared_avatar(&self) -> SharedString {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1&default=npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445.blossom.band/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png",
|
||||
IMAGE_SERVICE, picture
|
||||
)
|
||||
.into()
|
||||
})
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
}
|
||||
|
||||
fn shared_name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata().display_name.as_ref() {
|
||||
if !display_name.is_empty() {
|
||||
return display_name.into();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata().name.as_ref() {
|
||||
if !name.is_empty() {
|
||||
return name.into();
|
||||
}
|
||||
}
|
||||
|
||||
let pubkey = self.public_key().to_hex();
|
||||
|
||||
format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into()
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,68 @@
|
||||
[package]
|
||||
name = "coop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "coop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ui = { path = "../ui" }
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
chats = { path = "../chats" }
|
||||
account = { path = "../account" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
|
||||
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"] }
|
||||
[package]
|
||||
name = "coop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "coop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[package.metadata.packager]
|
||||
name = "Coop"
|
||||
product-name = "Coop"
|
||||
description = "Chat Freely, Stay Private on Nostr"
|
||||
identifier = "su.reya.coop"
|
||||
category = "SocialNetworking"
|
||||
version = "0.3.0"
|
||||
out-dir = "../../dist"
|
||||
before-packaging-command = "cargo build --release"
|
||||
resources = ["Cargo.toml", "src"]
|
||||
icons = [
|
||||
"resources/32x32.png",
|
||||
"resources/128x128.png",
|
||||
"resources/128x128@2x.png",
|
||||
"resources/icon.icns",
|
||||
"resources/icon.ico",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
ui = { path = "../ui" }
|
||||
title_bar = { path = "../title_bar" }
|
||||
theme = { path = "../theme" }
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
key_store = { path = "../key_store" }
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
settings = { path = "../settings" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
account = { path = "../account" }
|
||||
encryption = { path = "../encryption" }
|
||||
encryption_ui = { path = "../encryption_ui" }
|
||||
person = { path = "../person" }
|
||||
relay_auth = { path = "../relay_auth" }
|
||||
|
||||
rust-i18n.workspace = true
|
||||
i18n.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
itertools.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
oneshot.workspace = true
|
||||
webbrowser.workspace = true
|
||||
|
||||
indexset = "0.12.3"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
|
||||
14
crates/coop/resources/coop.desktop.in
Normal file
@@ -0,0 +1,14 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=$APP_NAME
|
||||
GenericName=Chat Application
|
||||
Comment=Chat Freely, Stay Private on Nostr.
|
||||
TryExec=$APP_CLI
|
||||
StartupNotify=$DO_STARTUP_NOTIFY
|
||||
Exec=$APP_CLI $APP_ARGS
|
||||
Icon=$APP_ICON
|
||||
Categories=Network;InstantMessaging;
|
||||
Keywords=chat;messaging;nostr;privacy;
|
||||
MimeType=x-scheme-handler/nostr;x-scheme-handler/coop;
|
||||
Actions=NewChat;
|
||||
87
crates/coop/resources/flatpak/coop.metainfo.xml.in
Normal file
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>$APP_ID</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
|
||||
<name>$APP_NAME</name>
|
||||
<summary>Chat Freely, Stay Private on Nostr</summary>
|
||||
|
||||
<developer id="su.reya">
|
||||
<name translate="no">Ren Amamiya</name>
|
||||
</developer>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Chat freely without sharing phone numbers or emails.
|
||||
</p>
|
||||
<p>
|
||||
Your messages are encrypted, and your privacy stays protected.
|
||||
</p>
|
||||
<p>
|
||||
Just privacy, made simple - with Nostr.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">$APP_ID.desktop</launchable>
|
||||
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">$BRANDING_LIGHT</color>
|
||||
<color type="primary" scheme_preference="dark">$BRANDING_DARK</color>
|
||||
</branding>
|
||||
|
||||
<content_rating type="oars-1.1">
|
||||
<content_attribute id="social-chat">intense</content_attribute>
|
||||
<content_attribute id="social-audio">intense</content_attribute>
|
||||
</content_rating>
|
||||
|
||||
<url type="homepage">https://reya.su/coop</url>
|
||||
<url type="bugtracker">https://github.com/lumehq/coop/issues</url>
|
||||
<url type="faq">https://github.com/lumehq/coop</url>
|
||||
<url type="help">https://github.com/lumehq/coop/issues</url>
|
||||
<url type="contact">https://reya.su/</url>
|
||||
<url type="vcs-browser">https://github.com/lumehq/coop</url>
|
||||
<url type="contribute">https://github.com/lumehq/coop/blob/main/CONTRIBUTING.md</url>
|
||||
|
||||
<supports>
|
||||
<internet>yes</internet>
|
||||
</supports>
|
||||
|
||||
<recommends>
|
||||
<control>pointing</control>
|
||||
<control>keyboard</control>
|
||||
<display_length compare="ge">768</display_length>
|
||||
</recommends>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Coop with the default screen, showing current user's chat rooms</caption>
|
||||
<image>https://npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445.blossom.band/07b03b8c269b67eedcd265d11ebe7384ba797364056a60105cd02cf34de4eff2.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Example of chat room's message list</caption>
|
||||
<image>https://npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445.blossom.band/4125cd78696274e92c53c09ac2fed0c8c7a74912dca6ab80bef21badd6ae4232.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Coop in Dark Mode with split panels feature</caption>
|
||||
<image>https://npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445.blossom.band/9cea2a2d5d18ec6472d357f7288f283dc43c672580956cef3242f0d07e214691.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Example of screening chat request</caption>
|
||||
<image>https://npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445.blossom.band/6f1c32b150788b706dd29a311bd7e342c63387013c0b084392b2e5a05c7908d5.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Example of composing a new message</caption>
|
||||
<image>https://npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445.blossom.band/9be26d1132de3feff0a518cb17afd8319e421ed2cfb0035cb47aceba928bf96d.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
@release_info@
|
||||
<release version="0.0.0" date="1970-01-01">
|
||||
<description>
|
||||
<p>Dummy release to keep flatpak-builder AppStream metadata validation from complaining</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
58
crates/coop/resources/flatpak/manifest-template.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"id": "$APP_ID",
|
||||
"runtime": "org.freedesktop.Platform",
|
||||
"runtime-version": "24.08",
|
||||
"sdk": "org.freedesktop.Sdk",
|
||||
"sdk-extensions": ["org.freedesktop.Sdk.Extension.rust-stable"],
|
||||
"command": "coop",
|
||||
"finish-args": [
|
||||
"--talk-name=org.freedesktop.Flatpak",
|
||||
"--device=dri",
|
||||
"--share=ipc",
|
||||
"--share=network",
|
||||
"--socket=wayland",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=pulseaudio",
|
||||
"--filesystem=host"
|
||||
],
|
||||
"build-options": {
|
||||
"append-path": "/usr/lib/sdk/rust-stable/bin"
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"name": "coop",
|
||||
"buildsystem": "simple",
|
||||
"build-options": {
|
||||
"env": {
|
||||
"APP_ID": "$APP_ID",
|
||||
"APP_ICON": "$APP_ID",
|
||||
"APP_NAME": "$APP_NAME",
|
||||
"BRANDING_LIGHT": "$BRANDING_LIGHT",
|
||||
"BRANDING_DARK": "$BRANDING_DARK",
|
||||
"APP_CLI": "coop",
|
||||
"APP_ARGS": "--foreground %U",
|
||||
"DO_STARTUP_NOTIFY": "false"
|
||||
}
|
||||
},
|
||||
"build-commands": [
|
||||
"install -Dm644 $ICON_FILE.png /app/share/icons/hicolor/512x512/apps/$APP_ID.png",
|
||||
"envsubst < coop.desktop.in > coop.desktop && install -Dm644 coop.desktop /app/share/applications/$APP_ID.desktop",
|
||||
"envsubst < flatpak/coop.metainfo.xml.in > coop.metainfo.xml && install -Dm644 coop.metainfo.xml /app/share/metainfo/$APP_ID.metainfo.xml",
|
||||
"sed -i -e '/@release_info@/{r flatpak/release-info/$CHANNEL' -e 'd}' /app/share/metainfo/$APP_ID.metainfo.xml",
|
||||
"install -Dm755 bin/coop /app/bin/coop",
|
||||
"install -Dm755 libexec/coop /app/libexec/coop",
|
||||
"install -Dm755 lib/* -t /app/lib"
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"path": "./target/release/$ARCHIVE"
|
||||
},
|
||||
{
|
||||
"type": "dir",
|
||||
"path": "./crates/coop/resources"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
BIN
crates/coop/resources/icon@2x.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
60
crates/coop/resources/snap/snapcraft.yaml.in
Normal file
@@ -0,0 +1,60 @@
|
||||
name: coop
|
||||
title: Coop
|
||||
base: core24
|
||||
version: "$RELEASE_VERSION"
|
||||
summary: Chat Freely, Stay Private on Nostr
|
||||
description: |
|
||||
Chat freely without sharing phone numbers or emails.
|
||||
Your messages are encrypted, and your privacy stays protected.
|
||||
Just privacy, made simple - with Nostr.
|
||||
grade: stable
|
||||
confinement: classic
|
||||
compression: lzo
|
||||
website: https://reya.su/coop
|
||||
source-code: https://github.com/lumehq/coop
|
||||
issues: https://github.com/lumehq/coop/issues
|
||||
contact: https://reya.su
|
||||
|
||||
parts:
|
||||
coop:
|
||||
plugin: dump
|
||||
source: target/release/coop-linux-$ARCH_SUFFIX.tar.gz
|
||||
|
||||
organize:
|
||||
# These renames seem to not be necessary, but it's tidier.
|
||||
bin: usr/bin
|
||||
libexec: usr/libexec
|
||||
|
||||
stage-packages:
|
||||
- libasound2t64
|
||||
# snapcraft has a lint that this is unused, but without it Coop exits with
|
||||
# "Missing Vulkan entry points: LibraryLoadFailure" in blade_graphics.
|
||||
- libvulkan1
|
||||
# snapcraft has a lint that this is unused, but without it Coop exits with
|
||||
# "NoWaylandLib" when run with Wayland.
|
||||
- libwayland-client0
|
||||
- libxcb1
|
||||
- libxkbcommon-x11-0
|
||||
- libxkbcommon0
|
||||
|
||||
build-attributes:
|
||||
- enable-patchelf
|
||||
|
||||
prime:
|
||||
# Omit unneeded files from the tarball
|
||||
- -lib
|
||||
- -licenses.md
|
||||
- -share
|
||||
|
||||
# Omit unneeded files from stage-packages
|
||||
- -etc
|
||||
- -usr/share/doc
|
||||
- -usr/share/lintian
|
||||
- -usr/share/man
|
||||
|
||||
apps:
|
||||
coop:
|
||||
command: usr/bin/coop
|
||||
common-id: su.reya.coop
|
||||
environment:
|
||||
COOP_BUNDLE_TYPE: snap
|
||||
94
crates/coop/src/actions.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use gpui::{actions, App};
|
||||
use key_store::{KeyItem, KeyStore};
|
||||
use nostr_connect::prelude::*;
|
||||
use state::NostrRegistry;
|
||||
|
||||
// Sidebar actions
|
||||
actions!(sidebar, [Reload, RelayStatus]);
|
||||
|
||||
// User actions
|
||||
actions!(
|
||||
coop,
|
||||
[
|
||||
KeyringPopup,
|
||||
DarkMode,
|
||||
ViewProfile,
|
||||
ViewRelays,
|
||||
Settings,
|
||||
Logout,
|
||||
Quit
|
||||
]
|
||||
);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
#[allow(mismatched_lifetime_syntaxes)]
|
||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
||||
Box::pin(async move {
|
||||
log::info!("Received Auth URL: {auth_url}");
|
||||
webbrowser::open(auth_url.as_str())?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_embedded_fonts(cx: &App) {
|
||||
let asset_source = cx.asset_source();
|
||||
let font_paths = asset_source.list("fonts").unwrap();
|
||||
let embedded_fonts = Mutex::new(Vec::new());
|
||||
let executor = cx.background_executor();
|
||||
|
||||
executor.block(executor.scoped(|scope| {
|
||||
for font_path in &font_paths {
|
||||
if !font_path.ends_with(".ttf") {
|
||||
continue;
|
||||
}
|
||||
|
||||
scope.spawn(async {
|
||||
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
|
||||
embedded_fonts.lock().unwrap().push(font_bytes);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
cx.text_system()
|
||||
.add_fonts(embedded_fonts.into_inner().unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn reset(cx: &mut App) {
|
||||
let backend = KeyStore::global(cx).read(cx).backend();
|
||||
let client = NostrRegistry::global(cx).read(cx).client();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
// Remove the signer
|
||||
client.unset_signer().await;
|
||||
|
||||
// Delete user's credentials
|
||||
backend
|
||||
.delete_credentials(&KeyItem::User.to_string(), cx)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Remove bunker's credentials if available
|
||||
backend
|
||||
.delete_credentials(&KeyItem::Bunker.to_string(), cx)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
cx.update(|cx| {
|
||||
cx.restart();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn quit(_: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
}
|
||||
@@ -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,35 +1,45 @@
|
||||
use account::Account;
|
||||
use anyhow::Error;
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
div, image_cache, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
|
||||
Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription,
|
||||
Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::{dock::DockPlacement, panel::PanelView, DockArea, DockItem},
|
||||
theme::{ActiveTheme, Appearance, Theme},
|
||||
ContextModal, IconName, Root, Sizable, TitleBar,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
lru_cache::cache_provider,
|
||||
views::{
|
||||
chat, compose, contacts, login, new_account, onboarding, profile, relays, sidebar, welcome,
|
||||
},
|
||||
use account::Account;
|
||||
use auto_update::{AutoUpdateStatus, AutoUpdater};
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
||||
use common::{RenderedProfile, DEFAULT_SIDEBAR_WIDTH};
|
||||
use encryption::Encryption;
|
||||
use encryption_ui::EncryptionPanel;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
deferred, div, px, rems, App, AppContext, Axis, ClipboardItem, Context, Entity,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Window,
|
||||
};
|
||||
use i18n::{shared_t, t};
|
||||
use key_store::{Credential, KeyItem, KeyStore};
|
||||
use nostr_connect::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use relay_auth::RelayAuth;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::{ActiveTheme, Theme, ThemeMode};
|
||||
use title_bar::TitleBar;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::dock::DockPlacement;
|
||||
use ui::dock_area::panel::PanelView;
|
||||
use ui::dock_area::{ClosePanel, DockArea, DockItem};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::popover::{Popover, PopoverContent};
|
||||
use ui::popup_menu::PopupMenuExt;
|
||||
use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt};
|
||||
|
||||
const CACHE_SIZE: usize = 200;
|
||||
const MODAL_WIDTH: f32 = 420.;
|
||||
const SIDEBAR_WIDTH: f32 = 280.;
|
||||
use crate::actions::{reset, DarkMode, KeyringPopup, Logout, Settings, ViewProfile, ViewRelays};
|
||||
use crate::user::viewer;
|
||||
use crate::views::compose::compose_button;
|
||||
use crate::views::{onboarding, preferences, setup_relay, startup, welcome};
|
||||
use crate::{login, new_identity, sidebar, user};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
|
||||
ChatSpace::new(window, cx)
|
||||
cx.new(|cx| ChatSpace::new(window, cx))
|
||||
}
|
||||
|
||||
pub fn login(window: &mut Window, cx: &mut App) {
|
||||
@@ -38,238 +48,337 @@ pub fn login(window: &mut Window, cx: &mut App) {
|
||||
}
|
||||
|
||||
pub fn new_account(window: &mut Window, cx: &mut App) {
|
||||
let panel = new_account::init(window, cx);
|
||||
let panel = new_identity::init(window, cx);
|
||||
ChatSpace::set_center_panel(panel, window, cx);
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum PanelKind {
|
||||
Room(u64),
|
||||
// More kind will be added here
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum ModalKind {
|
||||
Profile,
|
||||
Compose,
|
||||
Contact,
|
||||
Relay,
|
||||
SetupRelay,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct ToggleModal {
|
||||
pub modal: ModalKind,
|
||||
}
|
||||
|
||||
impl_internal_actions!(dock, [AddPanel, ToggleModal]);
|
||||
|
||||
#[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 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ChatSpace {
|
||||
titlebar: bool,
|
||||
/// App's Title Bar
|
||||
title_bar: Entity<TitleBar>,
|
||||
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
#[allow(unused)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
/// App's Encryption Panel
|
||||
encryption_panel: Entity<EncryptionPanel>,
|
||||
|
||||
/// Determines if the chat space is ready to use
|
||||
ready: bool,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
|
||||
impl ChatSpace {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let dock = cx.new(|cx| {
|
||||
let panel = Arc::new(onboarding::init(window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
let mut dock = DockArea::new(window, cx);
|
||||
// Initialize the dock area with the center panel
|
||||
dock.set_center(center, window, cx);
|
||||
dock
|
||||
});
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let keystore = KeyStore::global(cx);
|
||||
let account = Account::global(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let account = Account::global(cx);
|
||||
let mut subscriptions = smallvec![];
|
||||
let title_bar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
let encryption_panel = encryption_ui::init(window, cx);
|
||||
|
||||
subscriptions.push(cx.observe_in(
|
||||
&account,
|
||||
window,
|
||||
|this: &mut ChatSpace, account, window, cx| {
|
||||
if account.read(cx).profile.is_some() {
|
||||
this.open_chats(window, cx);
|
||||
} else {
|
||||
this.open_onboarding(window, cx);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Automatically sync theme with system appearance
|
||||
window.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe account entity changes
|
||||
cx.observe_in(&account, window, move |this, state, window, cx| {
|
||||
if !this.ready && state.read(cx).has_account() {
|
||||
this.set_default_layout(window, cx);
|
||||
|
||||
// Load all chat room in the database if available
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
});
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe keystore entity changes
|
||||
cx.observe_in(&keystore, window, move |_this, state, window, cx| {
|
||||
if state.read(cx).initialized {
|
||||
let backend = state.read(cx).backend();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = backend
|
||||
.read_credentials(&KeyItem::User.to_string(), cx)
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(Some((user, secret))) => {
|
||||
let credential = Credential::new(user, secret);
|
||||
this.set_startup_layout(credential, window, cx);
|
||||
}
|
||||
_ => {
|
||||
this.set_onboarding_layout(window, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the chat registry
|
||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||
match ev {
|
||||
ChatEvent::OpenRoom(id) => {
|
||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
let panel = chat_ui::init(room, window, cx);
|
||||
this.add_panel(Arc::new(panel), DockPlacement::Center, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
ChatEvent::CloseRoom(..) => {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.focus_tab_panel(window, cx);
|
||||
|
||||
Self {
|
||||
dock,
|
||||
subscriptions,
|
||||
titlebar: false,
|
||||
}
|
||||
})
|
||||
cx.defer_in(window, |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the chat registry
|
||||
cx.observe(&chat, move |this, chat, cx| {
|
||||
let ids = this.get_all_panels(cx);
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
this.refresh_rooms(ids, cx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
dock,
|
||||
title_bar,
|
||||
encryption_panel,
|
||||
ready: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn show_titlebar(&mut self, cx: &mut Context<Self>) {
|
||||
self.titlebar = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn set_onboarding_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let panel = Arc::new(onboarding::init(window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.reset(window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn open_chats(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.show_titlebar(cx);
|
||||
fn set_startup_layout(&mut self, cre: Credential, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let panel = Arc::new(startup::init(cre, window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.reset(window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn set_default_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let weak_dock = self.dock.downgrade();
|
||||
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
|
||||
let sidebar = Arc::new(sidebar::init(window, cx));
|
||||
let center = Arc::new(welcome::init(window, cx));
|
||||
|
||||
let left = DockItem::panel(sidebar);
|
||||
let center = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(
|
||||
vec![Arc::new(welcome::init(window, cx))],
|
||||
None,
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
)],
|
||||
vec![DockItem::tabs(vec![center], None, &weak_dock, window, cx)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
self.ready = true;
|
||||
self.dock.update(cx, |this, cx| {
|
||||
this.set_left_dock(left, Some(px(SIDEBAR_WIDTH)), true, window, cx);
|
||||
this.set_left_dock(left, Some(px(DEFAULT_SIDEBAR_WIDTH)), true, window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
let verify_messaging_relays = this.verify_messaging_relays(cx);
|
||||
fn on_settings(&mut self, _ev: &Settings, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = preferences::init(window, cx);
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
if let Ok(status) = verify_messaging_relays.await {
|
||||
if !status {
|
||||
cx.update(|window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ToggleModal {
|
||||
modal: ModalKind::SetupRelay,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
modal
|
||||
.title(shared_t!("common.preferences"))
|
||||
.width(px(520.))
|
||||
.child(view.clone())
|
||||
});
|
||||
}
|
||||
|
||||
fn verify_messaging_relays(&self, cx: &App) -> Task<Result<bool, Error>> {
|
||||
cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
fn on_profile(&mut self, _ev: &ViewProfile, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = user::init(window, cx);
|
||||
let entity = view.downgrade();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
let entity = entity.clone();
|
||||
|
||||
let exist = client.database().query(filter).await?.first().is_some();
|
||||
modal
|
||||
.title("Profile")
|
||||
.confirm()
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text("Update"))
|
||||
.on_ok(move |_, window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let set_metadata = this.set_metadata(cx);
|
||||
|
||||
Ok(exist)
|
||||
})
|
||||
}
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = set_metadata.await;
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
match result {
|
||||
Ok(profile) => {
|
||||
persons.update(cx, |this, cx| {
|
||||
this.insert_or_update_person(profile, cx);
|
||||
// Close the edit profile modal
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
|
||||
fn on_modal_action(
|
||||
&mut self,
|
||||
action: &ToggleModal,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match action.modal {
|
||||
ModalKind::Profile => {
|
||||
let profile = profile::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |modal, _, _| {
|
||||
modal
|
||||
.title("Profile")
|
||||
.width(px(MODAL_WIDTH))
|
||||
.child(profile.clone())
|
||||
// false to keep the modal open
|
||||
false
|
||||
})
|
||||
}
|
||||
ModalKind::Compose => {
|
||||
let compose = compose::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |modal, _, _| {
|
||||
modal
|
||||
.title("Direct Messages")
|
||||
.width(px(MODAL_WIDTH))
|
||||
.child(compose.clone())
|
||||
})
|
||||
}
|
||||
ModalKind::Contact => {
|
||||
let contacts = contacts::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(MODAL_WIDTH))
|
||||
.title("Contacts")
|
||||
.child(contacts.clone())
|
||||
});
|
||||
}
|
||||
ModalKind::Relay => {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _, _| {
|
||||
this.width(px(MODAL_WIDTH))
|
||||
.title("Edit your Messaging Relays")
|
||||
.child(relays.clone())
|
||||
});
|
||||
}
|
||||
ModalKind::SetupRelay => {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _, _| {
|
||||
this.width(px(MODAL_WIDTH))
|
||||
.title("Your Messaging Relays are not configured")
|
||||
.child(relays.clone())
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
|
||||
fn on_relays(&mut self, _ev: &ViewRelays, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = setup_relay::init(window, cx);
|
||||
let entity = view.downgrade();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let entity = entity.clone();
|
||||
|
||||
this.confirm()
|
||||
.title(shared_t!("relays.modal"))
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
|
||||
.on_ok(move |_, window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.set_relays(window, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// false to keep the modal open
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn on_dark_mode(&mut self, _ev: &DarkMode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if cx.theme().mode.is_dark() {
|
||||
Theme::change(ThemeMode::Light, Some(window), cx);
|
||||
} else {
|
||||
Theme::change(ThemeMode::Dark, Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_sign_out(&mut self, _e: &Logout, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
reset(cx);
|
||||
}
|
||||
|
||||
fn on_open_pubkey(&mut self, ev: &OpenPublicKey, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let public_key = ev.0;
|
||||
let view = viewer::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.alert()
|
||||
.show_close(true)
|
||||
.overlay_closable(true)
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text("View on njump.me"))
|
||||
.on_ok(move |_, _window, cx| {
|
||||
let bech32 = public_key.to_bech32().unwrap();
|
||||
let url = format!("https://njump.me/{bech32}");
|
||||
|
||||
// Open the URL in the default browser
|
||||
cx.open_url(&url);
|
||||
|
||||
// false to keep the modal open
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn on_copy_pubkey(&mut self, ev: &CopyPublicKey, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = ev.0.to_bech32();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(bech32));
|
||||
window.push_notification(t!("common.copied"), cx);
|
||||
}
|
||||
|
||||
fn on_keyring(&mut self, _ev: &KeyringPopup, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.show_close(true)
|
||||
.title(shared_t!("keyring_disable.label"))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.pb_4()
|
||||
.text_sm()
|
||||
.child(shared_t!("keyring_disable.body_1"))
|
||||
.child(shared_t!("keyring_disable.body_2"))
|
||||
.child(shared_t!("keyring_disable.body_3")),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn get_all_panels(&self, cx: &App) -> Option<Vec<u64>> {
|
||||
let ids: Vec<u64> = self
|
||||
.dock
|
||||
.read(cx)
|
||||
.items
|
||||
.panel_ids(cx)
|
||||
.into_iter()
|
||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
||||
.collect();
|
||||
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
fn set_center_panel<P>(panel: P, window: &mut Window, cx: &mut App)
|
||||
where
|
||||
P: PanelView,
|
||||
{
|
||||
if let Some(Some(root)) = window.root::<Root>() {
|
||||
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
|
||||
let panel = Arc::new(panel);
|
||||
@@ -283,6 +392,233 @@ impl ChatSpace {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn titlebar_left(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
||||
let account = Account::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let status = chat.read(cx).loading;
|
||||
|
||||
if !account.read(cx).has_account() {
|
||||
return div();
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.h_6()
|
||||
.w_full()
|
||||
.child(compose_button())
|
||||
.when(status, |this| {
|
||||
this.child(deferred(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.h_6()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().surface_background)
|
||||
.child(shared_t!("loading.label")),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let auto_update = AutoUpdater::global(cx);
|
||||
let account = Account::global(cx);
|
||||
let relay_auth = RelayAuth::global(cx);
|
||||
let pending_requests = relay_auth.read(cx).pending_requests(cx);
|
||||
let encryption_panel = self.encryption_panel.downgrade();
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.map(|this| match auto_update.read(cx).status.as_ref() {
|
||||
AutoUpdateStatus::Checking => this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Checking for Coop updates...")),
|
||||
),
|
||||
AutoUpdateStatus::Installing => this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Installing updates...")),
|
||||
),
|
||||
AutoUpdateStatus::Errored { msg } => this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(msg.as_ref())),
|
||||
),
|
||||
AutoUpdateStatus::Updated => this.child(
|
||||
div()
|
||||
.id("restart")
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Updated. Click to restart"))
|
||||
.on_click(|_ev, _window, cx| {
|
||||
cx.restart();
|
||||
}),
|
||||
),
|
||||
_ => this.child(div()),
|
||||
})
|
||||
.when(pending_requests > 0, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.id("requests")
|
||||
.h_6()
|
||||
.px_2()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.hover(|this| this.bg(cx.theme().warning_hover))
|
||||
.active(|this| this.bg(cx.theme().warning_active))
|
||||
.child(shared_t!("auth.requests", u = pending_requests))
|
||||
.on_click(move |_ev, window, cx| {
|
||||
relay_auth.update(cx, |this, cx| {
|
||||
this.re_ask(window, cx);
|
||||
});
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(account.read(cx).has_account(), |this| {
|
||||
let account = Account::global(cx);
|
||||
let public_key = account.read(cx).public_key();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get_person(&public_key, cx);
|
||||
|
||||
let encryption = Encryption::global(cx);
|
||||
let has_encryption = encryption.read(cx).has_encryption(cx);
|
||||
|
||||
let keystore = KeyStore::global(cx);
|
||||
let is_using_file_keystore = keystore.read(cx).is_using_file_keystore();
|
||||
|
||||
let keyring_label = if is_using_file_keystore {
|
||||
SharedString::from("Disabled")
|
||||
} else {
|
||||
SharedString::from("Enabled")
|
||||
};
|
||||
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Popover::new("encryption")
|
||||
.trigger(
|
||||
Button::new("encryption-trigger")
|
||||
.tooltip("Manage Encryption Key")
|
||||
.icon(IconName::Encryption)
|
||||
.rounded()
|
||||
.small()
|
||||
.cta()
|
||||
.map(|this| match has_encryption {
|
||||
true => this.ghost_alt(),
|
||||
false => this.warning(),
|
||||
}),
|
||||
)
|
||||
.content(move |window, cx| {
|
||||
let encryption_panel = encryption_panel.clone();
|
||||
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, move |_window, _cx| {
|
||||
if let Some(view) = encryption_panel.upgrade() {
|
||||
view.clone().into_any_element()
|
||||
} else {
|
||||
div().into_any_element()
|
||||
}
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("user")
|
||||
.small()
|
||||
.reverse()
|
||||
.transparent()
|
||||
.icon(IconName::CaretDown)
|
||||
.child(Avatar::new(profile.avatar(proxy)).size(rems(1.45)))
|
||||
.popup_menu(move |this, _window, _cx| {
|
||||
this.label(profile.display_name())
|
||||
.menu_with_icon(
|
||||
"Profile",
|
||||
IconName::EmojiFill,
|
||||
Box::new(ViewProfile),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Messaging Relays",
|
||||
IconName::Server,
|
||||
Box::new(ViewRelays),
|
||||
)
|
||||
.separator()
|
||||
.label(SharedString::from("Keyring Service"))
|
||||
.menu_with_icon_and_disabled(
|
||||
keyring_label.clone(),
|
||||
IconName::Encryption,
|
||||
Box::new(KeyringPopup),
|
||||
!is_using_file_keystore,
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Dark Mode",
|
||||
IconName::Sun,
|
||||
Box::new(DarkMode),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Settings",
|
||||
IconName::Settings,
|
||||
Box::new(Settings),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Sign Out",
|
||||
IconName::Logout,
|
||||
Box::new(Logout),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn titlebar_center(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let entity = cx.entity().downgrade();
|
||||
let panel = self.dock.read(cx).items.view();
|
||||
let title = panel.title(cx);
|
||||
let id = panel.panel_id(cx);
|
||||
|
||||
if id == "Onboarding" {
|
||||
return div();
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.text_sm()
|
||||
.child(
|
||||
div().flex_1().child(
|
||||
Button::new("back")
|
||||
.icon(IconName::ArrowLeft)
|
||||
.small()
|
||||
.ghost_alt()
|
||||
.rounded()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.set_onboarding_layout(window, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().child(title))
|
||||
.child(div().flex_1())
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ChatSpace {
|
||||
@@ -290,73 +626,43 @@ impl Render for ChatSpace {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
let left = self.titlebar_left(window, cx).into_any_element();
|
||||
let right = self.titlebar_right(window, cx).into_any_element();
|
||||
let center = self.titlebar_center(cx).into_any_element();
|
||||
let single_panel = self.dock.read(cx).items.panel_ids(cx).is_empty();
|
||||
|
||||
// Update title bar children
|
||||
self.title_bar.update(cx, |this, _cx| {
|
||||
if single_panel {
|
||||
this.set_children(vec![center]);
|
||||
} else {
|
||||
this.set_children(vec![left, right]);
|
||||
}
|
||||
});
|
||||
|
||||
div()
|
||||
.id(SharedString::from("chatspace"))
|
||||
.on_action(cx.listener(Self::on_settings))
|
||||
.on_action(cx.listener(Self::on_profile))
|
||||
.on_action(cx.listener(Self::on_relays))
|
||||
.on_action(cx.listener(Self::on_dark_mode))
|
||||
.on_action(cx.listener(Self::on_sign_out))
|
||||
.on_action(cx.listener(Self::on_open_pubkey))
|
||||
.on_action(cx.listener(Self::on_copy_pubkey))
|
||||
.on_action(cx.listener(Self::on_keyring))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
image_cache(cache_provider("image-cache", CACHE_SIZE))
|
||||
v_flex()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.when(self.titlebar, |this| {
|
||||
this.child(
|
||||
TitleBar::new()
|
||||
// Left side
|
||||
.child(div())
|
||||
// Right side
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(
|
||||
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,
|
||||
);
|
||||
}
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
),
|
||||
// Title Bar
|
||||
.child(self.title_bar.clone())
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
// Notifications
|
||||
.child(div().absolute().top_8().children(notification_layer))
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
// Actions
|
||||
.on_action(cx.listener(Self::on_panel_action))
|
||||
.on_action(cx.listener(Self::on_modal_action))
|
||||
}
|
||||
}
|
||||
|
||||
428
crates/coop/src/login/mod.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use common::BUNKER_TIMEOUT;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use i18n::{shared_t, t};
|
||||
use key_store::{KeyItem, KeyStore};
|
||||
use nostr_connect::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::notification::Notification;
|
||||
use ui::{v_flex, ContextModal, Disableable, StyledExt};
|
||||
|
||||
use crate::actions::CoopAuthUrlHandler;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
||||
cx.new(|cx| Login::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Login {
|
||||
key_input: Entity<InputState>,
|
||||
pass_input: Entity<InputState>,
|
||||
error: Entity<Option<SharedString>>,
|
||||
countdown: Entity<Option<u64>>,
|
||||
require_password: bool,
|
||||
logging_in: bool,
|
||||
|
||||
/// Panel
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Login {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let key_input = cx.new(|cx| InputState::new(window, cx));
|
||||
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
|
||||
let error = cx.new(|_| None);
|
||||
let countdown = cx.new(|_| None);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to key input events and process login when the user presses enter
|
||||
cx.subscribe_in(&key_input, window, |this, input, event, window, cx| {
|
||||
match event {
|
||||
InputEvent::PressEnter { .. } => {
|
||||
this.login(window, cx);
|
||||
}
|
||||
InputEvent::Change => {
|
||||
if input.read(cx).value().starts_with("ncryptsec1") {
|
||||
this.require_password = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
key_input,
|
||||
pass_input,
|
||||
error,
|
||||
countdown,
|
||||
name: "Welcome Back".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
logging_in: false,
|
||||
require_password: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.logging_in {
|
||||
return;
|
||||
};
|
||||
|
||||
// Prevent duplicate login requests
|
||||
self.set_logging_in(true, cx);
|
||||
|
||||
let value = self.key_input.read(cx).value();
|
||||
let password = self.pass_input.read(cx).value();
|
||||
|
||||
if value.starts_with("bunker://") {
|
||||
self.login_with_bunker(&value, window, cx);
|
||||
} else if value.starts_with("ncryptsec1") {
|
||||
self.login_with_password(&value, &password, cx);
|
||||
} else if value.starts_with("nsec1") {
|
||||
if let Ok(secret) = SecretKey::parse(&value) {
|
||||
let keys = Keys::new(secret);
|
||||
self.login_with_keys(keys, cx);
|
||||
} else {
|
||||
self.set_error("Invalid", cx);
|
||||
}
|
||||
} else {
|
||||
self.set_error("Invalid", cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(uri) = NostrConnectUri::parse(content) else {
|
||||
self.set_error(t!("login.bunker_invalid"), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let app_keys = Keys::generate();
|
||||
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
|
||||
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||
|
||||
// Handle auth url with the default browser
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
// Start countdown
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
for i in (0..=BUNKER_TIMEOUT).rev() {
|
||||
if i == 0 {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(None, cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_countdown(Some(i), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle connection
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = signer.bunker_uri().await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(uri) => {
|
||||
this.save_connection(&app_keys, &uri, window, cx);
|
||||
this.connect(signer, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn save_connection(
|
||||
&mut self,
|
||||
keys: &Keys,
|
||||
uri: &NostrConnectUri,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||
let username = keys.public_key().to_hex();
|
||||
let secret = keys.secret_key().to_secret_bytes();
|
||||
let mut clean_uri = uri.to_string();
|
||||
|
||||
// Clear the secret parameter in the URI if it exists
|
||||
if let Some(s) = uri.secret() {
|
||||
clean_uri = clean_uri.replace(s, "");
|
||||
}
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let user_url = KeyItem::User.to_string();
|
||||
let bunker_url = KeyItem::Bunker.to_string();
|
||||
let user_password = clean_uri.into_bytes();
|
||||
|
||||
// Write bunker uri to keyring for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&user_url, "bunker", &user_password, cx)
|
||||
.await
|
||||
{
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Write the app keys for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&bunker_url, &username, &secret, cx)
|
||||
.await
|
||||
{
|
||||
this.update_in(cx, |_, window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
client.set_signer(signer).await;
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context<Self>) {
|
||||
if pwd.is_empty() {
|
||||
self.set_error("Password is required", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
|
||||
self.set_error("Secret Key is invalid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let password = pwd.to_owned();
|
||||
|
||||
// Decrypt in the background to ensure it doesn't block the UI
|
||||
let task = cx.background_spawn(async move {
|
||||
if let Ok(content) = enc.decrypt(&password) {
|
||||
Ok(Keys::new(content))
|
||||
} else {
|
||||
Err(anyhow!("Invalid password"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
match result {
|
||||
Ok(keys) => {
|
||||
this.login_with_keys(keys, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
this.set_error(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context<Self>) {
|
||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let username = keys.public_key().to_hex();
|
||||
let secret = keys.secret_key().to_secret_hex().into_bytes();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let bunker_url = KeyItem::User.to_string();
|
||||
|
||||
// Write the app keys for further connection
|
||||
if let Err(e) = keystore
|
||||
.write_credentials(&bunker_url, &username, &secret, cx)
|
||||
.await
|
||||
{
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Update the signer
|
||||
cx.background_spawn(async move {
|
||||
client.set_signer(keys).await;
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||
where
|
||||
S: Into<SharedString>,
|
||||
{
|
||||
// Reset the log in state
|
||||
self.set_logging_in(false, cx);
|
||||
|
||||
// Reset the countdown
|
||||
self.set_countdown(None, cx);
|
||||
|
||||
// Update error message
|
||||
self.error.update(cx, |this, cx| {
|
||||
*this = Some(message.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Clear the error message after 3 secs
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.logging_in = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
||||
self.countdown.update(cx, |this, cx| {
|
||||
*this = i;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Login {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Login {}
|
||||
|
||||
impl Focusable for Login {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Login {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.relative()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
v_flex()
|
||||
.w_96()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.text_xl()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(SharedString::from("Continue with Private Key or Bunker")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("nsec or bunker://")
|
||||
.child(TextInput::new(&self.key_input)),
|
||||
)
|
||||
.when(self.require_password, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Password:")
|
||||
.child(TextInput::new(&self.pass_input)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label(t!("common.continue"))
|
||||
.primary()
|
||||
.loading(self.logging_in)
|
||||
.disabled(self.logging_in)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.login(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("login.approve_message", i = i)),
|
||||
)
|
||||
})
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use futures::FutureExt;
|
||||
use gpui::{
|
||||
hash, AnyImageCache, App, AppContext, Asset, AssetLogger, Context, ElementId, Entity,
|
||||
ImageAssetLoader, ImageCache, ImageCacheProvider, Window,
|
||||
};
|
||||
|
||||
pub fn cache_provider(id: impl Into<ElementId>, max_items: usize) -> LruCacheProvider {
|
||||
LruCacheProvider {
|
||||
id: id.into(),
|
||||
max_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LruCacheProvider {
|
||||
id: ElementId,
|
||||
max_items: usize,
|
||||
}
|
||||
|
||||
impl ImageCacheProvider for LruCacheProvider {
|
||||
fn provide(&mut self, window: &mut Window, cx: &mut App) -> AnyImageCache {
|
||||
window
|
||||
.with_global_id(self.id.clone(), |global_id, window| {
|
||||
window.with_element_state::<Entity<LruCache>, _>(global_id, |lru_cache, _window| {
|
||||
let mut lru_cache =
|
||||
lru_cache.unwrap_or_else(|| cx.new(|cx| LruCache::new(self.max_items, cx)));
|
||||
if lru_cache.read(cx).max_items != self.max_items {
|
||||
lru_cache = cx.new(|cx| LruCache::new(self.max_items, cx));
|
||||
}
|
||||
(lru_cache.clone(), lru_cache)
|
||||
})
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
struct LruCache {
|
||||
max_items: usize,
|
||||
usages: Vec<u64>,
|
||||
cache: HashMap<u64, gpui::ImageCacheItem>,
|
||||
}
|
||||
|
||||
impl LruCache {
|
||||
fn new(max_items: usize, cx: &mut Context<Self>) -> Self {
|
||||
cx.on_release(|simple_cache, cx| {
|
||||
for (_, mut item) in std::mem::take(&mut simple_cache.cache) {
|
||||
if let Some(Ok(image)) = item.get() {
|
||||
cx.drop_image(image, None);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
max_items,
|
||||
usages: Vec::with_capacity(max_items),
|
||||
cache: HashMap::with_capacity(max_items),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCache for LruCache {
|
||||
fn load(
|
||||
&mut self,
|
||||
resource: &gpui::Resource,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<Result<Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
||||
assert_eq!(self.usages.len(), self.cache.len());
|
||||
assert!(self.cache.len() <= self.max_items);
|
||||
|
||||
let hash = hash(resource);
|
||||
|
||||
if let Some(item) = self.cache.get_mut(&hash) {
|
||||
let current_ix = self
|
||||
.usages
|
||||
.iter()
|
||||
.position(|item| *item == hash)
|
||||
.expect("cache and usages must stay in sync");
|
||||
self.usages.remove(current_ix);
|
||||
self.usages.insert(0, hash);
|
||||
|
||||
return item.get();
|
||||
}
|
||||
|
||||
let fut = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
||||
let task = cx.background_executor().spawn(fut).shared();
|
||||
if self.usages.len() == self.max_items {
|
||||
let oldest = self.usages.pop().unwrap();
|
||||
let mut image = self
|
||||
.cache
|
||||
.remove(&oldest)
|
||||
.expect("cache and usages must be in sync");
|
||||
if let Some(Ok(image)) = image.get() {
|
||||
cx.drop_image(image, Some(window));
|
||||
}
|
||||
}
|
||||
self.cache
|
||||
.insert(hash, gpui::ImageCacheItem::Loading(task.clone()));
|
||||
self.usages.insert(0, hash);
|
||||
|
||||
let entity = window.current_view();
|
||||
window
|
||||
.spawn(cx, {
|
||||
async move |cx| {
|
||||
_ = task.await;
|
||||
cx.on_next_frame(move |_, cx| {
|
||||
cx.notify(entity);
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,339 +1,114 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use asset::Assets;
|
||||
use auto_update::AutoUpdater;
|
||||
use chats::ChatRegistry;
|
||||
use futures::{select, FutureExt};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use global::constants::APP_NAME;
|
||||
use global::{
|
||||
constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, NEW_MESSAGE_SUB_ID},
|
||||
get_client,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use assets::Assets;
|
||||
use common::{APP_ID, CLIENT_NAME};
|
||||
use gpui::{
|
||||
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||
WindowBounds, WindowKind, WindowOptions,
|
||||
point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString,
|
||||
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
|
||||
WindowOptions,
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use gpui::{point, SharedString, TitlebarOptions};
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
||||
use nostr_sdk::{
|
||||
nips::nip01::Coordinate, pool::prelude::ReqExitPolicy, Client, Event, EventBuilder, EventId,
|
||||
Filter, JsonUtil, Keys, Kind, Metadata, PublicKey, RelayMessage, RelayPoolNotification,
|
||||
SubscribeAutoCloseOptions, SubscriptionId, Tag,
|
||||
};
|
||||
use smol::Timer;
|
||||
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
|
||||
use ui::{theme::Theme, Root};
|
||||
use ui::Root;
|
||||
|
||||
pub(crate) mod asset;
|
||||
pub(crate) mod chatspace;
|
||||
pub(crate) mod lru_cache;
|
||||
pub(crate) mod views;
|
||||
use crate::actions::{load_embedded_fonts, quit, Quit};
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
mod actions;
|
||||
mod chatspace;
|
||||
mod login;
|
||||
mod new_identity;
|
||||
mod sidebar;
|
||||
mod user;
|
||||
mod views;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Signal {
|
||||
/// Receive event
|
||||
Event(Event),
|
||||
/// Receive metadata
|
||||
Metadata(Box<(PublicKey, Option<Metadata>)>),
|
||||
/// Receive eose
|
||||
Eose,
|
||||
/// Receive app updates
|
||||
AppUpdates(Event),
|
||||
}
|
||||
i18n::init!();
|
||||
|
||||
fn main() {
|
||||
// Enable logging
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt::init();
|
||||
// Fix crash on startup
|
||||
_ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(2048);
|
||||
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(500);
|
||||
|
||||
// Initialize nostr client
|
||||
let client = get_client();
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
// Initialize application
|
||||
// Initialize the 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 move {
|
||||
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
||||
if let Err(e) = client.add_relay(relay).await {
|
||||
log::error!("Failed to add relay {}: {}", relay, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Establish connection to bootstrap relays
|
||||
client.connect().await;
|
||||
|
||||
log::info!("Connected to bootstrap relays");
|
||||
log::info!("Subscribing to app updates...");
|
||||
|
||||
let coordinate = Coordinate {
|
||||
kind: Kind::Custom(32267),
|
||||
public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"),
|
||||
identifier: APP_ID.into(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ReleaseArtifactSet)
|
||||
.coordinate(&coordinate)
|
||||
.limit(1);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe for app updates: {}", e);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle batch metadata
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
const BATCH_SIZE: usize = 500;
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(300);
|
||||
|
||||
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 {
|
||||
sync_metadata(mem::take(&mut batch), client, opts).await;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
_ = timeout => {
|
||||
if !batch.is_empty() {
|
||||
sync_metadata(mem::take(&mut batch), client, opts).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 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 => {
|
||||
let event = match get_unwrapped(event.id).await {
|
||||
Ok(event) => event,
|
||||
Err(_) => match client.unwrap_gift_wrap(&event).await {
|
||||
Ok(unwrap) => {
|
||||
match unwrap.rumor.sign_with_keys(&rng_keys) {
|
||||
Ok(ev) => {
|
||||
set_unwrapped(event.id, &ev, &rng_keys)
|
||||
.await
|
||||
.ok();
|
||||
ev
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
},
|
||||
};
|
||||
|
||||
let mut pubkeys = vec![];
|
||||
pubkeys.extend(event.tags.public_keys());
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
// Send all pubkeys to the batch to sync metadata
|
||||
batch_tx.send(pubkeys).await.ok();
|
||||
|
||||
// Save the event to the database, use for query directly.
|
||||
client.database().save_event(&event).await.ok();
|
||||
|
||||
// Send this event to the GPUI
|
||||
if new_id == *subscription_id {
|
||||
event_tx.send(Signal::Event(event)).await.ok();
|
||||
}
|
||||
}
|
||||
Kind::Metadata => {
|
||||
let metadata = Metadata::from_json(&event.content).ok();
|
||||
|
||||
event_tx
|
||||
.send(Signal::Metadata(Box::new((event.pubkey, metadata))))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Kind::ContactList => {
|
||||
if let Ok(signer) = client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
if public_key == event.pubkey {
|
||||
let pubkeys = event
|
||||
.tags
|
||||
.public_keys()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
batch_tx.send(pubkeys).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::ReleaseArtifactSet => {
|
||||
let filter = Filter::new()
|
||||
.ids(event.tags.event_ids().copied())
|
||||
.kind(Kind::FileMetadata);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe for file metadata: {}", e);
|
||||
} else {
|
||||
event_tx
|
||||
.send(Signal::AppUpdates(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
||||
if all_id == *subscription_id {
|
||||
event_tx.send(Signal::Eose).await.ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Run application
|
||||
app.run(move |cx| {
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
// Load embedded fonts in assets/fonts
|
||||
load_embedded_fonts(cx);
|
||||
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
|
||||
// Register the `quit` function with CMD+Q
|
||||
// Register the `quit` function with CMD+Q (macOS)
|
||||
#[cfg(target_os = "macos")]
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
|
||||
// Register the `quit` function with Super+Q (others)
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
|
||||
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
|
||||
// Set up the window bounds
|
||||
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
|
||||
|
||||
// Set up the window options
|
||||
let opts = WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
window_background: WindowBackgroundAppearance::Opaque,
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
kind: WindowKind::Normal,
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
title: Some(SharedString::new_static(CLIENT_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(920.0), px(700.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();
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
|
||||
// Root Entity
|
||||
cx.new(|cx| {
|
||||
// Initialize the tokio runtime
|
||||
gpui_tokio::init(cx);
|
||||
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
// Initialize backend for keys storage
|
||||
key_store::init(cx);
|
||||
|
||||
// Initialize chat state
|
||||
chats::init(cx);
|
||||
// Initialize the nostr client
|
||||
state::init(cx);
|
||||
|
||||
// Initialize person registry
|
||||
person::init(cx);
|
||||
|
||||
// Initialize settings
|
||||
settings::init(cx);
|
||||
|
||||
// Initialize account state
|
||||
account::init(cx);
|
||||
|
||||
// Spawn a task to handle events from nostr channel
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
while let Ok(signal) = event_rx.recv().await {
|
||||
cx.update(|window, cx| {
|
||||
let chats = ChatRegistry::global(cx);
|
||||
let auto_updater = AutoUpdater::global(cx);
|
||||
// Initialize encryption state
|
||||
encryption::init(cx);
|
||||
|
||||
match signal {
|
||||
Signal::Event(event) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.push_message(event, window, cx)
|
||||
});
|
||||
}
|
||||
Signal::Metadata(data) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.add_profile(data.0, data.1, cx)
|
||||
});
|
||||
}
|
||||
Signal::Eose => {
|
||||
chats.update(cx, |this, cx| {
|
||||
// This function maybe called multiple times
|
||||
// TODO: only handle the last EOSE signal
|
||||
this.load_rooms(window, cx)
|
||||
});
|
||||
}
|
||||
Signal::AppUpdates(event) => {
|
||||
// TODO: add settings for auto updates
|
||||
auto_updater.update(cx, |this, cx| {
|
||||
this.update(event, cx);
|
||||
})
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
// Initialize app registry
|
||||
chat::init(cx);
|
||||
|
||||
// Initialize relay auth registry
|
||||
relay_auth::init(window, cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
|
||||
Root::new(chatspace::init(window, cx).into(), window, cx)
|
||||
})
|
||||
@@ -341,60 +116,3 @@ fn main() {
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
});
|
||||
}
|
||||
|
||||
async fn set_unwrapped(root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> {
|
||||
let client = get_client();
|
||||
let event = EventBuilder::new(Kind::Custom(9001), event.as_json())
|
||||
.tags(vec![Tag::event(root)])
|
||||
.sign(keys)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_unwrapped(gift_wrap: EventId) -> Result<Event, Error> {
|
||||
let client = get_client();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(9001))
|
||||
.event(gift_wrap)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let parsed = Event::from_json(event.content)?;
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err(anyhow!("Event not found"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_metadata(
|
||||
buffer: HashSet<PublicKey>,
|
||||
client: &Client,
|
||||
opts: SubscribeAutoCloseOptions,
|
||||
) {
|
||||
let kinds = vec![
|
||||
Kind::Metadata,
|
||||
Kind::ContactList,
|
||||
Kind::InboxRelays,
|
||||
Kind::UserStatus,
|
||||
];
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(buffer.iter().cloned())
|
||||
.limit(buffer.len() * kinds.len())
|
||||
.kinds(kinds);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, 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();
|
||||
}
|
||||
|
||||
217
crates/coop/src/new_identity/backup.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::home_dir;
|
||||
use gpui::{
|
||||
div, App, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(keys: &Keys, window: &mut Window, cx: &mut App) -> Entity<Backup> {
|
||||
cx.new(|cx| Backup::new(keys, window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Backup {
|
||||
pubkey_input: Entity<InputState>,
|
||||
secret_input: Entity<InputState>,
|
||||
error: Option<SharedString>,
|
||||
copied: bool,
|
||||
|
||||
// Async operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl Backup {
|
||||
pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let Ok(npub) = keys.public_key.to_bech32();
|
||||
let Ok(nsec) = keys.secret_key().to_bech32();
|
||||
|
||||
let pubkey_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.disabled(true)
|
||||
.default_value(npub)
|
||||
});
|
||||
|
||||
let secret_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.disabled(true)
|
||||
.default_value(nsec)
|
||||
});
|
||||
|
||||
Self {
|
||||
pubkey_input,
|
||||
secret_input,
|
||||
error: None,
|
||||
copied: false,
|
||||
_tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backup(&self, window: &Window, cx: &Context<Self>) -> Task<Result<(), Error>> {
|
||||
let dir = home_dir();
|
||||
let path = cx.prompt_for_new_path(dir, Some("My Nostr Account"));
|
||||
let nsec = self.secret_input.read(cx).value().to_string();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match Flatten::flatten(path.await.map_err(|e| e.into())) {
|
||||
Ok(Ok(Some(path))) => {
|
||||
if let Err(e) = smol::fs::write(&path, nsec).await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::error!("Failed to save backup keys");
|
||||
}
|
||||
};
|
||||
|
||||
Err(anyhow!("Failed to backup keys"))
|
||||
})
|
||||
}
|
||||
|
||||
fn copy(&mut self, value: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let item = ClipboardItem::new_string(value.into());
|
||||
cx.write_to_clipboard(item);
|
||||
|
||||
self.set_copied(true, window, cx);
|
||||
}
|
||||
|
||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.copied = status;
|
||||
cx.notify();
|
||||
|
||||
// Reset the copied state after a delay
|
||||
if status {
|
||||
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
|
||||
// Clear the error message after a delay
|
||||
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Backup {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const DESCRIPTION: &str = "In Nostr, your account is defined by a KEY PAIR. These keys are used to sign your messages and identify you.";
|
||||
const WARN: &str = "You must keep the Secret Key in a safe place. If you lose it, you will lose access to your account.";
|
||||
const PK: &str = "Public Key is the address that others will use to find you.";
|
||||
const SK: &str = "Secret Key provides access to your account.";
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(SharedString::from(DESCRIPTION))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(TextInput::new(&self.pubkey_input).small())
|
||||
.child(
|
||||
Button::new("copy-pubkey")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.ghost_alt()
|
||||
.disabled(self.copied)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy(this.pubkey_input.read(cx).value(), window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(PK)),
|
||||
),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Secret Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(TextInput::new(&self.secret_input).small())
|
||||
.child(
|
||||
Button::new("copy-secret")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.ghost_alt()
|
||||
.disabled(self.copied)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy(this.secret_input.read(cx).value(), window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(SK)),
|
||||
),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(SharedString::from(WARN)),
|
||||
)
|
||||
}
|
||||
}
|
||||
353
crates/coop/src/new_identity/mod.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS};
|
||||
use gpui::{
|
||||
rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||
Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use i18n::{shared_t, t};
|
||||
use key_store::{KeyItem, KeyStore};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use smol::fs;
|
||||
use state::NostrRegistry;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable};
|
||||
|
||||
mod backup;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||
cx.new(|cx| NewAccount::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewAccount {
|
||||
name_input: Entity<InputState>,
|
||||
avatar_input: Entity<InputState>,
|
||||
temp_keys: Entity<Keys>,
|
||||
uploading: bool,
|
||||
submitting: bool,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl NewAccount {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let temp_keys = cx.new(|_| Keys::generate());
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||
let avatar_input = cx.new(|cx| InputState::new(window, cx));
|
||||
|
||||
Self {
|
||||
name_input,
|
||||
avatar_input,
|
||||
temp_keys,
|
||||
uploading: false,
|
||||
submitting: false,
|
||||
name: "Create a new identity".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.submitting(true, cx);
|
||||
|
||||
let keys = self.temp_keys.read(cx).clone();
|
||||
let view = backup::init(&keys, window, cx);
|
||||
let weak_view = view.downgrade();
|
||||
let current_view = cx.entity().downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
let current_view = current_view.clone();
|
||||
|
||||
modal
|
||||
.alert()
|
||||
.title(shared_t!("new_account.backup_label"))
|
||||
.child(view.clone())
|
||||
.button_props(
|
||||
ModalButtonProps::default().ok_text(t!("new_account.backup_download")),
|
||||
)
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
let view = current_view.clone();
|
||||
let task = this.backup(window, cx);
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
view.update_in(cx, |this, window, cx| {
|
||||
this.set_signer(window, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to backup: {e}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
// true to close the modal
|
||||
false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_signer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let keys = self.temp_keys.read(cx).clone();
|
||||
let username = keys.public_key().to_hex();
|
||||
let secret = keys.secret_key().to_secret_hex().into_bytes();
|
||||
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
|
||||
|
||||
if let Ok(url) = Url::parse(&avatar) {
|
||||
metadata = metadata.picture(url);
|
||||
};
|
||||
|
||||
// Close all modals if available
|
||||
window.close_all_modals(cx);
|
||||
|
||||
// Set the client's signer with the current keys
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let signer = keys.clone();
|
||||
let nip65_relays = default_nip65_relays();
|
||||
let nip17_relays = default_nip17_relays();
|
||||
|
||||
// Construct a NIP-65 event
|
||||
let event = EventBuilder::new(Kind::RelayList, "")
|
||||
.tags(
|
||||
nip65_relays
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|(url, metadata)| Tag::relay_metadata(url, metadata)),
|
||||
)
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
// Set NIP-65 relays
|
||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
||||
|
||||
// Extract only write relays
|
||||
let write_relays: Vec<RelayUrl> = nip65_relays
|
||||
.iter()
|
||||
.filter_map(|(url, metadata)| {
|
||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Ensure relays are connected
|
||||
for url in write_relays.iter() {
|
||||
client.add_relay(url).await?;
|
||||
client.connect_relay(url).await?;
|
||||
}
|
||||
|
||||
// Construct a NIP-17 event
|
||||
let event = EventBuilder::new(Kind::InboxRelays, "")
|
||||
.tags(nip17_relays.iter().cloned().map(Tag::relay))
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
// Set NIP-17 relays
|
||||
client.send_event_to(&write_relays, &event).await?;
|
||||
|
||||
// Construct a metadata event
|
||||
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
||||
|
||||
// Send metadata event to both write relays and bootstrap relays
|
||||
client.send_event_to(&write_relays, &event).await?;
|
||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
||||
|
||||
// Update the client's signer with the current keys
|
||||
client.set_signer(keys).await;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let url = KeyItem::User.to_string();
|
||||
|
||||
// Write the app keys for further connection
|
||||
keystore
|
||||
.write_credentials(&url, &username, &secret, cx)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
if let Err(e) = task.await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.submitting(false, cx);
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.uploading(true, cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get the user's configured NIP96 server
|
||||
let nip96_server = AppSettings::get_media_server(cx);
|
||||
|
||||
// Open native file dialog
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: None,
|
||||
});
|
||||
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
if let Some(path) = paths.pop() {
|
||||
let file = fs::read(path).await?;
|
||||
let url = nip96_upload(&client, &nip96_server, file).await?;
|
||||
|
||||
Ok(url)
|
||||
} else {
|
||||
Err(anyhow!("Path not found"))
|
||||
}
|
||||
}
|
||||
Ok(None) => Err(anyhow!("User cancelled")),
|
||||
Err(e) => Err(anyhow!("File dialog error: {e}")),
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(Ok(url)) => {
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
this.set_value(url.to_string(), window, cx);
|
||||
});
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to upload avatar: {e}");
|
||||
}
|
||||
};
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for NewAccount {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for NewAccount {}
|
||||
|
||||
impl Focusable for NewAccount {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NewAccount {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar = self.avatar_input.read(cx).value();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
v_flex()
|
||||
.w_96()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.h_40()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_4()
|
||||
.child(Avatar::new(avatar).size(rems(4.25)))
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::PlusCircleFill)
|
||||
.label("Add an avatar")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.disabled(self.uploading)
|
||||
//.loading(self.uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(shared_t!("new_account.name"))
|
||||
.child(
|
||||
TextInput::new(&self.name_input)
|
||||
.disabled(self.submitting)
|
||||
.small(),
|
||||
),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label(t!("common.continue"))
|
||||
.primary()
|
||||
.loading(self.submitting)
|
||||
.disabled(self.submitting || self.uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.create(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
200
crates/coop/src/sidebar/list_item.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::{ChatRegistry, RoomKind};
|
||||
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::context_menu::ContextMenuExt;
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::skeleton::Skeleton;
|
||||
use ui::{h_flex, ContextModal, StyledExt};
|
||||
|
||||
use crate::views::screening;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct RoomListItem {
|
||||
ix: usize,
|
||||
room_id: Option<u64>,
|
||||
public_key: Option<PublicKey>,
|
||||
name: Option<SharedString>,
|
||||
avatar: Option<SharedString>,
|
||||
created_at: Option<SharedString>,
|
||||
kind: Option<RoomKind>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl RoomListItem {
|
||||
pub fn new(ix: usize) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
room_id: None,
|
||||
public_key: None,
|
||||
name: None,
|
||||
avatar: None,
|
||||
created_at: None,
|
||||
kind: None,
|
||||
handler: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn room_id(mut self, room_id: u64) -> Self {
|
||||
self.room_id = Some(room_id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn public_key(mut self, public_key: PublicKey) -> Self {
|
||||
self.public_key = Some(public_key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(avatar.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
|
||||
self.created_at = Some(created_at.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RoomListItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let hide_avatar = AppSettings::get_hide_user_avatars(cx);
|
||||
let require_screening = AppSettings::get_screening(cx);
|
||||
|
||||
let (
|
||||
Some(public_key),
|
||||
Some(room_id),
|
||||
Some(name),
|
||||
Some(avatar),
|
||||
Some(created_at),
|
||||
Some(kind),
|
||||
Some(handler),
|
||||
) = (
|
||||
self.public_key,
|
||||
self.room_id,
|
||||
self.name,
|
||||
self.avatar,
|
||||
self.created_at,
|
||||
self.kind,
|
||||
self.handler,
|
||||
)
|
||||
else {
|
||||
return h_flex()
|
||||
.id(self.ix)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.child(Skeleton::new().w_32().h_2p5().rounded_sm())
|
||||
.child(Skeleton::new().w_6().h_2p5().rounded_sm()),
|
||||
);
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.child(Avatar::new(avatar).size(rems(1.5))),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.line_clamp(1)
|
||||
.text_ellipsis()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(created_at),
|
||||
),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.context_menu(move |this, _window, _cx| {
|
||||
this.menu(t!("profile.view"), Box::new(OpenPublicKey(public_key)))
|
||||
.menu(t!("profile.copy"), Box::new(CopyPublicKey(public_key)))
|
||||
})
|
||||
.on_click(move |event, window, cx| {
|
||||
handler(event, window, cx);
|
||||
|
||||
if kind != RoomKind::Ongoing && require_screening {
|
||||
let screening = screening::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.child(screening.clone())
|
||||
.button_props(
|
||||
ModalButtonProps::default()
|
||||
.cancel_text(t!("screening.ignore"))
|
||||
.ok_text(t!("screening.response")),
|
||||
)
|
||||
.on_cancel(move |_event, _window, cx| {
|
||||
ChatRegistry::global(cx).update(cx, |this, cx| {
|
||||
this.close_room(room_id, cx);
|
||||
});
|
||||
// false to prevent closing the modal
|
||||
// modal will be closed after closing panel
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
807
crates/coop/src/sidebar/mod.rs
Normal file
@@ -0,0 +1,807 @@
|
||||
use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
use account::Account;
|
||||
use anyhow::{anyhow, Error};
|
||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||
use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
deferred, div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
RetainAllImageCache, SharedString, Styled, Subscription, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use i18n::{shared_t, t};
|
||||
use list_item::RoomListItem;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::popup_menu::PopupMenuExt;
|
||||
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Selectable, Sizable, StyledExt};
|
||||
|
||||
use crate::actions::{RelayStatus, Reload};
|
||||
|
||||
mod list_item;
|
||||
|
||||
const FIND_DELAY: u64 = 600;
|
||||
const FIND_LIMIT: usize = 20;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||
cx.new(|cx| Sidebar::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct Sidebar {
|
||||
name: SharedString,
|
||||
|
||||
/// Focus handle for the sidebar
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Image cache
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
|
||||
/// Search results
|
||||
search_results: Entity<Option<Vec<Entity<Room>>>>,
|
||||
|
||||
/// Async search operation
|
||||
search_task: Option<Task<()>>,
|
||||
|
||||
find_input: Entity<InputState>,
|
||||
find_debouncer: DebouncedDelay<Self>,
|
||||
finding: bool,
|
||||
|
||||
indicator: Entity<Option<RoomKind>>,
|
||||
active_filter: Entity<RoomKind>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let active_filter = cx.new(|_| RoomKind::Ongoing);
|
||||
let indicator = cx.new(|_| None);
|
||||
let search_results = cx.new(|_| None);
|
||||
|
||||
let find_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("sidebar.search_label")));
|
||||
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Clear the image cache when sidebar is closed
|
||||
cx.on_release_in(window, move |this, window, cx| {
|
||||
this.image_cache.update(cx, |this, cx| {
|
||||
this.clear(window, cx);
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe for registry new events
|
||||
cx.subscribe_in(&chat, window, move |this, _, event, _window, cx| {
|
||||
if let ChatEvent::NewChatRequest(kind) = event {
|
||||
this.indicator.update(cx, |this, cx| {
|
||||
*this = Some(kind.to_owned());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe for find input events
|
||||
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
|
||||
match event {
|
||||
InputEvent::PressEnter { .. } => {
|
||||
this.search(window, cx);
|
||||
}
|
||||
InputEvent::Change => {
|
||||
// Clear the result when input is empty
|
||||
if state.read(cx).value().is_empty() {
|
||||
this.clear(window, cx);
|
||||
} else {
|
||||
// Run debounced search
|
||||
this.find_debouncer.fire_new(
|
||||
Duration::from_millis(FIND_DELAY),
|
||||
window,
|
||||
cx,
|
||||
|this, window, cx| this.debounced_search(window, cx),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Sidebar".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
find_debouncer: DebouncedDelay::new(),
|
||||
finding: false,
|
||||
indicator,
|
||||
active_filter,
|
||||
find_input,
|
||||
search_results,
|
||||
search_task: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
async fn nip50(client: &Client, query: &str) -> Result<Vec<Event>, Error> {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.search(query.to_lowercase())
|
||||
.limit(FIND_LIMIT);
|
||||
|
||||
let mut stream = client
|
||||
.stream_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3))
|
||||
.await?;
|
||||
|
||||
let mut results: Vec<Event> = Vec::with_capacity(FIND_LIMIT);
|
||||
|
||||
while let Some(event) = stream.next().await {
|
||||
// Skip if author is match current user
|
||||
if event.pubkey == public_key {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if the event has already been added
|
||||
if results.iter().any(|this| this.pubkey == event.pubkey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(event);
|
||||
}
|
||||
|
||||
if results.is_empty() {
|
||||
return Err(anyhow!("No results for query {query}"));
|
||||
}
|
||||
|
||||
// Get all public keys
|
||||
let public_keys: Vec<PublicKey> = results.iter().map(|event| event.pubkey).collect();
|
||||
|
||||
// Fetch metadata and contact lists if public keys is not empty
|
||||
if !public_keys.is_empty() {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::Metadata, Kind::ContactList])
|
||||
.limit(public_keys.len() * 2)
|
||||
.authors(public_keys);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.search(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let account = Account::global(cx);
|
||||
let public_key = account.read(cx).public_key();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let query = query.to_owned();
|
||||
|
||||
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = Self::nip50(&client, &query).await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(results) => {
|
||||
let rooms = results
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
cx.new(|_| Room::new(None, public_key, vec![event.pubkey]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
this.set_results(rooms, cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
this.set_finding(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let address = query.to_owned();
|
||||
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
match common::nip05_profile(&address).await {
|
||||
Ok(profile) => {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let receivers = vec![profile.public_key];
|
||||
let room = Room::new(None, public_key, receivers);
|
||||
|
||||
Ok(room)
|
||||
}
|
||||
Err(e) => Err(anyhow!(e)),
|
||||
}
|
||||
});
|
||||
|
||||
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(Ok(room)) => {
|
||||
this.set_results(vec![cx.new(|_| room)], cx);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
}
|
||||
this.set_finding(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Ok(public_key) = query.to_public_key() else {
|
||||
window.push_notification("Public Key is invalid", cx);
|
||||
self.set_finding(false, window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<Room, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let author = signer.get_public_key().await?;
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let receivers = vec![public_key];
|
||||
let room = Room::new(None, author, receivers);
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::Metadata, Kind::ContactList])
|
||||
.author(public_key)
|
||||
.limit(2);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(room)
|
||||
});
|
||||
|
||||
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(room) => {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let local_results = chat.read(cx).search_by_public_key(public_key, cx);
|
||||
|
||||
if !local_results.is_empty() {
|
||||
this.set_results(local_results, cx);
|
||||
} else {
|
||||
this.set_results(vec![cx.new(|_| room)], cx);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
};
|
||||
this.set_finding(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Return if the query is empty
|
||||
if self.find_input.read(cx).value().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Return if search is in progress
|
||||
if self.finding {
|
||||
if self.search_task.is_none() {
|
||||
window.push_notification("There is another search in progress", cx);
|
||||
return;
|
||||
} else {
|
||||
// Cancel ongoing search request
|
||||
self.search_task = None;
|
||||
}
|
||||
}
|
||||
|
||||
let input = self.find_input.read(cx).value();
|
||||
let query = input.to_string();
|
||||
|
||||
// Block the input until the search process completes
|
||||
self.set_finding(true, window, cx);
|
||||
|
||||
// Process to search by pubkey if query starts with npub or nprofile
|
||||
if query.starts_with("npub1") || query.starts_with("nprofile1") {
|
||||
self.search_by_pubkey(&query, window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Process to search by NIP05 if query is a valid NIP-05 identifier (name@domain.tld)
|
||||
if query.split('@').count() == 2 {
|
||||
let parts: Vec<&str> = query.split('@').collect();
|
||||
if !parts[0].is_empty() && !parts[1].is_empty() && parts[1].contains('.') {
|
||||
self.search_by_nip05(&query, window, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all local results with current query
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let local_results = chat.read(cx).search(&query, cx);
|
||||
|
||||
// Try to update with local results first
|
||||
if !local_results.is_empty() {
|
||||
self.set_results(local_results, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// If no local results, try global search via NIP-50
|
||||
self.search_by_nip50(&query, window, cx);
|
||||
}
|
||||
|
||||
fn set_results(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) {
|
||||
self.search_results.update(cx, |this, cx| {
|
||||
*this = Some(rooms);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.finding = status;
|
||||
// Disable the input to prevent duplicate requests
|
||||
self.find_input.update(cx, |this, cx| {
|
||||
this.set_disabled(status, cx);
|
||||
this.set_loading(status, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Reset the input state
|
||||
if self.finding {
|
||||
self.set_finding(false, window, cx);
|
||||
}
|
||||
|
||||
// Clear all local results
|
||||
self.search_results.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn filter(&self, kind: &RoomKind, cx: &Context<Self>) -> bool {
|
||||
self.active_filter.read(cx) == kind
|
||||
}
|
||||
|
||||
fn set_filter(&mut self, kind: RoomKind, cx: &mut Context<Self>) {
|
||||
self.indicator.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
self.active_filter.update(cx, |this, cx| {
|
||||
*this = kind;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn open_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let room = if let Some(room) = chat.read(cx).room(&id, cx) {
|
||||
room
|
||||
} else {
|
||||
let Some(result) = self.search_results.read(cx).as_ref() else {
|
||||
window.push_notification(t!("common.room_error"), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(room) = result.iter().find(|this| this.read(cx).id == id).cloned() else {
|
||||
window.push_notification(t!("common.room_error"), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Clear all search results
|
||||
self.clear(window, cx);
|
||||
|
||||
room
|
||||
};
|
||||
|
||||
chat.update(cx, |this, cx| {
|
||||
this.push_room(room, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn on_reload(&mut self, _ev: &Reload, window: &mut Window, cx: &mut Context<Self>) {
|
||||
ChatRegistry::global(cx).update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
});
|
||||
window.push_notification(t!("common.refreshed"), cx);
|
||||
}
|
||||
|
||||
fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let task: Task<Result<Vec<Relay>, Error>> = cx.background_spawn(async move {
|
||||
let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
|
||||
let subscription = client.subscription(&id).await;
|
||||
|
||||
let mut relays: Vec<Relay> = vec![];
|
||||
|
||||
for (url, _filter) in subscription.into_iter() {
|
||||
relays.push(client.pool().relay(url).await?);
|
||||
}
|
||||
|
||||
Ok(relays)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(relays) = task.await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.manage_relays(relays, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn manage_relays(&mut self, relays: Vec<Relay>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.show_close(true)
|
||||
.overlay_closable(true)
|
||||
.keyboard(true)
|
||||
.title(shared_t!("manage_relays.modal"))
|
||||
.child(v_flex().pb_4().gap_2().children({
|
||||
let mut items = Vec::with_capacity(relays.len());
|
||||
|
||||
for relay in relays.clone().into_iter() {
|
||||
let url = relay.url().to_string();
|
||||
let time = relay.stats().connected_at().to_ago();
|
||||
let connected = relay.is_connected();
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.justify_between()
|
||||
.text_xs()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.font_semibold()
|
||||
.child(
|
||||
Icon::new(IconName::Signal)
|
||||
.small()
|
||||
.text_color(cx.theme().danger_active)
|
||||
.when(connected, |this| {
|
||||
this.text_color(gpui::green().alpha(0.75))
|
||||
}),
|
||||
)
|
||||
.child(url),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_right()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("manage_relays.time", t = time)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
fn list_items(
|
||||
&self,
|
||||
rooms: &[Entity<Room>],
|
||||
range: Range<usize>,
|
||||
cx: &Context<Self>,
|
||||
) -> Vec<impl IntoElement> {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let mut items = Vec::with_capacity(range.end - range.start);
|
||||
|
||||
for ix in range {
|
||||
let Some(room) = rooms.get(ix) else {
|
||||
items.push(RoomListItem::new(ix));
|
||||
continue;
|
||||
};
|
||||
|
||||
let this = room.read(cx);
|
||||
let room_id = this.id;
|
||||
let member = this.display_member(cx);
|
||||
|
||||
let handler = cx.listener({
|
||||
move |this, _, window, cx| {
|
||||
this.open_room(room_id, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
items.push(
|
||||
RoomListItem::new(ix)
|
||||
.room_id(room_id)
|
||||
.name(this.display_name(cx))
|
||||
.avatar(this.display_image(proxy, cx))
|
||||
.public_key(member.public_key())
|
||||
.kind(this.kind)
|
||||
.created_at(this.created_at.to_ago())
|
||||
.on_click(handler),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Sidebar {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
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 chat = ChatRegistry::global(cx);
|
||||
let loading = chat.read(cx).loading;
|
||||
|
||||
// Get rooms from either search results or the chat registry
|
||||
let rooms = if let Some(results) = self.search_results.read(cx).as_ref() {
|
||||
results.to_owned()
|
||||
} else {
|
||||
// Filter rooms based on the active filter
|
||||
if self.active_filter.read(cx) == &RoomKind::Ongoing {
|
||||
chat.read(cx).ongoing_rooms(cx)
|
||||
} else {
|
||||
chat.read(cx).request_rooms(cx)
|
||||
}
|
||||
};
|
||||
|
||||
// Get total rooms count
|
||||
let mut total_rooms = rooms.len();
|
||||
|
||||
// Add 3 dummy rooms to display as skeletons
|
||||
if loading {
|
||||
total_rooms += 3
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.on_action(cx.listener(Self::on_reload))
|
||||
.on_action(cx.listener(Self::on_manage))
|
||||
.image_cache(self.image_cache.clone())
|
||||
.size_full()
|
||||
.relative()
|
||||
.gap_3()
|
||||
// Search Input
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.mt_3()
|
||||
.px_2p5()
|
||||
.w_full()
|
||||
.h_7()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.child(
|
||||
TextInput::new(&self.find_input)
|
||||
.small()
|
||||
.cleanable()
|
||||
.appearance(true)
|
||||
.text_xs()
|
||||
.map(|this| {
|
||||
if !self.find_input.read(cx).loading {
|
||||
this.suffix(
|
||||
Button::new("find")
|
||||
.icon(IconName::Search)
|
||||
.tooltip(t!("sidebar.search_tooltip"))
|
||||
.transparent()
|
||||
.small(),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
// Chat Rooms
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.flex_1()
|
||||
.px_1p5()
|
||||
.w_full()
|
||||
.overflow_y_hidden()
|
||||
.child(
|
||||
div()
|
||||
.px_1()
|
||||
.h_flex()
|
||||
.gap_2()
|
||||
.flex_none()
|
||||
.child(
|
||||
Button::new("all")
|
||||
.label(t!("sidebar.all_button"))
|
||||
.tooltip(t!("sidebar.all_conversations_tooltip"))
|
||||
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
|
||||
this.when(kind == &RoomKind::Ongoing, |this| {
|
||||
this.child(
|
||||
div().size_1().rounded_full().bg(cx.theme().cursor),
|
||||
)
|
||||
})
|
||||
})
|
||||
.small()
|
||||
.cta()
|
||||
.bold()
|
||||
.secondary()
|
||||
.rounded()
|
||||
.selected(self.filter(&RoomKind::Ongoing, cx))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.set_filter(RoomKind::Ongoing, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("requests")
|
||||
.label(t!("sidebar.requests_button"))
|
||||
.tooltip(t!("sidebar.requests_tooltip"))
|
||||
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
|
||||
this.when(kind != &RoomKind::Ongoing, |this| {
|
||||
this.child(
|
||||
div().size_1().rounded_full().bg(cx.theme().cursor),
|
||||
)
|
||||
})
|
||||
})
|
||||
.small()
|
||||
.cta()
|
||||
.bold()
|
||||
.secondary()
|
||||
.rounded()
|
||||
.selected(!self.filter(&RoomKind::Ongoing, cx))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.set_filter(RoomKind::default(), cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.justify_end()
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
Button::new("option")
|
||||
.icon(IconName::Ellipsis)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.popup_menu(move |this, _window, _cx| {
|
||||
this.menu(
|
||||
t!("sidebar.reload_menu"),
|
||||
Box::new(Reload),
|
||||
)
|
||||
.menu(
|
||||
t!("sidebar.status_menu"),
|
||||
Box::new(RelayStatus),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(!loading && total_rooms == 0, |this| {
|
||||
this.map(|this| {
|
||||
if self.filter(&RoomKind::Ongoing, cx) {
|
||||
this.child(deferred(
|
||||
v_flex()
|
||||
.py_2()
|
||||
.px_1p5()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(shared_t!("sidebar.no_conversations")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.line_height(relative(1.25))
|
||||
.child(shared_t!("sidebar.no_conversations_label")),
|
||||
),
|
||||
))
|
||||
} else {
|
||||
this.child(deferred(
|
||||
v_flex()
|
||||
.py_2()
|
||||
.px_1p5()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(shared_t!("sidebar.no_requests")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.line_height(relative(1.25))
|
||||
.child(shared_t!("sidebar.no_requests_label")),
|
||||
),
|
||||
))
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(
|
||||
uniform_list(
|
||||
"rooms",
|
||||
total_rooms,
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.list_items(&rooms, range, cx)
|
||||
}),
|
||||
)
|
||||
.h_full(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
393
crates/coop/src/user/mod.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{nip96_upload, shorten_pubkey};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, App, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement,
|
||||
PathPromptOptions, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
|
||||
|
||||
pub mod viewer;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
|
||||
cx.new(|cx| UserProfile::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserProfile {
|
||||
/// User profile
|
||||
profile: Option<Profile>,
|
||||
|
||||
/// User's name text input
|
||||
name_input: Entity<InputState>,
|
||||
|
||||
/// User's avatar url text input
|
||||
avatar_input: Entity<InputState>,
|
||||
|
||||
/// User's bio multi line input
|
||||
bio_input: Entity<InputState>,
|
||||
|
||||
/// User's website url text input
|
||||
website_input: Entity<InputState>,
|
||||
|
||||
/// Uploading state
|
||||
uploading: bool,
|
||||
|
||||
/// Copied states
|
||||
copied: bool,
|
||||
|
||||
/// Async operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl UserProfile {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
||||
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
|
||||
|
||||
// Use multi-line input for bio
|
||||
let bio_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.multi_line()
|
||||
.auto_grow(3, 8)
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
|
||||
let get_profile = Self::get_profile(cx);
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Get metadata in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(profile) = get_profile.await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_profile(profile, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
profile: None,
|
||||
name_input,
|
||||
avatar_input,
|
||||
bio_input,
|
||||
website_input,
|
||||
uploading: false,
|
||||
copied: false,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile(cx: &App) -> Task<Result<Profile, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let metadata = client
|
||||
.database()
|
||||
.metadata(public_key)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_profile(&mut self, profile: Profile, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let metadata = profile.metadata();
|
||||
|
||||
self.avatar_input.update(cx, |this, cx| {
|
||||
if let Some(avatar) = metadata.picture.as_ref() {
|
||||
this.set_value(avatar, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
self.bio_input.update(cx, |this, cx| {
|
||||
if let Some(bio) = metadata.about.as_ref() {
|
||||
this.set_value(bio, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
self.name_input.update(cx, |this, cx| {
|
||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||
this.set_value(display_name, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
self.website_input.update(cx, |this, cx| {
|
||||
if let Some(website) = metadata.website.as_ref() {
|
||||
this.set_value(website, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
self.profile = Some(profile);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let item = ClipboardItem::new_string(value);
|
||||
cx.write_to_clipboard(item);
|
||||
|
||||
self.set_copied(true, window, cx);
|
||||
}
|
||||
|
||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.copied = status;
|
||||
cx.notify();
|
||||
|
||||
if status {
|
||||
self._tasks.push(
|
||||
// Reset the copied state after a delay
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.uploading(true, cx);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get the user's configured NIP96 server
|
||||
let nip96_server = AppSettings::get_media_server(cx);
|
||||
|
||||
// Open native file dialog
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: None,
|
||||
});
|
||||
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
if let Some(path) = paths.pop() {
|
||||
let file = fs::read(path).await?;
|
||||
let url = nip96_upload(&client, &nip96_server, file).await?;
|
||||
|
||||
Ok(url)
|
||||
} else {
|
||||
Err(anyhow!("Path not found"))
|
||||
}
|
||||
}
|
||||
Ok(None) => Err(anyhow!("User cancelled")),
|
||||
Err(e) => Err(anyhow!("File dialog error: {e}")),
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
match result {
|
||||
Ok(Ok(url)) => {
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
this.set_value(url.to_string(), window, cx);
|
||||
});
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to upload avatar: {e}");
|
||||
}
|
||||
};
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Profile, Error>> {
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let bio = self.bio_input.read(cx).value().to_string();
|
||||
let website = self.website_input.read(cx).value().to_string();
|
||||
|
||||
// Get the current profile metadata
|
||||
let old_metadata = self
|
||||
.profile
|
||||
.as_ref()
|
||||
.map(|profile| profile.metadata())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Construct the new metadata
|
||||
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 nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let gossip = nostr.read(cx).gossip();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let gossip = gossip.read().await;
|
||||
let write_relays = gossip.inbox_relays(&public_key);
|
||||
|
||||
// Ensure connections to the write relays
|
||||
gossip.ensure_connections(&client, &write_relays).await;
|
||||
|
||||
// Sign the new metadata event
|
||||
let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?;
|
||||
|
||||
// Send event to user's write relayss
|
||||
client.send_event_to(write_relays, &event).await?;
|
||||
|
||||
// Return the updated profile
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let profile = Profile::new(event.pubkey, metadata);
|
||||
|
||||
Ok(profile)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for UserProfile {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.w_full()
|
||||
.h_32()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.map(|this| {
|
||||
let picture = self.avatar_input.read(cx).value();
|
||||
let source = if picture.is_empty() {
|
||||
"brand/avatar.png"
|
||||
} else {
|
||||
picture.as_str()
|
||||
};
|
||||
this.child(img(source).rounded_full().size_10().flex_shrink_0())
|
||||
})
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::Upload)
|
||||
.label("Change")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Name:"))
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Bio:"))
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::from("Website:"))
|
||||
.child(TextInput::new(&self.website_input).small()),
|
||||
)
|
||||
.when_some(self.profile.as_ref(), |this, profile| {
|
||||
let public_key = profile.public_key();
|
||||
let display = SharedString::from(shorten_pubkey(profile.public_key(), 8));
|
||||
|
||||
this.child(div().my_1().h_px().w_full().bg(cx.theme().border))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h_12()
|
||||
.justify_center()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.child(display)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy(
|
||||
public_key.to_bech32().unwrap(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
259
crates/coop/src/user/viewer.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{nip05_verify, shorten_pubkey, RenderedProfile};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfileViewer> {
|
||||
cx.new(|cx| ProfileViewer::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProfileViewer {
|
||||
profile: Profile,
|
||||
|
||||
/// Follow status
|
||||
followed: bool,
|
||||
|
||||
/// Verification status
|
||||
verified: bool,
|
||||
|
||||
/// Copy status
|
||||
copied: bool,
|
||||
|
||||
/// Async operations
|
||||
_tasks: SmallVec<[Task<()>; 1]>,
|
||||
}
|
||||
|
||||
impl ProfileViewer {
|
||||
pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get_person(&target, cx);
|
||||
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
let check_follow: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
||||
|
||||
Ok(contact_list.contains(&target))
|
||||
});
|
||||
|
||||
let verify_nip05 = if let Some(address) = profile.metadata().nip05 {
|
||||
Some(Tokio::spawn(cx, async move {
|
||||
nip05_verify(target, &address).await.unwrap_or(false)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tasks.push(
|
||||
// Load user profile data
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let followed = check_follow.await.unwrap_or(false);
|
||||
|
||||
// Update the followed status
|
||||
this.update(cx, |this, cx| {
|
||||
this.followed = followed;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Update the NIP05 verification status if user has NIP05 address
|
||||
if let Some(task) = verify_nip05 {
|
||||
if let Ok(verified) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = verified;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
profile,
|
||||
followed: false,
|
||||
verified: false,
|
||||
copied: false,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn address(&self, _cx: &Context<Self>) -> Option<String> {
|
||||
self.profile.metadata().nip05
|
||||
}
|
||||
|
||||
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = self.profile.public_key().to_bech32();
|
||||
let item = ClipboardItem::new_string(bech32);
|
||||
cx.write_to_clipboard(item);
|
||||
|
||||
self.set_copied(true, window, cx);
|
||||
}
|
||||
|
||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.copied = status;
|
||||
cx.notify();
|
||||
|
||||
if status {
|
||||
self._tasks.push(
|
||||
// Reset the copied state after a delay
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProfileViewer {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let bech32 = shorten_pubkey(self.profile.public_key(), 16);
|
||||
let shared_bech32 = SharedString::from(bech32);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.text_sm()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(self.profile.avatar(proxy)).size(rems(4.)))
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(self.profile.display_name()),
|
||||
)
|
||||
.when_some(self.address(cx), |this, address| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(address)
|
||||
.when(self.verified, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.relative()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(
|
||||
Icon::new(IconName::CheckCircleFill)
|
||||
.small()
|
||||
.block(),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when(!self.followed, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w_32()
|
||||
.p_1()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Unknown contact")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Bio:")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_2()
|
||||
.min_h_16()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
self.profile
|
||||
.metadata()
|
||||
.about
|
||||
.map(SharedString::from)
|
||||
.unwrap_or(SharedString::from("No bio.")),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(div().my_1().h_px().w_full().bg(cx.theme().border))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::from("Public Key:")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h_12()
|
||||
.justify_center()
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.child(shared_bech32)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy_pubkey(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,629 +0,0 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use async_utility::task::spawn;
|
||||
use chats::{
|
||||
message::RoomMessage,
|
||||
room::{Room, SendStatus},
|
||||
ChatRegistry,
|
||||
};
|
||||
use common::{nip96_upload, profile::SharedProfile};
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, impl_internal_actions, list, prelude::FluentBuilder, px, relative, svg, white,
|
||||
AnyElement, App, AppContext, Context, Element, Empty, Entity, EventEmitter, Flatten,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit,
|
||||
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
StyledImage, Subscription, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
emoji_picker::EmojiPicker,
|
||||
input::{InputEvent, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
text::RichText,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, Size, StyledExt,
|
||||
};
|
||||
|
||||
use crate::views::subject;
|
||||
|
||||
const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages.";
|
||||
const DESC: &str = "This conversation is private. Only members can see each other's messages.";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct ChangeSubject(pub String);
|
||||
|
||||
impl_internal_actions!(chat, [ChangeSubject]);
|
||||
|
||||
pub fn init(id: &u64, window: &mut Window, cx: &mut App) -> Result<Arc<Entity<Chat>>, Error> {
|
||||
if let Some(room) = ChatRegistry::global(cx).read(cx).room(id, cx) {
|
||||
Ok(Arc::new(Chat::new(id, room, window, cx)))
|
||||
} else {
|
||||
Err(anyhow!("Chat Room not found."))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Chat {
|
||||
// Panel
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
// Chat Room
|
||||
room: Entity<Room>,
|
||||
messages: Entity<Vec<RoomMessage>>,
|
||||
text_data: HashMap<EventId, RichText>,
|
||||
list_state: ListState,
|
||||
// New Message
|
||||
input: Entity<TextInput>,
|
||||
// Media Attachment
|
||||
attaches: Entity<Option<Vec<Url>>>,
|
||||
is_uploading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl Chat {
|
||||
pub fn new(id: &u64, room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let messages = cx.new(|_| vec![RoomMessage::announcement()]);
|
||||
let attaches = cx.new(|_| None);
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Message...")
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
this.send_message(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this, _, event, _window, cx| {
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let message = event.event.clone();
|
||||
|
||||
cx.update_entity(&this.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
this.list_state.splice(old_len..old_len, 1);
|
||||
},
|
||||
));
|
||||
|
||||
// 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_or(Empty.into_any())
|
||||
}
|
||||
});
|
||||
|
||||
let this = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
is_uploading: false,
|
||||
id: id.to_string().into(),
|
||||
text_data: HashMap::new(),
|
||||
room,
|
||||
messages,
|
||||
list_state,
|
||||
input,
|
||||
attaches,
|
||||
subscriptions,
|
||||
};
|
||||
|
||||
// Verify messaging relays of all members
|
||||
this.verify_messaging_relays(window, cx);
|
||||
|
||||
// Load all messages from database
|
||||
this.load_messages(window, cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_messaging_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let room = self.room.read(cx);
|
||||
let task = room.messaging_relays(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(result) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
result.into_iter().for_each(|item| {
|
||||
if !item.1 {
|
||||
let profile = this
|
||||
.room
|
||||
.read_with(cx, |this, _| this.profile_by_pubkey(&item.0, cx));
|
||||
|
||||
this.push_system_message(
|
||||
format!("{} {}", profile.shared_name(), ALERT),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let room = self.room.read(cx);
|
||||
let task = room.load_messages(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(events) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let new_len = events.len();
|
||||
|
||||
// Extend the messages list with the new events
|
||||
this.messages.update(cx, |this, cx| {
|
||||
this.extend(events);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Update list state with the new messages
|
||||
this.list_state.splice(old_len..old_len, new_len);
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = RoomMessage::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 send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
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)
|
||||
}
|
||||
|
||||
// Check if content is empty
|
||||
if content.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update input state
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, window, cx);
|
||||
this.set_disabled(true, window, cx);
|
||||
});
|
||||
|
||||
let room = self.room.read(cx);
|
||||
let task = room.send_message(content, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut received = false;
|
||||
|
||||
match task {
|
||||
Some(rx) => {
|
||||
while let Ok(message) = rx.recv().await {
|
||||
if let SendStatus::Failed(error) = message {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string())
|
||||
.title("Message Failed to Send"),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
} else if !received {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.input.update(cx, |this, cx| {
|
||||
this.set_loading(false, window, cx);
|
||||
this.set_disabled(false, window, cx);
|
||||
this.set_text("", window, cx);
|
||||
});
|
||||
received = true;
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error("User is not logged in"), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let Some(path) = paths.pop() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
// spawn task via async_utility
|
||||
spawn(async move {
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
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();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
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() {
|
||||
if let Some(ix) = urls.iter().position(|x| x == url) {
|
||||
urls.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let Some(message) = self.messages.read(cx).get(ix) else {
|
||||
return div().into_element();
|
||||
};
|
||||
|
||||
let text_data = &mut self.text_data;
|
||||
|
||||
div()
|
||||
.group("")
|
||||
.w_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.map(|this| match message {
|
||||
RoomMessage::User(item) => {
|
||||
let text = text_data
|
||||
.entry(item.id)
|
||||
.or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions));
|
||||
|
||||
this.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
|
||||
.text_sm()
|
||||
.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.author.shared_avatar()).size_8().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().font_semibold().child(item.author.shared_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::NINE),
|
||||
)
|
||||
.child(item.ago()),
|
||||
),
|
||||
)
|
||||
.child(text.element("body".into(), window, cx)),
|
||||
)
|
||||
}
|
||||
RoomMessage::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.png").size_8().flex_shrink_0())
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(content.clone()),
|
||||
RoomMessage::Announcement => this
|
||||
.w_full()
|
||||
.h_32()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::NINE))
|
||||
.line_height(relative(1.3))
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_10()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(DESC),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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.avatars(cx);
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_row_reverse()
|
||||
.items_center()
|
||||
.justify_start()
|
||||
.children(
|
||||
facepill
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.map(|(ix, facepill)| {
|
||||
div()
|
||||
.when(ix > 0, |div| div.ml_neg_1())
|
||||
.child(img(facepill).size_5())
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(this.display_name(cx))
|
||||
.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> {
|
||||
let id = self.room.read(cx).id;
|
||||
let subject = self
|
||||
.room
|
||||
.read(cx)
|
||||
.subject
|
||||
.as_ref()
|
||||
.map(|subject| subject.to_string());
|
||||
|
||||
let button = Button::new("subject")
|
||||
.icon(IconName::EditFill)
|
||||
.tooltip("Change Subject")
|
||||
.on_click(move |_, window, cx| {
|
||||
let subject = subject::init(id, subject.clone(), window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.title("Change the subject of the conversation")
|
||||
.child(subject.clone())
|
||||
});
|
||||
});
|
||||
|
||||
vec![button]
|
||||
}
|
||||
}
|
||||
|
||||
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().px_3().py_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()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(Icon::new(IconName::Upload))
|
||||
.ghost()
|
||||
.disabled(self.is_uploading)
|
||||
.loading(self.is_uploading)
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.upload_media(window, cx);
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
EmojiPicker::new(self.input.downgrade())
|
||||
.icon(IconName::EmojiFill),
|
||||
),
|
||||
)
|
||||
.child(self.input.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,494 +1,513 @@
|
||||
use anyhow::Error;
|
||||
use chats::{
|
||||
room::{Room, RoomKind},
|
||||
ChatRegistry,
|
||||
};
|
||||
use common::{profile::SharedProfile, random_name};
|
||||
use global::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::{BTreeSet, HashSet},
|
||||
time::Duration,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
};
|
||||
use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Compose> {
|
||||
cx.new(|cx| Compose::new(window, cx))
|
||||
use account::Account;
|
||||
use anyhow::{anyhow, Error};
|
||||
use chat::{ChatRegistry, Room};
|
||||
use common::{nip05_profile, RenderedProfile, TextUtils, BOOTSTRAP_RELAYS};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use i18n::{shared_t, t};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::notification::Notification;
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn compose_button() -> impl IntoElement {
|
||||
div().child(
|
||||
Button::new("compose")
|
||||
.icon(IconName::Plus)
|
||||
.ghost_alt()
|
||||
.cta()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(move |_, window, cx| {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
let weak_view = compose.downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
let label = if compose.read(cx).selected(cx).len() > 1 {
|
||||
shared_t!("compose.create_group_dm_button")
|
||||
} else {
|
||||
shared_t!("compose.create_dm_button")
|
||||
};
|
||||
|
||||
modal
|
||||
.alert()
|
||||
.overlay_closable(true)
|
||||
.keyboard(true)
|
||||
.show_close(true)
|
||||
.button_props(ModalButtonProps::default().ok_text(label))
|
||||
.title(shared_t!("sidebar.direct_messages"))
|
||||
.child(compose.clone())
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
this.submit(window, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// false to prevent the modal from closing
|
||||
false
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
struct SelectContact(PublicKey);
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct Contact {
|
||||
public_key: PublicKey,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl_internal_actions!(contacts, [SelectContact]);
|
||||
impl AsRef<PublicKey> for Contact {
|
||||
fn as_ref(&self) -> &PublicKey {
|
||||
&self.public_key
|
||||
}
|
||||
}
|
||||
|
||||
impl Contact {
|
||||
pub fn new(public_key: PublicKey) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(mut self) -> Self {
|
||||
self.selected = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Compose {
|
||||
title_input: Entity<TextInput>,
|
||||
user_input: Entity<TextInput>,
|
||||
contacts: Entity<Vec<Profile>>,
|
||||
selected: Entity<HashSet<PublicKey>>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
is_submitting: bool,
|
||||
/// Input for the room's subject
|
||||
title_input: Entity<InputState>,
|
||||
|
||||
/// Input for the room's members
|
||||
user_input: Entity<InputState>,
|
||||
|
||||
/// User's contacts
|
||||
contacts: Entity<Vec<Contact>>,
|
||||
|
||||
/// Error message
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
_tasks: SmallVec<[Task<()>; 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());
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let contacts = cx.new(|_| vec![]);
|
||||
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::Small);
|
||||
let user_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("npub or nprofile..."));
|
||||
|
||||
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 title_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = 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 get_contacts: Task<Result<Vec<Contact>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let profiles = client.database().contacts(public_key).await?;
|
||||
let contacts: Vec<Contact> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| Contact::new(profile.public_key()))
|
||||
.collect();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<BTreeSet<Profile>, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let profiles = client.database().contacts(public_key).await?;
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
Ok(profiles)
|
||||
});
|
||||
tasks.push(
|
||||
// Load all contacts
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match get_contacts.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.extend_contacts(contacts, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if let Ok(contacts) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.extend(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok()
|
||||
subscriptions.push(
|
||||
// Clear the image cache when sidebar is closed
|
||||
cx.on_release_in(window, move |this, window, cx| {
|
||||
this.image_cache.update(cx, |this, cx| {
|
||||
this.clear(window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Handle Enter event for user input
|
||||
cx.subscribe_in(
|
||||
&user_input,
|
||||
window,
|
||||
move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add_and_select_contact(window, cx)
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Self {
|
||||
title_input,
|
||||
user_input,
|
||||
contacts,
|
||||
selected,
|
||||
error_message,
|
||||
is_loading: false,
|
||||
is_submitting: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
subscriptions,
|
||||
contacts,
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let kinds = vec![Kind::Metadata, Kind::ContactList];
|
||||
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: IntoIterator<Item = Contact>,
|
||||
{
|
||||
self.contacts.update(cx, |this, cx| {
|
||||
this.extend(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn push_contact(&mut self, contact: Contact, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let pk = contact.public_key;
|
||||
|
||||
if !self.contacts.read(cx).iter().any(|c| c.public_key == pk) {
|
||||
self._tasks.push(cx.background_spawn(async move {
|
||||
Self::request_metadata(&client, pk).await.ok();
|
||||
}));
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, contact);
|
||||
cx.notify();
|
||||
});
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
self.set_error(t!("compose.contact_existed"), cx);
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
fn select_contact(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
self.contacts.update(cx, |this, cx| {
|
||||
if let Some(contact) = this.iter_mut().find(|c| c.public_key == public_key) {
|
||||
contact.selected = true;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
// Get all pubkeys
|
||||
let pubkeys: Vec<PublicKey> = self.selected.read(cx).iter().copied().collect();
|
||||
fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.user_input.read(cx).value().to_string();
|
||||
|
||||
// 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 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 Room later.
|
||||
let event = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "")
|
||||
.tags(tags)
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
Ok(event)
|
||||
// Show loading indicator in the input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(event) = event.await {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
})
|
||||
.ok();
|
||||
if let Ok(public_key) = content.to_public_key() {
|
||||
let contact = Contact::new(public_key).selected();
|
||||
self.push_contact(contact, window, cx);
|
||||
} else if content.contains("@") {
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
if let Ok(profile) = nip05_profile(&content).await {
|
||||
let public_key = profile.public_key;
|
||||
let contact = Contact::new(public_key).selected();
|
||||
|
||||
let chats = ChatRegistry::global(cx);
|
||||
let room = Room::new(&event).kind(RoomKind::Ongoing);
|
||||
Ok(contact)
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
});
|
||||
|
||||
chats.update(cx, |chats, cx| {
|
||||
match chats.push(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(Ok(contact)) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.push_contact(contact, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Tokio error: {e}");
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
let content = self.user_input.read(cx).text().to_string();
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let task: Task<Result<Profile, 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(Profile::new(public_key, metadata))
|
||||
fn selected(&self, cx: &App) -> Vec<PublicKey> {
|
||||
self.contacts
|
||||
.read(cx)
|
||||
.iter()
|
||||
.filter_map(|contact| {
|
||||
if contact.selected {
|
||||
Some(contact.public_key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} 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;
|
||||
};
|
||||
.collect()
|
||||
}
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
})
|
||||
let account = Account::global(cx);
|
||||
let public_key = account.read(cx).public_key();
|
||||
|
||||
let receivers: Vec<PublicKey> = self.selected(cx);
|
||||
let subject_input = self.title_input.read(cx).value();
|
||||
let subject = (!subject_input.is_empty()).then(|| subject_input.to_string());
|
||||
|
||||
if !self.user_input.read(cx).value().is_empty() {
|
||||
self.add_and_select_contact(window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(profile) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
let public_key = profile.public_key();
|
||||
chat.update(cx, |this, cx| {
|
||||
this.push_room(cx.new(|_| Room::new(subject, public_key, receivers)), cx);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
window.close_modal(cx);
|
||||
}
|
||||
|
||||
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
|
||||
fn set_error(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
// Unlock the user input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
|
||||
// Update error message
|
||||
self.error_message.update(cx, |this, cx| {
|
||||
*this = error;
|
||||
*this = Some(error.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Dismiss error after 2 seconds
|
||||
cx.spawn(async move |this, cx| {
|
||||
Timer::after(Duration::from_secs(2)).await;
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(None, cx);
|
||||
})
|
||||
.ok();
|
||||
this.update(cx, |this, cx| {
|
||||
this.error_message.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
fn list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let mut items = Vec::with_capacity(self.contacts.read(cx).len());
|
||||
|
||||
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);
|
||||
for ix in range {
|
||||
let Some(contact) = self.contacts.read(cx).get(ix) else {
|
||||
continue;
|
||||
};
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let public_key = contact.public_key;
|
||||
let profile = persons.read(cx).get_person(&public_key, cx);
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_2()
|
||||
.h_11()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.text_sm()
|
||||
.child(Avatar::new(profile.avatar(proxy)).size(rems(1.75)))
|
||||
.child(profile.display_name()),
|
||||
)
|
||||
.when(contact.selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircleFill)
|
||||
.small()
|
||||
.text_color(cx.theme().text_accent),
|
||||
)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.select_contact(public_key, cx);
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Compose {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const DESCRIPTION: &str =
|
||||
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
|
||||
let error = self.error_message.read(cx).as_ref();
|
||||
let loading = self.user_input.read(cx).loading;
|
||||
let contacts = self.contacts.read(cx);
|
||||
|
||||
let label: SharedString = if self.selected.read(cx).len() > 1 {
|
||||
"Create Group DM".into()
|
||||
} else {
|
||||
"Create DM".into()
|
||||
};
|
||||
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_action_select))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
v_flex()
|
||||
.image_cache(self.image_cache.clone())
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(DESCRIPTION),
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("compose.description")),
|
||||
)
|
||||
.when_some(self.error_message.read(cx).as_ref(), |this, msg| {
|
||||
.when_some(error, |this, msg| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.italic()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(msg.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div().flex().flex_col().child(
|
||||
div()
|
||||
.h_10()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(div().pb_0p5().text_sm().font_semibold().child("Title:"))
|
||||
.child(self.title_input.clone()),
|
||||
),
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.h_10()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(shared_t!("compose.subject_label")),
|
||||
)
|
||||
.child(TextInput::new(&self.title_input).small().appearance(false)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
v_flex()
|
||||
.pt_1()
|
||||
.gap_2()
|
||||
.mt_1()
|
||||
.child(div().text_sm().font_semibold().child("To:"))
|
||||
.child(self.user_input.clone())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(shared_t!("compose.to_label")),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(&self.user_input)
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.suffix(
|
||||
Button::new("add")
|
||||
.icon(IconName::PlusCircleFill)
|
||||
.transparent()
|
||||
.small()
|
||||
.disabled(loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add_and_select_contact(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
let contacts = self.contacts.read(cx).clone();
|
||||
let view = cx.entity();
|
||||
|
||||
if contacts.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.w_full()
|
||||
v_flex()
|
||||
.h_24()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_align(TextAlign::Center)
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("No contacts"),
|
||||
.child(shared_t!("compose.no_contacts_message")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child("Your recently contacts will appear here."),
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("compose.no_contacts_description")),
|
||||
),
|
||||
)
|
||||
} 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_10()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
img(item.shared_avatar())
|
||||
.size_7()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(item.shared_name()),
|
||||
)
|
||||
.when(is_select, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircleFill)
|
||||
.small()
|
||||
.text_color(
|
||||
cx.theme().accent.step(
|
||||
cx,
|
||||
ColorScaleStep::NINE,
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.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
|
||||
},
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.list_items(range, cx)
|
||||
}),
|
||||
)
|
||||
.pb_4()
|
||||
.min_h(px(280.)),
|
||||
.h(px(300.)),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div().mt_2().child(
|
||||
Button::new("create_dm_btn")
|
||||
.label(label)
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(self.is_submitting)
|
||||
.disabled(self.is_submitting)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.compose(window, cx))),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||