218 Commits

Author SHA1 Message Date
75c3783522 feat: rewrite the nip-4e implementation (#1)
Some checks are pending
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Make NIP-4e a core feature, not an optional feature.

Note:
- The UI is broken and needs to be updated in a separate PR.

Reviewed-on: #1
2026-01-13 16:00:08 +08:00
reya
bb455871e5 remove i18n crate (#213) 2025-12-31 09:23:59 +07:00
0507fa7ac5 chore: update deps 2025-12-26 08:25:22 +07:00
af115321b4 Merge branch 'master' of github.com:lumehq/coop 2025-12-26 08:23:57 +07:00
reya
34e026751b feat: add support for multi-themes (#210)
* chore: update deps

* wip

* add themes

* add matrix theme

* add flexoki and spaceduck themes

* .

* simple theme change function

* .

* respect shadow and radius settings

* add rose pine themes

* toggle theme
2025-12-26 08:20:18 +07:00
e9e662dccc chore: update deps 2025-12-23 10:10:50 +07:00
5b7780ec9b chore: update deps 2025-12-17 08:10:02 +07:00
782efd7498 chore: update deps 2025-12-13 10:06:09 +07:00
8192023479 chore: release version 0.3.0 2025-12-08 11:10:54 +07:00
4637478a0b chore: minor fixes 2025-12-05 10:26:24 +07:00
6b5adb0a56 chore: update deps 2025-12-05 08:10:36 +07:00
9fd55cf3ff chore: update deps 2025-11-30 09:47:40 +07:00
reya
14c36e4731 feat: refine the search bar (#207)
* update deps

* refactor the search cancellation

* .

* .
2025-11-22 07:25:08 +07:00
reya
a6e00b47d8 feat: refine user profile popup (#206)
* update user popup

* .

* .
2025-11-20 08:23:02 +07:00
0784a20be5 chore: update deps 2025-11-18 07:36:31 +07:00
reya
6023063cf4 feat: revamp the onboarding process (#205)
* redesign

* restructure

* .

* .

* .

* .

* .
2025-11-17 15:10:14 +07:00
67c92cb319 chore: update deps 2025-11-15 14:51:16 +07:00
reya
122299f548 chore: improve nip4e implementation (#204)
* patch

* update ui

* add load response

* fix

* .

* wip: rewrite gossip

* new gossip implementation

* clean up

* .

* debug

* .

* .

* update

* .

* fix

* fix
2025-11-15 08:30:45 +07:00
d87bcfbd65 chore: follow up on #203 2025-11-11 11:02:37 +07:00
de5134676d chore: update deps 2025-11-11 09:25:46 +07:00
reya
512834b640 chore: rewrite the backend (not tested) (#203)
* wip: refactor

* refactor

* clean up

* .

* rename

* add relay auth

* .

* .

* optimize

* .

* clean up

* add encryption crate

* .

* .

* .

* .

* .

* add encryption crate

* .

* refactor nip4e

* .

* fix endless loop

* fix metadata fetching
2025-11-11 09:09:33 +07:00
a1a0a7ecd4 chore: update deps 2025-11-03 19:09:59 +07:00
reya
a4067d2c00 chore: fix crash when failing to parse message (#202)
* clean up

* .

* fix rich text component

* clean up
2025-11-03 19:04:16 +07:00
4ebe590f8a chore: update deps 2025-11-02 20:27:47 +07:00
reya
9da624dd0c feat: nostr based auto updater (#200)
* .

* refactor

* fix

* .

* clean up

* clean up
2025-11-02 08:22:55 +07:00
reya
7091fa1cab chore: restructure and refine the ui (#199)
* update deps

* clean up

* add account crate

* add person crate

* add chat and chat ui crates

* .

* clean up the ui crate

* .

* .
2025-11-01 09:16:02 +07:00
a1bd4954eb chore: clean up 2025-10-29 08:30:04 +07:00
fde1499796 chore: update deps 2025-10-29 07:52:53 +07:00
reya
649cdff49c re add verify relay connection (#197) 2025-10-29 07:42:50 +07:00
reya
b0fa98831d chore: fix nip4e implementation (#196)
* push

* debug

* disable verify sender

* .
2025-10-28 20:35:34 +07:00
reya
b9297d3a01 chore: follow up on nip-4e (#195)
* update deps

* .

* remove resend button

* clean up

* .

* .

* .

* .

* .
2025-10-28 14:37:30 +07:00
reya
b5ed079a0e chore: improve nip-4e (#194)
* update texts

* update ui

* .

* .
2025-10-27 17:10:38 +07:00
6017eebaed chore: update gpui & components 2025-10-27 08:20:37 +07:00
reya
15bbe82a87 feat: nip4e (#188)
* encryption keys

* .

* .

* move nip4e to device crate

* .

* .

* use i18n for device crate

* refactor

* refactor

* .

* add reset button

* send message with encryption keys

* clean up

* .

* choose signer

* fix

* update i18n

* fix sending
2025-10-26 18:10:40 +07:00
alltheseas
83687e5448 Adds outbound dm relay hint (#193)
* Include relay hints in DM rumor tags

* Add unit test for DM relay hints

---------

Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
2025-10-25 07:40:55 +07:00
alltheseas
48c90f5bb0 Store DM rumors without re-signing (#192)
Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
2025-10-25 07:39:02 +07:00
alltheseas
47abd2909b Reject gift wraps whose rumor pubkey doesn’t match the seal signer (#190)
* Verify seal sender before caching rumors

* Test rumor sender verification logic

---------

Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
2025-10-24 16:30:25 +07:00
reya
ac0b233089 feat: implement multiple keystores (#187)
* keystore

* .

* fix

* .

* allow user disable keyring

* update texts
2025-10-20 07:40:02 +07:00
reya
a1e0934fc3 chore: clean up codebase (#186)
* refactor app state

* clean up

* clean up

* .
2025-10-18 09:46:45 +07:00
32a0401907 chore: simplify codebase 2025-10-17 08:51:34 +07:00
1742031901 chore: update deps 2025-10-12 20:51:46 +07:00
reya
2415374567 chore: improve gossip implementation (#184)
* add send event function

* add set nip17 and set nip65 functions

* setup gossip relays

* .
2025-10-12 20:22:57 +07:00
reya
7fc727461e chore: follow up #181 (#183)
* update deps

* .

* fix
2025-10-11 16:27:14 +07:00
reya
68a8ec7a69 feat: custom gossip implementation (#181)
* .

* rename global to app_state

* refactor event tracker

* gossip

* .

* .
2025-10-10 17:36:38 +07:00
b7693444e6 chore: update deps 2025-10-07 14:45:44 +07:00
6e7f63d79a chore: release version 0.2.11 2025-10-01 13:50:57 +07:00
ee693aa503 chore: update the release script 2025-10-01 13:49:39 +07:00
reya
ebcc60cd92 chore: follow up on #172 (#173)
* clean up

* wip

* clean up

* remove unused picture field
2025-10-01 13:45:13 +07:00
reya
0db48bc003 chore: refactor message sending (#172)
* refactor send message

* refactor resend

* fix

* refactor

* clean up
2025-09-30 08:59:38 +07:00
880ba30d20 chore: bump version 2025-09-28 08:01:41 +07:00
reya
d889f9b25d chore: always call get_public_key on nip46 (#171)
* get public key on login

* .
2025-09-28 07:57:04 +07:00
reya
0de1b20951 feat: add context menu for quick profile viewing (#170)
* add profile context menu

* add context menu for avatar
2025-09-27 15:15:00 +07:00
reya
338a947b57 chore: fix duplicate reply (#169)
* prevent duplicate reply

* .
2025-09-27 08:02:16 +07:00
reya
98ce928f0c chore: fix double message on sent (#166)
* .

* fix

* update
2025-09-26 14:17:31 +07:00
reya
61cad5dd96 chore: refactor the input component (#165)
* refactor the input component

* fix clippy

* clean up
2025-09-25 08:03:14 +07:00
a87184214f chore: add release script 2025-09-23 09:36:49 +07:00
fff3a44f62 chore: bump version 2025-09-23 09:05:35 +07:00
reya
9abcc25f32 chore: optimize resource usage (#162)
* avoid string allocation

* cache image

* .

* .

* .

* fix
2025-09-23 09:03:48 +07:00
reya
fb3da096f8 chore: improve the media uploader (#161)
* refactor upload

* .

* .
2025-09-22 07:30:32 +07:00
1de3045505 chore: update deps 2025-09-19 08:42:08 +07:00
reya
9f369bf57f chore: improve auth handling in startup screen (#160)
* cancel auth

* .
2025-09-18 20:01:10 +07:00
reya
4164651342 chore: refactor the compose modal (#156)
* .

* update

* clean up
2025-09-18 08:39:24 +07:00
c12856cda0 chore: bump version 2025-09-16 20:31:10 +07:00
reya
c67b223a53 chore: add missing ui elements (#153)
* add empty state

* .

* update welcome panel
2025-09-16 19:59:03 +07:00
reya
9880a3ed3d chore: follow up on #151 (#152)
* improve ui

* .

* clean up
2025-09-15 20:53:25 +07:00
reya
d13ffd5a54 feat: detect user dm relays when opening chat panel (#151)
* preconnect to user messaging relays

* .
2025-09-15 19:34:48 +07:00
cc79f0ed1c chore: clean up 2025-09-15 09:10:37 +07:00
reya
5127eaadbb feat: add seen-on-relays viewer per message (#149)
* chore: bump version

* add seen on

* seen on menu
2025-09-14 11:50:14 +07:00
d38e70ecbf chore: update deps 2025-09-13 07:51:33 +07:00
reya
b142982ab1 chore: refactor event fetching (#148)
* use stream for nip65 and nip17 relays fetching

* .
2025-09-13 07:42:17 +07:00
reya
2ea2519e8b feat: resend failed messages (#147)
* .

* .

* fix

* fix

* update

* fix

* .

* .
2025-09-12 17:07:57 +07:00
reya
2ea5feaf4b chore: improve handling of user profiles (#146)
* resubscribe metadata for all pubkeys

* .
2025-09-10 10:06:45 +07:00
4ec7530b91 chore: update deps 2025-09-10 08:21:43 +07:00
df82861101 chore: bump version 2025-09-10 07:25:15 +07:00
reya
fc99ef4dfe chore: improve the activity check (#145)
* better check for activity

* .
2025-09-09 10:23:40 +07:00
reya
d0f7a1abd3 feat: extended screening (#144)
* improve mutual contacts check

* .

* .
2025-09-08 17:11:29 +07:00
reya
71140beb52 feat: relay status viewer (#143)
* add relay status

* .
2025-09-07 14:54:28 +07:00
reya
e177facef4 chore: better handle async tasks (#142)
* improve some codes

* .
2025-09-07 08:33:21 +07:00
60bca49200 chore: update deps and refactor the event loop 2025-09-06 20:55:11 +07:00
reya
ede41c41c3 chore: improve the event loop (#141)
* improve wait for signer

* refactor gift wrap processor

* .

* .

* .

* .

* .
2025-09-05 19:01:26 +07:00
reya
70e235dcc2 chore: minor ui components improvements (#140)
* improve ui

* .

* .
2025-09-04 07:30:03 +07:00
b11b0e0115 chore: update deps 2025-09-03 12:12:40 +07:00
reya
d8edac0bb9 chore: fix rooms out of order while loading (#139)
* fix room out of order while loading

* .

* .
2025-09-03 09:16:36 +07:00
reya
d392602ed6 feat: resend messages after authentication (#137)
* resend failed message

* update settings
2025-09-02 18:19:53 +07:00
reya
5a36354cc8 chore: fix handling of ongoing room kind incorrectly (#136) 2025-09-01 19:44:23 +07:00
a1df66e176 chore: bump hotfix version 2025-09-01 17:31:34 +07:00
reya
78d913ae38 chore: fix high cpu usage and incorrect use of list indices (#135)
* .

* fix cpu usage
2025-09-01 17:30:33 +07:00
b4691aa689 chore: temporary disable room's announcement 2025-09-01 10:16:48 +07:00
c49530b030 chore: bump hotfix version 2025-09-01 08:24:10 +07:00
reya
e7ffe7627c chore: fix double messages on load (#134) 2025-09-01 08:22:04 +07:00
6a5304514f chore: bump version 2025-08-31 19:08:37 +07:00
reya
f2be8fca08 feat: add setting for relay authentication (#133)
* remember auth relay

* .

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

* .

* .

* .

* refactor

* refactor

* remove identity

* manually auth

* auth

* prevent duplicate message

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

* redesign onboarding screen

* .

* add signer proxy

* .

* .

* .

* .

* fix proxy

* clean up

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

* .

* .

* skip sent message

* improve sent reports

* .

* .

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

* .

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

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

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

* refactor

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

* .

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

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

* fix ci

* fix build

* fix ci on linux

* .

* fix flatpak

* .

* .

* .

* fix snap

* .

* .

* .

* .

* fix

* .

* .

* fix path

* .

* .

* fix upload artifacts

* fix build on arm

* fix snap arm

* .

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

* update window options

* linux title bar

* fix build

* .

* fix build

* rounded corners on linux

* .

* .

* fix i18n key

* fix change subject modal

* .

* update new account

* .

* update relay modal

* .

* fix i18n keys

---------

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

* close panel on ignore

* bypass screening

* .

* improve settings

* refine modal

* .

* .

* .

* .

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

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

* .

* refactor

* .

* screening

* add report user function

* add danger and warning styles

* update deps

* update

* fix line height

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

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

* .

* update input to catch up with gpui-component

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

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

* refactor upload button

* refactor

* dispatch action on mention clicked

* add profile modal

* .

* .

* .

* improve rich_text

* improve handle url

* make registry simpler

* refactor

* .

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

* .

* wip: add nip05 search

* add nip05 search

* .

* support cancel search

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

* refactor

* add documents

* clean up

* refactor

* clean up

* refactor identity

* .

* .

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

* add rust-i18n

* translate

* .

* update translations

* fix

* update translate

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

* add auto login status

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

* script/linux:add libx11-dev

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

---------

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

* simplify nostr connect uri logic

* improve wait for connection

* improve handle password

* .

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

* .

* .

* .

* chore: prevent update local search with empty result

* clean up

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

* refactor

* refactor

* refactor

* improve extend rooms function

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

* .

* .

* .

* refactor client keys

* .

* .

* refactor

* .

* .

* .

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

* .

* .

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

* update

* .

* rustfmt

* .

* .

* .

* .

* .

* add document

* .

* add logout

* handle error

* chore: update gpui

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

* .

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

* middle click to close tab

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

* .

* add some tooltips

* .

* fix button hovered style

* .

* improve prevent duplicate message

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

* move profile cache to outside gpui context

* update comment

* refactor

* refactor

* .

* .

* add avatar component

* .

* refactor

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

* show reply message

* refactor

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

* optimistically update message list

* fix

* update

* handle duplicate messages

* update ui

* refactor input

* update multi line input

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

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

* redesign

* search

* fix

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

* update

* temporary switch to rust-nostr fork

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

* update

* improve search

* add error msg
2025-05-02 17:03:49 +07:00
2c2aeb915e feat: add option for toggle chat folders 2025-04-30 13:10:18 +07:00
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
276 changed files with 30788 additions and 12681 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: ${{ 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

4730
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,50 @@
[workspace]
members = ["crates/*"]
default-members = ["crates/app"]
resolver = "2"
[workspace.dependencies]
coop = { path = "crates/*" }
# UI
gpui = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"lmdb",
"nip96",
"nip59",
"nip49",
"nip44",
"nip05",
] }
smol = "2"
oneshot = { git = "https://github.com/faern/oneshot" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dirs = "5.0"
itertools = "0.13.0"
futures = "0.3.30"
chrono = "0.4.38"
tracing = "0.1.40"
anyhow = "1.0.44"
smallvec = "1.13.2"
rust-embed = "8.5.0"
log = "0.4"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
[workspace]
resolver = "2"
members = ["crates/*"]
default-members = ["crates/coop"]
[workspace.package]
version = "0.3.0"
edition = "2021"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
# Others
anyhow = "1.0.44"
chrono = "0.4.38"
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
oneshot = "0.1.10"
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
rust-embed = "8.5.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "1"
smallvec = "1.14.0"
smol = "2"
tracing = "0.1.40"
webbrowser = "1.0.4"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
[profile.profiling]
inherits = "release"
debug = true

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.2"
resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"]
icons = [
"assets/brand/32x32.png",
"assets/brand/128x128.png",
"assets/brand/128x128@2x.png",
"assets/brand/icon.icns",
"assets/brand/icon.ico",
]
before-packaging-command = "cargo build --release"
out-dir = "./target/release"
binaries = [
{ path = "coop", main = true },
]

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

BIN
assets/brand/system.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m7.5 3.25 4.5 3.5 4.5-3.5m-11.75 17h14.5a2 2 0 0 0 2-2v-9.5a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2v9.5a2 2 0 0 0 2 2Z"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10 5.75 3.75 12 10 18.25M4.5 12h15.75"/>
</svg>

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 244 B

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

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.22-1.53 3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 1 1-1.06 1.06l-1.97-1.97v6.69a.75.75 0 0 1-1.5 0V9.56l-1.97 1.97a.75.75 0 0 1-1.06-1.06Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 397 B

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

4
assets/icons/edit.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.75 21.25h-4a2 2 0 0 1-2-2V4.75a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v7"/>
<path stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.5" d="M13.75 21.25v-2.333l3.75-3.75a1.65 1.65 0 0 1 2.333 2.333l-3.75 3.75H13.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

View File

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8.75a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm0 0v6m8.25-2.838v-4.97a2 2 0 0 0-1.367-1.898l-6.25-2.083a2 2 0 0 0-1.265 0l-6.25 2.083A2 2 0 0 0 3.75 6.942v4.97c0 4.973 4.25 7.338 8.25 9.496 4-2.158 8.25-4.523 8.25-9.496Z"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

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

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill="#000" d="M3.999 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm9.499.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0ZM7.999 12c-1.765 0-3.236.635-4.365 1.72-1.117 1.074-1.868 2.557-2.282 4.225C.932 19.64 2.351 21 3.9 21h8.197c1.55 0 2.968-1.361 2.548-3.055-.413-1.668-1.164-3.151-2.281-4.225-1.13-1.085-2.6-1.72-4.365-1.72Zm6.174.715c1.21 1.337 1.983 3.011 2.414 4.749.231.934.167 1.79-.103 2.536h3.86c1.538 0 2.996-1.365 2.51-3.075C22.06 14.14 20.103 12 16.997 12c-1.08 0-2.023.26-2.825.715Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 595 B

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

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

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

Before

Width:  |  Height:  |  Size: 434 B

View File

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

After

Width:  |  Height:  |  Size: 489 B

View File

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

4
assets/icons/refresh.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M13 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8-10a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm-1.07 3.268a1 1 0 1 1-1 1.732 1 1 0 0 1 1-1.732Zm-2.562 5.026a1 1 0 1 0-1-1.732 1 1 0 0 0 1 1.732ZM18.927 8a1 1 0 1 1-1-1.732 1 1 0 0 1 1 1.732Z"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.25 14.75v5.5h-5.5M9 19.688a8.25 8.25 0 1 1 6.25-15.273"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

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

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

After

Width:  |  Height:  |  Size: 400 B

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

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

After

Width:  |  Height:  |  Size: 462 B

View File

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

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 287 B

4
assets/icons/server.svg Normal file
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="square" stroke-linejoin="round" stroke-width="1.5" d="M21.25 12V6.75a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2V12m18.5 0H2.75m18.5 0v5.25a2 2 0 0 1-2 2H4.75a2 2 0 0 1-2-2V12"/>
<path fill="currentColor" stroke="currentColor" stroke-width=".5" d="M6.5 14.875a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Zm0-7.25a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

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

3
assets/icons/signal.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="2" d="M3 17v2m6-6v6m6-10v10m6-14v14"/>
</svg>

After

Width:  |  Height:  |  Size: 233 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

3
assets/icons/warning.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-width="2" d="M12 9.02v2.993M12 15h.01M10.277 3.99 3.275 15.998C2.499 17.328 3.458 19 4.998 19h14.004c1.54 0 2.5-1.671 1.723-3.002L13.723 3.99c-.77-1.32-2.677-1.32-3.447 0ZM12.25 15a.25.25 0 1 1-.5 0 .25.25 0 0 1 .5 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

View File

@@ -0,0 +1,136 @@
{
"id": "catppuccin-frappe",
"name": "Catppuccin Frappé",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"light": {
"background": "#303446",
"surface_background": "#292c3c",
"elevated_surface_background": "#232634",
"panel_background": "#303446",
"overlay": "#c6d0f51a",
"title_bar": "#00000000",
"title_bar_inactive": "#303446",
"window_border": "#626880",
"border": "#626880",
"border_variant": "#51576d",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#00000000",
"border_disabled": "#414559",
"ring": "#8caaee",
"text": "#c6d0f5",
"text_muted": "#b5bfe2",
"text_placeholder": "#a5adce",
"text_accent": "#8caaee",
"icon": "#c6d0f5",
"icon_muted": "#b5bfe2",
"icon_accent": "#8caaee",
"element_foreground": "#303446",
"element_background": "#8caaee",
"element_hover": "#8caaeee6",
"element_active": "#7e99d6",
"element_selected": "#7088be",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#8caaee",
"secondary_background": "#414559",
"secondary_hover": "#8caaee1a",
"secondary_active": "#51576d",
"secondary_selected": "#51576d",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#e78284e6",
"danger_active": "#d07576",
"danger_selected": "#b96869",
"danger_disabled": "#e782844d",
"warning_foreground": "#303446",
"warning_background": "#e5c890",
"warning_hover": "#e5c890e6",
"warning_active": "#ceb481",
"warning_selected": "#b7a072",
"warning_disabled": "#e5c8904d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#414559",
"ghost_element_hover": "#c6d0f51a",
"ghost_element_active": "#51576d",
"ghost_element_selected": "#51576d",
"ghost_element_disabled": "#c6d0f50d",
"tab_inactive_background": "#414559",
"tab_hover_background": "#51576d",
"tab_active_background": "#626880",
"scrollbar_thumb_background": "#c6d0f533",
"scrollbar_thumb_hover_background": "#c6d0f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#51576d",
"drop_target_background": "#8caaee1a",
"cursor": "#99d1db",
"selection": "#99d1db40"
},
"dark": {
"background": "#303446",
"surface_background": "#292c3c",
"elevated_surface_background": "#232634",
"panel_background": "#303446",
"overlay": "#c6d0f51a",
"title_bar": "#00000000",
"title_bar_inactive": "#303446",
"window_border": "#626880",
"border": "#626880",
"border_variant": "#51576d",
"border_focused": "#8caaee",
"border_selected": "#8caaee",
"border_transparent": "#00000000",
"border_disabled": "#414559",
"ring": "#8caaee",
"text": "#c6d0f5",
"text_muted": "#b5bfe2",
"text_placeholder": "#a5adce",
"text_accent": "#8caaee",
"icon": "#c6d0f5",
"icon_muted": "#b5bfe2",
"icon_accent": "#8caaee",
"element_foreground": "#303446",
"element_background": "#8caaee",
"element_hover": "#8caaeee6",
"element_active": "#7e99d6",
"element_selected": "#7088be",
"element_disabled": "#8caaee4d",
"secondary_foreground": "#8caaee",
"secondary_background": "#414559",
"secondary_hover": "#8caaee1a",
"secondary_active": "#51576d",
"secondary_selected": "#51576d",
"secondary_disabled": "#8caaee4d",
"danger_foreground": "#303446",
"danger_background": "#e78284",
"danger_hover": "#e78284e6",
"danger_active": "#d07576",
"danger_selected": "#b96869",
"danger_disabled": "#e782844d",
"warning_foreground": "#303446",
"warning_background": "#e5c890",
"warning_hover": "#e5c890e6",
"warning_active": "#ceb481",
"warning_selected": "#b7a072",
"warning_disabled": "#e5c8904d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#414559",
"ghost_element_hover": "#c6d0f51a",
"ghost_element_active": "#51576d",
"ghost_element_selected": "#51576d",
"ghost_element_disabled": "#c6d0f50d",
"tab_inactive_background": "#414559",
"tab_hover_background": "#51576d",
"tab_active_background": "#626880",
"scrollbar_thumb_background": "#c6d0f533",
"scrollbar_thumb_hover_background": "#c6d0f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#51576d",
"drop_target_background": "#8caaee1a",
"cursor": "#99d1db",
"selection": "#99d1db40"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "catppuccin-latte",
"name": "Catppuccin Latte",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"light": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#00000000",
"title_bar_inactive": "#eff1f5",
"window_border": "#acb0be",
"border": "#acb0be",
"border_variant": "#bcc0cc",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#00000000",
"border_disabled": "#ccd0da",
"ring": "#1e66f5",
"text": "#4c4f69",
"text_muted": "#5c5f77",
"text_placeholder": "#6c6f85",
"text_accent": "#1e66f5",
"icon": "#4c4f69",
"icon_muted": "#5c5f77",
"icon_accent": "#1e66f5",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#1e66f5e6",
"element_active": "#1b5cdc",
"element_selected": "#1852c3",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#1e66f5",
"secondary_background": "#e6e9ef",
"secondary_hover": "#1e66f51a",
"secondary_active": "#dce0e8",
"secondary_selected": "#dce0e8",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#d20f39e6",
"danger_active": "#bc0e33",
"danger_selected": "#a60c2d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#4c4f69",
"warning_background": "#df8e1d",
"warning_hover": "#df8e1de6",
"warning_active": "#c9801a",
"warning_selected": "#b47217",
"warning_disabled": "#df8e1d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e6e9ef",
"ghost_element_hover": "#4c4f691a",
"ghost_element_active": "#dce0e8",
"ghost_element_selected": "#dce0e8",
"ghost_element_disabled": "#4c4f690d",
"tab_inactive_background": "#e6e9ef",
"tab_hover_background": "#dce0e8",
"tab_active_background": "#ccd0da",
"scrollbar_thumb_background": "#4c4f6933",
"scrollbar_thumb_hover_background": "#4c4f694d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dce0e8",
"drop_target_background": "#1e66f51a",
"cursor": "#04a5e5",
"selection": "#04a5e540"
},
"dark": {
"background": "#eff1f5",
"surface_background": "#e6e9ef",
"elevated_surface_background": "#dce0e8",
"panel_background": "#eff1f5",
"overlay": "#4c4f691a",
"title_bar": "#00000000",
"title_bar_inactive": "#eff1f5",
"window_border": "#acb0be",
"border": "#acb0be",
"border_variant": "#bcc0cc",
"border_focused": "#1e66f5",
"border_selected": "#1e66f5",
"border_transparent": "#00000000",
"border_disabled": "#ccd0da",
"ring": "#1e66f5",
"text": "#4c4f69",
"text_muted": "#5c5f77",
"text_placeholder": "#6c6f85",
"text_accent": "#1e66f5",
"icon": "#4c4f69",
"icon_muted": "#5c5f77",
"icon_accent": "#1e66f5",
"element_foreground": "#eff1f5",
"element_background": "#1e66f5",
"element_hover": "#1e66f5e6",
"element_active": "#1b5cdc",
"element_selected": "#1852c3",
"element_disabled": "#1e66f54d",
"secondary_foreground": "#1e66f5",
"secondary_background": "#e6e9ef",
"secondary_hover": "#1e66f51a",
"secondary_active": "#dce0e8",
"secondary_selected": "#dce0e8",
"secondary_disabled": "#1e66f54d",
"danger_foreground": "#eff1f5",
"danger_background": "#d20f39",
"danger_hover": "#d20f39e6",
"danger_active": "#bc0e33",
"danger_selected": "#a60c2d",
"danger_disabled": "#d20f394d",
"warning_foreground": "#4c4f69",
"warning_background": "#df8e1d",
"warning_hover": "#df8e1de6",
"warning_active": "#c9801a",
"warning_selected": "#b47217",
"warning_disabled": "#df8e1d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e6e9ef",
"ghost_element_hover": "#4c4f691a",
"ghost_element_active": "#dce0e8",
"ghost_element_selected": "#dce0e8",
"ghost_element_disabled": "#4c4f690d",
"tab_inactive_background": "#e6e9ef",
"tab_hover_background": "#dce0e8",
"tab_active_background": "#ccd0da",
"scrollbar_thumb_background": "#4c4f6933",
"scrollbar_thumb_hover_background": "#4c4f694d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dce0e8",
"drop_target_background": "#1e66f51a",
"cursor": "#04a5e5",
"selection": "#04a5e540"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "catppuccin-macchiato",
"name": "Catppuccin Macchiato",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"light": {
"background": "#24273a",
"surface_background": "#1e2030",
"elevated_surface_background": "#181926",
"panel_background": "#24273a",
"overlay": "#cad3f51a",
"title_bar": "#00000000",
"title_bar_inactive": "#24273a",
"window_border": "#5b6078",
"border": "#5b6078",
"border_variant": "#494d64",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#00000000",
"border_disabled": "#363a4f",
"ring": "#8aadf4",
"text": "#cad3f5",
"text_muted": "#b8c0e0",
"text_placeholder": "#a5adcb",
"text_accent": "#8aadf4",
"icon": "#cad3f5",
"icon_muted": "#b8c0e0",
"icon_accent": "#8aadf4",
"element_foreground": "#24273a",
"element_background": "#8aadf4",
"element_hover": "#8aadf4e6",
"element_active": "#7c9cdc",
"element_selected": "#6e8bc4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#8aadf4",
"secondary_background": "#363a4f",
"secondary_hover": "#8aadf41a",
"secondary_active": "#494d64",
"secondary_selected": "#494d64",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ed8796e6",
"danger_active": "#d57a87",
"danger_selected": "#bd6d78",
"danger_disabled": "#ed87964d",
"warning_foreground": "#24273a",
"warning_background": "#eed49f",
"warning_hover": "#eed49fe6",
"warning_active": "#d6bf8f",
"warning_selected": "#beaa7f",
"warning_disabled": "#eed49f4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#363a4f",
"ghost_element_hover": "#cad3f51a",
"ghost_element_active": "#494d64",
"ghost_element_selected": "#494d64",
"ghost_element_disabled": "#cad3f50d",
"tab_inactive_background": "#363a4f",
"tab_hover_background": "#494d64",
"tab_active_background": "#5b6078",
"scrollbar_thumb_background": "#cad3f533",
"scrollbar_thumb_hover_background": "#cad3f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#494d64",
"drop_target_background": "#8aadf41a",
"cursor": "#91d7e3",
"selection": "#91d7e340"
},
"dark": {
"background": "#24273a",
"surface_background": "#1e2030",
"elevated_surface_background": "#181926",
"panel_background": "#24273a",
"overlay": "#cad3f51a",
"title_bar": "#00000000",
"title_bar_inactive": "#24273a",
"window_border": "#5b6078",
"border": "#5b6078",
"border_variant": "#494d64",
"border_focused": "#8aadf4",
"border_selected": "#8aadf4",
"border_transparent": "#00000000",
"border_disabled": "#363a4f",
"ring": "#8aadf4",
"text": "#cad3f5",
"text_muted": "#b8c0e0",
"text_placeholder": "#a5adcb",
"text_accent": "#8aadf4",
"icon": "#cad3f5",
"icon_muted": "#b8c0e0",
"icon_accent": "#8aadf4",
"element_foreground": "#24273a",
"element_background": "#8aadf4",
"element_hover": "#8aadf4e6",
"element_active": "#7c9cdc",
"element_selected": "#6e8bc4",
"element_disabled": "#8aadf44d",
"secondary_foreground": "#8aadf4",
"secondary_background": "#363a4f",
"secondary_hover": "#8aadf41a",
"secondary_active": "#494d64",
"secondary_selected": "#494d64",
"secondary_disabled": "#8aadf44d",
"danger_foreground": "#24273a",
"danger_background": "#ed8796",
"danger_hover": "#ed8796e6",
"danger_active": "#d57a87",
"danger_selected": "#bd6d78",
"danger_disabled": "#ed87964d",
"warning_foreground": "#24273a",
"warning_background": "#eed49f",
"warning_hover": "#eed49fe6",
"warning_active": "#d6bf8f",
"warning_selected": "#beaa7f",
"warning_disabled": "#eed49f4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#363a4f",
"ghost_element_hover": "#cad3f51a",
"ghost_element_active": "#494d64",
"ghost_element_selected": "#494d64",
"ghost_element_disabled": "#cad3f50d",
"tab_inactive_background": "#363a4f",
"tab_hover_background": "#494d64",
"tab_active_background": "#5b6078",
"scrollbar_thumb_background": "#cad3f533",
"scrollbar_thumb_hover_background": "#cad3f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#494d64",
"drop_target_background": "#8aadf41a",
"cursor": "#91d7e3",
"selection": "#91d7e340"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "catppuccin-mocha",
"name": "Catppuccin Mocha",
"author": "Catppuccin",
"url": "https://github.com/catppuccin/catppuccin",
"light": {
"background": "#1e1e2e",
"surface_background": "#181825",
"elevated_surface_background": "#11111b",
"panel_background": "#1e1e2e",
"overlay": "#cdd6f41a",
"title_bar": "#00000000",
"title_bar_inactive": "#1e1e2e",
"window_border": "#585b70",
"border": "#585b70",
"border_variant": "#45475a",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#00000000",
"border_disabled": "#313244",
"ring": "#89b4fa",
"text": "#cdd6f4",
"text_muted": "#bac2de",
"text_placeholder": "#a6adc8",
"text_accent": "#89b4fa",
"icon": "#cdd6f4",
"icon_muted": "#bac2de",
"icon_accent": "#89b4fa",
"element_foreground": "#1e1e2e",
"element_background": "#89b4fa",
"element_hover": "#89b4fae6",
"element_active": "#7ba2e1",
"element_selected": "#6d90c8",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#89b4fa",
"secondary_background": "#313244",
"secondary_hover": "#89b4fa1a",
"secondary_active": "#45475a",
"secondary_selected": "#45475a",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#f38ba8e6",
"danger_active": "#db7d97",
"danger_selected": "#c36f86",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#1e1e2e",
"warning_background": "#f9e2af",
"warning_hover": "#f9e2afe6",
"warning_active": "#e0cb9e",
"warning_selected": "#c7b48d",
"warning_disabled": "#f9e2af4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#313244",
"ghost_element_hover": "#cdd6f41a",
"ghost_element_active": "#45475a",
"ghost_element_selected": "#45475a",
"ghost_element_disabled": "#cdd6f50d",
"tab_inactive_background": "#313244",
"tab_hover_background": "#45475a",
"tab_active_background": "#585b70",
"scrollbar_thumb_background": "#cdd6f533",
"scrollbar_thumb_hover_background": "#cdd6f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#45475a",
"drop_target_background": "#89b4fa1a",
"cursor": "#89dceb",
"selection": "#89dceb40"
},
"dark": {
"background": "#1e1e2e",
"surface_background": "#181825",
"elevated_surface_background": "#11111b",
"panel_background": "#1e1e2e",
"overlay": "#cdd6f41a",
"title_bar": "#00000000",
"title_bar_inactive": "#1e1e2e",
"window_border": "#585b70",
"border": "#585b70",
"border_variant": "#45475a",
"border_focused": "#89b4fa",
"border_selected": "#89b4fa",
"border_transparent": "#00000000",
"border_disabled": "#313244",
"ring": "#89b4fa",
"text": "#cdd6f4",
"text_muted": "#bac2de",
"text_placeholder": "#a6adc8",
"text_accent": "#89b4fa",
"icon": "#cdd6f4",
"icon_muted": "#bac2de",
"icon_accent": "#89b4fa",
"element_foreground": "#1e1e2e",
"element_background": "#89b4fa",
"element_hover": "#89b4fae6",
"element_active": "#7ba2e1",
"element_selected": "#6d90c8",
"element_disabled": "#89b4fa4d",
"secondary_foreground": "#89b4fa",
"secondary_background": "#313244",
"secondary_hover": "#89b4fa1a",
"secondary_active": "#45475a",
"secondary_selected": "#45475a",
"secondary_disabled": "#89b4fa4d",
"danger_foreground": "#1e1e2e",
"danger_background": "#f38ba8",
"danger_hover": "#f38ba8e6",
"danger_active": "#db7d97",
"danger_selected": "#c36f86",
"danger_disabled": "#f38ba84d",
"warning_foreground": "#1e1e2e",
"warning_background": "#f9e2af",
"warning_hover": "#f9e2afe6",
"warning_active": "#e0cb9e",
"warning_selected": "#c7b48d",
"warning_disabled": "#f9e2af4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#313244",
"ghost_element_hover": "#cdd6f41a",
"ghost_element_active": "#45475a",
"ghost_element_selected": "#45475a",
"ghost_element_disabled": "#cdd6f50d",
"tab_inactive_background": "#313244",
"tab_hover_background": "#45475a",
"tab_active_background": "#585b70",
"scrollbar_thumb_background": "#cdd6f533",
"scrollbar_thumb_hover_background": "#cdd6f54d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#45475a",
"drop_target_background": "#89b4fa1a",
"cursor": "#89dceb",
"selection": "#89dceb40"
}
}

136
assets/themes/flexoki.json Normal file
View File

@@ -0,0 +1,136 @@
{
"id": "flexoki",
"name": "Flexoki",
"author": "Steph Ango",
"url": "https://stephango.com/flexoki",
"light": {
"background": "#FFFCF0",
"surface_background": "#F2F0E5",
"elevated_surface_background": "#E6E4D9",
"panel_background": "#FFFCF0",
"overlay": "#100F0F1a",
"title_bar": "#00000000",
"title_bar_inactive": "#FFFCF0",
"window_border": "#CECDC3",
"border": "#CECDC3",
"border_variant": "#DAD8CE",
"border_focused": "#24837B",
"border_selected": "#24837B",
"border_transparent": "#00000000",
"border_disabled": "#E6E4D9",
"ring": "#24837B",
"text": "#100F0F",
"text_muted": "#6F6E69",
"text_placeholder": "#878580",
"text_accent": "#24837B",
"icon": "#100F0F",
"icon_muted": "#6F6E69",
"icon_accent": "#24837B",
"element_foreground": "#DDF1E4",
"element_background": "#24837B",
"element_hover": "#24837Be5",
"element_active": "#20756E",
"element_selected": "#1C6861",
"element_disabled": "#24837B4c",
"secondary_foreground": "#24837B",
"secondary_background": "#E6E4D9",
"secondary_hover": "#24837B1a",
"secondary_active": "#DAD8CE",
"secondary_selected": "#DAD8CE",
"secondary_disabled": "#24837B4c",
"danger_foreground": "#FFE1D5",
"danger_background": "#AF3029",
"danger_hover": "#AF3029e5",
"danger_active": "#9E2B25",
"danger_selected": "#8D2620",
"danger_disabled": "#AF30294c",
"warning_foreground": "#FFE7CE",
"warning_background": "#BC5215",
"warning_hover": "#BC5215e5",
"warning_active": "#A94913",
"warning_selected": "#964011",
"warning_disabled": "#BC52154c",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#E6E4D9",
"ghost_element_hover": "#100F0F1a",
"ghost_element_active": "#DAD8CE",
"ghost_element_selected": "#DAD8CE",
"ghost_element_disabled": "#100F0F0d",
"tab_inactive_background": "#E6E4D9",
"tab_hover_background": "#DAD8CE",
"tab_active_background": "#CECDC3",
"scrollbar_thumb_background": "#100F0F33",
"scrollbar_thumb_hover_background": "#100F0F4d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#DAD8CE",
"drop_target_background": "#24837B1a",
"cursor": "#205EA6",
"selection": "#24837B40"
},
"dark": {
"background": "#100F0F",
"surface_background": "#1C1B1A",
"elevated_surface_background": "#282726",
"panel_background": "#100F0F",
"overlay": "#FFFCF01a",
"title_bar": "#00000000",
"title_bar_inactive": "#100F0F",
"window_border": "#403E3C",
"border": "#403E3C",
"border_variant": "#343331",
"border_focused": "#3AA99F",
"border_selected": "#3AA99F",
"border_transparent": "#00000000",
"border_disabled": "#282726",
"ring": "#3AA99F",
"text": "#FFFCF0",
"text_muted": "#878580",
"text_placeholder": "#575653",
"text_accent": "#3AA99F",
"icon": "#FFFCF0",
"icon_muted": "#878580",
"icon_accent": "#3AA99F",
"element_foreground": "#101F1D",
"element_background": "#3AA99F",
"element_hover": "#3AA99Fe5",
"element_active": "#34988F",
"element_selected": "#2F877F",
"element_disabled": "#3AA99F4c",
"secondary_foreground": "#3AA99F",
"secondary_background": "#282726",
"secondary_hover": "#3AA99F1a",
"secondary_active": "#343331",
"secondary_selected": "#343331",
"secondary_disabled": "#3AA99F4c",
"danger_foreground": "#261312",
"danger_background": "#D14D41",
"danger_hover": "#D14D41e5",
"danger_active": "#BC453A",
"danger_selected": "#A73D33",
"danger_disabled": "#D14D414c",
"warning_foreground": "#27180E",
"warning_background": "#DA702C",
"warning_hover": "#DA702Ce5",
"warning_active": "#C46527",
"warning_selected": "#AF5A22",
"warning_disabled": "#DA702C4c",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#282726",
"ghost_element_hover": "#FFFCF01a",
"ghost_element_active": "#343331",
"ghost_element_selected": "#343331",
"ghost_element_disabled": "#FFFCF00d",
"tab_inactive_background": "#282726",
"tab_hover_background": "#343331",
"tab_active_background": "#403E3C",
"scrollbar_thumb_background": "#FFFCF033",
"scrollbar_thumb_hover_background": "#FFFCF04d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#343331",
"drop_target_background": "#3AA99F1a",
"cursor": "#4385BE",
"selection": "#3AA99F40"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "rose-pine-dawn",
"name": "Rosé Pine Dawn",
"author": "Rosé Pine",
"url": "https://rosepinetheme.com/",
"light": {
"background": "#faf4ed",
"surface_background": "#fffaf3",
"elevated_surface_background": "#f2e9e1",
"panel_background": "#fffaf3",
"overlay": "#5752791a",
"title_bar": "#00000000",
"title_bar_inactive": "#faf4ed",
"window_border": "#cecacd",
"border": "#cecacd",
"border_variant": "#dfdad9",
"border_focused": "#286983",
"border_selected": "#286983",
"border_transparent": "#00000000",
"border_disabled": "#f4ede8",
"ring": "#286983",
"text": "#575279",
"text_muted": "#797593",
"text_placeholder": "#9893a5",
"text_accent": "#907aa9",
"icon": "#575279",
"icon_muted": "#797593",
"icon_accent": "#907aa9",
"element_foreground": "#faf4ed",
"element_background": "#286983",
"element_hover": "#286983e6",
"element_active": "#245f76",
"element_selected": "#205569",
"element_disabled": "#2869834d",
"secondary_foreground": "#286983",
"secondary_background": "#f4ede8",
"secondary_hover": "#2869831a",
"secondary_active": "#dfdad9",
"secondary_selected": "#dfdad9",
"secondary_disabled": "#2869834d",
"danger_foreground": "#faf4ed",
"danger_background": "#b4637a",
"danger_hover": "#b4637ae6",
"danger_active": "#a2596e",
"danger_selected": "#904f62",
"danger_disabled": "#b4637a4d",
"warning_foreground": "#faf4ed",
"warning_background": "#ea9d34",
"warning_hover": "#ea9d34e6",
"warning_active": "#d38d2f",
"warning_selected": "#bc7d2a",
"warning_disabled": "#ea9d344d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#f4ede8",
"ghost_element_hover": "#5752791a",
"ghost_element_active": "#dfdad9",
"ghost_element_selected": "#dfdad9",
"ghost_element_disabled": "#5752790d",
"tab_inactive_background": "#f4ede8",
"tab_hover_background": "#dfdad9",
"tab_active_background": "#cecacd",
"scrollbar_thumb_background": "#57527933",
"scrollbar_thumb_hover_background": "#5752794d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dfdad9",
"drop_target_background": "#2869831a",
"cursor": "#56949f",
"selection": "#56949f40"
},
"dark": {
"background": "#faf4ed",
"surface_background": "#fffaf3",
"elevated_surface_background": "#f2e9e1",
"panel_background": "#fffaf3",
"overlay": "#5752791a",
"title_bar": "#00000000",
"title_bar_inactive": "#faf4ed",
"window_border": "#cecacd",
"border": "#cecacd",
"border_variant": "#dfdad9",
"border_focused": "#286983",
"border_selected": "#286983",
"border_transparent": "#00000000",
"border_disabled": "#f4ede8",
"ring": "#286983",
"text": "#575279",
"text_muted": "#797593",
"text_placeholder": "#9893a5",
"text_accent": "#907aa9",
"icon": "#575279",
"icon_muted": "#797593",
"icon_accent": "#907aa9",
"element_foreground": "#faf4ed",
"element_background": "#286983",
"element_hover": "#286983e6",
"element_active": "#245f76",
"element_selected": "#205569",
"element_disabled": "#2869834d",
"secondary_foreground": "#286983",
"secondary_background": "#f4ede8",
"secondary_hover": "#2869831a",
"secondary_active": "#dfdad9",
"secondary_selected": "#dfdad9",
"secondary_disabled": "#2869834d",
"danger_foreground": "#faf4ed",
"danger_background": "#b4637a",
"danger_hover": "#b4637ae6",
"danger_active": "#a2596e",
"danger_selected": "#904f62",
"danger_disabled": "#b4637a4d",
"warning_foreground": "#faf4ed",
"warning_background": "#ea9d34",
"warning_hover": "#ea9d34e6",
"warning_active": "#d38d2f",
"warning_selected": "#bc7d2a",
"warning_disabled": "#ea9d344d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#f4ede8",
"ghost_element_hover": "#5752791a",
"ghost_element_active": "#dfdad9",
"ghost_element_selected": "#dfdad9",
"ghost_element_disabled": "#5752790d",
"tab_inactive_background": "#f4ede8",
"tab_hover_background": "#dfdad9",
"tab_active_background": "#cecacd",
"scrollbar_thumb_background": "#57527933",
"scrollbar_thumb_hover_background": "#5752794d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#dfdad9",
"drop_target_background": "#2869831a",
"cursor": "#56949f",
"selection": "#56949f40"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "rose-pine-moon",
"name": "Rosé Pine Moon",
"author": "Rosé Pine",
"url": "https://rosepinetheme.com/",
"light": {
"background": "#232136",
"surface_background": "#2a273f",
"elevated_surface_background": "#393552",
"panel_background": "#2a273f",
"overlay": "#e0def41a",
"title_bar": "#00000000",
"title_bar_inactive": "#232136",
"window_border": "#56526e",
"border": "#56526e",
"border_variant": "#44415a",
"border_focused": "#3e8fb0",
"border_selected": "#3e8fb0",
"border_transparent": "#00000000",
"border_disabled": "#2a283e",
"ring": "#3e8fb0",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#e0def4",
"icon_muted": "#908caa",
"icon_accent": "#c4a7e7",
"element_foreground": "#232136",
"element_background": "#3e8fb0",
"element_hover": "#3e8fb0e6",
"element_active": "#38809d",
"element_selected": "#32718a",
"element_disabled": "#3e8fb04d",
"secondary_foreground": "#3e8fb0",
"secondary_background": "#2a283e",
"secondary_hover": "#3e8fb01a",
"secondary_active": "#44415a",
"secondary_selected": "#44415a",
"secondary_disabled": "#3e8fb04d",
"danger_foreground": "#232136",
"danger_background": "#eb6f92",
"danger_hover": "#eb6f92e6",
"danger_active": "#d46483",
"danger_selected": "#bd5974",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#232136",
"warning_background": "#f6c177",
"warning_hover": "#f6c177e6",
"warning_active": "#ddae6b",
"warning_selected": "#c49b5f",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#2a283e",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#44415a",
"ghost_element_selected": "#44415a",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#2a283e",
"tab_hover_background": "#44415a",
"tab_active_background": "#56526e",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#44415a",
"drop_target_background": "#3e8fb01a",
"cursor": "#9ccfd8",
"selection": "#9ccfd840"
},
"dark": {
"background": "#232136",
"surface_background": "#2a273f",
"elevated_surface_background": "#393552",
"panel_background": "#2a273f",
"overlay": "#e0def41a",
"title_bar": "#00000000",
"title_bar_inactive": "#232136",
"window_border": "#56526e",
"border": "#56526e",
"border_variant": "#44415a",
"border_focused": "#3e8fb0",
"border_selected": "#3e8fb0",
"border_transparent": "#00000000",
"border_disabled": "#2a283e",
"ring": "#3e8fb0",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#e0def4",
"icon_muted": "#908caa",
"icon_accent": "#c4a7e7",
"element_foreground": "#232136",
"element_background": "#3e8fb0",
"element_hover": "#3e8fb0e6",
"element_active": "#38809d",
"element_selected": "#32718a",
"element_disabled": "#3e8fb04d",
"secondary_foreground": "#3e8fb0",
"secondary_background": "#2a283e",
"secondary_hover": "#3e8fb01a",
"secondary_active": "#44415a",
"secondary_selected": "#44415a",
"secondary_disabled": "#3e8fb04d",
"danger_foreground": "#232136",
"danger_background": "#eb6f92",
"danger_hover": "#eb6f92e6",
"danger_active": "#d46483",
"danger_selected": "#bd5974",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#232136",
"warning_background": "#f6c177",
"warning_hover": "#f6c177e6",
"warning_active": "#ddae6b",
"warning_selected": "#c49b5f",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#2a283e",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#44415a",
"ghost_element_selected": "#44415a",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#2a283e",
"tab_hover_background": "#44415a",
"tab_active_background": "#56526e",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#44415a",
"drop_target_background": "#3e8fb01a",
"cursor": "#9ccfd8",
"selection": "#9ccfd840"
}
}

View File

@@ -0,0 +1,136 @@
{
"id": "rose-pine",
"name": "Rosé Pine",
"author": "Rosé Pine",
"url": "https://rosepinetheme.com/",
"light": {
"background": "#191724",
"surface_background": "#1f1d2e",
"elevated_surface_background": "#26233a",
"panel_background": "#1f1d2e",
"overlay": "#e0def41a",
"title_bar": "#00000000",
"title_bar_inactive": "#191724",
"window_border": "#524f67",
"border": "#524f67",
"border_variant": "#403d52",
"border_focused": "#31748f",
"border_selected": "#31748f",
"border_transparent": "#00000000",
"border_disabled": "#21202e",
"ring": "#31748f",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#e0def4",
"icon_muted": "#908caa",
"icon_accent": "#c4a7e7",
"element_foreground": "#191724",
"element_background": "#31748f",
"element_hover": "#31748fe6",
"element_active": "#2c6980",
"element_selected": "#275e71",
"element_disabled": "#31748f4d",
"secondary_foreground": "#31748f",
"secondary_background": "#21202e",
"secondary_hover": "#31748f1a",
"secondary_active": "#403d52",
"secondary_selected": "#403d52",
"secondary_disabled": "#31748f4d",
"danger_foreground": "#191724",
"danger_background": "#eb6f92",
"danger_hover": "#eb6f92e6",
"danger_active": "#d46483",
"danger_selected": "#bd5974",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#191724",
"warning_background": "#f6c177",
"warning_hover": "#f6c177e6",
"warning_active": "#ddae6b",
"warning_selected": "#c49b5f",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#21202e",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#403d52",
"ghost_element_selected": "#403d52",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#21202e",
"tab_hover_background": "#403d52",
"tab_active_background": "#524f67",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#403d52",
"drop_target_background": "#31748f1a",
"cursor": "#9ccfd8",
"selection": "#9ccfd840"
},
"dark": {
"background": "#191724",
"surface_background": "#1f1d2e",
"elevated_surface_background": "#26233a",
"panel_background": "#1f1d2e",
"overlay": "#e0def41a",
"title_bar": "#00000000",
"title_bar_inactive": "#191724",
"window_border": "#524f67",
"border": "#524f67",
"border_variant": "#403d52",
"border_focused": "#31748f",
"border_selected": "#31748f",
"border_transparent": "#00000000",
"border_disabled": "#21202e",
"ring": "#31748f",
"text": "#e0def4",
"text_muted": "#908caa",
"text_placeholder": "#6e6a86",
"text_accent": "#c4a7e7",
"icon": "#e0def4",
"icon_muted": "#908caa",
"icon_accent": "#c4a7e7",
"element_foreground": "#191724",
"element_background": "#31748f",
"element_hover": "#31748fe6",
"element_active": "#2c6980",
"element_selected": "#275e71",
"element_disabled": "#31748f4d",
"secondary_foreground": "#31748f",
"secondary_background": "#21202e",
"secondary_hover": "#31748f1a",
"secondary_active": "#403d52",
"secondary_selected": "#403d52",
"secondary_disabled": "#31748f4d",
"danger_foreground": "#191724",
"danger_background": "#eb6f92",
"danger_hover": "#eb6f92e6",
"danger_active": "#d46483",
"danger_selected": "#bd5974",
"danger_disabled": "#eb6f924d",
"warning_foreground": "#191724",
"warning_background": "#f6c177",
"warning_hover": "#f6c177e6",
"warning_active": "#ddae6b",
"warning_selected": "#c49b5f",
"warning_disabled": "#f6c1774d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#21202e",
"ghost_element_hover": "#e0def41a",
"ghost_element_active": "#403d52",
"ghost_element_selected": "#403d52",
"ghost_element_disabled": "#e0def40d",
"tab_inactive_background": "#21202e",
"tab_hover_background": "#403d52",
"tab_active_background": "#524f67",
"scrollbar_thumb_background": "#e0def433",
"scrollbar_thumb_hover_background": "#e0def44d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#403d52",
"drop_target_background": "#31748f1a",
"cursor": "#9ccfd8",
"selection": "#9ccfd840"
}
}

View File

@@ -1,34 +0,0 @@
[package]
name = "coop"
version = "0.1.2"
edition = "2021"
publish = false
[[bin]]
name = "coop"
path = "src/main.rs"
[dependencies]
ui = { path = "../ui" }
common = { path = "../common" }
state = { path = "../state" }
chats = { path = "../chats" }
gpui.workspace = true
reqwest_client.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
dirs.workspace = true
rust-embed.workspace = true
log.workspace = true
smol.workspace = true
oneshot.workspace = true
rustls = "0.23.23"
futures= "0.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

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,407 +0,0 @@
use asset::Assets;
use chats::registry::ChatRegistry;
use common::{
constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID},
profile::NostrProfile,
};
use futures::{select, FutureExt};
use gpui::{
actions, px, size, App, AppContext, Application, AsyncApp, Bounds, KeyBinding, Menu, MenuItem,
WindowBounds, WindowKind, WindowOptions,
};
#[cfg(not(target_os = "linux"))]
use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use log::{error, info};
use nostr_sdk::{
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, Metadata, PublicKey,
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions,
};
use nostr_sdk::{prelude::NostrEventsDatabaseExt, FromBech32, SubscriptionId};
use smol::Timer;
use state::{get_client, initialize_client};
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
use ui::{theme::Theme, Root};
use views::{app, onboarding, startup};
mod asset;
mod views;
actions!(coop, [Quit]);
#[derive(Clone)]
enum Signal {
/// Receive event
Event(Event),
/// Receive EOSE
Eose,
}
fn main() {
// Fix crash on startup
// TODO: why this is needed?
_ = rustls::crypto::ring::default_provider().install_default();
// Enable logging
tracing_subscriber::fmt::init();
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(2048);
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
// Initialize nostr client
let client = initialize_client();
// Initialize application
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
// Connect to default relays
app.background_executor()
.spawn(async {
_ = client.add_relay("wss://relay.damus.io/").await;
_ = client.add_relay("wss://relay.primal.net/").await;
_ = client.add_relay("wss://user.kindpag.es/").await;
_ = client.add_relay("wss://purplepag.es/").await;
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
_ = client.connect().await
})
.detach();
// Handle batch metadata
app.background_executor()
.spawn(async move {
const BATCH_SIZE: usize = 20;
const BATCH_TIMEOUT: Duration = Duration::from_millis(200);
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
let mut timeout = Box::pin(Timer::after(BATCH_TIMEOUT).fuse());
select! {
pubkeys = batch_rx.recv().fuse() => {
match pubkeys {
Ok(keys) => {
batch.extend(keys);
if batch.len() >= BATCH_SIZE {
sync_metadata(client, mem::take(&mut batch)).await;
}
}
Err(_) => break,
}
}
_ = timeout => {
if !batch.is_empty() {
sync_metadata(client, mem::take(&mut batch)).await;
}
}
}
}
})
.detach();
// Handle notifications
app.background_executor()
.spawn(async move {
let rng_keys = Keys::generate();
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let mut notifications = client.notifications();
while let Ok(notification) = notifications.recv().await {
if let RelayPoolNotification::Message { message, .. } = notification {
match message {
RelayMessage::Event {
event,
subscription_id,
} => {
match event.kind {
Kind::GiftWrap => {
if let Ok(gift) = client.unwrap_gift_wrap(&event).await {
let mut pubkeys = vec![];
// Sign the rumor with the generated keys,
// this event will be used for internal only,
// and NEVER send to relays.
if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) {
pubkeys.extend(event.tags.public_keys());
pubkeys.push(event.pubkey);
// Save the event to the database, use for query directly.
if let Err(e) =
client.database().save_event(&event).await
{
error!("Failed to save event: {}", e);
}
// Send all pubkeys to the batch
if let Err(e) = batch_tx.send(pubkeys).await {
error!("Failed to send pubkeys to batch: {}", e)
}
// Send this event to the GPUI
if new_id == *subscription_id {
if let Err(e) =
event_tx.send(Signal::Event(event)).await
{
error!("Failed to send event to GPUI: {}", e)
}
}
}
}
}
Kind::ContactList => {
let pubkeys =
event.tags.public_keys().copied().collect::<HashSet<_>>();
sync_metadata(client, pubkeys).await;
}
_ => {}
}
}
RelayMessage::EndOfStoredEvents(subscription_id) => {
if all_id == *subscription_id {
if let Err(e) = event_tx.send(Signal::Eose).await {
error!("Failed to send eose: {}", e)
};
}
}
_ => {}
}
}
}
})
.detach();
// Handle re-open window
app.on_reopen(move |cx| {
let client = get_client();
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
cx.background_spawn(async move {
if let Ok(signer) = client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
let metadata =
if let Ok(Some(metadata)) = client.database().metadata(public_key).await {
metadata
} else {
Metadata::new()
};
_ = tx.send(Some(NostrProfile::new(public_key, metadata)));
} else {
_ = tx.send(None);
}
} else {
_ = tx.send(None);
}
})
.detach();
cx.spawn(|mut cx| async move {
if let Ok(result) = rx.await {
_ = restore_window(result, &mut cx).await;
}
})
.detach();
});
app.run(move |cx| {
// Initialize chat global state
chats::registry::init(cx);
// Initialize components
ui::init(cx);
// Bring the app to the foreground
cx.activate(true);
// Register the `quit` function
cx.on_action(quit);
// Register the `quit` function with CMD+Q
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Set menu items
cx.set_menus(vec![Menu {
name: "Coop".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
// Open window with default options
cx.open_window(
WindowOptions {
#[cfg(not(target_os = "linux"))]
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(900.0), px(680.0)),
cx,
))),
#[cfg(target_os = "linux")]
window_background: WindowBackgroundAppearance::Transparent,
#[cfg(target_os = "linux")]
window_decorations: Some(WindowDecorations::Client),
kind: WindowKind::Normal,
..Default::default()
},
|window, cx| {
window.set_window_title(APP_NAME);
window.set_app_id(APP_ID);
#[cfg(not(target_os = "linux"))]
window
.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
})
.detach();
let handle = window.window_handle();
let root = cx.new(|cx| Root::new(startup::init(window, cx).into(), window, cx));
let task = cx.read_credentials(KEYRING_SERVICE);
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
// Read credential in OS Keyring
cx.background_spawn(async {
let profile = if let Ok(Some((npub, secret))) = task.await {
let public_key = PublicKey::from_bech32(&npub).unwrap();
let secret_hex = String::from_utf8(secret).unwrap();
let keys = Keys::parse(&secret_hex).unwrap();
// Update nostr signer
_ = client.set_signer(keys).await;
// Get user's metadata
let metadata = if let Ok(Some(metadata)) =
client.database().metadata(public_key).await
{
metadata
} else {
Metadata::new()
};
Some(NostrProfile::new(public_key, metadata))
} else {
None
};
_ = tx.send(profile)
})
.detach();
// Set root view based on credential status
cx.spawn(|mut cx| async move {
if let Ok(Some(profile)) = rx.await {
_ = cx.update_window(handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx)
});
});
} else {
_ = cx.update_window(handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(onboarding::init(window, cx).into(), window, cx)
});
});
}
})
.detach();
cx.spawn(|cx| async move {
while let Ok(signal) = event_rx.recv().await {
cx.update(|cx| {
match signal {
Signal::Eose => {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
}
}
Signal::Event(event) => {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.push_message(event, cx))
}
}
};
})
.ok();
}
})
.detach();
root
},
)
.expect("System error. Please re-open the app.");
});
}
async fn sync_metadata(client: &Client, buffer: HashSet<PublicKey>) {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
.authors(buffer.iter().cloned())
.kind(Kind::Metadata)
.limit(buffer.len());
if let Err(e) = client.subscribe(filter, Some(opts)).await {
error!("Subscribe error: {e}");
}
}
async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> anyhow::Result<()> {
let opts = cx
.update(|cx| WindowOptions {
#[cfg(not(target_os = "linux"))]
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(900.0), px(680.0)),
cx,
))),
#[cfg(target_os = "linux")]
window_background: WindowBackgroundAppearance::Transparent,
#[cfg(target_os = "linux")]
window_decorations: Some(WindowDecorations::Client),
kind: WindowKind::Normal,
..Default::default()
})
.expect("Failed to set window options.");
if let Some(profile) = profile {
_ = cx.open_window(opts, |window, cx| {
window.set_window_title(APP_NAME);
window.set_app_id(APP_ID);
#[cfg(not(target_os = "linux"))]
window
.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
})
.detach();
cx.new(|cx| Root::new(app::init(profile, window, cx).into(), window, cx))
});
} else {
_ = cx.open_window(opts, |window, cx| {
window.set_window_title(APP_NAME);
window.set_app_id(APP_ID);
window
.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
})
.detach();
cx.new(|cx| Root::new(onboarding::init(window, cx).into(), window, cx))
});
};
Ok(())
}
fn quit(_: &Quit, cx: &mut App) {
info!("Gracefully quitting the application . . .");
cx.quit();
}

View File

@@ -1,365 +0,0 @@
use common::profile::NostrProfile;
use gpui::{
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
StyledImage, Window,
};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use state::get_client;
use std::sync::Arc;
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::{dock::DockPlacement, DockArea, DockItem},
popup_menu::PopupMenuExt,
theme::{scale::ColorScaleStep, ActiveTheme, Appearance, Theme},
ContextModal, Icon, IconName, Root, Sizable, TitleBar,
};
use super::{chat, contacts, onboarding, profile, relays::Relays, settings, sidebar, welcome};
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub enum PanelKind {
Room(u64),
Profile,
Contacts,
Settings,
}
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct AddPanel {
panel: PanelKind,
position: DockPlacement,
}
impl AddPanel {
pub fn new(panel: PanelKind, position: DockPlacement) -> Self {
Self { panel, position }
}
}
impl_internal_actions!(dock, [AddPanel]);
actions!(account, [Logout]);
pub fn init(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<AppView> {
AppView::new(account, window, cx)
}
pub struct AppView {
account: NostrProfile,
relays: Entity<Option<Vec<String>>>,
dock: Entity<DockArea>,
}
impl AppView {
pub fn new(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> {
// Initialize dock layout
let dock = cx.new(|cx| DockArea::new(window, cx));
let weak_dock = dock.downgrade();
// Initialize left dock
let left_panel = DockItem::panel(Arc::new(sidebar::init(window, cx)));
// Initial central dock
let center_panel = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(
vec![Arc::new(welcome::init(window, cx))],
None,
&weak_dock,
window,
cx,
)],
vec![None],
&weak_dock,
window,
cx,
);
// Set default dock layout with left and central docks
_ = weak_dock.update(cx, |view, cx| {
view.set_left_dock(left_panel, Some(px(240.)), true, window, cx);
view.set_center(center_panel, window, cx);
});
cx.new(|cx| {
let public_key = account.public_key();
let relays = cx.new(|_| None);
let async_relays = relays.downgrade();
// Check user's messaging relays and determine user is ready for NIP17 or not.
// If not, show the setup modal and instruct user setup inbox relays
let client = get_client();
let window_handle = window.window_handle();
let (tx, rx) = oneshot::channel::<Option<Vec<String>>>();
let this = Self {
account,
relays,
dock,
};
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let relays = if let Ok(events) = client.database().query(filter).await {
if let Some(event) = events.first_owned() {
Some(
event
.tags
.filter_standardized(TagKind::Relay)
.filter_map(|t| match t {
TagStandard::Relay(url) => Some(url.to_string()),
_ => None,
})
.collect::<Vec<_>>(),
)
} else {
None
}
} else {
None
};
_ = tx.send(relays);
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(result) = rx.await {
if let Some(relays) = result {
_ = cx.update(|cx| {
_ = async_relays.update(cx, |this, cx| {
*this = Some(relays);
cx.notify();
});
});
} else {
_ = cx.update_window(window_handle, |_, window, cx| {
this.update(cx, |this: &mut Self, cx| {
this.render_setup_relays(window, cx)
})
});
}
}
})
.detach();
this
})
}
fn render_setup_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = cx.new(|cx| Relays::new(None, window, cx));
window.open_modal(cx, move |this, window, cx| {
let is_loading = relays.read(cx).loading();
this.keyboard(false)
.closable(false)
.width(px(420.))
.title("Your Messaging Relays are not configured")
.child(relays.clone())
.footer(
div()
.p_2()
.border_t_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
.child(
Button::new("update_inbox_relays_btn")
.label("Update")
.primary()
.bold()
.rounded(ButtonRounded::Large)
.w_full()
.loading(is_loading)
.on_click(window.listener_for(&relays, |this, _, window, cx| {
this.update(window, cx);
})),
),
)
});
}
fn render_edit_relay(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = self.relays.read(cx).clone();
let view = cx.new(|cx| Relays::new(relays, window, cx));
window.open_modal(cx, move |this, window, cx| {
let is_loading = view.read(cx).loading();
this.width(px(420.))
.title("Edit your Messaging Relays")
.child(view.clone())
.footer(
div()
.p_2()
.border_t_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
.child(
Button::new("update_inbox_relays_btn")
.label("Update")
.primary()
.bold()
.rounded(ButtonRounded::Large)
.w_full()
.loading(is_loading)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.update(window, cx);
})),
),
)
});
}
fn render_appearance_button(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
Button::new("appearance")
.xsmall()
.ghost()
.map(|this| {
if cx.theme().appearance.is_dark() {
this.icon(IconName::Sun)
} else {
this.icon(IconName::Moon)
}
})
.on_click(cx.listener(|_, _, window, cx| {
if cx.theme().appearance.is_dark() {
Theme::change(Appearance::Light, Some(window), cx);
} else {
Theme::change(Appearance::Dark, Some(window), cx);
}
}))
}
fn render_relays_button(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
Button::new("relays")
.xsmall()
.ghost()
.icon(IconName::Relays)
.on_click(cx.listener(|this, _, window, cx| {
this.render_edit_relay(window, cx);
}))
}
fn render_account(&self) -> impl IntoElement {
Button::new("account")
.ghost()
.xsmall()
.reverse()
.icon(Icon::new(IconName::ChevronDownSmall))
.child(
img(self.account.avatar())
.size_5()
.rounded_full()
.object_fit(ObjectFit::Cover),
)
.popup_menu(move |this, _, _cx| {
this.menu(
"Profile",
Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)),
)
.menu(
"Contacts",
Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)),
)
.menu(
"Settings",
Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)),
)
.separator()
.menu("Change account", Box::new(Logout))
})
}
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
match &action.panel {
PanelKind::Room(id) => match chat::init(id, window, cx) {
Ok(panel) => {
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
Err(e) => window.push_notification(e.to_string(), cx),
},
PanelKind::Profile => {
let panel = Arc::new(profile::init(self.account.clone(), window, cx));
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
PanelKind::Contacts => {
let panel = Arc::new(contacts::init(window, cx));
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
PanelKind::Settings => {
let panel = Arc::new(settings::init(window, cx));
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
};
}
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
cx.background_spawn(async move { get_client().reset().await })
.detach();
window.replace_root(cx, |window, cx| {
Root::new(onboarding::init(window, cx).into(), window, cx)
});
}
}
impl Render for AppView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
div()
.relative()
.size_full()
.flex()
.flex_col()
// Main
.child(
TitleBar::new()
// Left side
.child(div())
// Right side
.child(
div()
.flex()
.items_center()
.justify_end()
.gap_2()
.px_2()
.child(self.render_appearance_button(window, cx))
.child(self.render_relays_button(window, cx))
.child(self.render_account()),
),
)
.child(self.dock.clone())
.child(div().absolute().top_8().children(notification_layer))
.children(modal_layer)
.on_action(cx.listener(Self::on_panel_action))
.on_action(cx.listener(Self::on_logout_action))
}
}

View File

@@ -1,854 +0,0 @@
use anyhow::anyhow;
use async_utility::task::spawn;
use chats::{registry::ChatRegistry, room::Room};
use common::{
constants::IMAGE_SERVICE,
last_seen::LastSeen,
profile::NostrProfile,
utils::{compare, nip96_upload},
};
use gpui::{
div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext,
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement,
IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render,
SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, WeakEntity,
Window,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use smol::fs;
use state::get_client;
use std::sync::Arc;
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::{InputEvent, TextInput},
notification::Notification,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
v_flex, ContextModal, Icon, IconName, Sizable, StyledExt,
};
const ALERT: &str =
"This conversation is private. Only members of this chat can see each other's messages.";
pub fn init(
id: &u64,
window: &mut Window,
cx: &mut App,
) -> Result<Arc<Entity<Chat>>, anyhow::Error> {
if let Some(chats) = ChatRegistry::global(cx) {
if let Some(room) = chats.read(cx).get(id, cx) {
Ok(Arc::new(Chat::new(id, &room, window, cx)))
} else {
Err(anyhow!("Chat room is not exist"))
}
} else {
Err(anyhow!("Chat Registry is not initialized"))
}
}
#[derive(PartialEq, Eq)]
struct ParsedMessage {
avatar: SharedString,
display_name: SharedString,
created_at: SharedString,
content: SharedString,
}
impl ParsedMessage {
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
let avatar = profile.avatar().into();
let display_name = profile.name().into();
let content = SharedString::new(content);
let created_at = LastSeen(created_at).human_readable();
Self {
avatar,
display_name,
created_at,
content,
}
}
}
#[derive(PartialEq, Eq)]
enum Message {
User(Box<ParsedMessage>),
System(SharedString),
Placeholder,
}
impl Message {
pub fn new(message: ParsedMessage) -> Self {
Self::User(Box::new(message))
}
pub fn system(content: SharedString) -> Self {
Self::System(content)
}
pub fn placeholder() -> Self {
Self::Placeholder
}
}
pub struct Chat {
// Panel
id: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
// Chat Room
room: WeakEntity<Room>,
messages: Entity<Vec<Message>>,
new_messages: WeakEntity<Vec<Event>>,
list_state: ListState,
subscriptions: Vec<Subscription>,
// New Message
input: Entity<TextInput>,
// Media
attaches: Entity<Option<Vec<Url>>>,
is_uploading: bool,
}
impl Chat {
pub fn new(id: &u64, model: &Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
let room = model.downgrade();
let new_messages = model.read(cx).new_messages.downgrade();
cx.new(|cx| {
let messages = cx.new(|_| vec![Message::placeholder()]);
let attaches = cx.new(|_| None);
let input = cx.new(|cx| {
TextInput::new(window, cx)
.appearance(false)
.text_size(ui::Size::Small)
.placeholder("Message...")
});
let subscriptions = vec![cx.subscribe_in(
&input,
window,
move |this: &mut Chat, _, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
this.send_message(window, cx);
}
},
)];
// Initialize list state
// [item_count] always equal to 1 at the beginning
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.), {
let this = cx.entity().downgrade();
move |ix, window, cx| {
this.update(cx, |this, cx| {
this.render_message(ix, window, cx).into_any_element()
})
.unwrap()
}
});
let mut this = Self {
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
is_uploading: false,
id: id.to_string().into(),
room,
new_messages,
messages,
list_state,
input,
attaches,
subscriptions,
};
// Verify messaging relays of all members
this.verify_messaging_relays(cx);
// Load all messages from database
this.load_messages(cx);
// Subscribe and load new messages
this.load_new_messages(cx);
this
})
}
fn verify_messaging_relays(&self, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else {
return;
};
let room = model.read(cx);
let pubkeys: Vec<PublicKey> = room.members.iter().map(|m| m.public_key()).collect();
let client = get_client();
let (tx, rx) = oneshot::channel::<Vec<(PublicKey, bool)>>();
cx.background_spawn(async move {
let mut result = Vec::new();
for pubkey in pubkeys.into_iter() {
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(pubkey)
.limit(1);
let is_ready = if let Ok(events) = client.database().query(filter).await {
events.first_owned().is_some()
} else {
false
};
result.push((pubkey, is_ready));
}
_ = tx.send(result);
})
.detach();
cx.spawn(|this, cx| async move {
if let Ok(result) = rx.await {
_ = cx.update(|cx| {
_ = this.update(cx, |this, cx| {
for item in result.into_iter() {
if !item.1 {
let name = this
.room
.read_with(cx, |this, _| this.name())
.unwrap_or("Unnamed".into());
this.push_system_message(
format!("{} has not set up Messaging (DM) Relays, so they will NOT receive your messages.", name),
cx,
);
}
}
});
});
}
})
.detach();
}
fn load_messages(&self, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else {
return;
};
let client = get_client();
let (tx, rx) = oneshot::channel::<Events>();
let room = model.read(cx);
let pubkeys = room
.members
.iter()
.map(|m| m.public_key())
.collect::<Vec<_>>();
let recv = Filter::new()
.kind(Kind::PrivateDirectMessage)
.author(room.owner.public_key())
.pubkeys(pubkeys.iter().copied());
let send = Filter::new()
.kind(Kind::PrivateDirectMessage)
.authors(pubkeys)
.pubkey(room.owner.public_key());
cx.background_spawn(async move {
let Ok(recv_events) = client.database().query(recv).await else {
return;
};
let Ok(send_events) = client.database().query(send).await else {
return;
};
let events = recv_events.merge(send_events);
_ = tx.send(events);
})
.detach();
cx.spawn(|this, cx| async move {
if let Ok(events) = rx.await {
_ = cx.update(|cx| {
_ = this.update(cx, |this, cx| {
this.push_messages(events, cx);
});
})
}
})
.detach();
}
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
let old_len = self.messages.read(cx).len();
let message = Message::system(content.into());
cx.update_entity(&self.messages, |this, cx| {
this.extend(vec![message]);
cx.notify();
});
self.list_state.splice(old_len..old_len, 1);
}
fn push_message(&self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else {
return;
};
let old_len = self.messages.read(cx).len();
let room = model.read(cx);
let message = Message::new(ParsedMessage::new(&room.owner, &content, Timestamp::now()));
// Update message list
cx.update_entity(&self.messages, |this, cx| {
this.extend(vec![message]);
cx.notify();
});
// Reset message input
cx.update_entity(&self.input, |this, cx| {
this.set_loading(false, window, cx);
this.set_disabled(false, window, cx);
this.set_text("", window, cx);
cx.notify();
});
self.list_state.splice(old_len..old_len, 1);
}
fn push_messages(&self, events: Events, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else {
return;
};
let old_len = self.messages.read(cx).len();
let room = model.read(cx);
let pubkeys = room.pubkeys();
let (messages, total) = {
let items: Vec<Message> = events
.into_iter()
.sorted_by_key(|ev| ev.created_at)
.filter_map(|ev| {
let mut other_pubkeys: Vec<_> = ev.tags.public_keys().copied().collect();
other_pubkeys.push(ev.pubkey);
if compare(&other_pubkeys, &pubkeys) {
let member = if let Some(member) =
room.members.iter().find(|&m| m.public_key() == ev.pubkey)
{
member.to_owned()
} else {
room.owner.to_owned()
};
let message =
Message::new(ParsedMessage::new(&member, &ev.content, ev.created_at));
Some(message)
} else {
None
}
})
.collect();
let total = items.len();
(items, total)
};
cx.update_entity(&self.messages, |this, cx| {
this.extend(messages);
cx.notify();
});
self.list_state.splice(old_len..old_len, total);
}
fn load_new_messages(&mut self, cx: &mut Context<Self>) {
let Some(model) = self.new_messages.upgrade() else {
return;
};
let subscription = cx.observe(&model, |view, this, cx| {
let Some(model) = view.room.upgrade() else {
return;
};
let room = model.read(cx);
let old_messages = view.messages.read(cx);
let old_len = old_messages.len();
let items: Vec<Message> = this
.read(cx)
.iter()
.filter_map(|event| {
if let Some(profile) = room.member(&event.pubkey) {
let message = Message::new(ParsedMessage::new(
&profile,
&event.content,
event.created_at,
));
if !old_messages.iter().any(|old| old == &message) {
Some(message)
} else {
None
}
} else {
None
}
})
.collect();
let total = items.len();
cx.update_entity(&view.messages, |this, cx| {
let messages: Vec<Message> = items
.into_iter()
.filter_map(|new| {
if !this.iter().any(|old| old == &new) {
Some(new)
} else {
None
}
})
.collect();
this.extend(messages);
cx.notify();
});
view.list_state.splice(old_len..old_len, total);
});
self.subscriptions.push(subscription);
}
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else {
return;
};
// Get message
let mut content = self.input.read(cx).text();
// Get all attaches and merge its with message
if let Some(attaches) = self.attaches.read(cx).as_ref() {
let merged = attaches
.iter()
.map(|url| url.to_string())
.collect::<Vec<_>>()
.join("\n");
content = format!("{}\n{}", content, merged).into()
}
if content.is_empty() {
window.push_notification("Cannot send an empty message", cx);
return;
}
// Disable input when sending message
self.input.update(cx, |this, cx| {
this.set_loading(true, window, cx);
this.set_disabled(true, window, cx);
});
let client = get_client();
let window_handle = window.window_handle();
let (tx, rx) = oneshot::channel::<Vec<Error>>();
let room = model.read(cx);
let pubkeys = room.pubkeys();
let async_content = content.clone().to_string();
let tags: Vec<Tag> = room
.pubkeys()
.iter()
.filter_map(|pubkey| {
if pubkey != &room.owner.public_key() {
Some(Tag::public_key(*pubkey))
} else {
None
}
})
.collect();
// Send message to all pubkeys
cx.background_spawn(async move {
let mut errors = Vec::new();
for pubkey in pubkeys.iter() {
if let Err(e) = client
.send_private_msg(*pubkey, &async_content, tags.clone())
.await
{
errors.push(e);
}
}
_ = tx.send(errors);
})
.detach();
cx.spawn(|this, mut cx| async move {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
this.push_message(content.to_string(), window, cx);
});
});
if let Ok(errors) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
for error in errors.into_iter() {
window.push_notification(
Notification::error(error.to_string()).title("Message Failed to Send"),
cx,
);
}
});
}
})
.detach();
}
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let window_handle = window.window_handle();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
// Show loading spinner
self.set_loading(true, cx);
// TODO: support multiple upload
cx.spawn(move |this, mut cx| async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let path = paths.pop().unwrap();
if let Ok(file_data) = fs::read(path).await {
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
let client = get_client();
if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
_ = cx.update_window(window_handle, |_, _, cx| {
_ = this.update(cx, |this, cx| {
// Stop loading spinner
this.set_loading(false, cx);
this.attaches.update(cx, |this, cx| {
if let Some(model) = this.as_mut() {
model.push(url);
} else {
*this = Some(vec![url]);
}
cx.notify();
});
});
});
}
}
}
Ok(None) => {
// Stop loading spinner
if let Some(view) = this.upgrade() {
cx.update_entity(&view, |this, cx| {
this.set_loading(false, cx);
})
.unwrap();
}
}
Err(_) => {}
}
})
.detach();
}
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
self.attaches.update(cx, |model, cx| {
if let Some(urls) = model.as_mut() {
let ix = urls.iter().position(|x| x == url).unwrap();
urls.remove(ix);
cx.notify();
}
});
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
cx.notify();
}
fn render_message(
&self,
ix: usize,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
if let Some(message) = self.messages.read(cx).get(ix) {
div()
.group("")
.relative()
.flex()
.gap_3()
.w_full()
.p_2()
.map(|this| match message {
Message::User(item) => this
.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
.child(
div()
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().transparent)
.group_hover("", |this| {
this.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
}),
)
.child(
img(item.avatar.clone())
.size_8()
.rounded_full()
.flex_shrink_0(),
)
.child(
div()
.flex()
.flex_col()
.flex_initial()
.overflow_hidden()
.child(
div()
.flex()
.items_baseline()
.gap_2()
.text_xs()
.child(
div().font_semibold().child(item.display_name.clone()),
)
.child(div().child(item.created_at.clone()).text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)),
)
.child(div().text_sm().child(item.content.clone())),
),
Message::System(content) => this
.items_center()
.child(
div()
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().transparent)
.group_hover("", |this| this.bg(cx.theme().danger)),
)
.child(
img("brand/avatar.png")
.size_8()
.rounded_full()
.flex_shrink_0(),
)
.text_xs()
.text_color(cx.theme().danger)
.child(content.clone()),
Message::Placeholder => this
.w_full()
.h_32()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_center()
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.line_height(relative(1.2))
.child(
svg()
.path("brand/coop.svg")
.size_8()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(ALERT),
})
} else {
div()
}
}
}
impl Panel for Chat {
fn panel_id(&self) -> SharedString {
self.id.clone()
}
fn title(&self, cx: &App) -> AnyElement {
self.room
.read_with(cx, |this, _cx| {
let name = this.name();
let facepill: Vec<String> =
this.members.iter().map(|member| member.avatar()).collect();
div()
.flex()
.items_center()
.gap_1()
.child(
div()
.flex()
.flex_row_reverse()
.items_center()
.justify_start()
.children(facepill.into_iter().enumerate().rev().map(|(ix, face)| {
div().when(ix > 0, |div| div.ml_neg_1()).child(
img(face)
.size_4()
.rounded_full()
.object_fit(ObjectFit::Cover),
)
})),
)
.child(name)
.into_any()
})
.unwrap_or("Unnamed".into_any())
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Chat {}
impl Focusable for Chat {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Chat {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.child(list(self.list_state.clone()).flex_1())
.child(
div().flex_shrink_0().p_2().child(
div()
.flex()
.flex_col()
.when_some(self.attaches.read(cx).as_ref(), |this, attaches| {
this.gap_1p5().children(attaches.iter().map(|url| {
let url = url.clone();
let path: SharedString = url.to_string().into();
div()
.id(path.clone())
.relative()
.w_16()
.child(
img(format!(
"{}/?url={}&w=128&h=128&fit=cover&n=-1",
IMAGE_SERVICE, path
))
.size_16()
.shadow_lg()
.rounded(px(cx.theme().radius))
.object_fit(ObjectFit::ScaleDown),
)
.child(
div()
.absolute()
.top_neg_2()
.right_neg_2()
.size_4()
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(cx.theme().danger)
.child(
Icon::new(IconName::Close)
.size_2()
.text_color(white()),
),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.remove_media(&url, window, cx);
}))
}))
})
.child(
div()
.w_full()
.h_9()
.flex()
.items_center()
.gap_2()
.child(
Button::new("upload")
.icon(Icon::new(IconName::Upload))
.ghost()
.on_click(cx.listener(move |this, _, window, cx| {
this.upload_media(window, cx);
}))
.loading(self.is_uploading),
)
.child(
div()
.flex_1()
.flex()
.items_center()
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
.rounded(px(cx.theme().radius))
.pl_2()
.pr_1()
.child(self.input.clone())
.child(
Button::new("send")
.ghost()
.xsmall()
.bold()
.rounded(ButtonRounded::Medium)
.label("SEND")
.on_click(cx.listener(|this, _, window, cx| {
this.send_message(window, cx)
})),
),
),
),
),
)
}
}

View File

@@ -1,168 +0,0 @@
use common::profile::NostrProfile;
use gpui::{
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Window,
};
use nostr_sdk::prelude::*;
use state::get_client;
use ui::{
button::Button,
dock_area::panel::{Panel, PanelEvent},
indicator::Indicator,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Sizable,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Contacts> {
Contacts::new(window, cx)
}
pub struct Contacts {
contacts: Entity<Option<Vec<NostrProfile>>>,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Contacts {
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
let contacts = cx.new(|_| None);
let async_contact = contacts.clone();
cx.spawn(|mut cx| async move {
let client = get_client();
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
cx.background_executor()
.spawn(async move {
let signer = client.signer().await.unwrap();
let public_key = signer.get_public_key().await.unwrap();
if let Ok(profiles) = client.database().contacts(public_key).await {
let members: Vec<NostrProfile> = profiles
.into_iter()
.map(|profile| {
NostrProfile::new(profile.public_key(), profile.metadata())
})
.collect();
_ = tx.send(members);
}
})
.detach();
if let Ok(contacts) = rx.await {
_ = cx.update_entity(&async_contact, |this, cx| {
*this = Some(contacts);
cx.notify();
});
}
})
.detach();
cx.new(|cx| Self {
contacts,
name: "Contacts".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
})
}
}
impl Panel for Contacts {
fn panel_id(&self) -> SharedString {
"ContactPanel".into()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Contacts {}
impl Focusable for Contacts {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Contacts {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().pt_2().px_2().map(|this| {
if let Some(contacts) = self.contacts.read(cx).clone() {
this.child(
uniform_list(
cx.entity().clone(),
"contacts",
contacts.len(),
move |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = contacts.get(ix).unwrap().clone();
items.push(
div()
.w_full()
.h_9()
.px_2()
.flex()
.items_center()
.justify_between()
.rounded(px(cx.theme().radius))
.child(
div()
.flex()
.items_center()
.gap_2()
.text_xs()
.child(
div()
.flex_shrink_0()
.child(img(item.avatar()).size_6()),
)
.child(item.name()),
)
.hover(|this| {
this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
}),
);
}
items
},
)
.h_full(),
)
} else {
this.flex()
.items_center()
.justify_center()
.h_16()
.child(Indicator::new().small())
}
})
}
}

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,419 +0,0 @@
use common::{profile::NostrProfile, qr::create_qr, utils::preload};
use gpui::{
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, ClipboardItem, Context, Div,
Entity, IntoElement, ParentElement, Render, Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use state::get_client;
use std::{path::PathBuf, time::Duration};
use ui::{
button::{Button, ButtonCustomVariant, ButtonVariants},
input::{InputEvent, TextInput},
notification::NotificationType,
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Root, Size, StyledExt,
};
use super::app;
const ALPHA_MESSAGE: &str =
"Coop is in the alpha stage of development; It may contain bugs, unfinished features, or unexpected behavior.";
const JOIN_URL: &str = "https://start.njump.me/";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx)
}
pub struct Onboarding {
app_keys: Keys,
connect_uri: NostrConnectURI,
qr_path: Option<PathBuf>,
nsec_input: Entity<TextInput>,
use_connect: bool,
use_privkey: bool,
is_loading: bool,
#[allow(dead_code)]
subscriptions: Vec<Subscription>,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let app_keys = Keys::generate();
let connect_uri = NostrConnectURI::client(
app_keys.public_key(),
vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()],
"Coop",
);
let nsec_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("nsec...")
});
// Save Connect URI as PNG file for display as QR Code
let qr_path = create_qr(connect_uri.to_string().as_str()).ok();
cx.new(|cx| {
// Handle Enter event for nsec input
let subscriptions = vec![cx.subscribe_in(
&nsec_input,
window,
move |this: &mut Self, _, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
this.privkey_login(window, cx);
}
},
)];
Self {
app_keys,
connect_uri,
qr_path,
nsec_input,
use_connect: false,
use_privkey: false,
is_loading: false,
subscriptions,
}
})
}
fn use_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let uri = self.connect_uri.clone();
let app_keys = self.app_keys.clone();
let window_handle = window.window_handle();
self.use_connect = true;
cx.notify();
cx.spawn(|_, mut cx| async move {
let (tx, rx) = oneshot::channel::<NostrProfile>();
cx.background_spawn(async move {
if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None)
{
if let Ok(uri) = signer.bunker_uri().await {
let client = get_client();
if let Some(public_key) = uri.remote_signer_public_key() {
let metadata = client
.fetch_metadata(*public_key, Duration::from_secs(2))
.await
.ok()
.unwrap_or_default();
if tx.send(NostrProfile::new(*public_key, metadata)).is_ok() {
_ = client.set_signer(signer).await;
_ = preload(client, *public_key).await;
}
}
}
}
})
.detach();
if let Ok(profile) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx)
});
})
}
})
.detach();
}
fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.use_privkey = true;
cx.notify();
}
fn reset(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.use_privkey = false;
self.use_connect = false;
cx.notify();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn privkey_login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.nsec_input.read(cx).text().to_string();
let window_handle = window.window_handle();
if !value.starts_with("nsec") || value.is_empty() {
window.push_notification((NotificationType::Warning, "Private Key is required"), cx);
return;
}
let keys = if let Ok(keys) = Keys::parse(&value) {
keys
} else {
window.push_notification((NotificationType::Warning, "Private Key isn't valid"), cx);
return;
};
// Show loading spinner
self.set_loading(true, cx);
cx.spawn(|_, mut cx| async move {
let client = get_client();
let (tx, rx) = oneshot::channel::<NostrProfile>();
cx.background_spawn(async move {
if let Ok(public_key) = keys.get_public_key().await {
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(2))
.await
.ok()
.unwrap_or_default();
if tx.send(NostrProfile::new(public_key, metadata)).is_ok() {
_ = client.set_signer(keys).await;
_ = preload(client, public_key).await;
}
}
})
.detach();
if let Ok(profile) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx)
});
})
}
})
.detach();
}
fn render_selection(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
div()
.w_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.child(
Button::new("login_connect_btn")
.label("Login with Nostr Connect")
.primary()
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.use_connect(window, cx);
})),
)
.child(
Button::new("login_privkey_btn")
.label("Login with Private Key")
.custom(
ButtonCustomVariant::new(window, cx)
.color(cx.theme().base.step(cx, ColorScaleStep::THREE))
.border(cx.theme().base.step(cx, ColorScaleStep::THREE))
.hover(cx.theme().base.step(cx, ColorScaleStep::FOUR))
.active(cx.theme().base.step(cx, ColorScaleStep::FIVE))
.foreground(cx.theme().base.step(cx, ColorScaleStep::TWELVE)),
)
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.use_privkey(window, cx);
})),
)
.child(
div()
.my_2()
.h_px()
.rounded_md()
.w_full()
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
Button::new("join_btn")
.label("Are you new? Join here!")
.ghost()
.w_full()
.on_click(|_, _, cx| {
cx.open_url(JOIN_URL);
}),
)
}
fn render_connect_login(&self, cx: &mut Context<Self>) -> Div {
let connect_string = self.connect_uri.to_string();
div()
.w_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.child(
div()
.flex()
.flex_col()
.text_xs()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.2))
.child("Scan this QR Code in the Nostr Signer app"),
)
.child("Recommend: Amber (Android), nsec.app (web),..."),
)
.when_some(self.qr_path.clone(), |this, path| {
this.child(
div()
.mb_2()
.p_2()
.size_72()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_lg()
.shadow_lg()
.when(cx.theme().appearance.is_dark(), |this| {
this.shadow_none()
.border_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::SIX))
})
.bg(cx.theme().background)
.child(img(path).h_64()),
)
})
.child(
Button::new("copy")
.label("Copy Connection String")
.primary()
.w_full()
.on_click(move |_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(connect_string.clone()))
}),
)
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.reset(window, cx);
})),
)
}
fn render_privkey_login(&self, cx: &mut Context<Self>) -> Div {
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Private Key:")
.child(self.nsec_input.clone()),
)
.child(
Button::new("login")
.label("Login")
.primary()
.w_full()
.loading(self.is_loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.privkey_login(window, cx);
})),
)
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.reset(window, cx);
})),
)
}
}
impl Render for Onboarding {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.flex()
.items_center()
.justify_center()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_8()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_4()
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
div()
.text_center()
.child(
div()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child("Welcome to Coop!"),
)
.child(
div()
.text_sm()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("A Nostr client for secure communication."),
),
),
)
.child(div().w_72().map(|_| {
if self.use_privkey {
self.render_privkey_login(cx)
} else if self.use_connect {
self.render_connect_login(cx)
} else {
self.render_selection(window, cx)
}
})),
)
.child(
div()
.absolute()
.bottom_2()
.w_full()
.flex()
.items_center()
.justify_center()
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.text_align(gpui::TextAlign::Center)
.child(ALPHA_MESSAGE),
)
}
}

View File

@@ -1,350 +0,0 @@
use async_utility::task::spawn;
use common::{constants::IMAGE_SERVICE, profile::NostrProfile, utils::nip96_upload};
use gpui::{
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render,
SharedString, Styled, Window,
};
use nostr_sdk::prelude::*;
use smol::fs;
use state::get_client;
use std::str::FromStr;
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::TextInput,
popup_menu::PopupMenu,
ContextModal, Disableable, Sizable, Size,
};
pub fn init(profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Profile> {
Profile::new(profile, window, cx)
}
pub struct Profile {
profile: NostrProfile,
// Form
name_input: Entity<TextInput>,
avatar_input: Entity<TextInput>,
bio_input: Entity<TextInput>,
website_input: Entity<TextInput>,
is_loading: bool,
is_submitting: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Profile {
pub fn new(mut profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> {
let name_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
if let Some(name) = profile.metadata().display_name.as_ref() {
input.set_text(name, window, cx);
}
input
});
let avatar_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
if let Some(picture) = profile.metadata().picture.as_ref() {
input.set_text(picture, window, cx);
}
input
});
let bio_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx)
.text_size(Size::XSmall)
.multi_line();
if let Some(about) = profile.metadata().about.as_ref() {
input.set_text(about, window, cx);
} else {
input.set_placeholder("A short introduce about you.");
}
input
});
let website_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
if let Some(website) = profile.metadata().website.as_ref() {
input.set_text(website, window, cx);
} else {
input.set_placeholder("https://your-website.com");
}
input
});
cx.new(|cx| Self {
profile,
name_input,
avatar_input,
bio_input,
website_input,
is_loading: false,
is_submitting: false,
name: "Profile".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
})
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let avatar_input = self.avatar_input.downgrade();
let window_handle = window.window_handle();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
// Show loading spinner
self.set_loading(true, cx);
cx.spawn(move |this, mut cx| async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let path = paths.pop().unwrap();
if let Ok(file_data) = fs::read(path).await {
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
let client = get_client();
if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update_window(window_handle, |_, window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_loading(false, cx);
})
.unwrap();
// Set avatar input
avatar_input
.update(cx, |this, cx| {
this.set_text(url.to_string(), window, cx);
})
.unwrap();
})
.unwrap();
}
}
}
Ok(None) => {
// Stop loading spinner
if let Some(view) = this.upgrade() {
cx.update_entity(&view, |this, cx| {
this.set_loading(false, cx);
})
.unwrap();
}
}
Err(_) => {}
}
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Show loading spinner
self.set_submitting(true, cx);
let avatar = self.avatar_input.read(cx).text().to_string();
let name = self.name_input.read(cx).text().to_string();
let bio = self.bio_input.read(cx).text().to_string();
let website = self.website_input.read(cx).text().to_string();
let mut new_metadata = self
.profile
.metadata()
.to_owned()
.display_name(name)
.about(bio);
if let Ok(url) = Url::from_str(&avatar) {
new_metadata = new_metadata.picture(url);
};
if let Ok(url) = Url::from_str(&website) {
new_metadata = new_metadata.website(url);
}
let window_handle = window.window_handle();
cx.spawn(|this, mut cx| async move {
let client = get_client();
let (tx, rx) = oneshot::channel::<EventId>();
cx.background_spawn(async move {
if let Ok(output) = client.set_metadata(&new_metadata).await {
_ = tx.send(output.val);
}
})
.detach();
if rx.await.is_ok() {
cx.update_window(window_handle, |_, window, cx| {
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
window.push_notification("Your profile has been updated successfully", cx);
})
.unwrap()
})
.unwrap();
}
})
.detach();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
}
impl Panel for Profile {
fn panel_id(&self) -> SharedString {
"ProfilePanel".into()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Profile {}
impl Focusable for Profile {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Profile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.px_2()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_end()
.gap_2()
.w_full()
.h_24()
.map(|this| {
let picture = self.avatar_input.read(cx).text();
if picture.is_empty() {
this.child(
img("brand/avatar.png")
.size_10()
.rounded_full()
.flex_shrink_0(),
)
} else {
this.child(
img(format!(
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
IMAGE_SERVICE,
self.avatar_input.read(cx).text()
))
.size_10()
.rounded_full()
.flex_shrink_0(),
)
}
})
.child(
div()
.flex()
.gap_1()
.items_center()
.w_full()
.child(self.avatar_input.clone())
.child(
Button::new("upload")
.label("Upload")
.ghost()
.small()
.disabled(self.is_submitting)
.loading(self.is_loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Name:")
.child(self.name_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Bio:")
.child(self.bio_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Website:")
.child(self.website_input.clone()),
)
.child(
div().flex().items_center().justify_end().child(
Button::new("submit")
.label("Update")
.primary()
.small()
.disabled(self.is_loading)
.loading(self.is_submitting)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
})),
),
)
}
}

View File

@@ -1,265 +0,0 @@
use gpui::{
div, prelude::FluentBuilder, px, uniform_list, AppContext, Context, Entity, FocusHandle,
InteractiveElement, IntoElement, ParentElement, Render, Styled, TextAlign, Window,
};
use nostr_sdk::prelude::*;
use state::get_client;
use ui::{
button::{Button, ButtonVariants},
input::{InputEvent, TextInput},
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, IconName, Sizable,
};
const MESSAGE: &str = "In order to receive messages from others, you need to setup Messaging Relays. You can use the recommend relays or add more.";
pub struct Relays {
relays: Entity<Vec<Url>>,
input: Entity<TextInput>,
focus_handle: FocusHandle,
is_loading: bool,
}
impl Relays {
pub fn new(
relays: Option<Vec<String>>,
window: &mut Window,
cx: &mut Context<'_, Self>,
) -> Self {
let relays = cx.new(|_| {
if let Some(value) = relays {
value.into_iter().map(|v| Url::parse(&v).unwrap()).collect()
} else {
vec![
Url::parse("wss://auth.nostr1.com").unwrap(),
Url::parse("wss://relay.0xchat.com").unwrap(),
]
}
});
let input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(ui::Size::XSmall)
.small()
.placeholder("wss://...")
});
cx.subscribe_in(&input, window, move |this, _, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
this.add(window, cx);
}
})
.detach();
Self {
relays,
input,
is_loading: false,
focus_handle: cx.focus_handle(),
}
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let relays = self.relays.read(cx).clone();
let window_handle = window.window_handle();
self.set_loading(true, cx);
let client = get_client();
let (tx, rx) = oneshot::channel();
cx.background_spawn(async move {
let signer = client.signer().await.expect("Signer is required");
let public_key = signer
.get_public_key()
.await
.expect("Cannot get public key");
// If user didn't have any NIP-65 relays, add default ones
// TODO: Is this really necessary?
if let Ok(relay_list) = client.database().relay_list(public_key).await {
if relay_list.is_empty() {
let builder = EventBuilder::relay_list(vec![
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
(RelayUrl::parse("wss://nos.lol/").unwrap(), None),
]);
if let Err(e) = client.send_event_builder(builder).await {
log::error!("Failed to send relay list event: {}", e)
}
}
}
let tags: Vec<Tag> = relays
.into_iter()
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
.collect();
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
if let Ok(output) = client.send_event_builder(builder).await {
_ = tx.send(output.val);
};
})
.detach();
cx.spawn(|this, mut cx| async move {
if rx.await.is_ok() {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
this.set_loading(false, cx);
});
window.close_modal(cx);
});
}
})
.detach();
}
pub fn loading(&self) -> bool {
self.is_loading
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).text().to_string();
if !value.starts_with("ws") {
return;
}
if let Ok(url) = Url::parse(&value) {
self.relays.update(cx, |this, cx| {
if !this.contains(&url) {
this.push(url);
cx.notify();
}
});
self.input.update(cx, |this, cx| {
this.set_text("", window, cx);
});
}
}
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
self.relays.update(cx, |this, cx| {
this.remove(ix);
cx.notify();
});
}
}
impl Render for Relays {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.track_focus(&self.focus_handle)
.flex()
.flex_col()
.gap_2()
.child(
div()
.px_2()
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(MESSAGE),
)
.child(
div()
.px_2()
.flex()
.flex_col()
.gap_2()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(self.input.clone())
.child(
Button::new("add_relay_btn")
.icon(IconName::Plus)
.small()
.rounded(px(cx.theme().radius))
.on_click(
cx.listener(|this, _, window, cx| this.add(window, cx)),
),
),
)
.map(|this| {
let view = cx.entity();
let relays = self.relays.read(cx).clone();
let total = relays.len();
if !relays.is_empty() {
this.child(
uniform_list(
view,
"relays",
total,
move |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = relays.get(ix).unwrap().clone().to_string();
items.push(
div().group("").w_full().h_9().py_0p5().child(
div()
.px_2()
.h_full()
.w_full()
.flex()
.items_center()
.justify_between()
.rounded(px(cx.theme().radius))
.bg(cx
.theme()
.base
.step(cx, ColorScaleStep::THREE))
.text_xs()
.child(item)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| {
this.visible()
})
.on_click(cx.listener(
move |this, _, window, cx| {
this.remove(ix, window, cx)
},
)),
),
),
)
}
items
},
)
.min_h(px(120.)),
)
} else {
this.h_20()
.mb_2()
.flex()
.items_center()
.justify_center()
.text_xs()
.text_align(TextAlign::Center)
.child("Please add some relays.")
}
}),
)
}
}

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,556 +0,0 @@
use async_utility::task::spawn;
use chats::{registry::ChatRegistry, room::Room};
use common::{
profile::NostrProfile,
utils::{random_name, signer_public_key},
};
use gpui::{
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, TextAlign, Window,
};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use smol::Timer;
use state::get_client;
use std::{collections::HashSet, time::Duration};
use ui::{
button::{Button, ButtonRounded},
input::{InputEvent, TextInput},
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Icon, IconName, Sizable, Size, StyledExt,
};
const ALERT: &str =
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
#[derive(Clone, PartialEq, Eq, Deserialize)]
struct SelectContact(PublicKey);
impl_internal_actions!(contacts, [SelectContact]);
pub struct Compose {
title_input: Entity<TextInput>,
user_input: Entity<TextInput>,
contacts: Entity<Vec<NostrProfile>>,
selected: Entity<HashSet<PublicKey>>,
focus_handle: FocusHandle,
is_loading: bool,
is_submitting: bool,
error_message: Entity<Option<SharedString>>,
#[allow(dead_code)]
subscriptions: Vec<Subscription>,
}
impl Compose {
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
let contacts = cx.new(|_| Vec::new());
let selected = cx.new(|_| HashSet::new());
let error_message = cx.new(|_| None);
let mut subscriptions = Vec::new();
let title_input = cx.new(|cx| {
let name = random_name(2);
let mut input = TextInput::new(window, cx)
.appearance(false)
.text_size(Size::XSmall);
input.set_placeholder("Family... . (Optional)");
input.set_text(name, window, cx);
input
});
let user_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(ui::Size::Small)
.small()
.placeholder("npub1...")
});
// Handle Enter event for user input
subscriptions.push(cx.subscribe_in(
&user_input,
window,
move |this, input, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
if input.read(cx).text().contains("@") {
this.add_nip05(window, cx);
} else {
this.add_npub(window, cx)
}
}
},
));
let client = get_client();
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
cx.background_spawn(async move {
if let Ok(public_key) = signer_public_key(client).await {
if let Ok(profiles) = client.database().contacts(public_key).await {
let members: Vec<NostrProfile> = profiles
.into_iter()
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
.collect();
_ = tx.send(members);
}
}
})
.detach();
cx.spawn(|this, cx| async move {
if let Ok(contacts) = rx.await {
_ = cx.update(|cx| {
this.update(cx, |this, cx| {
this.contacts.update(cx, |this, cx| {
this.extend(contacts);
cx.notify();
});
})
});
}
})
.detach();
Self {
title_input,
user_input,
contacts,
selected,
error_message,
is_loading: false,
is_submitting: false,
focus_handle: cx.focus_handle(),
subscriptions,
}
}
pub fn compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.selected.read(cx).is_empty() {
self.set_error(Some("You need to add at least 1 receiver".into()), cx);
return;
}
// Show loading spinner
self.set_submitting(true, cx);
// Get all pubkeys
let pubkeys: Vec<PublicKey> = self.selected.read(cx).iter().copied().collect();
// Convert selected pubkeys into Nostr tags
let mut tag_list: Vec<Tag> = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect();
// Add subject if it is present
if !self.title_input.read(cx).text().is_empty() {
tag_list.push(Tag::custom(
TagKind::Subject,
vec![self.title_input.read(cx).text().to_string()],
));
}
let tags = Tags::from_list(tag_list);
let client = get_client();
let window_handle = window.window_handle();
let (tx, rx) = oneshot::channel::<Event>();
cx.background_spawn(async move {
let signer = client.signer().await.expect("Signer is required");
// [IMPORTANT]
// Make sure this event is never send,
// this event existed just use for convert to Coop's Chat Room later.
if let Ok(event) = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "")
.tags(tags)
.sign(&signer)
.await
{
_ = tx.send(event)
};
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(event) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
// Stop loading spinner
_ = this.update(cx, |this, cx| {
this.set_submitting(false, cx);
});
if let Some(chats) = ChatRegistry::global(cx) {
let room = Room::parse(&event, cx);
chats.update(cx, |state, cx| match state.new_room(room, cx) {
Ok(_) => {
// TODO: open chat panel
window.close_modal(cx);
}
Err(e) => {
_ = this.update(cx, |this, cx| {
this.set_error(Some(e.to_string().into()), cx);
});
}
});
}
});
}
})
.detach();
}
pub fn label(&self, _window: &Window, cx: &App) -> SharedString {
if self.selected.read(cx).len() > 1 {
"Create Group DM".into()
} else {
"Create DM".into()
}
}
pub fn is_submitting(&self) -> bool {
self.is_submitting
}
fn add_npub(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let window_handle = window.window_handle();
let content = self.user_input.read(cx).text().to_string();
// Show loading spinner
self.set_loading(true, cx);
let Ok(public_key) = PublicKey::parse(&content) else {
self.set_loading(false, cx);
self.set_error(Some("Public Key is not valid".into()), cx);
return;
};
if self
.contacts
.read(cx)
.iter()
.any(|c| c.public_key() == public_key)
{
self.set_loading(false, cx);
return;
};
let client = get_client();
let (tx, rx) = oneshot::channel::<Metadata>();
cx.background_spawn(async move {
let metadata = (client
.fetch_metadata(public_key, Duration::from_secs(2))
.await)
.unwrap_or_default();
_ = tx.send(metadata);
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(metadata) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
this.contacts.update(cx, |this, cx| {
this.insert(0, NostrProfile::new(public_key, metadata));
cx.notify();
});
this.selected.update(cx, |this, cx| {
this.insert(public_key);
cx.notify();
});
// Stop loading indicator
this.set_loading(false, cx);
// Clear input
this.user_input.update(cx, |this, cx| {
this.set_text("", window, cx);
cx.notify();
});
});
});
}
})
.detach();
}
fn add_nip05(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let window_handle = window.window_handle();
let content = self.user_input.read(cx).text().to_string();
// Show loading spinner
self.set_loading(true, cx);
let client = get_client();
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
cx.background_spawn(async move {
spawn(async move {
if let Ok(profile) = nip05::profile(&content, None).await {
let metadata = (client
.fetch_metadata(profile.public_key, Duration::from_secs(2))
.await)
.unwrap_or_default();
_ = tx.send(Some(NostrProfile::new(profile.public_key, metadata)));
} else {
_ = tx.send(None);
}
});
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(Some(profile)) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
let public_key = profile.public_key();
this.contacts.update(cx, |this, cx| {
this.insert(0, profile);
cx.notify();
});
this.selected.update(cx, |this, cx| {
this.insert(public_key);
cx.notify();
});
// Stop loading indicator
this.set_loading(false, cx);
// Clear input
this.user_input.update(cx, |this, cx| {
this.set_text("", window, cx);
cx.notify();
});
});
});
} else {
_ = cx.update_window(window_handle, |_, _, cx| {
_ = this.update(cx, |this, cx| {
this.set_loading(false, cx);
this.set_error(Some("NIP-05 Address is not valid".into()), cx);
});
});
}
})
.detach();
}
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
self.error_message.update(cx, |this, cx| {
*this = error;
cx.notify();
});
// Dismiss error after 2 seconds
cx.spawn(|this, cx| async move {
Timer::after(Duration::from_secs(2)).await;
_ = cx.update(|cx| {
this.update(cx, |this, cx| {
this.set_error(None, cx);
})
});
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
fn on_action_select(
&mut self,
action: &SelectContact,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.selected.update(cx, |this, cx| {
if this.contains(&action.0) {
this.remove(&action.0);
} else {
this.insert(action.0);
};
cx.notify();
});
}
}
impl Render for Compose {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_action_select))
.flex()
.flex_col()
.gap_1()
.child(
div()
.px_2()
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(ALERT),
)
.when_some(self.error_message.read(cx).as_ref(), |this, msg| {
this.child(
div()
.px_2()
.text_xs()
.text_color(cx.theme().danger)
.child(msg.clone()),
)
})
.child(
div().flex().flex_col().child(
div()
.h_10()
.px_2()
.border_b_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
.flex()
.items_center()
.gap_1()
.child(div().text_xs().font_semibold().child("Title:"))
.child(self.title_input.clone()),
),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(div().px_2().text_xs().font_semibold().child("To:"))
.child(
div()
.flex()
.items_center()
.gap_2()
.px_2()
.child(
Button::new("add_user_to_compose_btn")
.icon(IconName::Plus)
.small()
.rounded(ButtonRounded::Size(px(9999.)))
.loading(self.is_loading)
.on_click(cx.listener(|this, _, window, cx| {
if this.user_input.read(cx).text().contains("@") {
this.add_nip05(window, cx);
} else {
this.add_npub(window, cx);
}
})),
)
.child(self.user_input.clone()),
)
.map(|this| {
let contacts = self.contacts.read(cx).clone();
let view = cx.entity();
if contacts.is_empty() {
this.child(
div()
.w_full()
.h_24()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_align(TextAlign::Center)
.child(
div()
.text_xs()
.font_semibold()
.line_height(relative(1.2))
.child("No contacts"),
)
.child(
div()
.text_xs()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Your recently contacts will appear here."),
),
)
} else {
this.child(
uniform_list(
view,
"contacts",
contacts.len(),
move |this, range, _window, cx| {
let selected = this.selected.read(cx);
let mut items = Vec::new();
for ix in range {
let item = contacts.get(ix).unwrap().clone();
let is_select = selected.contains(&item.public_key());
items.push(
div()
.id(ix)
.w_full()
.h_9()
.px_2()
.flex()
.items_center()
.justify_between()
.child(
div()
.flex()
.items_center()
.gap_2()
.text_xs()
.child(
div().flex_shrink_0().child(
img(item.avatar()).size_6(),
),
)
.child(item.name()),
)
.when(is_select, |this| {
this.child(
Icon::new(IconName::CircleCheck)
.size_3()
.text_color(cx.theme().base.step(
cx,
ColorScaleStep::TWELVE,
)),
)
})
.hover(|this| {
this.bg(cx
.theme()
.base
.step(cx, ColorScaleStep::THREE))
})
.on_click(move |_, window, cx| {
window.dispatch_action(
Box::new(SelectContact(
item.public_key(),
)),
cx,
);
}),
);
}
items
},
)
.min_h(px(250.)),
)
}
}),
)
}
}

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

View File

@@ -1,101 +0,0 @@
use gpui::{
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use ui::{
button::Button,
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
StyledExt,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
Welcome::new(window, cx)
}
pub struct Welcome {
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Welcome {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
name: "Welcome".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for Welcome {
fn panel_id(&self) -> SharedString {
"WelcomePanel".into()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Welcome {}
impl Focusable for Welcome {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Welcome {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_1()
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
div()
.child("coop on nostr.")
.text_color(cx.theme().base.step(cx, ColorScaleStep::FOUR))
.font_black()
.text_sm(),
),
)
}
}

11
crates/assets/Cargo.toml Normal file
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

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

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

View File

@@ -0,0 +1,21 @@
[package]
name = "auto_update"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
gpui.workspace = true
gpui_tokio.workspace = true
reqwest.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smol.workspace = true
log.workspace = true
smallvec.workspace = true
semver = "1.0.27"
tempfile = "3.23.0"

View File

@@ -0,0 +1,494 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::BOOTSTRAP_RELAYS;
use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
};
use nostr_sdk::prelude::*;
use semver::Version;
use smallvec::{smallvec, SmallVec};
use smol::fs::File;
use smol::process::Command;
use state::NostrRegistry;
const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q";
pub fn init(cx: &mut App) {
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
}
struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
#[cfg(not(target_os = "windows"))]
struct InstallerDir(tempfile::TempDir);
#[cfg(not(target_os = "windows"))]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
Ok(Self(
tempfile::Builder::new()
.prefix("coop-auto-update")
.tempdir()?,
))
}
fn path(&self) -> &Path {
self.0.path()
}
}
#[cfg(target_os = "windows")]
struct InstallerDir(PathBuf);
#[cfg(target_os = "windows")]
impl InstallerDir {
async fn new() -> Result<Self, Error> {
let installer_dir = std::env::current_exe()?
.parent()
.context("No parent dir for Coop.exe")?
.join("updates");
if smol::fs::metadata(&installer_dir).await.is_ok() {
smol::fs::remove_dir_all(&installer_dir).await?;
}
smol::fs::create_dir(&installer_dir).await?;
Ok(Self(installer_dir))
}
fn path(&self) -> &Path {
self.0.as_path()
}
}
struct MacOsUnmounter<'a> {
mount_path: PathBuf,
background_executor: &'a BackgroundExecutor,
}
impl Drop for MacOsUnmounter<'_> {
fn drop(&mut self) {
let mount_path = std::mem::take(&mut self.mount_path);
self.background_executor
.spawn(async move {
let unmount_output = Command::new("hdiutil")
.args(["detach", "-force"])
.arg(&mount_path)
.output()
.await;
match unmount_output {
Ok(output) if output.status.success() => {
log::info!("Successfully unmounted the disk image");
}
Ok(output) => {
log::error!(
"Failed to unmount disk image: {:?}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(error) => {
log::error!("Error while trying to unmount disk image: {:?}", error);
}
}
})
.detach();
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum AutoUpdateStatus {
Idle,
Checking,
Checked { files: Vec<EventId> },
Installing,
Updated,
Errored { msg: Box<String> },
}
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
fn as_ref(&self) -> &AutoUpdateStatus {
self
}
}
impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing)
}
pub fn is_updated(&self) -> bool {
matches!(self, Self::Updated)
}
pub fn checked(files: Vec<EventId>) -> Self {
Self::Checked { files }
}
pub fn error(e: String) -> Self {
Self::Errored { msg: Box::new(e) }
}
}
#[derive(Debug)]
pub struct AutoUpdater {
/// Current status of the auto updater
pub status: AutoUpdateStatus,
/// Current version of the application
pub version: Version,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 2]>,
}
impl AutoUpdater {
/// Retrieve the global auto updater instance
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone()
}
/// Set the global auto updater instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state));
}
fn new(cx: &mut Context<Self>) -> Self {
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
let async_version = version.clone();
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
tasks.push(
// Subscribe to get the new update event in the bootstrap relays
Self::subscribe_to_updates(cx),
);
tasks.push(
// Subscribe to get the new update event in the bootstrap relays
cx.spawn(async move |this, cx| {
// Check for updates after 2 minutes
cx.background_executor()
.timer(Duration::from_secs(120))
.await;
// Update the status to checking
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Checking, cx);
});
match Self::check_for_updates(async_version, cx).await {
Ok(ids) => {
// Update the status to downloading
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(ids), cx);
});
}
Err(e) => {
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
});
log::warn!("{e}");
}
}
}),
);
subscriptions.push(
// Observe the status
cx.observe_self(|this, cx| {
if let AutoUpdateStatus::Checked { files } = this.status.clone() {
this.get_latest_release(&files, cx);
}
}),
);
Self {
status: AutoUpdateStatus::Idle,
version,
_subscriptions: subscriptions,
_tasks: tasks,
}
}
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
}
fn subscribe_to_updates(cx: &App) -> Task<()> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
let filter = Filter::new()
.kind(Kind::ReleaseArtifactSet)
.author(app_pubkey)
.limit(1);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to subscribe to updates: {e}");
};
})
}
fn check_for_updates(version: Version, cx: &AsyncApp) -> Task<Result<Vec<EventId>, Error>> {
let client = cx.update(|cx| {
let nostr = NostrRegistry::global(cx);
nostr.read(cx).client()
});
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
let filter = Filter::new()
.kind(Kind::ReleaseArtifactSet)
.author(app_pubkey)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let new_version: Version = event
.tags
.find(TagKind::d())
.and_then(|tag| tag.content())
.and_then(|content| content.split("@").last())
.and_then(|content| Version::parse(content).ok())
.context("Failed to parse version")?;
if new_version > version {
// Get all file metadata event ids
let ids: Vec<EventId> = event.tags.event_ids().copied().collect();
let filter = Filter::new()
.kind(Kind::FileMetadata)
.author(app_pubkey)
.ids(ids.clone());
// Get all files for this release
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
Ok(ids)
} else {
Err(anyhow!("No update available"))
}
} else {
Err(anyhow!("No update available"))
}
})
}
fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let http_client = cx.http_client();
let ids = ids.to_vec();
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
let os = std::env::consts::OS;
let filter = Filter::new()
.kind(Kind::FileMetadata)
.author(app_pubkey)
.ids(ids);
// Get all urls for this release
let events = client.database().query(filter).await?;
for event in events.into_iter() {
// Only process events that match current platform
if event.content != os {
continue;
}
// Parse the url
let url = event
.tags
.find(TagKind::Url)
.and_then(|tag| tag.content())
.and_then(|content| Url::parse(content).ok())
.context("Failed to parse url")?;
let installer_dir = InstallerDir::new().await?;
let target_path = Self::target_path(&installer_dir).await?;
// Download the release
download(url.as_str(), &target_path, http_client).await?;
return Ok((installer_dir, target_path));
}
Err(anyhow!("Failed to get latest release"))
});
self._tasks.push(
// Install the new release
cx.spawn(async move |this, cx| {
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Installing, cx);
});
match task.await {
Ok((installer_dir, target_path)) => {
if Self::install(installer_dir, target_path, cx).await.is_ok() {
// Update the status to updated
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
});
}
}
Err(e) => {
// Update the status to error including the error message
_ = this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
});
}
}
}),
);
}
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
let filename = match std::env::consts::OS {
"macos" => anyhow::Ok("Coop.dmg"),
"windows" => Ok("Coop.exe"),
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
}?;
Ok(installer_dir.path().join(filename))
}
async fn install(
installer_dir: InstallerDir,
target_path: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
match std::env::consts::OS {
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
"windows" => install_release_windows(target_path).await,
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
}
}
}
async fn download(
url: &str,
target_path: &std::path::Path,
client: Arc<dyn HttpClient>,
) -> Result<(), Error> {
let body = AsyncBody::default();
let mut target_file = File::create(&target_path).await?;
let mut response = client.get(url, body, true).await?;
// Copy the response body to the target file
smol::io::copy(response.body_mut(), &mut target_file).await?;
Ok(())
}
async fn install_release_macos(
temp_dir: &InstallerDir,
downloaded_dmg: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
let running_app_filename = running_app_path
.file_name()
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
let mount_path = temp_dir.path().join("Coop");
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
mounted_app_path.push("/");
let output = Command::new("hdiutil")
.args(["attach", "-nobrowse"])
.arg(&downloaded_dmg)
.arg("-mountroot")
.arg(temp_dir.path())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to mount: {:?}",
String::from_utf8_lossy(&output.stderr)
);
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
let _unmounter = MacOsUnmounter {
mount_path: mount_path.clone(),
background_executor: cx.background_executor(),
};
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&mounted_app_path)
.arg(&running_app_path)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
//const CREATE_NO_WINDOW: u32 = 0x08000000;
let system_root = std::env::var("SYSTEMROOT");
let powershell_path = system_root.as_ref().map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
let mut installer_path = std::ffi::OsString::new();
installer_path.push("\"");
installer_path.push(&downloaded_installer);
installer_path.push("\"");
let output = Command::new(powershell_path)
//.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-WindowStyle", "Hidden"])
.args(["Start-Process"])
.arg(installer_path)
.arg("-ArgumentList")
.args(["/P", "/R"])
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to start installer: {:?}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}

27
crates/chat/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "chat"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
device = { path = "../device" }
person = { path = "../person" }
settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
futures.workspace = true
flume.workspace = true
serde.workspace = true
serde_json.workspace = true
fuzzy-matcher = "0.3.7"

718
crates/chat/src/lib.rs Normal file
View File

@@ -0,0 +1,718 @@
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::EventUtils;
use device::DeviceRegistry;
use flume::Sender;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity,
};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use state::{tracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION};
mod message;
mod room;
pub use message::*;
pub use room::*;
pub fn init(cx: &mut App) {
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
}
struct GlobalChatRegistry(Entity<ChatRegistry>);
impl Global for GlobalChatRegistry {}
/// Chat event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChatEvent {
/// An event to open a room by its ID
OpenRoom(u64),
/// An event to close a room by its ID
CloseRoom(u64),
/// An event to notify UI about a new chat request
Ping,
}
/// Channel signal.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum NostrEvent {
/// Message received from relay pool
Message(NewMessage),
/// Unwrapping status
Unwrapping(bool),
/// Eose received from relay pool
Eose,
}
/// Chat Registry
#[derive(Debug)]
pub struct ChatRegistry {
/// Collection of all chat rooms
rooms: Vec<Entity<Room>>,
/// Loading status of the registry
loading: bool,
/// Tracking the status of unwrapping gift wrap events.
tracking_flag: Arc<AtomicBool>,
/// Channel's sender for communication between nostr and gpui
sender: Sender<NostrEvent>,
/// Handle notifications asynchronous task
notifications: Option<Task<Result<(), Error>>>,
/// Tasks for asynchronous operations
tasks: Vec<Task<()>>,
/// Subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl EventEmitter<ChatEvent> for ChatRegistry {}
impl ChatRegistry {
/// Retrieve the global chat registry state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalChatRegistry>().0.clone()
}
/// Set the global chat registry instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalChatRegistry(state));
}
/// Create a new chat registry instance
fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity();
let device = DeviceRegistry::global(cx);
let device_signer = device.read(cx).device_signer.clone();
// A flag to indicate if the registry is loading
let tracking_flag = Arc::new(AtomicBool::new(true));
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<NostrEvent>(2048);
let mut tasks = vec![];
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe the identity
cx.observe(&identity, |this, state, cx| {
if state.read(cx).has_public_key() {
// Handle nostr notifications
this.handle_notifications(cx);
// Track unwrapping progress
this.tracking(cx);
}
}),
);
subscriptions.push(
// Observe the device signer state
cx.observe(&device_signer, |this, state, cx| {
if state.read(cx).is_some() {
this.handle_notifications(cx);
}
}),
);
tasks.push(
// Update GPUI states
cx.spawn(async move |this, cx| {
while let Ok(message) = rx.recv_async().await {
match message {
NostrEvent::Message(message) => {
this.update(cx, |this, cx| {
this.new_message(message, cx);
})
.ok();
}
NostrEvent::Eose => {
this.update(cx, |this, cx| {
this.get_rooms(cx);
})
.ok();
}
NostrEvent::Unwrapping(status) => {
this.update(cx, |this, cx| {
this.set_loading(status, cx);
this.get_rooms(cx);
})
.ok();
}
};
}
}),
);
Self {
rooms: vec![],
loading: true,
tracking_flag,
sender: tx.clone(),
notifications: None,
tasks,
_subscriptions: subscriptions,
}
}
/// Handle nostr notifications
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let device = DeviceRegistry::global(cx);
let device_signer = device.read(cx).signer(cx);
let status = self.tracking_flag.clone();
let tx = self.sender.clone();
self.tasks.push(cx.background_spawn(async move {
let initialized_at = Timestamp::now();
let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
// Skip non-message notifications
continue;
};
match message {
RelayMessage::Event { event, .. } => {
if !processed_events.insert(event.id) {
// Skip if the event has already been processed
continue;
}
if event.kind != Kind::GiftWrap {
// Skip non-gift wrap events
continue;
}
// Extract the rumor from the gift wrap event
match Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
Ok(rumor) => match rumor.created_at >= initialized_at {
true => {
// Check if the event is sent by coop
let sent_by_coop = {
let tracker = tracker().read().await;
tracker.is_sent_by_coop(&event.id)
};
// No need to emit if sent by coop
// the event is already emitted
if !sent_by_coop {
let new_message = NewMessage::new(event.id, rumor);
let signal = NostrEvent::Message(new_message);
tx.send_async(signal).await.ok();
}
}
false => {
status.store(true, Ordering::Release);
}
},
Err(e) => {
log::warn!("Failed to unwrap: {e}");
}
}
}
RelayMessage::EndOfStoredEvents(id) => {
if id.as_ref() == &subscription_id {
tx.send_async(NostrEvent::Eose).await.ok();
}
}
_ => {}
}
}
}));
}
/// Tracking the status of unwrapping gift wrap events.
fn tracking(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let status = self.tracking_flag.clone();
let tx = self.sender.clone();
self.notifications = Some(cx.background_spawn(async move {
let loop_duration = Duration::from_secs(12);
let mut is_start_processing = false;
let mut total_loops = 0;
loop {
if client.has_signer().await {
total_loops += 1;
if status.load(Ordering::Acquire) {
is_start_processing = true;
// Reset gift wrap processing flag
_ = status.compare_exchange(
true,
false,
Ordering::Release,
Ordering::Relaxed,
);
tx.send_async(NostrEvent::Unwrapping(true)).await.ok();
} else {
// Only run further if we are already processing
// Wait until after 2 loops to prevent exiting early while events are still being processed
if is_start_processing && total_loops >= 2 {
tx.send_async(NostrEvent::Unwrapping(false)).await.ok();
// Reset the counter
is_start_processing = false;
total_loops = 0;
}
}
}
smol::Timer::after(loop_duration).await;
}
}));
}
/// Get the loading status of the chat registry
pub fn loading(&self) -> bool {
self.loading
}
/// Set the loading status of the chat registry
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
self.loading = loading;
cx.notify();
}
/// Get a weak reference to a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|this| &this.read(cx).id == id)
.map(|this| this.downgrade())
}
/// Get all ongoing rooms.
pub fn ongoing_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).kind == RoomKind::Ongoing)
.cloned()
.collect()
}
/// Get all request rooms.
pub fn request_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).kind != RoomKind::Ongoing)
.cloned()
.collect()
}
/// Add a new room to the start of list.
pub fn add_room<I>(&mut self, room: I, cx: &mut Context<Self>)
where
I: Into<Room>,
{
self.rooms.insert(0, cx.new(|_| room.into()));
cx.notify();
}
/// Emit an open room event.
/// If the room is new, add it to the registry.
pub fn emit_room(&mut self, room: WeakEntity<Room>, cx: &mut Context<Self>) {
if let Some(room) = room.upgrade() {
let id = room.read(cx).id;
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room);
}
// Emit the open room event.
cx.emit(ChatEvent::OpenRoom(id));
}
}
/// Close a room.
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
if self.rooms.iter().any(|r| r.read(cx).id == id) {
cx.emit(ChatEvent::CloseRoom(id));
}
}
/// Sort rooms by their created at.
pub fn sort(&mut self, cx: &mut Context<Self>) {
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
cx.notify();
}
/// Search rooms by their name.
pub fn search(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
let matcher = SkimMatcherV2::default();
self.rooms
.iter()
.filter(|room| {
matcher
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
.is_some()
})
.cloned()
.collect()
}
/// Search rooms by public keys.
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).members.contains(&public_key))
.cloned()
.collect()
}
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
cx.notify();
}
/// Extend the registry with new rooms.
fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
let mut room_map: HashMap<u64, usize> = self
.rooms
.iter()
.enumerate()
.map(|(idx, room)| (room.read(cx).id, idx))
.collect();
for new_room in rooms.into_iter() {
// Check if we already have a room with this ID
if let Some(&index) = room_map.get(&new_room.id) {
self.rooms[index].update(cx, |this, cx| {
if new_room.created_at > this.created_at {
*this = new_room;
cx.notify();
}
});
} else {
let new_room_id = new_room.id;
self.rooms.push(cx.new(|_| new_room));
let new_index = self.rooms.len();
room_map.insert(new_room_id, new_index);
}
}
}
/// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let task = self.create_get_rooms_task(cx);
self.tasks.push(
// Run and finished in the background
cx.spawn(async move |this, cx| {
match task.await {
Ok(rooms) => {
this.update(cx, move |this, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
})
.expect("Entity has been released");
}
Err(e) => {
log::error!("Failed to load rooms: {e}")
}
};
}),
);
}
/// Create a task to load rooms from the database
fn create_get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get the contact bypass setting
let bypass_setting = AppSettings::get_contact_bypass(cx);
cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let contacts = client.database().contacts_public_keys(public_key).await?;
let authored_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
// Get all authored events
let authored = client.database().query(authored_filter).await?;
let addressed_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
// Get all addressed events
let addressed = client.database().query(addressed_filter).await?;
// Merge authored and addressed events
let events = authored.merge(addressed);
let mut rooms: HashSet<Room> = HashSet::new();
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
// Process each event and group by room hash
for raw in events.into_iter() {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) {
if rumor.tags.public_keys().peekable().peek().is_some() {
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
}
}
}
for (_id, mut messages) in grouped.into_iter() {
messages.sort_by_key(|m| Reverse(m.created_at));
let Some(latest) = messages.first() else {
continue;
};
let mut room = Room::from(latest);
if rooms.iter().any(|r| r.id == room.id) {
continue;
}
let mut public_keys = room.members();
public_keys.retain(|pk| pk != &public_key);
// Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// Determine if the room is ongoing or not
let mut bypassed = false;
// Check if public keys are from the user's contacts
if bypass_setting {
bypassed = public_keys.iter().any(|k| contacts.contains(k));
}
// Set the room's kind based on status
if user_sent || bypassed {
room = room.kind(RoomKind::Ongoing);
}
rooms.insert(room);
}
Ok(rooms)
})
}
/// Trigger a refresh of the opened chat rooms by their IDs
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
if let Some(ids) = ids {
for room in self.rooms.iter() {
if ids.contains(&room.read(cx).id) {
room.update(cx, |this, cx| {
this.emit_refresh(cx);
});
}
}
}
}
/// Parse a Nostr event into a Coop Message and push it to the belonging room
///
/// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
// Get the unique id
let id = message.rumor.uniq_id();
// Get the author
let author = message.rumor.pubkey;
match self.rooms.iter().find(|room| room.read(cx).id == id) {
Some(room) => {
let new_message = message.rumor.created_at > room.read(cx).created_at;
let created_at = message.rumor.created_at;
// Update room
room.update(cx, |this, cx| {
// Update the last timestamp if the new message is newer
if new_message {
this.set_created_at(created_at, cx);
}
// Set this room is ongoing if the new message is from current user
if author == nostr.read(cx).identity().read(cx).public_key() {
this.set_ongoing(cx);
}
// Emit the new message to the room
this.emit_message(message, cx);
});
// Resort all rooms in the registry by their created at (after updated)
if new_message {
self.sort(cx);
}
}
None => {
// Push the new room to the front of the list
self.add_room(&message.rumor, cx);
// Notify the UI about the new room
cx.emit(ChatEvent::Ping);
}
}
}
// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor(
client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>,
gift_wrap: &Event,
) -> Result<UnsignedEvent, Error> {
// Try to get cached rumor first
if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await {
return Ok(event);
}
// Try to unwrap with the available signer
let unwrapped = Self::try_unwrap(client, device_signer, gift_wrap).await?;
let mut rumor_unsigned = unwrapped.rumor;
// Generate event id for the rumor if it doesn't have one
rumor_unsigned.ensure_id();
// Cache the rumor
Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?;
Ok(rumor_unsigned)
}
// Helper method to try unwrapping with different signers
async fn try_unwrap(
client: &Client,
device_signer: &Option<Arc<dyn NostrSigner>>,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
if let Some(signer) = device_signer.as_ref() {
let seal = signer
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
.await?;
let seal: Event = Event::from_json(seal)?;
seal.verify_with_ctx(&SECP256K1)?;
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
let rumor = UnsignedEvent::from_json(rumor)?;
return Ok(UnwrappedGift {
sender: seal.pubkey,
rumor,
});
}
let signer = client.signer().await?;
let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
Ok(unwrapped)
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
let author = rumor.pubkey;
let conversation = Self::conversation_id(rumor);
let mut tags = rumor.tags.clone().to_vec();
// Add a unique identifier
tags.push(Tag::identifier(id));
// Add a reference to the rumor's author
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
[author],
));
// Add a conversation id
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
[conversation.to_string()],
));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys().copied() {
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
[receiver],
));
}
// Convert rumor to json
let content = rumor.as_json();
// Construct the event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(tags)
.sign(&Keys::generate())
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Retrieves a previously unwrapped event from local database
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(gift_wrap)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
} else {
Err(anyhow!("Event is not cached yet."))
}
}
/// Get the conversation ID for a given rumor (message).
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
hasher.finish()
}
}

212
crates/chat/src/message.rs Normal file
View File

@@ -0,0 +1,212 @@
use std::hash::Hash;
use nostr_sdk::prelude::*;
/// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage {
pub gift_wrap: EventId,
pub rumor: UnsignedEvent,
}
impl NewMessage {
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
Self { gift_wrap, rumor }
}
}
/// Message.
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum Message {
User(RenderedMessage),
Warning(String, Timestamp),
System(Timestamp),
}
impl Message {
pub fn user<I>(user: I) -> Self
where
I: Into<RenderedMessage>,
{
Self::User(user.into())
}
pub fn warning<I>(content: I) -> Self
where
I: Into<String>,
{
Self::Warning(content.into(), Timestamp::now())
}
pub fn system() -> Self {
Self::System(Timestamp::default())
}
fn timestamp(&self) -> &Timestamp {
match self {
Message::User(msg) => &msg.created_at,
Message::Warning(_, ts) => ts,
Message::System(ts) => ts,
}
}
}
impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self {
Self::User(val.into())
}
}
impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self {
Self::User(val.into())
}
}
impl Ord for Message {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match (self, other) {
// System always comes first
(Message::System(_), Message::System(_)) => self.timestamp().cmp(other.timestamp()),
(Message::System(_), _) => std::cmp::Ordering::Less,
(_, Message::System(_)) => std::cmp::Ordering::Greater,
// For non-system messages, compare by timestamp
_ => self.timestamp().cmp(other.timestamp()),
}
}
}
impl PartialOrd for Message {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
/// Rendered message.
#[derive(Debug, Clone)]
pub struct RenderedMessage {
pub id: EventId,
/// Author's public key
pub author: PublicKey,
/// The content/text of the message
pub content: String,
/// Message created time as unix timestamp
pub created_at: Timestamp,
/// List of mentioned public keys in the message
pub mentions: Vec<PublicKey>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
}
impl From<&Event> for RenderedMessage {
fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
Self {
id: val.id,
author: val.pubkey,
content: val.content.clone(),
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&UnsignedEvent> for RenderedMessage {
fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content);
let replies_to = extract_reply_ids(&val.tags);
Self {
// Event ID must be known
id: val.id.unwrap(),
author: val.pubkey,
content: val.content.clone(),
created_at: val.created_at,
mentions,
replies_to,
}
}
}
impl From<&NewMessage> for RenderedMessage {
fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content);
let replies_to = extract_reply_ids(&val.rumor.tags);
Self {
// Event ID must be known
id: val.rumor.id.unwrap(),
author: val.rumor.pubkey,
content: val.rumor.content.clone(),
created_at: val.rumor.created_at,
mentions,
replies_to,
}
}
}
impl Eq for RenderedMessage {}
impl PartialEq for RenderedMessage {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Ord for RenderedMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for RenderedMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Hash for RenderedMessage {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
/// Extracts all mentions (public keys) from a content string.
fn extract_mentions(content: &str) -> Vec<PublicKey> {
let parser = NostrParser::new();
let tokens = parser.parse(content);
tokens
.filter_map(|token| match token {
Token::Nostr(nip21) => match nip21 {
Nip21::Pubkey(pubkey) => Some(pubkey),
Nip21::Profile(profile) => Some(profile.public_key),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>()
}
/// Extracts all reply (ids) from the event tags.
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![];
for tag in inner.filter(TagKind::e()) {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}
}
for tag in inner.filter(TagKind::q()) {
if let Some(id) = tag.content().and_then(|id| EventId::parse(id).ok()) {
replies_to.push(id);
}
}
replies_to
}

625
crates/chat/src/room.rs Normal file
View File

@@ -0,0 +1,625 @@
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::time::Duration;
use anyhow::Error;
use common::EventUtils;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use state::{tracker, NostrRegistry};
use crate::NewMessage;
const SEND_RETRY: usize = 10;
#[derive(Debug, Clone)]
pub struct SendReport {
pub receiver: PublicKey,
pub status: Option<Output<EventId>>,
pub error: Option<SharedString>,
pub on_hold: Option<Event>,
pub encryption: bool,
pub relays_not_found: bool,
pub device_not_found: bool,
}
impl SendReport {
pub fn new(receiver: PublicKey) -> Self {
Self {
receiver,
status: None,
error: None,
on_hold: None,
encryption: false,
relays_not_found: false,
device_not_found: false,
}
}
pub fn status(mut self, output: Output<EventId>) -> Self {
self.status = Some(output);
self
}
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
self.error = Some(error.into());
self
}
pub fn on_hold(mut self, event: Event) -> Self {
self.on_hold = Some(event);
self
}
pub fn encryption(mut self) -> Self {
self.encryption = true;
self
}
pub fn relays_not_found(mut self) -> Self {
self.relays_not_found = true;
self
}
pub fn device_not_found(mut self) -> Self {
self.device_not_found = true;
self
}
pub fn is_relay_error(&self) -> bool {
self.error.is_some() || self.relays_not_found
}
pub fn is_sent_success(&self) -> bool {
if let Some(output) = self.status.as_ref() {
!output.success.is_empty()
} else {
false
}
}
}
/// Room event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum RoomEvent {
/// Incoming message.
Incoming(NewMessage),
/// Reloads the current room's messages.
Reload,
}
/// Room kind.
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum RoomKind {
#[default]
Request,
Ongoing,
}
#[derive(Debug)]
pub struct Room {
/// Conversation ID
pub id: u64,
/// The timestamp of the last message in the room
pub created_at: Timestamp,
/// Subject of the room
pub subject: Option<SharedString>,
/// All members of the room
pub members: Vec<PublicKey>,
/// Kind
pub kind: RoomKind,
}
impl Ord for Room {
fn cmp(&self, other: &Self) -> Ordering {
self.created_at.cmp(&other.created_at)
}
}
impl PartialOrd for Room {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for Room {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Hash for Room {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl Eq for Room {}
impl EventEmitter<RoomEvent> for Room {}
impl From<&UnsignedEvent> for Room {
fn from(val: &UnsignedEvent) -> Self {
let id = val.uniq_id();
let created_at = val.created_at;
// Get the members from the event's tags and event's pubkey
let members = val.extract_public_keys();
// Get subject from tags
let subject = val
.tags
.find(TagKind::Subject)
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
Room {
id,
created_at,
subject,
members,
kind: RoomKind::default(),
}
}
}
impl Room {
/// Constructs a new room with the given receiver and tags.
pub fn new(subject: Option<String>, author: PublicKey, receivers: Vec<PublicKey>) -> Self {
// Convert receiver's public keys into tags
let mut tags: Tags = Tags::from_list(
receivers
.iter()
.map(|pubkey| Tag::public_key(pubkey.to_owned()))
.collect(),
);
// Add subject if it is present
if let Some(subject) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
subject,
)));
}
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
.tags(tags)
.build(author);
// Generate event ID
event.ensure_id();
Room::from(&event)
}
/// Sets the kind of the room and returns the modified room
pub fn kind(mut self, kind: RoomKind) -> Self {
self.kind = kind;
self
}
/// Sets this room is ongoing conversation
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
if self.kind != RoomKind::Ongoing {
self.kind = RoomKind::Ongoing;
cx.notify();
}
}
/// Updates the creation timestamp of the room
pub fn set_created_at(&mut self, created_at: impl Into<Timestamp>, cx: &mut Context<Self>) {
self.created_at = created_at.into();
cx.notify();
}
/// Updates the subject of the room
pub fn set_subject<T>(&mut self, subject: T, cx: &mut Context<Self>)
where
T: Into<SharedString>,
{
self.subject = Some(subject.into());
cx.notify();
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
}
/// Returns the members of the room with their messaging relays
pub fn members_with_relays(&self, cx: &App) -> Task<Vec<(PublicKey, Vec<RelayUrl>)>> {
let nostr = NostrRegistry::global(cx);
let mut tasks = vec![];
for member in self.members.iter() {
let task = nostr.read(cx).messaging_relays(member, cx);
tasks.push((*member, task));
}
cx.background_spawn(async move {
let mut results = vec![];
for (public_key, task) in tasks.into_iter() {
let urls = task.await;
results.push((public_key, urls));
}
results
})
}
/// Checks if the room has more than two members (group)
pub fn is_group(&self) -> bool {
self.members.len() > 2
}
/// Gets the display name for the room
pub fn display_name(&self, cx: &App) -> SharedString {
if let Some(value) = self.subject.clone() {
value
} else {
self.merged_name(cx)
}
}
/// Gets the display image for the room
pub fn display_image(&self, cx: &App) -> SharedString {
if !self.is_group() {
self.display_member(cx).avatar()
} else {
SharedString::from("brand/group.png")
}
}
/// Get a member to represent the room
///
/// Display member is always different from the current user.
pub fn display_member(&self, cx: &App) -> Person {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key();
let target_member = self
.members
.iter()
.find(|&member| member != &public_key)
.or_else(|| self.members.first())
.expect("Room should have at least one member");
persons.read(cx).get(target_member, cx)
}
/// Merge the names of the first two members of the room.
fn merged_name(&self, cx: &App) -> SharedString {
let persons = PersonRegistry::global(cx);
if self.is_group() {
let profiles: Vec<Person> = self
.members
.iter()
.map(|public_key| persons.read(cx).get(public_key, cx))
.collect();
let mut name = profiles
.iter()
.take(2)
.map(|p| p.name())
.collect::<Vec<_>>()
.join(", ");
if profiles.len() > 2 {
name = format!("{}, +{}", name, profiles.len() - 2);
}
SharedString::from(name)
} else {
self.display_member(cx).name()
}
}
/// Emits a new message signal to the current room
pub fn emit_message(&self, message: NewMessage, cx: &mut Context<Self>) {
cx.emit(RoomEvent::Incoming(message));
}
/// Emits a signal to reload the current room's messages.
pub fn emit_refresh(&mut self, cx: &mut Context<Self>) {
cx.emit(RoomEvent::Reload);
}
/// Get gossip relays for each member
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members();
let id = SubscriptionId::new(format!("room-{}", self.id));
cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
// Subscription options
let opts = SubscribeAutoCloseOptions::default()
.timeout(Some(Duration::from_secs(2)))
.exit_policy(ReqExitPolicy::ExitOnEOSE);
for member in members.into_iter() {
if member == public_key {
continue;
};
// Construct a filter for gossip relays
let filter = Filter::new().kind(Kind::RelayList).author(member).limit(1);
// Subscribe to get member's gossip relays
client
.subscribe_with_id(id.clone(), filter, Some(opts))
.await?;
}
Ok(())
})
}
/// Get all messages belonging to the room
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let conversation_id = self.id.to_string();
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
let messages = client
.database()
.query(filter)
.await?
.into_iter()
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
.sorted_by_key(|message| message.created_at)
.collect();
Ok(messages)
})
}
/// Create a new message event (unsigned)
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
let nostr = NostrRegistry::global(cx);
// Get current user
let public_key = nostr.read(cx).identity().read(cx).public_key();
// Get room's subject
let subject = self.subject.clone();
let mut tags = vec![];
// Add receivers
//
// NOTE: current user will be removed from the list of receivers
for member in self.members.iter() {
// Get relay hint if available
let relay_url = nostr.read(cx).relay_hint(member, cx);
// Construct a public key tag with relay hint
let tag = TagStandard::PublicKey {
public_key: member.to_owned(),
relay_url,
alias: None,
uppercase: false,
};
tags.push(Tag::from_standardized_without_cell(tag));
}
// Add subject tag if it's present
if let Some(value) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
// Add reply/quote tag
if replies.len() == 1 {
tags.push(Tag::event(replies[0]))
} else {
for id in replies {
let tag = TagStandard::Quote {
event_id: id.to_owned(),
relay_url: None,
public_key: None,
};
tags.push(Tag::from_standardized_without_cell(tag))
}
}
// Construct a direct message event
//
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
.tags(tags)
.build(public_key);
// Ensure the event id has been generated
event.ensure_id();
event
}
/// Create a task to send a message to all room members
pub fn send_message(
&self,
rumor: &UnsignedEvent,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get current user's public key and relays
let current_user = nostr.read(cx).identity().read(cx).public_key();
let current_user_relays = nostr.read(cx).messaging_relays(&current_user, cx);
let rumor = rumor.to_owned();
// Get all members and their messaging relays
let task = self.members_with_relays(cx);
cx.background_spawn(async move {
let signer = client.signer().await?;
let current_user_relays = current_user_relays.await;
let mut members = task.await;
// Remove the current user's public key from the list of receivers
// the current user will be handled separately
members.retain(|(this, _)| this != &current_user);
// Collect the send reports
let mut reports: Vec<SendReport> = vec![];
for (receiver, relays) in members.into_iter() {
// Check if there are any relays to send the message to
if relays.is_empty() {
reports.push(SendReport::new(receiver).relays_not_found());
continue;
}
// Ensure relay connection
for url in relays.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
// Construct the gift wrap event
let event =
EventBuilder::gift_wrap(&signer, &receiver, rumor.clone(), vec![]).await?;
// Send the gift wrap event to the messaging relays
match client.send_event_to(relays, &event).await {
Ok(output) => {
let id = output.id().to_owned();
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
let report = SendReport::new(receiver).status(output);
let tracker = tracker().read().await;
if auth {
// Wait for authenticated and resent event successfully
for attempt in 0..=SEND_RETRY {
// Check if event was successfully resent
if tracker.is_sent_by_coop(&id) {
let output = Output::new(id);
let report = SendReport::new(receiver).status(output);
reports.push(report);
break;
}
// Check if retry limit exceeded
if attempt == SEND_RETRY {
reports.push(report);
break;
}
smol::Timer::after(Duration::from_millis(1200)).await;
}
} else {
reports.push(report);
}
}
Err(e) => {
reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
// Construct the gift-wrapped event
let event =
EventBuilder::gift_wrap(&signer, &current_user, rumor.clone(), vec![]).await?;
// Only send a backup message to current user if sent successfully to others
if reports.iter().all(|r| r.is_sent_success()) {
// Check if there are any relays to send the event to
if current_user_relays.is_empty() {
reports.push(SendReport::new(current_user).relays_not_found());
return Ok(reports);
}
// Ensure relay connection
for url in current_user_relays.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
// Send the event to the messaging relays
match client.send_event_to(current_user_relays, &event).await {
Ok(output) => {
reports.push(SendReport::new(current_user).status(output));
}
Err(e) => {
reports.push(SendReport::new(current_user).error(e.to_string()));
}
}
} else {
reports.push(SendReport::new(current_user).on_hold(event));
}
Ok(reports)
})
}
/// Create a task to resend a failed message
pub fn resend_message(
&self,
reports: Vec<SendReport>,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let mut resend_reports = vec![];
for report in reports.into_iter() {
let receiver = report.receiver;
// Process failed events
if let Some(output) = report.status {
let id = output.id();
let urls: Vec<&RelayUrl> = output.failed.keys().collect();
if let Some(event) = client.database().event_by_id(id).await? {
for url in urls.into_iter() {
let relay = client.pool().relay(url).await?;
let id = relay.send_event(&event).await?;
let resent: Output<EventId> = Output {
val: id,
success: HashSet::from([url.to_owned()]),
failed: HashMap::new(),
};
resend_reports.push(SendReport::new(receiver).status(resent));
}
}
}
// Process the on hold event if it exists
if let Some(event) = report.on_hold {
// Send the event to the messaging relays
match client.send_event(&event).await {
Ok(output) => {
resend_reports.push(SendReport::new(receiver).status(output));
}
Err(e) => {
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
}
}
}
}
Ok(resend_reports)
})
}
}

31
crates/chat_ui/Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "chat_ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
state = { path = "../state" }
ui = { path = "../ui" }
theme = { path = "../theme" }
common = { path = "../common" }
person = { path = "../person" }
chat = { path = "../chat" }
settings = { path = "../settings" }
gpui.workspace = true
gpui_tokio.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
indexset = "0.12.3"
emojis = "0.6.4"
once_cell = "1.19.0"
regex = "1"

View File

@@ -0,0 +1,17 @@
use gpui::Action;
use nostr_sdk::prelude::*;
use serde::Deserialize;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub struct SeenOn(pub EventId);
/// Define a open public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]
pub struct OpenPublicKey(pub PublicKey);
/// Define a copy inline public key action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = pubkey, no_json)]
pub struct CopyPublicKey(pub PublicKey);

139
crates/chat_ui/src/emoji.rs Normal file
View File

@@ -0,0 +1,139 @@
use std::sync::OnceLock;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, AppContext, Corner, Element, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::InputState;
use ui::popover::{Popover, PopoverContent};
use ui::{Icon, Sizable, Size};
static EMOJIS: OnceLock<Vec<SharedString>> = OnceLock::new();
fn get_emojis() -> &'static Vec<SharedString> {
EMOJIS.get_or_init(|| {
let mut emojis: Vec<SharedString> = vec![];
emojis.extend(
emojis::Group::SmileysAndEmotion
.emojis()
.map(|e| SharedString::from(e.as_str()))
.collect::<Vec<SharedString>>(),
);
emojis
})
}
#[derive(IntoElement)]
pub struct EmojiPicker {
target: Option<WeakEntity<InputState>>,
icon: Option<Icon>,
anchor: Option<Corner>,
size: Size,
}
impl EmojiPicker {
pub fn new() -> Self {
Self {
size: Size::default(),
target: None,
anchor: None,
icon: None,
}
}
pub fn target(mut self, target: WeakEntity<InputState>) -> Self {
self.target = Some(target);
self
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
#[allow(dead_code)]
pub fn anchor(mut self, corner: Corner) -> Self {
self.anchor = Some(corner);
self
}
}
impl Sizable for EmojiPicker {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for EmojiPicker {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
Popover::new("emojis")
.map(|this| {
if let Some(corner) = self.anchor {
this.anchor(corner)
} else {
this.anchor(gpui::Corner::BottomLeft)
}
})
.trigger(
Button::new("emojis-trigger")
.when_some(self.icon, |this, icon| this.icon(icon))
.ghost()
.with_size(self.size),
)
.content(move |window, cx| {
let input = self.target.clone();
cx.new(|cx| {
PopoverContent::new(window, cx, move |_window, cx| {
div()
.flex()
.flex_wrap()
.items_center()
.gap_2()
.children(get_emojis().iter().map(|e| {
div()
.id(e.clone())
.flex_auto()
.size_10()
.flex()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.child(e.clone())
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.on_click({
let item = e.clone();
let input = input.clone();
move |_, window, cx| {
if let Some(input) = input.as_ref() {
_ = input.update(cx, |this, cx| {
let value = this.value();
let new_text = if value.is_empty() {
format!("{item}")
} else if value.ends_with(" ") {
format!("{value}{item}")
} else {
format!("{value} {item}")
};
this.set_value(new_text, window, cx);
});
}
}
})
}))
.into_any()
})
.scrollable()
.max_h(px(300.))
.max_w(px(300.))
})
})
}
}

1210
crates/chat_ui/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
use gpui::{
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Window,
};
use theme::ActiveTheme;
use ui::input::{InputState, TextInput};
use ui::{v_flex, Sizable};
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
cx.new(|cx| Subject::new(subject, window, cx))
}
pub struct Subject {
input: Entity<InputState>,
}
impl Subject {
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Plan for holiday"));
if let Some(value) = subject {
input.update(cx, |this, cx| {
this.set_value(value, window, cx);
});
};
Self { input }
}
pub fn new_subject(&self, cx: &App) -> SharedString {
self.input.read(cx).value()
}
}
impl Render for Subject {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_2()
.child(
v_flex()
.gap_1()
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Subject:")),
)
.child(TextInput::new(&self.input).small()),
)
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(SharedString::from(
"Subject will be updated when you send a new message.",
)),
)
}
}

323
crates/chat_ui/src/text.rs Normal file
View File

@@ -0,0 +1,323 @@
use std::ops::Range;
use std::sync::Arc;
use gpui::{
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
StyledText, UnderlineStyle, Window,
};
use nostr_sdk::prelude::*;
use once_cell::sync::Lazy;
use person::PersonRegistry;
use regex::Regex;
use theme::ActiveTheme;
use crate::actions::OpenPublicKey;
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
});
static NOSTR_URI_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Link,
Nostr,
}
#[derive(Default)]
pub struct RenderedText {
pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>,
pub link_ranges: Vec<Range<usize>>,
pub link_urls: Arc<[String]>,
}
impl RenderedText {
pub fn new(content: &str, cx: &App) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_plain_text_mut(
content,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
cx,
);
text.truncate(text.trim_end().len());
RenderedText {
text: SharedString::from(text),
link_urls: link_urls.into(),
link_ranges,
highlights,
}
}
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let link_color = cx.theme().text_accent;
InteractiveText::new(
id,
StyledText::new(self.text.clone()).with_default_highlights(
&window.text_style(),
self.highlights.iter().map(|(range, highlight)| {
(
range.clone(),
match highlight {
Highlight::Link => HighlightStyle {
color: Some(link_color),
underline: Some(UnderlineStyle::default()),
..Default::default()
},
Highlight::Nostr => HighlightStyle {
color: Some(link_color),
..Default::default()
},
},
)
}),
),
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
move |ix, window, cx| {
let token = link_urls[ix].as_str();
if let Some(clean_url) = token.strip_prefix("nostr:") {
if let Ok(public_key) = PublicKey::parse(clean_url) {
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
}
} else if is_url(token) {
let url = if token.starts_with("http") {
token.to_string()
} else {
format!("https://{token}")
};
cx.open_url(&url);
} else {
log::warn!("Unrecognized token {token}")
}
}
})
.into_any_element()
}
}
fn render_plain_text_mut(
content: &str,
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
// Copy the content directly
text.push_str(content);
// Collect all URLs
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
for link in URL_REGEX.find_iter(content) {
let range = link.start()..link.end();
let url = link.as_str().to_string();
url_matches.push((range, url));
}
// Collect all nostr entities with nostr: prefix
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
let range = nostr_match.start()..nostr_match.end();
let nostr_uri = nostr_match.as_str().to_string();
// Check if this nostr URI overlaps with any already processed URL
if !url_matches
.iter()
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end)
{
nostr_matches.push((range, nostr_uri));
}
}
// Combine all matches for processing from end to start
let mut all_matches = Vec::new();
all_matches.extend(url_matches);
all_matches.extend(nostr_matches);
// Sort by position (end to start) to avoid changing positions when replacing text
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
// Process all matches
for (range, entity) in all_matches {
// Handle URL token
if is_url(&entity) {
highlights.push((range.clone(), Highlight::Link));
link_ranges.push(range);
link_urls.push(entity);
continue;
};
if let Ok(nip21) = Nip21::parse(&entity) {
match nip21 {
Nip21::Pubkey(public_key) => {
render_pubkey(
public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
Nip21::Profile(nip19_profile) => {
render_pubkey(
nip19_profile.public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
Nip21::EventId(event_id) => {
render_bech32(
event_id.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Event(nip19_event) => {
render_bech32(
nip19_event.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Coordinate(nip19_coordinate) => {
render_bech32(
nip19_coordinate.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
}
}
}
}
/// Check if a string is a URL
fn is_url(s: &str) -> bool {
URL_REGEX.is_match(s)
}
/// Format a bech32 entity with ellipsis and last 4 characters
fn format_shortened_entity(entity: &str) -> String {
let prefix_end = entity.find('1').unwrap_or(0);
if prefix_end > 0 && entity.len() > prefix_end + 5 {
let prefix = &entity[0..=prefix_end]; // Include the '1'
let suffix = &entity[entity.len() - 4..]; // Last 4 chars
format!("{prefix}...{suffix}")
} else {
entity.to_string()
}
}
fn render_pubkey(
public_key: PublicKey,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
let display_name = format!("@{}", profile.name());
text.replace_range(range.clone(), &display_name);
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
let new_range = range.start..(range.start + new_length);
highlights.push((new_range.clone(), Highlight::Nostr));
link_ranges.push(new_range);
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
fn render_bech32(
bech32: String,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
let njump_url = format!("https://njump.me/{bech32}");
let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}");
text.replace_range(range.clone(), &display_text);
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
let new_range = range.start..(range.start + new_length);
highlights.push((new_range.clone(), Highlight::Link));
link_ranges.push(new_range);
link_urls.push(njump_url);
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
// Helper function to adjust ranges when text length changes
fn adjust_ranges(
highlights: &mut [(Range<usize>, Highlight)],
link_ranges: &mut [Range<usize>],
position: usize,
length_diff: isize,
) {
// Adjust highlight ranges
for (range, _) in highlights.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
}
}
// Adjust link ranges
for range in link_ranges.iter_mut() {
if range.start > position {
range.start = (range.start as isize + length_diff) as usize;
range.end = (range.end as isize + length_diff) as usize;
}
}
}

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