75 Commits

Author SHA1 Message Date
fad30a89f1 .
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 5m17s
2026-02-19 10:10:16 +07:00
3e8efdd0ef update components 2026-02-19 08:17:46 +07:00
2c33670ba5 .
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m41s
2026-02-19 06:41:23 +07:00
8026a4f5a5 wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m46s
2026-02-17 13:23:43 +07:00
1d8e3724a8 wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m43s
2026-02-17 07:54:46 +07:00
d25080f5e7 wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m18s
2026-02-16 16:53:06 +07:00
452253bece wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m25s
2026-02-15 16:52:35 +07:00
a1aaa30a48 wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m59s
2026-02-14 16:43:28 +07:00
e327178161 chore: fix some performance issues (#6)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m14s
Reviewed-on: #6
2026-02-14 02:01:49 +00:00
ecd7f6aa9b Continue redesign for the v1 stable release (#5)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m32s
Reviewed-on: #5
2026-02-12 08:32:17 +00:00
32201554ec Redesign for the v1 stable release (#3)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m26s
Only half done. Will continue in another PR.

Reviewed-on: #3
2026-02-04 01:43:21 +00:00
014757cfc9 chore: update gpui 2026-01-14 15:00:52 +07:00
ac9afb1790 chore: refactor app settings (#2)
# Changelog

### Added

- [x] Add `Auth Mode` setting.
- [x] Add `Room Config` setting.

### Changed

- [x] Rename `media server` setting to `file server`

### Removed

- [x] Remove `proxy` setting. Coop is no longer depend on any 3rd party services.
- [x] Remove `contact bypass` settings. All chat requests from known contacts will be bypass by default.

**Note:**
- The Settings UI has been removed. It will be re-added in a separate PR.

Reviewed-on: #2
2026-01-14 09:48:15 +08:00
75c3783522 feat: rewrite the nip-4e implementation (#1)
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
252 changed files with 26050 additions and 23310 deletions

View File

@@ -13,7 +13,7 @@ jobs:
build: build:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest]
rustup: [stable] rustup: [stable]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}

4143
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,11 @@ members = ["crates/*"]
default-members = ["crates/coop"] default-members = ["crates/coop"]
[workspace.package] [workspace.package]
version = "0.2.8" version = "0.3.0"
edition = "2021" edition = "2021"
publish = false publish = false
[workspace.metadata.i18n]
available-locales = ["en"]
default-locale = "en"
load-path = "locales"
[workspace.dependencies] [workspace.dependencies]
i18n = { path = "crates/i18n" }
# GPUI # GPUI
gpui = { git = "https://github.com/zed-industries/zed" } gpui = { git = "https://github.com/zed-industries/zed" }
@@ -22,21 +16,15 @@ gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr # Nostr
nostr = { git = "https://github.com/rust-nostr/nostr" } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { 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 = [ nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
"lmdb",
"nip96",
"nip59",
"nip49",
"nip44",
] }
# Others # Others
anyhow = "1.0.44" anyhow = "1.0.44"
chrono = "0.4.38" chrono = "0.4.38"
dirs = "5.0"
emojis = "0.6.4"
futures = "0.3" futures = "0.3"
itertools = "0.13.0" itertools = "0.13.0"
log = "0.4" log = "0.4"
@@ -44,9 +32,9 @@ oneshot = "0.1.10"
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] } reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] } flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
rust-embed = "8.5.0" rust-embed = "8.5.0"
rust-i18n = "3"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
schemars = "1"
smallvec = "1.14.0" smallvec = "1.14.0"
smol = "2" smol = "2"
tracing = "0.1.40" tracing = "0.1.40"
@@ -58,3 +46,7 @@ opt-level = "z"
lto = true lto = true
codegen-units = 1 codegen-units = 1
panic = "abort" panic = "abort"
[profile.profiling]
inherits = "release"
debug = true

Binary file not shown.

Binary file not shown.

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

@@ -1,92 +0,0 @@
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,92 +0,0 @@
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,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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"/> <path
d="M10 5.75L3.75 12L10 18.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.5 12H20.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 418 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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"/> <path d="M14 5.75L20.25 12L14 18.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.5 12H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 320 B

View File

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

Before

Width:  |  Height:  |  Size: 370 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M17.25 14C17.25 18.0041 14.0041 21.25 10 21.25C5.99594 21.25 2.75 18.0041 2.75 14C2.75 9.99594 5.99594 6.75 10 6.75C14.0041 6.75 17.25 9.99594 17.25 14Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.5 8.5L17.5 6.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M16.75 1.75V3.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.75 7.25H22.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20 4L21.25 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 800 B

View File

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

Before

Width:  |  Height:  |  Size: 218 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m8 10 3.293 3.293a1 1 0 0 0 1.414 0L16 10"/> <path d="M5.75 9.5L12 15.75L18.25 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 247 B

After

Width:  |  Height:  |  Size: 209 B

View File

@@ -1 +1,3 @@
<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> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.5 18.25L15.75 12L9.5 5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 249 B

After

Width:  |  Height:  |  Size: 209 B

View File

@@ -1 +1,3 @@
<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> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M5.75 14.5L12 8.25L18.25 14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 210 B

View File

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

Before

Width:  |  Height:  |  Size: 298 B

View File

@@ -1 +1,3 @@
<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> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.9231L10.5625 15.75L15.25 8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 369 B

After

Width:  |  Height:  |  Size: 341 B

View File

@@ -1 +1,3 @@
<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> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M6.75 13.0625L9.9 16.25L17.25 7.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 215 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.8007 10.25C8.74816 10.25 8.16683 11.4713 8.83056 12.2882L11.0301 14.9953C11.5303 15.611 12.4701 15.611 12.9704 14.9953L15.1699 12.2882C15.8336 11.4713 15.2523 10.25 14.1997 10.25H9.8007Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 302 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM9.53033 8.46967C9.23744 8.17678 8.76256 8.17678 8.46967 8.46967C8.17678 8.76256 8.17678 9.23744 8.46967 9.53033L10.9393 12L8.46967 14.4697C8.17678 14.7626 8.17678 15.2374 8.46967 15.5303C8.76256 15.8232 9.23744 15.8232 9.53033 15.5303L12 13.0607L14.4697 15.5303C14.7626 15.8232 15.2374 15.8232 15.5303 15.5303C15.8232 15.2374 15.8232 14.7626 15.5303 14.4697L13.0607 12L15.5303 9.53033C15.8232 9.23744 15.8232 8.76256 15.5303 8.46967C15.2374 8.17678 14.7626 8.17678 14.4697 8.46967L12 10.9393L9.53033 8.46967Z" fill="currentColor"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 774 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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"/> <path d="M15 9L9 15M15 15L9 9M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 329 B

View File

@@ -1 +1,3 @@
<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> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 201 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M7.10352 4C7.42998 2.84575 8.49122 2 9.75 2H14.25C15.5088 2 16.57 2.84575 16.8965 4H18.25C19.7688 4 21 5.23122 21 6.75V19.25C21 20.7688 19.7688 22 18.25 22H5.75C4.23122 22 3 20.7688 3 19.25V6.75C3 5.23122 4.23122 4 5.75 4H7.10352ZM8.5 4.75V6.25C8.5 6.38807 8.61193 6.5 8.75 6.5H15.25C15.3881 6.5 15.5 6.38807 15.5 6.25V4.75C15.5 4.05964 14.9404 3.5 14.25 3.5H9.75C9.05964 3.5 8.5 4.05964 8.5 4.75Z" fill="currentColor"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 444 B

After

Width:  |  Height:  |  Size: 550 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 21.25L21.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.75 21.25V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

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

Before

Width:  |  Height:  |  Size: 454 B

View File

@@ -1,4 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path fill="currentColor" d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8.25 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm-16.5 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"/> <path d="M3.75 10.25C2.7835 10.25 2 11.0335 2 12C2 12.9665 2.7835 13.75 3.75 13.75C4.7165 13.75 5.5 12.9665 5.5 12C5.5 11.0335 4.7165 10.25 3.75 10.25Z" fill="currentColor"/><path d="M12 10.25C11.0335 10.25 10.25 11.0335 10.25 12C10.25 12.9665 11.0335 13.75 12 13.75C12.9665 13.75 13.75 12.9665 13.75 12C13.75 11.0335 12.9665 10.25 12 10.25Z" fill="currentColor"/><path d="M20.25 10.25C19.2835 10.25 18.5 11.0335 18.5 12C18.5 12.9665 19.2835 13.75 20.25 13.75C21.2165 13.75 22 12.9665 22 12C22 11.0335 21.2165 10.25 20.25 10.25Z" fill="currentColor"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm8.25 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm-16.5 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 457 B

After

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

Before

Width:  |  Height:  |  Size: 604 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M19 1.75V8.25M15.75 5H22.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10.75 9.9C10.75 11.0046 10.0784 11.75 9.25 11.75C8.42157 11.75 7.75 11.0046 7.75 9.9C7.75 8.79543 8.42157 8 9.25 8C10.0784 8 10.75 8.79543 10.75 9.9Z" fill="currentColor"/><path d="M16.25 9.9C16.25 11.0046 15.5784 11.75 14.75 11.75C13.9216 11.75 13.25 11.0046 13.25 9.9C13.25 8.79543 13.9216 8 14.75 8C15.5784 8 16.25 8.79543 16.25 9.9Z" fill="currentColor"/><path d="M16.1123 14.8493C16.1942 14.7105 16.2249 14.545 16.192 14.3857C16.1592 14.2263 16.0665 14.0867 15.933 13.9968C15.7996 13.9069 15.6354 13.8733 15.4754 13.9028C15.3154 13.9321 15.1736 14.0226 15.0757 14.1507C15.0008 14.2469 14.9237 14.3367 14.8415 14.4241C14.1096 15.2083 13.061 15.628 12.0035 15.625C10.946 15.6265 9.8972 15.2055 9.16254 14.4222C9.08002 14.3348 9.00261 14.2451 8.92738 14.1491C8.8291 14.0214 8.68699 13.9313 8.52686 13.9024C8.36679 13.8735 8.20268 13.9076 8.06954 13.9979C7.9364 14.0882 7.84406 14.2281 7.81174 14.3875C7.77938 14.547 7.81054 14.7123 7.89293 14.8509C7.97731 14.99 8.06686 15.1223 8.16553 15.2526C9.04297 16.4311 10.5292 17.1343 12.0024 17.125C13.4754 17.1367 14.965 16.4342 15.8405 15.2521C15.939 15.1215 16.0282 14.9888 16.1123 14.8493Z" fill="currentColor"/><path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3.75 13.0199C8.54029 18.1132 15.4597 18.1132 20.25 13.0199" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 7.62257C6.14516 5.07587 9.0726 3.80251 12 3.80249C14.9274 3.80247 17.8549 5.07576 20.25 7.62238" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 17V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.25 16.5L6.75 18.9821" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.5 16.5L17.25 18.9821" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 800 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12.7507 3.75C12.7507 3.33579 12.4149 3 12.0007 3C11.5865 3 11.2507 3.33579 11.2507 3.75V6.25C11.2507 6.66421 11.5865 7 12.0007 7C12.4149 7 12.7507 6.66421 12.7507 6.25V3.75Z" fill="currentColor"/><path d="M7.26594 5.19799C6.99969 4.88068 6.52662 4.8393 6.20932 5.10555C5.89201 5.3718 5.85062 5.84487 6.11687 6.16217L7.72384 8.07728C7.99009 8.39459 8.46316 8.43598 8.78047 8.16973C9.09777 7.90347 9.13916 7.43041 8.87291 7.1131L7.26594 5.19799Z" fill="currentColor"/><path d="M17.8726 6.16227C18.1389 5.84496 18.0975 5.37189 17.7802 5.10564C17.4629 4.83939 16.9898 4.88078 16.7235 5.19809L15.1166 7.1132C14.8503 7.4305 14.8917 7.90357 15.209 8.16982C15.5263 8.43607 15.9994 8.39468 16.2656 8.07738L17.8726 6.16227Z" fill="currentColor"/><path d="M5.22073 9C4.33013 9 3.52687 9.5355 3.18434 10.3576L2.78846 11.3077C2.61378 11.7269 2.20416 12 1.75 12C1.33579 12 1 12.3358 1 12.75V19.25C1 19.6642 1.33579 20 1.75 20H7.38937C9.39779 20 11.0763 18.4715 11.2637 16.4719L11.4255 14.746C11.6391 12.468 9.84697 10.5 7.559 10.5C7.46053 10.5 7.36858 10.4508 7.31396 10.3689L7.0563 9.98237C6.64715 9.36864 5.95834 9 5.22073 9Z" fill="currentColor"/><path d="M18.722 9C17.9844 9 17.2956 9.36864 16.8865 9.98237L16.6288 10.3689C16.5742 10.4508 16.4822 10.5 16.3838 10.5C14.0958 10.5 12.3037 12.468 12.5172 14.746L12.679 16.4719C12.8665 18.4715 14.545 20 16.5534 20H22.1928C22.607 20 22.9428 19.6642 22.9428 19.25V12.75C22.9428 12.3358 22.607 12 22.1928 12C21.7386 12 21.329 11.7269 21.1543 11.3077L20.7584 10.3576C20.4159 9.5355 19.6126 9 18.722 9Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M1.75 19.25H7.38937C9.0107 19.25 10.3657 18.0161 10.517 16.4019L10.6788 14.676C10.8511 12.8379 9.40511 11.25 7.559 11.25C7.20977 11.25 6.88364 11.0755 6.68992 10.7849L6.43226 10.3984C6.16221 9.99331 5.70757 9.75 5.22073 9.75C4.6329 9.75 4.10273 10.1034 3.87664 10.6461L3.48077 11.5962C3.18964 12.2949 2.50694 12.75 1.75 12.75M22.1928 19.25H16.5534C14.9321 19.25 13.5771 18.0161 13.4258 16.4019L13.264 14.676C13.0916 12.8379 14.5377 11.25 16.3838 11.25C16.733 11.25 17.0591 11.0755 17.2528 10.7849L17.5105 10.3984C17.7806 9.99331 18.2352 9.75 18.722 9.75C19.3099 9.75 19.84 10.1034 20.0661 10.6461L20.462 11.5962C20.7531 12.2949 21.4358 12.75 22.1928 12.75M12.0007 3.75V6.25M6.69141 5.68008L8.29838 7.59519M17.2981 5.68018L15.6911 7.59529" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 918 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.00001 13.7453L3.00004 5.75C3.00004 4.23122 4.23126 3.00001 5.75004 3L18.25 3C19.7688 3 21 4.23122 21 5.75V13.7466C21 13.7477 21 13.7489 21 13.75L21 18.25C21 19.7688 19.7688 21 18.25 21H5.75C4.32614 21 3.15502 19.9179 3.0142 18.5312C3.00481 18.4387 3 18.3449 3 18.25M5.75004 4.5L18.25 4.5C18.9403 4.5 19.5 5.05964 19.5 5.75V13L15.9298 13C15.5695 13 15.2601 13.2562 15.1929 13.6102C14.9078 15.1135 13.5858 16.25 12 16.25C10.4142 16.25 9.09221 15.1135 8.80706 13.6102C8.73991 13.2562 8.43051 13 8.0702 13H4.50002L4.50004 5.75C4.50004 5.05965 5.05968 4.50001 5.75004 4.5Z" fill="currentColor"/><path d="M3 18.25V13.75C3 13.7484 3 13.7469 3.00001 13.7453" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 805 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3.75 12.75H8.0702C8.42126 14.6006 10.0472 16 12 16C13.9528 16 15.5787 14.6006 15.9298 12.75L20.25 12.75M18.25 20.25H5.75001C4.64543 20.25 3.75 19.3546 3.75001 18.25L3.75005 5.75C3.75005 4.64543 4.64548 3.75001 5.75005 3.75L18.25 3.75C19.3546 3.75 20.25 4.64543 20.25 5.75V18.25C20.25 19.3546 19.3546 20.25 18.25 20.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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 2Zm-2 9a.75.75 0 0 1 .75-.75H12a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 1-1.5 0v-4.5h-.5A.75.75 0 0 1 10 11Zm2-3.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" clip-rule="evenodd"/> <path d="M10.75 11H12L12 16.25M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 7.375C11.6548 7.375 11.375 7.65482 11.375 8C11.375 8.34518 11.6548 8.625 12 8.625C12.3452 8.625 12.625 8.34518 12.625 8C12.625 7.65482 12.3452 7.375 12 7.375Z" fill="currentColor" stroke="currentColor" stroke-width="0.25"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 590 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4.75 10.9853V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V10.9853M9.75 7.75H14.25M12.617 13.5499L19.9415 11.1744C20.5875 10.9649 21.25 11.4465 21.25 12.1256V18.25C21.25 19.3546 20.3546 20.25 19.25 20.25H4.75C3.64543 20.25 2.75 19.3546 2.75 18.25V12.1256C2.75 11.4465 3.41249 10.9649 4.0585 11.1744L11.383 13.5499C11.784 13.68 12.216 13.68 12.617 13.5499Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.75027 5.52371L10.7168 4.55722C13.1264 2.14759 17.0332 2.14759 19.4428 4.55722C21.8524 6.96684 21.8524 10.8736 19.4428 13.2832L18.4742 14.2519M5.52886 9.74513L4.55722 10.7168C2.14759 13.1264 2.1476 17.0332 4.55722 19.4428C6.96684 21.8524 10.8736 21.8524 13.2832 19.4428L14.2478 18.4782M9.5 14.5L14.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 462 B

View File

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

Before

Width:  |  Height:  |  Size: 302 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.75 3.75v6.5m0 0h6.5m-6.5 0 6.5-6.5m-10 16.5v-6.5m0 0h-6.5m6.5 0-6.5 6.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 281 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21.248 11.811a6.5 6.5 0 0 1-9.06-9.06 9.25 9.25 0 1 0 9.06 9.06Z"/> <path d="M21.2481 11.8112C20.1889 12.56 18.8958 13 17.5 13C13.9101 13 11 10.0899 11 6.5C11 5.10416 11.44 3.81108 12.1888 2.75189C12.126 2.75063 12.0631 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 11.9369 21.2494 11.874 21.2481 11.8112Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 271 B

After

Width:  |  Height:  |  Size: 489 B

View File

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

Before

Width:  |  Height:  |  Size: 489 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path fill="#000" fill-rule="evenodd" d="M9 4.5v15h9.25c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25H9ZM3 5.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.25V5.75Z" clip-rule="evenodd"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M19.25 4C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75C2 5.23122 3.23122 4 4.75 4H19.25ZM6.25 7.5C5.83579 7.5 5.5 7.83579 5.5 8.25V15.75C5.5 16.1642 5.83579 16.5 6.25 16.5C6.66421 16.5 7 16.1642 7 15.75V8.25C7 7.83579 6.66421 7.5 6.25 7.5Z" fill="currentColor"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 451 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 5.75a2 2 0 0 1 2-2h12.5a2 2 0 0 1 2 2v12.5a2 2 0 0 1-2 2H5.75a2 2 0 0 1-2-2V5.75Zm5-1.75v16"/> <path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M6.25 8.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path fill="#000" fill-rule="evenodd" d="M15 4.5v15H5.75c-.69 0-1.25-.56-1.25-1.25V5.75c0-.69.56-1.25 1.25-1.25H15Zm6 1.25A2.75 2.75 0 0 0 18.25 3H5.75A2.75 2.75 0 0 0 3 5.75v12.5A2.75 2.75 0 0 0 5.75 21h12.5A2.75 2.75 0 0 0 21 18.25V5.75Z" clip-rule="evenodd"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M4.75 4C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75C22 5.23122 20.7688 4 19.25 4H4.75ZM17.75 7.5C18.1642 7.5 18.5 7.83579 18.5 8.25V15.75C18.5 16.1642 18.1642 16.5 17.75 16.5C17.3358 16.5 17 16.1642 17 15.75V8.25C17 7.83579 17.3358 7.5 17.75 7.5Z" fill="currentColor"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 459 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.25 4v16M3.75 5.75a2 2 0 0 1 2-2h12.5a2 2 0 0 1 2 2v12.5a2 2 0 0 1-2 2H5.75a2 2 0 0 1-2-2V5.75Z"/> <path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M17.75 8.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 304 B

After

Width:  |  Height:  |  Size: 436 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.66936 5.12886C2.12122 3.64104 3.6759 2.24953 5.09409 2.95862L20.0468 10.435C21.3366 11.0799 21.3366 12.9205 20.0468 13.5655L5.09409 21.0418C3.67589 21.7509 2.12122 20.3594 2.66936 18.8715L4.92467 12.75H9.25021C9.66442 12.75 10.0002 12.4142 10.0002 12C10.0002 11.5858 9.66442 11.25 9.25021 11.25H4.92452L2.66936 5.12886Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

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

Before

Width:  |  Height:  |  Size: 281 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M16.2426 12.0005H7.75736M12 16.2431V7.75781M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

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

Before

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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 4v8m0 0v8m0-8H4m8 0h8"/> <path d="M12 6.75V12M12 12V17.25M12 12H6.75M12 12H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 205 B

After

Width:  |  Height:  |  Size: 203 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M17.75 2.75H6.25C5.14543 2.75 4.25 3.64543 4.25 4.75V19.25C4.25 20.3546 5.14543 21.25 6.25 21.25H17.75C18.8546 21.25 19.75 20.3546 19.75 19.25V4.75C19.75 3.64543 18.8546 2.75 17.75 2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12.25" r="2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M16 21C16 18.7909 14.2091 17 12 17C9.79086 17 8 18.7909 8 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M9.75 6.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

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

Before

Width:  |  Height:  |  Size: 512 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="9.25" r="1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 21.25L11.75 9.25H12.25L16.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.5 17.75H14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75693 12.7501C6.08102 10.7234 6.08103 7.77679 7.75693 5.75M16.2431 5.75C17.919 7.77679 17.919 10.7234 16.2431 12.7501" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.06494 2.7574C1.64285 6.40823 1.64502 12.1018 5.07145 15.75M18.9281 2.75C22.3572 6.40053 22.3573 12.0993 18.9285 15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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"/> <path d="M1.84521 11.4494L9.99071 3.91478C10.471 3.47055 11.25 3.81116 11.25 4.46535V7.99994C11.25 8.27608 11.478 8.49949 11.7541 8.50388C19.8394 8.63247 22 11.9205 22 20.2499C20.5303 17.3105 19.7806 15.5711 11.7551 15.5021C11.4789 15.4997 11.25 15.7238 11.25 15.9999V19.5345C11.25 20.1887 10.471 20.5293 9.99071 20.0851L1.84521 12.5505C1.52425 12.2536 1.52425 11.7463 1.84521 11.4494Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 534 B

View File

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

Before

Width:  |  Height:  |  Size: 462 B

View File

@@ -1,9 +0,0 @@
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="list-filter" transform="translate(16.142767, 16.107233) rotate(-45.000000) translate(-16.142767, -16.107233) translate(3.642767, 10.491117)" stroke="#000000" stroke-width="2">
<line x1="0.454058454" y1="0.48959236" x2="24.1421356" y2="0.843145751" stroke-linecap="square"></line>
<line x1="4.69669914" y1="6.14644661" x2="20.1188954" y2="5.79289322"></line>
<line x1="9.06066017" y1="10.732233" x2="15.3033009" y2="10.3890873"></line>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 730 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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"/> <path d="M20 20L16.1265 16.1265M16.1265 16.1265C17.4385 14.8145 18.25 13.002 18.25 11C18.25 6.99594 15.0041 3.75 11 3.75C6.99594 3.75 3.75 6.99594 3.75 11C3.75 15.0041 6.99594 18.25 11 18.25C13.002 18.25 14.8145 17.4385 16.1265 16.1265Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 287 B

After

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

Before

Width:  |  Height:  |  Size: 486 B

View File

@@ -1,4 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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 d="M7.878 5.21415L7.17474 5.05186C6.58003 4.91462 5.95657 5.09343 5.525 5.525C5.09343 5.95657 4.91462 6.58003 5.05186 7.17474L5.21415 7.878C5.40122 8.6886 5.06696 9.53036 4.37477 9.99182L3.51965 10.5619C3.03881 10.8825 2.75 11.4221 2.75 12C2.75 12.5779 3.03881 13.1175 3.51965 13.4381L4.37477 14.0082C5.06696 14.4696 5.40122 15.3114 5.21415 16.122L5.05186 16.8253C4.91462 17.42 5.09343 18.0434 5.525 18.475C5.95657 18.9066 6.58003 19.0854 7.17474 18.9481L7.878 18.7858C8.6886 18.5988 9.53036 18.933 9.99182 19.6252L10.5619 20.4804C10.8825 20.9612 11.4221 21.25 12 21.25C12.5779 21.25 13.1175 20.9612 13.4381 20.4804L14.0082 19.6252C14.4696 18.933 15.3114 18.5988 16.122 18.7858L16.8253 18.9481C17.42 19.0854 18.0434 18.9066 18.475 18.475C18.9066 18.0434 19.0854 17.42 18.9481 16.8253L18.7858 16.122C18.5988 15.3114 18.933 14.4696 19.6252 14.0082L20.4804 13.4381C20.9612 13.1175 21.25 12.5779 21.25 12C21.25 11.4221 20.9612 10.8825 20.4804 10.5619L19.6252 9.99182C18.933 9.53036 18.5988 8.6886 18.7858 7.878L18.9481 7.17473C19.0854 6.58003 18.9066 5.95657 18.475 5.525C18.0434 5.09343 17.42 4.91462 16.8253 5.05186L16.122 5.21415C15.3114 5.40122 14.4696 5.06696 14.0082 4.37477L13.4381 3.51965C13.1175 3.03881 12.5779 2.75 12 2.75C11.4221 2.75 10.8825 3.03881 10.5619 3.51965L9.99182 4.37477C9.53036 5.06696 8.6886 5.40122 7.878 5.21415Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M14.75 12C14.75 13.5188 13.5188 14.75 12 14.75C10.4812 14.75 9.25 13.5188 9.25 12C9.25 10.4812 10.4812 9.25 12 9.25C13.5188 9.25 14.75 10.4812 14.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<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> </svg>

Before

Width:  |  Height:  |  Size: 855 B

After

Width:  |  Height:  |  Size: 1.7 KiB

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M20.25 6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124V6.94155Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 446 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 20.25L6.26353 19.4903M6.26353 19.4903L6.95233 19.3414C7.23089 19.2812 7.51911 19.2812 7.79767 19.3414L11.5773 20.1586C11.8559 20.2188 12.1441 20.2188 12.4227 20.1586L16.2023 19.3414C16.4809 19.2812 16.7691 19.2812 17.0477 19.3414L17.7365 19.4903M6.26353 19.4903C5.08645 17.9188 4.46034 16.5675 4.08992 15.0117C3.8539 14.0205 4.52677 13.0678 5.51689 12.827L11.5273 11.365C11.8379 11.2894 12.1621 11.2894 12.4727 11.365L18.4831 12.827C19.4732 13.0678 20.1461 14.0205 19.9101 15.0117C19.5397 16.5675 18.9136 17.9188 17.7365 19.4903M17.7365 19.4903L21.25 20.25M5.75 12.75V7.75C5.75 7.19772 6.19772 6.75 6.75 6.75H17.25C17.8023 6.75 18.25 7.19772 18.25 7.75V12.75M9.75 6.75V3.75C9.75 3.19772 10.1977 2.75 10.75 2.75H13.25C13.8023 2.75 14.25 3.19772 14.25 3.75V6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 946 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 17v2m6-6v6m6-10v10m6-14v14"/>
</svg>

Before

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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.998 3.29V1.769M5.84 18.158l-1.077 1.078m7.235 2.997v-1.524m7.235-15.944-1.077 1.077M20.707 12h1.523m-4.074 6.159 1.077 1.077M1.766 12h1.523m1.474-7.235L5.84 5.842m9.87 2.446a5.25 5.25 0 1 1-7.424 7.424 5.25 5.25 0 0 1 7.424-7.424Z"/> <path d="M11.9982 3.29083V1.76758M5.83985 18.1586L4.76275 19.2357M11.9982 22.2327V20.7094M19.2334 4.76468L18.1562 5.84179M20.707 12.0001H22.2303M18.1562 18.1586L19.2334 19.2357M1.76562 12.0001H3.28888M4.76267 4.76462L5.83977 5.84173M15.7104 8.28781C17.7606 10.3381 17.7606 13.6622 15.7104 15.7124C13.6601 17.7627 10.336 17.7627 8.28574 15.7124C6.23548 13.6622 6.23548 10.3381 8.28574 8.28781C10.336 6.23756 13.6601 6.23756 15.7104 8.28781Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 440 B

After

Width:  |  Height:  |  Size: 611 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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"/> <path d="M12 19.25V13L14.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 13L9.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.375 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H8.92963C9.59834 3.75 10.2228 4.0842 10.5937 4.6406L11.7031 6.3047C11.8886 6.5829 12.2008 6.75 12.5352 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.625" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 718 B

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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M10 5.75V7.25M14 5.75V7.25M3.75 10.25H20.25V19.25C20.25 20.3546 19.3546 21.25 18.25 21.25H5.75C4.64543 21.25 3.75 20.3546 3.75 19.25V10.25ZM5.75 2.75H18.25V10.25H5.75V2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 353 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<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"/> <circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M11.3121 12.3511L11.0582 7.9983C11.0266 7.45662 11.4574 7 12 7C12.5426 7 12.9734 7.45662 12.9418 7.9983L12.6879 12.3511C12.6666 12.7154 12.365 13 12 13C11.635 13 11.3334 12.7154 11.3121 12.3511Z" fill="currentColor"/><circle cx="11.9999" cy="15.8998" r="1.1" fill="currentColor"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 384 B

After

Width:  |  Height:  |  Size: 445 B

58
assets/icons/zoom.svg Normal file
View File

@@ -0,0 +1,58 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path
d="M4.75 9.25V4.75H9.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19.25 9.25V4.75H14.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19.25 14.75V19.25H14.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.75 14.75V19.25H9.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5 5L9.5 9.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 5L14.5 9.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 19L14.5 14.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5 19L9.5 14.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -5,8 +5,9 @@ use rust_embed::RustEmbed;
#[derive(RustEmbed)] #[derive(RustEmbed)]
#[folder = "../../assets"] #[folder = "../../assets"]
#[include = "fonts/**/*"] #[include = "fonts/**/*"]
#[include = "brand/*"] #[include = "brand/**/*"]
#[include = "icons/**/*"] #[include = "icons/**/*"]
#[include = "themes/**/*"]
#[exclude = "*.DS_Store"] #[exclude = "*.DS_Store"]
pub struct Assets; pub struct Assets;
@@ -47,13 +48,4 @@ impl Assets {
cx.text_system().add_fonts(embedded_fonts) cx.text_system().add_fonts(embedded_fonts)
} }
pub fn load_test_fonts(&self, cx: &App) {
cx.text_system()
.add_fonts(vec![self
.load("fonts/plex-mono/ZedPlexMono-Regular.ttf")
.unwrap()
.unwrap()])
.unwrap()
}
} }

View File

@@ -6,13 +6,16 @@ publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
global = { path = "../global" } state = { path = "../state" }
gpui.workspace = true gpui.workspace = true
gpui_tokio.workspace = true
reqwest.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
smol.workspace = true smol.workspace = true
log.workspace = true log.workspace = true
smallvec.workspace = true smallvec.workspace = true
cargo-packager-updater = "0.2.3" semver = "1.0.27"
tempfile = "3.23.0"

View File

@@ -1,10 +1,21 @@
use anyhow::Error; use std::ffi::OsString;
use cargo_packager_updater::semver::Version; use std::path::{Path, PathBuf};
use cargo_packager_updater::{check_update, Config, Update}; use std::sync::Arc;
use global::constants::{APP_PUBKEY, APP_UPDATER_ENDPOINT}; use std::time::Duration;
use gpui::http_client::Url;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window}; use anyhow::{anyhow, Context as AnyhowContext, Error};
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 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) { pub fn init(cx: &mut App) {
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx); AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
@@ -14,16 +25,101 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {} impl Global for GlobalAutoUpdater {}
#[derive(Debug, Clone)] #[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 { pub enum AutoUpdateStatus {
Idle, Idle,
Checking, Checking,
Checked { update: Box<Update> }, Checked { files: Vec<EventId> },
Installing, Installing,
Updated, Updated,
Errored { msg: Box<String> }, Errored { msg: Box<String> },
} }
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
fn as_ref(&self) -> &AutoUpdateStatus {
self
}
}
impl AutoUpdateStatus { impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool { pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing) matches!(self, Self::Checked { .. } | Self::Installing)
@@ -33,10 +129,8 @@ impl AutoUpdateStatus {
matches!(self, Self::Updated) matches!(self, Self::Updated)
} }
pub fn checked(update: Update) -> Self { pub fn checked(files: Vec<EventId>) -> Self {
Self::Checked { Self::Checked { files }
update: Box::new(update),
}
} }
pub fn error(e: String) -> Self { pub fn error(e: String) -> Self {
@@ -44,109 +138,89 @@ impl AutoUpdateStatus {
} }
} }
#[derive(Debug)]
pub struct AutoUpdater { pub struct AutoUpdater {
/// Current status of the auto updater
pub status: AutoUpdateStatus, pub status: AutoUpdateStatus,
config: Config,
version: Version, /// Current version of the application
#[allow(dead_code)] pub version: Version,
subscriptions: SmallVec<[Subscription; 1]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 2]>,
} }
impl AutoUpdater { impl AutoUpdater {
/// Retrieve the Global Auto Updater instance /// Retrieve the global auto updater instance
pub fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone() cx.global::<GlobalAutoUpdater>().0.clone()
} }
/// Retrieve the Auto Updater instance /// Set the global auto updater instance
pub fn read_global(cx: &App) -> &Self { fn set_global(state: Entity<Self>, cx: &mut App) {
cx.global::<GlobalAutoUpdater>().0.read(cx)
}
/// Set the Global Auto Updater instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state)); cx.set_global(GlobalAutoUpdater(state));
} }
pub(crate) fn new(cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let config = cargo_packager_updater::Config { let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
endpoints: vec![Url::parse(APP_UPDATER_ENDPOINT).expect("Endpoint is not valid")], let async_version = version.clone();
pubkey: String::from(APP_PUBKEY),
..Default::default()
};
let version = Version::parse(env!("CARGO_PKG_VERSION")).expect("Failed to parse version");
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| { let mut subscriptions = smallvec![];
if let Some(window) = window { let mut tasks = smallvec![];
this.check_for_updates(window, cx);
} 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 { Self {
status: AutoUpdateStatus::Idle, status: AutoUpdateStatus::Idle,
version, version,
config, _subscriptions: subscriptions,
subscriptions, _tasks: tasks,
}
}
pub fn check_for_updates(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let config = self.config.clone();
let current_version = self.version.clone();
log::info!("Checking for updates...");
self.set_status(AutoUpdateStatus::Checking, cx);
let checking: Task<Result<Option<Update>, Error>> = cx.background_spawn(async move {
if let Some(update) = check_update(current_version, config)? {
Ok(Some(update))
} else {
Ok(None)
}
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(update)) = checking.await {
this.update_in(cx, |this, window, cx| {
this.set_status(AutoUpdateStatus::checked(update), cx);
this.install_update(window, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
})
.ok();
}
})
.detach();
}
pub(crate) fn install_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_status(AutoUpdateStatus::Installing, cx);
if let AutoUpdateStatus::Checked { update } = self.status.clone() {
let install: Task<Result<(), Error>> =
cx.background_spawn(async move { Ok(update.download_and_install()?) });
cx.spawn_in(window, async move |this, cx| {
match install.await {
Ok(_) => {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Updated, cx);
})
.ok();
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
} }
} }
@@ -154,4 +228,258 @@ impl AutoUpdater {
self.status = status; self.status = status;
cx.notify(); 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);
// TODO
})
}
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());
// TODO
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"

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

@@ -0,0 +1,627 @@
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 fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, DEVICE_GIFTWRAP, USER_GIFTWRAP};
mod message;
mod room;
pub use message::*;
pub use room::*;
pub fn init(window: &mut Window, cx: &mut App) {
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), 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 Signal {
/// Message received from relay pool
Message(NewMessage),
/// Eose received from relay pool
Eose,
}
/// Chat Registry
#[derive(Debug)]
pub struct ChatRegistry {
/// Collection of all chat rooms
rooms: Vec<Entity<Room>>,
/// Tracking the status of unwrapping gift wrap events.
tracking_flag: Arc<AtomicBool>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
/// 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(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let nip65 = nostr.read(cx).nip65_state();
let nip17 = nostr.read(cx).nip17_state();
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe the nip65 state and load chat rooms on every state change
cx.observe(&nip65, |this, state, cx| {
if state.read(cx).idle() {
this.reset(cx);
}
}),
);
subscriptions.push(
// Observe the nip17 state and load chat rooms on every state change
cx.observe(&nip17, |this, _state, cx| {
this.get_rooms(cx);
}),
);
cx.defer_in(window, |this, _window, cx| {
this.handle_notifications(cx);
this.tracking(cx);
});
Self {
rooms: vec![],
tracking_flag: Arc::new(AtomicBool::new(false)),
tasks: smallvec![],
_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 signer = nostr.read(cx).signer();
let status = self.tracking_flag.clone();
let initialized_at = Timestamp::now();
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Signal>(1024);
self.tasks.push(cx.background_spawn(async move {
let device_signer = signer.get_encryption_signer().await;
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
while let Some(notification) = notifications.next().await {
let ClientNotification::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;
}
log::info!("Received gift wrap event: {:?}", event);
// 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 => {
let new_message = NewMessage::new(event.id, rumor);
let signal = Signal::Message(new_message);
tx.send_async(signal).await?;
}
false => {
status.store(true, Ordering::Release);
}
},
Err(e) => {
log::warn!("Failed to unwrap the gift wrap event: {e}");
}
}
}
RelayMessage::EndOfStoredEvents(id) => {
if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 {
tx.send_async(Signal::Eose).await?;
}
}
_ => {}
}
}
Ok(())
}));
self.tasks.push(cx.spawn(async move |this, cx| {
while let Ok(message) = rx.recv_async().await {
match message {
Signal::Message(message) => {
this.update(cx, |this, cx| {
this.new_message(message, cx);
})?;
}
Signal::Eose => {
this.update(cx, |this, cx| {
this.get_rooms(cx);
})?;
}
};
}
Ok(())
}));
}
/// Tracking the status of unwrapping gift wrap events.
fn tracking(&mut self, cx: &mut Context<Self>) {
let status = self.tracking_flag.clone();
self.tasks.push(cx.background_spawn(async move {
let loop_duration = Duration::from_secs(10);
loop {
if status.load(Ordering::Acquire) {
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
}
smol::Timer::after(loop_duration).await;
}
}));
}
/// Get the loading status of the chat registry
pub fn loading(&self) -> bool {
self.tracking_flag.load(Ordering::Acquire)
}
/// 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 rooms based on the filter.
pub fn rooms(&self, filter: &RoomKind, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| &room.read(cx).kind == filter)
.cloned()
.collect()
}
/// Count the number of rooms based on the filter.
pub fn count(&self, filter: &RoomKind, cx: &App) -> usize {
self.rooms
.iter()
.filter(|room| &room.read(cx).kind == filter)
.count()
}
/// 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> + 'static,
{
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.spawn(async move |this, cx| {
let signer = client.signer()?;
let public_key = signer.get_public_key().await.ok()?;
let room: Room = room.into().organize(&public_key);
this.update(cx, |this, cx| {
this.rooms.insert(0, cx.new(|_| room));
cx.emit(ChatEvent::Ping);
cx.notify();
})
.ok()
})
.detach();
}
/// Emit an open room event.
///
/// If the room is new, add it to the registry.
pub fn emit_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
// Get the room's ID.
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.to_owned());
}
// 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();
}
/// Finding rooms based on a query.
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
let matcher = SkimMatcherV2::default();
if let Ok(public_key) = PublicKey::parse(query) {
self.rooms
.iter()
.filter(|room| room.read(cx).members.contains(&public_key))
.cloned()
.collect()
} else {
self.rooms
.iter()
.filter(|room| {
matcher
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
.is_some()
})
.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.get_rooms_from_database(cx);
cx.spawn(async move |this, cx| {
let rooms = task.await.ok()?;
this.update(cx, move |this, cx| {
this.extend_rooms(rooms, cx);
this.sort(cx);
})
.ok()
})
.detach();
}
/// Create a task to load rooms from the database
fn get_rooms_from_database(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// Get contacts
let contacts = client.database().contacts_public_keys(public_key).await?;
// Construct authored filter
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?;
// Construct addressed filter
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);
// Collect results
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));
// Always use the latest message
let Some(latest) = messages.first() else {
continue;
};
// Construct the room from the latest message.
//
// Call `.organize` to ensure the current user is at the end of the list.
let mut room = Room::from(latest).organize(&public_key);
// Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// Check if public keys are from the user's contacts
let is_contact = room.members.iter().any(|k| contacts.contains(k));
// Set the room's kind based on status
if user_sent || is_contact {
room = room.kind(RoomKind::Ongoing);
}
rooms.insert(room);
}
Ok(rooms)
})
}
/// Parse a nostr event into a 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>) {
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
Some(room) => {
room.update(cx, |this, cx| {
this.push_message(message, cx);
});
}
None => {
// Push the new room to the front of the list
self.add_room(message.rumor, cx);
}
}
}
/// 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);
});
}
}
}
}
/// 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> {
// Try with the device signer first
if let Some(signer) = device_signer {
if let Ok(unwrapped) = Self::try_unwrap_with(gift_wrap, signer).await {
return Ok(unwrapped);
};
};
// Try with the user's signer
let user_signer = client.signer().context("Signer not found")?;
let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?;
Ok(unwrapped)
}
/// Attempts to unwrap a gift wrap event with a given signer.
async fn try_unwrap_with(
gift_wrap: &Event,
signer: &Arc<dyn NostrSigner>,
) -> Result<UnwrappedGift, Error> {
// Get the sealed event
let seal = signer
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
.await?;
// Verify the sealed event
let seal: Event = Event::from_json(seal)?;
seal.verify_with_ctx(&SECP256K1)?;
// Get the rumor event
let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?;
let rumor = UnsignedEvent::from_json(rumor)?;
Ok(UnwrappedGift {
sender: seal.pubkey,
rumor,
})
}
/// 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()
}
}

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

@@ -0,0 +1,220 @@
use std::hash::Hash;
use common::EventUtils;
use nostr_sdk::prelude::*;
/// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage {
pub room: u64,
pub gift_wrap: EventId,
pub rumor: UnsignedEvent,
}
impl NewMessage {
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
let room = rumor.uniq_id();
Self {
room,
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
}

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

@@ -0,0 +1,783 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error};
use common::EventUtils;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT};
use crate::{ChatRegistry, NewMessage};
#[derive(Debug, Clone)]
pub struct SendReport {
pub receiver: PublicKey,
pub gift_wrap_id: Option<EventId>,
pub error: Option<SharedString>,
pub output: Option<Output<EventId>>,
}
impl SendReport {
pub fn new(receiver: PublicKey) -> Self {
Self {
receiver,
gift_wrap_id: None,
error: None,
output: None,
}
}
/// Set the gift wrap ID.
pub fn gift_wrap_id(mut self, gift_wrap_id: EventId) -> Self {
self.gift_wrap_id = Some(gift_wrap_id);
self
}
/// Set the output.
pub fn output(mut self, output: Output<EventId>) -> Self {
self.output = Some(output);
self
}
/// Set the error message.
pub fn error<T>(mut self, error: T) -> Self
where
T: Into<SharedString>,
{
self.error = Some(error.into());
self
}
/// Returns true if the send is pending.
pub fn pending(&self) -> bool {
self.output.is_none() && self.error.is_none()
}
/// Returns true if the send was successful.
pub fn success(&self) -> bool {
if let Some(output) = self.output.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, Clone)]
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(super) members: Vec<PublicKey>,
/// Kind
pub kind: RoomKind,
/// Configuration
config: RoomConfig,
}
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;
let members = val.extract_public_keys();
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(),
config: RoomConfig::default(),
}
}
}
impl From<UnsignedEvent> for Room {
fn from(val: UnsignedEvent) -> Self {
Room::from(&val)
}
}
impl Room {
/// Constructs a new room with the given receiver and tags.
pub fn new<T>(author: PublicKey, receivers: T) -> Self
where
T: IntoIterator<Item = PublicKey>,
{
// Map receiver public keys to tags
let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect());
// Construct an unsigned event for a direct message
//
// WARNING: never sign this event
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
.tags(tags)
.build(author);
// Ensure that the ID is set
event.ensure_id();
Room::from(&event)
}
/// Organizes the members of the room by moving the target member to the end.
///
/// Always call this function to ensure the current user is at the end of the list.
pub fn organize(mut self, target: &PublicKey) -> Self {
if let Some(index) = self.members.iter().position(|member| member == target) {
let member = self.members.remove(index);
self.members.push(member);
}
self
}
/// 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()
}
/// 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);
persons.read(cx).get(&self.members[0], 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() > 3 {
name = format!("{}, +{}", name, profiles.len() - 2);
}
SharedString::from(name)
} else {
self.display_member(cx).name()
}
}
/// Push a new message to the current room
pub fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let created_at = message.rumor.created_at;
let new_message = created_at > self.created_at;
// Emit the incoming message event
cx.emit(RoomEvent::Incoming(message));
if new_message {
self.set_created_at(created_at, cx);
// Sort chats after emitting a new message
ChatRegistry::global(cx).update(cx, |this, cx| {
this.sort(cx);
});
}
}
/// 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 early_connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members();
let subscription_id = SubscriptionId::new(format!("room-{}", self.id));
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
for member in members.into_iter() {
if member == public_key {
continue;
};
// Construct a filter for messaging relays
let inbox = Filter::new()
.kind(Kind::InboxRelays)
.author(member)
.limit(1);
// Construct a filter for announcement
let announcement = Filter::new()
.kind(Kind::Custom(10044))
.author(member)
.limit(1);
// Subscribe to get member's gossip relays
client
.subscribe(vec![inbox, announcement])
.with_id(subscription_id.clone())
.close_on(
SubscribeAutoCloseOptions::default()
.timeout(Some(Duration::from_secs(TIMEOUT)))
.exit_policy(ReqExitPolicy::ExitOnEOSE),
)
.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)
})
}
// Construct a rumor event for direct message
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
where
S: Into<String>,
I: IntoIterator<Item = EventId>,
{
let kind = Kind::PrivateDirectMessage;
let content: String = content.into();
let replies: Vec<EventId> = replies.into_iter().collect();
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
// Get current user's public key
let sender = nostr.read(cx).signer().public_key()?;
// Get all members
let members: Vec<Person> = self
.members
.iter()
.filter(|public_key| public_key != &&sender)
.map(|member| persons.read(cx).get(member, cx))
.collect();
// Construct event's tags
let mut tags = vec![];
// Add subject tag if present
if let Some(value) = self.subject.as_ref() {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
// Add all reply tags
for id in replies.into_iter() {
tags.push(Tag::event(id))
}
// Add all receiver tags
for member in members.into_iter() {
// Skip current user
if member.public_key() == sender {
continue;
}
tags.push(Tag::from_standardized_without_cell(
TagStandard::PublicKey {
public_key: member.public_key(),
relay_url: member.messaging_relay_hint(),
alias: None,
uppercase: false,
},
));
}
// Construct a direct message rumor event
// WARNING: never sign and send this event to relays
let mut event = EventBuilder::new(kind, content).tags(tags).build(sender);
// Ensure that the ID is set
event.ensure_id();
Some(event)
}
/// Send rumor event to all members's messaging relays
pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option<Task<Vec<SendReport>>> {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get room's config
let config = self.config.clone();
// Get current user's public key
let sender = nostr.read(cx).signer().public_key()?;
// Get all members (excluding sender)
let members: Vec<Person> = self
.members
.iter()
.filter(|public_key| public_key != &&sender)
.map(|member| persons.read(cx).get(member, cx))
.collect();
Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind();
let user_signer = signer.get().await;
let encryption_signer = signer.get_encryption_signer().await;
let mut reports = Vec::new();
for member in members {
let relays = member.messaging_relays();
let announcement = member.announcement();
// Skip if member has no messaging relays
if relays.is_empty() {
reports.push(SendReport::new(member.public_key()).error("No messaging relays"));
continue;
}
// Ensure relay connections
for url in relays.iter() {
client
.add_relay(url)
.and_connect()
.capabilities(RelayCapabilities::GOSSIP)
.await
.ok();
}
// When forced to use encryption signer, skip if receiver has no announcement
if signer_kind.encryption() && announcement.is_none() {
reports
.push(SendReport::new(member.public_key()).error("Encryption not found"));
continue;
}
// Determine receiver and signer based on signer kind
let (receiver, signer_to_use) = match signer_kind {
SignerKind::Auto => {
if let Some(announcement) = announcement {
if let Some(enc_signer) = encryption_signer.as_ref() {
(announcement.public_key(), enc_signer.clone())
} else {
(member.public_key(), user_signer.clone())
}
} else {
(member.public_key(), user_signer.clone())
}
}
SignerKind::Encryption => {
let Some(encryption_signer) = encryption_signer.as_ref() else {
reports.push(
SendReport::new(member.public_key()).error("Encryption not found"),
);
continue;
};
let Some(announcement) = announcement else {
reports.push(
SendReport::new(member.public_key())
.error("Announcement not found"),
);
continue;
};
(announcement.public_key(), encryption_signer.clone())
}
SignerKind::User => (member.public_key(), user_signer.clone()),
};
// Create and send gift-wrapped event
match EventBuilder::gift_wrap(&signer_to_use, &receiver, rumor.clone(), []).await {
Ok(event) => {
match client
.send_event(&event)
.to(relays)
.ack_policy(AckPolicy::none())
.await
{
Ok(output) => {
reports.push(
SendReport::new(member.public_key())
.gift_wrap_id(event.id)
.output(output),
);
}
Err(e) => {
reports.push(
SendReport::new(member.public_key()).error(e.to_string()),
);
}
}
}
Err(e) => {
reports.push(SendReport::new(member.public_key()).error(e.to_string()));
}
}
}
reports
}))
}
/*
* /// Create a new unsigned message event
pub fn create_message(
&self,
content: &str,
replies: Vec<EventId>,
cx: &App,
) -> Task<Result<UnsignedEvent, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let subject = self.subject.clone();
let content = content.to_string();
let mut member_and_relay_hints = HashMap::new();
// Populate the hashmap with member and relay hint tasks
for member in self.members.iter() {
let hint = nostr.read(cx).relay_hint(member, cx);
member_and_relay_hints.insert(member.to_owned(), hint);
}
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let public_key = signer.get_public_key().await?;
// List of event tags for each receiver
let mut tags = vec![];
for (member, task) in member_and_relay_hints.into_iter() {
// Skip current user
if member == public_key {
continue;
}
// Get relay hint if available
let relay_url = task.await;
// Construct a public key tag with relay hint
let tag = TagStandard::PublicKey {
public_key: member,
relay_url,
alias: None,
uppercase: false,
};
tags.push(Tag::from_standardized_without_cell(tag));
}
// Add subject tag if present
if let Some(value) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
value.to_string(),
)));
}
// Add all reply tags
for id in replies {
tags.push(Tag::event(id))
}
// 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();
Ok(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();
let mut members = self.members();
let rumor = rumor.to_owned();
cx.background_spawn(async move {
let signer = client.signer().context("Signer not found")?;
let current_user = signer.get_public_key().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 in members.into_iter() {
// 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(&event).to_nip17().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()) {
// Send the event to the messaging relays
match client.send_event(&event).to_nip17().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.relay(url).await?.context("Relay not found")?;
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" }
dock = { path = "../dock" }
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
flume.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
once_cell = "1.19.0"
regex = "1"

View File

@@ -0,0 +1,24 @@
use gpui::Action;
use nostr_sdk::prelude::*;
use serde::Deserialize;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub enum Command {
Insert(&'static str),
ChangeSubject(&'static str),
}
#[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);

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

View File

@@ -1,22 +1,20 @@
use std::ops::Range; use std::ops::Range;
use std::sync::Arc; use std::sync::Arc;
use common::display::ReadableProfile;
use gpui::{ use gpui::{
AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement, AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
SharedString, StyledText, UnderlineStyle, Window, StyledText, UnderlineStyle, Window,
}; };
use linkify::{LinkFinder, LinkKind};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use person::PersonRegistry;
use regex::Regex; use regex::Regex;
use registry::Registry;
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::actions::OpenProfile; use crate::actions::OpenPublicKey;
static URL_REGEX: Lazy<Regex> = Lazy::new(|| { static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?:[a-zA-Z]+://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(/.*)?$").unwrap() 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> = static NOSTR_URI_REGEX: Lazy<Regex> =
@@ -24,43 +22,16 @@ static NOSTR_URI_REGEX: Lazy<Regex> =
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight { pub enum Highlight {
Link(HighlightStyle), Link,
Nostr, Nostr,
} }
impl Highlight {
fn link() -> Self {
Self::Link(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
})
}
fn nostr() -> Self {
Self::Nostr
}
}
impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Link(style)
}
}
type CustomRangeTooltipFn =
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
#[derive(Default)] #[derive(Default)]
pub struct RenderedText { pub struct RenderedText {
pub text: SharedString, pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>, pub highlights: Vec<(Range<usize>, Highlight)>,
pub link_ranges: Vec<Range<usize>>, pub link_ranges: Vec<Range<usize>>,
pub link_urls: Arc<[String]>, pub link_urls: Arc<[String]>,
pub custom_ranges: Vec<Range<usize>>,
custom_ranges_tooltip_fn: CustomRangeTooltipFn,
} }
impl RenderedText { impl RenderedText {
@@ -86,18 +57,9 @@ impl RenderedText {
link_urls: link_urls.into(), link_urls: link_urls.into(),
link_ranges, link_ranges,
highlights, highlights,
custom_ranges: Vec::new(),
custom_ranges_tooltip_fn: None,
} }
} }
pub fn set_tooltip_builder_for_custom_ranges<F>(&mut self, f: F)
where
F: Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
{
self.custom_ranges_tooltip_fn = Some(Arc::new(f));
}
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement { pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let link_color = cx.theme().text_accent; let link_color = cx.theme().text_accent;
@@ -109,17 +71,11 @@ impl RenderedText {
( (
range.clone(), range.clone(),
match highlight { match highlight {
Highlight::Link(highlight) => { Highlight::Link => HighlightStyle {
// Check if this is a link highlight by seeing if it has an underline color: Some(link_color),
if highlight.underline.is_some() { underline: Some(UnderlineStyle::default()),
// It's a link, so apply the link color ..Default::default()
let mut link_style = *highlight; },
link_style.color = Some(link_color);
link_style
} else {
*highlight
}
}
Highlight::Nostr => HighlightStyle { Highlight::Nostr => HighlightStyle {
color: Some(link_color), color: Some(link_color),
..Default::default() ..Default::default()
@@ -134,49 +90,22 @@ impl RenderedText {
move |ix, window, cx| { move |ix, window, cx| {
let token = link_urls[ix].as_str(); let token = link_urls[ix].as_str();
if token.starts_with("nostr:") { if let Some(clean_url) = token.strip_prefix("nostr:") {
let clean_url = token.replace("nostr:", ""); if let Ok(public_key) = PublicKey::parse(clean_url) {
let Ok(public_key) = PublicKey::parse(&clean_url) else { window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
log::error!("Failed to parse public key from: {clean_url}");
return;
};
window.dispatch_action(Box::new(OpenProfile(public_key)), cx);
} else if is_url(token) {
if !token.starts_with("http") {
cx.open_url(&format!("https://{token}"));
} else {
cx.open_url(token);
} }
} else if is_url(token) {
let url = if token.starts_with("http") {
token.to_string()
} else {
format!("https://{token}")
};
cx.open_url(&url);
} else { } else {
log::warn!("Unrecognized token {token}") log::warn!("Unrecognized token {token}")
} }
} }
}) })
.tooltip({
let link_ranges = self.link_ranges.clone();
let link_urls = self.link_urls.clone();
let custom_tooltip_ranges = self.custom_ranges.clone();
let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone();
move |idx, window, cx| {
for (ix, range) in link_ranges.iter().enumerate() {
if range.contains(&idx) {
let url = &link_urls[ix];
if url.starts_with("http") {
// return Some(LinkPreview::new(url, cx));
}
// You can add custom tooltip handling for mentions here
}
}
for range in &custom_tooltip_ranges {
if range.contains(&idx) {
if let Some(f) = &custom_tooltip_fn {
return f(idx, range.clone(), window, cx);
}
}
}
None
}
})
.into_any_element() .into_any_element()
} }
} }
@@ -192,18 +121,11 @@ fn render_plain_text_mut(
// Copy the content directly // Copy the content directly
text.push_str(content); text.push_str(content);
// Initialize the link finder
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
finder.kinds(&[LinkKind::Url]);
// Collect all URLs // Collect all URLs
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new(); let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
for link in finder.links(content) { for link in URL_REGEX.find_iter(content) {
let start = link.start(); let range = link.start()..link.end();
let end = link.end();
let range = start..end;
let url = link.as_str().to_string(); let url = link.as_str().to_string();
url_matches.push((range, url)); url_matches.push((range, url));
@@ -213,9 +135,7 @@ fn render_plain_text_mut(
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new(); let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
for nostr_match in NOSTR_URI_REGEX.find_iter(content) { for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
let start = nostr_match.start(); let range = nostr_match.start()..nostr_match.end();
let end = nostr_match.end();
let range = start..end;
let nostr_uri = nostr_match.as_str().to_string(); let nostr_uri = nostr_match.as_str().to_string();
// Check if this nostr URI overlaps with any already processed URL // Check if this nostr URI overlaps with any already processed URL
@@ -239,12 +159,9 @@ fn render_plain_text_mut(
for (range, entity) in all_matches { for (range, entity) in all_matches {
// Handle URL token // Handle URL token
if is_url(&entity) { if is_url(&entity) {
// Add underline highlight highlights.push((range.clone(), Highlight::Link));
highlights.push((range.clone(), Highlight::link()));
// Make it clickable
link_ranges.push(range); link_ranges.push(range);
link_urls.push(entity); link_urls.push(entity);
continue; continue;
}; };
@@ -305,75 +222,6 @@ fn render_plain_text_mut(
} }
} }
} }
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 registry = Registry::read_global(cx);
let profile = registry.get_person(&public_key, cx);
let display_name = format!("@{}", profile.display_name());
// Replace token with display name
text.replace_range(range.clone(), &display_name);
// Adjust ranges
let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add highlight for the profile name
highlights.push((new_range.clone(), Highlight::nostr()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
// Adjust subsequent ranges if needed
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}");
// Create a shortened display format for the URL
let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}");
// Replace the original entity with the shortened display version
text.replace_range(range.clone(), &display_text);
// Adjust the ranges
let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement
let new_range = range.start..(range.start + new_length);
// Add underline highlight
highlights.push((new_range.clone(), Highlight::link()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(njump_url);
// Adjust subsequent ranges if needed
if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff);
}
}
} }
/// Check if a string is a URL /// Check if a string is a URL
@@ -395,6 +243,61 @@ fn format_shortened_entity(entity: &str) -> 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 // Helper function to adjust ranges when text length changes
fn adjust_ranges( fn adjust_ranges(
highlights: &mut [(Range<usize>, Highlight)], highlights: &mut [(Range<usize>, Highlight)],

View File

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

View File

@@ -5,12 +5,10 @@ edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
global = { path = "../global" }
gpui.workspace = true gpui.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
nostr.workspace = true nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
itertools.workspace = true itertools.workspace = true
chrono.workspace = true chrono.workspace = true
@@ -19,6 +17,6 @@ smol.workspace = true
futures.workspace = true futures.workspace = true
reqwest.workspace = true reqwest.workspace = true
log.workspace = true log.workspace = true
webbrowser.workspace = true
dirs = "5.0"
qrcode = "0.14.1" qrcode = "0.14.1"

View File

@@ -2,8 +2,7 @@ use std::sync::Arc;
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Error};
use chrono::{Local, TimeZone}; use chrono::{Local, TimeZone};
use global::constants::IMAGE_RESIZE_SERVICE; use gpui::{Image, ImageFormat, SharedString};
use gpui::{Image, ImageFormat};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use qrcode::render::svg; use qrcode::render::svg;
use qrcode::QrCode; use qrcode::QrCode;
@@ -13,89 +12,51 @@ const SECONDS_IN_MINUTE: i64 = 60;
const MINUTES_IN_HOUR: i64 = 60; const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24; const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30; const DAYS_IN_MONTH: i64 = 30;
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
pub trait ReadableProfile { pub trait RenderedTimestamp {
fn avatar_url(&self, proxy: bool) -> String; fn to_human_time(&self) -> SharedString;
fn display_name(&self) -> String; fn to_ago(&self) -> SharedString;
} }
impl ReadableProfile for Profile { impl RenderedTimestamp for Timestamp {
fn avatar_url(&self, proxy: bool) -> String { fn to_human_time(&self) -> SharedString {
self.metadata() let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
if proxy {
format!(
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
)
} else {
picture.into()
}
})
.unwrap_or_else(|| "brand/avatar.png".into())
}
fn display_name(&self) -> String {
if let Some(display_name) = self.metadata().display_name.as_ref() {
if !display_name.is_empty() {
return display_name.into();
}
}
if let Some(name) = self.metadata().name.as_ref() {
if !name.is_empty() {
return name.into();
}
}
shorten_pubkey(self.public_key(), 4)
}
}
pub trait ReadableTimestamp {
fn to_human_time(&self) -> String;
fn to_ago(&self) -> String;
}
impl ReadableTimestamp for Timestamp {
fn to_human_time(&self) -> String {
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
chrono::LocalResult::Single(time) => time, chrono::LocalResult::Single(time) => time,
_ => return "9999".into(), _ => return SharedString::from("9999"),
}; };
let now = Local::now(); let now = Local::now();
let input_date = input_time.date_naive(); let input_date = input_time.date_naive();
let now_date = now.date_naive(); let now_date = now.date_naive();
let yesterday_date = (now - chrono::Duration::days(1)).date_naive(); let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
let time_format = input_time.format("%H:%M %p"); let time_format = input_time.format("%H:%M %p");
match input_date { match input_date {
date if date == now_date => format!("Today at {time_format}"), date if date == now_date => SharedString::from(format!("Today at {time_format}")),
date if date == yesterday_date => format!("Yesterday at {time_format}"), date if date == yesterday_date => {
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")), SharedString::from(format!("Yesterday at {time_format}"))
}
_ => SharedString::from(format!("{}, {time_format}", input_time.format("%d/%m/%y"))),
} }
} }
fn to_ago(&self) -> String { fn to_ago(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) { let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
chrono::LocalResult::Single(time) => time, chrono::LocalResult::Single(time) => time,
_ => return "1m".into(), _ => return SharedString::from("1m"),
}; };
let now = Local::now(); let now = Local::now();
let duration = now.signed_duration_since(input_time); let duration = now.signed_duration_since(input_time);
match duration { match duration {
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(), d if d.num_seconds() < SECONDS_IN_MINUTE => SharedString::from(NOW),
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()), d if d.num_minutes() < MINUTES_IN_HOUR => {
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()), SharedString::from(format!("{}m", d.num_minutes()))
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()), }
_ => input_time.format("%b %d").to_string(), d if d.num_hours() < HOURS_IN_DAY => SharedString::from(format!("{}h", d.num_hours())),
d if d.num_days() < DAYS_IN_MONTH => SharedString::from(format!("{}d", d.num_days())),
_ => SharedString::from(input_time.format("%b %d").to_string()),
} }
} }
} }

View File

@@ -1,4 +1,3 @@
use std::collections::HashSet;
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use itertools::Itertools; use itertools::Itertools;
@@ -6,11 +5,27 @@ use nostr_sdk::prelude::*;
pub trait EventUtils { pub trait EventUtils {
fn uniq_id(&self) -> u64; fn uniq_id(&self) -> u64;
fn all_pubkeys(&self) -> Vec<PublicKey>; fn extract_public_keys(&self) -> Vec<PublicKey>;
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool;
} }
impl EventUtils for Event { impl EventUtils for Event {
fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
pubkeys.sort();
pubkeys.hash(&mut hasher);
hasher.finish()
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
public_keys.push(self.pubkey);
public_keys.into_iter().unique().collect()
}
}
impl EventUtils for UnsignedEvent {
fn uniq_id(&self) -> u64 { fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = vec![]; let mut pubkeys: Vec<PublicKey> = vec![];
@@ -30,18 +45,9 @@ impl EventUtils for Event {
hasher.finish() hasher.finish()
} }
fn all_pubkeys(&self) -> Vec<PublicKey> { fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect(); let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
public_keys.push(self.pubkey); public_keys.push(self.pubkey);
public_keys.into_iter().unique().sorted().collect()
public_keys
}
fn compare_pubkeys(&self, other: &[PublicKey]) -> bool {
let pubkeys = self.all_pubkeys();
let a: HashSet<_> = pubkeys.iter().collect();
let b: HashSet<_> = other.iter().collect();
a == b
} }
} }

View File

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

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