124 Commits

Author SHA1 Message Date
5bef1a2c6c chore: fix flatpak 2025-08-10 14:58:59 +07:00
cd26244538 chore: bump version 2025-08-10 12:50:52 +07:00
reya
ca622d1262 chore: improve logout behavior (#118)
* resubscribe on logout

* .

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

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

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

* refactor

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

* .

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

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

* fix ci

* fix build

* fix ci on linux

* .

* fix flatpak

* .

* .

* .

* fix snap

* .

* .

* .

* .

* fix

* .

* .

* fix path

* .

* .

* fix upload artifacts

* fix build on arm

* fix snap arm

* .

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

* update window options

* linux title bar

* fix build

* .

* fix build

* rounded corners on linux

* .

* .

* fix i18n key

* fix change subject modal

* .

* update new account

* .

* update relay modal

* .

* fix i18n keys

---------

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

* close panel on ignore

* bypass screening

* .

* improve settings

* refine modal

* .

* .

* .

* .

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

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

* .

* refactor

* .

* screening

* add report user function

* add danger and warning styles

* update deps

* update

* fix line height

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

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

* .

* update input to catch up with gpui-component

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

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

* refactor upload button

* refactor

* dispatch action on mention clicked

* add profile modal

* .

* .

* .

* improve rich_text

* improve handle url

* make registry simpler

* refactor

* .

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

* .

* wip: add nip05 search

* add nip05 search

* .

* support cancel search

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

* refactor

* add documents

* clean up

* refactor

* clean up

* refactor identity

* .

* .

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

* add rust-i18n

* translate

* .

* update translations

* fix

* update translate

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

* add auto login status

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

* script/linux:add libx11-dev

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

---------

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

* simplify nostr connect uri logic

* improve wait for connection

* improve handle password

* .

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

* .

* .

* .

* chore: prevent update local search with empty result

* clean up

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

* refactor

* refactor

* refactor

* improve extend rooms function

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

* .

* .

* .

* refactor client keys

* .

* .

* refactor

* .

* .

* .

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

* .

* .

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

* update

* .

* rustfmt

* .

* .

* .

* .

* .

* add document

* .

* add logout

* handle error

* chore: update gpui

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

* .

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

* middle click to close tab

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

* .

* add some tooltips

* .

* fix button hovered style

* .

* improve prevent duplicate message

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

* move profile cache to outside gpui context

* update comment

* refactor

* refactor

* .

* .

* add avatar component

* .

* refactor

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

* show reply message

* refactor

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

* optimistically update message list

* fix

* update

* handle duplicate messages

* update ui

* refactor input

* update multi line input

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

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

* redesign

* search

* fix

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

* update

* temporary switch to rust-nostr fork

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

* update

* improve search

* add error msg
2025-05-02 17:03:49 +07:00
2c2aeb915e feat: add option for toggle chat folders 2025-04-30 13:10:18 +07:00
44f0650617 chore: update gpui 2025-04-28 15:08:47 +07:00
reya
107fedeafd feat: Emoji Picker (#18)
* wip: emoji picker

* update
2025-04-26 08:39:52 +07:00
17251be3fd chore: improve ui responsiveness when send message 2025-04-23 09:05:50 +07:00
73b2eac080 feat: add image cache 2025-04-23 08:16:26 +07:00
86eca5803f feat: add support for subject of conversation 2025-04-22 15:10:36 +07:00
52a79dca08 chore: bump version 2025-04-21 15:19:01 +07:00
87f038248c chore: refactor account and fixes 2025-04-21 15:18:02 +07:00
reya
a30f2dcc8a refactor ui (#17)
* wip: redesign sidebar

* wip: adjust dpi

* update

* update

* refactor modal

* fix modal
2025-04-18 13:43:07 +07:00
5c5748a80c chore: restructure 2025-04-13 08:03:22 +07:00
reya
b667dd3f1c feat: Nostr Auto Updater (#16)
* clean up

* fix version

* add auto updater

* add windows
2025-04-12 12:33:30 +07:00
reya
3246abace1 refactor chats (#15)
* refactor

* update

* update

* update

* remove nostrprofile struct

* update

* refactor contacts

* prevent double login
2025-04-10 08:10:53 +07:00
reya
f7610cc9c9 feat: Chat Folders (#14)
* add room kinds

* add folders

* adjust design

* update

* refactor

* cache

* update
2025-04-06 15:29:36 +07:00
16530a3804 chore: only subscribe for metadata in specific relays 2025-03-28 18:53:43 +07:00
b778bb13e4 chore: clean up 2025-03-28 17:34:42 +07:00
reya
cfc2300c0c feat: Rich Text Rendering (#13)
* add text

* fix avatar is not show

* refactor chats

* improve rich text

* add benchmark for text

* update
2025-03-28 09:49:07 +07:00
42d6328d82 chore: update gpui 2025-03-25 20:53:22 +07:00
4c9533bfe4 chore: fix high cpu 2025-03-25 15:04:41 +07:00
reya
00cf7792e5 feat: Out-of-Box Experience (#12)
* refactor app view

* feat: onboarding

* add back buttons in onboarding
2025-03-25 12:34:39 +07:00
e15cbcc22c chore: update deps 2025-03-19 08:30:19 +07:00
348dc496a6 chore: bump version 2025-03-13 13:50:16 +07:00
09df38a3b2 chore: fix build on linux 2025-03-13 13:22:31 +07:00
cae96157ca chore: release 0.1.4 2025-03-13 13:02:58 +07:00
0a7f0475a4 chore: small fixes 2025-03-12 16:44:44 +07:00
8156d9d046 chore: small fixes 2025-03-11 13:22:44 +07:00
b92d446184 chore: follow up to 73b8a1a 2025-03-10 14:56:18 +07:00
73b8a1a6da chore: some fixes for nip4e 2025-03-10 13:25:58 +07:00
ba0b377cee chore: update nstart url 2025-03-10 09:40:14 +07:00
0822b46596 feat: follow-up to d93cecb 2025-03-10 08:34:41 +07:00
d93cecbea3 chore: refactor NIP-4E implementation 2025-03-09 18:31:29 +07:00
0887970374 chore: update deps 2025-03-08 19:32:07 +07:00
reya
a53b2181ab feat: Implemented NIP-4e (#11)
* chore: refactor account registry

* wip: nip4e

* chore: rename account to device

* feat: nip44 encryption with master signer

* update

* refactor

* feat: unwrap with device keys

* chore: improve handler

* chore: fix rustls

* chore: refactor onboarding

* chore: fix compose

* chore: fix send message

* chore: fix forgot to request device

* fix send message

* chore: fix deadlock

* chore: small fixes

* chore: improve

* fix

* refactor

* refactor

* refactor

* fix

* add fetch request

* save keys

* fix

* update

* update

* update
2025-03-08 19:29:25 +07:00
81664e3d4e feat: add empty and loading states for the inbox section 2025-02-26 08:01:45 +07:00
29ec6da872 chore: fix the issue when new user cannot see their messages 2025-02-25 18:21:10 +07:00
111ab3b082 chore: internal changes 2025-02-25 15:22:24 +07:00
1c4806bd92 chore: refactor chat room 2025-02-24 16:18:21 +07:00
3f8c02aef8 chore: bump version 2025-02-23 14:14:20 +07:00
b73babf274 feat: add new default avatar 2025-02-23 14:13:56 +07:00
reya
bbc778d5ca feat: sharpen chat experiences (#9)
* feat: add global account and refactor chat registry

* chore: improve last seen

* chore: reduce string alloc

* wip: refactor room

* chore: fix edit profile panel

* chore: refactor open window in main

* chore: refactor sidebar

* chore: refactor room
2025-02-23 08:29:05 +07:00
cfa628a8a6 feat: automatically load inbox on startup 2025-02-19 15:35:14 +07:00
5e1d76bbcd chore: bump version 2025-02-19 09:22:49 +07:00
61fb90bd34 chore: improve chat panel 2025-02-19 09:08:29 +07:00
50242981a5 feat: sort inbox by time after added new messages 2025-02-18 17:04:19 +07:00
85c485a4e4 feat: refactor async task and remove tokio as dep 2025-02-18 16:43:30 +07:00
48af00950a fix: cannot launch app on linux 2025-02-17 13:23:32 +07:00
31e94c53c6 chore: remove cargo-packager-updater (it sucks) 2025-02-16 20:32:23 +07:00
ae01a2d67a chore: fix version 2025-02-16 20:06:57 +07:00
253 changed files with 23910 additions and 13039 deletions

View File

@@ -1,81 +0,0 @@
name: Packager Release Process
run-name: Triggered by ${{ github.actor }}.
on: workflow_dispatch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CN_APPLICATION: lume/coop
jobs:
draft:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create draft release
uses: crabnebula-dev/cloud-release@v0
with:
command: release draft ${{ env.CN_APPLICATION }} --framework packager
api-key: ${{ secrets.CN_API_KEY }}
build:
needs: draft
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Install stable toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
cache: true
- name: Install dependencies (ubuntu only)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y gcc g++ libasound2-dev libfontconfig-dev libwayland-dev libxkbcommon-x11-dev libssl-dev libzstd-dev libvulkan1 libgit2-dev make cmake clang jq netcat-openbsd git curl gettext-base elfutils libsqlite3-dev musl-tools musl-dev build-essential
- name: install cargo packager
run: |
cargo install cargo-packager --locked
- name: Build packager app
run: |
cargo packager --release
- name: Move assets to workdir
run: |
mv target/release/* .
- name: Upload assets
uses: crabnebula-dev/cloud-release@v0
with:
command: release upload ${{ env.CN_APPLICATION }} --framework packager
api-key: ${{ secrets.CN_API_KEY }}
publish:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish release
uses: crabnebula-dev/cloud-release@v0
with:
command: release publish ${{ env.CN_APPLICATION }} --framework packager
api-key: ${{ secrets.CN_API_KEY }}

172
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,172 @@
name: Build and Release
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: windows-x64
os: windows-latest
target: x86_64-pc-windows-msvc
- platform: windows-arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- platform: macos-x64
os: macos-13
target: x86_64-apple-darwin
- platform: macos-arm64
os: macos-latest
target: aarch64-apple-darwin
- platform: linux-x64
os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- platform: linux-arm64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# Windows and macOS builds using cargo-packager
- name: Build with cargo-packager (Windows/macOS)
if: runner.os != 'Linux'
working-directory: crates/coop
run: |
cargo install cargo-packager --locked
cargo packager --release
- name: Upload Windows/macOS artifacts
if: runner.os != 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}-artifacts
path: |
dist/*.dmg
dist/*.msi
dist/*.exe
if-no-files-found: error
# Linux builds using custom scripts
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder snapd squashfs-tools jq gettext-base
- name: Install Snapcraft
if: runner.os == 'Linux'
run: sudo snap install snapcraft --classic
- name: Make scripts executable
if: runner.os == 'Linux'
run: |
chmod +x script/get-crate-version
chmod +x script/linux
chmod +x script/bundle-snap
chmod +x script/bundle-linux
chmod +x script/flatpak/deps
chmod +x script/flatpak/bundle-flatpak
- name: Install required dependencies
if: runner.os == 'Linux'
run: ./script/linux
# Only build Flatpak and Snap for x86_64 (most common use case)
- name: Build Flatpak
if: runner.os == 'Linux'
run: |
./script/bundle-linux --flatpak
./script/flatpak/deps
./script/flatpak/bundle-flatpak
- name: Build Snap
if: runner.os == 'Linux'
run: |
VERSION=$(script/get-crate-version coop)
./script/bundle-linux
./script/bundle-snap $VERSION
- name: Collect Linux artifacts
if: runner.os == 'Linux'
working-directory: ${{ github.workspace }}
run: |
mkdir -p linux-artifacts
# Copy the tarball created by bundle-linux
find target/release -name "*.tar.gz" -exec cp {} linux-artifacts/ \;
# Find and copy flatpak files (if they exist)
find . -name "*.flatpak" -exec cp {} linux-artifacts/ \; || true
# Find and copy snap files (if they exist)
find . -name "*.snap" -exec cp {} linux-artifacts/ \; || true
ls -la linux-artifacts/
- name: Upload Linux artifacts
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}-artifacts
path: linux-artifacts/**/*
if-no-files-found: error
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Make get-crate-version executable
run: chmod +x script/get-crate-version
- name: Get version
id: version
run: |
VERSION=$(script/get-crate-version coop)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display artifacts structure
run: |
echo "Artifacts structure:"
find artifacts -type f -exec ls -la {} \;
- name: Create draft release
id: create_release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Release ${{ steps.version.outputs.tag }}
draft: true
prerelease: false
generate_release_notes: true
files: |
artifacts/**/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Output release info
run: |
echo "Created draft release: ${{ steps.create_release.outputs.url }}"
echo "Release ID: ${{ steps.create_release.outputs.id }}"

32
.github/workflows/rust.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Rust
on:
push:
branches: ["**"]
pull_request:
branches: ["m**"]
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
rustup: [stable]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: script/linux
run: chmod +x ./script/linux && ./script/linux
if: matrix.os == 'ubuntu-latest'
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose

2
.gitignore vendored
View File

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

3770
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,58 @@
[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",
"all-nips",
] }
smol = "2"
tokio = { version = "1", features = ["full"] }
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"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
[workspace]
resolver = "2"
members = ["crates/*"]
default-members = ["crates/coop"]
[workspace.package]
version = "0.2.2"
edition = "2021"
publish = false
[workspace.metadata.i18n]
available-locales = ["en"]
default-locale = "en"
load-path = "locales"
[workspace.dependencies]
i18n = { path = "crates/i18n" }
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"lmdb",
"nip96",
"nip59",
"nip49",
"nip44",
] }
# Others
anyhow = "1.0.44"
chrono = "0.4.38"
dirs = "5.0"
emojis = "0.6.4"
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
oneshot = "0.1.10"
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
rust-embed = "8.5.0"
rust-i18n = "3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smallvec = "1.14.0"
smol = "2"
tracing = "0.1.40"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

View File

@@ -1,18 +0,0 @@
name = "coop"
description = "Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability."
product-name = "Coop"
identifier = "su.reya.coop"
version = "0.1.1"
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 },
]

View File

@@ -1,19 +1,34 @@
![CoopDemo](/docs/coop.jpg)
![Coop](/docs/coop.png)
<p>
<a href="https://github.com/lumehq/coop/actions/workflows/main.yml">
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/main.yml/badge.svg">
<a href="https://github.com/lumehq/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/lumehq/coop/actions/workflows/rust.yml/badge.svg">
</a>
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/lumehq/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/lumehq/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/lumehq/coop">
</p>
Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability.
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
**New**✨: A blog post introducing Coop in details has been posted [here](#).
### Screenshots
> Coop is currently in the **alpha stage** of development. This means the app may contain bugs, incomplete features, or unexpected behavior. We recommend using it for testing purposes only and not for critical or sensitive communications. Your feedback is invaluable in helping us improve Coop, so please report any issues or suggestions via the [GitHub Issue Tracker](https://github.com/lumehq/coop/issues). Thank you for your understanding and support!
<p float="left">
<img src="/docs/mac_01.png" width="250" />
<img src="/docs/mac_02.png" width="250" />
<img src="/docs/mac_03.png" width="250" />
<img src="/docs/mac_04.png" width="250" />
<img src="/docs/mac_05.png" width="250" />
<img src="/docs/mac_06.png" width="250" />
<img src="/docs/mac_07.png" width="250" />
<img src="/docs/mac_08.png" width="250" />
<img src="/docs/mac_09.png" width="250" />
<img src="/docs/linux_01.png" width="250" />
<img src="/docs/linux_02.png" width="250" />
<img src="/docs/linux_03.png" width="250" />
<img src="/docs/linux_04.png" width="250" />
<img src="/docs/linux_05.png" width="250" />
</p>
### Installation
@@ -28,9 +43,7 @@ To install Coop, follow these steps:
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
- **Ubuntu**: Run the downloaded `.deb` or `.AppImage` installer and follow the on-screen instructions.
- **Arch Linux**: For `.tar.gz` packages, extract and install manually. For PKGBUILD, use `makepkg -si` to build and install.
- **Flatpak**: Coming soon.
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
3. **Run Coop**:
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
@@ -56,13 +69,25 @@ Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nos
cd coop
```
2. Install dependencies:
2.1 Install Linux dependencies:
```bash
./script/linux
```
2.2 Install FreeBSD dependencies:
```bash
./script/freebsd
```
3. Install Rust dependencies:
```bash
cargo build
```
3. Run the app:
4. Run the app:
```bash
cargo run
```
@@ -88,14 +113,6 @@ If you'd like to contribute to Coop, please follow these steps:
For more information, see the [Contributing](#contributing) section.
#### Debugging
To debug Coop, you can use `cargo`'s built-in debugging tools or attach a debugger like `gdb` or `lldb`. For example:
```bash
cargo run -- --debug
```
#### Additional Resources
- [Rust Nostr](https://github.com/rust-nostr/nostr/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
assets/brand/group.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,92 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,92 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -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="M19.25 21.25H6c-.69 0-1.25-.56-1.25-1.25M11.5 9.25h1M4.75 20V4.75a2 2 0 0 1 2-2h12.5v16H6c-.69 0-1.25.56-1.25 1.25Zm5-6.25s0-1.5 2.25-1.5 2.25 1.5 2.25 1.5h-4.5ZM13 9.25a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10 5.75 3.75 12 10 18.25M4.5 12h15.75"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 5.75 20.25 12 14 18.25M19.5 12H3.75"/>
</svg>

After

Width:  |  Height:  |  Size: 245 B

View 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

View File

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

After

Width:  |  Height:  |  Size: 488 B

View 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

View File

Before

Width:  |  Height:  |  Size: 247 B

After

Width:  |  Height:  |  Size: 247 B

View 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

View 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

View 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

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

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" 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

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill="#000" fill-rule="evenodd" d="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

View File

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 429 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="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

View File

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

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill="#000" fill-rule="evenodd" d="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
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8v.75m0-3.75v-.25a2 2 0 0 1 2-2H11m8 0h.25a2 2 0 0 1 2 2V5M14 2.75h2M21.25 8v2m0 3v.25a2 2 0 0 1-2 2H19m-3 0h-.75M14 8.75H4c-.69 0-1.25.56-1.25 1.25v10c0 .69.56 1.25 1.25 1.25h10c.69 0 1.25-.56 1.25-1.25V10c0-.69-.56-1.25-1.25-1.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M4 4.75A2.75 2.75 0 0 1 6.75 2h10.5A2.75 2.75 0 0 1 20 4.75v7.917a3.9 3.9 0 0 0-4.091.909l-3.75 3.75a2.25 2.25 0 0 0-.659 1.59v2.334c0 .263.045.515.128.75H6.75A2.75 2.75 0 0 1 4 19.25V4.75Z"/>
<path fill="currentColor" fill-rule="evenodd" d="M19.303 15.697a.9.9 0 0 0-1.273 0l-3.53 3.53V20.5h1.273l3.53-3.53a.9.9 0 0 0 0-1.273Zm-2.333-1.06a2.4 2.4 0 1 1 3.394 3.393l-3.75 3.75a.75.75 0 0 1-.53.22H13.75a.75.75 0 0 1-.75-.75v-2.333a.75.75 0 0 1 .22-.53l3.75-3.75Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="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

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M12 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

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M5.75 3A2.75 2.75 0 0 0 3 5.75v1.422c0 .729.29 1.428.805 1.944l4.829 4.829c.234.234.366.552.366.883v6.422a.75.75 0 0 0 .95.723l4.5-1.25A.75.75 0 0 0 15 20v-5.172c0-.331.132-.649.366-.883l4.829-4.829A2.75 2.75 0 0 0 21 7.172V5.75A2.75 2.75 0 0 0 18.25 3H5.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 396 B

3
assets/icons/filter.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="M18.25 3.75H5.75a2 2 0 0 0-2 2v1.422a2 2 0 0 0 .586 1.414l4.828 4.828a2 2 0 0 1 .586 1.414v6.422l4.5-1.25v-5.172a2 2 0 0 1 .586-1.414l4.828-4.828a2 2 0 0 0 .586-1.414V5.75a2 2 0 0 0-2-2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 369 B

3
assets/icons/folder.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 350 B

3
assets/icons/forward.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m21.76 11.45-8.146-7.535a.75.75 0 0 0-1.26.55V8a.51.51 0 0 1-.504.504C3.765 8.632 1.604 11.92 1.604 20.25c1.47-2.94 2.22-4.679 10.245-4.748a.501.501 0 0 1 .505.498v3.535a.75.75 0 0 0 1.26.55l8.145-7.535a.75.75 0 0 0 0-1.1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@@ -1,3 +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

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.75 19.25h2.596c1.163 0 2.106-1.001 1.788-2.12-.733-2.573-2.465-4.38-5.134-4.38-.446 0-.866.05-1.26.147M11.25 7a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Zm8.5.5a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0ZM2.08 18.126c.78-3.14 2.78-5.376 5.92-5.376s5.14 2.237 5.918 5.376c.28 1.128-.658 2.124-1.82 2.124H3.901c-1.162 0-2.1-.996-1.82-2.124Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 550 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 5.816h8.5M8 5.75v-2m4 10.5C7.935 13.198 5.845 10.614 5.25 6"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 14c4.064-1.02 6.154-3.527 6.75-8m3.594 11.125h5.312m1.594 2.125-3.314-8.774c-.326-.862-1.546-.862-1.872 0L12.75 19.25"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

3
assets/icons/logout.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 302 B

View File

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

After

Width:  |  Height:  |  Size: 429 B

View File

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

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18.25 14v3.05c0 1.12 0 1.68-.218 2.108a2 2 0 0 1-.874.874c-.428.218-.988.218-2.108.218h-8.1c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874c-.218-.428-.218-.988-.218-2.108V8.875c0-1.05 0-1.574.192-1.98a2 2 0 0 1 .953-.953c.406-.192.93-.192 1.98-.192H9.25m4.5-2h6.5m0 0v6.5m0-6.5L11 13"/>
</svg>

After

Width:  |  Height:  |  Size: 489 B

View File

@@ -0,0 +1 @@
<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

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3.75V12m0 0v8.25M12 12H3.75M12 12h8.25"/>
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 4v8m0 0v8m0-8H4m8 0h8"/>
</svg>

Before

Width:  |  Height:  |  Size: 248 B

After

Width:  |  Height:  |  Size: 205 B

3
assets/icons/reply.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m1.845 11.45 8.146-7.535a.75.75 0 0 1 1.259.55V8c0 .276.228.5.504.504C19.84 8.632 22 11.92 22 20.25c-1.47-2.94-2.22-4.679-10.245-4.748a.501.501 0 0 0-.505.498v3.535a.75.75 0 0 1-1.26.55L1.846 12.55a.75.75 0 0 1 0-1.1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

3
assets/icons/report.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20.001 16-2 2m0 0-2 2m2-2-2-2m2 2 2 2m-8.147-6.749c-3.319.058-5.832 2.055-6.87 4.862-.41 1.105.535 2.137 1.713 2.137h5.554m-.397-6.999L12 13.25c.52 0 1.021.047 1.5.138m-1.647-.137A7.89 7.89 0 0 0 10 13.5m5.75-7a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 462 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M3 11a8 8 0 1 1 14.162 5.102l3.368 3.368a.75.75 0 1 1-1.06 1.06l-3.368-3.368A8 8 0 0 1 3 11Z"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20.25 20.25-4.123-4.123m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m20 20-3.873-3.873m0 0A7.25 7.25 0 1 0 5.873 5.873a7.25 7.25 0 0 0 10.253 10.253Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 287 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="m7.878 5.214-.703-.162a1.77 1.77 0 0 0-2.123 2.123l.162.703a2 2 0 0 1-.84 2.114l-.854.57a1.728 1.728 0 0 0 0 2.876l.855.57a2 2 0 0 1 .84 2.114l-.163.703a1.77 1.77 0 0 0 2.123 2.123l.703-.162a2 2 0 0 1 2.114.84l.57.854a1.728 1.728 0 0 0 2.876 0l.57-.855a2 2 0 0 1 2.114-.84l.703.163a1.77 1.77 0 0 0 2.123-2.123l-.162-.703a2 2 0 0 1 .84-2.114l.854-.57a1.728 1.728 0 0 0 0-2.876l-.855-.57a2 2 0 0 1-.84-2.114l.163-.703a1.77 1.77 0 0 0-2.123-2.123l-.703.162a2 2 0 0 1-2.114-.84l-.57-.854a1.728 1.728 0 0 0-2.876 0l-.57.855a2 2 0 0 1-2.114.84Z"/>
<path stroke="currentColor" stroke-linejoin="round" stroke-width="1.5" d="M14.75 12a2.75 2.75 0 1 1-5.5 0 2.75 2.75 0 0 1 5.5 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 855 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M1 11.75A5.75 5.75 0 0 1 6.75 6h10.5A5.75 5.75 0 0 1 23 11.75v.5A5.75 5.75 0 0 1 17.25 18H6.75A5.75 5.75 0 0 1 1 12.25v-.5ZM17 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

3
assets/icons/toggle.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-width="1.5" d="M17 17.25H7a5.25 5.25 0 1 1 0-10.5h10m0 10.5a5.25 5.25 0 1 0 0-10.5m0 10.5a5.25 5.25 0 1 1 0-10.5"/>
</svg>

After

Width:  |  Height:  |  Size: 256 B

View File

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

View 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="M64.12,147.8a4,4,0,0,1-4,4.2H16a8,8,0,0,1-7.8-6.17,8.35,8.35,0,0,1,1.62-6.93A67.79,67.79,0,0,1,37,117.51a40,40,0,1,1,66.46-35.8,3.94,3.94,0,0,1-2.27,4.18A64.08,64.08,0,0,0,64,144C64,145.28,64,146.54,64.12,147.8Zm182-8.91A67.76,67.76,0,0,0,219,117.51a40,40,0,1,0-66.46-35.8,3.94,3.94,0,0,0,2.27,4.18A64.08,64.08,0,0,1,192,144c0,1.28,0,2.54-.12,3.8a4,4,0,0,0,4,4.2H240a8,8,0,0,0,7.8-6.17A8.33,8.33,0,0,0,246.17,138.89Zm-89,43.18a48,48,0,1,0-58.37,0A72.13,72.13,0,0,0,65.07,212,8,8,0,0,0,72,224H184a8,8,0,0,0,6.93-12A72.15,72.15,0,0,0,157.19,182.07Z"></path></svg>

After

Width:  |  Height:  |  Size: 676 B

View File

@@ -1,34 +0,0 @@
[package]
name = "coop"
version = "0.0.0"
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
tokio.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
smol.workspace = true
rustls = "0.23.23"
cargo-packager-updater = "0.2.2"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
log = "0.4"

View File

@@ -1,27 +0,0 @@
use anyhow::anyhow;
use gpui::{AssetSource, Result, SharedString};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../../assets"]
pub struct Assets;
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
Self::get(path)
.map(|f| Some(f.data))
.ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path))
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
Ok(Self::iter()
.filter_map(|p| {
if p.starts_with(path) {
Some(p.into())
} else {
None
}
})
.collect())
}
}

View File

@@ -1,436 +0,0 @@
use asset::Assets;
use async_utility::task::spawn;
use chats::registry::ChatRegistry;
use common::{
constants::{
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, FAKE_SIG, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID,
},
profile::NostrProfile,
};
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::prelude::*;
use state::{get_client, initialize_client};
use std::{borrow::Cow, collections::HashSet, str::FromStr, sync::Arc, time::Duration};
use tokio::sync::{mpsc, oneshot};
use ui::{theme::Theme, Root};
use views::{app, onboarding, startup};
mod asset;
mod views;
actions!(main_menu, [Quit]);
#[derive(Clone)]
pub enum Signal {
/// Receive event
Event(Event),
/// Receive EOSE
Eose,
}
fn main() {
// Issue: https://github.com/snapview/tokio-tungstenite/issues/353
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
// Initialize Nostr client
initialize_client();
// Get client
let client = get_client();
let (signal_tx, mut signal_rx) = tokio::sync::mpsc::channel::<Signal>(2048);
spawn(async move {
// Add some bootstrap relays
_ = 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://directory.yabu.me/").await;
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
// Connect to all relays
_ = client.connect().await
});
spawn(async move {
let (batch_tx, mut batch_rx) = mpsc::channel::<Cow<Event>>(20);
async fn sync_metadata(client: &Client, buffer: &HashSet<PublicKey>) {
let filter = Filter::new()
.authors(buffer.iter().copied())
.kind(Kind::Metadata)
.limit(buffer.len());
if let Err(e) = client.sync(filter, &SyncOptions::default()).await {
error!("NEG error: {e}");
}
}
async fn process_batch(client: &Client, events: &[Cow<'_, Event>]) {
let sig = Signature::from_str(FAKE_SIG).unwrap();
let mut buffer: HashSet<PublicKey> = HashSet::with_capacity(20);
for event in events.iter() {
if let Ok(UnwrappedGift { mut rumor, sender }) =
client.unwrap_gift_wrap(event).await
{
let pubkeys: HashSet<PublicKey> = event.tags.public_keys().copied().collect();
buffer.extend(pubkeys);
buffer.insert(sender);
// Create event's ID is not exist
rumor.ensure_id();
// Save event to database
if let Some(id) = rumor.id {
let ev = Event::new(
id,
rumor.pubkey,
rumor.created_at,
rumor.kind,
rumor.tags,
rumor.content,
sig,
);
if let Err(e) = client.database().save_event(&ev).await {
error!("Save error: {}", e);
}
}
}
}
sync_metadata(client, &buffer).await;
}
// Spawn a thread to handle batch process
spawn(async move {
const BATCH_SIZE: usize = 20;
const BATCH_TIMEOUT: Duration = Duration::from_millis(200);
let mut batch = Vec::with_capacity(20);
let mut timeout = Box::pin(tokio::time::sleep(BATCH_TIMEOUT));
loop {
tokio::select! {
event = batch_rx.recv() => {
if let Some(event) = event {
batch.push(event);
if batch.len() == BATCH_SIZE {
process_batch(client, &batch).await;
batch.clear();
timeout = Box::pin(tokio::time::sleep(BATCH_TIMEOUT));
}
} else {
break;
}
}
_ = &mut timeout => {
if !batch.is_empty() {
process_batch(client, &batch).await;
batch.clear();
}
timeout = Box::pin(tokio::time::sleep(BATCH_TIMEOUT));
}
}
}
});
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let sig = Signature::from_str(FAKE_SIG).unwrap();
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 new_id == *subscription_id {
if let Ok(UnwrappedGift { mut rumor, .. }) =
client.unwrap_gift_wrap(&event).await
{
// Compute event id if not exist
rumor.ensure_id();
if let Some(id) = rumor.id {
let ev = Event::new(
id,
rumor.pubkey,
rumor.created_at,
rumor.kind,
rumor.tags,
rumor.content,
sig,
);
// Save rumor to database to further query
if let Err(e) = client.database().save_event(&ev).await {
error!("Save error: {}", e);
}
// Send new event to GPUI
if let Err(e) = signal_tx.send(Signal::Event(ev)).await {
error!("Send error: {}", e)
}
}
}
}
if let Err(e) = batch_tx.send(event).await {
error!("Failed to add to batch: {}", e);
}
}
Kind::ContactList => {
let public_keys: HashSet<_> =
event.tags.public_keys().copied().collect();
sync_metadata(client, &public_keys).await;
}
_ => {}
},
RelayMessage::EndOfStoredEvents(subscription_id) => {
if all_id == *subscription_id {
if let Err(e) = signal_tx.send(Signal::Eose).await {
error!("Failed to send eose: {}", e)
};
}
}
_ => {}
}
}
}
});
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
app.on_reopen(move |cx| {
let client = get_client();
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
cx.spawn(|mut cx| async move {
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();
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)],
}]);
let opts = 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()
};
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();
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();
// Listen for messages from the Nostr thread
cx.spawn(|cx| async move {
while let Some(signal) = signal_rx.recv().await {
match signal {
Signal::Eose => {
_ = cx.update(|cx| {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
}
});
}
Signal::Event(event) => {
_ = cx.update(|cx| {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.push_message(event, cx))
}
});
}
}
}
})
.detach();
root
})
.expect("System error. Please re-open the app.");
});
}
async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> 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);
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();
}

View File

@@ -1,391 +0,0 @@
use cargo_packager_updater::{check_update, semver::Version, url::Url};
use common::{
constants::{UPDATER_PUBKEY, UPDATER_URL},
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 log::info;
use nostr_sdk::prelude::*;
use serde::Deserialize;
use state::get_client;
use std::sync::Arc;
use tokio::sync::oneshot;
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);
});
// Check and auto update to the latest version
cx.background_spawn(async move {
// Set auto updater config
let config = cargo_packager_updater::Config {
endpoints: vec![Url::parse(UPDATER_URL).expect("Failed to parse UPDATER URL")],
pubkey: String::from(UPDATER_PUBKEY),
..Default::default()
};
// Run auto updater
if let Ok(current_version) = Version::parse(env!("CARGO_PKG_VERSION")) {
if let Ok(Some(update)) = check_update(current_version, config) {
if update.download_and_install().is_ok() {
info!("Update installed")
}
}
}
})
.detach();
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))
}
}

View File

@@ -1,842 +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 tokio::sync::oneshot;
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 ChatItem {
profile: NostrProfile,
content: SharedString,
ago: SharedString,
}
#[derive(PartialEq, Eq)]
enum Message {
Item(Box<ChatItem>),
System(SharedString),
Placeholder,
}
impl Message {
pub fn new(chat_message: ChatItem) -> Self {
Self::Item(Box::new(chat_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 ago = LastSeen(Timestamp::now()).human_readable();
let message = Message::new(ChatItem {
profile: room.owner.clone(),
content: content.into(),
ago,
});
// 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()
};
Some(Message::new(ChatItem {
profile: member,
content: ev.content.into(),
ago: LastSeen(ev.created_at).human_readable(),
}))
} 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(ChatItem {
profile,
content: event.content.clone().into(),
ago: LastSeen(event.created_at).human_readable(),
});
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::Item(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.profile.avatar())
.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.profile.name()))
.child(div().child(item.ago.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.))
.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)
})),
),
),
),
),
)
}
}

View File

@@ -1,169 +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 tokio::sync::oneshot;
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())
}
})
}
}

View File

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

View File

@@ -1,420 +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 tokio::sync::oneshot;
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),
)
}
}

View File

@@ -1,351 +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 tokio::sync::oneshot;
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);
})),
),
)
}
}

View File

@@ -1,266 +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 tokio::sync::oneshot;
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.")
}
}),
)
}
}

View File

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

View File

@@ -1,557 +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 tokio::sync::oneshot;
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.)),
)
}
}),
)
}
}

View File

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

View File

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

View File

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

11
crates/assets/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "assets"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
rust-embed.workspace = true

59
crates/assets/src/lib.rs Normal file
View File

@@ -0,0 +1,59 @@
use anyhow::Context;
use gpui::{App, AssetSource, Result, SharedString};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../../assets"]
#[include = "fonts/**/*"]
#[include = "brand/*"]
#[include = "icons/**/*"]
#[exclude = "*.DS_Store"]
pub struct Assets;
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
Self::get(path)
.map(|f| Some(f.data))
.with_context(|| format!("loading asset at path {path:?}"))
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
Ok(Self::iter()
.filter_map(|p| {
if p.starts_with(path) {
Some(p.into())
} else {
None
}
})
.collect())
}
}
impl Assets {
/// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
let font_paths = self.list("fonts")?;
let mut embedded_fonts = Vec::new();
for font_path in font_paths {
if font_path.ends_with(".ttf") {
let font_bytes = cx
.asset_source()
.load(&font_path)?
.expect("Assets should never return None");
embedded_fonts.push(font_bytes);
}
}
cx.text_system().add_fonts(embedded_fonts)
}
pub fn load_test_fonts(&self, cx: &App) {
cx.text_system()
.add_fonts(vec![self
.load("fonts/plex-mono/ZedPlexMono-Regular.ttf")
.unwrap()
.unwrap()])
.unwrap()
}
}

View File

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

View File

@@ -0,0 +1,160 @@
use anyhow::Error;
use cargo_packager_updater::semver::Version;
use cargo_packager_updater::{check_update, Config, Update};
use global::constants::{APP_PUBKEY, APP_UPDATER_ENDPOINT};
use gpui::http_client::Url;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use smallvec::{smallvec, SmallVec};
pub fn init(cx: &mut App) {
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
}
struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
#[derive(Debug, Clone)]
pub enum AutoUpdateStatus {
Idle,
Checking,
Checked { update: Box<Update> },
Installing,
Updated,
Errored { msg: Box<String> },
}
impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing)
}
pub fn is_updated(&self) -> bool {
matches!(self, Self::Updated)
}
pub fn checked(update: Update) -> Self {
Self::Checked {
update: Box::new(update),
}
}
pub fn error(e: String) -> Self {
Self::Errored { msg: Box::new(e) }
}
}
pub struct AutoUpdater {
pub status: AutoUpdateStatus,
config: Config,
version: Version,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl AutoUpdater {
/// Retrieve the Global Auto Updater instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone()
}
/// Retrieve the Auto Updater instance
pub fn read_global(cx: &App) -> &Self {
cx.global::<GlobalAutoUpdater>().0.read(cx)
}
/// Set the Global Auto Updater instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state));
}
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let config = cargo_packager_updater::Config {
endpoints: vec![Url::parse(APP_UPDATER_ENDPOINT).expect("Endpoint is not valid")],
pubkey: String::from(APP_PUBKEY),
..Default::default()
};
let version = Version::parse(env!("CARGO_PKG_VERSION")).expect("Failed to parse version");
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
if let Some(window) = window {
this.check_for_updates(window, cx);
}
}));
Self {
status: AutoUpdateStatus::Idle,
version,
config,
subscriptions,
}
}
pub fn check_for_updates(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let config = self.config.clone();
let current_version = self.version.clone();
log::info!("Checking for updates...");
self.set_status(AutoUpdateStatus::Checking, cx);
let checking: Task<Result<Option<Update>, Error>> = cx.background_spawn(async move {
if let Some(update) = check_update(current_version, config)? {
Ok(Some(update))
} else {
Ok(None)
}
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(update)) = checking.await {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(update), cx);
this.install_update(window, cx);
})
.ok();
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
})
.ok();
}
})
.detach();
}
pub(crate) fn install_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_status(AutoUpdateStatus::Installing, cx);
if let AutoUpdateStatus::Checked { update } = self.status.clone() {
let install: Task<Result<(), Error>> =
cx.background_spawn(async move { Ok(update.download_and_install()?) });
cx.spawn_in(window, async move |this, cx| {
match install.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
})
.ok();
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
}
}
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
}
}

View File

@@ -1,16 +0,0 @@
[package]
name = "chats"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smol.workspace = true
chrono.workspace = true

View File

@@ -1,2 +0,0 @@
pub mod registry;
pub mod room;

View File

@@ -1,194 +0,0 @@
use anyhow::anyhow;
use async_utility::tokio::sync::oneshot;
use common::utils::{compare, room_hash, signer_public_key};
use gpui::{App, AppContext, Context, Entity, Global};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use state::get_client;
use std::cmp::Reverse;
use crate::room::Room;
pub fn init(cx: &mut App) {
ChatRegistry::register(cx);
}
struct GlobalChatRegistry(Entity<ChatRegistry>);
impl Global for GlobalChatRegistry {}
pub struct ChatRegistry {
rooms: Vec<Entity<Room>>,
is_loading: bool,
}
impl ChatRegistry {
pub fn global(cx: &mut App) -> Option<Entity<Self>> {
cx.try_global::<GlobalChatRegistry>()
.map(|global| global.0.clone())
}
pub fn register(cx: &mut App) -> Entity<Self> {
Self::global(cx).unwrap_or_else(|| {
let entity = cx.new(Self::new);
// Set global state
cx.set_global(GlobalChatRegistry(entity.clone()));
// Observe and load metadata for any new rooms
cx.observe_new::<Room>(|this, _window, cx| {
let client = get_client();
let pubkeys = this.pubkeys();
let (tx, rx) = oneshot::channel::<Vec<(PublicKey, Metadata)>>();
cx.background_spawn(async move {
let mut profiles = Vec::new();
for public_key in pubkeys.into_iter() {
if let Ok(metadata) = client.database().metadata(public_key).await {
profiles.push((public_key, metadata.unwrap_or_default()));
}
}
_ = tx.send(profiles);
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(profiles) = rx.await {
if let Some(room) = this.upgrade() {
_ = cx.update_entity(&room, |this, cx| {
for profile in profiles.into_iter() {
this.set_metadata(profile.0, profile.1);
}
cx.notify();
});
}
}
})
.detach();
})
.detach();
entity
})
}
fn new(_cx: &mut Context<Self>) -> Self {
Self {
rooms: Vec::with_capacity(5),
is_loading: true,
}
}
pub fn current_rooms_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
self.rooms.iter().map(|room| room.read(cx).id).collect()
}
pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) {
let client = get_client();
let (tx, rx) = oneshot::channel::<Vec<Event>>();
cx.background_spawn(async move {
if let Ok(public_key) = signer_public_key(client).await {
let filter = Filter::new()
.kind(Kind::PrivateDirectMessage)
.author(public_key);
// Get all DM events from database
if let Ok(events) = client.database().query(filter).await {
let result: Vec<Event> = events
.into_iter()
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
.unique_by(room_hash)
.sorted_by_key(|ev| Reverse(ev.created_at))
.collect();
_ = tx.send(result);
}
}
})
.detach();
cx.spawn(|this, cx| async move {
if let Ok(events) = rx.await {
_ = cx.update(|cx| {
_ = this.update(cx, |this, cx| {
let current_rooms = this.current_rooms_ids(cx);
let items: Vec<Entity<Room>> = events
.into_iter()
.filter_map(|ev| {
let new = room_hash(&ev);
// Filter all seen events
if !current_rooms.iter().any(|this| this == &new) {
Some(cx.new(|cx| Room::parse(&ev, cx)))
} else {
None
}
})
.collect();
this.rooms.extend(items);
this.is_loading = false;
cx.notify();
});
});
}
})
.detach();
}
pub fn rooms(&self) -> &Vec<Entity<Room>> {
&self.rooms
}
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn get(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
self.rooms
.iter()
.find(|model| &model.read(cx).id == id)
.cloned()
}
pub fn new_room(&mut self, room: Room, cx: &mut Context<Self>) -> Result<(), anyhow::Error> {
if !self
.rooms
.iter()
.any(|current| compare(&current.read(cx).pubkeys(), &room.pubkeys()))
{
self.rooms.insert(0, cx.new(|_| room));
cx.notify();
Ok(())
} else {
Err(anyhow!("Room is existed"))
}
}
pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) {
// Get all pubkeys from event's tags for comparision
let mut pubkeys: Vec<_> = event.tags.public_keys().copied().collect();
pubkeys.push(event.pubkey);
if let Some(room) = self
.rooms
.iter()
.find(|room| compare(&room.read(cx).pubkeys(), &pubkeys))
{
room.update(cx, |this, cx| {
this.last_seen.set(event.created_at);
this.new_messages.update(cx, |this, cx| {
this.push(event);
cx.notify();
});
cx.notify();
});
} else {
let room = cx.new(|cx| Room::parse(&event, cx));
self.rooms.insert(0, room);
cx.notify();
}
}
}

View File

@@ -1,134 +0,0 @@
use common::{
last_seen::LastSeen,
profile::NostrProfile,
utils::{compare, random_name, room_hash},
};
use gpui::{App, AppContext, Entity, SharedString};
use nostr_sdk::prelude::*;
use std::collections::HashSet;
pub struct Room {
pub id: u64,
pub title: Option<SharedString>,
pub owner: NostrProfile, // Owner always match current user
pub members: Vec<NostrProfile>, // Extract from event's tags
pub last_seen: LastSeen,
pub is_group: bool,
pub new_messages: Entity<Vec<Event>>, // Hold all new messages
}
impl PartialEq for Room {
fn eq(&self, other: &Self) -> bool {
compare(&self.pubkeys(), &other.pubkeys())
}
}
impl Room {
pub fn new(
id: u64,
owner: NostrProfile,
members: Vec<NostrProfile>,
title: Option<SharedString>,
last_seen: LastSeen,
cx: &mut App,
) -> Self {
let new_messages = cx.new(|_| Vec::new());
let is_group = members.len() > 1;
let title = if title.is_none() {
Some(random_name(2).into())
} else {
title
};
Self {
id,
owner,
members,
title,
last_seen,
is_group,
new_messages,
}
}
/// Convert nostr event to room
pub fn parse(event: &Event, cx: &mut App) -> Room {
let id = room_hash(event);
let last_seen = LastSeen(event.created_at);
// Always equal to current user
let owner = NostrProfile::new(event.pubkey, Metadata::default());
// Get all pubkeys that invole in this group
let members: Vec<NostrProfile> = event
.tags
.public_keys()
.collect::<HashSet<_>>()
.into_iter()
.map(|public_key| NostrProfile::new(*public_key, Metadata::default()))
.collect();
// Get title from event's tags
let title = if let Some(tag) = event.tags.find(TagKind::Subject) {
tag.content().map(|s| s.to_owned().into())
} else {
None
};
Self::new(id, owner, members, title, last_seen, cx)
}
/// Set contact's metadata by public key
pub fn set_metadata(&mut self, public_key: PublicKey, metadata: Metadata) {
if self.owner.public_key() == public_key {
self.owner.set_metadata(&metadata);
}
for member in self.members.iter_mut() {
if member.public_key() == public_key {
member.set_metadata(&metadata);
}
}
}
/// Get room's member by public key
pub fn member(&self, public_key: &PublicKey) -> Option<NostrProfile> {
if &self.owner.public_key() == public_key {
Some(self.owner.clone())
} else {
self.members
.iter()
.find(|m| &m.public_key() == public_key)
.cloned()
}
}
/// Get room's display name
pub fn name(&self) -> String {
if self.members.len() <= 2 {
self.members
.iter()
.map(|profile| profile.name())
.collect::<Vec<_>>()
.join(", ")
} else {
let name = self
.members
.iter()
.take(2)
.map(|profile| profile.name())
.collect::<Vec<_>>()
.join(", ");
format!("{}, +{}", name, self.members.len() - 2)
}
}
/// Get all public keys from current room
pub fn pubkeys(&self) -> Vec<PublicKey> {
let mut pubkeys: Vec<_> = self.members.iter().map(|m| m.public_key()).collect();
pubkeys.push(self.owner.public_key());
pubkeys
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "client_keys"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
global = { path = "../global" }
nostr-sdk.workspace = true
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true

View File

@@ -0,0 +1,129 @@
use global::{constants::KEYRING_URL, first_run};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
pub fn init(cx: &mut App) {
ClientKeys::set_global(cx.new(ClientKeys::new), cx);
}
struct GlobalClientKeys(Entity<ClientKeys>);
impl Global for GlobalClientKeys {}
pub struct ClientKeys {
keys: Option<Keys>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl ClientKeys {
/// Retrieve the Global Client Keys instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalClientKeys>().0.clone()
}
/// Retrieve the Client Keys instance
pub fn get_global(cx: &App) -> &Self {
cx.global::<GlobalClientKeys>().0.read(cx)
}
/// Set the Global Client Keys instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalClientKeys(state));
}
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
if let Some(window) = window {
this.load(window, cx);
}
}));
Self {
keys: None,
subscriptions,
}
}
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let read_client_keys = cx.read_credentials(KEYRING_URL);
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some((_, secret))) = read_client_keys.await {
// Update keys
this.update(cx, |this, cx| {
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
this.set_keys(None, false, true, cx);
return;
};
let keys = Keys::new(secret_key);
this.set_keys(Some(keys), false, true, cx);
})
.ok();
} else if *first_run() {
// Generate a new keys and update
this.update(cx, |this, cx| {
this.new_keys(cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_keys(None, false, true, cx);
})
.ok();
}
})
.detach();
}
pub(crate) fn set_keys(
&mut self,
keys: Option<Keys>,
persist: bool,
notify: bool,
cx: &mut Context<Self>,
) {
if persist {
if let Some(keys) = keys.as_ref() {
let username = keys.public_key().to_hex();
let password = keys.secret_key().secret_bytes();
let write_keys = cx.write_credentials(KEYRING_URL, &username, &password);
cx.background_spawn(async move {
if let Err(e) = write_keys.await {
log::error!("Failed to save the client keys: {e}")
}
})
.detach();
}
}
self.keys = keys;
// Notify GPUI to reload UI
if notify {
cx.notify();
}
}
pub fn new_keys(&mut self, cx: &mut Context<Self>) {
self.set_keys(Some(Keys::generate()), true, true, cx);
}
pub fn force_new_keys(&mut self, cx: &mut Context<Self>) {
self.set_keys(Some(Keys::generate()), true, false, cx);
}
pub fn keys(&self) -> Keys {
self.keys
.as_ref()
.cloned()
.expect("Keys should always be initialized")
}
pub fn has_keys(&self) -> bool {
self.keys.is_some()
}
}

View File

@@ -1,16 +1,24 @@
[package]
name = "common"
version = "0.0.0"
edition = "2021"
publish = false
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
global = { path = "../global" }
gpui.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
nostr.workspace = true
anyhow.workspace = true
itertools.workspace = true
chrono.workspace = true
dirs.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
reqwest.workspace = true
log.workspace = true
random_name_generator = "0.3.6"
webbrowser = "1.0.4"
qrcode-generator = "5.0.0"

View File

@@ -1,21 +0,0 @@
pub const KEYRING_SERVICE: &str = "Coop Safe Storage";
pub const APP_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const FAKE_SIG: &str = "f9e79d141c004977192d05a86f81ec7c585179c371f7350a5412d33575a2a356433f58e405c2296ed273e2fe0aafa25b641e39cc4e1f3f261ebf55bce0cbac83";
/// Subscriptions
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps";
/// Image Resizer Service
pub const IMAGE_SERVICE: &str = "https://wsrv.nl";
/// NIP96 Media Server
pub const NIP96_SERVER: &str = "https://nostrmedia.com";
/// Updater Public Key
pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkxM0EzQTQyRTBBMENENTYKUldSV3phRGdRam82a1dtU0JqYll4VnBaVUpSWUxCWlVQbnRkUnNERS96MzFMWDhqNW5zOXplMEwK";
/// Updater Server URL
pub const UPDATER_URL: &str =
"https://cdn.crabnebula.app/update/lume/coop/{{target}}-{{arch}}/{{current_version}}";

View File

@@ -0,0 +1,66 @@
use std::marker::PhantomData;
use std::time::Duration;
use futures::channel::oneshot;
use futures::FutureExt;
use gpui::{Context, Task, Window};
pub struct DebouncedDelay<E: 'static> {
task: Option<Task<()>>,
cancel_channel: Option<oneshot::Sender<()>>,
_phantom_data: PhantomData<E>,
}
impl<E: 'static> Default for DebouncedDelay<E> {
fn default() -> Self {
Self::new()
}
}
impl<E: 'static> DebouncedDelay<E> {
pub fn new() -> Self {
Self {
task: None,
cancel_channel: None,
_phantom_data: PhantomData,
}
}
pub fn fire_new<F>(
&mut self,
delay: Duration,
window: &mut Window,
cx: &mut Context<E>,
func: F,
) where
F: 'static + Send + FnOnce(&mut E, &mut Window, &mut Context<E>) -> Task<()>,
{
if let Some(channel) = self.cancel_channel.take() {
_ = channel.send(());
}
let (sender, mut receiver) = oneshot::channel::<()>();
self.cancel_channel = Some(sender);
let previous_task = self.task.take();
self.task = Some(cx.spawn_in(window, async move |entity, cx| {
let mut timer = cx.background_executor().timer(delay).fuse();
if let Some(previous_task) = previous_task {
previous_task.await;
}
futures::select_biased! {
_ = receiver => return,
_ = timer => {}
}
if let Ok(Ok(task)) =
cx.update(|window, cx| entity.update(cx, |project, cx| (func)(project, window, cx)))
{
task.await;
}
}));
}
}

View File

@@ -0,0 +1,108 @@
use std::sync::Arc;
use anyhow::{anyhow, Error};
use global::constants::IMAGE_RESIZE_SERVICE;
use gpui::{Image, ImageFormat, SharedString};
use nostr_sdk::prelude::*;
use qrcode_generator::QrCodeEcc;
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
pub trait DisplayProfile {
fn avatar_url(&self, proxy: bool) -> SharedString;
fn display_name(&self) -> SharedString;
}
impl DisplayProfile for Profile {
fn avatar_url(&self, proxy: bool) -> SharedString {
self.metadata()
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
if proxy {
format!(
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
)
.into()
} else {
picture.into()
}
})
.unwrap_or_else(|| "brand/avatar.png".into())
}
fn display_name(&self) -> SharedString {
if let Some(display_name) = self.metadata().display_name.as_ref() {
if !display_name.is_empty() {
return display_name.into();
}
}
if let Some(name) = self.metadata().name.as_ref() {
if !name.is_empty() {
return name.into();
}
}
shorten_pubkey(self.public_key(), 4)
}
}
pub trait TextUtils {
fn to_public_key(&self) -> Result<PublicKey, Error>;
fn to_qr(&self) -> Option<Arc<Image>>;
}
impl TextUtils for String {
fn to_public_key(&self) -> Result<PublicKey, Error> {
if self.starts_with("nprofile1") {
Ok(Nip19Profile::from_bech32(self)?.public_key)
} else if self.starts_with("npub1") {
Ok(PublicKey::parse(self)?)
} else {
Err(anyhow!("Invalid public key"))
}
}
fn to_qr(&self) -> Option<Arc<Image>> {
let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(self, QrCodeEcc::Medium, 256)
else {
return None;
};
Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes)))
}
}
impl TextUtils for &str {
fn to_public_key(&self) -> Result<PublicKey, Error> {
if self.starts_with("nprofile1") {
Ok(Nip19Profile::from_bech32(self)?.public_key)
} else if self.starts_with("npub1") {
Ok(PublicKey::parse(self)?)
} else {
Err(anyhow!("Invalid public key"))
}
}
fn to_qr(&self) -> Option<Arc<Image>> {
let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(self, QrCodeEcc::Medium, 256)
else {
return None;
};
Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes)))
}
}
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString {
let Ok(pubkey) = public_key.to_bech32();
format!(
"{}:{}",
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)
.into()
}

View File

@@ -0,0 +1,47 @@
use std::collections::HashSet;
use std::hash::{DefaultHasher, Hash, Hasher};
use itertools::Itertools;
use nostr_sdk::prelude::*;
pub trait EventUtils {
fn uniq_id(&self) -> u64;
fn all_pubkeys(&self) -> Vec<PublicKey>;
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool;
}
impl EventUtils for Event {
fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = vec![];
// Add all public keys from event
pubkeys.push(self.pubkey);
pubkeys.extend(self.tags.public_keys().collect::<Vec<_>>());
// Generate unique hash
pubkeys
.into_iter()
.unique()
.sorted()
.collect::<Vec<_>>()
.hash(&mut hasher);
hasher.finish()
}
fn all_pubkeys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
public_keys.push(self.pubkey);
public_keys
}
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool {
let pubkeys = self.all_pubkeys();
let a: HashSet<_> = pubkeys.iter().collect();
let b: HashSet<_> = other.iter().collect();
a == b
}
}

View File

@@ -0,0 +1,14 @@
use nostr_connect::prelude::*;
#[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
Box::pin(async move {
log::info!("Received Auth URL: {auth_url}");
webbrowser::open(auth_url.as_str())?;
Ok(())
})
}
}

View File

@@ -1,57 +0,0 @@
use chrono::{Datelike, Local, TimeZone};
use gpui::SharedString;
use nostr_sdk::prelude::*;
pub struct LastSeen(pub Timestamp);
impl LastSeen {
pub fn ago(&self) -> SharedString {
let now = Local::now();
let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap();
let diff = (now - input_time).num_hours();
if diff < 24 {
let duration = now.signed_duration_since(input_time);
if duration.num_seconds() < 60 {
"now".to_string().into()
} else if duration.num_minutes() == 1 {
"1m".to_string().into()
} else if duration.num_minutes() < 60 {
format!("{}m", duration.num_minutes()).into()
} else if duration.num_hours() == 1 {
"1h".to_string().into()
} else if duration.num_hours() < 24 {
format!("{}h", duration.num_hours()).into()
} else if duration.num_days() == 1 {
"1d".to_string().into()
} else {
format!("{}d", duration.num_days()).into()
}
} else {
input_time.format("%b %d").to_string().into()
}
}
pub fn human_readable(&self) -> SharedString {
let now = Local::now();
let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap();
if input_time.day() == now.day() {
format!("Today at {}", input_time.format("%H:%M %p")).into()
} else if input_time.day() == now.day() - 1 {
format!("Yesterday at {}", input_time.format("%H:%M %p")).into()
} else {
format!(
"{}, {}",
input_time.format("%d/%m/%y"),
input_time.format("%H:%M %p")
)
.into()
}
}
pub fn set(&mut self, created_at: Timestamp) {
self.0 = created_at
}
}

View File

@@ -1,5 +1,6 @@
pub mod constants;
pub mod last_seen;
pub mod profile;
pub mod qr;
pub mod utils;
pub mod debounced_delay;
pub mod display;
pub mod event;
pub mod handle_auth;
pub mod nip05;
pub mod nip96;

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