Compare commits
218 Commits
0.1.2-alph
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c3783522 | |||
|
|
bb455871e5 | ||
| 0507fa7ac5 | |||
| af115321b4 | |||
|
|
34e026751b | ||
| e9e662dccc | |||
| 5b7780ec9b | |||
| 782efd7498 | |||
| 8192023479 | |||
| 4637478a0b | |||
| 6b5adb0a56 | |||
| 9fd55cf3ff | |||
|
|
14c36e4731 | ||
|
|
a6e00b47d8 | ||
| 0784a20be5 | |||
|
|
6023063cf4 | ||
| 67c92cb319 | |||
|
|
122299f548 | ||
| d87bcfbd65 | |||
| de5134676d | |||
|
|
512834b640 | ||
| a1a0a7ecd4 | |||
|
|
a4067d2c00 | ||
| 4ebe590f8a | |||
|
|
9da624dd0c | ||
|
|
7091fa1cab | ||
| a1bd4954eb | |||
| fde1499796 | |||
|
|
649cdff49c | ||
|
|
b0fa98831d | ||
|
|
b9297d3a01 | ||
|
|
b5ed079a0e | ||
| 6017eebaed | |||
|
|
15bbe82a87 | ||
|
|
83687e5448 | ||
|
|
48c90f5bb0 | ||
|
|
47abd2909b | ||
|
|
ac0b233089 | ||
|
|
a1e0934fc3 | ||
| 32a0401907 | |||
| 1742031901 | |||
|
|
2415374567 | ||
|
|
7fc727461e | ||
|
|
68a8ec7a69 | ||
| b7693444e6 | |||
| 6e7f63d79a | |||
| ee693aa503 | |||
|
|
ebcc60cd92 | ||
|
|
0db48bc003 | ||
| 880ba30d20 | |||
|
|
d889f9b25d | ||
|
|
0de1b20951 | ||
|
|
338a947b57 | ||
|
|
98ce928f0c | ||
|
|
61cad5dd96 | ||
| a87184214f | |||
| fff3a44f62 | |||
|
|
9abcc25f32 | ||
|
|
fb3da096f8 | ||
| 1de3045505 | |||
|
|
9f369bf57f | ||
|
|
4164651342 | ||
| c12856cda0 | |||
|
|
c67b223a53 | ||
|
|
9880a3ed3d | ||
|
|
d13ffd5a54 | ||
| cc79f0ed1c | |||
|
|
5127eaadbb | ||
| d38e70ecbf | |||
|
|
b142982ab1 | ||
|
|
2ea2519e8b | ||
|
|
2ea5feaf4b | ||
| 4ec7530b91 | |||
| df82861101 | |||
|
|
fc99ef4dfe | ||
|
|
d0f7a1abd3 | ||
|
|
71140beb52 | ||
|
|
e177facef4 | ||
| 60bca49200 | |||
|
|
ede41c41c3 | ||
|
|
70e235dcc2 | ||
| b11b0e0115 | |||
|
|
d8edac0bb9 | ||
|
|
d392602ed6 | ||
|
|
5a36354cc8 | ||
| a1df66e176 | |||
|
|
78d913ae38 | ||
| b4691aa689 | |||
| c49530b030 | |||
|
|
e7ffe7627c | ||
| 6a5304514f | |||
|
|
f2be8fca08 | ||
|
|
807851518a | ||
| 49a3dedd9c | |||
|
|
b19bb01003 | ||
| 3a6fc2bcc5 | |||
|
|
5edcc97ada | ||
| a8ccda259c | |||
|
|
23ad28e96e | ||
| 07a2f6980e | |||
|
|
c2b276f3f3 | ||
| 5bef1a2c6c | |||
| cd26244538 | |||
|
|
ca622d1262 | ||
|
|
5011becacb | ||
|
|
17f92d767e | ||
| be660cb14b | |||
|
|
8fca202c05 | ||
| 7b20131e3b | |||
|
|
9127696517 | ||
|
|
af74a4ed23 | ||
|
|
bd2b72a57a | ||
| d6edc8b546 | |||
| 053ecc6a15 | |||
|
|
fe864e4a7f | ||
| 871bbdac78 | |||
| 0ea919901e | |||
| 3772853141 | |||
| ab4597cb6f | |||
| ed6e4f2082 | |||
| 493223276c | |||
| c8c5a6668d | |||
| 86d24ccbd5 | |||
| 80c649f9a0 | |||
|
|
c188f12993 | ||
|
|
3cf9dde882 | ||
|
|
91cca37d69 | ||
| 12168c6084 | |||
|
|
a631dd90d2 | ||
|
|
00b40db82c | ||
| 59cfdb9ae2 | |||
| 73a2678278 | |||
|
|
c7ab75d310 | ||
|
|
8195eedaf6 | ||
|
|
9f02942d87 | ||
|
|
2e3a4b3634 | ||
|
|
8bfad30a99 | ||
| 122dbaf693 | |||
| 9bb784652d | |||
|
|
c1d5c7e719 | ||
| f9bf29df09 | |||
| 2e046ec5d7 | |||
| abb1474300 | |||
|
|
b212095334 | ||
|
|
2dfb48b538 | ||
|
|
14076054c0 | ||
| 3c2eaabab2 | |||
|
|
edee9305cc | ||
|
|
c7e3331eb0 | ||
| 1d77fd443e | |||
| 5f5bb33654 | |||
| 052b0163cb | |||
| 5f8e886a34 | |||
|
|
440f17af18 | ||
|
|
cc36adeafe | ||
|
|
e687204361 | ||
|
|
50beaebd2c | ||
|
|
7cc512331b | ||
| 63191c16bd | |||
|
|
a674ac898a | ||
|
|
557ff18714 | ||
| 7a447da447 | |||
| 92d862e1fa | |||
|
|
0f884f8142 | ||
|
|
45564c7722 | ||
| b0a6b73801 | |||
| e851063de9 | |||
|
|
3fd236de73 | ||
| ba42bafc3a | |||
| 71fbd97bad | |||
|
|
443dbc82a6 | ||
|
|
4f066b7c00 | ||
|
|
4e24061817 | ||
| 2f83b5091e | |||
|
|
97e66fbeb7 | ||
| 3fea18f038 | |||
| 3bd8592f86 | |||
|
|
8c211be11a | ||
| 2c2aeb915e | |||
| 44f0650617 | |||
|
|
107fedeafd | ||
| 17251be3fd | |||
| 73b2eac080 | |||
| 86eca5803f | |||
| 52a79dca08 | |||
| 87f038248c | |||
|
|
a30f2dcc8a | ||
| 5c5748a80c | |||
|
|
b667dd3f1c | ||
|
|
3246abace1 | ||
|
|
f7610cc9c9 | ||
| 16530a3804 | |||
| b778bb13e4 | |||
|
|
cfc2300c0c | ||
| 42d6328d82 | |||
| 4c9533bfe4 | |||
|
|
00cf7792e5 | ||
| e15cbcc22c | |||
| 348dc496a6 | |||
| 09df38a3b2 | |||
| cae96157ca | |||
| 0a7f0475a4 | |||
| 8156d9d046 | |||
| b92d446184 | |||
| 73b8a1a6da | |||
| ba0b377cee | |||
| 0822b46596 | |||
| d93cecbea3 | |||
| 0887970374 | |||
|
|
a53b2181ab | ||
| 81664e3d4e | |||
| 29ec6da872 | |||
| 111ab3b082 | |||
| 1c4806bd92 | |||
| 3f8c02aef8 | |||
| b73babf274 | |||
|
|
bbc778d5ca | ||
| cfa628a8a6 |
81
.github/workflows/main.yml
vendored
@@ -1,81 +0,0 @@
|
||||
name: Packager Release Process
|
||||
|
||||
run-name: Triggered by ${{ github.actor }}.
|
||||
on: workflow_dispatch
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CN_APPLICATION: lume/coop
|
||||
|
||||
jobs:
|
||||
draft:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Create draft release
|
||||
uses: crabnebula-dev/cloud-release@v0
|
||||
with:
|
||||
command: release draft ${{ env.CN_APPLICATION }} --framework packager
|
||||
api-key: ${{ secrets.CN_API_KEY }}
|
||||
|
||||
build:
|
||||
needs: draft
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Install stable toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc g++ libasound2-dev libfontconfig-dev libwayland-dev libxkbcommon-x11-dev libssl-dev libzstd-dev libvulkan1 libgit2-dev make cmake clang jq netcat-openbsd git curl gettext-base elfutils libsqlite3-dev musl-tools musl-dev build-essential
|
||||
|
||||
- name: install cargo packager
|
||||
run: |
|
||||
cargo install cargo-packager --locked
|
||||
|
||||
- name: Build packager app
|
||||
run: |
|
||||
cargo packager --release
|
||||
|
||||
- name: Move assets to workdir
|
||||
run: |
|
||||
mv target/release/* .
|
||||
|
||||
- name: Upload assets
|
||||
uses: crabnebula-dev/cloud-release@v0
|
||||
with:
|
||||
command: release upload ${{ env.CN_APPLICATION }} --framework packager
|
||||
api-key: ${{ secrets.CN_API_KEY }}
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish release
|
||||
uses: crabnebula-dev/cloud-release@v0
|
||||
with:
|
||||
command: release publish ${{ env.CN_APPLICATION }} --framework packager
|
||||
api-key: ${{ secrets.CN_API_KEY }}
|
||||
172
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: windows-x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
- platform: windows-arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- platform: macos-x64
|
||||
os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
- platform: macos-arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- platform: linux-x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- platform: linux-arm64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# Windows and macOS builds using cargo-packager
|
||||
- name: Build with cargo-packager (Windows/macOS)
|
||||
if: runner.os != 'Linux'
|
||||
working-directory: crates/coop
|
||||
run: |
|
||||
cargo install cargo-packager --locked
|
||||
cargo packager --release
|
||||
|
||||
- name: Upload Windows/macOS artifacts
|
||||
if: runner.os != 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.platform }}-artifacts
|
||||
path: |
|
||||
dist/*.dmg
|
||||
dist/*.msi
|
||||
dist/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# Linux builds using custom scripts
|
||||
- name: Install Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder snapd squashfs-tools jq gettext-base
|
||||
|
||||
- name: Install Snapcraft
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
- name: Make scripts executable
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
chmod +x script/get-crate-version
|
||||
chmod +x script/linux
|
||||
chmod +x script/bundle-snap
|
||||
chmod +x script/bundle-linux
|
||||
chmod +x script/flatpak/deps
|
||||
chmod +x script/flatpak/bundle-flatpak
|
||||
|
||||
- name: Install required dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: ./script/linux
|
||||
|
||||
# Only build Flatpak and Snap for x86_64 (most common use case)
|
||||
- name: Build Flatpak
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
./script/bundle-linux --flatpak
|
||||
./script/flatpak/deps
|
||||
./script/flatpak/bundle-flatpak
|
||||
|
||||
- name: Build Snap
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
VERSION=$(script/get-crate-version coop)
|
||||
./script/bundle-linux
|
||||
./script/bundle-snap $VERSION
|
||||
|
||||
- name: Collect Linux artifacts
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
mkdir -p linux-artifacts
|
||||
# Copy the tarball created by bundle-linux
|
||||
find target/release -name "*.tar.gz" -exec cp {} linux-artifacts/ \;
|
||||
# Find and copy flatpak files (if they exist)
|
||||
find . -name "*.flatpak" -exec cp {} linux-artifacts/ \; || true
|
||||
# Find and copy snap files (if they exist)
|
||||
find . -name "*.snap" -exec cp {} linux-artifacts/ \; || true
|
||||
ls -la linux-artifacts/
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.platform }}-artifacts
|
||||
path: linux-artifacts/**/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Make get-crate-version executable
|
||||
run: chmod +x script/get-crate-version
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(script/get-crate-version coop)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Display artifacts structure
|
||||
run: |
|
||||
echo "Artifacts structure:"
|
||||
find artifacts -type f -exec ls -la {} \;
|
||||
|
||||
- name: Create draft release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: ${{ steps.version.outputs.tag }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/**/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Output release info
|
||||
run: |
|
||||
echo "Created draft release: ${{ steps.create_release.outputs.url }}"
|
||||
echo "Release ID: ${{ steps.create_release.outputs.id }}"
|
||||
32
.github/workflows/rust.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["m**"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
rustup: [stable]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: script/linux
|
||||
run: chmod +x ./script/linux && ./script/linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
2
.gitignore
vendored
@@ -19,3 +19,5 @@ dist/
|
||||
|
||||
# Useless stuffs
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
.intentionally-empty-file.o
|
||||
|
||||
4730
Cargo.lock
generated
94
Cargo.toml
@@ -1,44 +1,50 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/app"]
|
||||
resolver = "2"
|
||||
|
||||
[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-relay-builder = { 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",
|
||||
"nip05",
|
||||
] }
|
||||
|
||||
smol = "2"
|
||||
oneshot = { git = "https://github.com/faern/oneshot" }
|
||||
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.13.2"
|
||||
rust-embed = "8.5.0"
|
||||
log = "0.4"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/*"]
|
||||
default-members = ["crates/coop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = "0.4.38"
|
||||
futures = "0.3"
|
||||
itertools = "0.13.0"
|
||||
log = "0.4"
|
||||
oneshot = "0.1.10"
|
||||
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
rust-embed = "8.5.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "1"
|
||||
smallvec = "1.14.0"
|
||||
smol = "2"
|
||||
tracing = "0.1.40"
|
||||
webbrowser = "1.0.4"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name = "coop"
|
||||
description = "Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability."
|
||||
product-name = "Coop"
|
||||
identifier = "su.reya.coop"
|
||||
version = "0.1.2"
|
||||
resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"]
|
||||
icons = [
|
||||
"assets/brand/32x32.png",
|
||||
"assets/brand/128x128.png",
|
||||
"assets/brand/128x128@2x.png",
|
||||
"assets/brand/icon.icns",
|
||||
"assets/brand/icon.ico",
|
||||
]
|
||||
before-packaging-command = "cargo build --release"
|
||||
out-dir = "./target/release"
|
||||
binaries = [
|
||||
{ path = "coop", main = true },
|
||||
]
|
||||
55
README.md
@@ -1,19 +1,34 @@
|
||||

|
||||

|
||||
|
||||
<p>
|
||||
<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/)
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 5.8 KiB |
BIN
assets/brand/group.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
assets/brand/system.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/fonts/plex-mono/ZedPlexMono-Bold.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-Italic.ttf
Normal file
BIN
assets/fonts/plex-mono/ZedPlexMono-Regular.ttf
Normal file
92
assets/fonts/plex-mono/license.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
assets/fonts/plex-sans/ZedPlexSans-Bold.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-Italic.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-Regular.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-SemiBold.ttf
Normal file
BIN
assets/fonts/plex-sans/ZedPlexSans-SemiBoldItalic.ttf
Normal file
92
assets/fonts/plex-sans/license.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="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="M10 5.75 3.75 12 10 18.25M4.5 12h15.75"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 244 B |
3
assets/icons/arrow-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 5.75 20.25 12 14 18.25M19.5 12H3.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.22-1.53 3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 1 1-1.06 1.06l-1.97-1.97v6.69a.75.75 0 0 1-1.5 0V9.56l-1.97 1.97a.75.75 0 0 1-1.06-1.06Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 397 B |
1
assets/icons/arrows-in.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,53.66,163.31,104H192a8,8,0,0,1,0,16H144a8,8,0,0,1-8-8V64a8,8,0,0,1,16,0V92.69l50.34-50.35a8,8,0,0,1,11.32,11.32ZM112,136H64a8,8,0,0,0,0,16H92.69L42.34,202.34a8,8,0,0,0,11.32,11.32L104,163.31V192a8,8,0,0,0,16,0V144A8,8,0,0,0,112,136Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 370 B |
1
assets/icons/caret-down-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 218 B |
|
Before Width: | Height: | Size: 247 B After Width: | Height: | Size: 247 B |
1
assets/icons/caret-right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 249 B |
1
assets/icons/caret-up.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,165.66a8,8,0,0,1-11.32,0L128,91.31,53.66,165.66a8,8,0,0,1-11.32-11.32l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,213.66,165.66Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 262 B |
1
assets/icons/check-circle-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 298 B |
1
assets/icons/check-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 369 B |
1
assets/icons/check.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9.8 10.25c-1.052 0-1.633 1.221-.97 2.038l2.2 2.707c.5.616 1.44.616 1.94 0l2.2-2.707c.664-.817.082-2.038-.97-2.038H9.8Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 257 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill="#000" fill-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm3.58 7.975a.75.75 0 0 0-1.16-.95l-3.976 4.859L9.03 12.47a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.11-.055l4.5-5.5Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 429 B |
3
assets/icons/close-circle.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm7.53-3.53a.75.75 0 0 0-1.06 1.06L10.94 12l-2.47 2.47a.75.75 0 1 0 1.06 1.06L12 13.06l2.47 2.47a.75.75 0 1 0 1.06-1.06L13.06 12l2.47-2.47a.75.75 0 0 0-1.06-1.06L12 10.94 9.53 8.47Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -1,3 +1 @@
|
||||
<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="M4.116 4.116a1.25 1.25 0 0 1 1.768 0L12 10.232l6.116-6.116a1.25 1.25 0 0 1 1.768 1.768L13.768 12l6.116 6.116a1.25 1.25 0 0 1-1.768 1.768L12 13.768l-6.116 6.116a1.25 1.25 0 0 1-1.768-1.768L10.232 12 4.116 5.884a1.25 1.25 0 0 1 0-1.768Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"></path></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 314 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill="#000" fill-rule="evenodd" d="M19.432 2.738c.505.54.728 1.327.443 2.133-.606 1.713-1.798 3.124-2.797 4.087a15.74 15.74 0 0 1-1.045.921l.137.1c.93.684 1.416 1.975.757 3.118-1.221 2.12-4.356 5.803-11.192 5.803a.753.753 0 0 1-.15-.015A32.702 32.702 0 0 0 5.5 21.25a.75.75 0 0 1-1.5 0c0-4.43.821-8.93 2.909-12.485 2.106-3.587 5.49-6.182 10.492-6.749a2.404 2.404 0 0 1 2.031.722Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 522 B |
3
assets/icons/copy.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" 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
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.75 21.25h-4a2 2 0 0 1-2-2V4.75a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v7"/>
|
||||
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M13.75 21.25v-2.333l3.75-3.75a1.65 1.65 0 0 1 2.333 2.333l-3.75 3.75H13.75Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 454 B |
3
assets/icons/emoji-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm7.75-5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 9.75 7Zm4.5 0a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75Zm-6.143 7.864a.75.75 0 0 1 1.029-.257c1.04.624 1.97.905 2.864.905.894 0 1.824-.281 2.864-.905a.75.75 0 1 1 .772 1.286c-1.21.726-2.405 1.12-3.636 1.12-1.23 0-2.426-.394-3.636-1.12a.75.75 0 0 1-.257-1.029Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
3
assets/icons/encryption.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8.75a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm0 0v6m8.25-2.838v-4.97a2 2 0 0 0-1.367-1.898l-6.25-2.083a2 2 0 0 0-1.265 0l-6.25 2.083A2 2 0 0 0 3.75 6.942v4.97c0 4.973 4.25 7.338 8.25 9.496 4-2.158 8.25-4.523 8.25-9.496Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10ZM8.405 10.2a.75.75 0 0 1-1.12.263l-2.428-1.82a.707.707 0 0 1-.204-.92 8.496 8.496 0 0 1 8.675-4.12c.409.064.653.475.553.877l-.77 3.084a.75.75 0 0 1-.545.546l-3.226.81a.75.75 0 0 0-.487.39l-.448.889Zm6.805 6.385a.75.75 0 0 1-.671.415h-2.135a.75.75 0 0 1-.624-.334l-1.436-2.153a.75.75 0 0 1 .095-.948l.433-.431a.75.75 0 0 1 .577-.217l1.403.09a.75.75 0 0 1 .37.125l2.233 1.5a.75.75 0 0 1 .252.958l-.498.995Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 654 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill="#000" d="M3.999 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm9.499.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0ZM7.999 12c-1.765 0-3.236.635-4.365 1.72-1.117 1.074-1.868 2.557-2.282 4.225C.932 19.64 2.351 21 3.9 21h8.197c1.55 0 2.968-1.361 2.548-3.055-.413-1.668-1.164-3.151-2.281-4.225-1.13-1.085-2.6-1.72-4.365-1.72Zm6.174.715c1.21 1.337 1.983 3.011 2.414 4.749.231.934.167 1.79-.103 2.536h3.86c1.538 0 2.996-1.365 2.51-3.075C22.06 14.14 20.103 12 16.997 12c-1.08 0-2.023.26-2.825.715Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 595 B |
3
assets/icons/logout.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.25 12H9m11.25 0-4.5 4.5m4.5-4.5-4.5-4.5m-4.5 12.75h-5.5a2 2 0 0 1-2-2V5.75a2 2 0 0 1 2-2h5.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M3 13.745V5.75A2.75 2.75 0 0 1 5.75 3h12.5A2.75 2.75 0 0 1 21 5.75v12.5A2.75 2.75 0 0 1 18.25 21H5.75A2.75 2.75 0 0 1 3 18.25M5.75 4.5h12.5c.69 0 1.25.56 1.25 1.25V13h-3.57a.75.75 0 0 0-.737.61 3.251 3.251 0 0 1-6.386 0A.75.75 0 0 0 8.07 13H4.5V5.75c0-.69.56-1.25 1.25-1.25Z" clip-rule="evenodd"/>
|
||||
<path fill="currentColor" d="M3 18.25v-4.505"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 502 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 2a7.795 7.795 0 0 0-7.696 6.554l-1.17 7.258A2.75 2.75 0 0 0 5.848 19h1.66c.849 1.75 2.512 3 4.492 3s3.643-1.25 4.492-3h1.66a2.75 2.75 0 0 0 2.714-3.188l-1.17-7.258A7.795 7.795 0 0 0 12 2Zm2.754 17H9.245c.678.937 1.68 1.5 2.754 1.5s2.076-.563 2.754-1.5Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 434 B |
3
assets/icons/open-url.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18.25 14v3.05c0 1.12 0 1.68-.218 2.108a2 2 0 0 1-.874.874c-.428.218-.988.218-2.108.218h-8.1c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874c-.218-.428-.218-.988-.218-2.108V8.875c0-1.05 0-1.574.192-1.98a2 2 0 0 1 .953-.953c.406-.192.93-.192 1.98-.192H9.25m4.5-2h6.5m0 0v6.5m0-6.5L11 13"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
1
assets/icons/plus-circle-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.13,104.13,0,0,0,128,24Zm40,112H136v32a8,8,0,0,1-16,0V136H88a8,8,0,0,1,0-16h32V88a8,8,0,0,1,16,0v32h32a8,8,0,0,1,0,16Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
3
assets/icons/plus-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 272 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3.75V12m0 0v8.25M12 12H3.75M12 12h8.25"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 4v8m0 0v8m0-8H4m8 0h8"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 248 B After Width: | Height: | Size: 205 B |
4
assets/icons/refresh.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M13 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8-10a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm-1.07 3.268a1 1 0 1 1-1 1.732 1 1 0 0 1 1-1.732Zm-2.562 5.026a1 1 0 1 0-1-1.732 1 1 0 0 0 1 1.732ZM18.927 8a1 1 0 1 1-1-1.732 1 1 0 0 1 1 1.732Z"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.25 14.75v5.5h-5.5M9 19.688a8.25 8.25 0 1 1 6.25-15.273"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
3
assets/icons/reply.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m1.845 11.45 8.146-7.535a.75.75 0 0 1 1.259.55V8c0 .276.228.5.504.504C19.84 8.632 22 11.92 22 20.25c-1.47-2.94-2.22-4.679-10.245-4.748a.501.501 0 0 0-.505.498v3.535a.75.75 0 0 1-1.26.55L1.846 12.55a.75.75 0 0 1 0-1.1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
3
assets/icons/report.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20.001 16-2 2m0 0-2 2m2-2-2-2m2 2 2 2m-8.147-6.749c-3.319.058-5.832 2.055-6.87 4.862-.41 1.105.535 2.137 1.713 2.137h5.554m-.397-6.999L12 13.25c.52 0 1.021.047 1.5.138m-1.647-.137A7.89 7.89 0 0 0 10 13.5m5.75-7a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20.25 20.25-4.123-4.123m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20 20-3.873-3.873m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 287 B |
4
assets/icons/server.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M21.25 12V6.75a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2V12m18.5 0H2.75m18.5 0v5.25a2 2 0 0 1-2 2H4.75a2 2 0 0 1-2-2V12"/>
|
||||
<path fill="currentColor" stroke="currentColor" stroke-width=".5" d="M6.5 14.875a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Zm0-7.25a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
4
assets/icons/settings.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m7.878 5.214-.703-.162a1.77 1.77 0 0 0-2.123 2.123l.162.703a2 2 0 0 1-.84 2.114l-.854.57a1.728 1.728 0 0 0 0 2.876l.855.57a2 2 0 0 1 .84 2.114l-.163.703a1.77 1.77 0 0 0 2.123 2.123l.703-.162a2 2 0 0 1 2.114.84l.57.854a1.728 1.728 0 0 0 2.876 0l.57-.855a2 2 0 0 1 2.114-.84l.703.163a1.77 1.77 0 0 0 2.123-2.123l-.162-.703a2 2 0 0 1 .84-2.114l.854-.57a1.728 1.728 0 0 0 0-2.876l-.855-.57a2 2 0 0 1-.84-2.114l.163-.703a1.77 1.77 0 0 0-2.123-2.123l-.703.162a2 2 0 0 1-2.114-.84l-.57-.854a1.728 1.728 0 0 0-2.876 0l-.57.855a2 2 0 0 1-2.114.84Z"/>
|
||||
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="M14.75 12a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
3
assets/icons/signal.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 17v2m6-6v6m6-10v10m6-14v14"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 233 B |
@@ -1,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 19.25V13m0 0 2.5 2.5M12 13l-2.5 2.5m-2.125 3.75H4.75a2 2 0 0 1-2-2V5.75a2 2 0 0 1 2-2h4.18a2 2 0 0 1 1.664.89l1.11 1.665a1 1 0 0 0 .831.445h6.715a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2h-2.625"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M2 4.75A2.75 2.75 0 0 1 4.75 2h8.5A2.75 2.75 0 0 1 16 4.75V8h3.25A2.75 2.75 0 0 1 22 10.75v8.5A2.75 2.75 0 0 1 19.25 22h-8.5A2.75 2.75 0 0 1 8 19.25V16H4.75A2.75 2.75 0 0 1 2 13.25v-8.5ZM14.5 8V4.75c0-.69-.56-1.25-1.25-1.25h-8.5c-.69 0-1.25.56-1.25 1.25v5.991l.983-.644a2.75 2.75 0 0 1 3.033.012l.5.334A2.75 2.75 0 0 1 10.75 8h3.75ZM5 6.25a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0Zm8.39 6.292a.75.75 0 0 1 .766.027l2.8 1.8a.75.75 0 0 1 0 1.262l-2.8 1.8A.75.75 0 0 1 13 16.8v-3.6a.75.75 0 0 1 .39-.658Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 394 B After Width: | Height: | Size: 682 B |
3
assets/icons/warning.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 9.02v2.993M12 15h.01M10.277 3.99 3.275 15.998C2.499 17.328 3.458 19 4.998 19h14.004c1.54 0 2.5-1.671 1.723-3.002L13.723 3.99c-.77-1.32-2.677-1.32-3.447 0ZM12.25 15a.25.25 0 1 1-.5 0 .25.25 0 0 1 .5 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
136
assets/themes/catppuccin-frappe.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"id": "catppuccin-frappe",
|
||||
"name": "Catppuccin Frappé",
|
||||
"author": "Catppuccin",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"light": {
|
||||
"background": "#303446",
|
||||
"surface_background": "#292c3c",
|
||||
"elevated_surface_background": "#232634",
|
||||
"panel_background": "#303446",
|
||||
"overlay": "#c6d0f51a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#303446",
|
||||
"window_border": "#626880",
|
||||
"border": "#626880",
|
||||
"border_variant": "#51576d",
|
||||
"border_focused": "#8caaee",
|
||||
"border_selected": "#8caaee",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#414559",
|
||||
"ring": "#8caaee",
|
||||
"text": "#c6d0f5",
|
||||
"text_muted": "#b5bfe2",
|
||||
"text_placeholder": "#a5adce",
|
||||
"text_accent": "#8caaee",
|
||||
"icon": "#c6d0f5",
|
||||
"icon_muted": "#b5bfe2",
|
||||
"icon_accent": "#8caaee",
|
||||
"element_foreground": "#303446",
|
||||
"element_background": "#8caaee",
|
||||
"element_hover": "#8caaeee6",
|
||||
"element_active": "#7e99d6",
|
||||
"element_selected": "#7088be",
|
||||
"element_disabled": "#8caaee4d",
|
||||
"secondary_foreground": "#8caaee",
|
||||
"secondary_background": "#414559",
|
||||
"secondary_hover": "#8caaee1a",
|
||||
"secondary_active": "#51576d",
|
||||
"secondary_selected": "#51576d",
|
||||
"secondary_disabled": "#8caaee4d",
|
||||
"danger_foreground": "#303446",
|
||||
"danger_background": "#e78284",
|
||||
"danger_hover": "#e78284e6",
|
||||
"danger_active": "#d07576",
|
||||
"danger_selected": "#b96869",
|
||||
"danger_disabled": "#e782844d",
|
||||
"warning_foreground": "#303446",
|
||||
"warning_background": "#e5c890",
|
||||
"warning_hover": "#e5c890e6",
|
||||
"warning_active": "#ceb481",
|
||||
"warning_selected": "#b7a072",
|
||||
"warning_disabled": "#e5c8904d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#414559",
|
||||
"ghost_element_hover": "#c6d0f51a",
|
||||
"ghost_element_active": "#51576d",
|
||||
"ghost_element_selected": "#51576d",
|
||||
"ghost_element_disabled": "#c6d0f50d",
|
||||
"tab_inactive_background": "#414559",
|
||||
"tab_hover_background": "#51576d",
|
||||
"tab_active_background": "#626880",
|
||||
"scrollbar_thumb_background": "#c6d0f533",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f54d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#51576d",
|
||||
"drop_target_background": "#8caaee1a",
|
||||
"cursor": "#99d1db",
|
||||
"selection": "#99d1db40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#303446",
|
||||
"surface_background": "#292c3c",
|
||||
"elevated_surface_background": "#232634",
|
||||
"panel_background": "#303446",
|
||||
"overlay": "#c6d0f51a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#303446",
|
||||
"window_border": "#626880",
|
||||
"border": "#626880",
|
||||
"border_variant": "#51576d",
|
||||
"border_focused": "#8caaee",
|
||||
"border_selected": "#8caaee",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#414559",
|
||||
"ring": "#8caaee",
|
||||
"text": "#c6d0f5",
|
||||
"text_muted": "#b5bfe2",
|
||||
"text_placeholder": "#a5adce",
|
||||
"text_accent": "#8caaee",
|
||||
"icon": "#c6d0f5",
|
||||
"icon_muted": "#b5bfe2",
|
||||
"icon_accent": "#8caaee",
|
||||
"element_foreground": "#303446",
|
||||
"element_background": "#8caaee",
|
||||
"element_hover": "#8caaeee6",
|
||||
"element_active": "#7e99d6",
|
||||
"element_selected": "#7088be",
|
||||
"element_disabled": "#8caaee4d",
|
||||
"secondary_foreground": "#8caaee",
|
||||
"secondary_background": "#414559",
|
||||
"secondary_hover": "#8caaee1a",
|
||||
"secondary_active": "#51576d",
|
||||
"secondary_selected": "#51576d",
|
||||
"secondary_disabled": "#8caaee4d",
|
||||
"danger_foreground": "#303446",
|
||||
"danger_background": "#e78284",
|
||||
"danger_hover": "#e78284e6",
|
||||
"danger_active": "#d07576",
|
||||
"danger_selected": "#b96869",
|
||||
"danger_disabled": "#e782844d",
|
||||
"warning_foreground": "#303446",
|
||||
"warning_background": "#e5c890",
|
||||
"warning_hover": "#e5c890e6",
|
||||
"warning_active": "#ceb481",
|
||||
"warning_selected": "#b7a072",
|
||||
"warning_disabled": "#e5c8904d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#414559",
|
||||
"ghost_element_hover": "#c6d0f51a",
|
||||
"ghost_element_active": "#51576d",
|
||||
"ghost_element_selected": "#51576d",
|
||||
"ghost_element_disabled": "#c6d0f50d",
|
||||
"tab_inactive_background": "#414559",
|
||||
"tab_hover_background": "#51576d",
|
||||
"tab_active_background": "#626880",
|
||||
"scrollbar_thumb_background": "#c6d0f533",
|
||||
"scrollbar_thumb_hover_background": "#c6d0f54d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#51576d",
|
||||
"drop_target_background": "#8caaee1a",
|
||||
"cursor": "#99d1db",
|
||||
"selection": "#99d1db40"
|
||||
}
|
||||
}
|
||||
136
assets/themes/catppuccin-latte.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"id": "catppuccin-latte",
|
||||
"name": "Catppuccin Latte",
|
||||
"author": "Catppuccin",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"light": {
|
||||
"background": "#eff1f5",
|
||||
"surface_background": "#e6e9ef",
|
||||
"elevated_surface_background": "#dce0e8",
|
||||
"panel_background": "#eff1f5",
|
||||
"overlay": "#4c4f691a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#eff1f5",
|
||||
"window_border": "#acb0be",
|
||||
"border": "#acb0be",
|
||||
"border_variant": "#bcc0cc",
|
||||
"border_focused": "#1e66f5",
|
||||
"border_selected": "#1e66f5",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#ccd0da",
|
||||
"ring": "#1e66f5",
|
||||
"text": "#4c4f69",
|
||||
"text_muted": "#5c5f77",
|
||||
"text_placeholder": "#6c6f85",
|
||||
"text_accent": "#1e66f5",
|
||||
"icon": "#4c4f69",
|
||||
"icon_muted": "#5c5f77",
|
||||
"icon_accent": "#1e66f5",
|
||||
"element_foreground": "#eff1f5",
|
||||
"element_background": "#1e66f5",
|
||||
"element_hover": "#1e66f5e6",
|
||||
"element_active": "#1b5cdc",
|
||||
"element_selected": "#1852c3",
|
||||
"element_disabled": "#1e66f54d",
|
||||
"secondary_foreground": "#1e66f5",
|
||||
"secondary_background": "#e6e9ef",
|
||||
"secondary_hover": "#1e66f51a",
|
||||
"secondary_active": "#dce0e8",
|
||||
"secondary_selected": "#dce0e8",
|
||||
"secondary_disabled": "#1e66f54d",
|
||||
"danger_foreground": "#eff1f5",
|
||||
"danger_background": "#d20f39",
|
||||
"danger_hover": "#d20f39e6",
|
||||
"danger_active": "#bc0e33",
|
||||
"danger_selected": "#a60c2d",
|
||||
"danger_disabled": "#d20f394d",
|
||||
"warning_foreground": "#4c4f69",
|
||||
"warning_background": "#df8e1d",
|
||||
"warning_hover": "#df8e1de6",
|
||||
"warning_active": "#c9801a",
|
||||
"warning_selected": "#b47217",
|
||||
"warning_disabled": "#df8e1d4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e6e9ef",
|
||||
"ghost_element_hover": "#4c4f691a",
|
||||
"ghost_element_active": "#dce0e8",
|
||||
"ghost_element_selected": "#dce0e8",
|
||||
"ghost_element_disabled": "#4c4f690d",
|
||||
"tab_inactive_background": "#e6e9ef",
|
||||
"tab_hover_background": "#dce0e8",
|
||||
"tab_active_background": "#ccd0da",
|
||||
"scrollbar_thumb_background": "#4c4f6933",
|
||||
"scrollbar_thumb_hover_background": "#4c4f694d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#dce0e8",
|
||||
"drop_target_background": "#1e66f51a",
|
||||
"cursor": "#04a5e5",
|
||||
"selection": "#04a5e540"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#eff1f5",
|
||||
"surface_background": "#e6e9ef",
|
||||
"elevated_surface_background": "#dce0e8",
|
||||
"panel_background": "#eff1f5",
|
||||
"overlay": "#4c4f691a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#eff1f5",
|
||||
"window_border": "#acb0be",
|
||||
"border": "#acb0be",
|
||||
"border_variant": "#bcc0cc",
|
||||
"border_focused": "#1e66f5",
|
||||
"border_selected": "#1e66f5",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#ccd0da",
|
||||
"ring": "#1e66f5",
|
||||
"text": "#4c4f69",
|
||||
"text_muted": "#5c5f77",
|
||||
"text_placeholder": "#6c6f85",
|
||||
"text_accent": "#1e66f5",
|
||||
"icon": "#4c4f69",
|
||||
"icon_muted": "#5c5f77",
|
||||
"icon_accent": "#1e66f5",
|
||||
"element_foreground": "#eff1f5",
|
||||
"element_background": "#1e66f5",
|
||||
"element_hover": "#1e66f5e6",
|
||||
"element_active": "#1b5cdc",
|
||||
"element_selected": "#1852c3",
|
||||
"element_disabled": "#1e66f54d",
|
||||
"secondary_foreground": "#1e66f5",
|
||||
"secondary_background": "#e6e9ef",
|
||||
"secondary_hover": "#1e66f51a",
|
||||
"secondary_active": "#dce0e8",
|
||||
"secondary_selected": "#dce0e8",
|
||||
"secondary_disabled": "#1e66f54d",
|
||||
"danger_foreground": "#eff1f5",
|
||||
"danger_background": "#d20f39",
|
||||
"danger_hover": "#d20f39e6",
|
||||
"danger_active": "#bc0e33",
|
||||
"danger_selected": "#a60c2d",
|
||||
"danger_disabled": "#d20f394d",
|
||||
"warning_foreground": "#4c4f69",
|
||||
"warning_background": "#df8e1d",
|
||||
"warning_hover": "#df8e1de6",
|
||||
"warning_active": "#c9801a",
|
||||
"warning_selected": "#b47217",
|
||||
"warning_disabled": "#df8e1d4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e6e9ef",
|
||||
"ghost_element_hover": "#4c4f691a",
|
||||
"ghost_element_active": "#dce0e8",
|
||||
"ghost_element_selected": "#dce0e8",
|
||||
"ghost_element_disabled": "#4c4f690d",
|
||||
"tab_inactive_background": "#e6e9ef",
|
||||
"tab_hover_background": "#dce0e8",
|
||||
"tab_active_background": "#ccd0da",
|
||||
"scrollbar_thumb_background": "#4c4f6933",
|
||||
"scrollbar_thumb_hover_background": "#4c4f694d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#dce0e8",
|
||||
"drop_target_background": "#1e66f51a",
|
||||
"cursor": "#04a5e5",
|
||||
"selection": "#04a5e540"
|
||||
}
|
||||
}
|
||||
136
assets/themes/catppuccin-macchiato.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"id": "catppuccin-macchiato",
|
||||
"name": "Catppuccin Macchiato",
|
||||
"author": "Catppuccin",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"light": {
|
||||
"background": "#24273a",
|
||||
"surface_background": "#1e2030",
|
||||
"elevated_surface_background": "#181926",
|
||||
"panel_background": "#24273a",
|
||||
"overlay": "#cad3f51a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#24273a",
|
||||
"window_border": "#5b6078",
|
||||
"border": "#5b6078",
|
||||
"border_variant": "#494d64",
|
||||
"border_focused": "#8aadf4",
|
||||
"border_selected": "#8aadf4",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#363a4f",
|
||||
"ring": "#8aadf4",
|
||||
"text": "#cad3f5",
|
||||
"text_muted": "#b8c0e0",
|
||||
"text_placeholder": "#a5adcb",
|
||||
"text_accent": "#8aadf4",
|
||||
"icon": "#cad3f5",
|
||||
"icon_muted": "#b8c0e0",
|
||||
"icon_accent": "#8aadf4",
|
||||
"element_foreground": "#24273a",
|
||||
"element_background": "#8aadf4",
|
||||
"element_hover": "#8aadf4e6",
|
||||
"element_active": "#7c9cdc",
|
||||
"element_selected": "#6e8bc4",
|
||||
"element_disabled": "#8aadf44d",
|
||||
"secondary_foreground": "#8aadf4",
|
||||
"secondary_background": "#363a4f",
|
||||
"secondary_hover": "#8aadf41a",
|
||||
"secondary_active": "#494d64",
|
||||
"secondary_selected": "#494d64",
|
||||
"secondary_disabled": "#8aadf44d",
|
||||
"danger_foreground": "#24273a",
|
||||
"danger_background": "#ed8796",
|
||||
"danger_hover": "#ed8796e6",
|
||||
"danger_active": "#d57a87",
|
||||
"danger_selected": "#bd6d78",
|
||||
"danger_disabled": "#ed87964d",
|
||||
"warning_foreground": "#24273a",
|
||||
"warning_background": "#eed49f",
|
||||
"warning_hover": "#eed49fe6",
|
||||
"warning_active": "#d6bf8f",
|
||||
"warning_selected": "#beaa7f",
|
||||
"warning_disabled": "#eed49f4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#363a4f",
|
||||
"ghost_element_hover": "#cad3f51a",
|
||||
"ghost_element_active": "#494d64",
|
||||
"ghost_element_selected": "#494d64",
|
||||
"ghost_element_disabled": "#cad3f50d",
|
||||
"tab_inactive_background": "#363a4f",
|
||||
"tab_hover_background": "#494d64",
|
||||
"tab_active_background": "#5b6078",
|
||||
"scrollbar_thumb_background": "#cad3f533",
|
||||
"scrollbar_thumb_hover_background": "#cad3f54d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#494d64",
|
||||
"drop_target_background": "#8aadf41a",
|
||||
"cursor": "#91d7e3",
|
||||
"selection": "#91d7e340"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#24273a",
|
||||
"surface_background": "#1e2030",
|
||||
"elevated_surface_background": "#181926",
|
||||
"panel_background": "#24273a",
|
||||
"overlay": "#cad3f51a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#24273a",
|
||||
"window_border": "#5b6078",
|
||||
"border": "#5b6078",
|
||||
"border_variant": "#494d64",
|
||||
"border_focused": "#8aadf4",
|
||||
"border_selected": "#8aadf4",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#363a4f",
|
||||
"ring": "#8aadf4",
|
||||
"text": "#cad3f5",
|
||||
"text_muted": "#b8c0e0",
|
||||
"text_placeholder": "#a5adcb",
|
||||
"text_accent": "#8aadf4",
|
||||
"icon": "#cad3f5",
|
||||
"icon_muted": "#b8c0e0",
|
||||
"icon_accent": "#8aadf4",
|
||||
"element_foreground": "#24273a",
|
||||
"element_background": "#8aadf4",
|
||||
"element_hover": "#8aadf4e6",
|
||||
"element_active": "#7c9cdc",
|
||||
"element_selected": "#6e8bc4",
|
||||
"element_disabled": "#8aadf44d",
|
||||
"secondary_foreground": "#8aadf4",
|
||||
"secondary_background": "#363a4f",
|
||||
"secondary_hover": "#8aadf41a",
|
||||
"secondary_active": "#494d64",
|
||||
"secondary_selected": "#494d64",
|
||||
"secondary_disabled": "#8aadf44d",
|
||||
"danger_foreground": "#24273a",
|
||||
"danger_background": "#ed8796",
|
||||
"danger_hover": "#ed8796e6",
|
||||
"danger_active": "#d57a87",
|
||||
"danger_selected": "#bd6d78",
|
||||
"danger_disabled": "#ed87964d",
|
||||
"warning_foreground": "#24273a",
|
||||
"warning_background": "#eed49f",
|
||||
"warning_hover": "#eed49fe6",
|
||||
"warning_active": "#d6bf8f",
|
||||
"warning_selected": "#beaa7f",
|
||||
"warning_disabled": "#eed49f4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#363a4f",
|
||||
"ghost_element_hover": "#cad3f51a",
|
||||
"ghost_element_active": "#494d64",
|
||||
"ghost_element_selected": "#494d64",
|
||||
"ghost_element_disabled": "#cad3f50d",
|
||||
"tab_inactive_background": "#363a4f",
|
||||
"tab_hover_background": "#494d64",
|
||||
"tab_active_background": "#5b6078",
|
||||
"scrollbar_thumb_background": "#cad3f533",
|
||||
"scrollbar_thumb_hover_background": "#cad3f54d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#494d64",
|
||||
"drop_target_background": "#8aadf41a",
|
||||
"cursor": "#91d7e3",
|
||||
"selection": "#91d7e340"
|
||||
}
|
||||
}
|
||||
136
assets/themes/catppuccin-mocha.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"id": "catppuccin-mocha",
|
||||
"name": "Catppuccin Mocha",
|
||||
"author": "Catppuccin",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"light": {
|
||||
"background": "#1e1e2e",
|
||||
"surface_background": "#181825",
|
||||
"elevated_surface_background": "#11111b",
|
||||
"panel_background": "#1e1e2e",
|
||||
"overlay": "#cdd6f41a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#1e1e2e",
|
||||
"window_border": "#585b70",
|
||||
"border": "#585b70",
|
||||
"border_variant": "#45475a",
|
||||
"border_focused": "#89b4fa",
|
||||
"border_selected": "#89b4fa",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#313244",
|
||||
"ring": "#89b4fa",
|
||||
"text": "#cdd6f4",
|
||||
"text_muted": "#bac2de",
|
||||
"text_placeholder": "#a6adc8",
|
||||
"text_accent": "#89b4fa",
|
||||
"icon": "#cdd6f4",
|
||||
"icon_muted": "#bac2de",
|
||||
"icon_accent": "#89b4fa",
|
||||
"element_foreground": "#1e1e2e",
|
||||
"element_background": "#89b4fa",
|
||||
"element_hover": "#89b4fae6",
|
||||
"element_active": "#7ba2e1",
|
||||
"element_selected": "#6d90c8",
|
||||
"element_disabled": "#89b4fa4d",
|
||||
"secondary_foreground": "#89b4fa",
|
||||
"secondary_background": "#313244",
|
||||
"secondary_hover": "#89b4fa1a",
|
||||
"secondary_active": "#45475a",
|
||||
"secondary_selected": "#45475a",
|
||||
"secondary_disabled": "#89b4fa4d",
|
||||
"danger_foreground": "#1e1e2e",
|
||||
"danger_background": "#f38ba8",
|
||||
"danger_hover": "#f38ba8e6",
|
||||
"danger_active": "#db7d97",
|
||||
"danger_selected": "#c36f86",
|
||||
"danger_disabled": "#f38ba84d",
|
||||
"warning_foreground": "#1e1e2e",
|
||||
"warning_background": "#f9e2af",
|
||||
"warning_hover": "#f9e2afe6",
|
||||
"warning_active": "#e0cb9e",
|
||||
"warning_selected": "#c7b48d",
|
||||
"warning_disabled": "#f9e2af4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#313244",
|
||||
"ghost_element_hover": "#cdd6f41a",
|
||||
"ghost_element_active": "#45475a",
|
||||
"ghost_element_selected": "#45475a",
|
||||
"ghost_element_disabled": "#cdd6f50d",
|
||||
"tab_inactive_background": "#313244",
|
||||
"tab_hover_background": "#45475a",
|
||||
"tab_active_background": "#585b70",
|
||||
"scrollbar_thumb_background": "#cdd6f533",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f54d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#45475a",
|
||||
"drop_target_background": "#89b4fa1a",
|
||||
"cursor": "#89dceb",
|
||||
"selection": "#89dceb40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#1e1e2e",
|
||||
"surface_background": "#181825",
|
||||
"elevated_surface_background": "#11111b",
|
||||
"panel_background": "#1e1e2e",
|
||||
"overlay": "#cdd6f41a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#1e1e2e",
|
||||
"window_border": "#585b70",
|
||||
"border": "#585b70",
|
||||
"border_variant": "#45475a",
|
||||
"border_focused": "#89b4fa",
|
||||
"border_selected": "#89b4fa",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#313244",
|
||||
"ring": "#89b4fa",
|
||||
"text": "#cdd6f4",
|
||||
"text_muted": "#bac2de",
|
||||
"text_placeholder": "#a6adc8",
|
||||
"text_accent": "#89b4fa",
|
||||
"icon": "#cdd6f4",
|
||||
"icon_muted": "#bac2de",
|
||||
"icon_accent": "#89b4fa",
|
||||
"element_foreground": "#1e1e2e",
|
||||
"element_background": "#89b4fa",
|
||||
"element_hover": "#89b4fae6",
|
||||
"element_active": "#7ba2e1",
|
||||
"element_selected": "#6d90c8",
|
||||
"element_disabled": "#89b4fa4d",
|
||||
"secondary_foreground": "#89b4fa",
|
||||
"secondary_background": "#313244",
|
||||
"secondary_hover": "#89b4fa1a",
|
||||
"secondary_active": "#45475a",
|
||||
"secondary_selected": "#45475a",
|
||||
"secondary_disabled": "#89b4fa4d",
|
||||
"danger_foreground": "#1e1e2e",
|
||||
"danger_background": "#f38ba8",
|
||||
"danger_hover": "#f38ba8e6",
|
||||
"danger_active": "#db7d97",
|
||||
"danger_selected": "#c36f86",
|
||||
"danger_disabled": "#f38ba84d",
|
||||
"warning_foreground": "#1e1e2e",
|
||||
"warning_background": "#f9e2af",
|
||||
"warning_hover": "#f9e2afe6",
|
||||
"warning_active": "#e0cb9e",
|
||||
"warning_selected": "#c7b48d",
|
||||
"warning_disabled": "#f9e2af4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#313244",
|
||||
"ghost_element_hover": "#cdd6f41a",
|
||||
"ghost_element_active": "#45475a",
|
||||
"ghost_element_selected": "#45475a",
|
||||
"ghost_element_disabled": "#cdd6f50d",
|
||||
"tab_inactive_background": "#313244",
|
||||
"tab_hover_background": "#45475a",
|
||||
"tab_active_background": "#585b70",
|
||||
"scrollbar_thumb_background": "#cdd6f533",
|
||||
"scrollbar_thumb_hover_background": "#cdd6f54d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#45475a",
|
||||
"drop_target_background": "#89b4fa1a",
|
||||
"cursor": "#89dceb",
|
||||
"selection": "#89dceb40"
|
||||
}
|
||||
}
|
||||
136
assets/themes/flexoki.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"id": "flexoki",
|
||||
"name": "Flexoki",
|
||||
"author": "Steph Ango",
|
||||
"url": "https://stephango.com/flexoki",
|
||||
"light": {
|
||||
"background": "#FFFCF0",
|
||||
"surface_background": "#F2F0E5",
|
||||
"elevated_surface_background": "#E6E4D9",
|
||||
"panel_background": "#FFFCF0",
|
||||
"overlay": "#100F0F1a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#FFFCF0",
|
||||
"window_border": "#CECDC3",
|
||||
"border": "#CECDC3",
|
||||
"border_variant": "#DAD8CE",
|
||||
"border_focused": "#24837B",
|
||||
"border_selected": "#24837B",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#E6E4D9",
|
||||
"ring": "#24837B",
|
||||
"text": "#100F0F",
|
||||
"text_muted": "#6F6E69",
|
||||
"text_placeholder": "#878580",
|
||||
"text_accent": "#24837B",
|
||||
"icon": "#100F0F",
|
||||
"icon_muted": "#6F6E69",
|
||||
"icon_accent": "#24837B",
|
||||
"element_foreground": "#DDF1E4",
|
||||
"element_background": "#24837B",
|
||||
"element_hover": "#24837Be5",
|
||||
"element_active": "#20756E",
|
||||
"element_selected": "#1C6861",
|
||||
"element_disabled": "#24837B4c",
|
||||
"secondary_foreground": "#24837B",
|
||||
"secondary_background": "#E6E4D9",
|
||||
"secondary_hover": "#24837B1a",
|
||||
"secondary_active": "#DAD8CE",
|
||||
"secondary_selected": "#DAD8CE",
|
||||
"secondary_disabled": "#24837B4c",
|
||||
"danger_foreground": "#FFE1D5",
|
||||
"danger_background": "#AF3029",
|
||||
"danger_hover": "#AF3029e5",
|
||||
"danger_active": "#9E2B25",
|
||||
"danger_selected": "#8D2620",
|
||||
"danger_disabled": "#AF30294c",
|
||||
"warning_foreground": "#FFE7CE",
|
||||
"warning_background": "#BC5215",
|
||||
"warning_hover": "#BC5215e5",
|
||||
"warning_active": "#A94913",
|
||||
"warning_selected": "#964011",
|
||||
"warning_disabled": "#BC52154c",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#E6E4D9",
|
||||
"ghost_element_hover": "#100F0F1a",
|
||||
"ghost_element_active": "#DAD8CE",
|
||||
"ghost_element_selected": "#DAD8CE",
|
||||
"ghost_element_disabled": "#100F0F0d",
|
||||
"tab_inactive_background": "#E6E4D9",
|
||||
"tab_hover_background": "#DAD8CE",
|
||||
"tab_active_background": "#CECDC3",
|
||||
"scrollbar_thumb_background": "#100F0F33",
|
||||
"scrollbar_thumb_hover_background": "#100F0F4d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#DAD8CE",
|
||||
"drop_target_background": "#24837B1a",
|
||||
"cursor": "#205EA6",
|
||||
"selection": "#24837B40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#100F0F",
|
||||
"surface_background": "#1C1B1A",
|
||||
"elevated_surface_background": "#282726",
|
||||
"panel_background": "#100F0F",
|
||||
"overlay": "#FFFCF01a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#100F0F",
|
||||
"window_border": "#403E3C",
|
||||
"border": "#403E3C",
|
||||
"border_variant": "#343331",
|
||||
"border_focused": "#3AA99F",
|
||||
"border_selected": "#3AA99F",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#282726",
|
||||
"ring": "#3AA99F",
|
||||
"text": "#FFFCF0",
|
||||
"text_muted": "#878580",
|
||||
"text_placeholder": "#575653",
|
||||
"text_accent": "#3AA99F",
|
||||
"icon": "#FFFCF0",
|
||||
"icon_muted": "#878580",
|
||||
"icon_accent": "#3AA99F",
|
||||
"element_foreground": "#101F1D",
|
||||
"element_background": "#3AA99F",
|
||||
"element_hover": "#3AA99Fe5",
|
||||
"element_active": "#34988F",
|
||||
"element_selected": "#2F877F",
|
||||
"element_disabled": "#3AA99F4c",
|
||||
"secondary_foreground": "#3AA99F",
|
||||
"secondary_background": "#282726",
|
||||
"secondary_hover": "#3AA99F1a",
|
||||
"secondary_active": "#343331",
|
||||
"secondary_selected": "#343331",
|
||||
"secondary_disabled": "#3AA99F4c",
|
||||
"danger_foreground": "#261312",
|
||||
"danger_background": "#D14D41",
|
||||
"danger_hover": "#D14D41e5",
|
||||
"danger_active": "#BC453A",
|
||||
"danger_selected": "#A73D33",
|
||||
"danger_disabled": "#D14D414c",
|
||||
"warning_foreground": "#27180E",
|
||||
"warning_background": "#DA702C",
|
||||
"warning_hover": "#DA702Ce5",
|
||||
"warning_active": "#C46527",
|
||||
"warning_selected": "#AF5A22",
|
||||
"warning_disabled": "#DA702C4c",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#282726",
|
||||
"ghost_element_hover": "#FFFCF01a",
|
||||
"ghost_element_active": "#343331",
|
||||
"ghost_element_selected": "#343331",
|
||||
"ghost_element_disabled": "#FFFCF00d",
|
||||
"tab_inactive_background": "#282726",
|
||||
"tab_hover_background": "#343331",
|
||||
"tab_active_background": "#403E3C",
|
||||
"scrollbar_thumb_background": "#FFFCF033",
|
||||
"scrollbar_thumb_hover_background": "#FFFCF04d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#343331",
|
||||
"drop_target_background": "#3AA99F1a",
|
||||
"cursor": "#4385BE",
|
||||
"selection": "#3AA99F40"
|
||||
}
|
||||
}
|
||||
136
assets/themes/rose-pine-dawn.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"id": "rose-pine-dawn",
|
||||
"name": "Rosé Pine Dawn",
|
||||
"author": "Rosé Pine",
|
||||
"url": "https://rosepinetheme.com/",
|
||||
"light": {
|
||||
"background": "#faf4ed",
|
||||
"surface_background": "#fffaf3",
|
||||
"elevated_surface_background": "#f2e9e1",
|
||||
"panel_background": "#fffaf3",
|
||||
"overlay": "#5752791a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#faf4ed",
|
||||
"window_border": "#cecacd",
|
||||
"border": "#cecacd",
|
||||
"border_variant": "#dfdad9",
|
||||
"border_focused": "#286983",
|
||||
"border_selected": "#286983",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#f4ede8",
|
||||
"ring": "#286983",
|
||||
"text": "#575279",
|
||||
"text_muted": "#797593",
|
||||
"text_placeholder": "#9893a5",
|
||||
"text_accent": "#907aa9",
|
||||
"icon": "#575279",
|
||||
"icon_muted": "#797593",
|
||||
"icon_accent": "#907aa9",
|
||||
"element_foreground": "#faf4ed",
|
||||
"element_background": "#286983",
|
||||
"element_hover": "#286983e6",
|
||||
"element_active": "#245f76",
|
||||
"element_selected": "#205569",
|
||||
"element_disabled": "#2869834d",
|
||||
"secondary_foreground": "#286983",
|
||||
"secondary_background": "#f4ede8",
|
||||
"secondary_hover": "#2869831a",
|
||||
"secondary_active": "#dfdad9",
|
||||
"secondary_selected": "#dfdad9",
|
||||
"secondary_disabled": "#2869834d",
|
||||
"danger_foreground": "#faf4ed",
|
||||
"danger_background": "#b4637a",
|
||||
"danger_hover": "#b4637ae6",
|
||||
"danger_active": "#a2596e",
|
||||
"danger_selected": "#904f62",
|
||||
"danger_disabled": "#b4637a4d",
|
||||
"warning_foreground": "#faf4ed",
|
||||
"warning_background": "#ea9d34",
|
||||
"warning_hover": "#ea9d34e6",
|
||||
"warning_active": "#d38d2f",
|
||||
"warning_selected": "#bc7d2a",
|
||||
"warning_disabled": "#ea9d344d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#f4ede8",
|
||||
"ghost_element_hover": "#5752791a",
|
||||
"ghost_element_active": "#dfdad9",
|
||||
"ghost_element_selected": "#dfdad9",
|
||||
"ghost_element_disabled": "#5752790d",
|
||||
"tab_inactive_background": "#f4ede8",
|
||||
"tab_hover_background": "#dfdad9",
|
||||
"tab_active_background": "#cecacd",
|
||||
"scrollbar_thumb_background": "#57527933",
|
||||
"scrollbar_thumb_hover_background": "#5752794d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#dfdad9",
|
||||
"drop_target_background": "#2869831a",
|
||||
"cursor": "#56949f",
|
||||
"selection": "#56949f40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#faf4ed",
|
||||
"surface_background": "#fffaf3",
|
||||
"elevated_surface_background": "#f2e9e1",
|
||||
"panel_background": "#fffaf3",
|
||||
"overlay": "#5752791a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#faf4ed",
|
||||
"window_border": "#cecacd",
|
||||
"border": "#cecacd",
|
||||
"border_variant": "#dfdad9",
|
||||
"border_focused": "#286983",
|
||||
"border_selected": "#286983",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#f4ede8",
|
||||
"ring": "#286983",
|
||||
"text": "#575279",
|
||||
"text_muted": "#797593",
|
||||
"text_placeholder": "#9893a5",
|
||||
"text_accent": "#907aa9",
|
||||
"icon": "#575279",
|
||||
"icon_muted": "#797593",
|
||||
"icon_accent": "#907aa9",
|
||||
"element_foreground": "#faf4ed",
|
||||
"element_background": "#286983",
|
||||
"element_hover": "#286983e6",
|
||||
"element_active": "#245f76",
|
||||
"element_selected": "#205569",
|
||||
"element_disabled": "#2869834d",
|
||||
"secondary_foreground": "#286983",
|
||||
"secondary_background": "#f4ede8",
|
||||
"secondary_hover": "#2869831a",
|
||||
"secondary_active": "#dfdad9",
|
||||
"secondary_selected": "#dfdad9",
|
||||
"secondary_disabled": "#2869834d",
|
||||
"danger_foreground": "#faf4ed",
|
||||
"danger_background": "#b4637a",
|
||||
"danger_hover": "#b4637ae6",
|
||||
"danger_active": "#a2596e",
|
||||
"danger_selected": "#904f62",
|
||||
"danger_disabled": "#b4637a4d",
|
||||
"warning_foreground": "#faf4ed",
|
||||
"warning_background": "#ea9d34",
|
||||
"warning_hover": "#ea9d34e6",
|
||||
"warning_active": "#d38d2f",
|
||||
"warning_selected": "#bc7d2a",
|
||||
"warning_disabled": "#ea9d344d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#f4ede8",
|
||||
"ghost_element_hover": "#5752791a",
|
||||
"ghost_element_active": "#dfdad9",
|
||||
"ghost_element_selected": "#dfdad9",
|
||||
"ghost_element_disabled": "#5752790d",
|
||||
"tab_inactive_background": "#f4ede8",
|
||||
"tab_hover_background": "#dfdad9",
|
||||
"tab_active_background": "#cecacd",
|
||||
"scrollbar_thumb_background": "#57527933",
|
||||
"scrollbar_thumb_hover_background": "#5752794d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#dfdad9",
|
||||
"drop_target_background": "#2869831a",
|
||||
"cursor": "#56949f",
|
||||
"selection": "#56949f40"
|
||||
}
|
||||
}
|
||||
136
assets/themes/rose-pine-moon.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"id": "rose-pine-moon",
|
||||
"name": "Rosé Pine Moon",
|
||||
"author": "Rosé Pine",
|
||||
"url": "https://rosepinetheme.com/",
|
||||
"light": {
|
||||
"background": "#232136",
|
||||
"surface_background": "#2a273f",
|
||||
"elevated_surface_background": "#393552",
|
||||
"panel_background": "#2a273f",
|
||||
"overlay": "#e0def41a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#232136",
|
||||
"window_border": "#56526e",
|
||||
"border": "#56526e",
|
||||
"border_variant": "#44415a",
|
||||
"border_focused": "#3e8fb0",
|
||||
"border_selected": "#3e8fb0",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#2a283e",
|
||||
"ring": "#3e8fb0",
|
||||
"text": "#e0def4",
|
||||
"text_muted": "#908caa",
|
||||
"text_placeholder": "#6e6a86",
|
||||
"text_accent": "#c4a7e7",
|
||||
"icon": "#e0def4",
|
||||
"icon_muted": "#908caa",
|
||||
"icon_accent": "#c4a7e7",
|
||||
"element_foreground": "#232136",
|
||||
"element_background": "#3e8fb0",
|
||||
"element_hover": "#3e8fb0e6",
|
||||
"element_active": "#38809d",
|
||||
"element_selected": "#32718a",
|
||||
"element_disabled": "#3e8fb04d",
|
||||
"secondary_foreground": "#3e8fb0",
|
||||
"secondary_background": "#2a283e",
|
||||
"secondary_hover": "#3e8fb01a",
|
||||
"secondary_active": "#44415a",
|
||||
"secondary_selected": "#44415a",
|
||||
"secondary_disabled": "#3e8fb04d",
|
||||
"danger_foreground": "#232136",
|
||||
"danger_background": "#eb6f92",
|
||||
"danger_hover": "#eb6f92e6",
|
||||
"danger_active": "#d46483",
|
||||
"danger_selected": "#bd5974",
|
||||
"danger_disabled": "#eb6f924d",
|
||||
"warning_foreground": "#232136",
|
||||
"warning_background": "#f6c177",
|
||||
"warning_hover": "#f6c177e6",
|
||||
"warning_active": "#ddae6b",
|
||||
"warning_selected": "#c49b5f",
|
||||
"warning_disabled": "#f6c1774d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#2a283e",
|
||||
"ghost_element_hover": "#e0def41a",
|
||||
"ghost_element_active": "#44415a",
|
||||
"ghost_element_selected": "#44415a",
|
||||
"ghost_element_disabled": "#e0def40d",
|
||||
"tab_inactive_background": "#2a283e",
|
||||
"tab_hover_background": "#44415a",
|
||||
"tab_active_background": "#56526e",
|
||||
"scrollbar_thumb_background": "#e0def433",
|
||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#44415a",
|
||||
"drop_target_background": "#3e8fb01a",
|
||||
"cursor": "#9ccfd8",
|
||||
"selection": "#9ccfd840"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#232136",
|
||||
"surface_background": "#2a273f",
|
||||
"elevated_surface_background": "#393552",
|
||||
"panel_background": "#2a273f",
|
||||
"overlay": "#e0def41a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#232136",
|
||||
"window_border": "#56526e",
|
||||
"border": "#56526e",
|
||||
"border_variant": "#44415a",
|
||||
"border_focused": "#3e8fb0",
|
||||
"border_selected": "#3e8fb0",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#2a283e",
|
||||
"ring": "#3e8fb0",
|
||||
"text": "#e0def4",
|
||||
"text_muted": "#908caa",
|
||||
"text_placeholder": "#6e6a86",
|
||||
"text_accent": "#c4a7e7",
|
||||
"icon": "#e0def4",
|
||||
"icon_muted": "#908caa",
|
||||
"icon_accent": "#c4a7e7",
|
||||
"element_foreground": "#232136",
|
||||
"element_background": "#3e8fb0",
|
||||
"element_hover": "#3e8fb0e6",
|
||||
"element_active": "#38809d",
|
||||
"element_selected": "#32718a",
|
||||
"element_disabled": "#3e8fb04d",
|
||||
"secondary_foreground": "#3e8fb0",
|
||||
"secondary_background": "#2a283e",
|
||||
"secondary_hover": "#3e8fb01a",
|
||||
"secondary_active": "#44415a",
|
||||
"secondary_selected": "#44415a",
|
||||
"secondary_disabled": "#3e8fb04d",
|
||||
"danger_foreground": "#232136",
|
||||
"danger_background": "#eb6f92",
|
||||
"danger_hover": "#eb6f92e6",
|
||||
"danger_active": "#d46483",
|
||||
"danger_selected": "#bd5974",
|
||||
"danger_disabled": "#eb6f924d",
|
||||
"warning_foreground": "#232136",
|
||||
"warning_background": "#f6c177",
|
||||
"warning_hover": "#f6c177e6",
|
||||
"warning_active": "#ddae6b",
|
||||
"warning_selected": "#c49b5f",
|
||||
"warning_disabled": "#f6c1774d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#2a283e",
|
||||
"ghost_element_hover": "#e0def41a",
|
||||
"ghost_element_active": "#44415a",
|
||||
"ghost_element_selected": "#44415a",
|
||||
"ghost_element_disabled": "#e0def40d",
|
||||
"tab_inactive_background": "#2a283e",
|
||||
"tab_hover_background": "#44415a",
|
||||
"tab_active_background": "#56526e",
|
||||
"scrollbar_thumb_background": "#e0def433",
|
||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#44415a",
|
||||
"drop_target_background": "#3e8fb01a",
|
||||
"cursor": "#9ccfd8",
|
||||
"selection": "#9ccfd840"
|
||||
}
|
||||
}
|
||||
136
assets/themes/rose-pine.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"id": "rose-pine",
|
||||
"name": "Rosé Pine",
|
||||
"author": "Rosé Pine",
|
||||
"url": "https://rosepinetheme.com/",
|
||||
"light": {
|
||||
"background": "#191724",
|
||||
"surface_background": "#1f1d2e",
|
||||
"elevated_surface_background": "#26233a",
|
||||
"panel_background": "#1f1d2e",
|
||||
"overlay": "#e0def41a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#191724",
|
||||
"window_border": "#524f67",
|
||||
"border": "#524f67",
|
||||
"border_variant": "#403d52",
|
||||
"border_focused": "#31748f",
|
||||
"border_selected": "#31748f",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#21202e",
|
||||
"ring": "#31748f",
|
||||
"text": "#e0def4",
|
||||
"text_muted": "#908caa",
|
||||
"text_placeholder": "#6e6a86",
|
||||
"text_accent": "#c4a7e7",
|
||||
"icon": "#e0def4",
|
||||
"icon_muted": "#908caa",
|
||||
"icon_accent": "#c4a7e7",
|
||||
"element_foreground": "#191724",
|
||||
"element_background": "#31748f",
|
||||
"element_hover": "#31748fe6",
|
||||
"element_active": "#2c6980",
|
||||
"element_selected": "#275e71",
|
||||
"element_disabled": "#31748f4d",
|
||||
"secondary_foreground": "#31748f",
|
||||
"secondary_background": "#21202e",
|
||||
"secondary_hover": "#31748f1a",
|
||||
"secondary_active": "#403d52",
|
||||
"secondary_selected": "#403d52",
|
||||
"secondary_disabled": "#31748f4d",
|
||||
"danger_foreground": "#191724",
|
||||
"danger_background": "#eb6f92",
|
||||
"danger_hover": "#eb6f92e6",
|
||||
"danger_active": "#d46483",
|
||||
"danger_selected": "#bd5974",
|
||||
"danger_disabled": "#eb6f924d",
|
||||
"warning_foreground": "#191724",
|
||||
"warning_background": "#f6c177",
|
||||
"warning_hover": "#f6c177e6",
|
||||
"warning_active": "#ddae6b",
|
||||
"warning_selected": "#c49b5f",
|
||||
"warning_disabled": "#f6c1774d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#21202e",
|
||||
"ghost_element_hover": "#e0def41a",
|
||||
"ghost_element_active": "#403d52",
|
||||
"ghost_element_selected": "#403d52",
|
||||
"ghost_element_disabled": "#e0def40d",
|
||||
"tab_inactive_background": "#21202e",
|
||||
"tab_hover_background": "#403d52",
|
||||
"tab_active_background": "#524f67",
|
||||
"scrollbar_thumb_background": "#e0def433",
|
||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#403d52",
|
||||
"drop_target_background": "#31748f1a",
|
||||
"cursor": "#9ccfd8",
|
||||
"selection": "#9ccfd840"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#191724",
|
||||
"surface_background": "#1f1d2e",
|
||||
"elevated_surface_background": "#26233a",
|
||||
"panel_background": "#1f1d2e",
|
||||
"overlay": "#e0def41a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#191724",
|
||||
"window_border": "#524f67",
|
||||
"border": "#524f67",
|
||||
"border_variant": "#403d52",
|
||||
"border_focused": "#31748f",
|
||||
"border_selected": "#31748f",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#21202e",
|
||||
"ring": "#31748f",
|
||||
"text": "#e0def4",
|
||||
"text_muted": "#908caa",
|
||||
"text_placeholder": "#6e6a86",
|
||||
"text_accent": "#c4a7e7",
|
||||
"icon": "#e0def4",
|
||||
"icon_muted": "#908caa",
|
||||
"icon_accent": "#c4a7e7",
|
||||
"element_foreground": "#191724",
|
||||
"element_background": "#31748f",
|
||||
"element_hover": "#31748fe6",
|
||||
"element_active": "#2c6980",
|
||||
"element_selected": "#275e71",
|
||||
"element_disabled": "#31748f4d",
|
||||
"secondary_foreground": "#31748f",
|
||||
"secondary_background": "#21202e",
|
||||
"secondary_hover": "#31748f1a",
|
||||
"secondary_active": "#403d52",
|
||||
"secondary_selected": "#403d52",
|
||||
"secondary_disabled": "#31748f4d",
|
||||
"danger_foreground": "#191724",
|
||||
"danger_background": "#eb6f92",
|
||||
"danger_hover": "#eb6f92e6",
|
||||
"danger_active": "#d46483",
|
||||
"danger_selected": "#bd5974",
|
||||
"danger_disabled": "#eb6f924d",
|
||||
"warning_foreground": "#191724",
|
||||
"warning_background": "#f6c177",
|
||||
"warning_hover": "#f6c177e6",
|
||||
"warning_active": "#ddae6b",
|
||||
"warning_selected": "#c49b5f",
|
||||
"warning_disabled": "#f6c1774d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#21202e",
|
||||
"ghost_element_hover": "#e0def41a",
|
||||
"ghost_element_active": "#403d52",
|
||||
"ghost_element_selected": "#403d52",
|
||||
"ghost_element_disabled": "#e0def40d",
|
||||
"tab_inactive_background": "#21202e",
|
||||
"tab_hover_background": "#403d52",
|
||||
"tab_active_background": "#524f67",
|
||||
"scrollbar_thumb_background": "#e0def433",
|
||||
"scrollbar_thumb_hover_background": "#e0def44d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#403d52",
|
||||
"drop_target_background": "#31748f1a",
|
||||
"cursor": "#9ccfd8",
|
||||
"selection": "#9ccfd840"
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
[package]
|
||||
name = "coop"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "coop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ui = { path = "../ui" }
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
chats = { path = "../chats" }
|
||||
|
||||
gpui.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
itertools.workspace = true
|
||||
dirs.workspace = true
|
||||
rust-embed.workspace = true
|
||||
log.workspace = true
|
||||
smol.workspace = true
|
||||
oneshot.workspace = true
|
||||
|
||||
rustls = "0.23.23"
|
||||
futures= "0.3"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
@@ -1,27 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../assets"]
|
||||
pub struct Assets;
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
|
||||
Self::get(path)
|
||||
.map(|f| Some(f.data))
|
||||
.ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path))
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
Ok(Self::iter()
|
||||
.filter_map(|p| {
|
||||
if p.starts_with(path) {
|
||||
Some(p.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
use asset::Assets;
|
||||
use chats::registry::ChatRegistry;
|
||||
use common::{
|
||||
constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID},
|
||||
profile::NostrProfile,
|
||||
};
|
||||
use futures::{select, FutureExt};
|
||||
use gpui::{
|
||||
actions, px, size, App, AppContext, Application, AsyncApp, 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 log::{error, info};
|
||||
use nostr_sdk::{
|
||||
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, Metadata, PublicKey,
|
||||
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions,
|
||||
};
|
||||
use nostr_sdk::{prelude::NostrEventsDatabaseExt, FromBech32, SubscriptionId};
|
||||
use smol::Timer;
|
||||
use state::{get_client, initialize_client};
|
||||
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
|
||||
use ui::{theme::Theme, Root};
|
||||
use views::{app, onboarding, startup};
|
||||
|
||||
mod asset;
|
||||
mod views;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Signal {
|
||||
/// Receive event
|
||||
Event(Event),
|
||||
/// Receive EOSE
|
||||
Eose,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Fix crash on startup
|
||||
// TODO: why this is needed?
|
||||
_ = rustls::crypto::ring::default_provider().install_default();
|
||||
// Enable logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(2048);
|
||||
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
|
||||
|
||||
// Initialize nostr client
|
||||
let client = initialize_client();
|
||||
|
||||
// Initialize application
|
||||
let app = Application::new()
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
|
||||
|
||||
// Connect to default relays
|
||||
app.background_executor()
|
||||
.spawn(async {
|
||||
_ = client.add_relay("wss://relay.damus.io/").await;
|
||||
_ = client.add_relay("wss://relay.primal.net/").await;
|
||||
_ = client.add_relay("wss://user.kindpag.es/").await;
|
||||
_ = client.add_relay("wss://purplepag.es/").await;
|
||||
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
|
||||
_ = client.connect().await
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle batch metadata
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
const BATCH_SIZE: usize = 20;
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(200);
|
||||
|
||||
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(client, mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
_ = timeout => {
|
||||
if !batch.is_empty() {
|
||||
sync_metadata(client, mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle notifications
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
let rng_keys = Keys::generate();
|
||||
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
if let RelayPoolNotification::Message { message, .. } = notification {
|
||||
match message {
|
||||
RelayMessage::Event {
|
||||
event,
|
||||
subscription_id,
|
||||
} => {
|
||||
match event.kind {
|
||||
Kind::GiftWrap => {
|
||||
if let Ok(gift) = client.unwrap_gift_wrap(&event).await {
|
||||
let mut pubkeys = vec![];
|
||||
|
||||
// Sign the rumor with the generated keys,
|
||||
// this event will be used for internal only,
|
||||
// and NEVER send to relays.
|
||||
if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) {
|
||||
pubkeys.extend(event.tags.public_keys());
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
// Save the event to the database, use for query directly.
|
||||
if let Err(e) =
|
||||
client.database().save_event(&event).await
|
||||
{
|
||||
error!("Failed to save event: {}", e);
|
||||
}
|
||||
|
||||
// Send all pubkeys to the batch
|
||||
if let Err(e) = batch_tx.send(pubkeys).await {
|
||||
error!("Failed to send pubkeys to batch: {}", e)
|
||||
}
|
||||
|
||||
// Send this event to the GPUI
|
||||
if new_id == *subscription_id {
|
||||
if let Err(e) =
|
||||
event_tx.send(Signal::Event(event)).await
|
||||
{
|
||||
error!("Failed to send event to GPUI: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::ContactList => {
|
||||
let pubkeys =
|
||||
event.tags.public_keys().copied().collect::<HashSet<_>>();
|
||||
sync_metadata(client, pubkeys).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
||||
if all_id == *subscription_id {
|
||||
if let Err(e) = event_tx.send(Signal::Eose).await {
|
||||
error!("Failed to send eose: {}", e)
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle re-open window
|
||||
app.on_reopen(move |cx| {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(signer) = client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
let metadata =
|
||||
if let Ok(Some(metadata)) = client.database().metadata(public_key).await {
|
||||
metadata
|
||||
} else {
|
||||
Metadata::new()
|
||||
};
|
||||
|
||||
_ = tx.send(Some(NostrProfile::new(public_key, metadata)));
|
||||
} else {
|
||||
_ = tx.send(None);
|
||||
}
|
||||
} else {
|
||||
_ = tx.send(None);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|mut cx| async move {
|
||||
if let Ok(result) = rx.await {
|
||||
_ = restore_window(result, &mut cx).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
app.run(move |cx| {
|
||||
// Initialize chat global state
|
||||
chats::registry::init(cx);
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
// Register the `quit` function with CMD+Q
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
|
||||
// Open window with default options
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
None,
|
||||
size(px(900.0), px(680.0)),
|
||||
cx,
|
||||
))),
|
||||
#[cfg(target_os = "linux")]
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
#[cfg(target_os = "linux")]
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
kind: WindowKind::Normal,
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
window.set_window_title(APP_NAME);
|
||||
window.set_app_id(APP_ID);
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
let handle = window.window_handle();
|
||||
let root = cx.new(|cx| Root::new(startup::init(window, cx).into(), window, cx));
|
||||
|
||||
let task = cx.read_credentials(KEYRING_SERVICE);
|
||||
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
|
||||
|
||||
// Read credential in OS Keyring
|
||||
cx.background_spawn(async {
|
||||
let profile = if let Ok(Some((npub, secret))) = task.await {
|
||||
let public_key = PublicKey::from_bech32(&npub).unwrap();
|
||||
let secret_hex = String::from_utf8(secret).unwrap();
|
||||
let keys = Keys::parse(&secret_hex).unwrap();
|
||||
|
||||
// Update nostr signer
|
||||
_ = client.set_signer(keys).await;
|
||||
|
||||
// Get user's metadata
|
||||
let metadata = if let Ok(Some(metadata)) =
|
||||
client.database().metadata(public_key).await
|
||||
{
|
||||
metadata
|
||||
} else {
|
||||
Metadata::new()
|
||||
};
|
||||
|
||||
Some(NostrProfile::new(public_key, metadata))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
_ = tx.send(profile)
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Set root view based on credential status
|
||||
cx.spawn(|mut cx| async move {
|
||||
if let Ok(Some(profile)) = rx.await {
|
||||
_ = cx.update_window(handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(app::init(profile, window, cx).into(), window, cx)
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_ = cx.update_window(handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(onboarding::init(window, cx).into(), window, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
while let Ok(signal) = event_rx.recv().await {
|
||||
cx.update(|cx| {
|
||||
match signal {
|
||||
Signal::Eose => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
|
||||
}
|
||||
}
|
||||
Signal::Event(event) => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.push_message(event, cx))
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
root
|
||||
},
|
||||
)
|
||||
.expect("System error. Please re-open the app.");
|
||||
});
|
||||
}
|
||||
|
||||
async fn sync_metadata(client: &Client, buffer: HashSet<PublicKey>) {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let filter = Filter::new()
|
||||
.authors(buffer.iter().cloned())
|
||||
.kind(Kind::Metadata)
|
||||
.limit(buffer.len());
|
||||
|
||||
if let Err(e) = client.subscribe(filter, Some(opts)).await {
|
||||
error!("Subscribe error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> anyhow::Result<()> {
|
||||
let opts = cx
|
||||
.update(|cx| WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
None,
|
||||
size(px(900.0), px(680.0)),
|
||||
cx,
|
||||
))),
|
||||
#[cfg(target_os = "linux")]
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
#[cfg(target_os = "linux")]
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
kind: WindowKind::Normal,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("Failed to set window options.");
|
||||
|
||||
if let Some(profile) = profile {
|
||||
_ = cx.open_window(opts, |window, cx| {
|
||||
window.set_window_title(APP_NAME);
|
||||
window.set_app_id(APP_ID);
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| Root::new(app::init(profile, window, cx).into(), window, cx))
|
||||
});
|
||||
} else {
|
||||
_ = cx.open_window(opts, |window, cx| {
|
||||
window.set_window_title(APP_NAME);
|
||||
window.set_app_id(APP_ID);
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| Root::new(onboarding::init(window, cx).into(), window, cx))
|
||||
});
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut App) {
|
||||
info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
use common::profile::NostrProfile;
|
||||
use gpui::{
|
||||
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
|
||||
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
|
||||
StyledImage, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use state::get_client;
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::{dock::DockPlacement, DockArea, DockItem},
|
||||
popup_menu::PopupMenuExt,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme, Appearance, Theme},
|
||||
ContextModal, Icon, IconName, Root, Sizable, TitleBar,
|
||||
};
|
||||
|
||||
use super::{chat, contacts, onboarding, profile, relays::Relays, settings, sidebar, welcome};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum PanelKind {
|
||||
Room(u64),
|
||||
Profile,
|
||||
Contacts,
|
||||
Settings,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct AddPanel {
|
||||
panel: PanelKind,
|
||||
position: DockPlacement,
|
||||
}
|
||||
|
||||
impl AddPanel {
|
||||
pub fn new(panel: PanelKind, position: DockPlacement) -> Self {
|
||||
Self { panel, position }
|
||||
}
|
||||
}
|
||||
|
||||
impl_internal_actions!(dock, [AddPanel]);
|
||||
actions!(account, [Logout]);
|
||||
|
||||
pub fn init(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<AppView> {
|
||||
AppView::new(account, window, cx)
|
||||
}
|
||||
|
||||
pub struct AppView {
|
||||
account: NostrProfile,
|
||||
relays: Entity<Option<Vec<String>>>,
|
||||
dock: Entity<DockArea>,
|
||||
}
|
||||
|
||||
impl AppView {
|
||||
pub fn new(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
// Initialize dock layout
|
||||
let dock = cx.new(|cx| DockArea::new(window, cx));
|
||||
let weak_dock = dock.downgrade();
|
||||
|
||||
// Initialize left dock
|
||||
let left_panel = DockItem::panel(Arc::new(sidebar::init(window, cx)));
|
||||
|
||||
// Initial central dock
|
||||
let center_panel = DockItem::split_with_sizes(
|
||||
Axis::Vertical,
|
||||
vec![DockItem::tabs(
|
||||
vec![Arc::new(welcome::init(window, cx))],
|
||||
None,
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
)],
|
||||
vec![None],
|
||||
&weak_dock,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Set default dock layout with left and central docks
|
||||
_ = weak_dock.update(cx, |view, cx| {
|
||||
view.set_left_dock(left_panel, Some(px(240.)), true, window, cx);
|
||||
view.set_center(center_panel, window, cx);
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let public_key = account.public_key();
|
||||
let relays = cx.new(|_| None);
|
||||
let async_relays = relays.downgrade();
|
||||
|
||||
// Check user's messaging relays and determine user is ready for NIP17 or not.
|
||||
// If not, show the setup modal and instruct user setup inbox relays
|
||||
let client = get_client();
|
||||
let window_handle = window.window_handle();
|
||||
let (tx, rx) = oneshot::channel::<Option<Vec<String>>>();
|
||||
|
||||
let this = Self {
|
||||
account,
|
||||
relays,
|
||||
dock,
|
||||
};
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let relays = if let Ok(events) = client.database().query(filter).await {
|
||||
if let Some(event) = events.first_owned() {
|
||||
Some(
|
||||
event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| match t {
|
||||
TagStandard::Relay(url) => Some(url.to_string()),
|
||||
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
_ = tx.send(relays);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(result) = rx.await {
|
||||
if let Some(relays) = result {
|
||||
_ = cx.update(|cx| {
|
||||
_ = async_relays.update(cx, |this, cx| {
|
||||
*this = Some(relays);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
this.update(cx, |this: &mut Self, cx| {
|
||||
this.render_setup_relays(window, cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn render_setup_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = cx.new(|cx| Relays::new(None, window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, window, cx| {
|
||||
let is_loading = relays.read(cx).loading();
|
||||
|
||||
this.keyboard(false)
|
||||
.closable(false)
|
||||
.width(px(420.))
|
||||
.title("Your Messaging Relays are not configured")
|
||||
.child(relays.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("update_inbox_relays_btn")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_loading)
|
||||
.on_click(window.listener_for(&relays, |this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn render_edit_relay(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let view = cx.new(|cx| Relays::new(relays, window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, window, cx| {
|
||||
let is_loading = view.read(cx).loading();
|
||||
|
||||
this.width(px(420.))
|
||||
.title("Edit your Messaging Relays")
|
||||
.child(view.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("update_inbox_relays_btn")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_loading)
|
||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn render_appearance_button(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
Button::new("appearance")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.map(|this| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
this.icon(IconName::Sun)
|
||||
} else {
|
||||
this.icon(IconName::Moon)
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
Theme::change(Appearance::Light, Some(window), cx);
|
||||
} else {
|
||||
Theme::change(Appearance::Dark, Some(window), cx);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_relays_button(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
Button::new("relays")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::Relays)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.render_edit_relay(window, cx);
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_account(&self) -> impl IntoElement {
|
||||
Button::new("account")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.reverse()
|
||||
.icon(Icon::new(IconName::ChevronDownSmall))
|
||||
.child(
|
||||
img(self.account.avatar())
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
.popup_menu(move |this, _, _cx| {
|
||||
this.menu(
|
||||
"Profile",
|
||||
Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Contacts",
|
||||
Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Settings",
|
||||
Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)),
|
||||
)
|
||||
.separator()
|
||||
.menu("Change account", Box::new(Logout))
|
||||
})
|
||||
}
|
||||
|
||||
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match &action.panel {
|
||||
PanelKind::Room(id) => match chat::init(id, window, cx) {
|
||||
Ok(panel) => {
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
Err(e) => window.push_notification(e.to_string(), cx),
|
||||
},
|
||||
PanelKind::Profile => {
|
||||
let panel = Arc::new(profile::init(self.account.clone(), window, cx));
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
PanelKind::Contacts => {
|
||||
let panel = Arc::new(contacts::init(window, cx));
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
PanelKind::Settings => {
|
||||
let panel = Arc::new(settings::init(window, cx));
|
||||
|
||||
self.dock.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(panel, action.position, window, cx);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.background_spawn(async move { get_client().reset().await })
|
||||
.detach();
|
||||
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(onboarding::init(window, cx).into(), window, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AppView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
// Main
|
||||
.child(
|
||||
TitleBar::new()
|
||||
// Left side
|
||||
.child(div())
|
||||
// Right side
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(self.render_appearance_button(window, cx))
|
||||
.child(self.render_relays_button(window, cx))
|
||||
.child(self.render_account()),
|
||||
),
|
||||
)
|
||||
.child(self.dock.clone())
|
||||
.child(div().absolute().top_8().children(notification_layer))
|
||||
.children(modal_layer)
|
||||
.on_action(cx.listener(Self::on_panel_action))
|
||||
.on_action(cx.listener(Self::on_logout_action))
|
||||
}
|
||||
}
|
||||
@@ -1,854 +0,0 @@
|
||||
use anyhow::anyhow;
|
||||
use async_utility::task::spawn;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{
|
||||
constants::IMAGE_SERVICE,
|
||||
last_seen::LastSeen,
|
||||
profile::NostrProfile,
|
||||
utils::{compare, nip96_upload},
|
||||
};
|
||||
use gpui::{
|
||||
div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext,
|
||||
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render,
|
||||
SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, WeakEntity,
|
||||
Window,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use state::get_client;
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::{InputEvent, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, ContextModal, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
const ALERT: &str =
|
||||
"This conversation is private. Only members of this chat can see each other's messages.";
|
||||
|
||||
pub fn init(
|
||||
id: &u64,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Result<Arc<Entity<Chat>>, anyhow::Error> {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
if let Some(room) = chats.read(cx).get(id, cx) {
|
||||
Ok(Arc::new(Chat::new(id, &room, window, cx)))
|
||||
} else {
|
||||
Err(anyhow!("Chat room is not exist"))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Chat Registry is not initialized"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct ParsedMessage {
|
||||
avatar: SharedString,
|
||||
display_name: SharedString,
|
||||
created_at: SharedString,
|
||||
content: SharedString,
|
||||
}
|
||||
|
||||
impl ParsedMessage {
|
||||
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
|
||||
let avatar = profile.avatar().into();
|
||||
let display_name = profile.name().into();
|
||||
let content = SharedString::new(content);
|
||||
let created_at = LastSeen(created_at).human_readable();
|
||||
|
||||
Self {
|
||||
avatar,
|
||||
display_name,
|
||||
created_at,
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum Message {
|
||||
User(Box<ParsedMessage>),
|
||||
System(SharedString),
|
||||
Placeholder,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn new(message: ParsedMessage) -> Self {
|
||||
Self::User(Box::new(message))
|
||||
}
|
||||
|
||||
pub fn system(content: SharedString) -> Self {
|
||||
Self::System(content)
|
||||
}
|
||||
|
||||
pub fn placeholder() -> Self {
|
||||
Self::Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Chat {
|
||||
// Panel
|
||||
id: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
// Chat Room
|
||||
room: WeakEntity<Room>,
|
||||
messages: Entity<Vec<Message>>,
|
||||
new_messages: WeakEntity<Vec<Event>>,
|
||||
list_state: ListState,
|
||||
subscriptions: Vec<Subscription>,
|
||||
// New Message
|
||||
input: Entity<TextInput>,
|
||||
// Media
|
||||
attaches: Entity<Option<Vec<Url>>>,
|
||||
is_uploading: bool,
|
||||
}
|
||||
|
||||
impl Chat {
|
||||
pub fn new(id: &u64, model: &Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let room = model.downgrade();
|
||||
let new_messages = model.read(cx).new_messages.downgrade();
|
||||
|
||||
cx.new(|cx| {
|
||||
let messages = cx.new(|_| vec![Message::placeholder()]);
|
||||
let attaches = cx.new(|_| None);
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.text_size(ui::Size::Small)
|
||||
.placeholder("Message...")
|
||||
});
|
||||
|
||||
let subscriptions = vec![cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Chat, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.send_message(window, cx);
|
||||
}
|
||||
},
|
||||
)];
|
||||
|
||||
// Initialize list state
|
||||
// [item_count] always equal to 1 at the beginning
|
||||
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.), {
|
||||
let this = cx.entity().downgrade();
|
||||
move |ix, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_message(ix, window, cx).into_any_element()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
is_uploading: false,
|
||||
id: id.to_string().into(),
|
||||
room,
|
||||
new_messages,
|
||||
messages,
|
||||
list_state,
|
||||
input,
|
||||
attaches,
|
||||
subscriptions,
|
||||
};
|
||||
|
||||
// Verify messaging relays of all members
|
||||
this.verify_messaging_relays(cx);
|
||||
|
||||
// Load all messages from database
|
||||
this.load_messages(cx);
|
||||
|
||||
// Subscribe and load new messages
|
||||
this.load_new_messages(cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_messaging_relays(&self, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let pubkeys: Vec<PublicKey> = room.members.iter().map(|m| m.public_key()).collect();
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<(PublicKey, bool)>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for pubkey in pubkeys.into_iter() {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(pubkey)
|
||||
.limit(1);
|
||||
|
||||
let is_ready = if let Ok(events) = client.database().query(filter).await {
|
||||
events.first_owned().is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
result.push((pubkey, is_ready));
|
||||
}
|
||||
|
||||
_ = tx.send(result);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(result) = rx.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
for item in result.into_iter() {
|
||||
if !item.1 {
|
||||
let name = this
|
||||
.room
|
||||
.read_with(cx, |this, _| this.name())
|
||||
.unwrap_or("Unnamed".into());
|
||||
|
||||
this.push_system_message(
|
||||
format!("{} has not set up Messaging (DM) Relays, so they will NOT receive your messages.", name),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn load_messages(&self, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Events>();
|
||||
|
||||
let room = model.read(cx);
|
||||
let pubkeys = room
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| m.public_key())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let recv = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.author(room.owner.public_key())
|
||||
.pubkeys(pubkeys.iter().copied());
|
||||
|
||||
let send = Filter::new()
|
||||
.kind(Kind::PrivateDirectMessage)
|
||||
.authors(pubkeys)
|
||||
.pubkey(room.owner.public_key());
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let Ok(recv_events) = client.database().query(recv).await else {
|
||||
return;
|
||||
};
|
||||
let Ok(send_events) = client.database().query(send).await else {
|
||||
return;
|
||||
};
|
||||
let events = recv_events.merge(send_events);
|
||||
|
||||
_ = tx.send(events);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(events) = rx.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.push_messages(events, cx);
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = Message::system(content.into());
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn push_message(&self, content: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let room = model.read(cx);
|
||||
let message = Message::new(ParsedMessage::new(&room.owner, &content, Timestamp::now()));
|
||||
|
||||
// Update message list
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Reset message input
|
||||
cx.update_entity(&self.input, |this, cx| {
|
||||
this.set_loading(false, window, cx);
|
||||
this.set_disabled(false, window, cx);
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn push_messages(&self, events: Events, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let room = model.read(cx);
|
||||
let pubkeys = room.pubkeys();
|
||||
|
||||
let (messages, total) = {
|
||||
let items: Vec<Message> = events
|
||||
.into_iter()
|
||||
.sorted_by_key(|ev| ev.created_at)
|
||||
.filter_map(|ev| {
|
||||
let mut other_pubkeys: Vec<_> = ev.tags.public_keys().copied().collect();
|
||||
other_pubkeys.push(ev.pubkey);
|
||||
|
||||
if compare(&other_pubkeys, &pubkeys) {
|
||||
let member = if let Some(member) =
|
||||
room.members.iter().find(|&m| m.public_key() == ev.pubkey)
|
||||
{
|
||||
member.to_owned()
|
||||
} else {
|
||||
room.owner.to_owned()
|
||||
};
|
||||
|
||||
let message =
|
||||
Message::new(ParsedMessage::new(&member, &ev.content, ev.created_at));
|
||||
|
||||
Some(message)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let total = items.len();
|
||||
|
||||
(items, total)
|
||||
};
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(messages);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, total);
|
||||
}
|
||||
|
||||
fn load_new_messages(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.new_messages.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let subscription = cx.observe(&model, |view, this, cx| {
|
||||
let Some(model) = view.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let room = model.read(cx);
|
||||
let old_messages = view.messages.read(cx);
|
||||
let old_len = old_messages.len();
|
||||
|
||||
let items: Vec<Message> = this
|
||||
.read(cx)
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
if let Some(profile) = room.member(&event.pubkey) {
|
||||
let message = Message::new(ParsedMessage::new(
|
||||
&profile,
|
||||
&event.content,
|
||||
event.created_at,
|
||||
));
|
||||
|
||||
if !old_messages.iter().any(|old| old == &message) {
|
||||
Some(message)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = items.len();
|
||||
|
||||
cx.update_entity(&view.messages, |this, cx| {
|
||||
let messages: Vec<Message> = items
|
||||
.into_iter()
|
||||
.filter_map(|new| {
|
||||
if !this.iter().any(|old| old == &new) {
|
||||
Some(new)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
this.extend(messages);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
view.list_state.splice(old_len..old_len, total);
|
||||
});
|
||||
|
||||
self.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get message
|
||||
let mut content = self.input.read(cx).text();
|
||||
|
||||
// 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).into()
|
||||
}
|
||||
|
||||
if content.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable input when sending message
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, window, cx);
|
||||
this.set_disabled(true, window, cx);
|
||||
});
|
||||
|
||||
let client = get_client();
|
||||
let window_handle = window.window_handle();
|
||||
let (tx, rx) = oneshot::channel::<Vec<Error>>();
|
||||
|
||||
let room = model.read(cx);
|
||||
let pubkeys = room.pubkeys();
|
||||
let async_content = content.clone().to_string();
|
||||
let tags: Vec<Tag> = room
|
||||
.pubkeys()
|
||||
.iter()
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &room.owner.public_key() {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Send message to all pubkeys
|
||||
cx.background_spawn(async move {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for pubkey in pubkeys.iter() {
|
||||
if let Err(e) = client
|
||||
.send_private_msg(*pubkey, &async_content, tags.clone())
|
||||
.await
|
||||
{
|
||||
errors.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
_ = tx.send(errors);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.push_message(content.to_string(), window, cx);
|
||||
});
|
||||
});
|
||||
|
||||
if let Ok(errors) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
for error in errors.into_iter() {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).title("Message Failed to Send"),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
// TODO: support multiple upload
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let path = paths.pop().unwrap();
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
spawn(async move {
|
||||
let client = get_client();
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, _, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
// Stop loading spinner
|
||||
this.set_loading(false, cx);
|
||||
|
||||
this.attaches.update(cx, |this, cx| {
|
||||
if let Some(model) = this.as_mut() {
|
||||
model.push(url);
|
||||
} else {
|
||||
*this = Some(vec![url]);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Stop loading spinner
|
||||
if let Some(view) = this.upgrade() {
|
||||
cx.update_entity(&view, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.attaches.update(cx, |model, cx| {
|
||||
if let Some(urls) = model.as_mut() {
|
||||
let ix = urls.iter().position(|x| x == url).unwrap();
|
||||
urls.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
if let Some(message) = self.messages.read(cx).get(ix) {
|
||||
div()
|
||||
.group("")
|
||||
.relative()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.map(|this| match message {
|
||||
Message::User(item) => this
|
||||
.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().transparent)
|
||||
.group_hover("", |this| {
|
||||
this.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
img(item.avatar.clone())
|
||||
.size_8()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div().font_semibold().child(item.display_name.clone()),
|
||||
)
|
||||
.child(div().child(item.created_at.clone()).text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)),
|
||||
)
|
||||
.child(div().text_sm().child(item.content.clone())),
|
||||
),
|
||||
Message::System(content) => this
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().transparent)
|
||||
.group_hover("", |this| this.bg(cx.theme().danger)),
|
||||
)
|
||||
.child(
|
||||
img("brand/avatar.png")
|
||||
.size_8()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(content.clone()),
|
||||
Message::Placeholder => this
|
||||
.w_full()
|
||||
.h_32()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.line_height(relative(1.2))
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_8()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(ALERT),
|
||||
})
|
||||
} else {
|
||||
div()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Chat {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.room
|
||||
.read_with(cx, |this, _cx| {
|
||||
let name = this.name();
|
||||
let facepill: Vec<String> =
|
||||
this.members.iter().map(|member| member.avatar()).collect();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_row_reverse()
|
||||
.items_center()
|
||||
.justify_start()
|
||||
.children(facepill.into_iter().enumerate().rev().map(|(ix, face)| {
|
||||
div().when(ix > 0, |div| div.ml_neg_1()).child(
|
||||
img(face)
|
||||
.size_4()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
})),
|
||||
)
|
||||
.child(name)
|
||||
.into_any()
|
||||
})
|
||||
.unwrap_or("Unnamed".into_any())
|
||||
}
|
||||
|
||||
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 Chat {}
|
||||
|
||||
impl Focusable for Chat {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Chat {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.child(list(self.list_state.clone()).flex_1())
|
||||
.child(
|
||||
div().flex_shrink_0().p_2().child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.when_some(self.attaches.read(cx).as_ref(), |this, attaches| {
|
||||
this.gap_1p5().children(attaches.iter().map(|url| {
|
||||
let url = url.clone();
|
||||
let path: SharedString = url.to_string().into();
|
||||
|
||||
div()
|
||||
.id(path.clone())
|
||||
.relative()
|
||||
.w_16()
|
||||
.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=128&h=128&fit=cover&n=-1",
|
||||
IMAGE_SERVICE, path
|
||||
))
|
||||
.size_16()
|
||||
.shadow_lg()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.object_fit(ObjectFit::ScaleDown),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_neg_2()
|
||||
.right_neg_2()
|
||||
.size_4()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().danger)
|
||||
.child(
|
||||
Icon::new(IconName::Close)
|
||||
.size_2()
|
||||
.text_color(white()),
|
||||
),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove_media(&url, window, cx);
|
||||
}))
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(Icon::new(IconName::Upload))
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload_media(window, cx);
|
||||
}))
|
||||
.loading(self.is_uploading),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.rounded(px(cx.theme().radius))
|
||||
.pl_2()
|
||||
.pr_1()
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
Button::new("send")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Medium)
|
||||
.label("SEND")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.send_message(window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
use common::profile::NostrProfile;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::get_client;
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
indicator::Indicator,
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
Sizable,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Contacts> {
|
||||
Contacts::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Contacts {
|
||||
contacts: Entity<Option<Vec<NostrProfile>>>,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Contacts {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let contacts = cx.new(|_| None);
|
||||
let async_contact = contacts.clone();
|
||||
|
||||
cx.spawn(|mut cx| async move {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
|
||||
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
let signer = client.signer().await.unwrap();
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| {
|
||||
NostrProfile::new(profile.public_key(), profile.metadata())
|
||||
})
|
||||
.collect();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Ok(contacts) = rx.await {
|
||||
_ = cx.update_entity(&async_contact, |this, cx| {
|
||||
*this = Some(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| Self {
|
||||
contacts,
|
||||
name: "Contacts".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Contacts {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"ContactPanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Contacts {}
|
||||
|
||||
impl Focusable for Contacts {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Contacts {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().pt_2().px_2().map(|this| {
|
||||
if let Some(contacts) = self.contacts.read(cx).clone() {
|
||||
this.child(
|
||||
uniform_list(
|
||||
cx.entity().clone(),
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
move |_, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = contacts.get(ix).unwrap().clone();
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.child(img(item.avatar()).size_6()),
|
||||
)
|
||||
.child(item.name()),
|
||||
)
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.h_full(),
|
||||
)
|
||||
} else {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_16()
|
||||
.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
mod chat;
|
||||
mod contacts;
|
||||
mod profile;
|
||||
mod relays;
|
||||
mod settings;
|
||||
mod sidebar;
|
||||
mod welcome;
|
||||
|
||||
pub mod app;
|
||||
pub mod onboarding;
|
||||
pub mod startup;
|
||||
@@ -1,419 +0,0 @@
|
||||
use common::{profile::NostrProfile, qr::create_qr, utils::preload};
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, ClipboardItem, Context, Div,
|
||||
Entity, IntoElement, ParentElement, Render, Styled, Subscription, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use state::get_client;
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonCustomVariant, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
notification::NotificationType,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Root, Size, StyledExt,
|
||||
};
|
||||
|
||||
use super::app;
|
||||
|
||||
const ALPHA_MESSAGE: &str =
|
||||
"Coop is in the alpha stage of development; It may contain bugs, unfinished features, or unexpected behavior.";
|
||||
const JOIN_URL: &str = "https://start.njump.me/";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
Onboarding::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
app_keys: Keys,
|
||||
connect_uri: NostrConnectURI,
|
||||
qr_path: Option<PathBuf>,
|
||||
nsec_input: Entity<TextInput>,
|
||||
use_connect: bool,
|
||||
use_privkey: bool,
|
||||
is_loading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let app_keys = Keys::generate();
|
||||
|
||||
let connect_uri = NostrConnectURI::client(
|
||||
app_keys.public_key(),
|
||||
vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()],
|
||||
"Coop",
|
||||
);
|
||||
|
||||
let nsec_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.placeholder("nsec...")
|
||||
});
|
||||
|
||||
// Save Connect URI as PNG file for display as QR Code
|
||||
let qr_path = create_qr(connect_uri.to_string().as_str()).ok();
|
||||
|
||||
cx.new(|cx| {
|
||||
// Handle Enter event for nsec input
|
||||
let subscriptions = vec![cx.subscribe_in(
|
||||
&nsec_input,
|
||||
window,
|
||||
move |this: &mut Self, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.privkey_login(window, cx);
|
||||
}
|
||||
},
|
||||
)];
|
||||
|
||||
Self {
|
||||
app_keys,
|
||||
connect_uri,
|
||||
qr_path,
|
||||
nsec_input,
|
||||
use_connect: false,
|
||||
use_privkey: false,
|
||||
is_loading: false,
|
||||
subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn use_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let uri = self.connect_uri.clone();
|
||||
let app_keys = self.app_keys.clone();
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
self.use_connect = true;
|
||||
cx.notify();
|
||||
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let (tx, rx) = oneshot::channel::<NostrProfile>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None)
|
||||
{
|
||||
if let Ok(uri) = signer.bunker_uri().await {
|
||||
let client = get_client();
|
||||
|
||||
if let Some(public_key) = uri.remote_signer_public_key() {
|
||||
let metadata = client
|
||||
.fetch_metadata(*public_key, Duration::from_secs(2))
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
|
||||
if tx.send(NostrProfile::new(*public_key, metadata)).is_ok() {
|
||||
_ = client.set_signer(signer).await;
|
||||
_ = preload(client, *public_key).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Ok(profile) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(app::init(profile, window, cx).into(), window, cx)
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_privkey = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn reset(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_privkey = false;
|
||||
self.use_connect = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn privkey_login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.nsec_input.read(cx).text().to_string();
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
if !value.starts_with("nsec") || value.is_empty() {
|
||||
window.push_notification((NotificationType::Warning, "Private Key is required"), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let keys = if let Ok(keys) = Keys::parse(&value) {
|
||||
keys
|
||||
} else {
|
||||
window.push_notification((NotificationType::Warning, "Private Key isn't valid"), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<NostrProfile>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(public_key) = keys.get_public_key().await {
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
|
||||
if tx.send(NostrProfile::new(public_key, metadata)).is_ok() {
|
||||
_ = client.set_signer(keys).await;
|
||||
_ = preload(client, public_key).await;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if let Ok(profile) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(app::init(profile, window, cx).into(), window, cx)
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_selection(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("login_connect_btn")
|
||||
.label("Login with Nostr Connect")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.use_connect(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("login_privkey_btn")
|
||||
.label("Login with Private Key")
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.color(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.border(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.hover(cx.theme().base.step(cx, ColorScaleStep::FOUR))
|
||||
.active(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.foreground(cx.theme().base.step(cx, ColorScaleStep::TWELVE)),
|
||||
)
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.use_privkey(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.h_px()
|
||||
.rounded_md()
|
||||
.w_full()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
Button::new("join_btn")
|
||||
.label("Are you new? Join here!")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(|_, _, cx| {
|
||||
cx.open_url(JOIN_URL);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_connect_login(&self, cx: &mut Context<Self>) -> Div {
|
||||
let connect_string = self.connect_uri.to_string();
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("Scan this QR Code in the Nostr Signer app"),
|
||||
)
|
||||
.child("Recommend: Amber (Android), nsec.app (web),..."),
|
||||
)
|
||||
.when_some(self.qr_path.clone(), |this, path| {
|
||||
this.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.p_2()
|
||||
.size_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.rounded_lg()
|
||||
.shadow_lg()
|
||||
.when(cx.theme().appearance.is_dark(), |this| {
|
||||
this.shadow_none()
|
||||
.border_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::SIX))
|
||||
})
|
||||
.bg(cx.theme().background)
|
||||
.child(img(path).h_64()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.label("Copy Connection String")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(connect_string.clone()))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.reset(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_privkey_login(&self, cx: &mut Context<Self>) -> Div {
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Private Key:")
|
||||
.child(self.nsec_input.clone()),
|
||||
)
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Login")
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.privkey_login(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.reset(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Onboarding {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_8()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.text_lg()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("Welcome to Coop!"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child("A Nostr client for secure communication."),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(div().w_72().map(|_| {
|
||||
if self.use_privkey {
|
||||
self.render_privkey_login(cx)
|
||||
} else if self.use_connect {
|
||||
self.render_connect_login(cx)
|
||||
} else {
|
||||
self.render_selection(window, cx)
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_2()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.text_align(gpui::TextAlign::Center)
|
||||
.child(ALPHA_MESSAGE),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
use async_utility::task::spawn;
|
||||
use common::{constants::IMAGE_SERVICE, profile::NostrProfile, utils::nip96_upload};
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render,
|
||||
SharedString, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use state::get_client;
|
||||
use std::str::FromStr;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::TextInput,
|
||||
popup_menu::PopupMenu,
|
||||
ContextModal, Disableable, Sizable, Size,
|
||||
};
|
||||
|
||||
pub fn init(profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Profile> {
|
||||
Profile::new(profile, window, cx)
|
||||
}
|
||||
|
||||
pub struct Profile {
|
||||
profile: NostrProfile,
|
||||
// Form
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
website_input: Entity<TextInput>,
|
||||
is_loading: bool,
|
||||
is_submitting: bool,
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(mut profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let name_input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
|
||||
if let Some(name) = profile.metadata().display_name.as_ref() {
|
||||
input.set_text(name, window, cx);
|
||||
}
|
||||
input
|
||||
});
|
||||
let avatar_input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
|
||||
if let Some(picture) = profile.metadata().picture.as_ref() {
|
||||
input.set_text(picture, window, cx);
|
||||
}
|
||||
input
|
||||
});
|
||||
let bio_input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.multi_line();
|
||||
if let Some(about) = profile.metadata().about.as_ref() {
|
||||
input.set_text(about, window, cx);
|
||||
} else {
|
||||
input.set_placeholder("A short introduce about you.");
|
||||
}
|
||||
input
|
||||
});
|
||||
let website_input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
|
||||
if let Some(website) = profile.metadata().website.as_ref() {
|
||||
input.set_text(website, window, cx);
|
||||
} else {
|
||||
input.set_placeholder("https://your-website.com");
|
||||
}
|
||||
input
|
||||
});
|
||||
|
||||
cx.new(|cx| Self {
|
||||
profile,
|
||||
name_input,
|
||||
avatar_input,
|
||||
bio_input,
|
||||
website_input,
|
||||
is_loading: false,
|
||||
is_submitting: false,
|
||||
name: "Profile".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
|
||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let avatar_input = self.avatar_input.downgrade();
|
||||
let window_handle = window.window_handle();
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
let path = paths.pop().unwrap();
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
|
||||
spawn(async move {
|
||||
let client = get_client();
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
cx.update_window(window_handle, |_, window, cx| {
|
||||
// Stop loading spinner
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
})
|
||||
.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Stop loading spinner
|
||||
if let Some(view) = this.upgrade() {
|
||||
cx.update_entity(&view, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
let avatar = self.avatar_input.read(cx).text().to_string();
|
||||
let name = self.name_input.read(cx).text().to_string();
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
let website = self.website_input.read(cx).text().to_string();
|
||||
|
||||
let mut new_metadata = self
|
||||
.profile
|
||||
.metadata()
|
||||
.to_owned()
|
||||
.display_name(name)
|
||||
.about(bio);
|
||||
|
||||
if let Ok(url) = Url::from_str(&avatar) {
|
||||
new_metadata = new_metadata.picture(url);
|
||||
};
|
||||
|
||||
if let Ok(url) = Url::from_str(&website) {
|
||||
new_metadata = new_metadata.website(url);
|
||||
}
|
||||
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<EventId>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(output) = client.set_metadata(&new_metadata).await {
|
||||
_ = tx.send(output.val);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
if rx.await.is_ok() {
|
||||
cx.update_window(window_handle, |_, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
window.push_notification("Your profile has been updated successfully", cx);
|
||||
})
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Profile {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"ProfilePanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn 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 Profile {}
|
||||
|
||||
impl Focusable for Profile {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Profile {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.px_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h_24()
|
||||
.map(|this| {
|
||||
let picture = self.avatar_input.read(cx).text();
|
||||
|
||||
if picture.is_empty() {
|
||||
this.child(
|
||||
img("brand/avatar.png")
|
||||
.size_10()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE,
|
||||
self.avatar_input.read(cx).text()
|
||||
))
|
||||
.size_10()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.child(self.avatar_input.clone())
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.label("Upload")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(self.is_submitting)
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.upload(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Name:")
|
||||
.child(self.name_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Website:")
|
||||
.child(self.website_input.clone()),
|
||||
)
|
||||
.child(
|
||||
div().flex().items_center().justify_end().child(
|
||||
Button::new("submit")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.small()
|
||||
.disabled(self.is_loading)
|
||||
.loading(self.is_submitting)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.submit(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, uniform_list, AppContext, Context, Entity, FocusHandle,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, Styled, TextAlign, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::get_client;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, IconName, Sizable,
|
||||
};
|
||||
|
||||
const MESSAGE: &str = "In order to receive messages from others, you need to setup Messaging Relays. You can use the recommend relays or add more.";
|
||||
|
||||
pub struct Relays {
|
||||
relays: Entity<Vec<Url>>,
|
||||
input: Entity<TextInput>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
}
|
||||
|
||||
impl Relays {
|
||||
pub fn new(
|
||||
relays: Option<Vec<String>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<'_, Self>,
|
||||
) -> Self {
|
||||
let relays = cx.new(|_| {
|
||||
if let Some(value) = relays {
|
||||
value.into_iter().map(|v| Url::parse(&v).unwrap()).collect()
|
||||
} else {
|
||||
vec![
|
||||
Url::parse("wss://auth.nostr1.com").unwrap(),
|
||||
Url::parse("wss://relay.0xchat.com").unwrap(),
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::XSmall)
|
||||
.small()
|
||||
.placeholder("wss://...")
|
||||
});
|
||||
|
||||
cx.subscribe_in(&input, window, move |this, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
relays,
|
||||
input,
|
||||
is_loading: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await.expect("Signer is required");
|
||||
let public_key = signer
|
||||
.get_public_key()
|
||||
.await
|
||||
.expect("Cannot get public key");
|
||||
|
||||
// If user didn't have any NIP-65 relays, add default ones
|
||||
// TODO: Is this really necessary?
|
||||
if let Ok(relay_list) = client.database().relay_list(public_key).await {
|
||||
if relay_list.is_empty() {
|
||||
let builder = EventBuilder::relay_list(vec![
|
||||
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
|
||||
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
|
||||
(RelayUrl::parse("wss://nos.lol/").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
|
||||
.into_iter()
|
||||
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
|
||||
.collect();
|
||||
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||
|
||||
if let Ok(output) = client.send_event_builder(builder).await {
|
||||
_ = tx.send(output.val);
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if rx.await.is_ok() {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
|
||||
window.close_modal(cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn loading(&self) -> bool {
|
||||
self.is_loading
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).text().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = Url::parse(&value) {
|
||||
self.relays.update(cx, |this, cx| {
|
||||
if !this.contains(&url) {
|
||||
this.push(url);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.relays.update(cx, |this, cx| {
|
||||
this.remove(ix);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Relays {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(MESSAGE),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
Button::new("add_relay_btn")
|
||||
.icon(IconName::Plus)
|
||||
.small()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.add(window, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.map(|this| {
|
||||
let view = cx.entity();
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let total = relays.len();
|
||||
|
||||
if !relays.is_empty() {
|
||||
this.child(
|
||||
uniform_list(
|
||||
view,
|
||||
"relays",
|
||||
total,
|
||||
move |_, range, _window, cx| {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = relays.get(ix).unwrap().clone().to_string();
|
||||
|
||||
items.push(
|
||||
div().group("").w_full().h_9().py_0p5().child(
|
||||
div()
|
||||
.px_2()
|
||||
.h_full()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx
|
||||
.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::THREE))
|
||||
.text_xs()
|
||||
.child(item)
|
||||
.child(
|
||||
Button::new("remove_{ix}")
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.invisible()
|
||||
.group_hover("", |this| {
|
||||
this.visible()
|
||||
})
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.remove(ix, window, cx)
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.min_h(px(120.)),
|
||||
)
|
||||
} else {
|
||||
this.h_20()
|
||||
.mb_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_align(TextAlign::Center)
|
||||
.child("Please add some relays.")
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
use gpui::{
|
||||
div, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Settings> {
|
||||
Settings::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Settings {
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self {
|
||||
name: "Settings".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Settings {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"SettingsPanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Settings {}
|
||||
|
||||
impl Focusable for Settings {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Settings {
|
||||
fn render(&mut self, _window: &mut gpui::Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child("Settings")
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
use async_utility::task::spawn;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{
|
||||
profile::NostrProfile,
|
||||
utils::{random_name, signer_public_key},
|
||||
};
|
||||
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, TextAlign, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smol::Timer;
|
||||
use state::get_client;
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded},
|
||||
input::{InputEvent, TextInput},
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Icon, IconName, Sizable, Size, StyledExt,
|
||||
};
|
||||
|
||||
const ALERT: &str =
|
||||
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
struct SelectContact(PublicKey);
|
||||
|
||||
impl_internal_actions!(contacts, [SelectContact]);
|
||||
|
||||
pub struct Compose {
|
||||
title_input: Entity<TextInput>,
|
||||
user_input: Entity<TextInput>,
|
||||
contacts: Entity<Vec<NostrProfile>>,
|
||||
selected: Entity<HashSet<PublicKey>>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
is_submitting: bool,
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Compose {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
|
||||
let contacts = cx.new(|_| Vec::new());
|
||||
let selected = cx.new(|_| HashSet::new());
|
||||
let error_message = cx.new(|_| None);
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
let title_input = cx.new(|cx| {
|
||||
let name = random_name(2);
|
||||
let mut input = TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.text_size(Size::XSmall);
|
||||
|
||||
input.set_placeholder("Family... . (Optional)");
|
||||
input.set_text(name, window, cx);
|
||||
input
|
||||
});
|
||||
|
||||
let user_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::Small)
|
||||
.small()
|
||||
.placeholder("npub1...")
|
||||
});
|
||||
|
||||
// Handle Enter event for user input
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&user_input,
|
||||
window,
|
||||
move |this, input, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
if input.read(cx).text().contains("@") {
|
||||
this.add_nip05(window, cx);
|
||||
} else {
|
||||
this.add_npub(window, cx)
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(public_key) = signer_public_key(client).await {
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
|
||||
.collect();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(contacts) = rx.await {
|
||||
_ = cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.extend(contacts);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
title_input,
|
||||
user_input,
|
||||
contacts,
|
||||
selected,
|
||||
error_message,
|
||||
is_loading: false,
|
||||
is_submitting: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected.read(cx).is_empty() {
|
||||
self.set_error(Some("You need to add at least 1 receiver".into()), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
// Get all pubkeys
|
||||
let pubkeys: Vec<PublicKey> = self.selected.read(cx).iter().copied().collect();
|
||||
|
||||
// Convert selected pubkeys into Nostr tags
|
||||
let mut tag_list: Vec<Tag> = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect();
|
||||
|
||||
// Add subject if it is present
|
||||
if !self.title_input.read(cx).text().is_empty() {
|
||||
tag_list.push(Tag::custom(
|
||||
TagKind::Subject,
|
||||
vec![self.title_input.read(cx).text().to_string()],
|
||||
));
|
||||
}
|
||||
|
||||
let tags = Tags::from_list(tag_list);
|
||||
let client = get_client();
|
||||
let window_handle = window.window_handle();
|
||||
let (tx, rx) = oneshot::channel::<Event>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await.expect("Signer is required");
|
||||
// [IMPORTANT]
|
||||
// Make sure this event is never send,
|
||||
// this event existed just use for convert to Coop's Chat Room later.
|
||||
if let Ok(event) = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "")
|
||||
.tags(tags)
|
||||
.sign(&signer)
|
||||
.await
|
||||
{
|
||||
_ = tx.send(event)
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(event) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
// Stop loading spinner
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_submitting(false, cx);
|
||||
});
|
||||
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
let room = Room::parse(&event, cx);
|
||||
|
||||
chats.update(cx, |state, cx| match state.new_room(room, cx) {
|
||||
Ok(_) => {
|
||||
// TODO: open chat panel
|
||||
window.close_modal(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn label(&self, _window: &Window, cx: &App) -> SharedString {
|
||||
if self.selected.read(cx).len() > 1 {
|
||||
"Create Group DM".into()
|
||||
} else {
|
||||
"Create DM".into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_submitting(&self) -> bool {
|
||||
self.is_submitting
|
||||
}
|
||||
|
||||
fn add_npub(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let window_handle = window.window_handle();
|
||||
let content = self.user_input.read(cx).text().to_string();
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let Ok(public_key) = PublicKey::parse(&content) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error(Some("Public Key is not valid".into()), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
if self
|
||||
.contacts
|
||||
.read(cx)
|
||||
.iter()
|
||||
.any(|c| c.public_key() == public_key)
|
||||
{
|
||||
self.set_loading(false, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Metadata>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let metadata = (client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await)
|
||||
.unwrap_or_default();
|
||||
|
||||
_ = tx.send(metadata);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(metadata) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, NostrProfile::new(public_key, metadata));
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
this.selected.update(cx, |this, cx| {
|
||||
this.insert(public_key);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Stop loading indicator
|
||||
this.set_loading(false, cx);
|
||||
|
||||
// Clear input
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn add_nip05(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let window_handle = window.window_handle();
|
||||
let content = self.user_input.read(cx).text().to_string();
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
spawn(async move {
|
||||
if let Ok(profile) = nip05::profile(&content, None).await {
|
||||
let metadata = (client
|
||||
.fetch_metadata(profile.public_key, Duration::from_secs(2))
|
||||
.await)
|
||||
.unwrap_or_default();
|
||||
|
||||
_ = tx.send(Some(NostrProfile::new(profile.public_key, metadata)));
|
||||
} else {
|
||||
_ = tx.send(None);
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(Some(profile)) = rx.await {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
let public_key = profile.public_key();
|
||||
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, profile);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
this.selected.update(cx, |this, cx| {
|
||||
this.insert(public_key);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Stop loading indicator
|
||||
this.set_loading(false, cx);
|
||||
|
||||
// Clear input
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_ = cx.update_window(window_handle, |_, _, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_error(Some("NIP-05 Address is not valid".into()), cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
|
||||
self.error_message.update(cx, |this, cx| {
|
||||
*this = error;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Dismiss error after 2 seconds
|
||||
cx.spawn(|this, cx| async move {
|
||||
Timer::after(Duration::from_secs(2)).await;
|
||||
|
||||
_ = cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_error(None, cx);
|
||||
})
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_submitting = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_action_select(
|
||||
&mut self,
|
||||
action: &SelectContact,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.selected.update(cx, |this, cx| {
|
||||
if this.contains(&action.0) {
|
||||
this.remove(&action.0);
|
||||
} else {
|
||||
this.insert(action.0);
|
||||
};
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Compose {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::on_action_select))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(ALERT),
|
||||
)
|
||||
.when_some(self.error_message.read(cx).as_ref(), |this, msg| {
|
||||
this.child(
|
||||
div()
|
||||
.px_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(msg.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div().flex().flex_col().child(
|
||||
div()
|
||||
.h_10()
|
||||
.px_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(div().text_xs().font_semibold().child("Title:"))
|
||||
.child(self.title_input.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(div().px_2().text_xs().font_semibold().child("To:"))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(
|
||||
Button::new("add_user_to_compose_btn")
|
||||
.icon(IconName::Plus)
|
||||
.small()
|
||||
.rounded(ButtonRounded::Size(px(9999.)))
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
if this.user_input.read(cx).text().contains("@") {
|
||||
this.add_nip05(window, cx);
|
||||
} else {
|
||||
this.add_npub(window, cx);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(self.user_input.clone()),
|
||||
)
|
||||
.map(|this| {
|
||||
let contacts = self.contacts.read(cx).clone();
|
||||
let view = cx.entity();
|
||||
|
||||
if contacts.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.w_full()
|
||||
.h_24()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_align(TextAlign::Center)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("No contacts"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.child("Your recently contacts will appear here."),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
uniform_list(
|
||||
view,
|
||||
"contacts",
|
||||
contacts.len(),
|
||||
move |this, range, _window, cx| {
|
||||
let selected = this.selected.read(cx);
|
||||
let mut items = Vec::new();
|
||||
|
||||
for ix in range {
|
||||
let item = contacts.get(ix).unwrap().clone();
|
||||
let is_select = selected.contains(&item.public_key());
|
||||
|
||||
items.push(
|
||||
div()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.h_9()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
div().flex_shrink_0().child(
|
||||
img(item.avatar()).size_6(),
|
||||
),
|
||||
)
|
||||
.child(item.name()),
|
||||
)
|
||||
.when(is_select, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CircleCheck)
|
||||
.size_3()
|
||||
.text_color(cx.theme().base.step(
|
||||
cx,
|
||||
ColorScaleStep::TWELVE,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.hover(|this| {
|
||||
this.bg(cx
|
||||
.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::THREE))
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(SelectContact(
|
||||
item.public_key(),
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.min_h(px(250.)),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
use crate::views::app::{AddPanel, PanelKind};
|
||||
use chats::registry::ChatRegistry;
|
||||
use gpui::{
|
||||
div, img, percentage, prelude::FluentBuilder, px, relative, Context, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
TextAlign, Window,
|
||||
};
|
||||
use ui::{
|
||||
dock_area::dock::DockPlacement,
|
||||
skeleton::Skeleton,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, Collapsible, Icon, IconName, StyledExt,
|
||||
};
|
||||
|
||||
pub struct Inbox {
|
||||
label: SharedString,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn new(_window: &mut Window, _cx: &mut Context<'_, Self>) -> Self {
|
||||
Self {
|
||||
label: "Inbox".into(),
|
||||
is_collapsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
(0..total).map(|_| {
|
||||
div()
|
||||
.h_8()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
|
||||
.child(Skeleton::new().w_20().h_3().rounded_sm())
|
||||
})
|
||||
}
|
||||
|
||||
fn render_item(&self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
div().map(|this| {
|
||||
let state = chats.read(cx);
|
||||
let rooms = state.rooms();
|
||||
|
||||
if state.is_loading() {
|
||||
this.children(self.render_skeleton(5))
|
||||
} else if rooms.is_empty() {
|
||||
this.px_1()
|
||||
.w_full()
|
||||
.h_20()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_align(TextAlign::Center)
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("No chats"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child("Recent chats will appear here."),
|
||||
)
|
||||
} else {
|
||||
this.children(rooms.iter().map(|model| {
|
||||
let room = model.read(cx);
|
||||
let room_id: SharedString = room.id.to_string().into();
|
||||
|
||||
div()
|
||||
.id(room_id)
|
||||
.h_8()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_xs()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
|
||||
.child(div().flex_1().truncate().font_medium().map(|this| {
|
||||
if room.is_group {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(img("brand/avatar.png").size_6().rounded_full())
|
||||
.child(room.name())
|
||||
} else {
|
||||
this.when_some(room.members.first(), |this, sender| {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
img(sender.avatar())
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(sender.name())
|
||||
})
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(room.last_seen.ago()),
|
||||
)
|
||||
.on_click({
|
||||
let id = room.id;
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.action(id, window, cx);
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
div().children(self.render_skeleton(5))
|
||||
}
|
||||
}
|
||||
|
||||
fn action(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Collapsible for Inbox {
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.is_collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.is_collapsed
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Inbox {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("inbox")
|
||||
.h_7()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.size_6()
|
||||
.when(self.is_collapsed, |this| {
|
||||
this.rotate(percentage(270. / 360.))
|
||||
}),
|
||||
)
|
||||
.child(self.label.clone())
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(move |view, _event, _window, cx| {
|
||||
view.is_collapsed = !view.is_collapsed;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.when(!self.is_collapsed, |this| {
|
||||
this.child(self.render_item(window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
use crate::views::sidebar::inbox::Inbox;
|
||||
use compose::Compose;
|
||||
use gpui::{
|
||||
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
mod compose;
|
||||
mod inbox;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||
Sidebar::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Sidebar {
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
// Dock
|
||||
inbox: Entity<Inbox>,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let inbox = cx.new(|cx| Inbox::new(window, cx));
|
||||
|
||||
Self {
|
||||
name: "Sidebar".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
inbox,
|
||||
}
|
||||
}
|
||||
|
||||
fn show_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |modal, window, cx| {
|
||||
let label = compose.read(cx).label(window, cx);
|
||||
let is_submitting = compose.read(cx).is_submitting();
|
||||
|
||||
modal
|
||||
.title("Direct Messages")
|
||||
.width(px(420.))
|
||||
.child(compose.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("create_dm_btn")
|
||||
.label(label)
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_submitting)
|
||||
.disabled(is_submitting)
|
||||
.on_click(window.listener_for(&compose, |this, _, window, cx| {
|
||||
this.compose(window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Sidebar {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"Sidebar".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 Sidebar {}
|
||||
|
||||
impl Focusable for Sidebar {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.py_3()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex().px_2().gap_1().child(
|
||||
div()
|
||||
.id("new")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.h_7()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.size_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
.child(
|
||||
Icon::new(IconName::ComposeFill)
|
||||
.small()
|
||||
.text_color(cx.theme().base.darken(cx)),
|
||||
),
|
||||
)
|
||||
.child("New Message")
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(|this, _, window, cx| this.show_compose(window, cx))),
|
||||
),
|
||||
)
|
||||
.child(self.inbox.clone())
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use gpui::{
|
||||
div, svg, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window,
|
||||
};
|
||||
use ui::theme::{scale::ColorScaleStep, ActiveTheme};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
|
||||
Startup::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Startup {}
|
||||
|
||||
impl Startup {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Startup {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
use gpui::{
|
||||
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
StyledExt,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
||||
Welcome::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Welcome {
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Welcome {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
name: "Welcome".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Welcome {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
"WelcomePanel".into()
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> AnyElement {
|
||||
self.name.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn closable(&self, _cx: &App) -> bool {
|
||||
self.closable
|
||||
}
|
||||
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
self.zoomable
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
menu.track_focus(&self.focus_handle)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Welcome {}
|
||||
|
||||
impl Focusable for Welcome {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Welcome {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.child("coop on nostr.")
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::FOUR))
|
||||
.font_black()
|
||||
.text_sm(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
11
crates/assets/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "assets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
rust-embed.workspace = true
|
||||
51
crates/assets/src/lib.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../assets"]
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[include = "themes/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
|
||||
Self::get(path)
|
||||
.map(|f| Some(f.data))
|
||||
.with_context(|| format!("loading asset at path {path:?}"))
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
Ok(Self::iter()
|
||||
.filter_map(|p| {
|
||||
if p.starts_with(path) {
|
||||
Some(p.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
}
|
||||
21
crates/auto_update/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "auto_update"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
semver = "1.0.27"
|
||||
tempfile = "3.23.0"
|
||||
494
crates/auto_update/src/lib.rs
Normal file
@@ -0,0 +1,494 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use common::BOOTSTRAP_RELAYS;
|
||||
use gpui::http_client::{AsyncBody, HttpClient};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use semver::Version;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::fs::File;
|
||||
use smol::process::Command;
|
||||
use state::NostrRegistry;
|
||||
|
||||
const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q";
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
|
||||
impl Global for GlobalAutoUpdater {}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
struct InstallerDir(tempfile::TempDir);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
impl InstallerDir {
|
||||
async fn new() -> Result<Self, Error> {
|
||||
Ok(Self(
|
||||
tempfile::Builder::new()
|
||||
.prefix("coop-auto-update")
|
||||
.tempdir()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct InstallerDir(PathBuf);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
impl InstallerDir {
|
||||
async fn new() -> Result<Self, Error> {
|
||||
let installer_dir = std::env::current_exe()?
|
||||
.parent()
|
||||
.context("No parent dir for Coop.exe")?
|
||||
.join("updates");
|
||||
|
||||
if smol::fs::metadata(&installer_dir).await.is_ok() {
|
||||
smol::fs::remove_dir_all(&installer_dir).await?;
|
||||
}
|
||||
|
||||
smol::fs::create_dir(&installer_dir).await?;
|
||||
|
||||
Ok(Self(installer_dir))
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
self.0.as_path()
|
||||
}
|
||||
}
|
||||
|
||||
struct MacOsUnmounter<'a> {
|
||||
mount_path: PathBuf,
|
||||
background_executor: &'a BackgroundExecutor,
|
||||
}
|
||||
|
||||
impl Drop for MacOsUnmounter<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mount_path = std::mem::take(&mut self.mount_path);
|
||||
|
||||
self.background_executor
|
||||
.spawn(async move {
|
||||
let unmount_output = Command::new("hdiutil")
|
||||
.args(["detach", "-force"])
|
||||
.arg(&mount_path)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match unmount_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("Successfully unmounted the disk image");
|
||||
}
|
||||
Ok(output) => {
|
||||
log::error!(
|
||||
"Failed to unmount disk image: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Error while trying to unmount disk image: {:?}", error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AutoUpdateStatus {
|
||||
Idle,
|
||||
Checking,
|
||||
Checked { files: Vec<EventId> },
|
||||
Installing,
|
||||
Updated,
|
||||
Errored { msg: Box<String> },
|
||||
}
|
||||
|
||||
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
|
||||
fn as_ref(&self) -> &AutoUpdateStatus {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoUpdateStatus {
|
||||
pub fn is_updating(&self) -> bool {
|
||||
matches!(self, Self::Checked { .. } | Self::Installing)
|
||||
}
|
||||
|
||||
pub fn is_updated(&self) -> bool {
|
||||
matches!(self, Self::Updated)
|
||||
}
|
||||
|
||||
pub fn checked(files: Vec<EventId>) -> Self {
|
||||
Self::Checked { files }
|
||||
}
|
||||
|
||||
pub fn error(e: String) -> Self {
|
||||
Self::Errored { msg: Box::new(e) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AutoUpdater {
|
||||
/// Current status of the auto updater
|
||||
pub status: AutoUpdateStatus,
|
||||
|
||||
/// Current version of the application
|
||||
pub version: Version,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
|
||||
/// Background tasks
|
||||
_tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
/// Retrieve the global auto updater instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||
}
|
||||
|
||||
/// Set the global auto updater instance
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAutoUpdater(state));
|
||||
}
|
||||
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let async_version = version.clone();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
let mut tasks = smallvec![];
|
||||
|
||||
tasks.push(
|
||||
// Subscribe to get the new update event in the bootstrap relays
|
||||
Self::subscribe_to_updates(cx),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Subscribe to get the new update event in the bootstrap relays
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Check for updates after 2 minutes
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_secs(120))
|
||||
.await;
|
||||
|
||||
// Update the status to checking
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Checking, cx);
|
||||
});
|
||||
|
||||
match Self::check_for_updates(async_version, cx).await {
|
||||
Ok(ids) => {
|
||||
// Update the status to downloading
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::checked(ids), cx);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||
});
|
||||
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the status
|
||||
cx.observe_self(|this, cx| {
|
||||
if let AutoUpdateStatus::Checked { files } = this.status.clone() {
|
||||
this.get_latest_release(&files, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
status: AutoUpdateStatus::Idle,
|
||||
version,
|
||||
_subscriptions: subscriptions,
|
||||
_tasks: tasks,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
|
||||
self.status = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn subscribe_to_updates(cx: &App) -> Task<()> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ReleaseArtifactSet)
|
||||
.author(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe to updates: {e}");
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
fn check_for_updates(version: Version, cx: &AsyncApp) -> Task<Result<Vec<EventId>, Error>> {
|
||||
let client = cx.update(|cx| {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
nostr.read(cx).client()
|
||||
});
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ReleaseArtifactSet)
|
||||
.author(app_pubkey)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let new_version: Version = event
|
||||
.tags
|
||||
.find(TagKind::d())
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| content.split("@").last())
|
||||
.and_then(|content| Version::parse(content).ok())
|
||||
.context("Failed to parse version")?;
|
||||
|
||||
if new_version > version {
|
||||
// Get all file metadata event ids
|
||||
let ids: Vec<EventId> = event.tags.event_ids().copied().collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::FileMetadata)
|
||||
.author(app_pubkey)
|
||||
.ids(ids.clone());
|
||||
|
||||
// Get all files for this release
|
||||
client
|
||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||
.await?;
|
||||
|
||||
Ok(ids)
|
||||
} else {
|
||||
Err(anyhow!("No update available"))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("No update available"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let http_client = cx.http_client();
|
||||
let ids = ids.to_vec();
|
||||
|
||||
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||
let os = std::env::consts::OS;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::FileMetadata)
|
||||
.author(app_pubkey)
|
||||
.ids(ids);
|
||||
|
||||
// Get all urls for this release
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
for event in events.into_iter() {
|
||||
// Only process events that match current platform
|
||||
if event.content != os {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse the url
|
||||
let url = event
|
||||
.tags
|
||||
.find(TagKind::Url)
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| Url::parse(content).ok())
|
||||
.context("Failed to parse url")?;
|
||||
|
||||
let installer_dir = InstallerDir::new().await?;
|
||||
let target_path = Self::target_path(&installer_dir).await?;
|
||||
|
||||
// Download the release
|
||||
download(url.as_str(), &target_path, http_client).await?;
|
||||
|
||||
return Ok((installer_dir, target_path));
|
||||
}
|
||||
|
||||
Err(anyhow!("Failed to get latest release"))
|
||||
});
|
||||
|
||||
self._tasks.push(
|
||||
// Install the new release
|
||||
cx.spawn(async move |this, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Installing, cx);
|
||||
});
|
||||
|
||||
match task.await {
|
||||
Ok((installer_dir, target_path)) => {
|
||||
if Self::install(installer_dir, target_path, cx).await.is_ok() {
|
||||
// Update the status to updated
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::Updated, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Update the status to error including the error message
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
|
||||
let filename = match std::env::consts::OS {
|
||||
"macos" => anyhow::Ok("Coop.dmg"),
|
||||
"windows" => Ok("Coop.exe"),
|
||||
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
|
||||
}?;
|
||||
|
||||
Ok(installer_dir.path().join(filename))
|
||||
}
|
||||
|
||||
async fn install(
|
||||
installer_dir: InstallerDir,
|
||||
target_path: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
match std::env::consts::OS {
|
||||
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
|
||||
"windows" => install_release_windows(target_path).await,
|
||||
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn download(
|
||||
url: &str,
|
||||
target_path: &std::path::Path,
|
||||
client: Arc<dyn HttpClient>,
|
||||
) -> Result<(), Error> {
|
||||
let body = AsyncBody::default();
|
||||
let mut target_file = File::create(&target_path).await?;
|
||||
let mut response = client.get(url, body, true).await?;
|
||||
|
||||
// Copy the response body to the target file
|
||||
smol::io::copy(response.body_mut(), &mut target_file).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_macos(
|
||||
temp_dir: &InstallerDir,
|
||||
downloaded_dmg: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let running_app_path = cx.update(|cx| cx.app_path())?;
|
||||
let running_app_filename = running_app_path
|
||||
.file_name()
|
||||
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
|
||||
|
||||
let mount_path = temp_dir.path().join("Coop");
|
||||
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
|
||||
|
||||
mounted_app_path.push("/");
|
||||
|
||||
let output = Command::new("hdiutil")
|
||||
.args(["attach", "-nobrowse"])
|
||||
.arg(&downloaded_dmg)
|
||||
.arg("-mountroot")
|
||||
.arg(temp_dir.path())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to mount: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
|
||||
let _unmounter = MacOsUnmounter {
|
||||
mount_path: mount_path.clone(),
|
||||
background_executor: cx.background_executor(),
|
||||
};
|
||||
|
||||
let output = Command::new("rsync")
|
||||
.args(["-av", "--delete"])
|
||||
.arg(&mounted_app_path)
|
||||
.arg(&running_app_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to copy app: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
|
||||
//const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let system_root = std::env::var("SYSTEMROOT");
|
||||
let powershell_path = system_root.as_ref().map_or_else(
|
||||
|_| "powershell.exe".to_string(),
|
||||
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||
);
|
||||
|
||||
let mut installer_path = std::ffi::OsString::new();
|
||||
installer_path.push("\"");
|
||||
installer_path.push(&downloaded_installer);
|
||||
installer_path.push("\"");
|
||||
|
||||
let output = Command::new(powershell_path)
|
||||
//.creation_flags(CREATE_NO_WINDOW)
|
||||
.args(["-NoProfile", "-WindowStyle", "Hidden"])
|
||||
.args(["Start-Process"])
|
||||
.arg(installer_path)
|
||||
.arg("-ArgumentList")
|
||||
.args(["/P", "/R"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"failed to start installer: {:?}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
27
crates/chat/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "chat"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
futures.workspace = true
|
||||
flume.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
fuzzy-matcher = "0.3.7"
|
||||
718
crates/chat/src/lib.rs
Normal file
@@ -0,0 +1,718 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||
use common::EventUtils;
|
||||
use device::DeviceRegistry;
|
||||
use flume::Sender;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::{tracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION};
|
||||
|
||||
mod message;
|
||||
mod room;
|
||||
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
|
||||
impl Global for GlobalChatRegistry {}
|
||||
|
||||
/// Chat event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ChatEvent {
|
||||
/// An event to open a room by its ID
|
||||
OpenRoom(u64),
|
||||
/// An event to close a room by its ID
|
||||
CloseRoom(u64),
|
||||
/// An event to notify UI about a new chat request
|
||||
Ping,
|
||||
}
|
||||
|
||||
/// Channel signal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum NostrEvent {
|
||||
/// Message received from relay pool
|
||||
Message(NewMessage),
|
||||
/// Unwrapping status
|
||||
Unwrapping(bool),
|
||||
/// Eose received from relay pool
|
||||
Eose,
|
||||
}
|
||||
|
||||
/// Chat Registry
|
||||
#[derive(Debug)]
|
||||
pub struct ChatRegistry {
|
||||
/// Collection of all chat rooms
|
||||
rooms: Vec<Entity<Room>>,
|
||||
|
||||
/// Loading status of the registry
|
||||
loading: bool,
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
tracking_flag: Arc<AtomicBool>,
|
||||
|
||||
/// Channel's sender for communication between nostr and gpui
|
||||
sender: Sender<NostrEvent>,
|
||||
|
||||
/// Handle notifications asynchronous task
|
||||
notifications: Option<Task<Result<(), Error>>>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<()>>,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<ChatEvent> for ChatRegistry {}
|
||||
|
||||
impl ChatRegistry {
|
||||
/// Retrieve the global chat registry state
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalChatRegistry>().0.clone()
|
||||
}
|
||||
|
||||
/// Set the global chat registry instance
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalChatRegistry(state));
|
||||
}
|
||||
|
||||
/// Create a new chat registry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let identity = nostr.read(cx).identity();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).device_signer.clone();
|
||||
|
||||
// A flag to indicate if the registry is loading
|
||||
let tracking_flag = Arc::new(AtomicBool::new(true));
|
||||
|
||||
// Channel for communication between nostr and gpui
|
||||
let (tx, rx) = flume::bounded::<NostrEvent>(2048);
|
||||
|
||||
let mut tasks = vec![];
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the identity
|
||||
cx.observe(&identity, |this, state, cx| {
|
||||
if state.read(cx).has_public_key() {
|
||||
// Handle nostr notifications
|
||||
this.handle_notifications(cx);
|
||||
// Track unwrapping progress
|
||||
this.tracking(cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe the device signer state
|
||||
cx.observe(&device_signer, |this, state, cx| {
|
||||
if state.read(cx).is_some() {
|
||||
this.handle_notifications(cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
tasks.push(
|
||||
// Update GPUI states
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Ok(message) = rx.recv_async().await {
|
||||
match message {
|
||||
NostrEvent::Message(message) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_message(message, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
NostrEvent::Eose => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
NostrEvent::Unwrapping(status) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(status, cx);
|
||||
this.get_rooms(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
rooms: vec![],
|
||||
loading: true,
|
||||
tracking_flag,
|
||||
sender: tx.clone(),
|
||||
notifications: None,
|
||||
tasks,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle nostr notifications
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let device_signer = device.read(cx).signer(cx);
|
||||
|
||||
let status = self.tracking_flag.clone();
|
||||
let tx = self.sender.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
let initialized_at = Timestamp::now();
|
||||
let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
|
||||
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed_events = HashSet::new();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
let RelayPoolNotification::Message { message, .. } = notification else {
|
||||
// Skip non-message notifications
|
||||
continue;
|
||||
};
|
||||
|
||||
match message {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
if !processed_events.insert(event.id) {
|
||||
// Skip if the event has already been processed
|
||||
continue;
|
||||
}
|
||||
|
||||
if event.kind != Kind::GiftWrap {
|
||||
// Skip non-gift wrap events
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the rumor from the gift wrap event
|
||||
match Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
|
||||
Ok(rumor) => match rumor.created_at >= initialized_at {
|
||||
true => {
|
||||
// Check if the event is sent by coop
|
||||
let sent_by_coop = {
|
||||
let tracker = tracker().read().await;
|
||||
tracker.is_sent_by_coop(&event.id)
|
||||
};
|
||||
// No need to emit if sent by coop
|
||||
// the event is already emitted
|
||||
if !sent_by_coop {
|
||||
let new_message = NewMessage::new(event.id, rumor);
|
||||
let signal = NostrEvent::Message(new_message);
|
||||
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
false => {
|
||||
status.store(true, Ordering::Release);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to unwrap: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id) => {
|
||||
if id.as_ref() == &subscription_id {
|
||||
tx.send_async(NostrEvent::Eose).await.ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Tracking the status of unwrapping gift wrap events.
|
||||
fn tracking(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let status = self.tracking_flag.clone();
|
||||
let tx = self.sender.clone();
|
||||
|
||||
self.notifications = Some(cx.background_spawn(async move {
|
||||
let loop_duration = Duration::from_secs(12);
|
||||
|
||||
let mut is_start_processing = false;
|
||||
let mut total_loops = 0;
|
||||
|
||||
loop {
|
||||
if client.has_signer().await {
|
||||
total_loops += 1;
|
||||
|
||||
if status.load(Ordering::Acquire) {
|
||||
is_start_processing = true;
|
||||
// Reset gift wrap processing flag
|
||||
_ = status.compare_exchange(
|
||||
true,
|
||||
false,
|
||||
Ordering::Release,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
tx.send_async(NostrEvent::Unwrapping(true)).await.ok();
|
||||
} else {
|
||||
// Only run further if we are already processing
|
||||
// Wait until after 2 loops to prevent exiting early while events are still being processed
|
||||
if is_start_processing && total_loops >= 2 {
|
||||
tx.send_async(NostrEvent::Unwrapping(false)).await.ok();
|
||||
|
||||
// Reset the counter
|
||||
is_start_processing = false;
|
||||
total_loops = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
smol::Timer::after(loop_duration).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Get the loading status of the chat registry
|
||||
pub fn loading(&self) -> bool {
|
||||
self.loading
|
||||
}
|
||||
|
||||
/// Set the loading status of the chat registry
|
||||
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
|
||||
self.loading = loading;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get a weak reference to a room by its ID.
|
||||
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.find(|this| &this.read(cx).id == id)
|
||||
.map(|this| this.downgrade())
|
||||
}
|
||||
|
||||
/// Get all ongoing rooms.
|
||||
pub fn ongoing_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).kind == RoomKind::Ongoing)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all request rooms.
|
||||
pub fn request_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).kind != RoomKind::Ongoing)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add a new room to the start of list.
|
||||
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: Into<Room>,
|
||||
{
|
||||
self.rooms.insert(0, cx.new(|_| room.into()));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Emit an open room event.
|
||||
/// If the room is new, add it to the registry.
|
||||
pub fn emit_room(&mut self, room: WeakEntity<Room>, cx: &mut Context<Self>) {
|
||||
if let Some(room) = room.upgrade() {
|
||||
let id = room.read(cx).id;
|
||||
|
||||
// If the room is new, add it to the registry.
|
||||
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||
self.rooms.insert(0, room);
|
||||
}
|
||||
|
||||
// Emit the open room event.
|
||||
cx.emit(ChatEvent::OpenRoom(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a room.
|
||||
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
if self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||
cx.emit(ChatEvent::CloseRoom(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort rooms by their created at.
|
||||
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
||||
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Search rooms by their name.
|
||||
pub fn search(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
||||
let matcher = SkimMatcherV2::default();
|
||||
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| {
|
||||
matcher
|
||||
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
|
||||
.is_some()
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Search rooms by public keys.
|
||||
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
|
||||
self.rooms
|
||||
.iter()
|
||||
.filter(|room| room.read(cx).members.contains(&public_key))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reset the registry.
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.rooms.clear();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Extend the registry with new rooms.
|
||||
fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
|
||||
let mut room_map: HashMap<u64, usize> = self
|
||||
.rooms
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, room)| (room.read(cx).id, idx))
|
||||
.collect();
|
||||
|
||||
for new_room in rooms.into_iter() {
|
||||
// Check if we already have a room with this ID
|
||||
if let Some(&index) = room_map.get(&new_room.id) {
|
||||
self.rooms[index].update(cx, |this, cx| {
|
||||
if new_room.created_at > this.created_at {
|
||||
*this = new_room;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let new_room_id = new_room.id;
|
||||
self.rooms.push(cx.new(|_| new_room));
|
||||
|
||||
let new_index = self.rooms.len();
|
||||
room_map.insert(new_room_id, new_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all rooms from the database.
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.create_get_rooms_task(cx);
|
||||
|
||||
self.tasks.push(
|
||||
// Run and finished in the background
|
||||
cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(rooms) => {
|
||||
this.update(cx, move |this, cx| {
|
||||
this.extend_rooms(rooms, cx);
|
||||
this.sort(cx);
|
||||
})
|
||||
.expect("Entity has been released");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load rooms: {e}")
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a task to load rooms from the database
|
||||
fn create_get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get the contact bypass setting
|
||||
let bypass_setting = AppSettings::get_contact_bypass(cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||
|
||||
let authored_filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
|
||||
|
||||
// Get all authored events
|
||||
let authored = client.database().query(authored_filter).await?;
|
||||
|
||||
let addressed_filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
|
||||
|
||||
// Get all addressed events
|
||||
let addressed = client.database().query(addressed_filter).await?;
|
||||
|
||||
// Merge authored and addressed events
|
||||
let events = authored.merge(addressed);
|
||||
|
||||
let mut rooms: HashSet<Room> = HashSet::new();
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
|
||||
// Process each event and group by room hash
|
||||
for raw in events.into_iter() {
|
||||
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) {
|
||||
if rumor.tags.public_keys().peekable().peek().is_some() {
|
||||
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (_id, mut messages) in grouped.into_iter() {
|
||||
messages.sort_by_key(|m| Reverse(m.created_at));
|
||||
|
||||
let Some(latest) = messages.first() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut room = Room::from(latest);
|
||||
|
||||
if rooms.iter().any(|r| r.id == room.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut public_keys = room.members();
|
||||
public_keys.retain(|pk| pk != &public_key);
|
||||
|
||||
// Check if the user has responded to the room
|
||||
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
||||
|
||||
// Determine if the room is ongoing or not
|
||||
let mut bypassed = false;
|
||||
|
||||
// Check if public keys are from the user's contacts
|
||||
if bypass_setting {
|
||||
bypassed = public_keys.iter().any(|k| contacts.contains(k));
|
||||
}
|
||||
|
||||
// Set the room's kind based on status
|
||||
if user_sent || bypassed {
|
||||
room = room.kind(RoomKind::Ongoing);
|
||||
}
|
||||
|
||||
rooms.insert(room);
|
||||
}
|
||||
|
||||
Ok(rooms)
|
||||
})
|
||||
}
|
||||
|
||||
/// Trigger a refresh of the opened chat rooms by their IDs
|
||||
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
|
||||
if let Some(ids) = ids {
|
||||
for room in self.rooms.iter() {
|
||||
if ids.contains(&room.read(cx).id) {
|
||||
room.update(cx, |this, cx| {
|
||||
this.emit_refresh(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Nostr event into a Coop Message and push it to the belonging room
|
||||
///
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
// Get the unique id
|
||||
let id = message.rumor.uniq_id();
|
||||
// Get the author
|
||||
let author = message.rumor.pubkey;
|
||||
|
||||
match self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
Some(room) => {
|
||||
let new_message = message.rumor.created_at > room.read(cx).created_at;
|
||||
let created_at = message.rumor.created_at;
|
||||
|
||||
// Update room
|
||||
room.update(cx, |this, cx| {
|
||||
// Update the last timestamp if the new message is newer
|
||||
if new_message {
|
||||
this.set_created_at(created_at, cx);
|
||||
}
|
||||
|
||||
// Set this room is ongoing if the new message is from current user
|
||||
if author == nostr.read(cx).identity().read(cx).public_key() {
|
||||
this.set_ongoing(cx);
|
||||
}
|
||||
|
||||
// Emit the new message to the room
|
||||
this.emit_message(message, cx);
|
||||
});
|
||||
|
||||
// Resort all rooms in the registry by their created at (after updated)
|
||||
if new_message {
|
||||
self.sort(cx);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Push the new room to the front of the list
|
||||
self.add_room(&message.rumor, cx);
|
||||
|
||||
// Notify the UI about the new room
|
||||
cx.emit(ChatEvent::Ping);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwraps a gift-wrapped event and processes its contents.
|
||||
async fn extract_rumor(
|
||||
client: &Client,
|
||||
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnsignedEvent, Error> {
|
||||
// Try to get cached rumor first
|
||||
if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await {
|
||||
return Ok(event);
|
||||
}
|
||||
|
||||
// Try to unwrap with the available signer
|
||||
let unwrapped = Self::try_unwrap(client, device_signer, gift_wrap).await?;
|
||||
let mut rumor_unsigned = unwrapped.rumor;
|
||||
|
||||
// Generate event id for the rumor if it doesn't have one
|
||||
rumor_unsigned.ensure_id();
|
||||
|
||||
// Cache the rumor
|
||||
Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?;
|
||||
|
||||
Ok(rumor_unsigned)
|
||||
}
|
||||
|
||||
// Helper method to try unwrapping with different signers
|
||||
async fn try_unwrap(
|
||||
client: &Client,
|
||||
device_signer: &Option<Arc<dyn NostrSigner>>,
|
||||
gift_wrap: &Event,
|
||||
) -> Result<UnwrappedGift, Error> {
|
||||
if let Some(signer) = device_signer.as_ref() {
|
||||
let seal = signer
|
||||
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
|
||||
.await?;
|
||||
|
||||
let seal: Event = Event::from_json(seal)?;
|
||||
seal.verify_with_ctx(&SECP256K1)?;
|
||||
|
||||
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
|
||||
let rumor = UnsignedEvent::from_json(rumor)?;
|
||||
|
||||
return Ok(UnwrappedGift {
|
||||
sender: seal.pubkey,
|
||||
rumor,
|
||||
});
|
||||
}
|
||||
|
||||
let signer = client.signer().await?;
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
|
||||
|
||||
Ok(unwrapped)
|
||||
}
|
||||
|
||||
/// Stores an unwrapped event in local database with reference to original
|
||||
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
|
||||
let author = rumor.pubkey;
|
||||
let conversation = Self::conversation_id(rumor);
|
||||
|
||||
let mut tags = rumor.tags.clone().to_vec();
|
||||
|
||||
// Add a unique identifier
|
||||
tags.push(Tag::identifier(id));
|
||||
|
||||
// Add a reference to the rumor's author
|
||||
tags.push(Tag::custom(
|
||||
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
|
||||
[author],
|
||||
));
|
||||
|
||||
// Add a conversation id
|
||||
tags.push(Tag::custom(
|
||||
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
|
||||
[conversation.to_string()],
|
||||
));
|
||||
|
||||
// Add a reference to the rumor's id
|
||||
tags.push(Tag::event(rumor_id));
|
||||
|
||||
// Add references to the rumor's participants
|
||||
for receiver in rumor.tags.public_keys().copied() {
|
||||
tags.push(Tag::custom(
|
||||
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
|
||||
[receiver],
|
||||
));
|
||||
}
|
||||
|
||||
// Convert rumor to json
|
||||
let content = rumor.as_json();
|
||||
|
||||
// Construct the event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tags(tags)
|
||||
.sign(&Keys::generate())
|
||||
.await?;
|
||||
|
||||
// Save the event to the database
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieves a previously unwrapped event from local database
|
||||
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(gift_wrap)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
||||
} else {
|
||||
Err(anyhow!("Event is not cached yet."))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the conversation ID for a given rumor (message).
|
||||
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
|
||||
pubkeys.push(rumor.pubkey);
|
||||
pubkeys.sort();
|
||||
pubkeys.dedup();
|
||||
pubkeys.hash(&mut hasher);
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
}
|
||||
212
crates/chat/src/message.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use std::hash::Hash;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// New message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
pub gift_wrap: EventId,
|
||||
pub rumor: UnsignedEvent,
|
||||
}
|
||||
|
||||
impl NewMessage {
|
||||
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||
Self { gift_wrap, rumor }
|
||||
}
|
||||
}
|
||||
|
||||
/// Message.
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
User(RenderedMessage),
|
||||
Warning(String, Timestamp),
|
||||
System(Timestamp),
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn user<I>(user: I) -> Self
|
||||
where
|
||||
I: Into<RenderedMessage>,
|
||||
{
|
||||
Self::User(user.into())
|
||||
}
|
||||
|
||||
pub fn warning<I>(content: I) -> Self
|
||||
where
|
||||
I: Into<String>,
|
||||
{
|
||||
Self::Warning(content.into(), Timestamp::now())
|
||||
}
|
||||
|
||||
pub fn system() -> Self {
|
||||
Self::System(Timestamp::default())
|
||||
}
|
||||
|
||||
fn timestamp(&self) -> &Timestamp {
|
||||
match self {
|
||||
Message::User(msg) => &msg.created_at,
|
||||
Message::Warning(_, ts) => ts,
|
||||
Message::System(ts) => ts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&NewMessage> for Message {
|
||||
fn from(val: &NewMessage) -> Self {
|
||||
Self::User(val.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnsignedEvent> for Message {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
Self::User(val.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Message {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
match (self, other) {
|
||||
// System always comes first
|
||||
(Message::System(_), Message::System(_)) => self.timestamp().cmp(other.timestamp()),
|
||||
(Message::System(_), _) => std::cmp::Ordering::Less,
|
||||
(_, Message::System(_)) => std::cmp::Ordering::Greater,
|
||||
|
||||
// For non-system messages, compare by timestamp
|
||||
_ => self.timestamp().cmp(other.timestamp()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Message {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderedMessage {
|
||||
pub id: EventId,
|
||||
/// Author's public key
|
||||
pub author: PublicKey,
|
||||
/// The content/text of the message
|
||||
pub content: String,
|
||||
/// Message created time as unix timestamp
|
||||
pub created_at: Timestamp,
|
||||
/// List of mentioned public keys in the message
|
||||
pub mentions: Vec<PublicKey>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
}
|
||||
|
||||
impl From<&Event> for RenderedMessage {
|
||||
fn from(val: &Event) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
|
||||
Self {
|
||||
id: val.id,
|
||||
author: val.pubkey,
|
||||
content: val.content.clone(),
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnsignedEvent> for RenderedMessage {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
|
||||
Self {
|
||||
// Event ID must be known
|
||||
id: val.id.unwrap(),
|
||||
author: val.pubkey,
|
||||
content: val.content.clone(),
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&NewMessage> for RenderedMessage {
|
||||
fn from(val: &NewMessage) -> Self {
|
||||
let mentions = extract_mentions(&val.rumor.content);
|
||||
let replies_to = extract_reply_ids(&val.rumor.tags);
|
||||
|
||||
Self {
|
||||
// Event ID must be known
|
||||
id: val.rumor.id.unwrap(),
|
||||
author: val.rumor.pubkey,
|
||||
content: val.rumor.content.clone(),
|
||||
created_at: val.rumor.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for RenderedMessage {}
|
||||
|
||||
impl PartialEq for RenderedMessage {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for RenderedMessage {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.created_at.cmp(&other.created_at)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for RenderedMessage {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for RenderedMessage {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all mentions (public keys) from a content string.
|
||||
fn extract_mentions(content: &str) -> Vec<PublicKey> {
|
||||
let parser = NostrParser::new();
|
||||
let tokens = parser.parse(content);
|
||||
|
||||
tokens
|
||||
.filter_map(|token| match token {
|
||||
Token::Nostr(nip21) => match nip21 {
|
||||
Nip21::Pubkey(pubkey) => Some(pubkey),
|
||||
Nip21::Profile(profile) => Some(profile.public_key),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Extracts all reply (ids) from the event tags.
|
||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||
let mut replies_to = vec![];
|
||||
|
||||
for tag in inner.filter(TagKind::e()) {
|
||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||
replies_to.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for tag in inner.filter(TagKind::q()) {
|
||||
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
|
||||
replies_to.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
replies_to
|
||||
}
|
||||
625
crates/chat/src/room.rs
Normal file
@@ -0,0 +1,625 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::EventUtils;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry};
|
||||
use state::{tracker, NostrRegistry};
|
||||
|
||||
use crate::NewMessage;
|
||||
|
||||
const SEND_RETRY: usize = 10;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SendReport {
|
||||
pub receiver: PublicKey,
|
||||
pub status: Option<Output<EventId>>,
|
||||
pub error: Option<SharedString>,
|
||||
pub on_hold: Option<Event>,
|
||||
pub encryption: bool,
|
||||
pub relays_not_found: bool,
|
||||
pub device_not_found: bool,
|
||||
}
|
||||
|
||||
impl SendReport {
|
||||
pub fn new(receiver: PublicKey) -> Self {
|
||||
Self {
|
||||
receiver,
|
||||
status: None,
|
||||
error: None,
|
||||
on_hold: None,
|
||||
encryption: false,
|
||||
relays_not_found: false,
|
||||
device_not_found: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(mut self, output: Output<EventId>) -> Self {
|
||||
self.status = Some(output);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
|
||||
self.error = Some(error.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_hold(mut self, event: Event) -> Self {
|
||||
self.on_hold = Some(event);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn encryption(mut self) -> Self {
|
||||
self.encryption = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn relays_not_found(mut self) -> Self {
|
||||
self.relays_not_found = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn device_not_found(mut self) -> Self {
|
||||
self.device_not_found = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_relay_error(&self) -> bool {
|
||||
self.error.is_some() || self.relays_not_found
|
||||
}
|
||||
|
||||
pub fn is_sent_success(&self) -> bool {
|
||||
if let Some(output) = self.status.as_ref() {
|
||||
!output.success.is_empty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Room event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum RoomEvent {
|
||||
/// Incoming message.
|
||||
Incoming(NewMessage),
|
||||
/// Reloads the current room's messages.
|
||||
Reload,
|
||||
}
|
||||
|
||||
/// Room kind.
|
||||
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub enum RoomKind {
|
||||
#[default]
|
||||
Request,
|
||||
Ongoing,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Room {
|
||||
/// Conversation ID
|
||||
pub id: u64,
|
||||
/// The timestamp of the last message in the room
|
||||
pub created_at: Timestamp,
|
||||
/// Subject of the room
|
||||
pub subject: Option<SharedString>,
|
||||
/// All members of the room
|
||||
pub members: Vec<PublicKey>,
|
||||
/// Kind
|
||||
pub kind: RoomKind,
|
||||
}
|
||||
|
||||
impl Ord for Room {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.created_at.cmp(&other.created_at)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Room {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Room {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Room {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Room {}
|
||||
|
||||
impl EventEmitter<RoomEvent> for Room {}
|
||||
|
||||
impl From<&UnsignedEvent> for Room {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let id = val.uniq_id();
|
||||
let created_at = val.created_at;
|
||||
|
||||
// Get the members from the event's tags and event's pubkey
|
||||
let members = val.extract_public_keys();
|
||||
|
||||
// Get subject from tags
|
||||
let subject = val
|
||||
.tags
|
||||
.find(TagKind::Subject)
|
||||
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
|
||||
|
||||
Room {
|
||||
id,
|
||||
created_at,
|
||||
subject,
|
||||
members,
|
||||
kind: RoomKind::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Room {
|
||||
/// Constructs a new room with the given receiver and tags.
|
||||
pub fn new(subject: Option<String>, author: PublicKey, receivers: Vec<PublicKey>) -> Self {
|
||||
// Convert receiver's public keys into tags
|
||||
let mut tags: Tags = Tags::from_list(
|
||||
receivers
|
||||
.iter()
|
||||
.map(|pubkey| Tag::public_key(pubkey.to_owned()))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
// Add subject if it is present
|
||||
if let Some(subject) = subject {
|
||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||
subject,
|
||||
)));
|
||||
}
|
||||
|
||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
||||
.tags(tags)
|
||||
.build(author);
|
||||
|
||||
// Generate event ID
|
||||
event.ensure_id();
|
||||
|
||||
Room::from(&event)
|
||||
}
|
||||
|
||||
/// Sets the kind of the room and returns the modified room
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets this room is ongoing conversation
|
||||
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
|
||||
if self.kind != RoomKind::Ongoing {
|
||||
self.kind = RoomKind::Ongoing;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the creation timestamp of the room
|
||||
pub fn set_created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
|
||||
self.created_at = created_at.into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Updates the subject of the room
|
||||
pub fn set_subject<T>(&mut self, subject: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
self.subject = Some(subject.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Returns the members of the room
|
||||
pub fn members(&self) -> Vec<PublicKey> {
|
||||
self.members.clone()
|
||||
}
|
||||
|
||||
/// Returns the members of the room with their messaging relays
|
||||
pub fn members_with_relays(&self, cx: &App) -> Task<Vec<(PublicKey, Vec<RelayUrl>)>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let mut tasks = vec![];
|
||||
|
||||
for member in self.members.iter() {
|
||||
let task = nostr.read(cx).messaging_relays(member, cx);
|
||||
tasks.push((*member, task));
|
||||
}
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut results = vec![];
|
||||
|
||||
for (public_key, task) in tasks.into_iter() {
|
||||
let urls = task.await;
|
||||
results.push((public_key, urls));
|
||||
}
|
||||
|
||||
results
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks if the room has more than two members (group)
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.members.len() > 2
|
||||
}
|
||||
|
||||
/// Gets the display name for the room
|
||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||
if let Some(value) = self.subject.clone() {
|
||||
value
|
||||
} else {
|
||||
self.merged_name(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display image for the room
|
||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
||||
if !self.is_group() {
|
||||
self.display_member(cx).avatar()
|
||||
} else {
|
||||
SharedString::from("brand/group.png")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a member to represent the room
|
||||
///
|
||||
/// Display member is always different from the current user.
|
||||
pub fn display_member(&self, cx: &App) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
|
||||
let target_member = self
|
||||
.members
|
||||
.iter()
|
||||
.find(|&member| member != &public_key)
|
||||
.or_else(|| self.members.first())
|
||||
.expect("Room should have at least one member");
|
||||
|
||||
persons.read(cx).get(target_member, cx)
|
||||
}
|
||||
|
||||
/// Merge the names of the first two members of the room.
|
||||
fn merged_name(&self, cx: &App) -> SharedString {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
if self.is_group() {
|
||||
let profiles: Vec<Person> = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|public_key| persons.read(cx).get(public_key, cx))
|
||||
.collect();
|
||||
|
||||
let mut name = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|p| p.name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
if profiles.len() > 2 {
|
||||
name = format!("{}, +{}", name, profiles.len() - 2);
|
||||
}
|
||||
|
||||
SharedString::from(name)
|
||||
} else {
|
||||
self.display_member(cx).name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a new message signal to the current room
|
||||
pub fn emit_message(&self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomEvent::Incoming(message));
|
||||
}
|
||||
|
||||
/// Emits a signal to reload the current room's messages.
|
||||
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(RoomEvent::Reload);
|
||||
}
|
||||
|
||||
/// Get gossip relays for each member
|
||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let members = self.members();
|
||||
let id = SubscriptionId::new(format!("room-{}", self.id));
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Subscription options
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.timeout(Some(Duration::from_secs(2)))
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
for member in members.into_iter() {
|
||||
if member == public_key {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Construct a filter for gossip relays
|
||||
let filter = Filter::new().kind(Kind::RelayList).author(member).limit(1);
|
||||
|
||||
// Subscribe to get member's gossip relays
|
||||
client
|
||||
.subscribe_with_id(id.clone(), filter, Some(opts))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all messages belonging to the room
|
||||
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let conversation_id = self.id.to_string();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
|
||||
|
||||
let messages = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
|
||||
.sorted_by_key(|message| message.created_at)
|
||||
.collect();
|
||||
|
||||
Ok(messages)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new message event (unsigned)
|
||||
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
// Get current user
|
||||
let public_key = nostr.read(cx).identity().read(cx).public_key();
|
||||
|
||||
// Get room's subject
|
||||
let subject = self.subject.clone();
|
||||
|
||||
let mut tags = vec![];
|
||||
|
||||
// Add receivers
|
||||
//
|
||||
// NOTE: current user will be removed from the list of receivers
|
||||
for member in self.members.iter() {
|
||||
// Get relay hint if available
|
||||
let relay_url = nostr.read(cx).relay_hint(member, cx);
|
||||
|
||||
// Construct a public key tag with relay hint
|
||||
let tag = TagStandard::PublicKey {
|
||||
public_key: member.to_owned(),
|
||||
relay_url,
|
||||
alias: None,
|
||||
uppercase: false,
|
||||
};
|
||||
|
||||
tags.push(Tag::from_standardized_without_cell(tag));
|
||||
}
|
||||
|
||||
// Add subject tag if it's present
|
||||
if let Some(value) = subject {
|
||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||
value.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// Add reply/quote tag
|
||||
if replies.len() == 1 {
|
||||
tags.push(Tag::event(replies[0]))
|
||||
} else {
|
||||
for id in replies {
|
||||
let tag = TagStandard::Quote {
|
||||
event_id: id.to_owned(),
|
||||
relay_url: None,
|
||||
public_key: None,
|
||||
};
|
||||
tags.push(Tag::from_standardized_without_cell(tag))
|
||||
}
|
||||
}
|
||||
|
||||
// Construct a direct message event
|
||||
//
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
||||
.tags(tags)
|
||||
.build(public_key);
|
||||
|
||||
// Ensure the event id has been generated
|
||||
event.ensure_id();
|
||||
|
||||
event
|
||||
}
|
||||
|
||||
/// Create a task to send a message to all room members
|
||||
pub fn send_message(
|
||||
&self,
|
||||
rumor: &UnsignedEvent,
|
||||
cx: &App,
|
||||
) -> Task<Result<Vec<SendReport>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
// Get current user's public key and relays
|
||||
let current_user = nostr.read(cx).identity().read(cx).public_key();
|
||||
let current_user_relays = nostr.read(cx).messaging_relays(¤t_user, cx);
|
||||
|
||||
let rumor = rumor.to_owned();
|
||||
|
||||
// Get all members and their messaging relays
|
||||
let task = self.members_with_relays(cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let current_user_relays = current_user_relays.await;
|
||||
let mut members = task.await;
|
||||
|
||||
// Remove the current user's public key from the list of receivers
|
||||
// the current user will be handled separately
|
||||
members.retain(|(this, _)| this != ¤t_user);
|
||||
|
||||
// Collect the send reports
|
||||
let mut reports: Vec<SendReport> = vec![];
|
||||
|
||||
for (receiver, relays) in members.into_iter() {
|
||||
// Check if there are any relays to send the message to
|
||||
if relays.is_empty() {
|
||||
reports.push(SendReport::new(receiver).relays_not_found());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure relay connection
|
||||
for url in relays.iter() {
|
||||
client.add_relay(url).await?;
|
||||
client.connect_relay(url).await?;
|
||||
}
|
||||
|
||||
// Construct the gift wrap event
|
||||
let event =
|
||||
EventBuilder::gift_wrap(&signer, &receiver, rumor.clone(), vec![]).await?;
|
||||
|
||||
// Send the gift wrap event to the messaging relays
|
||||
match client.send_event_to(relays, &event).await {
|
||||
Ok(output) => {
|
||||
let id = output.id().to_owned();
|
||||
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
|
||||
let report = SendReport::new(receiver).status(output);
|
||||
let tracker = tracker().read().await;
|
||||
|
||||
if auth {
|
||||
// Wait for authenticated and resent event successfully
|
||||
for attempt in 0..=SEND_RETRY {
|
||||
// Check if event was successfully resent
|
||||
if tracker.is_sent_by_coop(&id) {
|
||||
let output = Output::new(id);
|
||||
let report = SendReport::new(receiver).status(output);
|
||||
reports.push(report);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if retry limit exceeded
|
||||
if attempt == SEND_RETRY {
|
||||
reports.push(report);
|
||||
break;
|
||||
}
|
||||
|
||||
smol::Timer::after(Duration::from_millis(1200)).await;
|
||||
}
|
||||
} else {
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
reports.push(SendReport::new(receiver).error(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the gift-wrapped event
|
||||
let event =
|
||||
EventBuilder::gift_wrap(&signer, ¤t_user, rumor.clone(), vec![]).await?;
|
||||
|
||||
// Only send a backup message to current user if sent successfully to others
|
||||
if reports.iter().all(|r| r.is_sent_success()) {
|
||||
// Check if there are any relays to send the event to
|
||||
if current_user_relays.is_empty() {
|
||||
reports.push(SendReport::new(current_user).relays_not_found());
|
||||
return Ok(reports);
|
||||
}
|
||||
|
||||
// Ensure relay connection
|
||||
for url in current_user_relays.iter() {
|
||||
client.add_relay(url).await?;
|
||||
client.connect_relay(url).await?;
|
||||
}
|
||||
|
||||
// Send the event to the messaging relays
|
||||
match client.send_event_to(current_user_relays, &event).await {
|
||||
Ok(output) => {
|
||||
reports.push(SendReport::new(current_user).status(output));
|
||||
}
|
||||
Err(e) => {
|
||||
reports.push(SendReport::new(current_user).error(e.to_string()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reports.push(SendReport::new(current_user).on_hold(event));
|
||||
}
|
||||
|
||||
Ok(reports)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a task to resend a failed message
|
||||
pub fn resend_message(
|
||||
&self,
|
||||
reports: Vec<SendReport>,
|
||||
cx: &App,
|
||||
) -> Task<Result<Vec<SendReport>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut resend_reports = vec![];
|
||||
|
||||
for report in reports.into_iter() {
|
||||
let receiver = report.receiver;
|
||||
|
||||
// Process failed events
|
||||
if let Some(output) = report.status {
|
||||
let id = output.id();
|
||||
let urls: Vec<&RelayUrl> = output.failed.keys().collect();
|
||||
|
||||
if let Some(event) = client.database().event_by_id(id).await? {
|
||||
for url in urls.into_iter() {
|
||||
let relay = client.pool().relay(url).await?;
|
||||
let id = relay.send_event(&event).await?;
|
||||
|
||||
let resent: Output<EventId> = Output {
|
||||
val: id,
|
||||
success: HashSet::from([url.to_owned()]),
|
||||
failed: HashMap::new(),
|
||||
};
|
||||
|
||||
resend_reports.push(SendReport::new(receiver).status(resent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the on hold event if it exists
|
||||
if let Some(event) = report.on_hold {
|
||||
// Send the event to the messaging relays
|
||||
match client.send_event(&event).await {
|
||||
Ok(output) => {
|
||||
resend_reports.push(SendReport::new(receiver).status(output));
|
||||
}
|
||||
Err(e) => {
|
||||
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resend_reports)
|
||||
})
|
||||
}
|
||||
}
|
||||
31
crates/chat_ui/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "chat_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
state = { path = "../state" }
|
||||
ui = { path = "../ui" }
|
||||
theme = { path = "../theme" }
|
||||
common = { path = "../common" }
|
||||
person = { path = "../person" }
|
||||
chat = { path = "../chat" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
indexset = "0.12.3"
|
||||
emojis = "0.6.4"
|
||||
once_cell = "1.19.0"
|
||||
regex = "1"
|
||||
17
crates/chat_ui/src/actions.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use gpui::Action;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub struct SeenOn(pub EventId);
|
||||
|
||||
/// Define a open public key action
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
||||
#[action(namespace = pubkey, no_json)]
|
||||
pub struct OpenPublicKey(pub PublicKey);
|
||||
|
||||
/// Define a copy inline public key action
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
||||
#[action(namespace = pubkey, no_json)]
|
||||
pub struct CopyPublicKey(pub PublicKey);
|
||||
139
crates/chat_ui/src/emoji.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
div, px, App, AppContext, Corner, Element, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::InputState;
|
||||
use ui::popover::{Popover, PopoverContent};
|
||||
use ui::{Icon, Sizable, Size};
|
||||
|
||||
static EMOJIS: OnceLock<Vec<SharedString>> = OnceLock::new();
|
||||
|
||||
fn get_emojis() -> &'static Vec<SharedString> {
|
||||
EMOJIS.get_or_init(|| {
|
||||
let mut emojis: Vec<SharedString> = vec![];
|
||||
|
||||
emojis.extend(
|
||||
emojis::Group::SmileysAndEmotion
|
||||
.emojis()
|
||||
.map(|e| SharedString::from(e.as_str()))
|
||||
.collect::<Vec<SharedString>>(),
|
||||
);
|
||||
|
||||
emojis
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct EmojiPicker {
|
||||
target: Option<WeakEntity<InputState>>,
|
||||
icon: Option<Icon>,
|
||||
anchor: Option<Corner>,
|
||||
size: Size,
|
||||
}
|
||||
|
||||
impl EmojiPicker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
size: Size::default(),
|
||||
target: None,
|
||||
anchor: None,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target(mut self, target: WeakEntity<InputState>) -> Self {
|
||||
self.target = Some(target);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn anchor(mut self, corner: Corner) -> Self {
|
||||
self.anchor = Some(corner);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for EmojiPicker {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EmojiPicker {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
Popover::new("emojis")
|
||||
.map(|this| {
|
||||
if let Some(corner) = self.anchor {
|
||||
this.anchor(corner)
|
||||
} else {
|
||||
this.anchor(gpui::Corner::BottomLeft)
|
||||
}
|
||||
})
|
||||
.trigger(
|
||||
Button::new("emojis-trigger")
|
||||
.when_some(self.icon, |this, icon| this.icon(icon))
|
||||
.ghost()
|
||||
.with_size(self.size),
|
||||
)
|
||||
.content(move |window, cx| {
|
||||
let input = self.target.clone();
|
||||
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, move |_window, cx| {
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.children(get_emojis().iter().map(|e| {
|
||||
div()
|
||||
.id(e.clone())
|
||||
.flex_auto()
|
||||
.size_10()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(e.clone())
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.on_click({
|
||||
let item = e.clone();
|
||||
let input = input.clone();
|
||||
|
||||
move |_, window, cx| {
|
||||
if let Some(input) = input.as_ref() {
|
||||
_ = input.update(cx, |this, cx| {
|
||||
let value = this.value();
|
||||
let new_text = if value.is_empty() {
|
||||
format!("{item}")
|
||||
} else if value.ends_with(" ") {
|
||||
format!("{value}{item}")
|
||||
} else {
|
||||
format!("{value} {item}")
|
||||
};
|
||||
this.set_value(new_text, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
.into_any()
|
||||
})
|
||||
.scrollable()
|
||||
.max_h(px(300.))
|
||||
.max_w(px(300.))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
1210
crates/chat_ui/src/lib.rs
Normal file
60
crates/chat_ui/src/subject.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use gpui::{
|
||||
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::input::{InputState, TextInput};
|
||||
use ui::{v_flex, Sizable};
|
||||
|
||||
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
|
||||
cx.new(|cx| Subject::new(subject, window, cx))
|
||||
}
|
||||
|
||||
pub struct Subject {
|
||||
input: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Plan for holiday"));
|
||||
|
||||
if let Some(value) = subject {
|
||||
input.update(cx, |this, cx| {
|
||||
this.set_value(value, window, cx);
|
||||
});
|
||||
};
|
||||
|
||||
Self { input }
|
||||
}
|
||||
|
||||
pub fn new_subject(&self, cx: &App) -> SharedString {
|
||||
self.input.read(cx).value()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Subject {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Subject:")),
|
||||
)
|
||||
.child(TextInput::new(&self.input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.italic()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::from(
|
||||
"Subject will be updated when you send a new message.",
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
323
crates/chat_ui/src/text.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
|
||||
StyledText, UnderlineStyle, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use person::PersonRegistry;
|
||||
use regex::Regex;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::actions::OpenPublicKey;
|
||||
|
||||
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
|
||||
});
|
||||
|
||||
static NOSTR_URI_REGEX: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Link,
|
||||
Nostr,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RenderedText {
|
||||
pub text: SharedString,
|
||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||
pub link_ranges: Vec<Range<usize>>,
|
||||
pub link_urls: Arc<[String]>,
|
||||
}
|
||||
|
||||
impl RenderedText {
|
||||
pub fn new(content: &str, cx: &App) -> Self {
|
||||
let mut text = String::new();
|
||||
let mut highlights = Vec::new();
|
||||
let mut link_ranges = Vec::new();
|
||||
let mut link_urls = Vec::new();
|
||||
|
||||
render_plain_text_mut(
|
||||
content,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
cx,
|
||||
);
|
||||
|
||||
text.truncate(text.trim_end().len());
|
||||
|
||||
RenderedText {
|
||||
text: SharedString::from(text),
|
||||
link_urls: link_urls.into(),
|
||||
link_ranges,
|
||||
highlights,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||
let link_color = cx.theme().text_accent;
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
StyledText::new(self.text.clone()).with_default_highlights(
|
||||
&window.text_style(),
|
||||
self.highlights.iter().map(|(range, highlight)| {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Link => HighlightStyle {
|
||||
color: Some(link_color),
|
||||
underline: Some(UnderlineStyle::default()),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Nostr => HighlightStyle {
|
||||
color: Some(link_color),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.on_click(self.link_ranges.clone(), {
|
||||
let link_urls = self.link_urls.clone();
|
||||
move |ix, window, cx| {
|
||||
let token = link_urls[ix].as_str();
|
||||
|
||||
if let Some(clean_url) = token.strip_prefix("nostr:") {
|
||||
if let Ok(public_key) = PublicKey::parse(clean_url) {
|
||||
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
|
||||
}
|
||||
} else if is_url(token) {
|
||||
let url = if token.starts_with("http") {
|
||||
token.to_string()
|
||||
} else {
|
||||
format!("https://{token}")
|
||||
};
|
||||
cx.open_url(&url);
|
||||
} else {
|
||||
log::warn!("Unrecognized token {token}")
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_plain_text_mut(
|
||||
content: &str,
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
cx: &App,
|
||||
) {
|
||||
// Copy the content directly
|
||||
text.push_str(content);
|
||||
|
||||
// Collect all URLs
|
||||
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||
|
||||
for link in URL_REGEX.find_iter(content) {
|
||||
let range = link.start()..link.end();
|
||||
let url = link.as_str().to_string();
|
||||
|
||||
url_matches.push((range, url));
|
||||
}
|
||||
|
||||
// Collect all nostr entities with nostr: prefix
|
||||
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||
|
||||
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
|
||||
let range = nostr_match.start()..nostr_match.end();
|
||||
let nostr_uri = nostr_match.as_str().to_string();
|
||||
|
||||
// Check if this nostr URI overlaps with any already processed URL
|
||||
if !url_matches
|
||||
.iter()
|
||||
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
|
||||
{
|
||||
nostr_matches.push((range, nostr_uri));
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all matches for processing from end to start
|
||||
let mut all_matches = Vec::new();
|
||||
all_matches.extend(url_matches);
|
||||
all_matches.extend(nostr_matches);
|
||||
|
||||
// Sort by position (end to start) to avoid changing positions when replacing text
|
||||
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
|
||||
|
||||
// Process all matches
|
||||
for (range, entity) in all_matches {
|
||||
// Handle URL token
|
||||
if is_url(&entity) {
|
||||
highlights.push((range.clone(), Highlight::Link));
|
||||
link_ranges.push(range);
|
||||
link_urls.push(entity);
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(nip21) = Nip21::parse(&entity) {
|
||||
match nip21 {
|
||||
Nip21::Pubkey(public_key) => {
|
||||
render_pubkey(
|
||||
public_key,
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Nip21::Profile(nip19_profile) => {
|
||||
render_pubkey(
|
||||
nip19_profile.public_key,
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Nip21::EventId(event_id) => {
|
||||
render_bech32(
|
||||
event_id.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
Nip21::Event(nip19_event) => {
|
||||
render_bech32(
|
||||
nip19_event.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
Nip21::Coordinate(nip19_coordinate) => {
|
||||
render_bech32(
|
||||
nip19_coordinate.to_bech32().unwrap(),
|
||||
text,
|
||||
&range,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a string is a URL
|
||||
fn is_url(s: &str) -> bool {
|
||||
URL_REGEX.is_match(s)
|
||||
}
|
||||
|
||||
/// Format a bech32 entity with ellipsis and last 4 characters
|
||||
fn format_shortened_entity(entity: &str) -> String {
|
||||
let prefix_end = entity.find('1').unwrap_or(0);
|
||||
|
||||
if prefix_end > 0 && entity.len() > prefix_end + 5 {
|
||||
let prefix = &entity[0..=prefix_end]; // Include the '1'
|
||||
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
|
||||
|
||||
format!("{prefix}...{suffix}")
|
||||
} else {
|
||||
entity.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pubkey(
|
||||
public_key: PublicKey,
|
||||
text: &mut String,
|
||||
range: &Range<usize>,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
cx: &App,
|
||||
) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
let display_name = format!("@{}", profile.name());
|
||||
|
||||
text.replace_range(range.clone(), &display_name);
|
||||
|
||||
let new_length = display_name.len();
|
||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||
let new_range = range.start..(range.start + new_length);
|
||||
|
||||
highlights.push((new_range.clone(), Highlight::Nostr));
|
||||
link_ranges.push(new_range);
|
||||
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
|
||||
|
||||
if length_diff != 0 {
|
||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_bech32(
|
||||
bech32: String,
|
||||
text: &mut String,
|
||||
range: &Range<usize>,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
let njump_url = format!("https://njump.me/{bech32}");
|
||||
let shortened_entity = format_shortened_entity(&bech32);
|
||||
let display_text = format!("https://njump.me/{shortened_entity}");
|
||||
|
||||
text.replace_range(range.clone(), &display_text);
|
||||
|
||||
let new_length = display_text.len();
|
||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||
let new_range = range.start..(range.start + new_length);
|
||||
|
||||
highlights.push((new_range.clone(), Highlight::Link));
|
||||
link_ranges.push(new_range);
|
||||
link_urls.push(njump_url);
|
||||
|
||||
if length_diff != 0 {
|
||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to adjust ranges when text length changes
|
||||
fn adjust_ranges(
|
||||
highlights: &mut [(Range<usize>, Highlight)],
|
||||
link_ranges: &mut [Range<usize>],
|
||||
position: usize,
|
||||
length_diff: isize,
|
||||
) {
|
||||
// Adjust highlight ranges
|
||||
for (range, _) in highlights.iter_mut() {
|
||||
if range.start > position {
|
||||
range.start = (range.start as isize + length_diff) as usize;
|
||||
range.end = (range.end as isize + length_diff) as usize;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust link ranges
|
||||
for range in link_ranges.iter_mut() {
|
||||
if range.start > position {
|
||||
range.start = (range.start as isize + length_diff) as usize;
|
||||
range.end = (range.end as isize + length_diff) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||