Compare commits
67 Commits
0.1.5-alph
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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: Release ${{ 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, nightly]
|
||||
|
||||
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
|
||||
|
||||
2563
Cargo.lock
generated
111
Cargo.toml
@@ -1,53 +1,58 @@
|
||||
[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.2.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace.metadata.i18n]
|
||||
available-locales = ["en", "zh-CN", "zh-TW", "ru", "vi", "ja", "es", "pt", "ko"]
|
||||
default-locale = "en"
|
||||
load-path = "locales"
|
||||
|
||||
[workspace.dependencies]
|
||||
i18n = { path = "crates/i18n" }
|
||||
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
|
||||
"lmdb",
|
||||
"nip96",
|
||||
"nip59",
|
||||
"nip49",
|
||||
"nip44",
|
||||
] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = "0.4.38"
|
||||
dirs = "5.0"
|
||||
emojis = "0.6.4"
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
|
||||
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"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name = "coop"
|
||||
description = "Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability."
|
||||
product-name = "Coop"
|
||||
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/fonts/plex-mono/ZedPlexMono-Bold.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-Italic.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-Regular.ttf
Normal file
92
assets/fonts/plex-mono/license.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
assets/fonts/plex-sans/ZedPlexSans-Bold.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-Italic.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-Regular.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-SemiBold.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-SemiBoldItalic.ttf
Normal file
92
assets/fonts/plex-sans/license.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="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 |
3
assets/icons/filter-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" d="M5.75 3A2.75 2.75 0 0 0 3 5.75v1.422c0 .729.29 1.428.805 1.944l4.829 4.829c.234.234.366.552.366.883v6.422a.75.75 0 0 0 .95.723l4.5-1.25A.75.75 0 0 0 15 20v-5.172c0-.331.132-.649.366-.883l4.829-4.829A2.75 2.75 0 0 0 21 7.172V5.75A2.75 2.75 0 0 0 18.25 3H5.75Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
3
assets/icons/filter.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="M18.25 3.75H5.75a2 2 0 0 0-2 2v1.422a2 2 0 0 0 .586 1.414l4.828 4.828a2 2 0 0 1 .586 1.414v6.422l4.5-1.25v-5.172a2 2 0 0 1 .586-1.414l4.828-4.828a2 2 0 0 0 .586-1.414V5.75a2 2 0 0 0-2-2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
3
assets/icons/forward.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="m21.76 11.45-8.146-7.535a.75.75 0 0 0-1.26.55V8a.51.51 0 0 1-.504.504C3.765 8.632 1.604 11.92 1.604 20.25c1.47-2.94 2.22-4.679 10.245-4.748a.501.501 0 0 1 .505.498v3.535a.75.75 0 0 0 1.26.55l8.145-7.535a.75.75 0 0 0 0-1.1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 405 B |
4
assets/icons/language.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="M3.75 5.816h8.5M8 5.75v-2m4 10.5C7.935 13.198 5.845 10.614 5.25 6"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 14c4.064-1.02 6.154-3.527 6.75-8m3.594 11.125h5.312m1.594 2.125-3.314-8.774c-.326-.862-1.546-.862-1.872 0L12.75 19.25"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 494 B |
3
assets/icons/logout.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.25 12H9m11.25 0-4.5 4.5m4.5-4.5-4.5-4.5m-4.5 12.75h-5.5a2 2 0 0 1-2-2V5.75a2 2 0 0 1 2-2h5.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 302 B |
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 |
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 |
3
assets/icons/search-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" d="M3 11a8 8 0 1 1 14.162 5.102l3.368 3.368a.75.75 0 1 1-1.06 1.06l-3.368-3.368A8 8 0 0 1 3 11Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 230 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20.25 20.25-4.123-4.123m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20 20-3.873-3.873m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 287 B |
4
assets/icons/settings.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m7.878 5.214-.703-.162a1.77 1.77 0 0 0-2.123 2.123l.162.703a2 2 0 0 1-.84 2.114l-.854.57a1.728 1.728 0 0 0 0 2.876l.855.57a2 2 0 0 1 .84 2.114l-.163.703a1.77 1.77 0 0 0 2.123 2.123l.703-.162a2 2 0 0 1 2.114.84l.57.854a1.728 1.728 0 0 0 2.876 0l.57-.855a2 2 0 0 1 2.114-.84l.703.163a1.77 1.77 0 0 0 2.123-2.123l-.162-.703a2 2 0 0 1 .84-2.114l.854-.57a1.728 1.728 0 0 0 0-2.876l-.855-.57a2 2 0 0 1-.84-2.114l.163-.703a1.77 1.77 0 0 0-2.123-2.123l-.703.162a2 2 0 0 1-2.114-.84l-.57-.854a1.728 1.728 0 0 0-2.876 0l-.57.855a2 2 0 0 1-2.114.84Z"/>
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="M14.75 12a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
3
assets/icons/toggle-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="M1 11.75A5.75 5.75 0 0 1 6.75 6h10.5A5.75 5.75 0 0 1 23 11.75v.5A5.75 5.75 0 0 1 17.25 18H6.75A5.75 5.75 0 0 1 1 12.25v-.5ZM17 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
3
assets/icons/toggle.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-width="1.5" d="M17 17.25H7a5.25 5.25 0 1 1 0-10.5h10m0 10.5a5.25 5.25 0 1 0 0-10.5m0 10.5a5.25 5.25 0 1 1 0-10.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@@ -1,230 +0,0 @@
|
||||
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 nostr_sdk::prelude::*;
|
||||
use ui::{notification::Notification, ContextModal};
|
||||
|
||||
struct GlobalAccount(Entity<Account>);
|
||||
|
||||
impl Global for GlobalAccount {}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
Account::set_global(cx.new(|_| Account { profile: None }), cx);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Account {
|
||||
pub profile: Option<Profile>,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
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));
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
// 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);
|
||||
|
||||
// Fetch user's metadata
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| match task.await {
|
||||
Ok(profile) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.profile(profile, cx);
|
||||
|
||||
cx.defer_in(window, |this, _, cx| {
|
||||
this.subscribe(cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// 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",
|
||||
];
|
||||
|
||||
const DEFAULT_MESSAGING_RELAYS: [&str; 2] =
|
||||
["wss://auth.nostr1.com", "wss://relay.0xchat.com"];
|
||||
|
||||
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();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Sets the profile for the account.
|
||||
pub fn profile(&mut self, profile: Profile, cx: &mut Context<Self>) {
|
||||
self.profile = Some(profile);
|
||||
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;
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
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,18 @@
|
||||
[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" }
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
cargo-packager-updater = "0.2.3"
|
||||
|
||||
@@ -1,350 +1,160 @@
|
||||
use std::{
|
||||
env::{self, consts::OS},
|
||||
ffi::OsString,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context as _, Error};
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SemanticVersion, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::{
|
||||
fs::{self, File},
|
||||
io::AsyncWriteExt,
|
||||
process::Command,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct GlobalAutoUpdate(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdate {}
|
||||
use anyhow::Error;
|
||||
use cargo_packager_updater::semver::Version;
|
||||
use cargo_packager_updater::{check_update, Config, Update};
|
||||
use global::constants::{APP_PUBKEY, APP_UPDATER_ENDPOINT};
|
||||
use gpui::http_client::Url;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AutoUpdateStatus {
|
||||
Idle,
|
||||
Checking,
|
||||
Downloading,
|
||||
Checked { update: Box<Update> },
|
||||
Installing,
|
||||
Updated { binary_path: PathBuf },
|
||||
Errored,
|
||||
Updated,
|
||||
Errored { msg: Box<String> },
|
||||
}
|
||||
|
||||
impl AutoUpdateStatus {
|
||||
pub fn is_updating(&self) -> bool {
|
||||
matches!(self, Self::Checked { .. } | Self::Installing)
|
||||
}
|
||||
|
||||
pub fn is_updated(&self) -> bool {
|
||||
matches!(self, Self::Updated { .. })
|
||||
matches!(self, Self::Updated)
|
||||
}
|
||||
|
||||
pub fn checked(update: Update) -> Self {
|
||||
Self::Checked {
|
||||
update: Box::new(update),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(e: String) -> Self {
|
||||
Self::Errored { msg: Box::new(e) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AutoUpdater {
|
||||
status: AutoUpdateStatus,
|
||||
current_version: SemanticVersion,
|
||||
pub status: AutoUpdateStatus,
|
||||
config: Config,
|
||||
version: Version,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Retrieve the Global Auto Updater instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<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));
|
||||
/// Retrieve the Auto Updater instance
|
||||
pub fn read_global(cx: &App) -> &Self {
|
||||
cx.global::<GlobalAutoUpdater>().0.read(cx)
|
||||
}
|
||||
|
||||
pub fn current_version(&self) -> SemanticVersion {
|
||||
self.current_version
|
||||
/// Set the Global Auto Updater instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAutoUpdater(state));
|
||||
}
|
||||
|
||||
pub fn status(&self) -> AutoUpdateStatus {
|
||||
self.status.clone()
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let config = cargo_packager_updater::Config {
|
||||
endpoints: vec![Url::parse(APP_UPDATER_ENDPOINT).expect("Endpoint is not valid")],
|
||||
pubkey: String::from(APP_PUBKEY),
|
||||
..Default::default()
|
||||
};
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).expect("Failed to parse version");
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.check_for_updates(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
status: AutoUpdateStatus::Idle,
|
||||
version,
|
||||
config,
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
pub fn check_for_updates(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let config = self.config.clone();
|
||||
let current_version = self.version.clone();
|
||||
|
||||
log::info!("Checking for updates...");
|
||||
self.set_status(AutoUpdateStatus::Checking, cx);
|
||||
|
||||
let checking: Task<Result<Option<Update>, Error>> = cx.background_spawn(async move {
|
||||
if let Some(update) = check_update(current_version, config)? {
|
||||
Ok(Some(update))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Some(update)) = checking.await {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::checked(update), cx);
|
||||
this.install_update(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn install_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_status(AutoUpdateStatus::Installing, cx);
|
||||
|
||||
if let AutoUpdateStatus::Checked { update } = self.status.clone() {
|
||||
let install: Task<Result<(), Error>> =
|
||||
cx.background_spawn(async move { Ok(update.download_and_install()?) });
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match install.await {
|
||||
Ok(_) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Updated, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn update(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
self.set_status(AutoUpdateStatus::Checking, cx);
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
// Convert the version string to a SemanticVersion
|
||||
let new_version: SemanticVersion = ident.parse().expect("Invalid version");
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// Download the new version
|
||||
self.set_status(AutoUpdateStatus::Downloading, cx);
|
||||
|
||||
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);
|
||||
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?;
|
||||
}
|
||||
|
||||
log::info!("downloaded update. path:{:?}", downloaded_asset);
|
||||
|
||||
Ok((temp_dir, downloaded_asset))
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
[package]
|
||||
name = "account"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ui = { path = "../ui" }
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
[package]
|
||||
name = "client_keys"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
global = { path = "../global" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
129
crates/client_keys/src/lib.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use global::{constants::KEYRING_URL, first_run};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ClientKeys::set_global(cx.new(ClientKeys::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalClientKeys(Entity<ClientKeys>);
|
||||
|
||||
impl Global for GlobalClientKeys {}
|
||||
|
||||
pub struct ClientKeys {
|
||||
keys: Option<Keys>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl ClientKeys {
|
||||
/// Retrieve the Global Client Keys instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalClientKeys>().0.clone()
|
||||
}
|
||||
|
||||
/// Retrieve the Client Keys instance
|
||||
pub fn get_global(cx: &App) -> &Self {
|
||||
cx.global::<GlobalClientKeys>().0.read(cx)
|
||||
}
|
||||
|
||||
/// Set the Global Client Keys instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalClientKeys(state));
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
keys: None,
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let read_client_keys = cx.read_credentials(KEYRING_URL);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Some((_, secret))) = read_client_keys.await {
|
||||
// Update keys
|
||||
this.update(cx, |this, cx| {
|
||||
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
|
||||
this.set_keys(None, false, true, cx);
|
||||
return;
|
||||
};
|
||||
let keys = Keys::new(secret_key);
|
||||
this.set_keys(Some(keys), false, true, cx);
|
||||
})
|
||||
.ok();
|
||||
} else if *first_run() {
|
||||
// Generate a new keys and update
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_keys(cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_keys(None, false, true, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn set_keys(
|
||||
&mut self,
|
||||
keys: Option<Keys>,
|
||||
persist: bool,
|
||||
notify: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if persist {
|
||||
if let Some(keys) = keys.as_ref() {
|
||||
let username = keys.public_key().to_hex();
|
||||
let password = keys.secret_key().secret_bytes();
|
||||
let write_keys = cx.write_credentials(KEYRING_URL, &username, &password);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = write_keys.await {
|
||||
log::error!("Failed to save the client keys: {e}")
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
self.keys = keys;
|
||||
// Notify GPUI to reload UI
|
||||
if notify {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_keys(&mut self, cx: &mut Context<Self>) {
|
||||
self.set_keys(Some(Keys::generate()), true, true, cx);
|
||||
}
|
||||
|
||||
pub fn force_new_keys(&mut self, cx: &mut Context<Self>) {
|
||||
self.set_keys(Some(Keys::generate()), true, false, cx);
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> Keys {
|
||||
self.keys
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.expect("Keys should always be initialized")
|
||||
}
|
||||
|
||||
pub fn has_keys(&self) -> bool {
|
||||
self.keys.is_some()
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,17 @@ publish.workspace = true
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
reqwest.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
random_name_generator = "0.3.6"
|
||||
webbrowser = "1.0.4"
|
||||
qrcode-generator = "5.0.0"
|
||||
|
||||
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;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
108
crates/common/src/display.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use global::constants::IMAGE_RESIZE_SERVICE;
|
||||
use gpui::{Image, ImageFormat, SharedString};
|
||||
use nostr_sdk::prelude::*;
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
|
||||
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
|
||||
|
||||
pub trait DisplayProfile {
|
||||
fn avatar_url(&self, proxy: bool) -> SharedString;
|
||||
fn display_name(&self) -> SharedString;
|
||||
}
|
||||
|
||||
impl DisplayProfile for Profile {
|
||||
fn avatar_url(&self, proxy: bool) -> SharedString {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| {
|
||||
if proxy {
|
||||
format!(
|
||||
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
|
||||
)
|
||||
.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 display_name.into();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata().name.as_ref() {
|
||||
if !name.is_empty() {
|
||||
return name.into();
|
||||
}
|
||||
}
|
||||
|
||||
shorten_pubkey(self.public_key(), 4)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TextUtils {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error>;
|
||||
fn to_qr(&self) -> Option<Arc<Image>>;
|
||||
}
|
||||
|
||||
impl TextUtils for String {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error> {
|
||||
if self.starts_with("nprofile1") {
|
||||
Ok(Nip19Profile::from_bech32(self)?.public_key)
|
||||
} else if self.starts_with("npub1") {
|
||||
Ok(PublicKey::parse(self)?)
|
||||
} else {
|
||||
Err(anyhow!("Invalid public key"))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||
let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(self, QrCodeEcc::Medium, 256)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes)))
|
||||
}
|
||||
}
|
||||
|
||||
impl TextUtils for &str {
|
||||
fn to_public_key(&self) -> Result<PublicKey, Error> {
|
||||
if self.starts_with("nprofile1") {
|
||||
Ok(Nip19Profile::from_bech32(self)?.public_key)
|
||||
} else if self.starts_with("npub1") {
|
||||
Ok(PublicKey::parse(self)?)
|
||||
} else {
|
||||
Err(anyhow!("Invalid public key"))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_qr(&self) -> Option<Arc<Image>> {
|
||||
let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(self, QrCodeEcc::Medium, 256)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString {
|
||||
let Ok(pubkey) = public_key.to_bech32();
|
||||
|
||||
format!(
|
||||
"{}:{}",
|
||||
&pubkey[0..(len + 1)],
|
||||
&pubkey[pubkey.len() - len..]
|
||||
)
|
||||
.into()
|
||||
}
|
||||
47
crates/common/src/event.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::collections::HashSet;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub trait EventUtils {
|
||||
fn uniq_id(&self) -> u64;
|
||||
fn all_pubkeys(&self) -> Vec<PublicKey>;
|
||||
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool;
|
||||
}
|
||||
|
||||
impl EventUtils for Event {
|
||||
fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = vec![];
|
||||
|
||||
// Add all public keys from event
|
||||
pubkeys.push(self.pubkey);
|
||||
pubkeys.extend(self.tags.public_keys().collect::<Vec<_>>());
|
||||
|
||||
// Generate unique hash
|
||||
pubkeys
|
||||
.into_iter()
|
||||
.unique()
|
||||
.sorted()
|
||||
.collect::<Vec<_>>()
|
||||
.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn all_pubkeys(&self) -> Vec<PublicKey> {
|
||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||
public_keys.push(self.pubkey);
|
||||
|
||||
public_keys
|
||||
}
|
||||
|
||||
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool {
|
||||
let pubkeys = self.all_pubkeys();
|
||||
let a: HashSet<_> = pubkeys.iter().collect();
|
||||
let b: HashSet<_> = other.iter().collect();
|
||||
|
||||
a == b
|
||||
}
|
||||
}
|
||||
14
crates/common/src/handle_auth.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use nostr_connect::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoopAuthUrlHandler;
|
||||
|
||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
||||
Box::pin(async move {
|
||||
log::info!("Received Auth URL: {auth_url}");
|
||||
webbrowser::open(auth_url.as_str())?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,6 @@
|
||||
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
|
||||
}
|
||||
pub mod debounced_delay;
|
||||
pub mod display;
|
||||
pub mod event;
|
||||
pub mod handle_auth;
|
||||
pub mod nip05;
|
||||
pub mod nip96;
|
||||
|
||||
31
crates/common/src/nip05.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use anyhow::anyhow;
|
||||
use nostr::prelude::*;
|
||||
use reqwest::Client as ReqClient;
|
||||
|
||||
pub async fn nip05_verify(public_key: PublicKey, address: &str) -> Result<bool, anyhow::Error> {
|
||||
let req_client = ReqClient::new();
|
||||
let address = Nip05Address::parse(address)?;
|
||||
|
||||
// Get NIP-05 response
|
||||
let res = req_client.get(address.url().to_string()).send().await?;
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
let verify = nip05::verify_from_json(&public_key, &address, &json);
|
||||
|
||||
Ok(verify)
|
||||
}
|
||||
|
||||
pub async fn nip05_profile(address: &str) -> Result<Nip05Profile, anyhow::Error> {
|
||||
let req_client = ReqClient::new();
|
||||
let address = Nip05Address::parse(address)?;
|
||||
|
||||
// Get NIP-05 response
|
||||
let res = req_client.get(address.url().to_string()).send().await?;
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
if let Ok(profile) = Nip05Profile::from_json(&address, &json) {
|
||||
Ok(profile)
|
||||
} else {
|
||||
Err(anyhow!("Failed to get NIP-05 profile"))
|
||||
}
|
||||
}
|
||||
84
crates/common/src/nip96.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use anyhow::anyhow;
|
||||
use nostr::hashes::sha256::Hash as Sha256Hash;
|
||||
use nostr::hashes::Hash;
|
||||
use nostr::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use reqwest::{multipart, Client as ReqClient, Response};
|
||||
|
||||
pub(crate) fn make_multipart_form(
|
||||
file_data: Vec<u8>,
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<multipart::Form, anyhow::Error> {
|
||||
let form_file_part = multipart::Part::bytes(file_data).file_name("filename");
|
||||
|
||||
// Set the part's MIME type, or leave it as is if mime_type is None
|
||||
|
||||
let part = match mime_type {
|
||||
Some(mime) => form_file_part.mime_str(mime)?,
|
||||
None => form_file_part,
|
||||
};
|
||||
|
||||
Ok(multipart::Form::new().part("file", part))
|
||||
}
|
||||
|
||||
pub(crate) async fn upload<T>(
|
||||
signer: &T,
|
||||
desc: &ServerConfig,
|
||||
file_data: Vec<u8>,
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<Url, anyhow::Error>
|
||||
where
|
||||
T: NostrSigner,
|
||||
{
|
||||
let payload: Sha256Hash = Sha256Hash::hash(&file_data);
|
||||
let data: HttpData = HttpData::new(desc.api_url.clone(), HttpMethod::POST).payload(payload);
|
||||
let nip98_auth: String = data.to_authorization(signer).await?;
|
||||
|
||||
// Make form
|
||||
let form: multipart::Form = make_multipart_form(file_data, mime_type)?;
|
||||
|
||||
// Make req client
|
||||
let req_client = ReqClient::new();
|
||||
|
||||
// Send
|
||||
let response: Response = req_client
|
||||
.post(desc.api_url.clone())
|
||||
.header("Authorization", nip98_auth)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Parse response
|
||||
let json: Value = response.json().await?;
|
||||
let upload_response = nip96::UploadResponse::from_json(json.to_string())?;
|
||||
|
||||
if upload_response.status == UploadResponseStatus::Error {
|
||||
return Err(anyhow!(upload_response.message));
|
||||
}
|
||||
|
||||
Ok(upload_response.download_url()?.to_owned())
|
||||
}
|
||||
|
||||
pub async fn nip96_upload(
|
||||
client: &Client,
|
||||
server: &Url,
|
||||
file: Vec<u8>,
|
||||
) -> Result<Url, anyhow::Error> {
|
||||
let req_client = ReqClient::new();
|
||||
let config_url = nip96::get_server_config_url(server)?;
|
||||
|
||||
// Get
|
||||
let res = req_client.get(config_url.to_string()).send().await?;
|
||||
let json: Value = res.json().await?;
|
||||
|
||||
let config = nip96::ServerConfig::from_json(json.to_string())?;
|
||||
let signer = if client.has_signer().await {
|
||||
client.signer().await?
|
||||
} else {
|
||||
Keys::generate().into_nostr_signer()
|
||||
};
|
||||
|
||||
let url = upload(&signer, &config, file, None).await?;
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
@@ -1,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,63 @@
|
||||
[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.2.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" }
|
||||
identity = { path = "../identity" }
|
||||
theme = { path = "../theme" }
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
registry = { path = "../registry" }
|
||||
settings = { path = "../settings" }
|
||||
client_keys = { path = "../client_keys" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
|
||||
rust-i18n.workspace = true
|
||||
i18n.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
itertools.workspace = true
|
||||
dirs.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
oneshot.workspace = true
|
||||
|
||||
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": "23.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
|
||||
@@ -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,32 +1,43 @@
|
||||
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 auto_update::AutoUpdater;
|
||||
use client_keys::ClientKeys;
|
||||
use common::display::DisplayProfile;
|
||||
use global::constants::DEFAULT_SIDEBAR_WIDTH;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
actions, div, px, rems, Action, App, AppContext, Axis, Context, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
Subscription, Window,
|
||||
};
|
||||
use i18n::{shared_t, t};
|
||||
use identity::Identity;
|
||||
use itertools::Itertools;
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use registry::{Registry, RegistrySignal};
|
||||
use serde::Deserialize;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::{ActiveTheme, Theme, ThemeMode};
|
||||
use title_bar::TitleBar;
|
||||
use ui::actions::OpenProfile;
|
||||
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::popup_menu::PopupMenuExt;
|
||||
use ui::{h_flex, ContextModal, IconName, Root, Sizable, StyledExt};
|
||||
|
||||
const CACHE_SIZE: usize = 200;
|
||||
const MODAL_WIDTH: f32 = 420.;
|
||||
const SIDEBAR_WIDTH: f32 = 280.;
|
||||
use crate::views::compose::compose_button;
|
||||
use crate::views::screening::Screening;
|
||||
use crate::views::user_profile::UserProfile;
|
||||
use crate::views::{
|
||||
backup_keys, chat, login, messaging_relays, new_account, onboarding, preferences, sidebar,
|
||||
startup, user_profile, welcome,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
|
||||
ChatSpace::new(window, cx)
|
||||
@@ -42,6 +53,8 @@ pub fn new_account(window: &mut Window, cx: &mut App) {
|
||||
ChatSpace::set_center_panel(panel, window, cx);
|
||||
}
|
||||
|
||||
actions!(user, [DarkMode, Settings, Logout]);
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum PanelKind {
|
||||
Room(u64),
|
||||
@@ -52,41 +65,33 @@ pub enum PanelKind {
|
||||
pub enum ModalKind {
|
||||
Profile,
|
||||
Compose,
|
||||
Contact,
|
||||
Relay,
|
||||
Onboarding,
|
||||
SetupRelay,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = story, no_json)]
|
||||
pub struct SelectLocale(SharedString);
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = modal, no_json)]
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChatSpace {
|
||||
titlebar: bool,
|
||||
title_bar: Entity<TitleBar>,
|
||||
dock: Entity<DockArea>,
|
||||
#[allow(unused)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
subscriptions: SmallVec<[Subscription; 5]>,
|
||||
}
|
||||
|
||||
impl ChatSpace {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let title_bar = cx.new(|_| TitleBar::new());
|
||||
let dock = cx.new(|cx| {
|
||||
let panel = Arc::new(onboarding::init(window, cx));
|
||||
let panel = Arc::new(startup::init(window, cx));
|
||||
let center = DockItem::panel(panel);
|
||||
let mut dock = DockArea::new(window, cx);
|
||||
// Initialize the dock area with the center panel
|
||||
@@ -95,48 +100,157 @@ impl ChatSpace {
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let account = Account::global(cx);
|
||||
let registry = Registry::global(cx);
|
||||
let client_keys = ClientKeys::global(cx);
|
||||
let identity = Identity::global(cx);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
// Observe the client keys and show an alert modal if they fail to initialize
|
||||
subscriptions.push(cx.observe_in(
|
||||
&account,
|
||||
&client_keys,
|
||||
window,
|
||||
|this: &mut ChatSpace, account, window, cx| {
|
||||
if account.read(cx).profile.is_some() {
|
||||
this.open_chats(window, cx);
|
||||
|_this: &mut Self, state, window, cx| {
|
||||
if !state.read(cx).has_keys() {
|
||||
let title = SharedString::new(t!("startup.client_keys_warning"));
|
||||
let desc = SharedString::new(t!("startup.client_keys_desc"));
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.overlay_closable(false)
|
||||
.show_close(false)
|
||||
.keyboard(false)
|
||||
.confirm()
|
||||
.button_props(
|
||||
ModalButtonProps::default()
|
||||
.cancel_text(t!("startup.create_new_keys"))
|
||||
.ok_text(t!("common.allow")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_40()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(title.clone()),
|
||||
)
|
||||
.child(desc.clone()),
|
||||
)
|
||||
.on_cancel(|_, _window, cx| {
|
||||
ClientKeys::global(cx).update(cx, |this, cx| {
|
||||
this.new_keys(cx);
|
||||
});
|
||||
// true: Close modal
|
||||
true
|
||||
})
|
||||
.on_ok(|_, window, cx| {
|
||||
ClientKeys::global(cx).update(cx, |this, cx| {
|
||||
this.load(window, cx);
|
||||
});
|
||||
// true: Close modal
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
// Observe the identity and show onboarding if it fails to initialize
|
||||
subscriptions.push(cx.observe_in(
|
||||
&identity,
|
||||
window,
|
||||
|this: &mut Self, state, window, cx| {
|
||||
if !state.read(cx).has_signer() {
|
||||
this.set_onboarding_panels(window, cx);
|
||||
} else {
|
||||
this.open_onboarding(window, cx);
|
||||
this.set_chat_panels(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
// Automatically run load function when UserProfile is created
|
||||
subscriptions.push(cx.observe_new::<UserProfile>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
// Automatically run load function when Screening is created
|
||||
subscriptions.push(cx.observe_new::<Screening>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
// Subscribe to open chat room requests
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
®istry,
|
||||
window,
|
||||
|this: &mut Self, _state, event, window, cx| {
|
||||
match event {
|
||||
RegistrySignal::Open(room) => {
|
||||
if let Some(room) = room.upgrade() {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
let panel = chat::init(room, window, cx);
|
||||
// Load messages on panel creation
|
||||
panel.update(cx, |this, cx| {
|
||||
this.load_messages(window, cx);
|
||||
});
|
||||
|
||||
this.add_panel(panel, DockPlacement::Center, window, cx);
|
||||
});
|
||||
} else {
|
||||
window.push_notification(t!("common.room_error"), cx);
|
||||
}
|
||||
}
|
||||
RegistrySignal::Close(..) => {
|
||||
this.dock.update(cx, |this, cx| {
|
||||
this.focus_tab_panel(window, cx);
|
||||
|
||||
cx.defer_in(window, |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
dock,
|
||||
title_bar,
|
||||
subscriptions,
|
||||
titlebar: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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>) {
|
||||
pub fn set_onboarding_panels(&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);
|
||||
|
||||
pub fn set_chat_panels(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let registry = Registry::global(cx);
|
||||
let weak_dock = self.dock.downgrade();
|
||||
|
||||
// The left panel will render sidebar
|
||||
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
|
||||
// The center panel will render chat rooms (as tabs)
|
||||
let center = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(
|
||||
@@ -152,124 +266,143 @@ impl ChatSpace {
|
||||
cx,
|
||||
);
|
||||
|
||||
// Update dock
|
||||
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);
|
||||
|
||||
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();
|
||||
// Load all chat rooms from the database
|
||||
registry.update(cx, |this, cx| {
|
||||
this.load_rooms(window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
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_settings(&mut self, _ev: &Settings, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = preferences::init(window, cx);
|
||||
let title = SharedString::new(t!("common.preferences"));
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let exist = client.database().query(filter).await?.first().is_some();
|
||||
|
||||
Ok(exist)
|
||||
})
|
||||
window.open_modal(cx, move |modal, _, _| {
|
||||
modal
|
||||
.title(title.clone())
|
||||
.width(px(480.))
|
||||
.child(view.clone())
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
};
|
||||
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_modal_action(
|
||||
fn on_sign_out(&mut self, _ev: &Logout, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let identity = Identity::global(cx);
|
||||
// TODO: save current session?
|
||||
identity.update(cx, |this, cx| {
|
||||
this.unload(window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn on_open_profile(&mut self, ev: &OpenProfile, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let public_key = ev.0;
|
||||
let profile = user_profile::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.alert()
|
||||
.show_close(true)
|
||||
.overlay_closable(true)
|
||||
.child(profile.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text(t!("profile.njump")))
|
||||
.on_ok(move |_, _window, cx| {
|
||||
let Ok(bech32) = public_key.to_bech32();
|
||||
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn render_titlebar_left_side(
|
||||
&mut self,
|
||||
action: &ToggleModal,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match action.modal {
|
||||
ModalKind::Profile => {
|
||||
let profile = profile::init(window, cx);
|
||||
_window: &mut Window,
|
||||
_cx: &Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let compose_button = compose_button().into_any_element();
|
||||
|
||||
window.open_modal(cx, move |modal, _, _| {
|
||||
modal
|
||||
.title("Profile")
|
||||
.width(px(MODAL_WIDTH))
|
||||
.child(profile.clone())
|
||||
})
|
||||
}
|
||||
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())
|
||||
});
|
||||
}
|
||||
};
|
||||
h_flex().gap_1().child(compose_button)
|
||||
}
|
||||
|
||||
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
|
||||
fn render_titlebar_right_side(
|
||||
&mut self,
|
||||
profile: &Profile,
|
||||
_window: &mut Window,
|
||||
cx: &Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let need_backup = Identity::read_global(cx).need_backup();
|
||||
let relay_ready = Identity::read_global(cx).relay_ready();
|
||||
|
||||
let updating = AutoUpdater::read_global(cx).status.is_updating();
|
||||
let updated = AutoUpdater::read_global(cx).status.is_updated();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.when(updating, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.h_6()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.bg(cx.theme().ghost_element_background_alt)
|
||||
.child(shared_t!("auto_update.updating")),
|
||||
)
|
||||
})
|
||||
.when(updated, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.id("updated")
|
||||
.h_6()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.bg(cx.theme().ghost_element_background_alt)
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.active(|this| this.bg(cx.theme().ghost_element_active))
|
||||
.child(shared_t!("auto_update.updated"))
|
||||
.on_click(|_, _window, cx| {
|
||||
cx.restart(None);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when_some(relay_ready, |this, status| {
|
||||
this.when(!status, |this| this.child(messaging_relays::relay_button()))
|
||||
})
|
||||
.when_some(need_backup, |this, keys| {
|
||||
this.child(backup_keys::backup_button(keys.to_owned()))
|
||||
})
|
||||
.child(
|
||||
Button::new("user")
|
||||
.small()
|
||||
.reverse()
|
||||
.transparent()
|
||||
.icon(IconName::CaretDown)
|
||||
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.49)))
|
||||
.popup_menu(|this, _window, _cx| {
|
||||
this.menu(t!("user.dark_mode"), Box::new(DarkMode))
|
||||
.menu(t!("user.settings"), Box::new(Settings))
|
||||
.separator()
|
||||
.menu(t!("user.sign_out"), Box::new(Logout))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) 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 +416,28 @@ impl ChatSpace {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn all_panels(window: &mut Window, cx: &mut App) -> Option<Vec<u64>> {
|
||||
let Some(Some(root)) = window.root::<Root>() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let ids = chatspace
|
||||
.read(cx)
|
||||
.dock
|
||||
.read(cx)
|
||||
.items
|
||||
.panel_ids(cx)
|
||||
.into_iter()
|
||||
.filter_map(|panel| panel.parse::<u64>().ok())
|
||||
.collect_vec();
|
||||
|
||||
Some(ids)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ChatSpace {
|
||||
@@ -290,73 +445,43 @@ impl Render for ChatSpace {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
// Only render titlebar element if user is logged in
|
||||
if let Some(identity) = Identity::read_global(cx).public_key() {
|
||||
let profile = Registry::read_global(cx).get_person(&identity, cx);
|
||||
|
||||
let left_side = self
|
||||
.render_titlebar_left_side(window, cx)
|
||||
.into_any_element();
|
||||
|
||||
let right_side = self
|
||||
.render_titlebar_right_side(&profile, window, cx)
|
||||
.into_any_element();
|
||||
|
||||
self.title_bar.update(cx, |this, _cx| {
|
||||
this.set_children(vec![left_side, right_side]);
|
||||
})
|
||||
}
|
||||
|
||||
div()
|
||||
.on_action(cx.listener(Self::on_settings))
|
||||
.on_action(cx.listener(Self::on_dark_mode))
|
||||
.on_action(cx.listener(Self::on_sign_out))
|
||||
.on_action(cx.listener(Self::on_open_profile))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
image_cache(cache_provider("image-cache", CACHE_SIZE))
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,276 +1,219 @@
|
||||
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 gpui::{
|
||||
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||
WindowBounds, WindowKind, WindowOptions,
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use gpui::{point, SharedString, TitlebarOptions};
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
||||
use nostr_sdk::{
|
||||
nips::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 std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use assets::Assets;
|
||||
use common::event::EventUtils;
|
||||
use global::constants::{
|
||||
ALL_MESSAGES_ID, APP_ID, APP_NAME, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
|
||||
METADATA_BATCH_TIMEOUT, NEW_MESSAGE_ID, SEARCH_RELAYS,
|
||||
};
|
||||
use global::{nostr_client, set_all_gift_wraps_fetched, NostrSignal};
|
||||
use gpui::{
|
||||
actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||
SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations,
|
||||
WindowKind, WindowOptions,
|
||||
};
|
||||
use identity::Identity;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use registry::Registry;
|
||||
use smol::channel::{self, Sender};
|
||||
use theme::Theme;
|
||||
use ui::Root;
|
||||
|
||||
use crate::chatspace::ChatSpace;
|
||||
|
||||
pub(crate) mod asset;
|
||||
pub(crate) mod chatspace;
|
||||
pub(crate) mod lru_cache;
|
||||
pub(crate) mod views;
|
||||
|
||||
i18n::init!();
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Signal {
|
||||
/// Receive event
|
||||
Event(Event),
|
||||
/// Receive metadata
|
||||
Metadata(Box<(PublicKey, Option<Metadata>)>),
|
||||
/// Receive eose
|
||||
Eose,
|
||||
/// Receive app updates
|
||||
AppUpdates(Event),
|
||||
}
|
||||
|
||||
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 the Nostr Client
|
||||
let client = nostr_client();
|
||||
|
||||
// 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
|
||||
let (signal_tx, signal_rx) = channel::bounded::<NostrSignal>(2048);
|
||||
let (mta_tx, mta_rx) = channel::bounded::<PublicKey>(1024);
|
||||
let (event_tx, event_rx) = channel::unbounded::<Event>();
|
||||
|
||||
let signal_tx_clone = signal_tx.clone();
|
||||
let mta_tx_clone = mta_tx.clone();
|
||||
|
||||
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);
|
||||
}
|
||||
// Subscribe for app updates from the bootstrap relays.
|
||||
if let Err(e) = connect(client).await {
|
||||
log::error!("Failed to connect to bootstrap relays: {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
|
||||
// Handle Nostr notifications.
|
||||
//
|
||||
// Send the redefined signal back to GPUI via channel.
|
||||
if let Err(e) =
|
||||
handle_nostr_notifications(client, &signal_tx_clone, &mta_tx_clone, &event_tx).await
|
||||
{
|
||||
log::error!("Failed to subscribe for app updates: {}", e);
|
||||
log::error!("Failed to handle Nostr notifications: {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 duration = Duration::from_millis(METADATA_BATCH_TIMEOUT);
|
||||
let mut batch: BTreeSet<PublicKey> = BTreeSet::new();
|
||||
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
/// Internal events for the metadata batching system
|
||||
enum BatchEvent {
|
||||
NewKeys(PublicKey),
|
||||
Timeout,
|
||||
Closed,
|
||||
}
|
||||
|
||||
loop {
|
||||
let mut timeout = Box::pin(Timer::after(BATCH_TIMEOUT).fuse());
|
||||
let duration = smol::Timer::after(duration);
|
||||
|
||||
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,
|
||||
let recv = || async {
|
||||
if let Ok(public_key) = mta_rx.recv().await {
|
||||
BatchEvent::NewKeys(public_key)
|
||||
} else {
|
||||
BatchEvent::Closed
|
||||
}
|
||||
};
|
||||
|
||||
let timeout = || async {
|
||||
duration.await;
|
||||
BatchEvent::Timeout
|
||||
};
|
||||
|
||||
match smol::future::or(recv(), timeout()).await {
|
||||
BatchEvent::NewKeys(public_key) => {
|
||||
batch.insert(public_key);
|
||||
// Process immediately if batch limit reached
|
||||
if batch.len() >= METADATA_BATCH_LIMIT {
|
||||
sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
_ = timeout => {
|
||||
BatchEvent::Timeout => {
|
||||
if !batch.is_empty() {
|
||||
sync_metadata(mem::take(&mut batch), client, opts).await;
|
||||
sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
BatchEvent::Closed => {
|
||||
if !batch.is_empty() {
|
||||
sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.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();
|
||||
let mut counter = 0;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
loop {
|
||||
// Signer is unset, probably user is not ready to retrieve gift wrap events
|
||||
if client.signer().await.is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut pubkeys = vec![];
|
||||
pubkeys.extend(event.tags.public_keys());
|
||||
pubkeys.push(event.pubkey);
|
||||
let duration = smol::Timer::after(Duration::from_secs(30));
|
||||
|
||||
// Send all pubkeys to the batch to sync metadata
|
||||
batch_tx.send(pubkeys).await.ok();
|
||||
let recv = || async {
|
||||
// no inline
|
||||
(event_rx.recv().await).ok()
|
||||
};
|
||||
|
||||
// Save the event to the database, use for query directly.
|
||||
client.database().save_event(&event).await.ok();
|
||||
let timeout = || async {
|
||||
duration.await;
|
||||
None
|
||||
};
|
||||
|
||||
// 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();
|
||||
match smol::future::or(recv(), timeout()).await {
|
||||
Some(event) => {
|
||||
// Process the gift wrap event unwrapping
|
||||
let cached = try_unwrap_event(&signal_tx, &mta_tx, &event, false).await;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Increment the total messages counter if message is not from cache
|
||||
if !cached {
|
||||
counter += 1;
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
||||
if all_id == *subscription_id {
|
||||
event_tx.send(Signal::Eose).await.ok();
|
||||
}
|
||||
|
||||
// Send partial finish signal to GPUI
|
||||
if counter >= 20 {
|
||||
signal_tx.send(NostrSignal::PartialFinish).await.ok();
|
||||
// Reset counter
|
||||
counter = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None => {
|
||||
// Notify the UI that the processing is finished
|
||||
signal_tx.send(NostrSignal::Finish).await.ok();
|
||||
// Mark all gift wraps as fetched
|
||||
// For the next time Coop only needs to process new gift wraps
|
||||
set_all_gift_wraps_fetched().await;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event channel is no longer needed when all gift wrap events have been processed
|
||||
event_rx.close();
|
||||
})
|
||||
.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)),
|
||||
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()
|
||||
};
|
||||
|
||||
@@ -285,48 +228,78 @@ fn main() {
|
||||
|
||||
// Root Entity
|
||||
cx.new(|cx| {
|
||||
cx.activate(true);
|
||||
// Initialize the tokio runtime
|
||||
gpui_tokio::init(cx);
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize app registry
|
||||
registry::init(cx);
|
||||
// Initialize settings
|
||||
settings::init(cx);
|
||||
// Initialize client keys
|
||||
client_keys::init(cx);
|
||||
// Initialize identity
|
||||
identity::init(window, cx);
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
|
||||
// Initialize chat state
|
||||
chats::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 {
|
||||
let all_messages = SubscriptionId::new(ALL_MESSAGES_ID);
|
||||
|
||||
while let Ok(signal) = signal_rx.recv().await {
|
||||
cx.update(|window, cx| {
|
||||
let chats = ChatRegistry::global(cx);
|
||||
let auto_updater = AutoUpdater::global(cx);
|
||||
let registry = Registry::global(cx);
|
||||
let identity = Identity::read_global(cx);
|
||||
|
||||
match signal {
|
||||
Signal::Event(event) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.push_message(event, window, cx)
|
||||
// Load chat rooms and stop the loading status
|
||||
NostrSignal::Finish => {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.load_rooms(window, cx);
|
||||
this.set_loading(false, cx);
|
||||
// Send a signal to refresh all opened rooms' messages
|
||||
if let Some(ids) = ChatSpace::all_panels(window, cx) {
|
||||
this.refresh_rooms(ids, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
Signal::Metadata(data) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.add_profile(data.0, data.1, cx)
|
||||
// Load chat rooms without setting as finished
|
||||
NostrSignal::PartialFinish => {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.load_rooms(window, cx);
|
||||
// Send a signal to refresh all opened rooms' messages
|
||||
if let Some(ids) = ChatSpace::all_panels(window, cx) {
|
||||
this.refresh_rooms(ids, 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)
|
||||
// Load chat rooms without setting as finished
|
||||
NostrSignal::Eose(subscription_id) => {
|
||||
// Only load chat rooms if the subscription ID matches the all_messages_sub_id
|
||||
if subscription_id == all_messages {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.load_rooms(window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Add the new metadata to the registry or update the existing one
|
||||
NostrSignal::Metadata(event) => {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.insert_or_update_person(event, cx);
|
||||
});
|
||||
}
|
||||
Signal::AppUpdates(event) => {
|
||||
// TODO: add settings for auto updates
|
||||
auto_updater.update(cx, |this, cx| {
|
||||
this.update(event, cx);
|
||||
})
|
||||
// Convert the gift wrapped message to a message
|
||||
NostrSignal::GiftWrap(event) => {
|
||||
if let Some(public_key) = identity.public_key() {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.event_to_message(public_key, event, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
NostrSignal::Notice(_msg) => {
|
||||
// window.push_notification(msg, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
@@ -342,48 +315,154 @@ fn main() {
|
||||
});
|
||||
}
|
||||
|
||||
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?;
|
||||
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();
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
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();
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
}
|
||||
|
||||
async fn connect(client: &Client) -> Result<(), Error> {
|
||||
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
||||
client.add_relay(relay).await?;
|
||||
}
|
||||
|
||||
log::info!("Connected to bootstrap relays");
|
||||
|
||||
for relay in SEARCH_RELAYS.into_iter() {
|
||||
client.add_relay(relay).await?;
|
||||
}
|
||||
|
||||
log::info!("Connected to search relays");
|
||||
|
||||
// Establish connection to relays
|
||||
client.connect().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);
|
||||
async fn handle_nostr_notifications(
|
||||
client: &Client,
|
||||
signal_tx: &Sender<NostrSignal>,
|
||||
mta_tx: &Sender<PublicKey>,
|
||||
event_tx: &Sender<Event>,
|
||||
) -> Result<(), Error> {
|
||||
let new_messages = SubscriptionId::new(NEW_MESSAGE_ID);
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
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"))
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events: BTreeSet<EventId> = BTreeSet::new();
|
||||
let mut processed_dm_relays: BTreeSet<PublicKey> = BTreeSet::new();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
let RelayPoolNotification::Message { message, .. } = notification else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match message {
|
||||
RelayMessage::Event {
|
||||
event,
|
||||
subscription_id,
|
||||
} => {
|
||||
if processed_events.contains(&event.id) {
|
||||
continue;
|
||||
}
|
||||
// Skip events that have already been processed
|
||||
processed_events.insert(event.id);
|
||||
|
||||
match event.kind {
|
||||
Kind::GiftWrap => {
|
||||
if *subscription_id == new_messages {
|
||||
let event = event.as_ref();
|
||||
_ = try_unwrap_event(signal_tx, mta_tx, event, false).await;
|
||||
} else {
|
||||
event_tx.send(event.into_owned()).await.ok();
|
||||
}
|
||||
}
|
||||
Kind::Metadata => {
|
||||
signal_tx
|
||||
.send(NostrSignal::Metadata(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Kind::ContactList => {
|
||||
if let Ok(true) = check_author(client, &event).await {
|
||||
for public_key in event.tags.public_keys().copied() {
|
||||
mta_tx.send(public_key).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::RelayList => {
|
||||
if processed_dm_relays.contains(&event.pubkey) {
|
||||
continue;
|
||||
}
|
||||
// Skip public keys that have already been processed
|
||||
processed_dm_relays.insert(event.pubkey);
|
||||
|
||||
let filter = Filter::new()
|
||||
.author(event.pubkey)
|
||||
.kind(Kind::InboxRelays)
|
||||
.limit(1);
|
||||
|
||||
if let Ok(output) = client.subscribe(filter, Some(opts)).await {
|
||||
log::info!(
|
||||
"Subscribed to get DM relays: {} - Relays: {:?}",
|
||||
event.pubkey.to_bech32().unwrap(),
|
||||
output.success
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
||||
signal_tx
|
||||
.send(NostrSignal::Eose(subscription_id.into_owned()))
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_metadata(
|
||||
buffer: HashSet<PublicKey>,
|
||||
client: &Client,
|
||||
opts: SubscribeAutoCloseOptions,
|
||||
) {
|
||||
let kinds = vec![
|
||||
Kind::Metadata,
|
||||
Kind::ContactList,
|
||||
Kind::InboxRelays,
|
||||
Kind::UserStatus,
|
||||
];
|
||||
async fn check_author(client: &Client, event: &Event) -> Result<bool, Error> {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
Ok(public_key == event.pubkey)
|
||||
}
|
||||
|
||||
async fn sync_data_for_pubkeys(client: &Client, public_keys: BTreeSet<PublicKey>) {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(buffer.iter().cloned())
|
||||
.limit(buffer.len() * kinds.len())
|
||||
.limit(public_keys.len() * kinds.len())
|
||||
.authors(public_keys)
|
||||
.kinds(kinds);
|
||||
|
||||
if let Err(e) = client
|
||||
@@ -394,7 +473,97 @@ async fn sync_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
/// Stores an unwrapped event in local database with reference to original
|
||||
async fn set_unwrapped(root: EventId, unwrapped: &Event) -> Result<(), Error> {
|
||||
let client = nostr_client();
|
||||
|
||||
// Save unwrapped event
|
||||
client.database().save_event(unwrapped).await?;
|
||||
|
||||
// Create a reference event pointing to the unwrapped event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, "")
|
||||
.tags(vec![Tag::identifier(root), Tag::event(unwrapped.id)])
|
||||
.sign(&Keys::generate())
|
||||
.await?;
|
||||
|
||||
// Save reference event
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieves a previously unwrapped event from local database
|
||||
async fn get_unwrapped(root: EventId) -> Result<Event, Error> {
|
||||
let client = nostr_client();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(root)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let target_id = event.tags.event_ids().collect_vec()[0];
|
||||
|
||||
if let Some(event) = client.database().event_by_id(target_id).await? {
|
||||
Ok(event)
|
||||
} else {
|
||||
Err(anyhow!("Event not found."))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Event is not cached yet."))
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn try_unwrap_event(
|
||||
signal_tx: &Sender<NostrSignal>,
|
||||
mta_tx: &Sender<PublicKey>,
|
||||
event: &Event,
|
||||
incoming: bool,
|
||||
) -> bool {
|
||||
let client = nostr_client();
|
||||
let mut is_cached = false;
|
||||
|
||||
let event = match get_unwrapped(event.id).await {
|
||||
Ok(event) => {
|
||||
is_cached = true;
|
||||
event
|
||||
}
|
||||
Err(_) => {
|
||||
match client.unwrap_gift_wrap(event).await {
|
||||
Ok(unwrap) => {
|
||||
// Sign the unwrapped event with a RANDOM KEYS
|
||||
let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else {
|
||||
log::error!("Failed to sign event");
|
||||
return false;
|
||||
};
|
||||
|
||||
// Save this event to the database for future use.
|
||||
if let Err(e) = set_unwrapped(event.id, &unwrapped).await {
|
||||
log::warn!("Failed to cache unwrapped event: {e}")
|
||||
}
|
||||
|
||||
unwrapped
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to unwrap event: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get all pubkeys from the event
|
||||
let all_pubkeys = event.all_pubkeys();
|
||||
|
||||
// Send all pubkeys to the metadata batch to sync data
|
||||
for public_key in all_pubkeys {
|
||||
mta_tx.send(public_key).await.ok();
|
||||
}
|
||||
|
||||
// Send a notify to GPUI if this is a new message
|
||||
if incoming {
|
||||
signal_tx.send(NostrSignal::GiftWrap(event)).await.ok();
|
||||
}
|
||||
|
||||
is_cached
|
||||
}
|
||||
|
||||
258
crates/coop/src/views/backup_keys.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
use dirs::document_dir;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, Render,
|
||||
SharedString, Styled, Window,
|
||||
};
|
||||
use i18n::{shared_t, t};
|
||||
use identity::Identity;
|
||||
use nostr_sdk::prelude::*;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{divider, h_flex, v_flex, ContextModal, Disableable, IconName, Sizable};
|
||||
|
||||
pub fn backup_button(keys: Keys) -> impl IntoElement {
|
||||
div().child(
|
||||
Button::new("backup")
|
||||
.icon(IconName::Info)
|
||||
.label(t!("new_account.backup_label"))
|
||||
.danger()
|
||||
.xsmall()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.on_click(move |_, window, cx| {
|
||||
let title = SharedString::new(t!("new_account.backup_label"));
|
||||
let keys = keys.clone();
|
||||
let view = cx.new(|cx| BackupKeys::new(&keys, window, cx));
|
||||
let weak_view = view.downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
|
||||
modal
|
||||
.confirm()
|
||||
.title(title.clone())
|
||||
.child(view.clone())
|
||||
.button_props(
|
||||
ModalButtonProps::default()
|
||||
.cancel_text(t!("new_account.backup_skip"))
|
||||
.ok_text(t!("new_account.backup_download")),
|
||||
)
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
this.download(window, cx);
|
||||
})
|
||||
.ok();
|
||||
// true to close the modal
|
||||
false
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct BackupKeys {
|
||||
password: Entity<InputState>,
|
||||
pubkey_input: Entity<InputState>,
|
||||
secret_input: Entity<InputState>,
|
||||
error: Option<SharedString>,
|
||||
copied: bool,
|
||||
}
|
||||
|
||||
impl BackupKeys {
|
||||
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 password = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
|
||||
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 {
|
||||
password,
|
||||
pubkey_input,
|
||||
secret_input,
|
||||
error: None,
|
||||
copied: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_secret(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let item = ClipboardItem::new_string(self.secret_input.read(cx).value().to_string());
|
||||
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 {
|
||||
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();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let document_dir = document_dir().expect("Failed to get document directory");
|
||||
let password = self.password.read(cx).value().to_string();
|
||||
|
||||
if password.is_empty() {
|
||||
self.set_error(t!("login.password_is_required"), window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let path = cx.prompt_for_new_path(&document_dir);
|
||||
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))) => {
|
||||
cx.update(|window, cx| {
|
||||
match fs::write(&path, nsec) {
|
||||
Ok(_) => {
|
||||
Identity::global(cx).update(cx, |this, cx| {
|
||||
this.clear_need_backup(password, cx);
|
||||
});
|
||||
// Close the current modal
|
||||
window.close_modal(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
_ => {
|
||||
log::error!("Failed to save backup keys");
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BackupKeys {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("new_account.backup_description")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(shared_t!("common.pubkey"))
|
||||
.child(TextInput::new(&self.pubkey_input).small())
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("new_account.backup_pubkey_note")),
|
||||
),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(shared_t!("common.secret"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(TextInput::new(&self.secret_input).small())
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.ghost()
|
||||
.disabled(self.copied)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy_secret(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("new_account.backup_secret_note")),
|
||||
),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(shared_t!("login.set_password"))
|
||||
.child(TextInput::new(&self.password).small())
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
944
crates/coop/src/views/chat/mod.rs
Normal file
@@ -0,0 +1,944 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use common::display::DisplayProfile;
|
||||
use common::nip96::nip96_upload;
|
||||
use global::nostr_client;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, list, px, red, rems, white, Action, AnyElement, App, AppContext, ClipboardItem,
|
||||
Context, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ListAlignment, ListState, MouseButton, ObjectFit,
|
||||
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
|
||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use i18n::t;
|
||||
use identity::Identity;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use registry::message::Message;
|
||||
use registry::room::{Room, RoomKind, RoomSignal, SendError};
|
||||
use registry::Registry;
|
||||
use serde::Deserialize;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::emoji_picker::EmojiPicker;
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::notification::Notification;
|
||||
use ui::popup_menu::PopupMenu;
|
||||
use ui::text::RichText;
|
||||
use ui::{
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
mod subject;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub struct ChangeSubject(pub String);
|
||||
|
||||
pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Arc<Entity<Chat>> {
|
||||
Arc::new(Chat::new(room, window, cx))
|
||||
}
|
||||
|
||||
pub struct Chat {
|
||||
// Panel
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
// Chat Room
|
||||
room: Entity<Room>,
|
||||
messages: Entity<Vec<Rc<RefCell<Message>>>>,
|
||||
text_data: HashMap<EventId, RichText>,
|
||||
list_state: ListState,
|
||||
// New Message
|
||||
input: Entity<InputState>,
|
||||
replies_to: Entity<Option<Vec<Message>>>,
|
||||
// Media Attachment
|
||||
attaches: Entity<Option<Vec<Url>>>,
|
||||
uploading: bool,
|
||||
// System
|
||||
image_cache: Entity<RetainAllImageCache>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl Chat {
|
||||
pub fn new(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let attaches = cx.new(|_| None);
|
||||
let replies_to = cx.new(|_| None);
|
||||
let messages = cx.new(|_| vec![]);
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("chat.placeholder"))
|
||||
.multi_line()
|
||||
.prevent_new_line_on_enter()
|
||||
.rows(1)
|
||||
.multi_line()
|
||||
.auto_grow(1, 20)
|
||||
.clean_on_escape()
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, input, event, window, cx| {
|
||||
match event {
|
||||
InputEvent::PressEnter { .. } => {
|
||||
this.send_message(window, cx);
|
||||
}
|
||||
InputEvent::Change(text) => {
|
||||
this.mention_popup(text, input, cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this, _, signal, window, cx| {
|
||||
match signal {
|
||||
RoomSignal::NewMessage(event) => {
|
||||
// Check if the incoming message is the same as the new message created by optimistic update
|
||||
if this.prevent_duplicate_message(event, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let message = event.clone().into_rc();
|
||||
|
||||
cx.update_entity(&this.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
this.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
RoomSignal::Refresh => {
|
||||
this.load_messages(window, cx);
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
// Initialize list state
|
||||
// [item_count] always equal to 1 at the beginning
|
||||
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.), {
|
||||
let this = cx.entity().downgrade();
|
||||
move |ix, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_message(ix, window, cx).into_any_element()
|
||||
})
|
||||
.unwrap_or(Empty.into_any())
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
id: room.read(cx).id.to_string().into(),
|
||||
image_cache: RetainAllImageCache::new(cx),
|
||||
focus_handle: cx.focus_handle(),
|
||||
uploading: false,
|
||||
text_data: HashMap::new(),
|
||||
room,
|
||||
messages,
|
||||
list_state,
|
||||
input,
|
||||
replies_to,
|
||||
attaches,
|
||||
subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Load all messages belonging to this room
|
||||
pub(crate) fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let room = self.room.read(cx);
|
||||
let load_messages = room.load_messages(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match load_messages.await {
|
||||
Ok(messages) => {
|
||||
this.update(cx, |this, cx| {
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let new_len = messages.len();
|
||||
|
||||
// Extend the messages list with the new events
|
||||
this.messages.update(cx, |this, cx| {
|
||||
this.extend(messages.into_iter().map(|e| e.into_rc()));
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Update list state with the new messages
|
||||
this.list_state.splice(old_len..old_len, new_len);
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn mention_popup(&mut self, _text: &str, _input: &Entity<InputState>, _cx: &mut Context<Self>) {
|
||||
// TODO: open mention popup at current cursor position
|
||||
}
|
||||
|
||||
/// Get user input content and merged all attachments
|
||||
fn input_content(&self, cx: &Context<Self>) -> String {
|
||||
let mut content = self.input.read(cx).value().trim().to_string();
|
||||
|
||||
// Get all attaches and merge its with message
|
||||
if let Some(attaches) = self.attaches.read(cx).as_ref() {
|
||||
if !attaches.is_empty() {
|
||||
content = format!(
|
||||
"{}\n{}",
|
||||
content,
|
||||
attaches
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect_vec()
|
||||
.join("\n")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
// TODO: find a better way to prevent duplicate messages during optimistic updates
|
||||
fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context<Self>) -> bool {
|
||||
let Some(identity) = Identity::read_global(cx).public_key() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if new_msg.author != identity {
|
||||
return false;
|
||||
}
|
||||
|
||||
let min_timestamp = new_msg.created_at.as_u64().saturating_sub(10);
|
||||
|
||||
self.messages
|
||||
.read(cx)
|
||||
.iter()
|
||||
.filter(|m| m.borrow().author == identity)
|
||||
.any(|existing| {
|
||||
let existing = existing.borrow();
|
||||
// Check if messages are within the time window
|
||||
(existing.created_at.as_u64() >= min_timestamp) &&
|
||||
// Compare content and author
|
||||
(existing.content == new_msg.content) &&
|
||||
(existing.author == new_msg.author)
|
||||
})
|
||||
}
|
||||
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Return if user is not logged in
|
||||
let Some(identity) = Identity::read_global(cx).public_key() else {
|
||||
// window.push_notification("Login is required", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Get the message which includes all attachments
|
||||
let content = self.input_content(cx);
|
||||
// Get the backup setting
|
||||
let backup = AppSettings::get_backup_messages(cx);
|
||||
|
||||
// Return if message is empty
|
||||
if content.trim().is_empty() {
|
||||
window.push_notification(t!("chat.empty_message_error"), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporary disable input
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
this.set_disabled(true, cx);
|
||||
});
|
||||
|
||||
// Get replies_to if it's present
|
||||
let replies = self.replies_to.read(cx).as_ref();
|
||||
|
||||
// Get the current room entity
|
||||
let room = self.room.read(cx);
|
||||
|
||||
// Create a temporary message for optimistic update
|
||||
let temp_message = room.create_temp_message(identity, &content, replies);
|
||||
|
||||
// Create a task for sending the message in the background
|
||||
let send_message = room.send_in_background(&content, replies, backup, cx);
|
||||
|
||||
if let Some(message) = temp_message {
|
||||
let id = message.id;
|
||||
// Optimistically update message list
|
||||
self.insert_message(message, cx);
|
||||
// Remove all replies
|
||||
self.remove_all_replies(cx);
|
||||
|
||||
// Reset the input state
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_disabled(false, cx);
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
|
||||
// Continue sending the message in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(reports) = send_message.await {
|
||||
if !reports.is_empty() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.room.update(cx, |this, cx| {
|
||||
if this.kind != RoomKind::Ongoing {
|
||||
this.kind = RoomKind::Ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
this.messages.update(cx, |this, cx| {
|
||||
if let Some(msg) =
|
||||
this.iter().find(|msg| msg.borrow().id == id).cloned()
|
||||
{
|
||||
msg.borrow_mut().errors = Some(reports);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_message(&self, message: Message, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = message.into_rc();
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn scroll_to(&self, id: EventId, cx: &Context<Self>) {
|
||||
if let Some(ix) = self
|
||||
.messages
|
||||
.read(cx)
|
||||
.iter()
|
||||
.position(|m| m.borrow().id == id)
|
||||
{
|
||||
self.list_state.scroll_to_reveal_item(ix);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_message(&self, ix: usize, cx: &Context<Self>) {
|
||||
let Some(item) = self
|
||||
.messages
|
||||
.read(cx)
|
||||
.get(ix)
|
||||
.map(|m| ClipboardItem::new_string(m.borrow().content.to_string()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
cx.write_to_clipboard(item);
|
||||
}
|
||||
|
||||
fn reply_to(&mut self, ix: usize, cx: &mut Context<Self>) {
|
||||
let Some(message) = self.messages.read(cx).get(ix).map(|m| m.borrow().clone()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.replies_to.update(cx, |this, cx| {
|
||||
if let Some(replies) = this {
|
||||
replies.push(message);
|
||||
} else {
|
||||
*this = Some(vec![message])
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_reply(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||
self.replies_to.update(cx, |this, cx| {
|
||||
if let Some(replies) = this {
|
||||
if let Some(ix) = replies.iter().position(|m| m.id == id) {
|
||||
replies.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_all_replies(&mut self, cx: &mut Context<Self>) {
|
||||
self.replies_to.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.uploading {
|
||||
return;
|
||||
}
|
||||
// Block the upload button to until current task is resolved
|
||||
self.uploading(true, cx);
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
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(nostr_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| {
|
||||
match Flatten::flatten(task.await.map_err(|e| e.into())) {
|
||||
Ok(Ok(url)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.add_attachment(url, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("User cancelled: {e}");
|
||||
this.update(cx, |this, cx| {
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn add_attachment(&mut self, url: Url, cx: &mut Context<Self>) {
|
||||
self.attaches.update(cx, |this, cx| {
|
||||
if let Some(model) = this.as_mut() {
|
||||
model.push(url);
|
||||
} else {
|
||||
*this = Some(vec![url]);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
self.uploading(false, cx);
|
||||
}
|
||||
|
||||
fn remove_attachment(&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 uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_attach(&mut self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
|
||||
let url = url.clone();
|
||||
let path: SharedString = url.to_string().into();
|
||||
|
||||
div()
|
||||
.id("")
|
||||
.relative()
|
||||
.w_16()
|
||||
.child(
|
||||
img(path.clone())
|
||||
.size_16()
|
||||
.shadow_lg()
|
||||
.rounded(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(red())
|
||||
.child(Icon::new(IconName::Close).size_2().text_color(white())),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove_attachment(&url, window, cx);
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_reply_to(&mut self, message: &Message, cx: &Context<Self>) -> impl IntoElement {
|
||||
let registry = Registry::read_global(cx);
|
||||
let profile = registry.get_person(&message.author, cx);
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.pl_2()
|
||||
.border_l_2()
|
||||
.border_color(cx.theme().element_active)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!("chat.replying_to_label")))
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(profile.display_name()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("remove-reply")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let id = message.id;
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.remove_reply(id, cx);
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(message.content.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
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).map(|m| m.borrow()) else {
|
||||
return div().id(ix);
|
||||
};
|
||||
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let hide_avatar = AppSettings::get_hide_user_avatars(cx);
|
||||
let registry = Registry::read_global(cx);
|
||||
let author = registry.get_person(&message.author, cx);
|
||||
|
||||
let texts = self
|
||||
.text_data
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| RichText::new(&message.content, cx));
|
||||
|
||||
div()
|
||||
.id(ix)
|
||||
.group("")
|
||||
.relative()
|
||||
.w_full()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(Avatar::new(author.avatar_url(proxy)).size(rems(2.)))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text)
|
||||
.child(author.display_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(message.ago()),
|
||||
),
|
||||
)
|
||||
.when_some(message.replies_to.as_ref(), |this, replies| {
|
||||
this.w_full().children({
|
||||
let mut items = Vec::with_capacity(replies.len());
|
||||
let messages = self.messages.read(cx);
|
||||
|
||||
for (ix, id) in replies.iter().cloned().enumerate() {
|
||||
let Some(message) = messages
|
||||
.iter()
|
||||
.map(|m| m.borrow())
|
||||
.find(|m| m.id == id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.px_2()
|
||||
.border_l_2()
|
||||
.border_color(cx.theme().element_selected)
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(author.display_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(message.content.clone()),
|
||||
)
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().elevated_surface_background)
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.scroll_to(id, cx)
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
})
|
||||
.child(texts.element(ix.into(), window, cx))
|
||||
.when_some(message.errors.as_ref(), |this, errors| {
|
||||
this.child(self.render_message_errors(errors, cx))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(self.render_border(cx))
|
||||
.child(self.render_actions(ix, cx))
|
||||
.on_mouse_down(
|
||||
MouseButton::Middle,
|
||||
cx.listener(move |this, _event, _window, cx| {
|
||||
this.copy_message(ix, cx);
|
||||
}),
|
||||
)
|
||||
.on_double_click(cx.listener({
|
||||
move |this, _event, _window, cx| {
|
||||
this.reply_to(ix, cx);
|
||||
}
|
||||
}))
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
}
|
||||
|
||||
fn render_message_errors(&self, errors: &[SendError], _cx: &Context<Self>) -> impl IntoElement {
|
||||
let errors = Rc::new(errors.to_owned());
|
||||
|
||||
div()
|
||||
.id("")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_color(gpui::red())
|
||||
.text_xs()
|
||||
.italic()
|
||||
.child(Icon::new(IconName::Info).small())
|
||||
.child(SharedString::new(t!("chat.send_fail")))
|
||||
.on_click(move |_, window, cx| {
|
||||
let errors = Rc::clone(&errors);
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.title(SharedString::new(t!("chat.logs_title"))).child(
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.children(errors.iter().map(|error| {
|
||||
div()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!("chat.send_to_label")))
|
||||
.child(error.profile.display_name()),
|
||||
)
|
||||
.child(error.message.clone())
|
||||
})),
|
||||
)
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn render_border(&self, cx: &Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.group_hover("", |this| this.bg(cx.theme().element_active))
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().border_transparent)
|
||||
}
|
||||
|
||||
fn render_actions(&self, ix: usize, cx: &Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.group_hover("", |this| this.visible())
|
||||
.invisible()
|
||||
.absolute()
|
||||
.right_4()
|
||||
.top_neg_2()
|
||||
.shadow_sm()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().background)
|
||||
.p_0p5()
|
||||
.flex()
|
||||
.gap_1()
|
||||
.children({
|
||||
vec![
|
||||
Button::new("reply")
|
||||
.icon(IconName::Reply)
|
||||
.tooltip(t!("chat.reply_button"))
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.reply_to(ix, cx);
|
||||
})),
|
||||
Button::new("copy")
|
||||
.icon(IconName::Copy)
|
||||
.tooltip(t!("chat.copy_message_button"))
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.copy_message(ix, cx);
|
||||
})),
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Chat {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.room.read_with(cx, |this, cx| {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let label = this.display_name(cx);
|
||||
let url = this.display_image(proxy, cx);
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(Avatar::new(url).size(rems(1.25)))
|
||||
.child(label)
|
||||
.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 room = self.room.downgrade();
|
||||
let subject = self
|
||||
.room
|
||||
.read(cx)
|
||||
.subject
|
||||
.as_ref()
|
||||
.map(|subject| subject.to_string());
|
||||
|
||||
let button = Button::new("subject")
|
||||
.icon(IconName::EditFill)
|
||||
.tooltip(t!("chat.change_subject_button"))
|
||||
.on_click(move |_, window, cx| {
|
||||
let view = subject::init(subject.clone(), window, cx);
|
||||
let room = room.clone();
|
||||
let weak_view = view.downgrade();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let room = room.clone();
|
||||
let weak_view = weak_view.clone();
|
||||
|
||||
this.confirm()
|
||||
.title(SharedString::new(t!("chat.change_subject_modal_title")))
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text(t!("common.change")))
|
||||
.on_ok(move |_, _window, cx| {
|
||||
if let Ok(subject) =
|
||||
weak_view.read_with(cx, |this, cx| this.new_subject(cx))
|
||||
{
|
||||
room.update(cx, |this, cx| {
|
||||
this.subject = Some(subject);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
// true to close the modal
|
||||
true
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
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()
|
||||
.image_cache(self.image_cache.clone())
|
||||
.size_full()
|
||||
.child(list(self.list_state.clone()).flex_1())
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.w_full()
|
||||
.relative()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.when_some(self.attaches.read(cx).as_ref(), |this, urls| {
|
||||
this.gap_1p5()
|
||||
.children(urls.iter().map(|url| self.render_attach(url, cx)))
|
||||
})
|
||||
.when_some(self.replies_to.read(cx).as_ref(), |this, messages| {
|
||||
this.gap_1p5().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for message in messages.iter() {
|
||||
items.push(self.render_reply_to(message, cx));
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_end()
|
||||
.gap_2p5()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::Upload)
|
||||
.ghost()
|
||||
.large()
|
||||
.disabled(self.uploading)
|
||||
.loading(self.uploading)
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
EmojiPicker::new(self.input.downgrade())
|
||||
.icon(IconName::EmojiFill)
|
||||
.large(),
|
||||
),
|
||||
)
|
||||
.child(TextInput::new(&self.input)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
55
crates/coop/src/views/chat/subject.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use gpui::{
|
||||
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
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> {
|
||||
Subject::new(subject, window, cx)
|
||||
}
|
||||
|
||||
pub struct Subject {
|
||||
input: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let input = cx.new(|cx| {
|
||||
let mut this = InputState::new(window, cx).placeholder(t!("subject.placeholder"));
|
||||
if let Some(text) = subject.as_ref() {
|
||||
this.set_value(text, window, cx);
|
||||
}
|
||||
this
|
||||
});
|
||||
|
||||
cx.new(|_| Self { input })
|
||||
}
|
||||
|
||||
pub fn new_subject(&self, cx: &App) -> SharedString {
|
||||
self.input.read(cx).value().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Subject {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!("subject.title"))),
|
||||
)
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.italic()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::new(t!("subject.help_text"))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,258 +1,325 @@
|
||||
use anyhow::Error;
|
||||
use chats::{
|
||||
room::{Room, RoomKind},
|
||||
ChatRegistry,
|
||||
};
|
||||
use common::{profile::SharedProfile, random_name};
|
||||
use global::get_client;
|
||||
use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::display::{DisplayProfile, TextUtils};
|
||||
use common::nip05::nip05_profile;
|
||||
use global::constants::BOOTSTRAP_RELAYS;
|
||||
use global::nostr_client;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
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,
|
||||
div, px, relative, rems, uniform_list, AppContext, Context, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
Subscription, Task, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use registry::room::{Room, RoomKind};
|
||||
use registry::Registry;
|
||||
use settings::AppSettings;
|
||||
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 theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::notification::Notification;
|
||||
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Compose> {
|
||||
cx.new(|cx| Compose::new(window, cx))
|
||||
pub fn compose_button() -> impl IntoElement {
|
||||
div().child(
|
||||
Button::new("compose")
|
||||
.icon(IconName::Plus)
|
||||
.ghost_alt()
|
||||
.cta()
|
||||
.small()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.on_click(move |_, window, cx| {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
let title = SharedString::new(t!("sidebar.direct_messages"));
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
modal.title(title.clone()).child(compose.clone())
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
struct SelectContact(PublicKey);
|
||||
#[derive(Debug)]
|
||||
struct Contact {
|
||||
public_key: PublicKey,
|
||||
select: 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,
|
||||
select: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select(mut self) -> Self {
|
||||
self.select = 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>,
|
||||
/// The current user's contacts
|
||||
contacts: Vec<Entity<Contact>>,
|
||||
/// Input error message
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
adding: bool,
|
||||
submitting: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Compose {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
|
||||
let contacts = cx.new(|_| Vec::new());
|
||||
let selected = cx.new(|_| HashSet::new());
|
||||
let user_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("compose.placeholder_npub")));
|
||||
|
||||
let title_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("compose.placeholder_title")));
|
||||
|
||||
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);
|
||||
|
||||
input.set_placeholder("Family... . (Optional)");
|
||||
input.set_text(name, window, cx);
|
||||
input
|
||||
});
|
||||
|
||||
let user_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::Small)
|
||||
.small()
|
||||
.placeholder("npub1...")
|
||||
});
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
// Handle Enter event for user input
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&user_input,
|
||||
window,
|
||||
move |this, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
move |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add_and_select_contact(window, cx)
|
||||
};
|
||||
},
|
||||
));
|
||||
|
||||
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?;
|
||||
let get_contacts: Task<Result<Vec<Contact>, Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let profiles = client.database().contacts(public_key).await?;
|
||||
let contacts = profiles
|
||||
.into_iter()
|
||||
.map(|profile| Contact::new(profile.public_key()))
|
||||
.collect_vec();
|
||||
|
||||
Ok(profiles)
|
||||
});
|
||||
Ok(contacts)
|
||||
});
|
||||
|
||||
if let Ok(contacts) = task.await {
|
||||
cx.update(|cx| {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match get_contacts.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.extend(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
this.extend_contacts(contacts, cx);
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
adding: false,
|
||||
submitting: false,
|
||||
contacts: vec![],
|
||||
title_input,
|
||||
user_input,
|
||||
contacts,
|
||||
selected,
|
||||
error_message,
|
||||
is_loading: false,
|
||||
is_submitting: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected.read(cx).is_empty() {
|
||||
self.set_error(Some("You need to add at least 1 receiver".into()), cx);
|
||||
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, Kind::RelayList];
|
||||
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
|
||||
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let public_keys: Vec<PublicKey> = self.selected(cx);
|
||||
|
||||
if public_keys.is_empty() {
|
||||
self.set_error(Some(t!("compose.receiver_required").into()), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
// Get all pubkeys
|
||||
let pubkeys: Vec<PublicKey> = self.selected.read(cx).iter().copied().collect();
|
||||
|
||||
// Convert selected pubkeys into Nostr tags
|
||||
let mut tag_list: Vec<Tag> = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect();
|
||||
let mut tag_list: Vec<Tag> = public_keys.iter().map(|pk| Tag::public_key(*pk)).collect();
|
||||
|
||||
// Add subject if it is present
|
||||
if !self.title_input.read(cx).text().is_empty() {
|
||||
if !self.title_input.read(cx).value().is_empty() {
|
||||
tag_list.push(Tag::custom(
|
||||
TagKind::Subject,
|
||||
vec![self.title_input.read(cx).text().to_string()],
|
||||
vec![self.title_input.read(cx).value().to_string()],
|
||||
));
|
||||
}
|
||||
|
||||
let tags = Tags::from_list(tag_list);
|
||||
let event: Task<Result<Room, Error>> = cx.background_spawn(async move {
|
||||
let signer = nostr_client().signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
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?;
|
||||
let room = EventBuilder::private_msg_rumor(public_keys[0], "")
|
||||
.tags(Tags::from_list(tag_list))
|
||||
.build(public_key)
|
||||
.sign(&Keys::generate())
|
||||
.await
|
||||
.map(|event| Room::new(&event).kind(RoomKind::Ongoing))?;
|
||||
|
||||
Ok(event)
|
||||
Ok(room)
|
||||
});
|
||||
|
||||
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);
|
||||
match event.await {
|
||||
Ok(room) => {
|
||||
cx.update(|window, cx| {
|
||||
let registry = Registry::global(cx);
|
||||
// Reset local state
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
})
|
||||
.ok();
|
||||
// Create and insert the new room into the registry
|
||||
registry.update(cx, |this, cx| {
|
||||
this.push_room(cx.new(|_| room), cx);
|
||||
});
|
||||
// Close the current modal
|
||||
window.close_modal(cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
let chats = ChatRegistry::global(cx);
|
||||
let room = Room::new(&event).kind(RoomKind::Ongoing);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.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();
|
||||
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: IntoIterator<Item = Contact>,
|
||||
{
|
||||
self.contacts
|
||||
.extend(contacts.into_iter().map(|contact| cx.new(|_| contact)));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
fn push_contact(&mut self, contact: Contact, cx: &mut Context<Self>) {
|
||||
if !self
|
||||
.contacts
|
||||
.iter()
|
||||
.any(|e| e.read(cx).public_key == contact.public_key)
|
||||
{
|
||||
self.contacts.insert(0, cx.new(|_| contact));
|
||||
cx.notify();
|
||||
} else {
|
||||
self.set_error(Some(t!("compose.contact_existed").into()), cx);
|
||||
}
|
||||
}
|
||||
|
||||
let task: Task<Result<Profile, anyhow::Error>> = if content.contains("@") {
|
||||
fn selected(&self, cx: &Context<Self>) -> Vec<PublicKey> {
|
||||
self.contacts
|
||||
.iter()
|
||||
.filter_map(|contact| {
|
||||
if contact.read(cx).select {
|
||||
Some(contact.read(cx).public_key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.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();
|
||||
|
||||
// Prevent multiple requests
|
||||
self.set_adding(true, cx);
|
||||
|
||||
// Show loading indicator in the input
|
||||
self.user_input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
});
|
||||
|
||||
let task: Task<Result<Contact, Error>> = if content.contains("@") {
|
||||
cx.background_spawn(async move {
|
||||
let profile = nip05::profile(&content, None).await?;
|
||||
let public_key = profile.public_key;
|
||||
let (tx, rx) = oneshot::channel::<Option<Nip05Profile>>();
|
||||
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
nostr_sdk::async_utility::task::spawn(async move {
|
||||
let profile = nip05_profile(&content).await.ok();
|
||||
tx.send(profile).ok();
|
||||
});
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
if let Ok(Some(profile)) = rx.await {
|
||||
let client = nostr_client();
|
||||
let public_key = profile.public_key;
|
||||
let contact = Contact::new(public_key).select();
|
||||
|
||||
Self::request_metadata(client, public_key).await?;
|
||||
|
||||
Ok(contact)
|
||||
} else {
|
||||
Err(anyhow!(t!("common.not_found")))
|
||||
}
|
||||
})
|
||||
} else if let Ok(public_key) = content.to_public_key() {
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let contact = Contact::new(public_key).select();
|
||||
|
||||
Self::request_metadata(client, public_key).await?;
|
||||
|
||||
Ok(contact)
|
||||
})
|
||||
} else {
|
||||
let Ok(public_key) = PublicKey::parse(&content) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error(Some("Public Key is not valid".into()), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
})
|
||||
self.set_error(Some(t!("common.pubkey_invalid").into()), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(profile) => {
|
||||
Ok(contact) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
let public_key = profile.public_key();
|
||||
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, profile);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
this.selected.update(cx, |this, cx| {
|
||||
this.insert(public_key);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Stop loading indicator
|
||||
this.set_loading(false, cx);
|
||||
|
||||
// Clear input
|
||||
this.push_contact(contact, cx);
|
||||
this.set_adding(false, cx);
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
this.set_value("", window, cx);
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
@@ -260,235 +327,233 @@ impl Compose {
|
||||
.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();
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
|
||||
fn set_error(&mut self, error: impl Into<Option<SharedString>>, cx: &mut Context<Self>) {
|
||||
if self.adding {
|
||||
self.set_adding(false, cx);
|
||||
}
|
||||
|
||||
// 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 = error.into();
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Dismiss error after 2 seconds
|
||||
cx.spawn(async move |this, cx| {
|
||||
Timer::after(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.set_error(None, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
fn set_adding(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.adding = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_submitting = status;
|
||||
self.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);
|
||||
fn list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let registry = Registry::read_global(cx);
|
||||
let mut items = Vec::with_capacity(self.contacts.len());
|
||||
|
||||
for ix in range {
|
||||
let Some(entity) = self.contacts.get(ix).cloned() else {
|
||||
continue;
|
||||
};
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let public_key = entity.read(cx).as_ref();
|
||||
let profile = registry.get_person(public_key, cx);
|
||||
let selected = entity.read(cx).select;
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_1()
|
||||
.h_9()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_sm()
|
||||
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.75)))
|
||||
.child(profile.display_name()),
|
||||
)
|
||||
.when(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, _event, _window, cx| {
|
||||
entity.update(cx, |this, cx| {
|
||||
this.select = !this.select;
|
||||
cx.notify();
|
||||
});
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
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 label: SharedString = if self.selected.read(cx).len() > 1 {
|
||||
"Create Group DM".into()
|
||||
let label = if self.submitting {
|
||||
t!("compose.creating_dm_button")
|
||||
} else if self.selected(cx).len() > 1 {
|
||||
t!("compose.create_group_dm_button")
|
||||
} else {
|
||||
"Create DM".into()
|
||||
t!("compose.create_dm_button")
|
||||
};
|
||||
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_action_select))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
let error = self.error_message.read(cx).as_ref();
|
||||
|
||||
v_flex()
|
||||
.mb_4()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(DESCRIPTION),
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(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(SharedString::new(t!("compose.subject_label"))),
|
||||
)
|
||||
.child(TextInput::new(&self.title_input).small().appearance(false)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
v_flex()
|
||||
.my_1()
|
||||
.gap_2()
|
||||
.mt_1()
|
||||
.child(div().text_sm().font_semibold().child("To:"))
|
||||
.child(self.user_input.clone())
|
||||
.map(|this| {
|
||||
let contacts = self.contacts.read(cx).clone();
|
||||
let view = cx.entity();
|
||||
|
||||
if contacts.is_empty() {
|
||||
this.child(
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(SharedString::new(t!("compose.to_label"))),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
TextInput::new(&self.user_input)
|
||||
.small()
|
||||
.disabled(self.adding),
|
||||
)
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::PlusCircleFill)
|
||||
.ghost()
|
||||
.loading(self.adding)
|
||||
.disabled(self.adding)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add_and_select_contact(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
if self.contacts.is_empty() {
|
||||
this.child(
|
||||
v_flex()
|
||||
.h_24()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_align(TextAlign::Center)
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("No contacts"),
|
||||
.child(SharedString::new(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."),
|
||||
div().text_xs().text_color(cx.theme().text_muted).child(
|
||||
SharedString::new(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
|
||||
},
|
||||
self.contacts.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.list_items(range, cx)
|
||||
}),
|
||||
)
|
||||
.pb_4()
|
||||
.min_h(px(280.)),
|
||||
.min_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))),
|
||||
),
|
||||
Button::new("create_dm_btn")
|
||||
.label(label)
|
||||
.primary()
|
||||
.small()
|
||||
.w_full()
|
||||
.loading(self.submitting)
|
||||
.disabled(self.submitting || self.adding)
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::profile::SharedProfile;
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
indicator::Indicator,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Sizable,
|
||||
};
|
||||
|
||||
const MIN_HEIGHT: f32 = 280.;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Contacts> {
|
||||
Contacts::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Contacts {
|
||||
contacts: Option<Vec<Profile>>,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Contacts {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let client = get_client();
|
||||
|
||||
let task: Task<Result<BTreeSet<Profile>, 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?;
|
||||
|
||||
Ok(profiles)
|
||||
});
|
||||
|
||||
if let Ok(contacts) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.contacts = Some(contacts.into_iter().collect_vec());
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
contacts: None,
|
||||
name: "Contacts".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Contacts {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"ContactPanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Contacts {}
|
||||
|
||||
impl Focusable for Contacts {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Contacts {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let entity = cx.entity().clone();
|
||||
|
||||
div().map(|this| {
|
||||
if let Some(contacts) = self.contacts.clone() {
|
||||
this.child(
|
||||
uniform_list(
|
||||
entity,
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
move |_, range, _window, cx| {
|
||||
let mut items = Vec::with_capacity(contacts.len());
|
||||
|
||||
for ix in range {
|
||||
if let Some(item) = contacts.get(ix) {
|
||||
items.push(
|
||||
div()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div().flex_shrink_0().child(
|
||||
img(item.shared_avatar()).size_6(),
|
||||
),
|
||||
)
|
||||
.child(item.shared_name()),
|
||||
)
|
||||
.hover(|this| {
|
||||
this.bg(cx
|
||||
.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::THREE))
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.min_h(px(MIN_HEIGHT)),
|
||||
)
|
||||
} else {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_16()
|
||||
.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,48 @@
|
||||
use async_utility::task::spawn;
|
||||
use common::nip96_upload;
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, px, App, AppContext, Context, Entity, Flatten, IntoElement,
|
||||
ParentElement, PathPromptOptions, Render, Styled, Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Disableable, IconName, Sizable, Size,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> {
|
||||
Profile::new(window, cx)
|
||||
use common::nip96::nip96_upload;
|
||||
use global::nostr_client;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, img, App, AppContext, Context, Entity, Flatten, IntoElement, ParentElement,
|
||||
PathPromptOptions, Render, SharedString, Styled, Task, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use smol::fs;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{v_flex, Disableable, IconName, Sizable};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EditProfile> {
|
||||
EditProfile::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Profile {
|
||||
pub struct EditProfile {
|
||||
profile: Option<Metadata>,
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
website_input: Entity<TextInput>,
|
||||
name_input: Entity<InputState>,
|
||||
avatar_input: Entity<InputState>,
|
||||
bio_input: Entity<InputState>,
|
||||
website_input: Entity<InputState>,
|
||||
is_loading: bool,
|
||||
is_submitting: bool,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
impl EditProfile {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let name_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Alice")
|
||||
});
|
||||
|
||||
let avatar_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.small()
|
||||
.placeholder("https://example.com/avatar.jpg")
|
||||
});
|
||||
|
||||
let website_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("https://your-website.com")
|
||||
});
|
||||
|
||||
let name_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name")));
|
||||
let avatar_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
|
||||
let website_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://your-website.com"));
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
InputState::new(window, cx)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
.placeholder(t!("profile.placeholder_bio"))
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
@@ -69,7 +57,7 @@ impl Profile {
|
||||
};
|
||||
|
||||
let task: Task<Result<Option<Metadata>, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let client = nostr_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let metadata = client
|
||||
@@ -82,29 +70,28 @@ impl Profile {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Some(metadata)) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this: &mut Profile, cx| {
|
||||
this.update(cx, |this: &mut EditProfile, cx| {
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
if let Some(avatar) = metadata.picture.as_ref() {
|
||||
this.set_text(avatar, window, cx);
|
||||
this.set_value(avatar, window, cx);
|
||||
}
|
||||
});
|
||||
this.bio_input.update(cx, |this, cx| {
|
||||
if let Some(bio) = metadata.about.as_ref() {
|
||||
this.set_text(bio, window, cx);
|
||||
this.set_value(bio, window, cx);
|
||||
}
|
||||
});
|
||||
this.name_input.update(cx, |this, cx| {
|
||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||
this.set_text(display_name, window, cx);
|
||||
this.set_value(display_name, window, cx);
|
||||
}
|
||||
});
|
||||
this.website_input.update(cx, |this, cx| {
|
||||
if let Some(website) = metadata.website.as_ref() {
|
||||
this.set_text(website, window, cx);
|
||||
this.set_value(website, window, cx);
|
||||
}
|
||||
});
|
||||
this.profile = Some(metadata);
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
@@ -119,6 +106,7 @@ impl Profile {
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nip96 = AppSettings::get_media_server(cx);
|
||||
let avatar_input = self.avatar_input.downgrade();
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
@@ -137,9 +125,8 @@ impl Profile {
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
spawn(async move {
|
||||
let client = get_client();
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
nostr_sdk::async_utility::task::spawn(async move {
|
||||
if let Ok(url) = nip96_upload(nostr_client(), &nip96, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
@@ -155,7 +142,7 @@ impl Profile {
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
this.set_value(url.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -179,14 +166,11 @@ impl Profile {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
let avatar = self.avatar_input.read(cx).text().to_string();
|
||||
let name = self.name_input.read(cx).text().to_string();
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
let website = self.website_input.read(cx).text().to_string();
|
||||
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Option<Event>, 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();
|
||||
|
||||
let old_metadata = if let Some(metadata) = self.profile.as_ref() {
|
||||
metadata.clone()
|
||||
@@ -204,78 +188,58 @@ impl Profile {
|
||||
new_metadata = new_metadata.website(url);
|
||||
}
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
_ = client.set_metadata(&new_metadata).await?;
|
||||
cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let output = client.set_metadata(&new_metadata).await?;
|
||||
let event = client.database().event_by_id(&output.val).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
window.push_notification("Your profile has been updated successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(event)
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Profile {
|
||||
impl Render for EditProfile {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_32()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::TWO))
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.map(|this| {
|
||||
let picture = self.avatar_input.read(cx).text();
|
||||
|
||||
let picture = self.avatar_input.read(cx).value();
|
||||
if picture.is_empty() {
|
||||
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
|
||||
this.child(
|
||||
img("brand/avatar.png")
|
||||
.rounded_full()
|
||||
.size_10()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE,
|
||||
self.avatar_input.read(cx).text()
|
||||
))
|
||||
.size_10()
|
||||
.flex_shrink_0(),
|
||||
img(picture.clone())
|
||||
.rounded_full()
|
||||
.size_10()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::Upload)
|
||||
.label("Change")
|
||||
.label(t!("common.change"))
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.is_loading || self.is_submitting)
|
||||
@@ -291,8 +255,8 @@ impl Render for Profile {
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Name:")
|
||||
.child(self.name_input.clone()),
|
||||
.child(SharedString::new(t!("profile.label_name")))
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -300,8 +264,8 @@ impl Render for Profile {
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Website:")
|
||||
.child(self.website_input.clone()),
|
||||
.child(SharedString::new(t!("profile.label_website")))
|
||||
.child(TextInput::new(&self.website_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -309,20 +273,8 @@ impl Render for Profile {
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div().mt_2().w_full().child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.disabled(self.is_loading || self.is_submitting)
|
||||
.loading(self.is_submitting)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
),
|
||||
.child(SharedString::new(t!("profile.label_bio")))
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
}
|
||||
}
|
||||
390
crates/coop/src/views/messaging_relays.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use global::constants::{NEW_MESSAGE_ID, NIP17_RELAYS};
|
||||
use global::nostr_client;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
||||
TextAlign, UniformList, Window,
|
||||
};
|
||||
use i18n::{shared_t, t};
|
||||
use identity::Identity;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::input::{InputEvent, InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<MessagingRelays> {
|
||||
cx.new(|cx| MessagingRelays::new(window, cx))
|
||||
}
|
||||
|
||||
pub fn relay_button() -> impl IntoElement {
|
||||
div().child(
|
||||
Button::new("dm-relays")
|
||||
.icon(IconName::Info)
|
||||
.label(t!("relays.button_label"))
|
||||
.warning()
|
||||
.xsmall()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.on_click(move |_, window, cx| {
|
||||
let title = SharedString::new(t!("relays.modal_title"));
|
||||
let view = cx.new(|cx| MessagingRelays::new(window, cx));
|
||||
let weak_view = view.downgrade();
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
|
||||
modal
|
||||
.confirm()
|
||||
.title(title.clone())
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
this.set_relays(window, cx);
|
||||
})
|
||||
.ok();
|
||||
// true to close the modal
|
||||
false
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct MessagingRelays {
|
||||
input: Entity<InputState>,
|
||||
relays: Vec<RelayUrl>,
|
||||
error: Option<SharedString>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl MessagingRelays {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.observe_new::<Self>(move |this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
input,
|
||||
subscriptions,
|
||||
relays: vec![],
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first() {
|
||||
let relays = event
|
||||
.tags
|
||||
.filter(TagKind::Relay)
|
||||
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(relays)
|
||||
} else {
|
||||
Err(anyhow!("Not found."))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(relays) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.relays = relays;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
if !self.relays.contains(&url) {
|
||||
self.relays.push(url);
|
||||
}
|
||||
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.relays.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.relays.is_empty() {
|
||||
self.set_error(t!("relays.empty"), window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let relays = self.relays.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(
|
||||
relays
|
||||
.iter()
|
||||
.map(|relay| Tag::relay(relay.clone()))
|
||||
.collect_vec(),
|
||||
);
|
||||
|
||||
// Set messaging relays
|
||||
client.send_event_builder(builder).await?;
|
||||
|
||||
// Connect to messaging relays
|
||||
for relay in relays.into_iter() {
|
||||
_ = client.add_relay(&relay).await;
|
||||
_ = client.connect_relay(&relay).await;
|
||||
}
|
||||
|
||||
let id = SubscriptionId::new(NEW_MESSAGE_ID);
|
||||
let new_messages = Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(public_key)
|
||||
.limit(0);
|
||||
|
||||
// Close old subscriptions
|
||||
client.unsubscribe(&id).await;
|
||||
|
||||
// Subscribe to new messages
|
||||
client.subscribe_with_id(id, new_messages, None).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
Identity::global(cx).update(cx, |this, cx| {
|
||||
this.verify_dm_relays(window, cx);
|
||||
});
|
||||
// Close the current modal
|
||||
window.close_modal(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> UniformList {
|
||||
let relays = self.relays.clone();
|
||||
let total = relays.len();
|
||||
|
||||
uniform_list(
|
||||
"relays",
|
||||
total,
|
||||
cx.processor(move |_, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = relays.get(ix).map(|i: &RelayUrl| i.to_string()).unwrap();
|
||||
|
||||
items.push(
|
||||
div().group("").w_full().h_9().py_0p5().child(
|
||||
div()
|
||||
.px_2()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_xs()
|
||||
.child(item)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove(ix, window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
.w_full()
|
||||
.min_h(px(200.))
|
||||
}
|
||||
|
||||
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.h_20()
|
||||
.mb_2()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::new(t!("relays.add_some_relays")))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MessagingRelays {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("relays.description")),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::PlusFill)
|
||||
.label(t!("common.add"))
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(shared_t!("relays.recommended")),
|
||||
)
|
||||
.child(h_flex().gap_1().children({
|
||||
NIP17_RELAYS.iter().map(|&relay| {
|
||||
div()
|
||||
.id(relay)
|
||||
.group("")
|
||||
.py_0p5()
|
||||
.px_1p5()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.bg(cx.theme().secondary_background)
|
||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
||||
.active(|this| this.bg(cx.theme().secondary_active))
|
||||
.rounded_full()
|
||||
.child(relay)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.input.update(cx, |this, cx| {
|
||||
this.set_value(relay, window, cx);
|
||||
});
|
||||
this.add(window, cx);
|
||||
}))
|
||||
})
|
||||
})),
|
||||
)
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if !self.relays.is_empty() {
|
||||
this.child(self.render_list(window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty(window, cx))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
pub mod backup_keys;
|
||||
pub mod chat;
|
||||
pub mod compose;
|
||||
pub mod contacts;
|
||||
pub mod edit_profile;
|
||||
pub mod login;
|
||||
pub mod messaging_relays;
|
||||
pub mod new_account;
|
||||
pub mod onboarding;
|
||||
pub mod profile;
|
||||
pub mod relays;
|
||||
pub mod preferences;
|
||||
pub mod screening;
|
||||
pub mod sidebar;
|
||||
pub mod subject;
|
||||
pub mod startup;
|
||||
pub mod user_profile;
|
||||
pub mod welcome;
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
use account::Account;
|
||||
use async_utility::task::spawn;
|
||||
use common::nip96_upload;
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use anyhow::anyhow;
|
||||
use common::nip96::nip96_upload;
|
||||
use global::nostr_client;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, relative, AnyElement, App, AppContext, Context, Entity,
|
||||
div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity,
|
||||
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
|
||||
Render, SharedString, Styled, Window,
|
||||
Render, SharedString, Styled, WeakEntity, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use i18n::t;
|
||||
use identity::Identity;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use smol::fs;
|
||||
use std::str::FromStr;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::TextInput,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::popup_menu::PopupMenu;
|
||||
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||
NewAccount::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct NewAccount {
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
name_input: Entity<InputState>,
|
||||
avatar_input: Entity<InputState>,
|
||||
is_uploading: bool,
|
||||
is_submitting: bool,
|
||||
// Panel
|
||||
@@ -43,29 +43,16 @@ impl NewAccount {
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let name_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Alice")
|
||||
InputState::new(window, cx)
|
||||
.placeholder(SharedString::new(t!("profile.placeholder_name")))
|
||||
});
|
||||
|
||||
let avatar_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.small()
|
||||
.placeholder("https://example.com/avatar.jpg")
|
||||
});
|
||||
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
let avatar_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.png"));
|
||||
|
||||
Self {
|
||||
name_input,
|
||||
avatar_input,
|
||||
bio_input,
|
||||
is_uploading: false,
|
||||
is_submitting: false,
|
||||
name: "New Account".into(),
|
||||
@@ -76,97 +63,97 @@ impl NewAccount {
|
||||
}
|
||||
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_submitting(true, cx);
|
||||
self.submitting(true, cx);
|
||||
|
||||
let avatar = self.avatar_input.read(cx).text().to_string();
|
||||
let name = self.name_input.read(cx).text().to_string();
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
let identity = Identity::global(cx);
|
||||
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).about(bio);
|
||||
// Build metadata
|
||||
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
|
||||
|
||||
if let Ok(url) = Url::from_str(&avatar) {
|
||||
if let Ok(url) = Url::parse(&avatar) {
|
||||
metadata = metadata.picture(url);
|
||||
};
|
||||
|
||||
Account::global(cx).update(cx, |this, cx| {
|
||||
this.new_account(metadata, window, cx);
|
||||
identity.update(cx, |this, cx| {
|
||||
this.new_identity(metadata, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let avatar_input = self.avatar_input.downgrade();
|
||||
self.uploading(true, cx);
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
self.set_uploading(true, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let task = Tokio::spawn(cx, async move {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let Some(path) = paths.pop() else {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
if let Some(path) = paths.pop() {
|
||||
let file = fs::read(path).await?;
|
||||
let url = nip96_upload(nostr_client(), &nip96_server, file).await?;
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
spawn(async move {
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
cx.update(|window, cx| {
|
||||
// Stop loading spinner
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Ok(url)
|
||||
} else {
|
||||
Err(anyhow!("Path not found"))
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
cx.update(|_, cx| {
|
||||
Ok(None) => Err(anyhow!("User cancelled")),
|
||||
Err(e) => Err(anyhow!("File dialog error: {e}")),
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match Flatten::flatten(task.await.map_err(|e| e.into())) {
|
||||
Ok(Ok(url)) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
this.uploading(false, cx);
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
this.set_value(url.to_string(), window, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(_) => {}
|
||||
Ok(Err(e)) => {
|
||||
Self::notify_error(cx, this, e.to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
Self::notify_error(cx, this, e.to_string());
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
fn notify_error(cx: &mut AsyncWindowContext, entity: WeakEntity<NewAccount>, e: String) {
|
||||
cx.update(|window, cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
window.push_notification(e, cx);
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
@@ -207,94 +194,75 @@ impl Focusable for NewAccount {
|
||||
}
|
||||
|
||||
impl Render for NewAccount {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.text_lg()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child("Create New Account"),
|
||||
.child(SharedString::new(t!("new_account.title"))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
v_flex()
|
||||
.w_96()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_32()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.map(|this| {
|
||||
if self.avatar_input.read(cx).text().is_empty() {
|
||||
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE,
|
||||
self.avatar_input.read(cx).text()
|
||||
))
|
||||
.size_10()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
}
|
||||
})
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(SharedString::new(t!("new_account.name")))
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.label("Set Profile Picture")
|
||||
.icon(Icon::new(IconName::Plus))
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.is_submitting)
|
||||
.loading(self.is_uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
div()
|
||||
.text_sm()
|
||||
.child(SharedString::new(t!("new_account.avatar"))),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.p_1()
|
||||
.h_32()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
Avatar::new(self.avatar_input.read(cx).value().to_string())
|
||||
.size(rems(2.25)),
|
||||
)
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::Plus)
|
||||
.label(t!("common.upload"))
|
||||
.ghost()
|
||||
.small()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.disabled(self.is_submitting)
|
||||
.loading(self.is_uploading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Name *:")
|
||||
.child(self.name_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.w_full()
|
||||
.h_px()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Continue")
|
||||
.label(SharedString::new(t!("common.continue")))
|
||||
.primary()
|
||||
.loading(self.is_submitting)
|
||||
.disabled(self.is_submitting || self.is_uploading)
|
||||
|
||||
@@ -1,140 +1,292 @@
|
||||
use gpui::{
|
||||
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Icon, IconName, StyledExt,
|
||||
};
|
||||
|
||||
use crate::chatspace;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
Onboarding::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
name: "Onboarding".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Onboarding {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Onboarding {}
|
||||
|
||||
impl Focusable for Onboarding {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const SUBTITLE: &str = "Secure Communication on Nostr.";
|
||||
|
||||
div()
|
||||
.py_4()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_10()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_16()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xl()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(TITLE),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(SUBTITLE),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("continue_btn")
|
||||
.icon(Icon::new(IconName::ArrowRight))
|
||||
.label("Start Messaging")
|
||||
.primary()
|
||||
.reverse()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::new_account(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("login_btn")
|
||||
.label("Already have an account? Log in.")
|
||||
.ghost()
|
||||
.underline()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::login(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
use anyhow::anyhow;
|
||||
use common::display::DisplayProfile;
|
||||
use global::constants::ACCOUNT_D;
|
||||
use global::nostr_client;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use identity::Identity;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::checkbox::Checkbox;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::popup_menu::PopupMenu;
|
||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
use crate::chatspace;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
Onboarding::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
name: SharedString,
|
||||
local_account: Entity<Option<Profile>>,
|
||||
loading: bool,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let local_account = cx.new(|_| None);
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(ACCOUNT_D)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = nostr_client().database().query(filter).await?.first_owned() {
|
||||
let public_key = event
|
||||
.tags
|
||||
.public_keys()
|
||||
.copied()
|
||||
.collect_vec()
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap();
|
||||
|
||||
let metadata = nostr_client()
|
||||
.database()
|
||||
.metadata(public_key)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Profile::new(public_key, metadata))
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(profile) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.local_account.update(cx, |this, cx| {
|
||||
*this = Some(profile);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
local_account,
|
||||
name: "Onboarding".into(),
|
||||
loading: false,
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Onboarding {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Onboarding {}
|
||||
|
||||
impl Focusable for Onboarding {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let auto_login = AppSettings::get_auto_login(cx);
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
|
||||
div()
|
||||
.py_4()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.mb_10()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_16()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xl()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.3))
|
||||
.child(SharedString::new(t!("welcome.title"))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!("welcome.subtitle"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
if let Some(profile) = self.local_account.read(cx).as_ref() {
|
||||
this.relative()
|
||||
.child(
|
||||
div()
|
||||
.id("account")
|
||||
.mb_3()
|
||||
.h_10()
|
||||
.w_72()
|
||||
.bg(cx.theme().element_background)
|
||||
.text_color(cx.theme().element_foreground)
|
||||
.rounded_lg()
|
||||
.text_sm()
|
||||
.map(|this| {
|
||||
if self.loading {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Indicator::new().small()),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(SharedString::new(t!(
|
||||
"onboarding.choose_account"
|
||||
)))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.font_semibold()
|
||||
.child(
|
||||
Avatar::new(profile.avatar_url(proxy))
|
||||
.size(rems(1.5)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.pb_px()
|
||||
.child(profile.display_name()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.set_loading(true, cx);
|
||||
Identity::global(cx).update(cx, |this, cx| {
|
||||
this.load(window, cx);
|
||||
});
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Checkbox::new("auto_login")
|
||||
.label(SharedString::new(t!("onboarding.auto_login")))
|
||||
.checked(auto_login)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_auto_login(!auto_login, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div().w_24().absolute().bottom_2().right_2().child(
|
||||
Button::new("logout")
|
||||
.icon(IconName::Logout)
|
||||
.label(SharedString::new(t!("user.sign_out")))
|
||||
.danger()
|
||||
.xsmall()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.disabled(self.loading)
|
||||
.on_click(|_, window, cx| {
|
||||
Identity::global(cx).update(cx, |this, cx| {
|
||||
this.unload(window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.w_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("continue_btn")
|
||||
.icon(Icon::new(IconName::ArrowRight))
|
||||
.label(SharedString::new(t!("onboarding.start_messaging")))
|
||||
.primary()
|
||||
.reverse()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::new_account(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("login_btn")
|
||||
.label(SharedString::new(t!("onboarding.already_have_account")))
|
||||
.ghost()
|
||||
.underline()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
chatspace::login(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
321
crates/coop/src/views/preferences.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use common::display::DisplayProfile;
|
||||
use gpui::http_client::Url;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use identity::Identity;
|
||||
use registry::Registry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::switch::Switch;
|
||||
use ui::{v_flex, ContextModal, IconName, Sizable, Size, StyledExt};
|
||||
|
||||
use crate::views::{edit_profile, messaging_relays};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||
Preferences::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Preferences {
|
||||
media_input: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl Preferences {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let media_server = AppSettings::get_media_server(cx).to_string();
|
||||
let media_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.default_value(media_server.clone())
|
||||
.placeholder(media_server)
|
||||
});
|
||||
|
||||
Self { media_input }
|
||||
})
|
||||
}
|
||||
|
||||
fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = edit_profile::init(window, cx);
|
||||
let weak_view = view.downgrade();
|
||||
let title = SharedString::new(t!("profile.title"));
|
||||
|
||||
window.open_modal(cx, move |modal, _window, _cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
|
||||
modal
|
||||
.confirm()
|
||||
.title(title.clone())
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
let set_metadata = this.set_metadata(cx);
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
match set_metadata.await {
|
||||
Ok(event) => {
|
||||
if let Some(event) = event {
|
||||
cx.update(|_, cx| {
|
||||
Registry::global(cx).update(cx, |this, cx| {
|
||||
this.insert_or_update_person(event, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(e.to_string(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
// true to close the modal
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let title = SharedString::new(t!("relays.modal_title"));
|
||||
let view = messaging_relays::init(window, cx);
|
||||
let weak_view = view.downgrade();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let weak_view = weak_view.clone();
|
||||
|
||||
this.confirm()
|
||||
.title(title.clone())
|
||||
.child(view.clone())
|
||||
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
|
||||
.on_ok(move |_, window, cx| {
|
||||
weak_view
|
||||
.update(cx, |this, cx| {
|
||||
this.set_relays(window, cx);
|
||||
})
|
||||
.ok();
|
||||
// true to close the modal
|
||||
false
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Preferences {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let input_state = self.media_input.downgrade();
|
||||
let profile = Identity::read_global(cx)
|
||||
.public_key()
|
||||
.map(|pk| Registry::read_global(cx).get_person(&pk, cx));
|
||||
|
||||
let backup_messages = AppSettings::get_backup_messages(cx);
|
||||
let screening = AppSettings::get_screening(cx);
|
||||
let contact_bypass = AppSettings::get_contact_bypass(cx);
|
||||
let proxy_avatar = AppSettings::get_proxy_user_avatars(cx);
|
||||
let hide_avatar = AppSettings::get_hide_user_avatars(cx);
|
||||
|
||||
v_flex()
|
||||
.child(
|
||||
v_flex()
|
||||
.py_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::new(t!("preferences.account_header"))),
|
||||
)
|
||||
.when_some(profile, |this, profile| {
|
||||
this.child(
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.id("current-user")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Avatar::new(profile.avatar_url(proxy_avatar))
|
||||
.size(rems(2.4)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.line_height(relative(1.3))
|
||||
.font_semibold()
|
||||
.child(profile.display_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.line_height(relative(1.3))
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!(
|
||||
"preferences.see_your_profile"
|
||||
))),
|
||||
),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_edit_profile(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("relays")
|
||||
.label("Messaging Relays")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_relays(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.py_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::new(t!("preferences.media_server_header"))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_1()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_1()
|
||||
.child(TextInput::new(&self.media_input).xsmall())
|
||||
.child(
|
||||
Button::new("update")
|
||||
.icon(IconName::CheckCircleFill)
|
||||
.ghost()
|
||||
.with_size(Size::Size(px(26.)))
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Some(input) = input_state.upgrade() {
|
||||
let Ok(url) = Url::parse(input.read(cx).value()) else {
|
||||
window.push_notification(
|
||||
t!("preferences.url_not_valid"),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
};
|
||||
AppSettings::update_media_server(url, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!("preferences.media_description"))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.py_2()
|
||||
.gap_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::new(t!("preferences.messages_header"))),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Switch::new("screening")
|
||||
.label(t!("preferences.screening_label"))
|
||||
.description(t!("preferences.screening_description"))
|
||||
.checked(screening)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_screening(!screening, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Switch::new("bypass")
|
||||
.label(t!("preferences.bypass_label"))
|
||||
.description(t!("preferences.bypass_description"))
|
||||
.checked(contact_bypass)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_contact_bypass(!contact_bypass, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Switch::new("backup_messages")
|
||||
.label(t!("preferences.backup_messages_label"))
|
||||
.description(t!("preferences.backup_description"))
|
||||
.checked(backup_messages)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_backup_messages(!backup_messages, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.py_2()
|
||||
.gap_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.child(SharedString::new(t!("preferences.display_header"))),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Switch::new("hide_user_avatars")
|
||||
.label(t!("preferences.hide_avatars_label"))
|
||||
.description(t!("preferences.hide_avatar_description"))
|
||||
.checked(hide_avatar)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_hide_user_avatars(!hide_avatar, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Switch::new("proxy_user_avatars")
|
||||
.label(t!("preferences.proxy_avatars_label"))
|
||||
.description(t!("preferences.proxy_description"))
|
||||
.checked(proxy_avatar)
|
||||
.on_click(move |_, _window, cx| {
|
||||
AppSettings::update_proxy_user_avatars(!proxy_avatar, cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
use anyhow::Error;
|
||||
use global::{constants::NEW_MESSAGE_SUB_ID, get_client};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, uniform_list, App, AppContext, Context, Entity, FocusHandle,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, Task, TextAlign,
|
||||
UniformList, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Disableable, IconName, Sizable,
|
||||
};
|
||||
|
||||
const MIN_HEIGHT: f32 = 200.0;
|
||||
const MESSAGE: &str = "In order to receive messages from others, you need to setup at least one Messaging Relay. You can use the recommend relays or add more.";
|
||||
const HELP_TEXT: &str = "Please add some relays.";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
|
||||
Relays::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Relays {
|
||||
relays: Entity<Vec<RelayUrl>>,
|
||||
input: Entity<TextInput>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Relays {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::XSmall)
|
||||
.small()
|
||||
.placeholder("wss://example.com")
|
||||
});
|
||||
|
||||
let relays = cx.new(|cx| {
|
||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let relays = event
|
||||
.tags
|
||||
.filter(TagKind::Relay)
|
||||
.filter_map(|tag| RelayUrl::parse(tag.content()?).ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(relays)
|
||||
} else {
|
||||
let relays = vec![
|
||||
RelayUrl::parse("wss://auth.nostr1.com")?,
|
||||
RelayUrl::parse("wss://relay.0xchat.com")?,
|
||||
];
|
||||
|
||||
Ok(relays)
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(relays) = task.await {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this: &mut Vec<RelayUrl>, cx| {
|
||||
*this = relays;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
vec![]
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Relays, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
relays,
|
||||
input,
|
||||
subscriptions,
|
||||
is_loading: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// If user didn't have any NIP-65 relays, add default ones
|
||||
if client.database().relay_list(public_key).await?.is_empty() {
|
||||
let builder = EventBuilder::relay_list(vec![
|
||||
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
|
||||
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
|
||||
]);
|
||||
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send relay list event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let tags: Vec<Tag> = relays
|
||||
.iter()
|
||||
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
|
||||
.collect();
|
||||
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||
let output = client.send_event_builder(builder).await?;
|
||||
|
||||
// Connect to messaging relays
|
||||
for relay in relays.into_iter() {
|
||||
_ = client.add_relay(&relay).await;
|
||||
_ = client.connect_relay(&relay).await;
|
||||
}
|
||||
|
||||
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
|
||||
// Close old subscription
|
||||
client.unsubscribe(&sub_id).await;
|
||||
|
||||
// Subscribe to new messages
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id(
|
||||
sub_id,
|
||||
Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(public_key)
|
||||
.limit(0),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe to new messages: {}", e);
|
||||
}
|
||||
|
||||
Ok(output.val)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
window.close_modal(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).text().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
self.relays.update(cx, |this, cx| {
|
||||
if !this.contains(&url) {
|
||||
this.push(url);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.relays.update(cx, |this, cx| {
|
||||
this.remove(ix);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn render_list(
|
||||
&mut self,
|
||||
relays: Vec<RelayUrl>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> UniformList {
|
||||
let view = cx.entity();
|
||||
let total = relays.len();
|
||||
|
||||
uniform_list(view, "relays", total, move |_, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = relays.get(ix).unwrap().clone().to_string();
|
||||
|
||||
items.push(
|
||||
div().group("").w_full().h_9().py_0p5().child(
|
||||
div()
|
||||
.px_2()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.text_xs()
|
||||
.child(item)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove(ix, window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.w_full()
|
||||
.min_h(px(MIN_HEIGHT))
|
||||
}
|
||||
|
||||
fn render_empty(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.h_20()
|
||||
.mb_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(HELP_TEXT)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Relays {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(MESSAGE),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
Button::new("add_relay_btn")
|
||||
.icon(IconName::Plus)
|
||||
.label("Add")
|
||||
.small()
|
||||
.ghost()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.add(window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
let relays = self.relays.read(cx).clone();
|
||||
|
||||
if !relays.is_empty() {
|
||||
this.child(self.render_list(relays, window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty(window, cx))
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("submti")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(self.is_loading)
|
||||
.disabled(self.is_loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
325
crates/coop/src/views/screening.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
use common::display::{shorten_pubkey, DisplayProfile};
|
||||
use common::nip05::nip05_verify;
|
||||
use global::nostr_client;
|
||||
use gpui::{
|
||||
div, relative, rems, App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render,
|
||||
SharedString, Styled, Task, Window,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use i18n::{shared_t, t};
|
||||
use identity::Identity;
|
||||
use nostr_sdk::prelude::*;
|
||||
use registry::Registry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonRounded, ButtonVariants};
|
||||
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
||||
Screening::new(public_key, window, cx)
|
||||
}
|
||||
|
||||
pub struct Screening {
|
||||
public_key: PublicKey,
|
||||
verified: bool,
|
||||
followed: bool,
|
||||
dm_relays: bool,
|
||||
mutual_contacts: usize,
|
||||
}
|
||||
|
||||
impl Screening {
|
||||
pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self {
|
||||
public_key,
|
||||
verified: false,
|
||||
followed: false,
|
||||
dm_relays: false,
|
||||
mutual_contacts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Skip if user isn't logged in
|
||||
let Some(identity) = Identity::read_global(cx).public_key() else {
|
||||
return;
|
||||
};
|
||||
let public_key = self.public_key;
|
||||
|
||||
let check_trust_score: Task<(bool, usize, bool)> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
|
||||
let follow = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(identity)
|
||||
.pubkey(public_key)
|
||||
.limit(1);
|
||||
|
||||
let contacts = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.pubkey(public_key)
|
||||
.limit(1);
|
||||
|
||||
let relays = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let is_follow = client.database().count(follow).await.unwrap_or(0) >= 1;
|
||||
let mutual_contacts = client.database().count(contacts).await.unwrap_or(0);
|
||||
let dm_relays = client.database().count(relays).await.unwrap_or(0) >= 1;
|
||||
|
||||
(is_follow, mutual_contacts, dm_relays)
|
||||
});
|
||||
|
||||
let verify_nip05 = if let Some(address) = self.address(cx) {
|
||||
Some(Tokio::spawn(cx, async move {
|
||||
nip05_verify(public_key, &address).await.unwrap_or(false)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let (followed, mutual_contacts, dm_relays) = check_trust_score.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.followed = followed;
|
||||
this.mutual_contacts = mutual_contacts;
|
||||
this.dm_relays = dm_relays;
|
||||
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();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn profile(&self, cx: &Context<Self>) -> Profile {
|
||||
let registry = Registry::read_global(cx);
|
||||
registry.get_person(&self.public_key, cx)
|
||||
}
|
||||
|
||||
fn address(&self, cx: &Context<Self>) -> Option<String> {
|
||||
self.profile(cx).metadata().nip05
|
||||
}
|
||||
|
||||
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
|
||||
let Ok(bech32) = self.public_key.to_bech32();
|
||||
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||
}
|
||||
|
||||
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let public_key = self.public_key;
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let builder = EventBuilder::report(
|
||||
vec![Tag::public_key_report(public_key, Report::Impersonation)],
|
||||
"scam/impersonation",
|
||||
);
|
||||
let _ = client.send_event_builder(builder).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
window.close_modal(cx);
|
||||
window.push_notification(t!("screening.report_msg"), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Screening {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let profile = self.profile(cx);
|
||||
let shorten_pubkey = shorten_pubkey(profile.public_key(), 8);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.)))
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(profile.display_name()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.p_1()
|
||||
.flex_1()
|
||||
.h_7()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().surface_background)
|
||||
.text_sm()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_center()
|
||||
.line_height(relative(1.))
|
||||
.child(shorten_pubkey),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("njump")
|
||||
.label(t!("profile.njump"))
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_njump(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("report")
|
||||
.tooltip(t!("screening.report"))
|
||||
.icon(IconName::Report)
|
||||
.danger()
|
||||
.rounded(ButtonRounded::Full)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.report(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(self.followed, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(shared_t!("screening.contact_label"))
|
||||
.child(div().text_color(cx.theme().text_muted).child({
|
||||
if self.followed {
|
||||
shared_t!("screening.contact")
|
||||
} else {
|
||||
shared_t!("screening.not_contact")
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(self.verified, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child({
|
||||
if let Some(addr) = self.address(cx) {
|
||||
shared_t!("screening.nip05_addr", addr = addr)
|
||||
} else {
|
||||
shared_t!("screening.nip05_label")
|
||||
}
|
||||
})
|
||||
.child(div().text_color(cx.theme().text_muted).child({
|
||||
if self.address(cx).is_some() {
|
||||
if self.verified {
|
||||
shared_t!("screening.nip05_ok")
|
||||
} else {
|
||||
shared_t!("screening.nip05_failed")
|
||||
}
|
||||
} else {
|
||||
shared_t!("screening.nip05_empty")
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(self.mutual_contacts > 0, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(shared_t!("screening.mutual_label"))
|
||||
.child(div().text_color(cx.theme().text_muted).child({
|
||||
if self.mutual_contacts > 0 {
|
||||
shared_t!("screening.mutual", u = self.mutual_contacts)
|
||||
} else {
|
||||
shared_t!("screening.no_mutual")
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(self.dm_relays, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.child({
|
||||
if self.dm_relays {
|
||||
shared_t!("screening.relay_found")
|
||||
} else {
|
||||
shared_t!("screening.relay_empty")
|
||||
}
|
||||
})
|
||||
.child(div().w_full().text_color(cx.theme().text_muted).child(
|
||||
{
|
||||
if self.dm_relays {
|
||||
shared_t!("screening.relay_found_desc")
|
||||
} else {
|
||||
shared_t!("screening.relay_empty_desc")
|
||||
}
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_badge(status: bool, cx: &App) -> Div {
|
||||
div()
|
||||
.pt_1()
|
||||
.flex_shrink_0()
|
||||
.child(Icon::new(IconName::CheckCircleFill).small().text_color({
|
||||
if status {
|
||||
cx.theme().icon_accent
|
||||
} else {
|
||||
cx.theme().icon_muted
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, App, ClickEvent, Div, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Icon,
|
||||
};
|
||||
|
||||
type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct SidebarButton {
|
||||
base: Div,
|
||||
label: SharedString,
|
||||
icon: Option<Icon>,
|
||||
handler: Handler,
|
||||
}
|
||||
|
||||
impl SidebarButton {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
base: div().flex().items_center().gap_3().px_3().h_8(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
handler: Rc::new(|_, _, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Rc::new(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SidebarButton {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let handler = self.handler.clone();
|
||||
|
||||
self.base
|
||||
.id(self.label.clone())
|
||||
.rounded(px(cx.theme().radius))
|
||||
.when_some(self.icon, |this, icon| this.child(icon))
|
||||
.child(self.label.clone())
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx))
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
div, percentage, prelude::FluentBuilder, px, App, ClickEvent, Div, Img, InteractiveElement,
|
||||
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
|
||||
Window,
|
||||
};
|
||||
use ui::{
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Collapsible, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Parent {
|
||||
base: Div,
|
||||
icon: Option<Icon>,
|
||||
label: SharedString,
|
||||
items: Vec<Folder>,
|
||||
collapsed: bool,
|
||||
handler: Handler,
|
||||
}
|
||||
|
||||
impl Parent {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
base: div().flex().flex_col().gap_2(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
items: Vec::new(),
|
||||
collapsed: false,
|
||||
handler: Rc::new(|_, _, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn child(mut self, child: impl Into<Folder>) -> Self {
|
||||
self.items.push(child.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Folder>>) -> Self {
|
||||
self.items = children.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Rc::new(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Collapsible for Parent {
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.collapsed
|
||||
}
|
||||
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Parent {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let handler = self.handler.clone();
|
||||
|
||||
self.base
|
||||
.child(
|
||||
div()
|
||||
.id(self.label.clone())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.h_8()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.font_medium()
|
||||
.child(
|
||||
Icon::new(IconName::CaretDown)
|
||||
.xsmall()
|
||||
.when(self.collapsed, |this| this.rotate(percentage(270. / 360.))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.when_some(self.icon, |this, icon| this.child(icon.small()))
|
||||
.child(self.label.clone()),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx)),
|
||||
)
|
||||
.when(!self.collapsed, |this| {
|
||||
this.child(div().flex().flex_col().gap_2().pl_3().children(self.items))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Folder {
|
||||
base: Div,
|
||||
icon: Option<Icon>,
|
||||
label: SharedString,
|
||||
items: Vec<FolderItem>,
|
||||
collapsed: bool,
|
||||
handler: Handler,
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
pub fn new(label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
base: div().flex().flex_col().gap_2(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
items: Vec::new(),
|
||||
collapsed: false,
|
||||
handler: Rc::new(|_, _, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<FolderItem>>) -> Self {
|
||||
self.items = children.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Rc::new(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Collapsible for Folder {
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.collapsed
|
||||
}
|
||||
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Folder {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let handler = self.handler.clone();
|
||||
|
||||
self.base
|
||||
.child(
|
||||
div()
|
||||
.id(self.label.clone())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.h_8()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.font_medium()
|
||||
.child(
|
||||
Icon::new(IconName::CaretDown)
|
||||
.xsmall()
|
||||
.when(self.collapsed, |this| this.rotate(percentage(270. / 360.))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.when_some(self.icon, |this, icon| this.child(icon.small()))
|
||||
.child(self.label.clone()),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx)),
|
||||
)
|
||||
.when(!self.collapsed, |this| {
|
||||
this.child(div().flex().flex_col().gap_1().pl_6().children(self.items))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct FolderItem {
|
||||
ix: usize,
|
||||
base: Div,
|
||||
img: Option<Img>,
|
||||
label: Option<SharedString>,
|
||||
description: Option<SharedString>,
|
||||
handler: Handler,
|
||||
}
|
||||
|
||||
impl FolderItem {
|
||||
pub fn new(ix: usize) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
base: div().h_8().w_full().px_2(),
|
||||
img: None,
|
||||
label: None,
|
||||
description: None,
|
||||
handler: Rc::new(|_, _, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn img(mut self, img: Option<Img>) -> Self {
|
||||
self.img = img;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Rc::new(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FolderItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let handler = self.handler.clone();
|
||||
|
||||
self.base
|
||||
.id(self.ix)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_sm()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.map(|this| {
|
||||
if let Some(img) = self.img {
|
||||
this.child(img.size_5().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
|
||||
.child(
|
||||
Icon::new(IconName::UsersThreeFill).xsmall().text_color(
|
||||
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
.when_some(self.label, |this, label| this.child(label)),
|
||||
)
|
||||
.when_some(self.description, |this, description| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::TEN))
|
||||
.child(description),
|
||||
)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx))
|
||||
}
|
||||
}
|
||||
146
crates/coop/src/views/sidebar/list_item.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use nostr_sdk::prelude::*;
|
||||
use registry::room::RoomKind;
|
||||
use registry::Registry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::actions::OpenProfile;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::context_menu::ContextMenuExt;
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{h_flex, ContextModal, StyledExt};
|
||||
|
||||
use crate::views::screening;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct RoomListItem {
|
||||
ix: usize,
|
||||
base: Div,
|
||||
room_id: u64,
|
||||
public_key: PublicKey,
|
||||
name: SharedString,
|
||||
avatar: SharedString,
|
||||
created_at: SharedString,
|
||||
kind: RoomKind,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
|
||||
}
|
||||
|
||||
impl RoomListItem {
|
||||
pub fn new(
|
||||
ix: usize,
|
||||
room_id: u64,
|
||||
public_key: PublicKey,
|
||||
name: SharedString,
|
||||
avatar: SharedString,
|
||||
created_at: SharedString,
|
||||
kind: RoomKind,
|
||||
) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
public_key,
|
||||
room_id,
|
||||
name,
|
||||
avatar,
|
||||
created_at,
|
||||
kind,
|
||||
base: h_flex().h_9().w_full().px_1p5(),
|
||||
handler: Rc::new(|_, _, _| {}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Rc::new(handler);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RoomListItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let public_key = self.public_key;
|
||||
let room_id = self.room_id;
|
||||
let kind = self.kind;
|
||||
let handler = self.handler.clone();
|
||||
let hide_avatar = AppSettings::get_hide_user_avatars(cx);
|
||||
let require_screening = AppSettings::get_screening(cx);
|
||||
|
||||
self.base
|
||||
.id(self.ix)
|
||||
.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(self.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(self.name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(self.created_at),
|
||||
),
|
||||
)
|
||||
.context_menu(move |this, _window, _cx| {
|
||||
// TODO: add share chat room
|
||||
this.menu(t!("profile.view"), Box::new(OpenProfile(public_key)))
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.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| {
|
||||
Registry::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
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
131
crates/coop/src/views/startup.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use i18n::t;
|
||||
use identity::Identity;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::popup_menu::PopupMenu;
|
||||
use ui::{Sizable, StyledExt};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
|
||||
Startup::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Startup {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Startup {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self {
|
||||
name: "Startup".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Startup {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Startup {}
|
||||
|
||||
impl Focusable for Startup {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Startup {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let identity = Identity::global(cx);
|
||||
let logging_in = identity.read(cx).logging_in();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.gap_6()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_24()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.when(logging_in, |this| {
|
||||
this.child(
|
||||
div().text_sm().text_color(cx.theme().text).child(
|
||||
SharedString::new(t!("startup.auto_login_in_progress")),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(Indicator::new().small()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().absolute().bottom_3().right_3().child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!("startup.stuck"))),
|
||||
)
|
||||
.child(
|
||||
Button::new("reset")
|
||||
.label(SharedString::new(t!("startup.reset")))
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(|_, window, cx| {
|
||||
Identity::global(cx).update(cx, |this, cx| {
|
||||
this.unload(window, cx);
|
||||
// Restart application
|
||||
cx.restart(None);
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
use chats::ChatRegistry;
|
||||
use gpui::{
|
||||
div, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Size,
|
||||
};
|
||||
|
||||
pub fn init(
|
||||
id: u64,
|
||||
subject: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Subject> {
|
||||
Subject::new(id, subject, window, cx)
|
||||
}
|
||||
|
||||
pub struct Subject {
|
||||
id: u64,
|
||||
input: Entity<TextInput>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
pub fn new(
|
||||
id: u64,
|
||||
subject: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let input = cx.new(|cx| {
|
||||
let mut this = TextInput::new(window, cx).text_size(Size::Small);
|
||||
if let Some(text) = subject.clone() {
|
||||
this.set_text(text, window, cx);
|
||||
} else {
|
||||
this.set_placeholder("prepare for holidays...");
|
||||
}
|
||||
this
|
||||
});
|
||||
|
||||
cx.new(|cx| Self {
|
||||
id,
|
||||
input,
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let registry = ChatRegistry::global(cx).read(cx);
|
||||
let subject = self.input.read(cx).text();
|
||||
|
||||
if subject.is_empty() {
|
||||
window.push_notification("Subject cannot be empty", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(room) = registry.room(&self.id, cx) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.subject = Some(subject);
|
||||
cx.notify();
|
||||
});
|
||||
window.close_modal(cx);
|
||||
} else {
|
||||
window.push_notification("Room not found", cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Subject {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const HELP_TEXT: &str = "Subject will be updated when you send a message.";
|
||||
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child("Subject:"),
|
||||
)
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.italic()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::NINE))
|
||||
.child(HELP_TEXT),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Change")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.update(window, cx))),
|
||||
)
|
||||
}
|
||||
}
|
||||
258
crates/coop/src/views/user_profile.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::display::DisplayProfile;
|
||||
use common::nip05::nip05_verify;
|
||||
use global::nostr_client;
|
||||
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 i18n::t;
|
||||
use identity::Identity;
|
||||
use nostr_sdk::prelude::*;
|
||||
use registry::Registry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, StyledExt};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
|
||||
UserProfile::new(public_key, window, cx)
|
||||
}
|
||||
|
||||
pub struct UserProfile {
|
||||
public_key: PublicKey,
|
||||
followed: bool,
|
||||
verified: bool,
|
||||
copied: bool,
|
||||
}
|
||||
|
||||
impl UserProfile {
|
||||
pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self {
|
||||
public_key,
|
||||
followed: false,
|
||||
verified: false,
|
||||
copied: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Skip if user isn't logged in
|
||||
let Some(identity) = Identity::read_global(cx).public_key() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let public_key = self.public_key;
|
||||
|
||||
let check_follow: Task<bool> = cx.background_spawn(async move {
|
||||
let client = nostr_client();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(identity)
|
||||
.pubkey(public_key)
|
||||
.limit(1);
|
||||
|
||||
client.database().count(filter).await.unwrap_or(0) >= 1
|
||||
});
|
||||
|
||||
let verify_nip05 = if let Some(address) = self.address(cx) {
|
||||
Some(Tokio::spawn(cx, async move {
|
||||
nip05_verify(public_key, &address).await.unwrap_or(false)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let followed = check_follow.await;
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn profile(&self, cx: &Context<Self>) -> Profile {
|
||||
let registry = Registry::read_global(cx);
|
||||
registry.get_person(&self.public_key, cx)
|
||||
}
|
||||
|
||||
fn address(&self, cx: &Context<Self>) -> Option<String> {
|
||||
self.profile(cx).metadata().nip05
|
||||
}
|
||||
|
||||
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = self.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();
|
||||
|
||||
// Reset the copied state after a delay
|
||||
if status {
|
||||
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();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for UserProfile {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||
let profile = self.profile(cx);
|
||||
|
||||
let Ok(bech32) = profile.public_key().to_bech32();
|
||||
let shared_bech32 = SharedString::new(bech32);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.)))
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(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::new(t!("profile.unknown"))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.block()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Public Key:"),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.p_2()
|
||||
.h_9()
|
||||
.rounded_md()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(shared_bech32),
|
||||
)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircleFill
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.ghost()
|
||||
.disabled(self.copied)
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.copy_pubkey(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::new(t!("profile.label_bio"))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_2()
|
||||
.rounded_md()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
profile
|
||||
.metadata()
|
||||
.about
|
||||
.unwrap_or(t!("profile.no_bio").to_string()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,11 @@ use gpui::{
|
||||
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
StyledExt,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::Button;
|
||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||
use ui::popup_menu::PopupMenu;
|
||||
use ui::StyledExt;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
||||
Welcome::new(window, cx)
|
||||
@@ -87,12 +85,12 @@ impl Render for Welcome {
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.child("coop on nostr.")
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::NINE))
|
||||
.child("coop on nostr")
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
.text_sm(),
|
||||
),
|
||||
|
||||
@@ -5,8 +5,13 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
dirs.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
whoami = "1.5.2"
|
||||
rustls = "0.23.23"
|
||||
|
||||
@@ -1,22 +1,60 @@
|
||||
pub const APP_NAME: &str = "Coop";
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b";
|
||||
|
||||
/// Bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://user.kindpag.es",
|
||||
"wss://relaydiscovery.com",
|
||||
"wss://purplepag.es",
|
||||
];
|
||||
|
||||
/// Subscriptions
|
||||
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
|
||||
pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps";
|
||||
|
||||
/// Image Resizer Service
|
||||
pub const IMAGE_SERVICE: &str = "https://wsrv.nl";
|
||||
|
||||
/// NIP96 Media Server
|
||||
pub const NIP96_SERVER: &str = "https://nostrmedia.com";
|
||||
pub const APP_NAME: &str = "Coop";
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
pub const APP_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc4MkNFRkQ2RkVGQURGNzUKUldSMTMvcisxdThzZUZraHc4Vno3NVNJek81VkJFUEV3MkJweGFxQXhpekdSU1JIekpqMG4yemMK";
|
||||
pub const APP_UPDATER_ENDPOINT: &str = "https://coop-updater.reya.su/";
|
||||
pub const KEYRING_URL: &str = "Coop Safe Storage";
|
||||
|
||||
pub const ACCOUNT_D: &str = "coop:account";
|
||||
pub const SETTINGS_D: &str = "coop:settings";
|
||||
|
||||
/// Bootstrap Relays.
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://nostr.Wine",
|
||||
"wss://user.kindpag.es",
|
||||
"wss://purplepag.es",
|
||||
];
|
||||
|
||||
/// Search Relays.
|
||||
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"];
|
||||
|
||||
/// NIP65 Relays. Used for new account
|
||||
pub const NIP65_RELAYS: [&str; 4] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
|
||||
/// Messaging Relays. Used for new account
|
||||
pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://relay.0xchat.com"];
|
||||
|
||||
/// Default relay for Nostr Connect
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||
|
||||
/// Default timeout (in seconds) for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
||||
|
||||
/// Unique ID for new message subscription.
|
||||
pub const NEW_MESSAGE_ID: &str = "listen_new_giftwraps";
|
||||
/// Unique ID for all messages subscription.
|
||||
pub const ALL_MESSAGES_ID: &str = "listen_all_giftwraps";
|
||||
/// Unique ID for all newest messages subscription.
|
||||
pub const ALL_NEWEST_MESSAGES_ID: &str = "listen_all_newest_giftwraps";
|
||||
|
||||
/// Total metadata requests will be grouped.
|
||||
pub const METADATA_BATCH_LIMIT: usize = 100;
|
||||
/// Maximum timeout for grouping metadata requests.
|
||||
pub const METADATA_BATCH_TIMEOUT: u64 = 400;
|
||||
|
||||
/// Default width for all modals.
|
||||
pub const DEFAULT_MODAL_WIDTH: f32 = 420.;
|
||||
/// Default width of the sidebar.
|
||||
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 280.;
|
||||
|
||||
/// Image Resize Service
|
||||
pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl";
|
||||
|
||||
/// Default NIP96 Media Server.
|
||||
pub const NIP96_SERVER: &str = "https://nostrmedia.com";
|
||||
|
||||
@@ -1,37 +1,90 @@
|
||||
use nostr_sdk::prelude::*;
|
||||
use paths::nostr_file;
|
||||
|
||||
use std::{sync::OnceLock, time::Duration};
|
||||
|
||||
pub mod constants;
|
||||
pub mod paths;
|
||||
|
||||
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
static CLIENT_KEYS: OnceLock<Keys> = OnceLock::new();
|
||||
|
||||
/// Nostr Client instance
|
||||
pub fn get_client() -> &'static Client {
|
||||
CLIENT.get_or_init(|| {
|
||||
// Setup database
|
||||
let db_path = nostr_file();
|
||||
let lmdb = NostrLMDB::open(db_path).expect("Database is NOT initialized");
|
||||
|
||||
// Client options
|
||||
let opts = Options::new()
|
||||
// NIP-65
|
||||
// Coop is don't really need to enable this option,
|
||||
// but this will help the client discover user's messaging relays efficiently.
|
||||
.gossip(true)
|
||||
// Skip all very slow relays
|
||||
// Note: max delay is 800ms
|
||||
.max_avg_latency(Duration::from_millis(800));
|
||||
|
||||
// Setup Nostr Client
|
||||
ClientBuilder::default().database(lmdb).opts(opts).build()
|
||||
})
|
||||
}
|
||||
|
||||
/// Client Keys
|
||||
pub fn get_client_keys() -> &'static Keys {
|
||||
CLIENT_KEYS.get_or_init(Keys::generate)
|
||||
}
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use paths::nostr_file;
|
||||
|
||||
use crate::paths::support_dir;
|
||||
|
||||
pub mod constants;
|
||||
pub mod paths;
|
||||
|
||||
/// Signals sent through the global event channel to notify UI components
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NostrSignal {
|
||||
/// Received a new metadata event from Relay Pool
|
||||
Metadata(Event),
|
||||
|
||||
/// Received a new gift wrap event from Relay Pool
|
||||
GiftWrap(Event),
|
||||
|
||||
/// Finished processing all gift wrap events
|
||||
Finish,
|
||||
|
||||
/// Partially finished processing all gift wrap events
|
||||
PartialFinish,
|
||||
|
||||
/// Receives EOSE response from relay pool
|
||||
Eose(SubscriptionId),
|
||||
|
||||
/// Notice from Relay Pool
|
||||
Notice(String),
|
||||
}
|
||||
|
||||
static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
static FIRST_RUN: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
pub fn nostr_client() -> &'static Client {
|
||||
NOSTR_CLIENT.get_or_init(|| {
|
||||
// rustls uses the `aws_lc_rs` provider by default
|
||||
// This only errors if the default provider has already
|
||||
// been installed. We can ignore this `Result`.
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.ok();
|
||||
|
||||
let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized");
|
||||
|
||||
let opts = ClientOptions::new()
|
||||
// Coop isn't social client,
|
||||
// but it needs this option because it needs user's NIP65 Relays to fetch NIP17 Relays.
|
||||
.gossip(true)
|
||||
// TODO: Coop should handle authentication by itself
|
||||
.automatic_authentication(true)
|
||||
// Sleep after idle for 5 seconds
|
||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||
timeout: Duration::from_secs(10),
|
||||
});
|
||||
|
||||
ClientBuilder::default().database(lmdb).opts(opts).build()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn first_run() -> &'static bool {
|
||||
FIRST_RUN.get_or_init(|| {
|
||||
let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
if !flag.exists() {
|
||||
if std::fs::write(&flag, "").is_err() {
|
||||
return false;
|
||||
}
|
||||
true // First run
|
||||
} else {
|
||||
false // Not first run
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_all_gift_wraps_fetched() {
|
||||
let flag = support_dir().join(".fetched");
|
||||
|
||||
if !flag.exists() && smol::fs::write(&flag, "").await.is_err() {
|
||||
log::error!("Failed to create full run flag");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_gift_wraps_fetch_complete() -> bool {
|
||||
let flag = support_dir().join(".fetched");
|
||||
flag.exists()
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
8
crates/i18n/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "i18n"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rust-i18n.workspace = true
|
||||
39
crates/i18n/src/lib.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use rust_i18n::Backend;
|
||||
|
||||
rust_i18n::i18n!("../../locales");
|
||||
|
||||
pub struct I18nBackend;
|
||||
|
||||
impl Backend for I18nBackend {
|
||||
fn available_locales(&self) -> Vec<&str> {
|
||||
_RUST_I18N_BACKEND.available_locales()
|
||||
}
|
||||
|
||||
fn translate(&self, locale: &str, key: &str) -> Option<&str> {
|
||||
let val = _RUST_I18N_BACKEND.translate(locale, key);
|
||||
if val.is_none() {
|
||||
_RUST_I18N_BACKEND.translate("en", key)
|
||||
} else {
|
||||
val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! init {
|
||||
() => {
|
||||
rust_i18n::i18n!(backend = i18n::I18nBackend);
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! shared_t {
|
||||
($key:expr) => {
|
||||
SharedString::new(t!($key))
|
||||
};
|
||||
($key:expr, $($param:ident = $value:expr),+) => {
|
||||
SharedString::new(t!($key, $($param = $value),+))
|
||||
};
|
||||
}
|
||||
|
||||
pub use rust_i18n::{set_locale, t};
|
||||