Compare commits

..

9 Commits

Author SHA1 Message Date
Ren Amamiya
025562f9f0 Merge pull request #61 from luminous-devs/main
v1.1.1
2023-07-27 09:26:26 +07:00
Ren Amamiya
595bcc9b3c Merge pull request #59 from luminous-devs/main
v1.1.0
2023-07-26 09:27:39 +07:00
Ren Amamiya
15991d07ab Merge pull request #53 from luminous-devs/main
update set password flow
2023-07-10 17:20:26 +07:00
Ren Amamiya
99fc1f0b10 Merge pull request #52 from luminous-devs/main
update gh action and fix migrate page
2023-07-10 15:34:03 +07:00
Ren Amamiya
1041e1ccd4 Merge pull request #50 from luminous-devs/main
v1.0.1
2023-07-10 14:40:17 +07:00
Ren Amamiya
2eeb2c896d Merge pull request #45 from luminous-devs/main
rebuild with code signing
2023-07-07 12:24:08 +07:00
Ren Amamiya
caf8fb584a Merge pull request #33 from luminous-devs/main
v1.0.0
2023-07-06 08:18:01 +07:00
Ren Amamiya
58205713ab Merge pull request #32 from luminous-devs/main
test github action again
2023-07-05 17:40:35 +07:00
Ren Amamiya
33802d32f3 Merge pull request #31 from luminous-devs/main
test github action
2023-07-05 09:50:57 +07:00
327 changed files with 28903 additions and 12228 deletions

49
.eslintrc.js Normal file
View File

@@ -0,0 +1,49 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
env: {
browser: true,
amd: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:jsx-a11y/recommended',
'prettier'
],
plugins: [],
rules: {
'react/react-in-jsx-scope': 'off',
'jsx-a11y/accessible-emoji': 'off',
'react/prop-types': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'jsx-a11y/anchor-is-valid': [
'error',
{
components: ['Link'],
specialLink: ['hrefLeft', 'hrefRight'],
aspects: ['invalidHref', 'preferButton'],
},
],
},
};

71
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: 'publish'
on:
push:
branches:
- release
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
jobs:
publish-tauri:
strategy:
fail-fast: false
matrix:
settings:
- platform: 'macos-latest'
args: '--target universal-apple-darwin'
- platform: 'ubuntu-20.04'
args: ''
- platform: 'windows-latest'
args: '--target x86_64-pc-windows-msvc'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v3
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 18
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
- name: install dependencies (ubuntu only)
if: matrix.settings.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 7.x.x
run_install: false
- name: Setup node and cache for package data
uses: actions/setup-node@v3
with:
node-version: 'lts/*'
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- run: pnpm install
- uses: tauri-apps/tauri-action@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false
args: ${{ matrix.settings.args }}

48
.gitignore vendored
View File

@@ -1,23 +1,31 @@
# Generated by Cargo # Logs
# will have compiled files and executables logs
debug/ *.log
target/ npm-debug.log*
dist/ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# These are backup files generated by rustfmt node_modules
**/*.rs.bk dist
dist-ssr
out
*.local
.next
.vscode
pnpm-lock.yaml
*.db
*.db-journal
# MSVC Windows builds of rustc generate these, which store debugging information # Editor directories and files
*.pdb .vscode/*
!.vscode/extensions.json
# RustRover .idea
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# Useless stuffs
.DS_Store .DS_Store
# Added by goreleaser init: *.suo
.intentionally-empty-file.o *.ntvs*
*.njsproj
*.sln
*.sw?
/.gtm/

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm exec lint-staged

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
.tmp
.cache/
coverage/
.nyc_output/
**/.yarn/**
**/.pnp.*
/dist*/
node_modules/
src-tauri/

22
.prettierrc Normal file
View File

@@ -0,0 +1,22 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"printWidth": 90,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"importOrder": [
"^@app/(.*)$",
"^@libs/(.*)$",
"^@shared/(.*)$",
"^@stores/(.*)$",
"^@utils/(.*)$",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
"pluginSearchDirs": false
}

8846
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +0,0 @@
[workspace]
resolver = "2"
members = ["crates/*"]
default-members = ["crates/lume"]
[workspace.package]
version = "0.0.1"
edition = "2021"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui-component = { git = "https://github.com/longbridge/gpui-component" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "all-nips", "pow-multi-thread" ] }
# Others
anyhow = "1.0.44"
chrono = "0.4.38"
dirs = "5.0"
futures = "0.3"
itertools = "0.13.0"
log = "0.4"
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
flume = { version = "0.12", default-features = false, features = ["async", "select"] }
rust-embed = "8.5.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smallvec = "1.14.0"
smol = "2"
tracing = "0.1.40"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

View File

@@ -1 +1,41 @@
### Rebooting... ### Introduction
Lume is a nostr client
### Usage
Download Lume for your platform here: [https://github.com/luminous-devs/lume/releases](https://github.com/luminous-devs/lume/releases)
Supported platform: macOS, Windows and Linux
### Develop
Clone project
```
git clone https://github.com/luminous-devs/lume.git && cd lume
```
Install packages
```
pnpm install
```
Run dev
```
pnpm tauri dev
```
Build
```
pnpm tauri build
```
(Advance) - Generate SQLite migration
```
pnpm add-migrate <migrate_name>
```

View File

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 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M3 12L21 12M21 12L12.5 3.5M21 12L12.5 20.5" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

Before

Width:  |  Height:  |  Size: 321 B

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M5 13L9 17L19 7" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

Before

Width:  |  Height:  |  Size: 294 B

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M9 6L15 12L9 18" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

Before

Width:  |  Height:  |  Size: 294 B

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M9.17218 14.8284L12.0006 12M14.829 9.17157L12.0006 12M12.0006 12L9.17218 9.17157M12.0006 12L14.829 14.8284" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

Before

Width:  |  Height:  |  Size: 599 B

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M6.75827 17.2426L12.0009 12M17.2435 6.75736L12.0009 12M12.0009 12L6.75827 6.75736M12.0009 12L17.2435 17.2426" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

Before

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

Before

Width:  |  Height:  |  Size: 457 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="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"/>
</svg>

Before

Width:  |  Height:  |  Size: 303 B

View File

@@ -1,19 +0,0 @@
[package]
name = "account"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -1,131 +0,0 @@
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::client;
pub fn init(cx: &mut App) {
Account::set_global(cx.new(Account::new), cx);
}
struct GlobalAccount(Entity<Account>);
impl Global for GlobalAccount {}
pub struct Account {
/// Public Key of the account
public_key: Option<PublicKey>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
}
impl Account {
/// Retrieve the global account state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAccount>().0.clone()
}
/// Check if the global account state exists
pub fn has_global(cx: &App) -> bool {
cx.has_global::<GlobalAccount>()
}
/// Remove the global account state
pub fn remove_global(cx: &mut App) {
cx.remove_global::<GlobalAccount>();
}
/// Set the global account instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAccount(state));
}
/// Create a new account instance
fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(
// Listen for public key set
cx.observe_self(move |this, cx| {
if let Some(public_key) = this.public_key {
this.init(public_key, cx);
}
}),
);
Self {
public_key: None,
_subscriptions: subscriptions,
_tasks: smallvec![],
}
}
fn init(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let client = client();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
// Construct a filter to get the user's metadata
let filter = Filter::new()
.kind(Kind::Metadata)
.author(public_key)
.limit(1);
// Subscribe to the user metadata
client.subscribe(filter, Some(opts)).await?;
// Construct a filter to get the user's contact list
let filter = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
// Subscribe to the user's contact list
client.subscribe(filter, Some(opts)).await?;
// Construct a filter to get the user's other metadata
let filter = Filter::new()
.kinds(vec![
Kind::MuteList,
Kind::Bookmarks,
Kind::BookmarkSet,
Kind::SearchRelays,
Kind::BlockedRelays,
Kind::RelaySet,
Kind::Custom(10012),
])
.author(public_key)
.limit(24);
// Subscribe to the user's other metadata
client.subscribe(filter, Some(opts)).await?;
log::info!("Subscribed to user metadata");
Ok(())
});
self._tasks.push(task);
}
/// Set the public key of the account
pub fn set_public_key(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
self.public_key = Some(public_key);
cx.notify();
}
/// Check if the account entity has a public key
pub fn has_account(&self) -> bool {
self.public_key.is_some()
}
/// Get the public key of the account
pub fn public_key(&self) -> PublicKey {
// This method is only called when user is logged in, so unwrap safely
self.public_key.unwrap()
}
}

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
[package]
name = "common"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
chrono.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
reqwest.workspace = true
log.workspace = true
dirs = "5.0"
nostr = { git = "https://github.com/rust-nostr/nostr" }

View File

@@ -1,23 +0,0 @@
/// Client (or application) name.
pub const CLIENT_NAME: &str = "Lume";
/// Application ID.
pub const APP_ID: &str = "su.reya.lume";
/// Bootstrap Relays.
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nos.social",
"wss://user.kindpag.es",
"wss://purplepag.es",
];
/// Default relay for Nostr Connect
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default timeout for Nostr Connect (seconds)
pub const NOSTR_CONNECT_TIMEOUT: u64 = 30;
/// Default width of the sidebar.
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;

View File

@@ -1,7 +0,0 @@
pub use constants::*;
pub use paths::*;
pub use utils::*;
mod constants;
mod paths;
mod utils;

View File

@@ -1,64 +0,0 @@
use std::path::PathBuf;
use std::sync::OnceLock;
/// Returns the path to the user's home directory.
pub fn home_dir() -> &'static PathBuf {
static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
}
/// Returns the path to the configuration directory used by Lume.
pub fn config_dir() -> &'static PathBuf {
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
CONFIG_DIR.get_or_init(|| {
if cfg!(target_os = "windows") {
return dirs::config_dir()
.expect("failed to determine RoamingAppData directory")
.join("Lume");
}
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
return if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") {
flatpak_xdg_config.into()
} else {
dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory")
}
.join("lume");
}
home_dir().join(".config").join("lume")
})
}
/// Returns the path to the support directory used by Lume.
pub fn support_dir() -> &'static PathBuf {
static SUPPORT_DIR: OnceLock<PathBuf> = OnceLock::new();
SUPPORT_DIR.get_or_init(|| {
if cfg!(target_os = "macos") {
return home_dir().join("Library/Application Support/Lume");
}
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
return if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
flatpak_xdg_data.into()
} else {
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
}
.join("lume");
}
if cfg!(target_os = "windows") {
return dirs::data_local_dir()
.expect("failed to determine LocalAppData directory")
.join("lume");
}
config_dir().clone()
})
}
/// Returns the path to the `nostr` file.
pub fn nostr_file() -> &'static PathBuf {
static NOSTR_FILE: OnceLock<PathBuf> = OnceLock::new();
NOSTR_FILE.get_or_init(|| support_dir().join("nostr-db"))
}

View File

@@ -1,11 +0,0 @@
use nostr_sdk::prelude::*;
pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String {
let Ok(pubkey) = public_key.to_bech32();
format!(
"{}:{}",
&pubkey[0..(len + 1)],
&pubkey[pubkey.len() - len..]
)
}

View File

@@ -1,38 +0,0 @@
[package]
name = "lume"
version.workspace = true
edition.workspace = true
publish.workspace = true
[[bin]]
name = "lume"
path = "src/main.rs"
[dependencies]
common = { path = "../common" }
assets = { path = "../assets" }
state = { path = "../state" }
account = { path = "../account" }
person = { path = "../person" }
gpui.workspace = true
gpui-component.workspace = true
gpui_tokio.workspace = true
reqwest_client.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
flume.workspace = true
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
qrcode = "0.14.1"
webbrowser = "1.0.6"

View File

@@ -1,44 +0,0 @@
use std::sync::Mutex;
use gpui::{actions, App};
use nostr_connect::prelude::*;
actions!(lume, [Quit, About, Open]);
#[derive(Debug, Clone)]
pub struct LumeAuthUrlHandler;
impl AuthUrlHandler for LumeAuthUrlHandler {
#[allow(mismatched_lifetime_syntaxes)]
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(())
})
}
}
pub fn load_embedded_fonts(cx: &App) {
let asset_source = cx.asset_source();
let font_paths = asset_source.list("fonts").unwrap();
let embedded_fonts = Mutex::new(Vec::new());
let executor = cx.background_executor();
executor.block(executor.scoped(|scope| {
for font_path in &font_paths {
if !font_path.ends_with(".ttf") {
continue;
}
scope.spawn(async {
let font_bytes = asset_source.load(font_path).unwrap().unwrap();
embedded_fonts.lock().unwrap().push(font_bytes);
});
}
}));
cx.text_system()
.add_fonts(embedded_fonts.into_inner().unwrap())
.unwrap();
}

View File

@@ -1,96 +0,0 @@
use std::sync::Arc;
use assets::Assets;
use common::{APP_ID, BOOTSTRAP_RELAYS, CLIENT_NAME};
use gpui::{
point, px, size, AppContext, Application, Bounds, SharedString, TitlebarOptions,
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
};
use gpui_component::Root;
use state::client;
use crate::actions::load_embedded_fonts;
use crate::workspace::Workspace;
mod actions;
mod menus;
mod panels;
mod sidebar;
mod themes;
mod title_bar;
mod workspace;
fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Initialize the Application
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
// Initialize the nostr client
let client = client();
// Establish connection to all bootstrap relays
app.background_executor()
.spawn_with_priority(gpui::Priority::High, async move {
// Add bootstrap relays to the relay pool
for url in BOOTSTRAP_RELAYS {
client.add_relay(url).await.ok();
}
// Connect to all added relays
client.connect().await;
})
.detach();
// Run application
app.run(move |cx| {
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
// Set up the window bounds
let bounds = Bounds::centered(None, size(px(920.0), px(700.0)), cx);
// Set up the window options
let opts = WindowOptions {
window_background: WindowBackgroundAppearance::Opaque,
window_decorations: Some(WindowDecorations::Client),
window_bounds: Some(WindowBounds::Windowed(bounds)),
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(CLIENT_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
..Default::default()
};
// Open a window with default options
cx.open_window(opts, |window, cx| {
// Bring the app to the foreground
cx.activate(true);
// Initialize the tokio runtime
gpui_tokio::init(cx);
// Initialize components
gpui_component::init(cx);
// Initialize themes
themes::init(cx);
// Initialize account
account::init(cx);
// Initialize person registry
person::init(cx);
let workspace = cx.new(|cx| Workspace::new(window, cx));
cx.new(|cx| Root::new(workspace, window, cx))
})
.expect("Failed to open window. Please restart the application.");
})
}

View File

@@ -1,104 +0,0 @@
use gpui::{App, Entity, Menu, MenuItem, SharedString};
use gpui_component::menu::AppMenuBar;
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
use crate::actions::{About, Open, Quit};
use crate::themes::{SwitchTheme, SwitchThemeMode};
pub fn init(title: impl Into<SharedString>, cx: &mut App) -> Entity<AppMenuBar> {
let app_menu_bar = AppMenuBar::new(cx);
let title: SharedString = title.into();
update_app_menu(title.clone(), app_menu_bar.clone(), cx);
// Observe theme changes to update the menu to refresh the checked state
cx.observe_global::<Theme>({
let title = title.clone();
let app_menu_bar = app_menu_bar.clone();
move |cx| {
update_app_menu(title.clone(), app_menu_bar.clone(), cx);
}
})
.detach();
app_menu_bar
}
fn update_app_menu(title: impl Into<SharedString>, app_menu_bar: Entity<AppMenuBar>, cx: &mut App) {
let mode = cx.theme().mode;
cx.set_menus(vec![
Menu {
name: title.into(),
items: vec![
MenuItem::action("About", About),
MenuItem::Separator,
MenuItem::action("Open...", Open),
MenuItem::Separator,
MenuItem::Submenu(Menu {
name: "Appearance".into(),
items: vec![
MenuItem::action("Light", SwitchThemeMode(ThemeMode::Light))
.checked(!mode.is_dark()),
MenuItem::action("Dark", SwitchThemeMode(ThemeMode::Dark))
.checked(mode.is_dark()),
],
}),
theme_menu(cx),
MenuItem::Separator,
MenuItem::action("Quit", Quit),
],
},
Menu {
name: "Edit".into(),
items: vec![
MenuItem::action("Undo", gpui_component::input::Undo),
MenuItem::action("Redo", gpui_component::input::Redo),
MenuItem::separator(),
MenuItem::action("Cut", gpui_component::input::Cut),
MenuItem::action("Copy", gpui_component::input::Copy),
MenuItem::action("Paste", gpui_component::input::Paste),
MenuItem::separator(),
MenuItem::action("Delete", gpui_component::input::Delete),
MenuItem::action(
"Delete Previous Word",
gpui_component::input::DeleteToPreviousWordStart,
),
MenuItem::action(
"Delete Next Word",
gpui_component::input::DeleteToNextWordEnd,
),
MenuItem::separator(),
MenuItem::action("Find", gpui_component::input::Search),
MenuItem::separator(),
MenuItem::action("Select All", gpui_component::input::SelectAll),
],
},
Menu {
name: "Help".into(),
items: vec![MenuItem::action("Open Website", Open)],
},
]);
app_menu_bar.update(cx, |menu_bar, cx| {
menu_bar.reload(cx);
})
}
fn theme_menu(cx: &App) -> MenuItem {
let themes = ThemeRegistry::global(cx).sorted_themes();
let current_name = cx.theme().theme_name();
MenuItem::Submenu(Menu {
name: "Theme".into(),
items: themes
.iter()
.map(|theme| {
let checked = current_name == &theme.name;
MenuItem::action(theme.name.clone(), SwitchTheme(theme.name.clone()))
.checked(checked)
})
.collect(),
})
}

View File

@@ -1,180 +0,0 @@
use std::time::Duration;
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window,
};
use gpui_component::dock::{Panel, PanelEvent};
use gpui_component::{h_flex, v_flex, ActiveTheme};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::client;
/// Feed
pub struct Feed {
focus_handle: FocusHandle,
/// All notes that match the query
notes: Entity<Option<Events>>,
/// Public Key
public_key: Option<PublicKey>,
/// Relay Url
relay_url: Option<RelayUrl>,
/// Async operations
_tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
}
impl Feed {
pub fn new(
public_key: Option<PublicKey>,
relay_url: Option<RelayUrl>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let notes = cx.new(|_| None);
let async_url = relay_url.clone();
let mut tasks = smallvec![];
tasks.push(
// Load newsfeed in the background
cx.spawn_in(window, async move |this, cx| {
let task: Task<Result<Events, Error>> = cx.background_spawn(async move {
let client = client();
let mut filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(20);
if let Some(author) = public_key {
filter = filter.author(author);
};
let events = match async_url {
Some(url) => {
client
.fetch_events_from(vec![url], filter, Duration::from_secs(5))
.await?
}
None => client.fetch_events(filter, Duration::from_secs(5)).await?,
};
Ok(events)
});
if let Ok(events) = task.await {
this.update(cx, |this, cx| {
this.notes.update(cx, |this, cx| {
*this = Some(events);
cx.notify();
});
})
.ok();
}
Ok(())
}),
);
Self {
focus_handle: cx.focus_handle(),
notes,
public_key,
relay_url,
_tasks: tasks,
}
}
}
impl Panel for Feed {
fn panel_name(&self) -> &'static str {
"Feed"
}
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.when_some(self.public_key.as_ref(), |this, public_key| {
let person = PersonRegistry::global(cx);
let profile = person.read(cx).get(public_key, cx);
this.child(
h_flex()
.gap_1()
.when_some(profile.metadata().picture.as_ref(), |this, url| {
this.child(img(SharedString::from(url)).size_4().rounded_full())
})
.child(SharedString::from(profile.name())),
)
})
.when_some(self.relay_url.as_ref(), |this, url| {
this.child(SharedString::from(url.to_string()))
})
}
}
impl EventEmitter<PanelEvent> for Feed {}
impl Focusable for Feed {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Feed {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let person = PersonRegistry::global(cx);
v_flex()
.p_2()
.gap_3()
.when_some(self.notes.read(cx).as_ref(), |this, notes| {
this.children({
let mut items = Vec::with_capacity(notes.len());
for note in notes.iter() {
let profile = person.read(cx).get(&note.pubkey, cx);
items.push(
v_flex()
.w_full()
.gap_2()
.child(
h_flex()
.w_full()
.items_center()
.justify_between()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(
h_flex()
.gap_1()
.when_some(
profile.metadata().picture.as_ref(),
|this, url| {
this.child(
img(SharedString::from(url))
.size_6()
.rounded_full(),
)
},
)
.child(SharedString::from(profile.name())),
)
.child(SharedString::from(
note.created_at.to_human_datetime(),
)),
)
.child(SharedString::from(note.content.clone())),
);
}
items
})
})
}
}

View File

@@ -1,384 +0,0 @@
use std::time::Duration;
use account::Account;
use anyhow::{anyhow, Error};
use common::NOSTR_CONNECT_TIMEOUT;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dock::{Panel, PanelEvent};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::notification::Notification;
use gpui_component::{v_flex, ActiveTheme, Disableable, StyledExt, WindowExt};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::client;
use crate::actions::LumeAuthUrlHandler;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
cx.new(|cx| Login::new(window, cx))
}
pub struct Login {
focus_handle: FocusHandle,
/// Input for nsec for bunker uri
key_input: Entity<InputState>,
/// Input for decryption password when available
pass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Timeout countdown
countdown: Entity<Option<u64>>,
/// Whether password is required
require_password: bool,
/// Whether logging in is in progress
logging_in: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Login {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let countdown = cx.new(|_| None);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to key input events and process login when the user presses enter
cx.subscribe_in(&key_input, window, |this, input, event, window, cx| {
match event {
InputEvent::PressEnter { .. } => {
this.login(window, cx);
}
InputEvent::Change => {
if input.read(cx).value().starts_with("ncryptsec1") {
this.require_password = true;
cx.notify();
}
}
_ => {}
};
}),
);
Self {
focus_handle: cx.focus_handle(),
key_input,
pass_input,
error,
countdown,
logging_in: false,
require_password: false,
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.login_with_bunker(&value, window, cx);
} else if value.starts_with("ncryptsec1") {
self.login_with_password(&value, &password, cx);
} else if value.starts_with("nsec1") {
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
self.login_with_keys(keys, cx);
} else {
self.set_error("Invalid", cx);
}
} else {
self.set_error("Invalid", cx);
}
}
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker URI is not valid", cx);
return;
};
let app_keys = Keys::generate();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(LumeAuthUrlHandler);
// Start countdown
cx.spawn_in(window, async move |this, cx| {
for i in (0..=NOSTR_CONNECT_TIMEOUT).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})
.ok();
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
})
.detach();
// Handle connection
cx.spawn_in(window, async move |this, cx| {
let result: Result<PublicKey, Error> = cx
.background_executor()
.await_on_background(async move {
let client = client();
let public_key = signer.get_public_key().await?;
// Update the signer
client.set_signer(signer).await;
// Return the URI for storing the connection
Ok(public_key)
})
.await;
this.update_in(cx, |_this, window, cx| {
match result {
Ok(public_key) => {
let account = Account::global(cx);
account.update(cx, |this, cx| {
this.set_public_key(public_key, cx);
})
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
})
.detach();
}
fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context<Self>) {
if pwd.is_empty() {
self.set_error("Password is required", cx);
return;
}
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
self.set_error("Secret Key is invalid", cx);
return;
};
let password = pwd.to_owned();
// Decrypt in the background to ensure it doesn't block the UI
let task = cx.background_spawn(async move {
if let Ok(content) = enc.decrypt(&password) {
Ok(Keys::new(content))
} else {
Err(anyhow!("Invalid password"))
}
});
cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| {
match result {
Ok(keys) => {
this.login_with_keys(keys, cx);
}
Err(e) => {
this.set_error(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn login_with_keys(&mut self, keys: Keys, cx: &mut Context<Self>) {
let public_key = keys.public_key();
cx.spawn(async move |this, cx| {
cx.background_executor()
.await_on_background(async move {
let client = client();
client.set_signer(keys).await;
})
.await;
this.update(cx, |_this, cx| {
let account = Account::global(cx);
account.update(cx, |this, cx| {
this.set_public_key(public_key, cx);
});
})
.ok();
})
.detach();
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_logging_in(false, cx);
// Reset the countdown
self.set_countdown(None, cx);
// Update error message
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
// Clear the error message after 3 secs
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = None;
cx.notify();
});
})
.ok();
})
.detach();
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.logging_in = status;
cx.notify();
}
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
self.countdown.update(cx, |this, cx| {
*this = i;
cx.notify();
});
}
}
impl Panel for Login {
fn panel_name(&self) -> &'static str {
"Login"
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
SharedString::from("Login")
}
}
impl EventEmitter<PanelEvent> for Login {}
impl Focusable for Login {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Login {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.relative()
.size_full()
.items_center()
.justify_center()
.child(
v_flex()
.w_96()
.gap_10()
.child(
div()
.text_center()
.text_lg()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from("Continue with Private Key or Bunker")),
)
.child(
v_flex()
.gap_3()
.text_sm()
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("nsec or bunker://")
.child(Input::new(&self.key_input)),
)
.when(self.require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Password:")
.child(Input::new(&self.pass_input)),
)
})
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.logging_in)
.disabled(self.logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
let msg = format!(
"Approve connection request from your signer in {} seconds",
i
);
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(msg)),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_foreground)
.child(error.clone()),
)
}),
),
)
}
}

View File

@@ -1,5 +0,0 @@
pub mod feed;
pub mod login;
pub mod new_account;
pub mod onboarding;
pub mod startup;

View File

@@ -1,45 +0,0 @@
use gpui::{
div, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Window,
};
use gpui_component::dock::{Panel, PanelEvent};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
cx.new(|cx| NewAccount::new(window, cx))
}
pub struct NewAccount {
focus_handle: FocusHandle,
}
impl NewAccount {
fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for NewAccount {
fn panel_name(&self) -> &'static str {
"New Account"
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
SharedString::from("New Account")
}
}
impl EventEmitter<PanelEvent> for NewAccount {}
impl Focusable for NewAccount {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for NewAccount {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child("New Account")
}
}

View File

@@ -1,306 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use account::Account;
use anyhow::Error;
use common::{CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, relative, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
Image, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Task, Window,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::divider::Divider;
use gpui_component::dock::{Panel, PanelEvent};
use gpui_component::notification::Notification;
use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt};
use nostr_connect::prelude::*;
use qrcode::render::svg;
use qrcode::QrCode;
use smallvec::{smallvec, SmallVec};
use state::client;
use crate::workspace::OpenPanel;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
cx.new(|cx| Onboarding::new(window, cx))
}
#[derive(Debug, Clone)]
pub enum NostrConnectApp {
Nsec(String),
Amber(String),
Aegis(String),
}
impl NostrConnectApp {
pub fn all() -> Vec<Self> {
vec![
NostrConnectApp::Nsec("https://nsec.app".to_string()),
NostrConnectApp::Amber("https://github.com/greenart7c3/Amber".to_string()),
NostrConnectApp::Aegis("https://github.com/ZharlieW/Aegis".to_string()),
]
}
pub fn url(&self) -> &str {
match self {
Self::Nsec(url) | Self::Amber(url) | Self::Aegis(url) => url,
}
}
pub fn as_str(&self) -> String {
match self {
NostrConnectApp::Nsec(_) => "nsec.app (Desktop)".into(),
NostrConnectApp::Amber(_) => "Amber (Android)".into(),
NostrConnectApp::Aegis(_) => "Aegis (iOS)".into(),
}
}
}
/// Onboarding
pub struct Onboarding {
focus_handle: FocusHandle,
#[allow(dead_code)]
/// App keys for communicating with the remote signer
app_keys: Keys,
/// QR code for logging in with Nostr Connect
qr_code: Option<Arc<Image>>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl Onboarding {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let app_keys = Keys::generate();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
let qr_code = Self::generate_qr(uri.to_string().as_ref());
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
// Direct connection initiated by the client
let signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
let mut tasks = smallvec![];
tasks.push(
// Wait for nostr connect
cx.spawn_in(window, async move |this, cx| {
let result: Result<PublicKey, Error> = cx
.background_executor()
.await_on_background(async move {
let client = client();
let public_key = signer.get_public_key().await?;
// Update the signer
client.set_signer(signer).await;
// Return the URI for storing the connection
Ok(public_key)
})
.await;
this.update_in(cx, |_this, window, cx| {
match result {
Ok(public_key) => {
let account = Account::global(cx);
account.update(cx, |this, cx| {
this.set_public_key(public_key, cx);
})
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
}),
);
Self {
focus_handle: cx.focus_handle(),
qr_code,
app_keys,
_tasks: tasks,
}
}
fn generate_qr(value: &str) -> Option<Arc<Image>> {
let code = QrCode::new(value).unwrap();
let svg = code
.render()
.min_dimensions(256, 256)
.dark_color(svg::Color("#000000"))
.light_color(svg::Color("#FFFFFF"))
.build();
Some(Arc::new(Image::from_bytes(
gpui::ImageFormat::Svg,
svg.into_bytes(),
)))
}
fn render_app<T>(&self, ix: usize, label: T, url: &str, cx: &Context<Self>) -> impl IntoElement
where
T: Into<SharedString>,
{
div()
.id(ix)
.flex_1()
.rounded_md()
.py_0p5()
.px_2()
.bg(cx.theme().list)
.child(label.into())
.on_click({
let url = url.to_owned();
move |_e, _window, cx| {
cx.open_url(&url);
}
})
}
fn render_apps(&self, cx: &Context<Self>) -> impl IntoIterator<Item = impl IntoElement> {
let all_apps = NostrConnectApp::all();
let mut items = Vec::with_capacity(all_apps.len());
for (ix, item) in all_apps.into_iter().enumerate() {
items.push(self.render_app(ix, item.as_str(), item.url(), cx));
}
items
}
}
impl Panel for Onboarding {
fn panel_name(&self) -> &'static str {
"Onboarding"
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
SharedString::from("Onboarding")
}
}
impl EventEmitter<PanelEvent> for Onboarding {}
impl EventEmitter<OpenPanel> for Onboarding {}
impl Focusable for Onboarding {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.child(
v_flex()
.flex_1()
.h_full()
.gap_10()
.items_center()
.justify_center()
.child(
v_flex()
.items_center()
.text_center()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from("Welcome to Lume")),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("An unambitious Nostr client")),
),
)
.child(
v_flex()
.w_80()
.gap_3()
.child(
Button::new("continue")
.icon(Icon::new(IconName::ArrowRight))
.label("Start Browsing")
.primary()
.on_click(cx.listener(move |_this, _ev, _window, cx| {
cx.emit(OpenPanel::Signup);
})),
)
.child(
Divider::horizontal()
.label("Already have an account? Continue with")
.text_xs(),
)
.child(
Button::new("key")
.label("Secret Key or Bunker")
.large()
.link()
.small()
.on_click(cx.listener(move |_this, _ev, _window, cx| {
cx.emit(OpenPanel::Login);
})),
),
),
)
.child(
v_flex()
.flex_1()
.size_full()
.items_center()
.justify_center()
.gap_5()
.bg(cx.theme().muted)
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded_xl()
.shadow_lg()
.border_1()
.border_color(cx.theme().primary_active),
)
})
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.3))
.child(SharedString::from("Continue with Nostr Connect")),
)
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(
"Use Nostr Connect apps to scan the code",
)),
)
.child(
h_flex()
.mt_2()
.gap_1()
.text_xs()
.justify_center()
.children(self.render_apps(cx)),
),
),
)
}
}

View File

@@ -1,45 +0,0 @@
use gpui::{
div, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Window,
};
use gpui_component::dock::{Panel, PanelEvent};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
cx.new(|cx| Startup::new(window, cx))
}
pub struct Startup {
focus_handle: FocusHandle,
}
impl Startup {
fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for Startup {
fn panel_name(&self) -> &'static str {
"Startup"
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
SharedString::from("Welcome")
}
}
impl EventEmitter<PanelEvent> for Startup {}
impl Focusable for Startup {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Startup {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child("Startup")
}
}

View File

@@ -1,129 +0,0 @@
use common::BOOTSTRAP_RELAYS;
use gpui::{
div, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Window,
};
use gpui_component::dock::{Panel, PanelEvent};
use gpui_component::scroll::ScrollableElement;
use gpui_component::{h_flex, v_flex, ActiveTheme, StyledExt};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use crate::workspace::OpenPanel;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
cx.new(|cx| Sidebar::new(window, cx))
}
pub struct Sidebar {
focus_handle: FocusHandle,
}
impl Sidebar {
fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for Sidebar {
fn panel_name(&self) -> &'static str {
"Sidebar"
}
}
impl EventEmitter<PanelEvent> for Sidebar {}
impl EventEmitter<OpenPanel> for Sidebar {}
impl Focusable for Sidebar {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let person = PersonRegistry::global(cx);
let contacts = Vec::new();
v_flex()
.id("sidebar-wrapper")
.size_full()
.relative()
.px_2()
.overflow_y_scrollbar()
.child(
v_flex()
.gap_1p5()
.child(
div()
.mt_4()
.mb_2()
.font_semibold()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Relays"),
)
.child(v_flex().gap_2().children({
let mut items = Vec::with_capacity(BOOTSTRAP_RELAYS.len());
for (ix, relay) in BOOTSTRAP_RELAYS.into_iter().enumerate() {
items.push(
h_flex()
.id(SharedString::from(format!("relay-{ix}")))
.h_7()
.px_2()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().list_hover))
.child(div().text_sm().child(SharedString::from(relay)))
.on_click(cx.listener(move |_this, _ev, _window, cx| {
if let Ok(url) = RelayUrl::parse(relay) {
cx.emit(OpenPanel::Relay(url));
}
})),
)
}
items
})),
)
.child(
v_flex()
.gap_1p5()
.child(
div()
.mt_4()
.mb_2()
.font_semibold()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Contacts"),
)
.child(v_flex().gap_2().children({
let mut items = Vec::with_capacity(contacts.len());
for (ix, contact) in contacts.iter().enumerate() {
let profile = person.read(cx).get(contact, cx);
let name = SharedString::from(profile.name());
items.push(
h_flex()
.id(SharedString::from(format!("contact-{ix}")))
.h_7()
.px_2()
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().list_hover))
.child(div().text_sm().child(name.clone()))
.on_click(cx.listener(move |_this, _ev, _window, cx| {
cx.emit(OpenPanel::PublicKey(profile.public_key()));
})),
)
}
items
})),
)
}
}

View File

@@ -1,82 +0,0 @@
use std::path::PathBuf;
use common::home_dir;
use gpui::{Action, App, SharedString};
use gpui_component::scroll::ScrollbarShow;
use gpui_component::{ActiveTheme, Theme, ThemeMode, ThemeRegistry};
use serde::{Deserialize, Serialize};
#[derive(Action, Clone, PartialEq)]
#[action(namespace = themes, no_json)]
pub(crate) struct SwitchTheme(pub(crate) SharedString);
#[derive(Action, Clone, PartialEq)]
#[action(namespace = themes, no_json)]
pub(crate) struct SwitchThemeMode(pub(crate) ThemeMode);
#[derive(Debug, Clone, Serialize, Deserialize)]
struct State {
theme: SharedString,
scrollbar_show: Option<ScrollbarShow>,
}
impl Default for State {
fn default() -> Self {
Self {
theme: "Default Light".into(),
scrollbar_show: None,
}
}
}
pub fn init(cx: &mut App) {
// Load last theme state
let path = home_dir().join("state.json");
let json = std::fs::read_to_string(path).unwrap_or_default();
let state = serde_json::from_str::<State>(&json).unwrap_or_default();
if let Err(err) = ThemeRegistry::watch_dir(PathBuf::from("./themes"), cx, move |cx| {
if let Some(theme) = ThemeRegistry::global(cx)
.themes()
.get(&state.theme)
.cloned()
{
Theme::global_mut(cx).apply_config(&theme);
}
}) {
log::error!("Failed to watch themes directory: {}", err);
}
if let Some(scrollbar_show) = state.scrollbar_show {
Theme::global_mut(cx).scrollbar_show = scrollbar_show;
}
cx.refresh_windows();
cx.observe_global::<Theme>(|cx| {
let state = State {
theme: cx.theme().theme_name().clone(),
scrollbar_show: Some(cx.theme().scrollbar_show),
};
if let Ok(json) = serde_json::to_string_pretty(&state) {
let path = home_dir().join("state.json");
// Ignore write errors - if STATE_FILE doesn't exist or can't be written, do nothing
let _ = std::fs::write(path, json);
}
})
.detach();
cx.on_action(|switch: &SwitchTheme, cx| {
let theme_name = switch.0.clone();
if let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&theme_name).cloned() {
Theme::global_mut(cx).apply_config(&theme_config);
}
cx.refresh_windows();
});
cx.on_action(|switch: &SwitchThemeMode, cx| {
let mode = switch.0;
Theme::change(mode, None, cx);
cx.refresh_windows();
});
}

View File

@@ -1,66 +0,0 @@
use std::rc::Rc;
use gpui::{
div, AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Render, SharedString, Styled as _, Subscription, Window,
};
use gpui_component::menu::AppMenuBar;
use gpui_component::TitleBar;
use crate::menus;
#[allow(clippy::type_complexity)]
pub struct AppTitleBar {
/// The app menu bar
app_menu_bar: Entity<AppMenuBar>,
/// Child elements
child: Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
/// Event subscriptions
_subscriptions: Vec<Subscription>,
}
impl AppTitleBar {
pub fn new(
title: impl Into<SharedString>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let app_menu_bar = menus::init(title, cx);
Self {
app_menu_bar,
child: Rc::new(|_, _| div().into_any_element()),
_subscriptions: vec![],
}
}
#[allow(dead_code)]
pub fn child<F, E>(mut self, f: F) -> Self
where
E: IntoElement,
F: Fn(&mut Window, &mut App) -> E + 'static,
{
self.child = Rc::new(move |window, cx| f(window, cx).into_any_element());
self
}
}
impl Render for AppTitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
TitleBar::new()
// left side
.child(div().flex().items_center().child(self.app_menu_bar.clone()))
.child(
div()
.flex()
.items_center()
.justify_end()
.px_2()
.gap_2()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child((self.child.clone())(window, cx)),
)
}
}

View File

@@ -1,178 +0,0 @@
use std::sync::Arc;
use account::Account;
use common::{CLIENT_NAME, DEFAULT_SIDEBAR_WIDTH};
use gpui::{
div, px, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, Styled, Subscription, Window,
};
use gpui_component::dock::{DockArea, DockItem, DockPlacement, PanelStyle};
use gpui_component::{v_flex, Root, Theme};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use crate::panels::feed::Feed;
use crate::panels::{login, new_account, onboarding, startup};
use crate::sidebar;
use crate::title_bar::AppTitleBar;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum OpenPanel {
PublicKey(PublicKey),
Relay(RelayUrl),
Signup,
Login,
}
#[derive(Debug)]
pub struct Workspace {
/// The dock area for the workspace.
dock: Entity<DockArea>,
/// App's title bar.
title_bar: Entity<AppTitleBar>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Workspace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let account = Account::global(cx);
let onboarding = Arc::new(onboarding::init(window, cx));
// App's title bar
let title_bar = cx.new(|cx| AppTitleBar::new(CLIENT_NAME, window, cx));
// Dock area for the workspace.
let dock = cx.new(|cx| {
let mut this = DockArea::new("dock", None, window, cx).panel_style(PanelStyle::TabBar);
this.set_center(DockItem::panel(onboarding.clone()), window, cx);
this
});
let mut subscriptions = smallvec![];
// Observe account entity changes
subscriptions.push(
cx.observe_in(&account, window, move |this, state, window, cx| {
if state.read(cx).has_account() {
this.init_app_layout(window, cx);
}
}),
);
// Observe onboarding panel events
subscriptions.push(cx.subscribe_in(
&onboarding,
window,
|this, _sidebar, event: &OpenPanel, window, cx| {
match event {
OpenPanel::Login => {
let view = login::init(window, cx);
this.dock.update(cx, |this, cx| {
this.set_center(DockItem::panel(Arc::new(view)), window, cx);
});
}
OpenPanel::Signup => {
let view = new_account::init(window, cx);
this.dock.update(cx, |this, cx| {
this.set_center(DockItem::panel(Arc::new(view)), window, cx);
});
}
_ => {}
};
},
));
// Automatically sync theme with system appearance
subscriptions.push(window.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}));
Self {
dock,
title_bar,
_subscriptions: subscriptions,
}
}
fn init_app_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let weak_dock = self.dock.downgrade();
let sidebar = sidebar::init(window, cx);
let startup = Arc::new(startup::init(window, cx));
self._subscriptions.push(cx.subscribe_in(
&sidebar,
window,
|this, _sidebar, event: &OpenPanel, window, cx| {
match event {
OpenPanel::PublicKey(public_key) => {
let view = cx.new(|cx| Feed::new(Some(*public_key), None, window, cx));
this.dock.update(cx, |this, cx| {
this.add_panel(Arc::new(view), DockPlacement::Center, None, window, cx);
});
}
OpenPanel::Relay(relay) => {
let view = cx.new(|cx| Feed::new(None, Some(relay.to_owned()), window, cx));
this.dock.update(cx, |this, cx| {
this.add_panel(Arc::new(view), DockPlacement::Center, None, window, cx);
});
}
_ => {}
};
},
));
// Construct left dock (sidebar)
let left = DockItem::panel(Arc::new(sidebar));
// Construct center dock
let center = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(vec![startup], &weak_dock, window, cx)],
vec![None],
&weak_dock,
window,
cx,
);
// Update dock layout
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(px(DEFAULT_SIDEBAR_WIDTH)), true, window, cx);
this.set_center(center, window, cx);
});
}
}
impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let dialog_layer = Root::render_dialog_layer(window, cx);
let sheet_layer = Root::render_sheet_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
div()
.id("root")
.relative()
.size_full()
.child(
v_flex()
.size_full()
// Title bar
.child(self.title_bar.clone())
// Dock
.child(self.dock.clone()),
)
// Notifications
.children(notification_layer)
// Sheets
.children(sheet_layer)
// Modals
.children(dialog_layer)
}
}

View File

@@ -1,17 +0,0 @@
[package]
name = "note"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
flume.workspace = true
log.workspace = true

View File

@@ -1,92 +0,0 @@
use std::collections::{HashMap, HashSet};
use gpui::{App, AppContext, Context, Entity, Global, Task};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::client;
pub fn init(cx: &mut App) {
NoteRegistry::set_global(cx.new(NoteRegistry::new), cx);
}
struct GlobalNoteRegistry(Entity<NoteRegistry>);
impl Global for GlobalNoteRegistry {}
/// Note Registry
#[derive(Debug)]
pub struct NoteRegistry {
/// Collection of all notes
pub notes: HashMap<EventId, Event>,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 2]>,
}
impl NoteRegistry {
/// Retrieve the global note registry state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalNoteRegistry>().0.clone()
}
/// Set the global note registry instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalNoteRegistry(state));
}
/// Create a new note registry instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut tasks = smallvec![];
// Channel for communication between Nostr and GPUI
let (tx, rx) = flume::bounded::<Event>(2048);
tasks.push(
// Handle nostr notifications
cx.background_spawn(async move {
let client = client();
let mut notifications = client.notifications();
let mut processed_events: HashSet<EventId> = HashSet::default();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
continue;
};
if let RelayMessage::Event { event, .. } = message {
// Skip if already processed
if !processed_events.insert(event.id) {
continue;
}
if event.kind == Kind::TextNote || event.kind == Kind::Repost {
tx.send_async(event.into_owned()).await.ok();
}
}
}
}),
);
tasks.push(
// Update GPUI state
cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| {
this.notes.insert(event.id, event);
cx.notify();
})
.ok();
}
}),
);
Self {
notes: HashMap::new(),
_tasks: tasks,
}
}
pub fn get(&self, id: &EventId) -> Option<&Event> {
self.notes.get(id)
}
}

View File

@@ -1,17 +0,0 @@
[package]
name = "person"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
flume.workspace = true
log.workspace = true

View File

@@ -1,160 +0,0 @@
use std::collections::{HashMap, HashSet};
use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, Task};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::client;
pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
}
struct GlobalPersonRegistry(Entity<PersonRegistry>);
impl Global for GlobalPersonRegistry {}
/// Person Registry
#[derive(Debug)]
pub struct PersonRegistry {
/// Collection of all profiels
pub persons: HashMap<PublicKey, Entity<Profile>>,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 3]>,
}
impl PersonRegistry {
/// Retrieve the global person registry state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalPersonRegistry>().0.clone()
}
/// Set the global person registry instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalPersonRegistry(state));
}
/// Create a new person registry instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut tasks = smallvec![];
// Channel for communication between Nostr and GPUI
let (tx, rx) = flume::bounded::<Profile>(1024);
tasks.push(
// Load all user profiles from the database
cx.spawn(async move |this, cx| {
let task = Self::init(cx);
match task.await {
Ok(profiles) => {
this.update(cx, |this, cx| {
this.bulk_insert(profiles, cx);
})
.ok();
}
Err(e) => {
log::error!("Failed to load user profiles from database: {e}");
}
}
}),
);
tasks.push(
// Handle nostr notifications
cx.background_spawn(async move {
let client = client();
let mut notifications = client.notifications();
let mut processed_events: HashSet<EventId> = HashSet::default();
while let Ok(notification) = notifications.recv().await {
let RelayPoolNotification::Message { message, .. } = notification else {
continue;
};
if let RelayMessage::Event { event, .. } = message {
// Skip if already processed
if !processed_events.insert(event.id) {
continue;
}
if event.kind == Kind::Metadata {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let profile = Profile::new(event.pubkey, metadata);
tx.send_async(profile).await.ok();
}
}
}
}),
);
tasks.push(
// Update GPUI state
cx.spawn(async move |this, cx| {
while let Ok(profile) = rx.recv_async().await {
this.update(cx, |this, cx| {
this.insert_or_update(&profile, cx);
})
.ok();
}
}),
);
Self {
persons: HashMap::new(),
_tasks: tasks,
}
}
/// Load all user profiles from the database
fn init(cx: &AsyncApp) -> Task<Result<Vec<Profile>, Error>> {
cx.background_spawn(async move {
let client = client();
let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?;
let mut profiles = vec![];
for event in events.into_iter() {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
let profile = Profile::new(event.pubkey, metadata);
profiles.push(profile);
}
Ok(profiles)
})
}
/// Insert batch of persons
fn bulk_insert(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
for profile in profiles.into_iter() {
self.persons
.insert(profile.public_key(), cx.new(|_| profile));
}
cx.notify();
}
/// Insert or update a person
pub fn insert_or_update(&mut self, profile: &Profile, cx: &mut App) {
let public_key = profile.public_key();
if let Some(person) = self.persons.get(&public_key) {
person.update(cx, |this, cx| {
*this = profile.to_owned();
cx.notify();
});
} else {
self.persons
.insert(public_key, cx.new(|_| profile.to_owned()));
}
}
/// Get person
pub fn get(&self, public_key: &PublicKey, cx: &App) -> Profile {
self.persons
.get(public_key)
.map(|e| e.read(cx).clone())
.unwrap_or(Profile::new(public_key.to_owned(), Metadata::default()))
}
}

View File

@@ -1,22 +0,0 @@
[package]
name = "state"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-gossip-memory.workspace = true
gpui.workspace = true
smol.workspace = true
smallvec.workspace = true
log.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
rustls = "0.23.23"

View File

@@ -1,7 +0,0 @@
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum StateEvent {
ReceivedContactList,
ReceivedProfile(Box<Profile>),
}

View File

@@ -1,46 +0,0 @@
use std::sync::OnceLock;
use std::time::Duration;
use common::config_dir;
pub use event::*;
use nostr_gossip_memory::prelude::*;
use nostr_lmdb::NostrLmdb;
use nostr_sdk::prelude::*;
mod event;
static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
pub fn client() -> &'static Client {
NOSTR_CLIENT.get_or_init(|| {
// rustls uses the `aws_lc_rs` provider by default
// This only errors if the default provider has already
// been installed. We can ignore this `Result`.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok();
// Construct the nostr client options
let opts = ClientOptions::new()
.automatic_authentication(false)
.verify_subscriptions(false)
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(600),
});
// Construct the lmdb
let lmdb = smol::block_on(async move {
let path = config_dir().join("nostr");
NostrLmdb::open(path)
.await
.expect("Failed to initialize database")
});
// Construct the nostr client
ClientBuilder::default()
.database(lmdb)
.gossip(NostrGossipMemory::unbounded())
.opts(opts)
.build()
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

11
index.html Normal file
View File

@@ -0,0 +1,11 @@
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume</title>
</head>
<body class="cursor-default select-none overflow-hidden font-sans antialiased h-screen w-screen dark:bg-black dark:text-zinc-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

99
package.json Normal file
View File

@@ -0,0 +1,99 @@
{
"name": "lume",
"private": true,
"version": "1.1.1",
"scripts": {
"dev": "vite",
"build": "vite build",
"tauri": "tauri",
"add-migrate": "cd src-tauri/ && sqlx migrate add",
"prepare": "husky install",
"lint": "eslint ./src --fix",
"format": "prettier ./src --write",
"dep-update": "pnpm update && cd src-tauri/ && cargo update"
},
"lint-staged": {
"**/*.{ts, tsx}": "eslint --fix",
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
},
"dependencies": {
"@headlessui/react": "^1.7.15",
"@nostr-dev-kit/ndk": "^0.7.7",
"@nostr-fetch/adapter-ndk": "^0.11.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.32.0",
"@tanstack/react-query-devtools": "^4.32.0",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.4.0",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-mention": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.9",
"destr": "^1.2.2",
"framer-motion": "^10.13.1",
"get-urls": "^11.0.0",
"html-to-text": "^9.0.5",
"immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0",
"nostr-fetch": "^0.12.2",
"nostr-tools": "^1.13.1",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.2",
"react-hotkeys-hook": "^4.4.1",
"react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-router-dom": "^6.14.2",
"react-string-replace": "^1.1.1",
"react-virtuoso": "^4.4.2",
"remark-gfm": "^3.0.1",
"tailwind-merge": "^1.14.0",
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
"tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1",
"tippy.js": "^6.3.7",
"zustand": "^4.3.9"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/html-to-text": "^9.0.1",
"@types/node": "^18.17.1",
"@types/react": "^18.2.17",
"@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.45.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"postcss": "^8.4.27",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"prop-types": "^15.8.1",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.5",
"vite": "^4.4.7",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0"
}
}

7604
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,10 +0,0 @@
[toolchain]
channel = "1.90"
profile = "minimal"
components = ["rustfmt", "clippy"]
targets = [
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]

View File

@@ -1,9 +0,0 @@
tab_spaces = 4
newline_style = "Auto"
reorder_imports = true
reorder_modules = true
reorder_impl_items = true
indent_style = "Block"
normalize_comments = false
imports_granularity = "Module"
group_imports = "StdExternalCrate"

View File

View File

@@ -1,240 +0,0 @@
#!/usr/bin/env bash
set -xeuo pipefail
# if root or if sudo/unavailable, define an empty variable
if [ "$(id -u)" -eq 0 ]
then maysudo=''
else maysudo="$(command -v sudo || command -v doas || true)"
fi
function finalize {
# after packages install (curl, etc), get the rust toolchain
which rustup > /dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# verify the mold situation
if ! command -v mold >/dev/null 2>&1; then
echo "Warning: Mold binaries are unavailable on your system." >&2
echo " Builds will be slower without mold. Try: script/install-mold" >&2
fi
echo "Finished installing Linux dependencies with script/linux"
}
# Ubuntu, Debian, Mint, Kali, Pop!_OS, Raspbian, etc.
apt=$(command -v apt-get || true)
if [[ -n $apt ]]; then
deps=(
gcc
g++
libasound2-dev
libfontconfig-dev
libwayland-dev
libx11-xcb-dev
libxkbcommon-x11-dev
libssl-dev
libzstd-dev
libvulkan1
libgit2-dev
make
cmake
clang
jq
git
curl
gettext-base
elfutils
libsqlite3-dev
musl-tools
musl-dev
build-essential
)
if (grep -qP 'PRETTY_NAME="(Linux Mint 22|.+24\.(04|10))' /etc/os-release); then
deps+=( mold libstdc++-14-dev )
elif (grep -qP 'PRETTY_NAME="((Debian|Raspbian).+12|Linux Mint 21|.+22\.04)' /etc/os-release); then
deps+=( mold libstdc++-12-dev )
elif (grep -qP 'PRETTY_NAME="((Debian|Raspbian).+11|Linux Mint 20|.+20\.04)' /etc/os-release); then
deps+=( libstdc++-10-dev )
fi
$maysudo "$apt" update
$maysudo "$apt" install -y "${deps[@]}"
finalize
exit 0
fi
# Fedora, CentOS, RHEL, Alma, Amazon 2023, Oracle, etc.
dnf=$(command -v dnf || true)
# Old Redhat (yum only): Amazon Linux 2, Oracle Linux 7, etc.
yum=$(command -v yum || true)
if [[ -n $dnf ]] || [[ -n $yum ]]; then
pkg_cmd="${dnf:-${yum}}"
deps=(
musl-gcc
gcc
clang
cmake
alsa-lib-devel
fontconfig-devel
wayland-devel
libxcb-devel
libxkbcommon-x11-devel
openssl-devel
libzstd-devel
vulkan-loader
sqlite-devel
jq
git
tar
)
# perl used for building openssl-sys crate. See: https://docs.rs/openssl/latest/openssl/
if grep -qP '^ID="?(fedora)' /etc/os-release; then
deps+=(
perl-FindBin
perl-IPC-Cmd
perl-File-Compare
perl-File-Copy
mold
)
elif grep -qP '^ID="?(rhel|rocky|alma|centos|ol)' /etc/os-release; then
deps+=( perl-interpreter )
fi
# gcc-c++ is g++ on RHEL8 and 8.x clones
if grep -qP '^ID="?(rhel|rocky|alma|centos|ol)' /etc/os-release \
&& grep -qP '^VERSION_ID="?(8)' /etc/os-release; then
deps+=( gcc-c++ )
else
deps+=( g++ )
fi
# libxkbcommon-x11-devel is in a non-default repo on RHEL 8.x/9.x (except on AmazonLinux)
if grep -qP '^VERSION_ID="?(8|9)' /etc/os-release && grep -qP '^ID="?(rhel|rocky|centos|alma|ol)' /etc/os-release; then
$maysudo dnf install -y 'dnf-command(config-manager)'
if grep -qP '^PRETTY_NAME="(AlmaLinux 8|Rocky Linux 8)' /etc/os-release; then
$maysudo dnf config-manager --set-enabled powertools
elif grep -qP '^PRETTY_NAME="((AlmaLinux|Rocky|CentOS Stream) 9|Red Hat.+(8|9))' /etc/os-release; then
$maysudo dnf config-manager --set-enabled crb
elif grep -qP '^PRETTY_NAME="Oracle Linux Server 8' /etc/os-release; then
$maysudo dnf config-manager --set-enabled ol8_codeready_builder
elif grep -qP '^PRETTY_NAME="Oracle Linux Server 9' /etc/os-release; then
$maysudo dnf config-manager --set-enabled ol9_codeready_builder
else
echo "Unexpected distro" && grep 'PRETTY_NAME' /etc/os-release && exit 1
fi
fi
$maysudo "$pkg_cmd" install -y "${deps[@]}"
finalize
exit 0
fi
# openSUSE
# https://software.opensuse.org/
zyp=$(command -v zypper || true)
if [[ -n $zyp ]]; then
deps=(
alsa-devel
clang
cmake
fontconfig-devel
gcc
gcc-c++
git
gzip
jq
libvulkan1
libxcb-devel
libxkbcommon-devel
libxkbcommon-x11-devel
libzstd-devel
make
mold
openssl-devel
sqlite3-devel
tar
wayland-devel
xcb-util-devel
)
$maysudo "$zyp" install -y "${deps[@]}"
finalize
exit 0
fi
# Arch, Manjaro, etc.
# https://archlinux.org/packages
pacman=$(command -v pacman || true)
if [[ -n $pacman ]]; then
deps=(
gcc
clang
musl
cmake
alsa-lib
fontconfig
wayland
libgit2
libxcb
libxkbcommon-x11
openssl
zstd
pkgconf
mold
sqlite
jq
git
)
$maysudo "$pacman" -Syu --needed --noconfirm "${deps[@]}"
finalize
exit 0
fi
# Void
# https://voidlinux.org/packages/
xbps=$(command -v xbps-install || true)
if [[ -n $xbps ]]; then
deps=(
gettext-devel
clang
cmake
jq
elfutils-devel
gcc
alsa-lib-devel
fontconfig-devel
libxcb-devel
libxkbcommon-devel
libzstd-devel
openssl-devel
wayland-devel
vulkan-loader
mold
)
$maysudo "$xbps" -Syu "${deps[@]}"
finalize
exit 0
fi
# Gentoo
# https://packages.gentoo.org/
emerge=$(command -v emerge || true)
if [[ -n $emerge ]]; then
deps=(
app-arch/zstd
app-misc/jq
dev-libs/openssl
dev-libs/wayland
dev-util/cmake
media-libs/alsa-lib
media-libs/fontconfig
media-libs/vulkan-loader
x11-libs/libxcb
x11-libs/libxkbcommon
sys-devel/mold
)
$maysudo "$emerge" -u "${deps[@]}"
finalize
exit 0
fi
echo "Unsupported Linux distribution in script/linux"
exit 1

View File

@@ -1,237 +0,0 @@
#!/usr/bin/env bash
set -xeuo pipefail
# if root or if sudo/unavailable, define an empty variable
if [ "$(id -u)" -eq 0 ]
then maysudo=''
else maysudo="$(command -v sudo || command -v doas || true)"
fi
function finalize {
# after packages install (curl, etc), get the rust toolchain
which rustup > /dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# verify the mold situation
if ! command -v mold >/dev/null 2>&1; then
echo "Warning: Mold binaries are unavailable on your system." >&2
echo " Builds will be slower without mold. Try: script/install-mold" >&2
fi
echo "Finished installing Linux dependencies with script/linux"
}
# Ubuntu, Debian, Mint, Kali, Pop!_OS, Raspbian, etc.
apt=$(command -v apt-get || true)
if [[ -n $apt ]]; then
deps=(
gcc
g++
libasound2-dev
libfontconfig-dev
libwayland-dev
libx11-xcb-dev
libxkbcommon-x11-dev
libssl-dev
libzstd-dev
libvulkan1
libgit2-dev
libx11-dev
make
cmake
clang
jq
git
curl
gettext-base
elfutils
musl-tools
musl-dev
build-essential
)
if (grep -qP 'PRETTY_NAME="(Linux Mint 22|.+24\.(04|10))' /etc/os-release); then
deps+=( mold libstdc++-14-dev )
elif (grep -qP 'PRETTY_NAME="((Debian|Raspbian).+12|Linux Mint 21|.+22\.04)' /etc/os-release); then
deps+=( mold libstdc++-12-dev )
elif (grep -qP 'PRETTY_NAME="((Debian|Raspbian).+11|Linux Mint 20|.+20\.04)' /etc/os-release); then
deps+=( libstdc++-10-dev )
fi
$maysudo "$apt" update
$maysudo "$apt" install -y "${deps[@]}"
finalize
exit 0
fi
# Fedora, CentOS, RHEL, Alma, Amazon 2023, Oracle, etc.
dnf=$(command -v dnf || true)
# Old Redhat (yum only): Amazon Linux 2, Oracle Linux 7, etc.
yum=$(command -v yum || true)
if [[ -n $dnf ]] || [[ -n $yum ]]; then
pkg_cmd="${dnf:-${yum}}"
deps=(
musl-gcc
gcc
clang
cmake
alsa-lib-devel
fontconfig-devel
wayland-devel
libxcb-devel
libxkbcommon-x11-devel
openssl-devel
libzstd-devel
vulkan-loader
jq
git
tar
)
# perl used for building openssl-sys crate. See: https://docs.rs/openssl/latest/openssl/
if grep -qP '^ID="?(fedora)' /etc/os-release; then
deps+=(
perl-FindBin
perl-IPC-Cmd
perl-File-Compare
perl-File-Copy
mold
)
elif grep -qP '^ID="?(rhel|rocky|alma|centos|ol)' /etc/os-release; then
deps+=( perl-interpreter )
fi
# gcc-c++ is g++ on RHEL8 and 8.x clones
if grep -qP '^ID="?(rhel|rocky|alma|centos|ol)' /etc/os-release \
&& grep -qP '^VERSION_ID="?(8)' /etc/os-release; then
deps+=( gcc-c++ )
else
deps+=( g++ )
fi
# libxkbcommon-x11-devel is in a non-default repo on RHEL 8.x/9.x (except on AmazonLinux)
if grep -qP '^VERSION_ID="?(8|9)' /etc/os-release && grep -qP '^ID="?(rhel|rocky|centos|alma|ol)' /etc/os-release; then
$maysudo dnf install -y 'dnf-command(config-manager)'
if grep -qP '^PRETTY_NAME="(AlmaLinux 8|Rocky Linux 8)' /etc/os-release; then
$maysudo dnf config-manager --set-enabled powertools
elif grep -qP '^PRETTY_NAME="((AlmaLinux|Rocky|CentOS Stream) 9|Red Hat.+(8|9))' /etc/os-release; then
$maysudo dnf config-manager --set-enabled crb
elif grep -qP '^PRETTY_NAME="Oracle Linux Server 8' /etc/os-release; then
$maysudo dnf config-manager --set-enabled ol8_codeready_builder
elif grep -qP '^PRETTY_NAME="Oracle Linux Server 9' /etc/os-release; then
$maysudo dnf config-manager --set-enabled ol9_codeready_builder
else
echo "Unexpected distro" && grep 'PRETTY_NAME' /etc/os-release && exit 1
fi
fi
$maysudo "$pkg_cmd" install -y "${deps[@]}"
finalize
exit 0
fi
# openSUSE
# https://software.opensuse.org/
zyp=$(command -v zypper || true)
if [[ -n $zyp ]]; then
deps=(
alsa-devel
clang
cmake
fontconfig-devel
gcc
gcc-c++
git
gzip
jq
libvulkan1
libxcb-devel
libxkbcommon-devel
libxkbcommon-x11-devel
libzstd-devel
make
mold
openssl-devel
tar
wayland-devel
xcb-util-devel
)
$maysudo "$zyp" install -y "${deps[@]}"
finalize
exit 0
fi
# Arch, Manjaro, etc.
# https://archlinux.org/packages
pacman=$(command -v pacman || true)
if [[ -n $pacman ]]; then
deps=(
gcc
clang
musl
cmake
alsa-lib
fontconfig
wayland
libgit2
libxcb
libxkbcommon-x11
openssl
zstd
pkgconf
mold
jq
git
)
$maysudo "$pacman" -Syu --needed --noconfirm "${deps[@]}"
finalize
exit 0
fi
# Void
# https://voidlinux.org/packages/
xbps=$(command -v xbps-install || true)
if [[ -n $xbps ]]; then
deps=(
gettext-devel
clang
cmake
jq
elfutils-devel
gcc
alsa-lib-devel
fontconfig-devel
libxcb-devel
libxkbcommon-devel
libzstd-devel
openssl-devel
wayland-devel
vulkan-loader
mold
)
$maysudo "$xbps" -Syu "${deps[@]}"
finalize
exit 0
fi
# Gentoo
# https://packages.gentoo.org/
emerge=$(command -v emerge || true)
if [[ -n $emerge ]]; then
deps=(
app-arch/zstd
app-misc/jq
dev-libs/openssl
dev-libs/wayland
dev-util/cmake
media-libs/alsa-lib
media-libs/fontconfig
media-libs/vulkan-loader
x11-libs/libxcb
x11-libs/libxkbcommon
sys-devel/mold
)
$maysudo "$emerge" -u "${deps[@]}"
finalize
exit 0
fi
echo "Unsupported Linux distribution in script/linux"
exit 1

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env bash
set -xeuo pipefail
export HOMEBREW_NO_INSTALL_CLEANUP=1
# if root or if sudo/unavailable, define an empty variable
if [ "$(id -u)" -eq 0 ]
then maysudo=''
else maysudo="$(command -v sudo || command -v doas || true)"
fi
function finalize {
# after packages install (curl, etc), get the rust toolchain
which rustup > /dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# verify the mold situation
if ! command -v mold >/dev/null 2>&1; then
echo "Warning: Mold binaries are unavailable on your system." >&2
echo " Builds will be slower without mold. Try: script/install-mold" >&2
fi
echo "Finished installing MacOS dependencies with script/macos"
}
# MacOS
brew=$(command -v brew || true)
if [[ -n $brew ]]; then
deps=(
gcc
libx11
libxkbcommon
openssl
zstd
vulkan-headers
libgit2
libx11
make
cmake
jq
git
curl
gettext
)
$brew update
for dep in "${deps[@]}";do
$brew search "$dep";
done
$brew install "${deps[@]}"
finalize
exit 0
fi

3
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
/target/

1
src-tauri/.rustfmt.toml Normal file
View File

@@ -0,0 +1 @@
tab_spaces=2

6377
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

74
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,74 @@
[package]
name = "lume"
version = "1.1.1"
description = "nostr client"
authors = ["Ren Amamiya"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.57"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = [
"fs-remove-file",
"fs-write-file",
"window-create",
"path-all",
"fs-read-dir",
"fs-read-file",
"clipboard-read-text",
"clipboard-write-text",
"dialog-open",
"http-all",
"http-multipart",
"notification-all",
"os-all",
"process-relaunch",
"shell-open",
"system-tray",
"updater",
"window-close",
"window-start-dragging",
] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
sqlx-cli = { version = "0.7.0", default-features = false, features = [
"sqlite",
] }
rust-argon2 = "1.0"
rand = "0.8.5"
[dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace"
branch = "v1"
features = ["sqlite"]
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2.7"
cocoa = "0.24.1"
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
# Optimized for bundle size. If you want faster builds comment out/delete this section.
[profile.release]
lto = true # Enable Link Time Optimization
opt-level = "z" # Optimize for size.
codegen-units = 1 # Reduce number of codegen units to increase optimizations.
panic = "abort" # Abort on panic
strip = true # Automatically strip symbols from the binary.
debug = false

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,59 @@
-- Add migration script here
-- create accounts table
-- is_active (multi-account feature), value:
-- 0: false
-- 1: true
CREATE TABLE
accounts (
id INTEGER NOT NULL PRIMARY KEY,
npub TEXT NOT NULL UNIQUE,
pubkey TEXT NOT NULL UNIQUE,
privkey TEXT NOT NULL,
follows JSON,
is_active INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- create notes table
CREATE TABLE
notes (
id INTEGER NOT NULL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
account_id INTEGER NOT NULL,
pubkey TEXT NOT NULL,
kind INTEGER NOT NULL DEFAULT 1,
tags JSON,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
parent_id TEXT,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);
-- create channels table
CREATE TABLE
channels (
id INTEGER NOT NULL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
name TEXT,
about TEXT,
picture TEXT,
created_at INTEGER NOT NULL
);
-- create settings table
CREATE TABLE
settings (
id INTEGER NOT NULL PRIMARY KEY,
key TEXT NOT NULL,
value TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- create metadata table
CREATE TABLE
metadata (
id TEXT NOT NULL PRIMARY KEY,
pubkey TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,12 @@
-- Add migration script here
-- create chats table
CREATE TABLE
chats (
id INTEGER NOT NULL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
receiver_pubkey INTEGER NOT NULL,
sender_pubkey TEXT NOT NULL,
content TEXT NOT NULL,
tags JSON,
created_at INTEGER NOT NULL
);

View File

@@ -0,0 +1,14 @@
-- Add migration script here
INSERT INTO
settings (key, value)
VALUES
("last_login", "0"),
(
"relays",
'["wss://relayable.org","wss://relay.damus.io","wss://relay.nostr.band/all","wss://relay.nostrgraph.net","wss://nostr.mutinywallet.com"]'
),
("auto_start", "0"),
("cache_time", "86400000"),
("compose_shortcut", "meta+n"),
("add_imageblock_shortcut", "meta+i"),
("add_feedblock_shortcut", "meta+f")

View File

@@ -0,0 +1,3 @@
-- Add migration script here
-- add pubkey to channel
ALTER TABLE channels ADD pubkey TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,38 @@
-- Add migration script here
INSERT
OR IGNORE INTO channels (
event_id,
pubkey,
name,
about,
picture,
created_at
)
VALUES
(
"e3cadf5beca1b2af1cddaa41a633679bedf263e3de1eb229c6686c50d85df753",
"126103bfddc8df256b6e0abfd7f3797c80dcc4ea88f7c2f87dd4104220b4d65f",
"lume-general",
"General channel for Lume",
"https://void.cat/d/UNyxBmAh1MUx5gQTX95jyf.webp",
1681898574
);
INSERT
OR IGNORE INTO channels (
event_id,
pubkey,
name,
about,
picture,
created_at
)
VALUES
(
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
"ed1d0e1f743a7d19aa2dfb0162df73bacdbc699f67cc55bb91a98c35f7deac69",
"Nostr",
"",
"https://cloudflare-ipfs.com/ipfs/QmTN4Eas9atUULVbEAbUU8cowhtvK7g3t7jfKztY7wc8eP?.png",
1661333723
);

View File

@@ -0,0 +1,11 @@
-- Add migration script here
-- create blacklist table
CREATE TABLE
blacklist (
id INTEGER NOT NULL PRIMARY KEY,
account_id INTEGER NOT NULL,
content TEXT NOT NULL UNIQUE,
status INTEGER NOT NULL DEFAULT 0,
kind INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);

View File

@@ -0,0 +1,11 @@
-- Add migration script here
CREATE TABLE
blocks (
id INTEGER NOT NULL PRIMARY KEY,
account_id INTEGER NOT NULL,
kind INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);

View File

@@ -0,0 +1,15 @@
-- Add migration script here
CREATE TABLE
channel_messages (
id INTEGER NOT NULL PRIMARY KEY,
channel_id TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
pubkey TEXT NOT NULL,
kind INTEGER NOT NULL,
content TEXT NOT NULL,
tags JSON,
mute BOOLEAN DEFAULT 0,
hide BOOLEAN DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY (channel_id) REFERENCES channels (event_id)
);

View File

@@ -0,0 +1,13 @@
-- Add migration script here
CREATE TABLE
replies (
id INTEGER NOT NULL PRIMARY KEY,
parent_id TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
pubkey TEXT NOT NULL,
kind INTEGER NOT NULL DEFAULT 1,
tags JSON,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (parent_id) REFERENCES notes (event_id)
);

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