89 Commits

Author SHA1 Message Date
6a5304514f chore: bump version 2025-08-31 19:08:37 +07:00
reya
f2be8fca08 feat: add setting for relay authentication (#133)
* remember auth relay

* .

* .
2025-08-31 18:06:04 +07:00
reya
807851518a feat: manually handle NIP-42 auth request (#132)
* improve fetch relays

* .

* .

* .

* refactor

* refactor

* remove identity

* manually auth

* auth

* prevent duplicate message

* clean up
2025-08-30 14:38:00 +07:00
49a3dedd9c chore: clean up 2025-08-25 13:46:46 +07:00
reya
b19bb01003 feat: support triple-click to select entire line 2025-08-25 12:37:20 +07:00
3a6fc2bcc5 chore: fix messages not loading 2025-08-25 12:13:45 +07:00
reya
5edcc97ada chore: rework login and identity (#129)
* .

* redesign onboarding screen

* .

* add signer proxy

* .

* .

* .

* .

* fix proxy

* clean up

* fix new account
2025-08-25 09:22:09 +07:00
a8ccda259c chore: update deps 2025-08-20 11:50:56 +07:00
reya
23ad28e96e fix cpu spike (#127) 2025-08-19 14:31:13 +07:00
07a2f6980e chore: update deps 2025-08-18 14:24:13 +07:00
reya
c2b276f3f3 chore: improve chat panel (#121)
* .

* .

* .

* skip sent message

* improve sent reports

* .

* .

* .
2025-08-18 13:20:29 +07:00
5bef1a2c6c chore: fix flatpak 2025-08-10 14:58:59 +07:00
cd26244538 chore: bump version 2025-08-10 12:50:52 +07:00
reya
ca622d1262 chore: improve logout behavior (#118)
* resubscribe on logout

* .

* .
2025-08-10 10:43:28 +07:00
reya
5011becacb chore: use a newer flatpak runtime (#117)
* use newer flatpak runtime

* .
2025-08-10 07:40:13 +07:00
reya
17f92d767e chore: improve ui consistency (#115)
* .

* .
2025-08-09 14:58:01 +07:00
be660cb14b chore: update deps 2025-08-08 13:14:11 +07:00
reya
8fca202c05 chore: refactor subscription (#113)
* fix duplicate set signer request

* refactor

* .
2025-08-07 20:53:21 +07:00
7b20131e3b chore: bump version 2025-08-06 10:11:04 +07:00
reya
9127696517 chore: fix missing message during initial fetch (#110)
* remove fetched flag

* .

* improve
2025-08-06 09:15:00 +07:00
reya
af74a4ed23 . (#109) 2025-08-06 07:10:00 +07:00
reya
bd2b72a57a chore: fix duplicate messages (#108)
* prevent duplicate message on load

* refactor
2025-08-05 21:15:10 +07:00
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
215 changed files with 23362 additions and 14183 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]
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

2922
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,59 @@
[workspace]
resolver = "2"
members = ["crates/*"]
default-members = ["crates/coop"]
[workspace.package]
version = "0.1.5"
edition = "2021"
publish = false
[workspace.dependencies]
coop = { path = "crates/*" }
# UI
gpui = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["parser"] }
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-keyring = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"lmdb",
"nip96",
"nip59",
"nip49",
"nip44",
"nip05",
] }
# Others
emojis = "0.6.4"
smol = "2"
oneshot = "0.1.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dirs = "5.0"
itertools = "0.13.0"
futures = "0.3.30"
chrono = "0.4.38"
tracing = "0.1.40"
anyhow = "1.0.44"
smallvec = "1.14.0"
rust-embed = "8.5.0"
log = "0.4"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
[workspace]
resolver = "2"
members = ["crates/*"]
default-members = ["crates/coop"]
[workspace.package]
version = "0.2.3"
edition = "2021"
publish = false
[workspace.metadata.i18n]
available-locales = ["en"]
default-locale = "en"
load-path = "locales"
[workspace.dependencies]
i18n = { path = "crates/i18n" }
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"lmdb",
"nip96",
"nip59",
"nip49",
"nip44",
] }
# Others
anyhow = "1.0.44"
chrono = "0.4.38"
dirs = "5.0"
emojis = "0.6.4"
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
oneshot = "0.1.10"
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
rust-embed = "8.5.0"
rust-i18n = "3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smallvec = "1.14.0"
smol = "2"
tracing = "0.1.40"
webbrowser = "1.0.4"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

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 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.25 21.25H6c-.69 0-1.25-.56-1.25-1.25M11.5 9.25h1M4.75 20V4.75a2 2 0 0 1 2-2h12.5v16H6c-.69 0-1.25.56-1.25 1.25Zm5-6.25s0-1.5 2.25-1.5 2.25 1.5 2.25 1.5h-4.5ZM13 9.25a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 404 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill="#000" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10a9.967 9.967 0 0 1-4.098-.876.313.313 0 0 0-.195-.026l-3.471.78a1.75 1.75 0 0 1-2.084-2.12l.809-3.33a.313.313 0 0 0-.028-.204A9.965 9.965 0 0 1 2 12Zm4.5 0a1 1 0 1 0 2 0 1 1 0 0 0-2 0Zm4.5 0a1 1 0 1 0 2 0 1 1 0 0 0-2 0Zm5.5 1a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 488 B

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

4
assets/icons/edit.svg Normal file
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="M10.75 21.25h-4a2 2 0 0 1-2-2V4.75a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v7"/>
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M13.75 21.25v-2.333l3.75-3.75a1.65 1.65 0 0 1 2.333 2.333l-3.75 3.75H13.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.75 5.75v11.5a2 2 0 0 0 2 2h14.5a2 2 0 0 0 2-2v-8.5a2 2 0 0 0-2-2h-6.18a2 2 0 0 1-1.664-.89l-.812-1.22a2 2 0 0 0-1.664-.89H4.75a2 2 0 0 0-2 2Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 350 B

3
assets/icons/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

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m7.5 3.25 4.5 3.5 4.5-3.5m-11.75 17h14.5a2 2 0 0 0 2-2v-9.5a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2v9.5a2 2 0 0 0 2 2Z"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.25 12H9m11.25 0-4.5 4.5m4.5-4.5-4.5-4.5m-4.5 12.75h-5.5a2 2 0 0 1-2-2V5.75a2 2 0 0 1 2-2h5.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M104,152a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H96A8,8,0,0,1,104,152ZM168,32h24a8,8,0,0,0,0-16H160a8,8,0,0,0-8,8V56h16Zm72,84v60a16,16,0,0,1-16,16H136v32a8,8,0,0,1-16,0V192H32a16,16,0,0,1-16-16V116A60.07,60.07,0,0,1,76,56h76v88a8,8,0,0,0,16,0V56h12A60.07,60.07,0,0,1,240,116Zm-120,0a44,44,0,0,0-88,0v60h88Z"></path></svg>

Before

Width:  |  Height:  |  Size: 429 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M12 2a7.795 7.795 0 0 0-7.696 6.554l-1.17 7.258A2.75 2.75 0 0 0 5.848 19h1.66c.849 1.75 2.512 3 4.492 3s3.643-1.25 4.492-3h1.66a2.75 2.75 0 0 0 2.714-3.188l-1.17-7.258A7.795 7.795 0 0 0 12 2Zm2.754 17H9.245c.678.937 1.68 1.5 2.754 1.5s2.076-.563 2.754-1.5Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 434 B

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

4
assets/icons/refresh.svg Normal file
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 fill="currentColor" d="M13 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8-10a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm-1.07 3.268a1 1 0 1 1-1 1.732 1 1 0 0 1 1-1.732Zm-2.562 5.026a1 1 0 1 0-1-1.732 1 1 0 0 0 1 1.732ZM18.927 8a1 1 0 1 1-1-1.732 1 1 0 0 1 1 1.732Z"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.25 14.75v5.5h-5.5M9 19.688a8.25 8.25 0 1 1 6.25-15.273"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

3
assets/icons/reply.svg Normal file
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

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

@@ -1,18 +1,18 @@
[package]
name = "auto_update"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
global = { path = "../global" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smol.workspace = true
log.workspace = true
tempfile = "3.19.1"
reqwest = { version = "0.12", features = ["stream"] }
[package]
name = "auto_update"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
global = { path = "../global" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smol.workspace = true
log.workspace = true
smallvec.workspace = true
cargo-packager-updater = "0.2.3"

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"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
ui = { path = "../ui" }
common = { path = "../common" }
global = { path = "../global" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smol.workspace = true
smallvec.workspace = true
log.workspace = true
[package]
name = "client_keys"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
global = { path = "../global" }
nostr-sdk.workspace = true
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true

View File

@@ -0,0 +1,139 @@
use global::constants::KEYRING_URL;
use global::first_run;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
pub fn init(cx: &mut App) {
ClientKeys::set_global(cx.new(ClientKeys::new), cx);
}
struct GlobalClientKeys(Entity<ClientKeys>);
impl Global for GlobalClientKeys {}
pub struct ClientKeys {
keys: Option<Keys>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl ClientKeys {
/// Retrieve the Global Client Keys instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalClientKeys>().0.clone()
}
/// Retrieve the Client Keys instance
pub fn read_global(cx: &App) -> &Self {
cx.global::<GlobalClientKeys>().0.read(cx)
}
/// Set the Global Client Keys instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalClientKeys(state));
}
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
if let Some(window) = window {
this.load(window, cx);
}
}));
Self {
keys: None,
subscriptions,
}
}
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Prevent macOS from asking for password every time
// Only for debug builds
if cfg!(debug_assertions) && cfg!(target_os = "macos") {
log::warn!("Running debug build on macOS");
log::warn!("Skipping keychain access, generating new client keys");
self.new_keys(cx);
return;
}
let read_client_keys = cx.read_credentials(KEYRING_URL);
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some((_, secret))) = read_client_keys.await {
// Update the client keys with the stored secret key from the keychain
this.update(cx, |this, cx| {
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
this.set_keys(None, false, true, cx);
return;
};
let keys = Keys::new(secret_key);
this.set_keys(Some(keys), false, true, cx);
})
.ok();
} else if *first_run() {
// If this is the first run, generate new keys and use them for the client keys
this.update(cx, |this, cx| {
this.new_keys(cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_keys(None, false, true, cx);
})
.ok();
}
})
.detach();
}
pub(crate) fn set_keys(
&mut self,
keys: Option<Keys>,
persist: bool,
notify: bool,
cx: &mut Context<Self>,
) {
if persist {
if let Some(keys) = keys.as_ref() {
let username = keys.public_key().to_hex();
let password = keys.secret_key().secret_bytes();
let write_keys = cx.write_credentials(KEYRING_URL, &username, &password);
cx.background_spawn(async move {
if let Err(e) = write_keys.await {
log::error!("Failed to save the client keys: {e}")
}
})
.detach();
}
}
self.keys = keys;
// Notify GPUI to reload UI
if notify {
cx.notify();
}
}
pub fn new_keys(&mut self, cx: &mut Context<Self>) {
self.set_keys(Some(Keys::generate()), true, true, cx);
}
pub fn force_new_keys(&mut self, cx: &mut Context<Self>) {
self.set_keys(Some(Keys::generate()), true, false, cx);
}
pub fn keys(&self) -> Keys {
self.keys
.clone()
.expect("Keys should always be initialized")
}
pub fn has_keys(&self) -> bool {
self.keys.is_some()
}
}

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
webbrowser.workspace = true
random_name_generator = "0.3.6"
qrcode-generator = "5.0.0"
qrcode = "0.14.1"

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,145 @@
use std::sync::Arc;
use anyhow::{anyhow, Error};
use chrono::{Local, TimeZone};
use global::constants::IMAGE_RESIZE_SERVICE;
use gpui::{Image, ImageFormat};
use nostr_sdk::prelude::*;
use qrcode::render::svg;
use qrcode::QrCode;
const NOW: &str = "now";
const SECONDS_IN_MINUTE: i64 = 60;
const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30;
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
pub trait ReadableProfile {
fn avatar_url(&self, proxy: bool) -> String;
fn display_name(&self) -> String;
}
impl ReadableProfile for Profile {
fn avatar_url(&self, proxy: bool) -> String {
self.metadata()
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
if proxy {
format!(
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
)
} else {
picture.into()
}
})
.unwrap_or_else(|| "brand/avatar.png".into())
}
fn display_name(&self) -> String {
if let Some(display_name) = self.metadata().display_name.as_ref() {
if !display_name.is_empty() {
return display_name.into();
}
}
if let Some(name) = self.metadata().name.as_ref() {
if !name.is_empty() {
return name.into();
}
}
shorten_pubkey(self.public_key(), 4)
}
}
pub trait ReadableTimestamp {
fn to_human_time(&self) -> String;
fn to_ago(&self) -> String;
}
impl ReadableTimestamp for Timestamp {
fn to_human_time(&self) -> String {
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return "9999".into(),
};
let now = Local::now();
let input_date = input_time.date_naive();
let now_date = now.date_naive();
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
let time_format = input_time.format("%H:%M %p");
match input_date {
date if date == now_date => format!("Today at {time_format}"),
date if date == yesterday_date => format!("Yesterday at {time_format}"),
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
}
}
fn to_ago(&self) -> String {
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return "1m".into(),
};
let now = Local::now();
let duration = now.signed_duration_since(input_time);
match duration {
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
_ => input_time.format("%b %d").to_string(),
}
}
}
pub trait TextUtils {
fn to_public_key(&self) -> Result<PublicKey, Error>;
fn to_qr(&self) -> Option<Arc<Image>>;
}
impl<T: AsRef<str>> TextUtils for T {
fn to_public_key(&self) -> Result<PublicKey, Error> {
let s = self.as_ref();
if s.starts_with("nprofile1") {
Ok(Nip19Profile::from_bech32(s)?.public_key)
} else if s.starts_with("npub1") {
Ok(PublicKey::parse(s)?)
} else {
Err(anyhow!("Invalid public key"))
}
}
fn to_qr(&self) -> Option<Arc<Image>> {
let s = self.as_ref();
let code = QrCode::new(s).unwrap();
let svg = code
.render()
.min_dimensions(256, 256)
.dark_color(svg::Color("#000000"))
.light_color(svg::Color("#FFFFFF"))
.build();
Some(Arc::new(Image::from_bytes(
ImageFormat::Svg,
svg.into_bytes(),
)))
}
}
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let Ok(pubkey) = public_key.to_bech32();
format!(
"{}:{}",
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)
}

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

@@ -1,38 +1,64 @@
[package]
name = "coop"
version.workspace = true
edition.workspace = true
publish.workspace = true
[[bin]]
name = "coop"
path = "src/main.rs"
[dependencies]
ui = { path = "../ui" }
common = { path = "../common" }
global = { path = "../global" }
chats = { path = "../chats" }
account = { path = "../account" }
auto_update = { path = "../auto_update" }
gpui.workspace = true
reqwest_client.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
dirs.workspace = true
rust-embed.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
oneshot.workspace = true
rustls = "0.23.23"
futures = "0.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
[package]
name = "coop"
version.workspace = true
edition.workspace = true
publish.workspace = true
[[bin]]
name = "coop"
path = "src/main.rs"
[package.metadata.packager]
name = "Coop"
product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "0.2.3"
out-dir = "../../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
icons = [
"resources/32x32.png",
"resources/128x128.png",
"resources/128x128@2x.png",
"resources/icon.icns",
"resources/icon.ico",
]
[dependencies]
assets = { path = "../assets" }
ui = { path = "../ui" }
title_bar = { path = "../title_bar" }
theme = { path = "../theme" }
common = { path = "../common" }
global = { path = "../global" }
registry = { path = "../registry" }
settings = { path = "../settings" }
client_keys = { path = "../client_keys" }
auto_update = { path = "../auto_update" }
signer_proxy = { path = "../signer_proxy" }
rust-i18n.workspace = true
i18n.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
reqwest_client.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
nostr.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
dirs.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
webbrowser.workspace = true
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": "24.08",
"sdk": "org.freedesktop.Sdk",
"sdk-extensions": ["org.freedesktop.Sdk.Extension.rust-stable"],
"command": "coop",
"finish-args": [
"--talk-name=org.freedesktop.Flatpak",
"--device=dri",
"--share=ipc",
"--share=network",
"--socket=wayland",
"--socket=fallback-x11",
"--socket=pulseaudio",
"--filesystem=host"
],
"build-options": {
"append-path": "/usr/lib/sdk/rust-stable/bin"
},
"modules": [
{
"name": "coop",
"buildsystem": "simple",
"build-options": {
"env": {
"APP_ID": "$APP_ID",
"APP_ICON": "$APP_ID",
"APP_NAME": "$APP_NAME",
"BRANDING_LIGHT": "$BRANDING_LIGHT",
"BRANDING_DARK": "$BRANDING_DARK",
"APP_CLI": "coop",
"APP_ARGS": "--foreground %U",
"DO_STARTUP_NOTIFY": "false"
}
},
"build-commands": [
"install -Dm644 $ICON_FILE.png /app/share/icons/hicolor/512x512/apps/$APP_ID.png",
"envsubst < coop.desktop.in > coop.desktop && install -Dm644 coop.desktop /app/share/applications/$APP_ID.desktop",
"envsubst < flatpak/coop.metainfo.xml.in > coop.metainfo.xml && install -Dm644 coop.metainfo.xml /app/share/metainfo/$APP_ID.metainfo.xml",
"sed -i -e '/@release_info@/{r flatpak/release-info/$CHANNEL' -e 'd}' /app/share/metainfo/$APP_ID.metainfo.xml",
"install -Dm755 bin/coop /app/bin/coop",
"install -Dm755 libexec/coop /app/libexec/coop",
"install -Dm755 lib/* -t /app/lib"
],
"sources": [
{
"type": "archive",
"path": "./target/release/$ARCHIVE"
},
{
"type": "dir",
"path": "./crates/coop/resources"
}
]
}
]
}

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

@@ -0,0 +1,34 @@
use std::sync::Mutex;
use gpui::{actions, App};
actions!(coop, [DarkMode, Settings, Logout, Quit]);
pub fn load_embedded_fonts(cx: &App) {
let asset_source = cx.asset_source();
let font_paths = asset_source.list("fonts").unwrap();
let embedded_fonts = Mutex::new(Vec::new());
let executor = cx.background_executor();
executor.block(executor.scoped(|scope| {
for font_path in &font_paths {
if !font_path.ends_with(".ttf") {
continue;
}
scope.spawn(async {
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
embedded_fonts.lock().unwrap().push(font_bytes);
});
}
}));
cx.text_system()
.add_fonts(embedded_fonts.into_inner().unwrap())
.unwrap();
}
pub fn quit(_: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}

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

File diff suppressed because it is too large Load Diff

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,82 @@
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::sync::Arc;
pub(crate) mod asset;
use assets::Assets;
use global::constants::{APP_ID, APP_NAME};
use global::{ingester, nostr_client, sent_ids, starting_time};
use gpui::{
point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString,
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
WindowOptions,
};
use theme::Theme;
use ui::Root;
use crate::actions::{load_embedded_fonts, quit, Quit};
pub(crate) mod actions;
pub(crate) mod chatspace;
pub(crate) mod lru_cache;
pub(crate) mod views;
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),
}
i18n::init!();
fn main() {
// Enable logging
// Initialize logging
tracing_subscriber::fmt::init();
// Fix crash on startup
_ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(2048);
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(500);
// Initialize the Nostr client
let _client = nostr_client();
// Initialize nostr client
let client = get_client();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
// Initialize the ingester
let _ingester = ingester();
// Initialize application
// Initialize the starting time
let _starting_time = starting_time();
// Initialize the sent IDs storage
let _sent_ids = sent_ids();
// Initialize the Application
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
// Connect to default relays
app.background_executor()
.spawn(async move {
for relay in BOOTSTRAP_RELAYS.into_iter() {
if let Err(e) = client.add_relay(relay).await {
log::error!("Failed to add relay {}: {}", relay, e);
}
}
// Establish connection to bootstrap relays
client.connect().await;
log::info!("Connected to bootstrap relays");
log::info!("Subscribing to app updates...");
let coordinate = Coordinate {
kind: Kind::Custom(32267),
public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"),
identifier: APP_ID.into(),
};
let filter = Filter::new()
.kind(Kind::ReleaseArtifactSet)
.coordinate(&coordinate)
.limit(1);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to subscribe for app updates: {}", e);
}
})
.detach();
// Handle batch metadata
app.background_executor()
.spawn(async move {
const BATCH_SIZE: usize = 500;
const BATCH_TIMEOUT: Duration = Duration::from_millis(300);
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
let mut timeout = Box::pin(Timer::after(BATCH_TIMEOUT).fuse());
select! {
pubkeys = batch_rx.recv().fuse() => {
match pubkeys {
Ok(keys) => {
batch.extend(keys);
if batch.len() >= BATCH_SIZE {
sync_metadata(mem::take(&mut batch), client, opts).await;
}
}
Err(_) => break,
}
}
_ = timeout => {
if !batch.is_empty() {
sync_metadata(mem::take(&mut batch), client, opts).await;
}
}
}
}
})
.detach();
// Handle notifications
app.background_executor()
.spawn(async move {
let rng_keys = Keys::generate();
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let mut notifications = client.notifications();
while let Ok(notification) = notifications.recv().await {
if let RelayPoolNotification::Message { message, .. } = notification {
match message {
RelayMessage::Event {
event,
subscription_id,
} => {
match event.kind {
Kind::GiftWrap => {
let event = match get_unwrapped(event.id).await {
Ok(event) => event,
Err(_) => match client.unwrap_gift_wrap(&event).await {
Ok(unwrap) => {
match unwrap.rumor.sign_with_keys(&rng_keys) {
Ok(ev) => {
set_unwrapped(event.id, &ev, &rng_keys)
.await
.ok();
ev
}
Err(_) => continue,
}
}
Err(_) => continue,
},
};
let mut pubkeys = vec![];
pubkeys.extend(event.tags.public_keys());
pubkeys.push(event.pubkey);
// Send all pubkeys to the batch to sync metadata
batch_tx.send(pubkeys).await.ok();
// Save the event to the database, use for query directly.
client.database().save_event(&event).await.ok();
// Send this event to the GPUI
if new_id == *subscription_id {
event_tx.send(Signal::Event(event)).await.ok();
}
}
Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).ok();
event_tx
.send(Signal::Metadata(Box::new((event.pubkey, metadata))))
.await
.ok();
}
Kind::ContactList => {
if let Ok(signer) = client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
if public_key == event.pubkey {
let pubkeys = event
.tags
.public_keys()
.copied()
.collect::<Vec<_>>();
batch_tx.send(pubkeys).await.ok();
}
}
}
}
Kind::ReleaseArtifactSet => {
let filter = Filter::new()
.ids(event.tags.event_ids().copied())
.kind(Kind::FileMetadata);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to subscribe for file metadata: {}", e);
} else {
event_tx
.send(Signal::AppUpdates(event.into_owned()))
.await
.ok();
}
}
_ => {}
}
}
RelayMessage::EndOfStoredEvents(subscription_id) => {
if all_id == *subscription_id {
event_tx.send(Signal::Eose).await.ok();
}
}
_ => {}
}
}
}
})
.detach();
// Run application
app.run(move |cx| {
// Bring the app to the foreground
cx.activate(true);
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
// Register the `quit` function
cx.on_action(quit);
// Register the `quit` function with CMD+Q
// Register the `quit` function with CMD+Q (macOS)
#[cfg(target_os = "macos")]
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Register the `quit` function with Super+Q (others)
#[cfg(not(target_os = "macos"))]
cx.bind_keys([KeyBinding::new("super-q", Quit, None)]);
// Set menu items
cx.set_menus(vec![Menu {
name: "Coop".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
// Set up the window bounds
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
// Set up the window options
let opts = WindowOptions {
#[cfg(not(target_os = "linux"))]
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
window_bounds: Some(WindowBounds::Windowed(bounds)),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
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,116 +91,28 @@ fn main() {
// Root Entity
cx.new(|cx| {
cx.activate(true);
// Initialize the tokio runtime
gpui_tokio::init(cx);
// Initialize components
ui::init(cx);
// Initialize client keys
client_keys::init(cx);
// Initialize app registry
registry::init(cx);
// Initialize settings
settings::init(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 {
cx.update(|window, cx| {
let chats = ChatRegistry::global(cx);
let auto_updater = AutoUpdater::global(cx);
match signal {
Signal::Event(event) => {
chats.update(cx, |this, cx| {
this.push_message(event, window, cx)
});
}
Signal::Metadata(data) => {
chats.update(cx, |this, cx| {
this.add_profile(data.0, data.1, cx)
});
}
Signal::Eose => {
chats.update(cx, |this, cx| {
// This function maybe called multiple times
// TODO: only handle the last EOSE signal
this.load_rooms(window, cx)
});
}
Signal::AppUpdates(event) => {
// TODO: add settings for auto updates
auto_updater.update(cx, |this, cx| {
this.update(event, cx);
})
}
};
})
.ok();
}
})
.detach();
Root::new(chatspace::init(window, cx).into(), window, cx)
})
})
.expect("Failed to open window. Please restart the application.");
});
}
async fn set_unwrapped(root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> {
let client = get_client();
let event = EventBuilder::new(Kind::Custom(9001), event.as_json())
.tags(vec![Tag::event(root)])
.sign(keys)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
async fn get_unwrapped(gift_wrap: EventId) -> Result<Event, Error> {
let client = get_client();
let filter = Filter::new()
.kind(Kind::Custom(9001))
.event(gift_wrap)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let parsed = Event::from_json(event.content)?;
Ok(parsed)
} else {
Err(anyhow!("Event not found"))
}
}
async fn sync_metadata(
buffer: HashSet<PublicKey>,
client: &Client,
opts: SubscribeAutoCloseOptions,
) {
let kinds = vec![
Kind::Metadata,
Kind::ContactList,
Kind::InboxRelays,
Kind::UserStatus,
];
let filter = Filter::new()
.authors(buffer.iter().cloned())
.limit(buffer.len() * kinds.len())
.kinds(kinds);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to sync metadata: {e}");
}
}
fn quit(_: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}

View File

@@ -0,0 +1,395 @@
use std::time::Duration;
use anyhow::Error;
use client_keys::ClientKeys;
use common::display::ReadableProfile;
use common::handle_auth::CoopAuthUrlHandler;
use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
use global::{ingester, nostr_client, IngesterSignal};
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Task, WeakEntity, Window,
};
use i18n::{shared_t, t};
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::input::{InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::{h_flex, v_flex, ContextModal, Disableable, Sizable, StyledExt};
use crate::chatspace::ChatSpace;
pub fn init(
secret: String,
profile: Profile,
window: &mut Window,
cx: &mut App,
) -> Entity<Account> {
Account::new(secret, profile, window, cx)
}
pub struct Account {
profile: Profile,
stored_secret: String,
is_bunker: bool,
is_extension: bool,
loading: bool,
// Panel
name: SharedString,
focus_handle: FocusHandle,
}
impl Account {
fn new(secret: String, profile: Profile, _window: &mut Window, cx: &mut App) -> Entity<Self> {
let is_bunker = secret.starts_with("bunker://");
let is_extension = secret.starts_with("extension");
cx.new(|cx| Self {
profile,
is_bunker,
is_extension,
stored_secret: secret,
loading: false,
name: "Account".into(),
focus_handle: cx.focus_handle(),
})
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_loading(true, cx);
if self.is_bunker {
if let Ok(uri) = NostrConnectURI::parse(&self.stored_secret) {
self.nostr_connect(uri, window, cx);
}
} else if self.is_extension {
self.set_proxy(window, cx);
} else if let Ok(enc) = EncryptedSecretKey::from_bech32(&self.stored_secret) {
self.keys(enc, window, cx);
} else {
window.push_notification("Cannot continue with current account", cx);
self.set_loading(false, cx);
}
}
fn nostr_connect(&mut self, uri: NostrConnectURI, window: &mut Window, cx: &mut Context<Self>) {
let client_keys = ClientKeys::global(cx);
let app_keys = client_keys.read(cx).keys();
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, app_keys, timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Handle connection
cx.spawn_in(window, async move |_this, cx| {
let client = nostr_client();
match signer.bunker_uri().await {
Ok(_) => {
// Set the client's signer with the current nostr connect instance
client.set_signer(signer).await;
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
}
})
.detach();
}
fn set_proxy(&mut self, window: &mut Window, cx: &mut Context<Self>) {
ChatSpace::proxy_signer(window, cx);
}
fn keys(&mut self, enc: EncryptedSecretKey, window: &mut Window, cx: &mut Context<Self>) {
let pwd_input: Entity<InputState> = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_input = pwd_input.downgrade();
let error: Entity<Option<SharedString>> = cx.new(|_| None);
let weak_error = error.downgrade();
let entity = cx.weak_entity();
window.open_modal(cx, move |this, _window, cx| {
let entity = entity.clone();
let entity_clone = entity.clone();
let weak_input = weak_input.clone();
let weak_error = weak_error.clone();
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, _window, cx| {
entity
.update(cx, |this, cx| {
this.set_loading(false, cx);
})
.ok();
// true to close the modal
true
})
.on_ok(move |_, window, cx| {
let weak_error = weak_error.clone();
let password = weak_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
entity_clone
.update(cx, |this, cx| {
this.verify_keys(enc, password, weak_error, window, cx);
})
.ok();
// false to keep the modal open
false
})
.child(
div()
.w_full()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child(shared_t!("login.password_to_decrypt"))
.child(TextInput::new(&pwd_input).small())
.when_some(error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
)
});
}
fn verify_keys(
&mut self,
enc: EncryptedSecretKey,
password: Option<SharedString>,
error: WeakEntity<Option<SharedString>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(password) = password else {
error
.update(cx, |this, cx| {
*this = Some("Password is required".into());
cx.notify();
})
.ok();
return;
};
if password.is_empty() {
error
.update(cx, |this, cx| {
*this = Some("Password cannot be empty".into());
cx.notify();
})
.ok();
return;
}
let task: Task<Result<SecretKey, Error>> = cx.background_spawn(async move {
let secret = enc.decrypt(&password)?;
Ok(secret)
});
cx.spawn_in(window, async move |_this, cx| {
match task.await {
Ok(secret) => {
cx.update(|window, cx| {
window.close_all_modals(cx);
})
.ok();
let client = nostr_client();
let keys = Keys::new(secret);
// Set the client's signer with the current keys
client.set_signer(keys).await
}
Err(e) => {
error
.update(cx, |this, cx| {
*this = Some(e.to_string().into());
cx.notify();
})
.ok();
}
};
})
.detach();
}
fn logout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
cx.background_spawn(async move {
let client = nostr_client();
let ingester = ingester();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ACCOUNT_IDENTIFIER);
// Delete account
client.database().delete(filter).await.ok();
// Unset the client's signer
client.unset_signer().await;
// Notify the channel about the signer being unset
ingester.send(IngesterSignal::SignerUnset).await;
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
}
impl Panel for Account {
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 Account {}
impl Focusable for Account {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Account {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.relative()
.size_full()
.gap_10()
.items_center()
.justify_center()
.child(
v_flex()
.items_center()
.justify_center()
.gap_4()
.child(
svg()
.path("brand/coop.svg")
.size_16()
.text_color(cx.theme().elevated_surface_background),
)
.child(
div()
.text_center()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(shared_t!("welcome.title")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("welcome.subtitle")),
),
),
)
.child(
v_flex()
.gap_2()
.child(
div()
.id("account")
.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(shared_t!("onboarding.choose_account"))
.child(
h_flex()
.gap_1()
.child(
Avatar::new(self.profile.avatar_url(true))
.size(rems(1.5)),
)
.child(
div()
.pb_px()
.font_semibold()
.child(self.profile.display_name()),
),
),
)
}
})
.hover(|this| this.bg(cx.theme().element_hover))
.on_click(cx.listener(move |this, _e, window, cx| {
this.login(window, cx);
})),
)
.child(
Button::new("logout")
.label(t!("user.sign_out"))
.ghost()
.disabled(self.loading)
.on_click(cx.listener(move |this, _e, window, cx| {
this.logout(window, cx);
})),
),
)
}
}

View File

@@ -0,0 +1,210 @@
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, Task, Window,
};
use i18n::{shared_t, t};
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable};
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,
}
}
pub fn password(&self, cx: &Context<Self>) -> String {
self.password.read(cx).value().to_string()
}
pub fn backup(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Task<()>> {
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 None;
};
let path = cx.prompt_for_new_path(&document_dir, Some("My Nostr Account"));
let nsec = self.secret_input.read(cx).value().to_string();
Some(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| {
if let Err(e) = fs::write(&path, nsec) {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
}
})
.ok();
}
_ => {
log::error!("Failed to save backup keys");
}
};
}))
}
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();
}
}
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()),
),
),
)
}
}

File diff suppressed because it is too large Load Diff

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) -> String {
self.input.read(cx).value().to_string()
}
}
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::{ReadableProfile, 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,11 +106,13 @@ 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,
directories: false,
multiple: false,
prompt: None,
});
// Show loading spinner
@@ -137,9 +126,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 +143,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 +167,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 +189,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 +256,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 +265,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 +274,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,418 +1,531 @@
use std::{sync::Arc, time::Duration};
use account::Account;
use common::create_qr;
use global::get_client_keys;
use gpui::{
div, img, prelude::FluentBuilder, relative, AnyElement, App, AppContext, Context, Entity,
EventEmitter, FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Window,
};
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.";
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>>,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 3]>,
}
impl Login {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let 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);
}
},
));
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.spawn(async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(500))
.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();
});
})
})
.ok();
})
.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(),
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_logging_in {
return;
};
self.set_logging_in(true, cx);
let content = self.key_input.read(cx).text();
let account = Account::global(cx);
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);
};
}
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())
else {
window.push_notification(Notification::error("Relay URL is not valid."), cx);
return;
};
let client_pubkey = get_client_keys().public_key();
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
self.connect_client.update(cx, |this, cx| {
*this = Some(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));
cx.notify();
});
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_logging_in = status;
cx.notify();
}
}
impl Panel for Login {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
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 Login {}
impl Focusable for Login {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Login {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.flex()
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.child(
div()
.w_80()
.flex()
.flex_col()
.gap_8()
.child(
div()
.text_center()
.child(
div()
.text_center()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child("Welcome Back!"),
)
.child(
div()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Continue with Private Key or Bunker"),
),
)
.child(
div()
.flex()
.flex_col()
.gap_3()
.child(self.key_input.clone())
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.is_logging_in)
.disabled(self.is_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),
)
},
),
),
),
)
.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()
.mb_2()
.p_2()
.size_64()
.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);
})),
),
),
),
)
}
}
use std::time::Duration;
use client_keys::ClientKeys;
use common::handle_auth::CoopAuthUrlHandler;
use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use i18n::{shared_t, t};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::{v_flex, ContextModal, Disableable, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx)
}
pub struct Login {
input: Entity<InputState>,
error: Entity<Option<SharedString>>,
countdown: Entity<Option<u64>>,
logging_in: bool,
// Panel
name: SharedString,
focus_handle: FocusHandle,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Login {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://..."));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
// Subscribe to key input events and process login when the user presses enter
subscriptions.push(
cx.subscribe_in(&input, window, |this, _e, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
}
}),
);
Self {
input,
error,
countdown,
subscriptions,
name: "Login".into(),
focus_handle: cx.focus_handle(),
logging_in: false,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
// Disable the input
self.input.update(cx, |this, cx| {
this.set_loading(true, cx);
this.set_disabled(true, cx);
});
// Content can be secret key or bunker://
match self.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;
};
if password.is_empty() {
self.set_error(t!("login.password_is_required"), window, cx);
return;
}
// Skip verification if key is ncryptsec
if is_ncryptsec {
self.login_with_keys(password.to_string(), window, cx);
return;
}
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.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);
// Encrypt and save user secret key to disk
self.write_keys_to_disk(&keys, password, cx);
// Set the client's signer with the current keys
cx.background_spawn(async move {
let client = nostr_client();
client.set_signer(keys).await;
})
.detach();
} 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::global(cx);
let app_keys = client_keys.read(cx).keys();
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
let mut signer = NostrConnect::new(uri, app_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..=BUNKER_TIMEOUT).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
cx.spawn_in(window, async move |this, cx| {
let client = nostr_client();
match signer.bunker_uri().await {
Ok(uri) => {
this.update(cx, |this, cx| {
this.write_uri_to_disk(&uri, cx);
})
.ok();
// Set the client's signer with the current nostr connect instance
client.set_signer(signer).await;
}
Err(error) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_error(error.to_string(), window, cx);
// Force reset the client keys
//
// This step is necessary to ensure that user can retry the connection
client_keys.update(cx, |this, cx| {
this.force_new_keys(cx);
});
})
.ok();
})
.ok();
}
}
})
.detach();
}
fn write_uri_to_disk(&mut self, uri: &NostrConnectURI, cx: &mut Context<Self>) {
let Some(public_key) = uri.remote_signer_public_key().cloned() else {
log::error!("Remote Signer's public key not found");
return;
};
let mut value = uri.to_string();
// Clear the secret param if it exists
if let Some(secret) = uri.secret() {
value = value.replace(secret, "");
}
cx.background_spawn(async move {
let client = nostr_client();
let keys = Keys::generate();
let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
let kind = Kind::ApplicationSpecificData;
let builder = EventBuilder::new(kind, value)
.tags(tags)
.build(public_key)
.sign(&keys)
.await;
if let Ok(event) = builder {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
}
})
.detach();
}
pub fn write_keys_to_disk(&self, keys: &Keys, password: String, cx: &mut Context<Self>) {
let keys = keys.to_owned();
let public_key = keys.public_key();
cx.background_spawn(async move {
if let Ok(enc_key) =
EncryptedSecretKey::new(keys.secret_key(), &password, 8, KeySecurity::Unknown)
{
let client = nostr_client();
let value = enc_key.to_bech32().unwrap();
let keys = Keys::generate();
let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
let kind = Kind::ApplicationSpecificData;
let builder = EventBuilder::new(kind, value)
.tags(tags)
.build(public_key)
.sign(&keys)
.await;
if let Ok(event) = builder {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
}
}
})
.detach();
}
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.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.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for Login {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
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 Login {}
impl Focusable for Login {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Login {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.relative()
.size_full()
.items_center()
.justify_center()
.child(
v_flex()
.w_96()
.gap_10()
.child(
div()
.text_center()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(shared_t!("login.title")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.key_description")),
),
)
.child(
v_flex()
.gap_3()
.child(TextInput::new(&self.input))
.child(
Button::new("login")
.label(t!("common.continue"))
.primary()
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.approve_message", i = i)),
)
})
.when_some(self.error.read(cx).clone(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_foreground)
.child(error),
)
}),
),
)
}
}

View File

@@ -1,11 +1,14 @@
pub mod account;
pub mod backup_keys;
pub mod chat;
pub mod compose;
pub mod contacts;
pub mod edit_profile;
pub mod login;
pub mod new_account;
pub mod onboarding;
pub mod profile;
pub mod relays;
pub mod preferences;
pub mod screening;
pub mod setup_relay;
pub mod sidebar;
pub mod subject;
pub mod user_profile;
pub mod welcome;

View File

@@ -1,38 +1,40 @@
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::constants::ACCOUNT_IDENTIFIER;
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::{shared_t, t};
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::modal::ModalButtonProps;
use ui::popup_menu::PopupMenu;
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
use crate::views::backup_keys::BackupKeys;
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>,
is_uploading: bool,
is_submitting: bool,
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
temp_keys: Entity<Keys>,
uploading: bool,
submitting: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
@@ -42,132 +44,197 @@ 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")
});
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 temp_keys = cx.new(|_| Keys::generate());
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let avatar_input = cx.new(|cx| InputState::new(window, cx));
Self {
name_input,
avatar_input,
bio_input,
is_uploading: false,
is_submitting: false,
temp_keys,
uploading: false,
submitting: false,
name: "New Account".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_submitting(true, cx);
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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 keys = self.temp_keys.read(cx).clone();
let view = cx.new(|cx| BackupKeys::new(&keys, window, cx));
let weak_view = view.downgrade();
let current_view = cx.entity().downgrade();
let mut metadata = Metadata::new().display_name(name).about(bio);
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
let current_view = current_view.clone();
modal
.alert()
.title(shared_t!("new_account.backup_label"))
.child(view.clone())
.button_props(
ModalButtonProps::default().ok_text(t!("new_account.backup_download")),
)
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
let password = this.password(cx);
let current_view = current_view.clone();
if let Ok(url) = Url::from_str(&avatar) {
if let Some(task) = this.backup(window, cx) {
cx.spawn_in(window, async move |_, cx| {
task.await;
cx.update(|window, cx| {
current_view
.update(cx, |this, cx| {
this.set_signer(password, window, cx);
})
.ok();
})
.ok()
})
.detach();
}
})
.ok();
// true to close the modal
false
})
})
}
fn set_signer(&mut self, password: String, window: &mut Window, cx: &mut Context<Self>) {
window.close_modal(cx);
let keys = self.temp_keys.read(cx).clone();
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
if let Ok(url) = Url::parse(&avatar) {
metadata = metadata.picture(url);
};
Account::global(cx).update(cx, |this, cx| {
this.new_account(metadata, window, cx);
});
// Encrypt and save user secret key to disk
self.write_keys_to_disk(&keys, password, cx);
// Set the client's signer with the current keys
cx.background_spawn(async move {
let client = nostr_client();
client.set_signer(keys).await;
client.set_metadata(&metadata).await.ok();
})
.detach();
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
fn write_keys_to_disk(&self, keys: &Keys, password: String, cx: &mut Context<Self>) {
let keys = keys.to_owned();
let public_key = keys.public_key();
self.set_uploading(true, cx);
cx.background_spawn(async move {
if let Ok(enc_key) =
EncryptedSecretKey::new(keys.secret_key(), &password, 8, KeySecurity::Unknown)
{
let client = nostr_client();
let value = enc_key.to_bech32().unwrap();
let keys = Keys::generate();
let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
let kind = Kind::ApplicationSpecificData;
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 {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
.ok();
})
.ok();
let builder = EventBuilder::new(kind, value)
.tags(tags)
.build(public_key)
.sign(&keys)
.await;
return;
if let Ok(event) = builder {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
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(None) => {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
})
.ok();
}
Err(_) => {}
}
})
.detach();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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,
prompt: None,
});
let task = Tokio::spawn(cx, async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(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)) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.uploading(false, cx);
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
})
.ok();
})
.ok();
}
Ok(Err(e)) => {
Self::notify_error(cx, this, e.to_string());
}
Err(e) => {
Self::notify_error(cx, this, e.to_string());
}
}
})
.detach();
}
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.submitting = status;
cx.notify();
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
}
}
@@ -181,14 +248,6 @@ impl Panel for NewAccount {
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)
}
@@ -207,99 +266,76 @@ 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(shared_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(shared_t!("new_account.name"))
.child(TextInput::new(&self.name_input).small()),
)
.child(
v_flex()
.gap_1()
.child(div().text_sm().child(shared_t!("new_account.avatar")))
.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);
})),
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.submitting || self.uploading)
.loading(self.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(t!("common.continue"))
.primary()
.loading(self.is_submitting)
.disabled(self.is_submitting || self.is_uploading)
.loading(self.submitting)
.disabled(self.submitting || self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
this.create(window, cx);
})),
),
)

View File

@@ -1,140 +1,437 @@
use gpui::{
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Icon, IconName, StyledExt,
};
use crate::chatspace;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx)
}
pub struct Onboarding {
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
name: "Onboarding".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for Onboarding {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
}
impl EventEmitter<PanelEvent> for Onboarding {}
impl Focusable for Onboarding {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const TITLE: &str = "Welcome to Coop!";
const SUBTITLE: &str = "Secure Communication on Nostr.";
div()
.py_4()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_10()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_4()
.child(
svg()
.path("brand/coop.svg")
.size_16()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
div()
.text_center()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(TITLE),
)
.child(
div()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(SUBTITLE),
),
),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_2()
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label("Start Messaging")
.primary()
.reverse()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::new_account(window, cx);
})),
)
.child(
Button::new("login_btn")
.label("Already have an account? Log in.")
.ghost()
.underline()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::login(window, cx);
})),
),
)
}
}
use std::sync::Arc;
use std::time::Duration;
use client_keys::ClientKeys;
use common::display::TextUtils;
use global::constants::{ACCOUNT_IDENTIFIER, APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, relative, svg, 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 nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::popup_menu::PopupMenu;
use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
use crate::chatspace::{self, ChatSpace};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx)
}
#[derive(Debug, Clone)]
pub enum NostrConnectApp {
Nsec(String),
Amber(String),
Aegis(String),
}
impl NostrConnectApp {
pub fn all() -> Vec<Self> {
vec![
NostrConnectApp::Nsec("https://nsec.app".to_string()),
NostrConnectApp::Amber("https://github.com/greenart7c3/Amber".to_string()),
NostrConnectApp::Aegis("https://github.com/ZharlieW/Aegis".to_string()),
]
}
pub fn url(&self) -> &str {
match self {
Self::Nsec(url) | Self::Amber(url) | Self::Aegis(url) => url,
}
}
pub fn as_str(&self) -> String {
match self {
NostrConnectApp::Nsec(_) => "nsec.app (Desktop)".into(),
NostrConnectApp::Amber(_) => "Amber (Android)".into(),
NostrConnectApp::Aegis(_) => "Aegis (iOS)".into(),
}
}
}
pub struct Onboarding {
nostr_connect_uri: Entity<NostrConnectURI>,
nostr_connect: Entity<Option<NostrConnect>>,
qr_code: Entity<Option<Arc<Image>>>,
connecting: bool,
// Panel
name: SharedString,
focus_handle: FocusHandle,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr_connect = cx.new(|_| None);
let qr_code = cx.new(|_| None);
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
// Direct connection initiated by the client
let nostr_connect_uri = cx.new(|cx| {
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let app_keys = ClientKeys::read_global(cx).keys();
NostrConnectURI::client(app_keys.public_key(), vec![relay], APP_NAME)
});
let mut subscriptions = smallvec![];
// Clean up when the current view is released
subscriptions.push(cx.on_release_in(window, |this, window, cx| {
this.shutdown_nostr_connect(window, cx);
}));
// Set Nostr Connect after the view is initialized
cx.defer_in(window, |this, window, cx| {
this.set_connect(window, cx);
});
Self {
nostr_connect,
nostr_connect_uri,
qr_code,
subscriptions,
connecting: false,
name: "Onboarding".into(),
focus_handle: cx.focus_handle(),
}
}
fn set_connecting(&mut self, cx: &mut Context<Self>) {
self.connecting = true;
cx.notify();
}
fn set_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let uri = self.nostr_connect_uri.read(cx).clone();
let app_keys = ClientKeys::read_global(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
self.qr_code.update(cx, |this, cx| {
*this = uri.to_string().to_qr();
cx.notify();
});
self.nostr_connect.update(cx, |this, cx| {
*this = NostrConnect::new(uri, app_keys, timeout, None).ok();
cx.notify();
});
cx.spawn_in(window, async move |this, cx| {
let client = nostr_client();
let connect = this.read_with(cx, |this, cx| this.nostr_connect.read(cx).clone());
if let Ok(Some(signer)) = connect {
match signer.bunker_uri().await {
Ok(uri) => {
this.update(cx, |this, cx| {
this.set_connecting(cx);
this.write_uri_to_disk(&uri, cx);
})
.ok();
// Set the client's signer with the current nostr connect instance
client.set_signer(signer).await;
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
};
}
})
.detach();
}
fn set_proxy(&mut self, window: &mut Window, cx: &mut Context<Self>) {
ChatSpace::proxy_signer(window, cx);
}
fn write_uri_to_disk(&mut self, uri: &NostrConnectURI, cx: &mut Context<Self>) {
let Some(public_key) = uri.remote_signer_public_key().cloned() else {
log::error!("Remote Signer's public key not found");
return;
};
let mut value = uri.to_string();
// Clear the secret param if it exists
if let Some(secret) = uri.secret() {
value = value.replace(secret, "");
}
cx.background_spawn(async move {
let client = nostr_client();
let keys = Keys::generate();
let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
let kind = Kind::ApplicationSpecificData;
let builder = EventBuilder::new(kind, value)
.tags(tags)
.build(public_key)
.sign(&keys)
.await;
if let Ok(event) = builder {
if let Err(e) = client.database().save_event(&event).await {
log::error!("Failed to save event: {e}");
};
}
})
.detach();
}
fn copy_uri(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.write_to_clipboard(ClipboardItem::new_string(
self.nostr_connect_uri.read(cx).to_string(),
));
window.push_notification(t!("common.copied"), cx);
}
fn shutdown_nostr_connect(&mut self, _window: &mut Window, cx: &mut App) {
if !self.connecting {
if let Some(signer) = self.nostr_connect.read(cx).clone() {
cx.background_spawn(async move {
log::info!("Shutting down Nostr Connect");
signer.shutdown().await;
})
.detach();
}
}
}
fn render_apps(&self, cx: &Context<Self>) -> impl IntoIterator<Item = impl IntoElement> {
let all_apps = NostrConnectApp::all();
let mut items = Vec::with_capacity(all_apps.len());
for (ix, item) in all_apps.into_iter().enumerate() {
items.push(self.render_app(ix, item.as_str(), item.url(), cx));
}
items
}
fn render_app<T>(&self, ix: usize, label: T, url: &str, cx: &Context<Self>) -> impl IntoElement
where
T: Into<SharedString>,
{
div()
.id(ix)
.flex_1()
.rounded_md()
.py_0p5()
.px_2()
.bg(cx.theme().ghost_element_background_alt)
.child(label.into())
.on_click({
let url = url.to_owned();
move |_e, _window, cx| {
cx.open_url(&url);
}
})
}
}
impl Panel for Onboarding {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
}
impl EventEmitter<PanelEvent> for Onboarding {}
impl Focusable for Onboarding {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.child(
v_flex()
.flex_1()
.h_full()
.gap_10()
.items_center()
.justify_center()
.child(
v_flex()
.items_center()
.justify_center()
.gap_4()
.child(
svg()
.path("brand/coop.svg")
.size_16()
.text_color(cx.theme().elevated_surface_background),
)
.child(
div()
.text_center()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(shared_t!("welcome.title")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("welcome.subtitle")),
),
),
)
.child(
v_flex()
.w_80()
.gap_3()
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label(shared_t!("onboarding.start_messaging"))
.primary()
.large()
.bold()
.reverse()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::new_account(window, cx);
})),
)
.child(
h_flex()
.my_1()
.gap_1()
.child(divider(cx))
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(shared_t!("onboarding.divider")),
)
.child(divider(cx)),
)
.child(
Button::new("key")
.label(t!("onboarding.key_login"))
.ghost_alt()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::login(window, cx);
})),
)
.child(
v_flex()
.gap_1()
.child(
Button::new("ext")
.label(t!("onboarding.ext_login"))
.ghost_alt()
.on_click(cx.listener(move |this, _, window, cx| {
this.set_proxy(window, cx);
})),
)
.child(
div()
.italic()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(shared_t!("onboarding.ext_login_note")),
),
),
),
)
.child(
div()
.relative()
.p_2()
.flex_1()
.h_full()
.rounded_2xl()
.child(
v_flex()
.size_full()
.justify_center()
.bg(cx.theme().surface_background)
.rounded_2xl()
.child(
v_flex()
.gap_5()
.items_center()
.justify_center()
.when_some(self.qr_code.read(cx).as_ref(), |this, qr| {
this.child(
div()
.id("")
.child(
img(qr.clone())
.size(px(256.))
.rounded_xl()
.shadow_lg()
.border_1()
.border_color(cx.theme().element_active),
)
.on_click(cx.listener(
move |this, _e, window, cx| {
this.copy_uri(window, cx)
},
)),
)
})
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.3))
.child(shared_t!("onboarding.nostr_connect")),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(shared_t!("onboarding.scan_qr")),
)
.child(
h_flex()
.mt_2()
.gap_1()
.text_xs()
.justify_center()
.children(self.render_apps(cx)),
),
),
),
),
)
}
}

View File

@@ -0,0 +1,316 @@
use common::display::ReadableProfile;
use gpui::http_client::Url;
use gpui::{
div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window,
};
use i18n::{shared_t, t};
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::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::switch::Switch;
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, Size, StyledExt};
use crate::views::{edit_profile, setup_relay};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx))
}
pub struct Preferences {
media_input: Entity<InputState>,
}
impl Preferences {
pub fn new(window: &mut Window, cx: &mut App) -> Self {
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 = setup_relay::init(Kind::InboxRelays, 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 = Registry::read_global(cx).identity(cx);
let auto_auth = AppSettings::get_auto_auth(cx);
let backup = AppSettings::get_backup_messages(cx);
let screening = AppSettings::get_screening(cx);
let bypass = AppSettings::get_contact_bypass(cx);
let proxy = AppSettings::get_proxy_user_avatars(cx);
let hide = AppSettings::get_hide_user_avatars(cx);
v_flex()
.child(
v_flex()
.pb_2()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(shared_t!("preferences.account_header")),
)
.child(
h_flex()
.w_full()
.justify_between()
.child(
h_flex()
.id("user")
.gap_2()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(2.4)))
.child(
div()
.flex_1()
.text_sm()
.child(
div()
.font_semibold()
.line_height(relative(1.3))
.child(profile.display_name()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.line_height(relative(1.3))
.child(shared_t!("preferences.account_btn")),
),
)
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_edit_profile(window, cx);
})),
)
.child(
Button::new("relays")
.label("Messaging Relays")
.xsmall()
.ghost_alt()
.rounded(ButtonRounded::Full)
.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(shared_t!("preferences.relay_and_media")),
)
.child(
v_flex()
.my_1()
.gap_1()
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.media_input).xsmall())
.child(
Button::new("update")
.icon(IconName::Check)
.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 {
return;
};
AppSettings::update_media_server(url, cx);
}
}),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(shared_t!("preferences.media_description")),
),
)
.child(
Switch::new("auth")
.label(t!("preferences.auto_auth"))
.description(t!("preferences.auto_auth_description"))
.checked(auto_auth)
.on_click(move |_, _window, cx| {
AppSettings::update_auto_auth(!auto_auth, 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(shared_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(bypass)
.on_click(move |_, _window, cx| {
AppSettings::update_contact_bypass(!bypass, cx);
}),
)
.child(
Switch::new("backup")
.label(t!("preferences.backup_label"))
.description(t!("preferences.backup_description"))
.checked(backup)
.on_click(move |_, _window, cx| {
AppSettings::update_backup_messages(!backup, 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(shared_t!("preferences.display_header")),
)
.child(
v_flex()
.gap_2()
.child(
Switch::new("hide_avatar")
.label(t!("preferences.hide_avatars_label"))
.description(t!("preferences.hide_avatar_description"))
.checked(hide)
.on_click(move |_, _window, cx| {
AppSettings::update_hide_user_avatars(!hide, cx);
}),
)
.child(
Switch::new("proxy_avatar")
.label(t!("preferences.proxy_avatars_label"))
.description(t!("preferences.proxy_description"))
.checked(proxy)
.on_click(move |_, _window, cx| {
AppSettings::update_proxy_user_avatars(!proxy, 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,322 @@
use common::display::{shorten_pubkey, ReadableProfile};
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 nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
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> {
cx.new(|cx| Screening::new(public_key, window, cx))
}
pub struct Screening {
profile: Profile,
verified: bool,
followed: bool,
dm_relays: bool,
mutual_contacts: usize,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let registry = Registry::read_global(cx);
let identity = registry.identity(cx).public_key();
let profile = registry.get_person(&public_key, cx);
let mut tasks = smallvec![];
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) = profile.metadata().nip05 {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
} else {
None
};
tasks.push(
// Load all necessary data
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();
}
}
}),
);
Self {
profile,
verified: false,
followed: false,
dm_relays: false,
mutual_contacts: 0,
_tasks: tasks,
}
}
fn address(&self, _cx: &Context<Self>) -> Option<String> {
self.profile.metadata().nip05
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
let Ok(bech32) = self.profile.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.profile.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 shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8);
v_flex()
.gap_4()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(self.profile.avatar_url(proxy)).size(rems(4.)))
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(self.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

@@ -0,0 +1,377 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use global::constants::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 itertools::Itertools;
use nostr_sdk::prelude::*;
use registry::Registry;
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(kind: Kind, window: &mut Window, cx: &mut App) -> Entity<SetupRelay> {
cx.new(|cx| SetupRelay::new(kind, window, cx))
}
pub fn setup_nip17_relay<T>(label: T) -> impl IntoElement
where
T: Into<SharedString>,
{
div().child(
Button::new("setup-relays")
.icon(IconName::Info)
.label(label)
.warning()
.xsmall()
.rounded(ButtonRounded::Full)
.on_click(move |_, window, cx| {
let view = cx.new(|cx| SetupRelay::new(Kind::InboxRelays, window, cx));
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(shared_t!("relays.modal_title"))
.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 SetupRelay {
input: Entity<InputState>,
relays: Vec<RelayUrl>,
error: Option<SharedString>,
_subscriptions: SmallVec<[Subscription; 1]>,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl SetupRelay {
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Self>) -> Self {
let identity = Registry::read_global(cx).identity(cx).public_key();
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
let load_relay = cx.background_spawn(async move {
let client = nostr_client();
let filter = Filter::new().kind(kind).author(identity).limit(1);
if let Some(event) = client.database().query(filter).await?.first() {
let relays = event
.tags
.iter()
.filter_map(|tag| tag.as_standardized())
.filter_map(|tag| {
if let TagStandard::RelayMetadata { relay_url, .. } = tag {
Some(relay_url.to_owned())
} else if let TagStandard::Relay(url) = tag {
Some(url.to_owned())
} else {
None
}
})
.collect_vec();
Ok(relays)
} else {
Err(anyhow!("Not found."))
}
});
tasks.push(
// Load user's relays in the local database
cx.spawn_in(window, async move |this, cx| {
if let Ok(relays) = load_relay.await {
this.update(cx, |this, cx| {
this.relays = relays;
cx.notify();
})
.ok();
}
}),
);
subscriptions.push(
// Subscribe to user's input events
cx.subscribe_in(
&input,
window,
move |this: &mut Self, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.add(window, cx);
}
},
),
);
Self {
input,
relays: vec![],
error: None,
_subscriptions: subscriptions,
_tasks: tasks,
}
}
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 tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::relay(relay.clone()))
.collect();
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
// 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;
}
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
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(shared_t!("relays.add_some_relays"))
}
}
impl Render for SetupRelay {
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,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,201 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window,
};
use i18n::t;
use nostr_sdk::prelude::*;
use 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::skeleton::Skeleton;
use ui::{h_flex, ContextModal, StyledExt};
use crate::views::screening;
#[derive(IntoElement)]
pub struct RoomListItem {
ix: usize,
room_id: Option<u64>,
public_key: Option<PublicKey>,
name: Option<SharedString>,
avatar: Option<SharedString>,
created_at: Option<SharedString>,
kind: Option<RoomKind>,
#[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl RoomListItem {
pub fn new(ix: usize) -> Self {
Self {
ix,
room_id: None,
public_key: None,
name: None,
avatar: None,
created_at: None,
kind: None,
handler: None,
}
}
pub fn room_id(mut self, room_id: u64) -> Self {
self.room_id = Some(room_id);
self
}
pub fn public_key(mut self, public_key: PublicKey) -> Self {
self.public_key = Some(public_key);
self
}
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
self.avatar = Some(avatar.into());
self
}
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
self.created_at = Some(created_at.into());
self
}
pub fn kind(mut self, kind: RoomKind) -> Self {
self.kind = Some(kind);
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Some(Rc::new(handler));
self
}
}
impl RenderOnce for RoomListItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let hide_avatar = AppSettings::get_hide_user_avatars(cx);
let require_screening = AppSettings::get_screening(cx);
let (
Some(public_key),
Some(room_id),
Some(name),
Some(avatar),
Some(created_at),
Some(kind),
Some(handler),
) = (
self.public_key,
self.room_id,
self.name,
self.avatar,
self.created_at,
self.kind,
self.handler,
)
else {
return h_flex()
.id(self.ix)
.h_9()
.w_full()
.px_1p5()
.gap_2()
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
.child(
div()
.flex_1()
.flex()
.justify_between()
.child(Skeleton::new().w_32().h_2p5().rounded_sm())
.child(Skeleton::new().w_6().h_2p5().rounded_sm()),
);
};
h_flex()
.id(self.ix)
.h_9()
.w_full()
.px_1p5()
.gap_2()
.text_sm()
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {
this.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.child(Avatar::new(avatar).size(rems(1.5))),
)
})
.child(
div()
.flex_1()
.flex()
.items_center()
.justify_between()
.child(
div()
.flex_1()
.line_clamp(1)
.text_ellipsis()
.truncate()
.font_medium()
.child(name),
)
.child(
div()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(created_at),
),
)
.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

@@ -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,253 @@
use std::time::Duration;
use common::display::ReadableProfile;
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::{shared_t, t};
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
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> {
cx.new(|cx| UserProfile::new(public_key, window, cx))
}
pub struct UserProfile {
profile: Profile,
followed: bool,
verified: bool,
copied: bool,
_tasks: SmallVec<[Task<()>; 1]>,
}
impl UserProfile {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let registry = Registry::read_global(cx);
let identity = registry.identity(cx).public_key();
let profile = registry.get_person(&public_key, cx);
let mut tasks = smallvec![];
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) = profile.metadata().nip05 {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
} else {
None
};
tasks.push(
// Load user profile data
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await;
// Update the followed status
this.update(cx, |this, cx| {
this.followed = followed;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
}),
);
Self {
profile,
followed: false,
verified: false,
copied: false,
_tasks: tasks,
}
}
fn address(&self, _cx: &Context<Self>) -> Option<String> {
self.profile.metadata().nip05
}
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.profile.public_key().to_bech32();
let item = ClipboardItem::new_string(bech32);
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
// 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 Ok(bech32) = self.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(self.profile.avatar_url(proxy)).size(rems(4.)))
.child(
v_flex()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(self.profile.display_name()),
)
.when_some(self.address(cx), |this, address| {
this.child(
h_flex()
.justify_center()
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child(address)
.when(self.verified, |this| {
this.child(
div()
.relative()
.text_color(cx.theme().text_accent)
.child(
Icon::new(IconName::CheckCircleFill)
.small()
.block(),
),
)
}),
)
}),
)
.when(!self.followed, |this| {
this.child(
div()
.flex_none()
.w_32()
.p_1()
.rounded_full()
.bg(cx.theme().elevated_surface_background)
.text_xs()
.font_semibold()
.child(shared_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(shared_t!("profile.label_bio")),
)
.child(
div()
.p_2()
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.child(
self.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,61 @@
pub const APP_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b";
/// Bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://user.kindpag.es",
"wss://relaydiscovery.com",
"wss://purplepag.es",
];
/// Subscriptions
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps";
/// Image Resizer Service
pub const IMAGE_SERVICE: &str = "https://wsrv.nl";
/// NIP96 Media Server
pub const NIP96_SERVER: &str = "https://nostrmedia.com";
pub const APP_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const APP_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc4MkNFRkQ2RkVGQURGNzUKUldSMTMvcisxdThzZUZraHc4Vno3NVNJek81VkJFUEV3MkJweGFxQXhpekdSU1JIekpqMG4yemMK";
pub const APP_UPDATER_ENDPOINT: &str = "https://coop-updater.reya.su/";
pub const KEYRING_URL: &str = "Coop Safe Storage";
pub const ACCOUNT_IDENTIFIER: &str = "coop:user";
pub const SETTINGS_IDENTIFIER: &str = "coop:settings";
/// Bootstrap Relays.
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nos.social",
"wss://user.kindpag.es",
"wss://purplepag.es",
];
/// Search Relays.
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
/// NIP65 Relays. Used for new account
pub const NIP65_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://nos.lol",
];
/// Messaging Relays. Used for new account
pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://auth.nostr1.com"];
/// Default relay for Nostr Connect
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default retry count for fetching NIP-17 relays
pub const TOTAL_RETRY: u64 = 2;
/// Default timeout (in seconds) for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default timeout (in seconds) for Nostr Connect (Bunker)
pub const BUNKER_TIMEOUT: u64 = 30;
/// Total metadata requests will be grouped.
pub const METADATA_BATCH_LIMIT: usize = 100;
/// Maximum timeout for grouping metadata requests. (milliseconds)
pub const METADATA_BATCH_TIMEOUT: u64 = 300;
/// Maximum timeout for waiting for finish (seconds)
pub const WAIT_FOR_FINISH: u64 = 60;
/// Default width of the sidebar.
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;
/// 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,169 @@
use nostr_sdk::prelude::*;
use paths::nostr_file;
use std::{sync::OnceLock, time::Duration};
pub mod constants;
pub mod paths;
static CLIENT: OnceLock<Client> = OnceLock::new();
static CLIENT_KEYS: OnceLock<Keys> = OnceLock::new();
/// Nostr Client instance
pub fn get_client() -> &'static Client {
CLIENT.get_or_init(|| {
// Setup database
let db_path = nostr_file();
let lmdb = NostrLMDB::open(db_path).expect("Database is NOT initialized");
// Client options
let opts = Options::new()
// NIP-65
// Coop is don't really need to enable this option,
// but this will help the client discover user's messaging relays efficiently.
.gossip(true)
// Skip all very slow relays
// Note: max delay is 800ms
.max_avg_latency(Duration::from_millis(800));
// Setup Nostr Client
ClientBuilder::default().database(lmdb).opts(opts).build()
})
}
/// Client Keys
pub fn get_client_keys() -> &'static Keys {
CLIENT_KEYS.get_or_init(Keys::generate)
}
use std::sync::OnceLock;
use std::time::Duration;
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use paths::nostr_file;
use smol::channel::{Receiver, Sender};
use smol::lock::RwLock;
use crate::paths::support_dir;
pub mod constants;
pub mod paths;
#[derive(Debug, Clone)]
pub struct AuthReq {
pub challenge: String,
pub url: RelayUrl,
}
impl AuthReq {
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
Self {
challenge: challenge.into(),
url,
}
}
}
#[derive(Debug, Clone)]
pub enum Notice {
RelayFailed(RelayUrl),
AuthFailed(RelayUrl),
Custom(String),
}
impl Notice {
pub fn as_str(&self) -> String {
match self {
Notice::AuthFailed(url) => format!("Authenticate failed for relay {url}"),
Notice::RelayFailed(url) => format!("Failed to connect the relay {url}"),
Notice::Custom(msg) => msg.into(),
}
}
}
/// Signals sent through the global event channel to notify UI
#[derive(Debug)]
pub enum IngesterSignal {
/// A signal to notify UI that the client's signer has been set
SignerSet(PublicKey),
/// A signal to notify UI that the client's signer has been unset
SignerUnset,
/// A signal to notify UI that the relay requires authentication
Auth(AuthReq),
/// A signal to notify UI that the browser proxy service is down
ProxyDown,
/// A signal to notify UI that a new metadata event has been received
Metadata(Event),
/// A signal to notify UI that a new gift wrap event has been received
GiftWrap((EventId, Event)),
/// A signal to notify UI that all gift wrap events have been processed
Finish,
/// A signal to notify UI that partial processing of gift wrap events has been completed
PartialFinish,
/// A signal to notify UI that no DM relay for current user was found
DmRelayNotFound,
/// A signal to notify UI that there are errors or notices occurred
Notice(Notice),
}
#[derive(Debug)]
pub struct Ingester {
rx: Receiver<IngesterSignal>,
tx: Sender<IngesterSignal>,
}
impl Default for Ingester {
fn default() -> Self {
Self::new()
}
}
impl Ingester {
pub fn new() -> Self {
let (tx, rx) = smol::channel::bounded::<IngesterSignal>(2048);
Self { rx, tx }
}
pub fn signals(&self) -> &Receiver<IngesterSignal> {
&self.rx
}
pub async fn send(&self, signal: IngesterSignal) {
if let Err(e) = self.tx.send(signal).await {
log::error!("Failed to send signal: {e}");
}
}
}
static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
static INGESTER: OnceLock<Ingester> = OnceLock::new();
static SENT_IDS: OnceLock<RwLock<Vec<EventId>>> = OnceLock::new();
static CURRENT_TIMESTAMP: OnceLock<Timestamp> = 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()
.gossip(true)
.automatic_authentication(false)
.verify_subscriptions(false)
// Sleep after idle for 30 seconds
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(30),
});
ClientBuilder::default().database(lmdb).opts(opts).build()
})
}
pub fn ingester() -> &'static Ingester {
INGESTER.get_or_init(Ingester::new)
}
pub fn starting_time() -> &'static Timestamp {
CURRENT_TIMESTAMP.get_or_init(Timestamp::now)
}
pub fn sent_ids() -> &'static RwLock<Vec<EventId>> {
SENT_IDS.get_or_init(|| RwLock::new(Vec::new()))
}
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
}
})
}

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