67 Commits

Author SHA1 Message Date
d6edc8b546 chore: update metadata and version 2025-08-05 15:06:51 +07:00
053ecc6a15 chore: update readme 2025-08-05 13:22:29 +07:00
reya
fe864e4a7f chore: improve github action (#105)
* fix build step

* fix ci

* fix build

* fix ci on linux

* .

* fix flatpak

* .

* .

* .

* fix snap

* .

* .

* .

* .

* fix

* .

* .

* fix path

* .

* .

* fix upload artifacts

* fix build on arm

* fix snap arm

* .

* .
2025-08-05 13:14:35 +07:00
871bbdac78 chore: fix ci 2025-08-04 11:47:50 +07:00
0ea919901e chore: fix ci 2025-08-04 10:57:05 +07:00
3772853141 chore: update gh action 2025-08-04 10:09:36 +07:00
ab4597cb6f chore: add release action 2025-08-04 08:59:24 +07:00
ed6e4f2082 chore: update deps 2025-08-04 07:27:53 +07:00
493223276c chore: improve message fetching 2025-08-03 20:34:35 +07:00
c8c5a6668d chore: refactor auto updater 2025-08-02 17:28:27 +07:00
86d24ccbd5 chore: fix identifier 2025-08-02 13:17:43 +07:00
80c649f9a0 chore: prepare to release 2025-08-02 13:17:06 +07:00
reya
c188f12993 chore: Refine the UI (#102)
* update deps

* update window options

* linux title bar

* fix build

* .

* fix build

* rounded corners on linux

* .

* .

* fix i18n key

* fix change subject modal

* .

* update new account

* .

* update relay modal

* .

* fix i18n keys

---------

Co-authored-by: reya <reya@macbook.local>
2025-08-02 11:37:15 +07:00
reya
3cf9dde882 chore: Improve Request Screening (#101)
* open chat while screening

* close panel on ignore

* bypass screening

* .

* improve settings

* refine modal

* .

* .

* .

* .

* .
2025-07-27 07:22:31 +07:00
reya
91cca37d69 chore: Improve Font Rendering on Linux (#100)
* add zed plex sans

* .
2025-07-25 07:20:47 +07:00
12168c6084 chore: update gpui-component 2025-07-23 20:47:15 +07:00
reya
a631dd90d2 feat: screening (#96)
* .

* .

* refactor

* .

* screening

* add report user function

* add danger and warning styles

* update deps

* update

* fix line height

* .
2025-07-23 12:45:01 +07:00
reya
00b40db82c chore: improve text input (#94)
* update history

* hide cursor & selection when window is deactivated - gpui-component

* .

* update input to catch up with gpui-component

* adjust history
2025-07-18 09:25:55 +07:00
59cfdb9ae2 chore: improve render modal and update deps 2025-07-17 07:54:40 +07:00
73a2678278 chore: update deps 2025-07-16 15:18:18 +07:00
reya
c7ab75d310 chore: fix translations (#93)
* fix

* .
2025-07-16 14:57:57 +07:00
reya
8195eedaf6 chore: improve render message (#84)
* .

* refactor upload button

* refactor

* dispatch action on mention clicked

* add profile modal

* .

* .

* .

* improve rich_text

* improve handle url

* make registry simpler

* refactor

* .

* clean up
2025-07-16 14:37:26 +07:00
alltheseas
9f02942d87 Update README.md with additional Linux prerequisites (#90)
Updated README.md with Linux prerequisites - openSSL, X11, build-essential
2025-07-16 07:19:54 +07:00
reya
2e3a4b3634 chore: improve search (#83)
* .

* .

* wip: add nip05 search

* add nip05 search

* .

* support cancel search

* .
2025-07-10 09:03:54 +07:00
reya
8bfad30a99 chore: improve data requests (#81)
* refactor

* refactor

* add documents

* clean up

* refactor

* clean up

* refactor identity

* .

* .

* rename
2025-07-08 15:23:35 +07:00
122dbaf693 chore: add document for actions 2025-07-05 08:33:40 +07:00
9bb784652d chore: update deps 2025-07-04 15:01:57 +07:00
reya
c1d5c7e719 feat: add support for multi languages (#79)
* update backup settings description

* add rust-i18n

* translate

* .

* update translations

* fix

* update translate

* .
2025-07-04 14:57:22 +07:00
f9bf29df09 chore: fix copy/paste on linux 2025-07-02 15:53:52 +07:00
2e046ec5d7 chore: update deps 2025-07-02 15:30:28 +07:00
abb1474300 chore: update gpui and nostr-sdk 2025-06-30 08:35:48 +07:00
reya
b212095334 chore: Improve Auto Login (#71)
* improve auto login

* add auto login status

* add reset button on startup
2025-06-29 08:01:08 +07:00
@RandyMcMillan
2dfb48b538 build tooling (#69)
* script/macos:add

* script/linux:add libx11-dev

* Cargo.toml:pin nostr nostr-sdk nostr nostr-connect

---------

Co-authored-by: reya <123083837+reyamir@users.noreply.github.com>
2025-06-28 14:14:54 +07:00
reya
14076054c0 chore: Improve Login Process (#70)
* wip

* simplify nostr connect uri logic

* improve wait for connection

* improve handle password

* .

* add countdown
2025-06-28 10:09:31 +07:00
3c2eaabab2 chore: update gpui and nostr sdk 2025-06-25 20:00:05 +07:00
reya
edee9305cc feat: improve search and handle input in compose (#67)
* feat: support search by npub or nprofile

* .

* .

* .

* chore: prevent update local search with empty result

* clean up

* .
2025-06-25 15:03:05 +07:00
reya
c7e3331eb0 feat: wait for processing to complete (#66)
* wait instead of check eose

* refactor

* refactor

* refactor

* improve extend rooms function

* .
2025-06-23 09:00:56 +07:00
1d77fd443e chore: always show title bar on linux and windows 2025-06-18 12:26:04 +07:00
5f5bb33654 chore: update gpui components 2025-06-17 14:50:46 +07:00
052b0163cb chore: add goreleaser 2025-06-17 13:04:17 +07:00
5f8e886a34 chore: update gpui 2025-06-17 08:00:47 +07:00
reya
440f17af18 refactor: Client keys and Identity (#61)
* .

* .

* .

* .

* refactor client keys

* .

* .

* refactor

* .

* .

* .

* update new account
2025-06-17 07:16:16 +07:00
reya
cc36adeafe feat: Basic Application Settings (#58)
* .

* .

* .

* update modal
2025-06-13 07:56:59 +07:00
reya
e687204361 chore: refactor the global state and improve signer (#56)
* refactor

* update

* .

* rustfmt

* .

* .

* .

* .

* .

* add document

* .

* add logout

* handle error

* chore: update gpui

* adjust timeout
2025-06-07 14:52:21 +07:00
reya
50beaebd2c chore: improve message copying functionality (#53)
* chore: improve message copying functionality

* .

* clean up
2025-05-31 07:29:56 +07:00
reya
7cc512331b press the down key to move to the end of the line (#52) 2025-05-30 12:14:51 +07:00
63191c16bd chore: use primary clipboard when pasting (linux only) 2025-05-30 07:39:30 +07:00
reya
a674ac898a feat: Middle Click (#51)
* paste on middle click

* middle click to close tab

* middle click to reply
2025-05-29 17:24:51 +07:00
reya
557ff18714 chore: improve room kind handling (#48)
* chore: improve room kind handling

* .

* add some tooltips

* .

* fix button hovered style

* .

* improve prevent duplicate message

* .
2025-05-29 09:05:08 +07:00
7a447da447 chore: improve nostr connect and search 2025-05-27 08:59:51 +07:00
92d862e1fa chore: update gpui 2025-05-27 07:44:17 +07:00
reya
0f884f8142 chore: improve performance (#42)
* use uniform list for rooms list

* move profile cache to outside gpui context

* update comment

* refactor

* refactor

* .

* .

* add avatar component

* .

* refactor

* .
2025-05-27 07:34:22 +07:00
Vitor Pamplona
45564c7722 Massively improves the boot speed by: (#41)
1. ignoring duplicated events coming to the client
2. avoiding checking for trust in disk for every event (uses a simple cache)
2025-05-22 07:18:09 +07:00
b0a6b73801 chore: update text input 2025-05-21 18:48:00 +07:00
e851063de9 chore: update gpui 2025-05-21 17:45:48 +07:00
reya
3fd236de73 feat: Reply or Reference a specific message (#39)
* add reply to when send message

* show reply message

* refactor

* multiple quote
2025-05-21 17:44:43 +07:00
ba42bafc3a chore: fix input auto-grow height 2025-05-19 07:25:39 +07:00
71fbd97bad chore: adjust global consts 2025-05-18 16:00:28 +07:00
reya
443dbc82a6 chore: Improve Chat Performance (#35)
* refactor

* optimistically update message list

* fix

* update

* handle duplicate messages

* update ui

* refactor input

* update multi line input

* clean up
2025-05-18 15:35:33 +07:00
reya
4f066b7c00 feat: Improve Blink Cursor (#34)
* add cursor color to theme

* adjust
2025-05-13 13:26:02 +07:00
reya
4e24061817 feat: Redesign New Chat (#31)
* make subject is optional

* redesign

* search

* fix

* adjust
2025-05-12 20:46:01 +07:00
2f83b5091e chore: revamp theme 2025-05-07 14:12:31 +07:00
reya
97e66fbeb7 chore: improve nostr connect (#21)
* ref

* update

* temporary switch to rust-nostr fork

* use nip46 branch
2025-05-06 07:38:15 +07:00
3fea18f038 feat: automatically open the chat room 2025-05-04 10:12:15 +07:00
3bd8592f86 chore: clean up current dock when logout 2025-05-03 08:39:17 +07:00
reya
8c211be11a feat: add search and refactor modal (#19)
* add find button to sidebar

* update

* improve search

* add error msg
2025-05-02 17:03:49 +07:00
2c2aeb915e feat: add option for toggle chat folders 2025-04-30 13:10:18 +07:00
208 changed files with 21673 additions and 13852 deletions

View File

@@ -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
View 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
View 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
View File

@@ -19,3 +19,5 @@ dist/
# Useless stuffs
.DS_Store
# Added by goreleaser init:
.intentionally-empty-file.o

2563
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,46 +4,51 @@ members = ["crates/*"]
default-members = ["crates/coop"]
[workspace.package]
version = "0.1.5"
version = "0.2.0"
edition = "2021"
publish = false
[workspace.dependencies]
coop = { path = "crates/*" }
[workspace.metadata.i18n]
available-locales = ["en", "zh-CN", "zh-TW", "ru", "vi", "ja", "es", "pt", "ko"]
default-locale = "en"
load-path = "locales"
# UI
[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", features = ["parser"] }
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
nostr = { 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",
"lmdb",
"nip96",
"nip59",
"nip49",
"nip44",
] }
# Others
anyhow = "1.0.44"
chrono = "0.4.38"
dirs = "5.0"
emojis = "0.6.4"
smol = "2"
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"
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"
smol = "2"
tracing = "0.1.40"
[profile.release]
strip = true

View File

@@ -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",
]

View File

@@ -1,19 +1,34 @@
![CoopDemo](/docs/coop.jpg)
![Coop](/docs/coop.png)
<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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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.

View File

@@ -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
View 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

View 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
View 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
View 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

View 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
View 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

View 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

View 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

View File

@@ -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
View 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
View 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

View 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

View File

@@ -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

View 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

View 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
View 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

View File

@@ -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
View 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
View 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()
}
}

View File

@@ -13,6 +13,6 @@ nostr-sdk.workspace = true
anyhow.workspace = true
smol.workspace = true
log.workspace = true
smallvec.workspace = true
tempfile = "3.19.1"
reqwest = { version = "0.12", features = ["stream"] }
cargo-packager-updater = "0.2.3"

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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();
}
}
}

View File

@@ -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
}
}

View File

@@ -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();
}
}

View File

@@ -1,17 +1,14 @@
[package]
name = "account"
name = "client_keys"
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
gpui.workspace = true
anyhow.workspace = true
smol.workspace = true
smallvec.workspace = true
log.workspace = true
smallvec.workspace = true

View 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()
}
}

View File

@@ -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"

View 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;
}
}));
}
}

View 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()
}

View 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
}
}

View 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(())
})
}
}

View File

@@ -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;

View 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"))
}
}

View 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)
}

View File

@@ -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()
}
}

View File

@@ -8,31 +8,56 @@ publish.workspace = true
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" }
chats = { path = "../chats" }
account = { path = "../account" }
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
rust-embed.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
rustls = "0.23.23"
futures = "0.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

View 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;

View 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>

View 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"
}
]
}
]
}

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View 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

View File

@@ -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())
}
}

View File

@@ -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(
&registry,
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))
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View 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()),
)
}),
)
}
}

View File

@@ -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()),
),
),
)
}
}

View 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)),
),
),
)
}
}

View 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"))),
)
}
}

View File

@@ -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);
})),
)
}
}

View File

@@ -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())
}
})
}
}

View File

@@ -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()),
)
}
}

View File

@@ -1,44 +1,43 @@
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
use std::time::Duration;
use account::Account;
use common::create_qr;
use global::get_client_keys;
use client_keys::ClientKeys;
use common::display::TextUtils;
use common::handle_auth::CoopAuthUrlHandler;
use global::constants::{APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, prelude::FluentBuilder, relative, AnyElement, App, AppContext, Context, Entity,
EventEmitter, FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Window,
div, img, red, relative, AnyElement, App, AppContext, ClipboardItem, Context, Entity,
EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
};
use i18n::{shared_t, t};
use identity::Identity;
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::{InputEvent, TextInput},
notification::Notification,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Disableable, Sizable, Size, StyledExt,
};
const INPUT_INVALID: &str = "You must provide a valid Private Key or Bunker.";
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::{ContextModal, Disableable, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx)
}
pub struct Login {
// Inputs
key_input: Entity<TextInput>,
error_message: Entity<Option<SharedString>>,
is_logging_in: bool,
// Nostr Connect
qr: Option<Arc<Image>>,
connect_relay: Entity<TextInput>,
connect_client: Entity<Option<NostrConnectURI>>,
key_input: Entity<InputState>,
relay_input: Entity<InputState>,
connection_string: Entity<NostrConnectURI>,
qr_image: Entity<Option<Arc<Image>>>,
// Error for the key input
error: Entity<Option<SharedString>>,
countdown: Entity<Option<u64>>,
logging_in: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 3]>,
@@ -50,87 +49,93 @@ impl Login {
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
// nsec or bunker_uri (NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md)
let key_input =
cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://..."));
let relay_input = cx.new(|cx| {
InputState::new(window, cx)
.default_value(NOSTR_CONNECT_RELAY)
.placeholder(NOSTR_CONNECT_RELAY)
});
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
// Direct connection initiated by the client
let connection_string = cx.new(|cx| {
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let client_keys = ClientKeys::get_global(cx).keys();
NostrConnectURI::client(client_keys.public_key(), vec![relay], APP_NAME)
});
let qr_image = cx.new(|_| None);
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
let error_message = cx.new(|_| None);
let connect_client = cx.new(|_: &mut Context<'_, Option<NostrConnectURI>>| None);
let key_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::Small)
.placeholder("nsec... or bunker://...")
});
let connect_relay = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::Small).small();
input.set_text("wss://relay.nsec.app", window, cx);
input
});
subscriptions.push(cx.subscribe_in(
&key_input,
window,
move |this, _, event, window, cx| {
if let InputEvent::PressEnter = event {
this.login(window, cx);
}
},
));
subscriptions.push(cx.subscribe_in(
&connect_relay,
window,
move |this, _, event, window, cx| {
if let InputEvent::PressEnter = event {
this.change_relay(window, cx);
}
},
));
// Subscribe to key input events and process login when the user presses enter
subscriptions.push(
cx.observe_in(&connect_client, window, |this, uri, window, cx| {
let keys = get_client_keys().to_owned();
let account = Account::global(cx);
if let Some(uri) = uri.read(cx).clone() {
if let Ok(qr) = create_qr(uri.to_string().as_str()) {
this.qr = Some(qr);
cx.notify();
}
match NostrConnect::new(uri, keys, Duration::from_secs(300), None) {
Ok(signer) => {
account.update(cx, |this, cx| {
this.login(signer, window, cx);
});
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
}
cx.subscribe_in(&key_input, window, |this, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
}
}),
);
cx.spawn(async move |this, cx| {
// Subscribe to relay input events and change relay when the user presses enter
subscriptions.push(
cx.subscribe_in(&relay_input, window, |this, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.change_relay(window, cx);
}
}),
);
// Observe changes to the Nostr Connect URI and wait for a connection
subscriptions.push(cx.observe_in(
&connection_string,
window,
|this, entity, window, cx| {
let connection_string = entity.read(cx).clone();
let client_keys = ClientKeys::get_global(cx).keys();
// Update the QR Image with the new connection string
this.qr_image.update(cx, |this, cx| {
*this = connection_string.to_string().to_qr();
cx.notify();
});
match NostrConnect::new(
connection_string,
client_keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
) {
Ok(mut signer) => {
// Automatically open auth url
signer.auth_url_handler(CoopAuthUrlHandler);
// Wait for connection in the background
this.wait_for_connection(signer, window, cx);
}
Err(e) => {
window.push_notification(
Notification::error(e.to_string()).title("Nostr Connect"),
cx,
);
}
}
},
));
// Create a Nostr Connect URI and QR Code 800ms after opening the login screen
cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(500))
.timer(Duration::from_millis(800))
.await;
cx.update(|cx| {
this.update(cx, |this, cx| {
let Ok(relay_url) =
RelayUrl::parse(this.connect_relay.read(cx).text().to_string().as_str())
else {
return;
};
let client_pubkey = get_client_keys().public_key();
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
this.connect_client.update(cx, |this, cx| {
*this = Some(uri);
cx.notify();
});
this.update(cx, |this, cx| {
this.connection_string.update(cx, |_, cx| {
cx.notify();
})
})
.ok();
@@ -138,98 +143,366 @@ impl Login {
.detach();
Self {
key_input,
connect_relay,
connect_client,
subscriptions,
error_message,
qr: None,
is_logging_in: false,
name: "Login".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
logging_in: false,
countdown,
key_input,
relay_input,
connection_string,
qr_image,
error,
subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_logging_in {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
// Disable the input
self.key_input.update(cx, |this, cx| {
this.set_loading(true, cx);
this.set_disabled(true, cx);
});
// Content can be secret key or bunker://
match self.key_input.read(cx).value().to_string() {
s if s.starts_with("nsec1") => self.ask_for_password(s, window, cx),
s if s.starts_with("ncryptsec1") => self.ask_for_password(s, window, cx),
s if s.starts_with("bunker://") => self.login_with_bunker(s, window, cx),
_ => self.set_error(t!("login.invalid_key"), window, cx),
};
}
fn ask_for_password(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let current_view = cx.entity().downgrade();
let is_ncryptsec = content.starts_with("ncryptsec1");
let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_pwd_input = pwd_input.downgrade();
let confirm_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_confirm_input = confirm_input.downgrade();
window.open_modal(cx, move |this, _window, cx| {
let weak_pwd_input = weak_pwd_input.clone();
let weak_confirm_input = weak_confirm_input.clone();
let view_cancel = current_view.clone();
let view_ok = current_view.clone();
let label: SharedString = if !is_ncryptsec {
t!("login.set_password").into()
} else {
t!("login.password_to_decrypt").into()
};
let description: SharedString = if is_ncryptsec {
t!("login.password_description").into()
} else {
t!("login.password_description_full").into()
};
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, window, cx| {
view_cancel
.update(cx, |this, cx| {
this.set_error(t!("login.password_is_required"), window, cx);
})
.ok();
true
})
.on_ok(move |_, window, cx| {
let value = weak_pwd_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
let confirm = weak_confirm_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
view_ok
.update(cx, |this, cx| {
this.verify_password(value, confirm, is_ncryptsec, window, cx);
})
.ok();
true
})
.child(
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.text_sm()
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(label)
.child(TextInput::new(&pwd_input).small()),
)
.when(content.starts_with("nsec1"), |this| {
this.child(
div()
.flex()
.flex_col()
.gap_1()
.child(SharedString::new(t!("login.confirm_password")))
.child(TextInput::new(&confirm_input).small()),
)
})
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(description),
),
)
});
}
fn verify_password(
&mut self,
password: Option<SharedString>,
confirm: Option<SharedString>,
is_ncryptsec: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(password) = password else {
self.set_error(t!("login.password_is_required"), window, cx);
return;
};
self.set_logging_in(true, cx);
if password.is_empty() {
self.set_error(t!("login.password_is_required"), window, cx);
return;
}
let content = self.key_input.read(cx).text();
let account = Account::global(cx);
// Skip verification if key is ncryptsec
if is_ncryptsec {
self.login_with_keys(password.to_string(), window, cx);
return;
}
if content.starts_with("nsec1") {
match SecretKey::parse(content.as_ref()) {
Ok(secret) => {
let keys = Keys::new(secret);
account.update(cx, |this, cx| {
this.login(keys, window, cx);
});
}
Err(e) => {
self.set_error_message(e.to_string(), cx);
self.set_logging_in(false, cx);
}
}
} else if content.starts_with("bunker://") {
let keys = get_client_keys().to_owned();
let Ok(uri) = NostrConnectURI::parse(content.as_ref()) else {
self.set_error_message("Bunker URL is not valid".to_owned(), cx);
self.set_logging_in(false, cx);
return;
};
match NostrConnect::new(uri, keys, Duration::from_secs(120), None) {
Ok(signer) => {
account.update(cx, |this, cx| {
this.login(signer, window, cx);
});
}
Err(e) => {
self.set_error_message(e.to_string(), cx);
self.set_logging_in(false, cx);
}
}
} else {
window.push_notification(Notification::error(INPUT_INVALID), cx);
self.set_logging_in(false, cx);
let Some(confirm) = confirm else {
self.set_error(t!("login.must_confirm_password"), window, cx);
return;
};
if confirm.is_empty() {
self.set_error(t!("login.must_confirm_password"), window, cx);
return;
}
if password != confirm {
self.set_error(t!("login.password_not_match"), window, cx);
return;
}
self.login_with_keys(password.to_string(), window, cx);
}
fn login_with_keys(&mut self, password: String, window: &mut Window, cx: &mut Context<Self>) {
let value = self.key_input.read(cx).value().to_string();
let secret_key = if value.starts_with("nsec1") {
SecretKey::parse(&value).ok()
} else if value.starts_with("ncryptsec1") {
EncryptedSecretKey::from_bech32(&value)
.map(|enc| enc.decrypt(&password).ok())
.unwrap_or_default()
} else {
None
};
if let Some(secret_key) = secret_key {
let keys = Keys::new(secret_key);
Identity::global(cx).update(cx, |this, cx| {
this.write_keys(&keys, password, cx);
this.set_signer(keys, window, cx);
});
} else {
self.set_error(t!("login.key_invalid"), window, cx);
}
}
fn login_with_bunker(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectURI::parse(content) else {
self.set_error(t!("login.bunker_invalid"), window, cx);
return;
};
let client_keys = ClientKeys::get_global(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT / 8);
// .unwrap() is fine here because there's no error handling for bunker uri
let mut signer = NostrConnect::new(uri, client_keys, timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=NOSTR_CONNECT_TIMEOUT / 8).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
cx.spawn_in(window, async move |this, cx| {
match signer.bunker_uri().await {
Ok(bunker_uri) => {
cx.update(|window, cx| {
window.push_notification(t!("login.logging_in"), cx);
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&bunker_uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
}
Err(error) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
// Force reset the client keys without notify UI
ClientKeys::global(cx).update(cx, |this, cx| {
log::info!("Timeout occurred. Reset client keys");
this.force_new_keys(cx);
});
this.set_error(error.to_string(), window, cx);
})
.ok();
})
.ok();
}
}
})
.detach();
}
fn wait_for_connection(
&mut self,
signer: NostrConnect,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.spawn_in(window, async move |this, cx| {
match signer.bunker_uri().await {
Ok(uri) => {
cx.update(|window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
// Only send notifications on the login screen
this.update(cx, |_, cx| {
window.push_notification(
Notification::error(e.to_string()).title("Nostr Connect"),
cx,
);
})
.ok();
})
.ok();
}
}
})
.detach();
}
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(relay_url) =
RelayUrl::parse(self.connect_relay.read(cx).text().to_string().as_str())
let Ok(relay_url) = RelayUrl::parse(self.relay_input.read(cx).value().to_string().as_str())
else {
window.push_notification(Notification::error("Relay URL is not valid."), cx);
window.push_notification(Notification::error(t!("relays.invalid")), cx);
return;
};
let client_pubkey = get_client_keys().public_key();
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
let client_keys = ClientKeys::get_global(cx).keys();
let uri = NostrConnectURI::client(client_keys.public_key(), vec![relay_url], "Coop");
self.connect_client.update(cx, |this, cx| {
*this = Some(uri);
self.connection_string.update(cx, |this, cx| {
*this = uri;
cx.notify();
});
}
fn set_error_message(&mut self, message: String, cx: &mut Context<Self>) {
self.error_message.update(cx, |this, cx| {
*this = Some(SharedString::new(message));
fn set_error(
&mut self,
message: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Reset the log in state
self.set_logging_in(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Re enable the input
self.key_input.update(cx, |this, cx| {
this.set_value("", window, cx);
this.set_loading(false, cx);
this.set_disabled(false, cx);
});
// Clear the error message after 3 secs
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})
.ok();
})
.detach();
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_logging_in = status;
self.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for Login {
@@ -241,14 +514,6 @@ impl Panel for Login {
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)
}
@@ -294,14 +559,12 @@ impl Render for Login {
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child("Welcome Back!"),
.child(shared_t!("login.title")),
)
.child(
div()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Continue with Private Key or Bunker"),
.text_color(cx.theme().text_muted)
.child(shared_t!("login.key_description")),
),
)
.child(
@@ -309,110 +572,123 @@ impl Render for Login {
.flex()
.flex_col()
.gap_3()
.child(self.key_input.clone())
.child(TextInput::new(&self.key_input))
.child(
Button::new("login")
.label("Continue")
.label(t!("common.continue"))
.primary()
.loading(self.is_logging_in)
.disabled(self.is_logging_in)
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(
self.error_message.read(cx).clone(),
|this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger)
.child(error),
)
},
),
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.approve_message", i = i)),
)
})
.when_some(self.error.read(cx).clone(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(red())
.child(error),
)
}),
),
),
)
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().base.step(cx, ColorScaleStep::TWO))
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.2))
.text_color(
cx.theme().base.step(cx, ColorScaleStep::TWELVE),
)
.child("Continue with Nostr Connect"),
)
.child(
div()
.text_sm()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Use Nostr Connect apps to scan the code"),
),
)
.when_some(self.qr.clone(), |this, qr| {
this.child(
div().flex_1().p_1().child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.mb_2()
.p_2()
.size_64()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.2))
.text_color(cx.theme().text)
.child(shared_t!("login.nostr_connect")),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.scan_qr")),
),
)
.when_some(self.qr_image.read(cx).clone(), |this, qr| {
this.child(
div()
.id("")
.mb_2()
.p_2()
.size_72()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.shadow_md()
.when(cx.theme().mode.is_dark(), |this| {
this.shadow_none()
.border_1()
.border_color(cx.theme().border)
})
.bg(cx.theme().background)
.child(img(qr).h_64())
.on_click(cx.listener(move |this, _, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
this.connection_string.read(cx).to_string(),
));
window.push_notification(t!("common.copied"), cx);
})),
)
})
.child(
div()
.w_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.shadow_md()
.when(cx.theme().appearance.is_dark(), |this| {
this.shadow_none().border_1().border_color(
cx.theme().base.step(cx, ColorScaleStep::SIX),
)
})
.bg(cx.theme().background)
.child(img(qr).h_56()),
)
})
.child(
div()
.w_full()
.flex()
.items_center()
.justify_center()
.gap_1()
.child(self.connect_relay.clone())
.child(
Button::new("change")
.label("Change")
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.change_relay(window, cx);
})),
),
),
),
.gap_1()
.child(TextInput::new(&self.relay_input).xsmall())
.child(
Button::new("change")
.label(t!("common.change"))
.ghost()
.xsmall()
.on_click(cx.listener(
move |this, _, window, cx| {
this.change_relay(window, cx);
},
)),
),
),
),
),
)
}
}

View 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))
}
})
}
}

View File

@@ -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;

View File

@@ -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)

View File

@@ -1,14 +1,26 @@
use anyhow::anyhow;
use common::display::DisplayProfile;
use global::constants::ACCOUNT_D;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
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,
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;
@@ -18,6 +30,8 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
pub struct Onboarding {
name: SharedString,
local_account: Entity<Option<Profile>>,
loading: bool,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
@@ -28,14 +42,64 @@ impl Onboarding {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
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 {
@@ -70,8 +134,8 @@ impl Focusable for Onboarding {
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.";
let auto_login = AppSettings::get_auto_login(cx);
let proxy = AppSettings::get_proxy_user_avatars(cx);
div()
.py_4()
@@ -80,9 +144,9 @@ impl Render for Onboarding {
.flex_col()
.items_center()
.justify_center()
.gap_10()
.child(
div()
.mb_10()
.flex()
.flex_col()
.items_center()
@@ -91,7 +155,7 @@ impl Render for Onboarding {
svg()
.path("brand/coop.svg")
.size_16()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
.text_color(cx.theme().elevated_surface_background),
)
.child(
div()
@@ -101,40 +165,128 @@ impl Render for Onboarding {
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(TITLE),
.child(SharedString::new(t!("welcome.title"))),
)
.child(
div()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(SUBTITLE),
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("welcome.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);
})),
.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);
})),
),
)
.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);
})),
),
)
}
})
}
}

View 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);
}),
),
),
)
}
}

View File

@@ -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);
})),
)
}
}

View 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
}
}))
}

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View 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
})
});
}
})
}
}

File diff suppressed because it is too large Load Diff

View 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);
});
}),
),
),
)
}
}

View File

@@ -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))),
)
}
}

View 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()),
),
),
)
}
}

View File

@@ -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(),
),

View File

@@ -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"

View File

@@ -1,22 +1,60 @@
pub const APP_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b";
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";
/// Bootstrap relays
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://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";
/// Search Relays.
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"];
/// Image Resizer Service
pub const IMAGE_SERVICE: &str = "https://wsrv.nl";
/// 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",
];
/// NIP96 Media Server
/// 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";

View File

@@ -1,37 +1,90 @@
use std::sync::OnceLock;
use std::time::Duration;
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use paths::nostr_file;
use std::{sync::OnceLock, time::Duration};
use crate::paths::support_dir;
pub mod constants;
pub mod paths;
static CLIENT: OnceLock<Client> = OnceLock::new();
static CLIENT_KEYS: OnceLock<Keys> = OnceLock::new();
/// 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),
/// 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");
/// Received a new gift wrap event from Relay Pool
GiftWrap(Event),
// 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.
/// 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)
// Skip all very slow relays
// Note: max delay is 800ms
.max_avg_latency(Duration::from_millis(800));
// 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),
});
// 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)
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()
}

View File

@@ -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
View 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
View 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};

Some files were not shown because too many files have changed in this diff Show More