Compare commits
530 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3f2264e9 | ||
|
|
407fe40b67 | ||
|
|
1f38eba2cc | ||
|
|
9b5867f80c | ||
|
|
cac774a0c1 | ||
|
|
82689bf3c3 | ||
|
|
f60e438a64 | ||
|
|
ca06f2b6ed | ||
|
|
99d9c70826 | ||
|
|
60afbf090b | ||
|
|
10ca4e6ff4 | ||
|
|
b0f387d029 | ||
|
|
1a8f750640 | ||
|
|
25523229a2 | ||
|
|
47835ed857 | ||
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df | ||
|
|
f1e17ff3c4 | ||
|
|
32954f17b6 | ||
|
|
cf70b0f882 | ||
|
|
135d0918b3 | ||
|
|
e1fbcf0460 | ||
|
|
99aaf3da82 | ||
|
|
3ef13e43f1 | ||
|
|
8939196ae4 | ||
|
|
571d4b4004 | ||
|
|
73f80f27fb | ||
|
|
b46a5cf68f | ||
|
|
8c0d03aed0 | ||
|
|
777eb15b4f | ||
|
|
c8e1b8b8bd | ||
|
|
437cd71f7e | ||
|
|
afb7c87fa3 | ||
|
|
c843626bca | ||
|
|
28337e5915 | ||
|
|
a4aef25adb | ||
|
|
61d1f095d4 | ||
|
|
f027eae52d | ||
|
|
174a3cc74e | ||
|
|
c00a7749b4 | ||
|
|
c755b8d137 | ||
| 17766d29d6 | |||
| 3b13dfeed8 | |||
| 17ba79e01b | |||
| bafad544e9 | |||
| 89c36423ae | |||
| cd31b99559 | |||
| f3c52237fa | |||
| 413d8d82df | |||
| 2eb2010d43 | |||
| 94d400cab2 | |||
| 09b143cb08 | |||
| e3ede34108 | |||
| ed6aca41ea | |||
| 89f577fbef | |||
| a14aeaeb55 | |||
|
|
35cf0abda4 | ||
| 8a7b246315 | |||
|
|
d98c6d0709 | ||
| bda20e8fe8 | |||
| c342daecc8 | |||
| 5e6692cd6d | |||
| 420be77b5c | |||
| 999073f84c | |||
| 174b28f1a7 | |||
| 763cb10e85 | |||
| 89bb8d88f6 | |||
| 09aa2ecafc | |||
| 7271e9ea87 | |||
| a02e496b29 | |||
| cbbf5eaf50 | |||
| d3fa59d2b1 | |||
| aa23e39334 | |||
| c8e2018fd0 | |||
| 6e489f1c49 | |||
| a49b88ab35 | |||
| 31839531ea | |||
| ec0f3fabc0 | |||
| dd7155a3a6 | |||
| cb565ff35b | |||
| 5d59040224 | |||
| 7fabf949c6 | |||
| ea5120e2f0 | |||
| 14f07dfea8 | |||
|
|
1de48cc640 | ||
|
|
f72eb456e8 | ||
| 05b52564e0 | |||
| c8e014f33e | |||
| 46cc01e0ee | |||
| 16e6d234e5 | |||
| 3005d27403 | |||
| 1be84f3139 | |||
|
|
04bde7dd43 | ||
| f1504d99ac | |||
| e928f2ee37 | |||
|
|
ccab78ca11 | ||
| 5b13c3e45c | |||
| e6b97ab9ae | |||
| bb6badfed6 | |||
|
|
0c4b309a11 | ||
| f4400d711f | |||
| a3e46aa96b | |||
| a4fdcfdf0b | |||
| 25d07303a3 | |||
| 0e1e7524c9 | |||
|
|
32d770eb91 | ||
| e93cbf78d5 | |||
| 71a2290b8f | |||
| 95294a80cb | |||
| 8eaf47f6d2 | |||
| 86183d799a | |||
| 2dbfff3c17 | |||
|
|
d1f5c372ae | ||
| 13281455ed | |||
| 9cfb32be4d | |||
| cb0672c22a | |||
| ca0e041731 | |||
| cfcb9bc6ed | |||
| 09df8672d0 | |||
| 2403231ac4 | |||
| 98ef1927f2 | |||
| 63db8b1423 | |||
| 2c8dd71792 | |||
|
|
fe73e1428b | ||
| 88a6c3c81f | |||
| 84584a4d1f | |||
| 64286aa354 | |||
| e9ce932646 | |||
|
|
4c28b4879c | ||
| 9127d5c5ea | |||
| 090e54ec5a | |||
| d0c9f93ebb | |||
| e2cdc5b576 | |||
| bfe35ad885 | |||
| 0f06522c28 | |||
| f47eba5af7 | |||
| f28a7ae82f | |||
| 296b11b7b8 | |||
| a0d9a729dd | |||
| 1de8c7240d | |||
| 70126ef1b3 | |||
| cdf29f8a54 | |||
| 6171b9bed1 | |||
| 60fd09000b | |||
| 4292def206 | |||
| 90f149b09c | |||
| ed52105c02 | |||
| 1950cb59a2 | |||
|
|
9753e6d6b4 | ||
|
|
3488f05960 | ||
| c809ab6b4e | |||
|
|
c6674c4a2d | ||
| 35c5b5fb78 | |||
| 739ba63e6c | |||
| ec78cf8bf7 | |||
| 17052aeeaa | |||
| d7bbda6e7b | |||
| 6a08c1de10 | |||
| 3c4bd39384 | |||
| a4069dae99 | |||
|
|
55cd556cd6 | ||
| a21da11a91 | |||
| 08fa7de01d | |||
| 2a58326cd1 | |||
| 8a17c36a5b | |||
| 7bd6f6a8db | |||
| 3ba870be4b | |||
| bd2b6a3759 | |||
| a3a8f57bfc | |||
| fd393a4d30 | |||
| e8bd48e51b | |||
| 0191180f31 | |||
| 60ed56b1b9 | |||
|
|
da722afed3 | ||
| d8eb51e49c | |||
| c700a45ab6 | |||
| b806a34edb | |||
| 21989e6fa5 | |||
| 0539c5649d | |||
| ad488ff72d | |||
| 02e0309a41 | |||
| b7f4af7883 | |||
| cc48a4f36b | |||
| 46ed3330fc | |||
| 1fa1872ca6 | |||
|
|
c389a23365 | ||
| eaf9bda077 | |||
| 84a248a5a9 | |||
|
|
711c1d561a | ||
| 21210b4336 | |||
| 3bd480b75e | |||
| 2b19650e46 | |||
| 23482531c5 | |||
| cfda9ba899 | |||
| 698bd78684 | |||
| b97676dd3e | |||
| 25ae4f2201 | |||
|
|
59435ccd13 | ||
|
|
e81912c5e9 | ||
| af1b4e60d3 | |||
| 648cbf6f80 | |||
| 7b06a82ee7 | |||
|
|
d18de93c60 | ||
| df15eb7a03 | |||
|
|
06674df6cc | ||
|
|
8295625a44 | ||
| b11e2a4291 | |||
| 353c18bb76 | |||
|
|
02b0c9e48a | ||
|
|
ff73c8ac88 | ||
|
|
bc48391a1a | ||
| b0a443c002 | |||
| bef1f136ad | |||
| 9ba584bf14 | |||
|
|
43509fc943 | ||
|
|
4a99eb94e2 | ||
| 74426e13c8 | |||
| bd45c36072 | |||
| c13aefcd15 | |||
| 167caee8bc | |||
| d527078d5c | |||
|
|
763ace5ddf | ||
| 057c57b70f | |||
| cb71786ac1 | |||
|
|
67afeac198 | ||
| f4ee25de8e | |||
| 445a218a9e | |||
| f09139ffbe | |||
| 446721729b | |||
|
|
e0250d7f5c | ||
| 9fcdac4edb | |||
| b726ae3c7c | |||
| a3460418f6 | |||
| f65175f11e | |||
| 16efd495a0 | |||
| ed6423e4aa | |||
| 0e9418949b | |||
| 240fe8bc7c | |||
| c3482cddd8 | |||
| d13e7b3ef6 | |||
| 47800bd2ff | |||
| c0305db5fc | |||
| 0b745cb40e | |||
| a20f5ca15d | |||
| c29b4e173e | |||
| 33dd8b1d8a | |||
| 1503d90bd5 | |||
| 6581ffb92b | |||
| 939dfd9cc1 | |||
| 7744a5e17c | |||
| 3301af5cbb | |||
| 3f1218e7bc | |||
| fbcb3ae6dc | |||
| e93aedb703 | |||
| dae4b1d52b | |||
| f908c46a19 | |||
| ab27bd5f44 | |||
| 72870bb131 | |||
| 1822eac488 | |||
| 0487b8a801 | |||
| 67c6177291 | |||
| ad6ae6745d | |||
| a9d10ff93b | |||
| e0d4c53098 | |||
| 2c8571ecc7 | |||
| a8cd34d998 | |||
| a5ad4fe05c | |||
| f2504071cd | |||
| 73f90ebaf9 | |||
| c172c0f80f | |||
| aa80301778 | |||
| c04ca3a1ab | |||
| 3eae38e1cb | |||
| 87099c6388 | |||
| 7554a35c31 | |||
| 70707f69c8 | |||
|
|
a98ffd4887 | ||
|
|
2e23b3ae06 | ||
| 8e8e6fe244 | |||
| 2726bfd595 | |||
| 542b6033c2 | |||
| fcde669685 | |||
| f4cbcee8b4 | |||
| ba13ac7535 | |||
| 9f27d68533 | |||
| 698f5a5d6d | |||
| 7856d6d49d | |||
| a52fb3c437 | |||
| 499765c10a | |||
| 56fab1dda6 | |||
| b1d2496f8e | |||
| ddbbcf41b5 | |||
| 55d6318614 | |||
| be333260f2 | |||
| e1edba8a78 | |||
| 4fc3cc8a80 | |||
| 893f3f7181 | |||
| 4103b509d4 | |||
| ed538c91c6 | |||
| b4dac2d477 | |||
| 3956ed622d | |||
| e1db873bd5 | |||
| 227c2ddefa | |||
| a6da07cd3f | |||
| 9591d8626d | |||
| ee4e6b1ee6 | |||
| a882ead649 | |||
| 0522611669 | |||
| 2536630ff7 | |||
| 4670778181 | |||
| a6ca2589ab | |||
| d9e8d05db7 | |||
| ec2ac2dce3 | |||
| 55298515af | |||
| 344bdc0c66 | |||
| ba88a4e0f2 | |||
| 17c64ee357 | |||
| ba93bdbb91 | |||
| 591373fd52 | |||
| 2fcc4dead1 | |||
| d9ab7893e0 | |||
|
|
a93ebd3861 | ||
| 7c4ec71089 | |||
|
|
e9d845cf25 | ||
| 8883be7ed6 | |||
| 132ea7f887 | |||
|
|
f9402f5c4f | ||
| 72a38e3aa7 | |||
| 38e82a4feb | |||
| 6440680898 | |||
| e507187044 | |||
|
|
feeb92b6ef | ||
|
|
2d4a77e8ed | ||
| 6f5ea1229d | |||
| 68886ad584 | |||
| 5f90bd0d22 | |||
| 8b434d577f | |||
| f2b1458bd2 | |||
| 7507cd9ba1 | |||
| 0d43c13928 | |||
| 95124e5ded | |||
| a42a2788ea | |||
| e30274dab3 | |||
|
|
740b7588bc | ||
| 24c2ed4eb2 | |||
| 4006c0010e | |||
| 7decf264d7 | |||
| 482b218f74 | |||
| e06b760e41 | |||
| 7efc35f622 | |||
| 8795923443 | |||
| 4093821fd0 | |||
| b19637bdb7 | |||
| 21e758ec13 | |||
| 48ab123850 | |||
| a401070031 | |||
| e5e4109e79 | |||
|
|
d62c814f33 | ||
| 2a92b7c202 | |||
| 255dcb43fe | |||
| a528b646e3 | |||
| fc35745c0d | |||
| 9ddf3471ce | |||
| 8355ad6863 | |||
| 217ac490b1 | |||
| 092cf49227 | |||
| 5318f6c4cb | |||
| 80f675cb54 | |||
| 6f68c2762b | |||
| f4390b29e2 | |||
| 00e4f9d357 | |||
| d28d183620 | |||
| 3c6c9c86d1 | |||
| bcd079c88e | |||
|
|
d989d6ffad | ||
| 5229458746 | |||
| 2bfa1db816 | |||
| 8439428ce1 | |||
| 34dceef4a3 | |||
|
|
619bfb8dff | ||
| 7759851541 | |||
| 9112c1c24a | |||
| 24b21a9451 | |||
| 31a53b9c48 | |||
| dc229f40cb | |||
| 54ad1e6e1d | |||
|
|
065ccbbea4 | ||
| 74738c36cd | |||
|
|
2fdf437789 | ||
| 731c72535c | |||
| 628102087e | |||
| 536ea30ed2 | |||
| 8ee38cdb42 | |||
|
|
a896300f23 | ||
| d3cf1200ba | |||
| b5ac3df090 | |||
| 3b40dd6903 | |||
|
|
efba6b20ea | ||
|
|
05fb56e5fc | ||
|
|
59d9646e9f | ||
| b73d84fccb | |||
| 1929ceb72d | |||
| a1d22c1daf | |||
| cf7af1ba64 | |||
| 933ca758ee | |||
| f537209b92 | |||
| 6777610b07 | |||
| 88803cd3cd | |||
|
|
6adf5933b0 | ||
| 9521a49fff | |||
|
|
5789a105f5 | ||
| b7a18bea34 | |||
| 7117ed05a9 | |||
| c53bdb68e5 | |||
| 6725dca807 | |||
|
|
077712cf43 | ||
| 2794c78ee1 | |||
| 21574023db | |||
| 954b729dc9 | |||
| 6dc4e1cde6 | |||
| efbdf26706 | |||
| b41ec353c6 | |||
| 875225591a | |||
| 04c1223f2e | |||
| 773e49afa2 | |||
| 025d7a623b | |||
| b6caab15e1 | |||
| 1d3d0a17dc | |||
| 1cbe781698 | |||
| dc5b4f8ac1 | |||
| fee4ad7b98 | |||
| d5647d7452 | |||
| 0a5076f06c | |||
| a3632571ff | |||
| 5c48ebe103 | |||
| 1c3119577f | |||
| 0710996a0d | |||
| 0cdf199cb5 | |||
| cb9006abb2 | |||
| 108ecafab7 | |||
| 6b030f2902 | |||
| ce864c8990 | |||
| ee3e8eb105 | |||
| 701712e7b8 | |||
| dad388c6ab | |||
|
|
912c740c51 | ||
| da0b48c5df | |||
| 64b4745993 | |||
| 8aa2ef39c5 | |||
| a945f04959 | |||
| c93989237a | |||
| 95cf3f60f3 | |||
|
|
dd21633624 | ||
| f01074ea9f | |||
| c8d04f4695 | |||
| 2f8aa66ff6 | |||
| 3ad6830bfb | |||
| f2dddf97f5 | |||
| e218ebee89 | |||
| fd5ecc18a9 | |||
| e7738fb128 | |||
| 0b25a4a04b | |||
| ace58ecdd5 | |||
| 6685d9af38 | |||
| 60b803f419 | |||
| 555c8ec08a | |||
| 42eb882f52 | |||
| b6784aa979 | |||
| 08a7bdc6d5 | |||
| 6fac37a0ca | |||
| 37ed0f4892 | |||
| 1cb2d8cb41 | |||
| 3abce5e6d6 | |||
| 842f9e14e0 | |||
| 0c8dcef937 | |||
|
|
50f81a7d0b | ||
| dcacf23625 | |||
|
|
b01af39445 | ||
| 507628bcaa | |||
|
|
4dfea49f71 | ||
| 854a47f266 | |||
| b1a44f2cbf | |||
| cade8c8b4c | |||
| de88ca51fe | |||
| 7c8d8a09fd | |||
| e1e54c1a98 | |||
| 0de72eb009 | |||
| 823fb0f239 | |||
|
|
3660db4887 | ||
| b18ae56c36 | |||
| 939a72f945 | |||
| 489ab6bd0b | |||
| 7fa1e89dc8 | |||
| 3aa4f294f9 | |||
| cd3b9ada5a | |||
| 620e763380 | |||
| 0777c483e5 | |||
| 893663561d | |||
| 35650a40f2 | |||
|
|
3b46e71525 | ||
|
|
2fcbf1987b | ||
|
|
c3f399ea0b | ||
|
|
770a63de63 | ||
|
|
bc4c3b9803 | ||
|
|
043c1b1220 | ||
|
|
d20ee26e22 | ||
|
|
8930300fb5 | ||
|
|
140b8a47bf | ||
|
|
ced23341d2 | ||
|
|
0946e9125e | ||
|
|
bce76bd41c | ||
|
|
cb91373d33 | ||
|
|
c71bfb3f6d | ||
| 9627c40d75 | |||
|
|
1240353e30 | ||
|
|
cef6b9aca9 | ||
|
|
508a746578 | ||
|
|
222ef2ca32 | ||
|
|
32843018aa | ||
|
|
f80dd78a8e | ||
|
|
9df4835be3 | ||
|
|
480580890e | ||
|
|
ca57ef1760 | ||
|
|
8e39bca57c | ||
|
|
8d9ec0dcfd | ||
|
|
428d52f175 | ||
|
|
cdeb5afd28 | ||
|
|
1f3ba09cec |
44
.dockerignore
Normal file
@@ -0,0 +1,44 @@
|
||||
# Dependencies
|
||||
**/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Build Outputs
|
||||
**/.next/
|
||||
**/out/
|
||||
**/build
|
||||
**/dist
|
||||
**/target
|
||||
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Unnecessary files
|
||||
**/.git/
|
||||
.github/
|
||||
flatpak/*.xml
|
||||
flatpak/*.desktop
|
||||
flatpak/*.yml
|
||||
@@ -1,3 +0,0 @@
|
||||
/**/node_modules/*
|
||||
node_modules/
|
||||
dist/
|
||||
49
.eslintrc.js
@@ -1,49 +0,0 @@
|
||||
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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
72
.github/workflows/flatpak-bundle.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Flatpak
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
prepare-repo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: cache of container
|
||||
id: cache-container
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: prepare-dist
|
||||
key: ${{ runner.os }}-container-${{ hashFiles('prepare-dist') }}
|
||||
- name: Run latest-tag
|
||||
id: latest-tag
|
||||
uses: oprypin/find-latest-tag@v1
|
||||
with:
|
||||
repository:
|
||||
lumehq/lume
|
||||
#FIXME: lumehq after merged fix, now it just won't find tags
|
||||
# repository: ${{ github.repository }}
|
||||
|
||||
- name: Build container
|
||||
# if: steps.cache-container.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
docker buildx build -t flatpak-prepare-lume --build-arg=${{steps.latest-tag.outputs.tag}} --rm --output=. --target=final -f flatpak/Containerfile .
|
||||
- name: Copy flatpak files content
|
||||
run: |
|
||||
cp -r flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: repo-dist
|
||||
path: prepare-dist
|
||||
flatpak:
|
||||
name: flatpak-bundle
|
||||
needs: prepare-repo
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: bilelmoussaoui/flatpak-github-actions:gnome-45
|
||||
options: --privileged
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: repo-dist
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: flathub/shared-modules
|
||||
path: shared-modules
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
with:
|
||||
bundle: lume.flatpak
|
||||
manifest-path: nu.lume.Lume.yml
|
||||
restore-cache: false
|
||||
# cache-key: flatpak-builder-${{ github.sha }}
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
append_body: true
|
||||
files: lume.flatpak
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: geekyeggo/delete-artifact@v4
|
||||
with:
|
||||
name: repo-dist
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
78
.github/workflows/main.yml
vendored
@@ -1,69 +1,71 @@
|
||||
name: 'Publish'
|
||||
name: "Publish"
|
||||
on: workflow_dispatch
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: short
|
||||
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
|
||||
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-22.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: '--target x86_64-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
include:
|
||||
- platform: "macos-latest" # for Arm based macs (M1 and above).
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel based macs.
|
||||
args: "--target x86_64-apple-darwin"
|
||||
#- platform: 'ubuntu-22.04'
|
||||
# args: ''
|
||||
#- platform: 'windows-latest'
|
||||
# args: '--target x86_64-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.settings.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libssl-dev libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: Install pnpm
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install PNPM
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8.x.x
|
||||
run_install: false
|
||||
- name: Setup node and cache for package data
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
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
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
||||
|
||||
- name: Install frontend dependencies
|
||||
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 }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_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 }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'App v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseName: "v__VERSION__"
|
||||
releaseBody: "See the assets to download this version and install."
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.settings.args }}
|
||||
includeDebug: true
|
||||
args: ${{ matrix.args }}
|
||||
includeDebug: false
|
||||
|
||||
61
.gitignore
vendored
@@ -1,31 +1,38 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
out
|
||||
*.local
|
||||
.next
|
||||
.vscode
|
||||
*.db
|
||||
*.db-journal
|
||||
bun.lockb
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Turbo
|
||||
.turbo/
|
||||
|
||||
# Vercel
|
||||
.vercel/
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
|
||||
# Debug
|
||||
*.log.*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/.gtm/
|
||||
*.pem
|
||||
.vscode/
|
||||
ndb/
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm exec lint-staged
|
||||
@@ -1,9 +0,0 @@
|
||||
.tmp
|
||||
.cache/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
**/.yarn/**
|
||||
**/.pnp.*
|
||||
/dist*/
|
||||
node_modules/
|
||||
src-tauri/
|
||||
22
.prettierrc
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
51
README.md
@@ -1,41 +1,66 @@
|
||||
### Introduction
|
||||
## Introduction
|
||||
|
||||
Lume is a nostr client
|
||||
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||
|
||||
### Usage
|
||||
## Usage
|
||||
|
||||
Download Lume for your platform here: [https://github.com/luminous-devs/lume/releases](https://github.com/luminous-devs/lume/releases)
|
||||
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||
|
||||
Supported platform: macOS, Windows and Linux
|
||||
Supported platform: macOS. Windows and Linux are coming soon.
|
||||
|
||||
### Prerequisites
|
||||
Windows and Linux are availabel on v3 and below.
|
||||
|
||||
- Bun: https://bun.sh/docs/installation
|
||||
## Prerequisites
|
||||
|
||||
- Tauri: https://tauri.app/v1/guides/getting-started/prerequisites#setting-up-macos
|
||||
- Node.js >= 18: https://nodejs.org/en
|
||||
|
||||
### Develop
|
||||
- Rust: https://rustup.rs/
|
||||
|
||||
- PNPM: https://pnpm.io
|
||||
|
||||
- Tauri v2: https://beta.tauri.app/guides/prerequisites/
|
||||
|
||||
## Develop
|
||||
|
||||
Clone project
|
||||
|
||||
```
|
||||
git clone https://github.com/luminous-devs/lume.git && cd lume
|
||||
git clone https://github.com/lumehq/lume.git && cd lume
|
||||
```
|
||||
|
||||
Install packages
|
||||
|
||||
```
|
||||
bun install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Run dev build
|
||||
|
||||
```
|
||||
bun tauri dev
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
Generate production build
|
||||
|
||||
```
|
||||
bun tauri build
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
## Nix
|
||||
|
||||
Requirements:
|
||||
|
||||
1. [Install Nix](https://zero-to-flakes.com/install)
|
||||
1. [Setup `direnv`](https://zero-to-flakes.com/direnv)
|
||||
|
||||
`cd` into the root folder of the project to enter `nix develop` shell. Run `direnv allow` (only once). Then run `pnpm` or `bun` (experimental) commands as described above.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors (see AUTHORS.md)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
26
apps/desktop2/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
src/router.gen.ts
|
||||
14
apps/desktop2/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lume Desktop</title>
|
||||
</head>
|
||||
<body
|
||||
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
|
||||
>
|
||||
<div id="root" class="h-full w-full"></div>
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
64
apps/desktop2/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@lume/desktop2",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/query-sync-storage-persister": "^5.36.0",
|
||||
"@tanstack/react-query": "^5.36.0",
|
||||
"@tanstack/react-query-persist-client": "^5.36.0",
|
||||
"@tanstack/react-router": "1.32.5",
|
||||
"i18next": "^23.11.4",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nostr-tools": "^2.5.2",
|
||||
"react": "^18.3.1",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.51.4",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.1.1",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"slate": "^0.103.0",
|
||||
"slate-react": "^0.102.0",
|
||||
"sonner": "^1.4.41",
|
||||
"use-debounce": "^10.0.0",
|
||||
"virtua": "^0.31.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@tanstack/router-devtools": "^1.32.5",
|
||||
"@tanstack/router-vite-plugin": "^1.32.2",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
}
|
||||
0
apps/desktop2/public/.keep
Normal file
BIN
apps/desktop2/public/antenas.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/desktop2/public/antenas@2x.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
apps/desktop2/public/foryou.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/desktop2/public/foryou@2x.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
apps/desktop2/public/global.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/desktop2/public/global@2x.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
apps/desktop2/public/group.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/desktop2/public/group@2x.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
apps/desktop2/public/heading-en.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/desktop2/public/heading-en@2x.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
apps/desktop2/public/heading-fr.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
apps/desktop2/public/heading-fr@2x.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
apps/desktop2/public/heading-ja.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/desktop2/public/heading-ja@2x.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
apps/desktop2/public/lock-screen.jpg
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
apps/desktop2/public/lock-screen@2x.jpg
Normal file
|
After Width: | Height: | Size: 951 KiB |
BIN
apps/desktop2/public/newsfeed.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/desktop2/public/newsfeed@2x.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
apps/desktop2/public/trending.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/desktop2/public/trending@2x.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
apps/desktop2/public/waifu.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/desktop2/public/waifu@2x.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
122
apps/desktop2/src/app.css
Normal file
@@ -0,0 +1,122 @@
|
||||
@tailwind base;
|
||||
@tailwind utilities;
|
||||
@tailwind components;
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
@apply w-[5px];
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
@apply rounded bg-black dark:bg-white;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.content-break {
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.shadow-toolbar {
|
||||
box-shadow:
|
||||
0 0 #0000,
|
||||
0 0 #0000,
|
||||
0 8px 24px 0 rgba(0, 0, 0, 0.2),
|
||||
0 2px 8px 0 rgba(0, 0, 0, 0.08),
|
||||
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
|
||||
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
|
||||
}
|
||||
|
||||
.shadow-primary {
|
||||
box-shadow: 0px 0px 4px rgba(66, 65, 73, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Overide some default styles
|
||||
*/
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply cursor-default no-underline !important;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-default focus:outline-none;
|
||||
}
|
||||
|
||||
input::-ms-reveal,
|
||||
input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::-webkit-input-placeholder {
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.spinner-leaf {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(50% - 12.5% / 2);
|
||||
width: 12.5%;
|
||||
height: 100%;
|
||||
animation: spinner-leaf-fade 800ms linear infinite;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
background-color: currentColor;
|
||||
@apply rounded;
|
||||
}
|
||||
|
||||
&:where(:nth-child(1)) {
|
||||
transform: rotate(0deg);
|
||||
animation-delay: -800ms;
|
||||
}
|
||||
&:where(:nth-child(2)) {
|
||||
transform: rotate(45deg);
|
||||
animation-delay: -700ms;
|
||||
}
|
||||
&:where(:nth-child(3)) {
|
||||
transform: rotate(90deg);
|
||||
animation-delay: -600ms;
|
||||
}
|
||||
&:where(:nth-child(4)) {
|
||||
transform: rotate(135deg);
|
||||
animation-delay: -500ms;
|
||||
}
|
||||
&:where(:nth-child(5)) {
|
||||
transform: rotate(180deg);
|
||||
animation-delay: -400ms;
|
||||
}
|
||||
&:where(:nth-child(6)) {
|
||||
transform: rotate(225deg);
|
||||
animation-delay: -300ms;
|
||||
}
|
||||
&:where(:nth-child(7)) {
|
||||
transform: rotate(270deg);
|
||||
animation-delay: -200ms;
|
||||
}
|
||||
&:where(:nth-child(8)) {
|
||||
transform: rotate(315deg);
|
||||
animation-delay: -100ms;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-leaf-fade {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
65
apps/desktop2/src/app.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Ark } from "@lume/ark";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import React, { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import "./app.css";
|
||||
import i18n from "./locale";
|
||||
import { routeTree } from "./router.gen"; // auto generated file
|
||||
|
||||
const ark = new Ark();
|
||||
const queryClient = new QueryClient();
|
||||
const platformName = await platform();
|
||||
|
||||
const persister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
});
|
||||
|
||||
// Set up a Router instance
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
ark,
|
||||
queryClient,
|
||||
platform: platformName,
|
||||
},
|
||||
Wrap: ({ children }) => {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Register things for typesafety
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: idk
|
||||
const rootElement = document.getElementById("root")!;
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
47
apps/desktop2/src/components/avatarUploader.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function AvatarUploader({
|
||||
setPicture,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
setPicture: Dispatch<SetStateAction<string>>;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
try {
|
||||
const image = await ark.upload();
|
||||
setPicture(image);
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadAvatar()}
|
||||
className={cn("size-4", className)}
|
||||
>
|
||||
{loading ? <Spinner className="size-4" /> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
42
apps/desktop2/src/components/balance.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { User } from "@/components/user";
|
||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export function Balance({ account }: { account: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [balance, setBalance] = useState(0);
|
||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getBalance() {
|
||||
const val = await ark.get_balance();
|
||||
setBalance(val);
|
||||
}
|
||||
|
||||
getBalance();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-16 items-center justify-end px-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-end">
|
||||
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Your balance
|
||||
</div>
|
||||
<div className="font-medium leading-tight">
|
||||
₿ {value.bitcoinFormatted}
|
||||
</div>
|
||||
</div>
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-9 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
apps/desktop2/src/components/column.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { CancelIcon, CheckIcon } from "@lume/icons";
|
||||
import type { LumeColumn } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function Column({
|
||||
column,
|
||||
account,
|
||||
isScroll,
|
||||
isResize,
|
||||
}: {
|
||||
column: LumeColumn;
|
||||
account: string;
|
||||
isScroll: boolean;
|
||||
isResize: boolean;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const webviewLabel = `column-${account}_${column.label}`;
|
||||
|
||||
const [isCreated, setIsCreated] = useState(false);
|
||||
|
||||
const repositionWebview = async () => {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("reposition_column", {
|
||||
label: webviewLabel,
|
||||
x: newRect.x,
|
||||
y: newRect.y,
|
||||
});
|
||||
};
|
||||
|
||||
const resizeWebview = async () => {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("resize_column", {
|
||||
label: webviewLabel,
|
||||
width: newRect.width,
|
||||
height: newRect.height,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreated) resizeWebview();
|
||||
}, [isResize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScroll && isCreated) repositionWebview();
|
||||
}, [isScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const rect = container.current.getBoundingClientRect();
|
||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||
|
||||
// create new webview
|
||||
invoke("create_column", {
|
||||
label: webviewLabel,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
url,
|
||||
}).then(() => setIsCreated(true));
|
||||
|
||||
// close webview when unmounted
|
||||
return () => {
|
||||
invoke("close_column", { label: webviewLabel });
|
||||
};
|
||||
}, [account]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-[440px] shrink-0 p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col w-full h-full rounded-xl",
|
||||
column.label !== "open"
|
||||
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{column.label !== "open" ? (
|
||||
<Header label={column.label} name={column.name} />
|
||||
) : null}
|
||||
<div ref={container} className="flex-1 w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ label, name }: { label: string; name: string }) {
|
||||
const [title, setTitle] = useState(name);
|
||||
const [isChanged, setIsChanged] = useState(false);
|
||||
|
||||
const saveNewTitle = async () => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "set_title", label, title });
|
||||
|
||||
// update search params
|
||||
// @ts-ignore, hahaha
|
||||
search.name = title;
|
||||
|
||||
// reset state
|
||||
setIsChanged(false);
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "remove", label });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (title.length !== name.length) setIsChanged(true);
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
|
||||
<div className="size-7" />
|
||||
<div className="shrink-0 h-9 flex items-center justify-center">
|
||||
<div className="relative flex gap-2 items-center">
|
||||
<div
|
||||
contentEditable
|
||||
suppressContentEditableWarning={true}
|
||||
onBlur={(e) => setTitle(e.currentTarget.textContent)}
|
||||
className="text-sm font-medium focus:outline-none"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{isChanged ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveNewTitle()}
|
||||
className="text-teal-500 hover:text-teal-600"
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close()}
|
||||
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<CancelIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/desktop2/src/components/conversation.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ThreadIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function Conversation({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const thread = ark.get_thread(event.tags);
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<ThreadIcon className="size-4" />
|
||||
Thread
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||
</div>
|
||||
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
|
||||
<div>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
26
apps/desktop2/src/components/note/activity.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { User } from "../user";
|
||||
|
||||
export function NoteActivity({ className }: { className?: string }) {
|
||||
const event = useNoteContext();
|
||||
const mentions = event.tags
|
||||
.filter((tag) => tag[0] === "p")
|
||||
.map((tag) => tag[1]);
|
||||
|
||||
return (
|
||||
<div className={cn("-mt-3 mb-2", className)}>
|
||||
<div className="text-neutral-700 dark:text-neutral-300 inline-flex items-baseline gap-2 w-full overflow-hidden">
|
||||
<div className="shrink-0 text-sm font-medium">To:</div>
|
||||
{mentions.splice(0, 4).map((mention) => (
|
||||
<User.Provider key={mention} pubkey={mention}>
|
||||
<User.Root>
|
||||
<User.Name className="text-sm font-medium" prefix="@" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
{mentions.length > 4 ? "..." : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/desktop2/src/components/note/buttons/open.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { VisitIcon } from "@lume/icons";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteOpenThread() {
|
||||
const event = useNoteContext();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_event(event)}
|
||||
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
>
|
||||
<VisitIcon className="shrink-0 size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||
Open
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
38
apps/desktop2/src/components/note/buttons/reply.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ReplyIcon } from "@lume/icons";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function NoteReply({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_editor(event.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
large
|
||||
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
<ReplyIcon className="shrink-0 size-4" />
|
||||
{large ? "Reply" : null}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Reply
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
101
apps/desktop2/src/components/note/buttons/repost.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { QuoteIcon, RepostIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isRepost, setIsRepost] = useState(false);
|
||||
|
||||
const repost = async () => {
|
||||
try {
|
||||
if (isRepost) return;
|
||||
setLoading(true);
|
||||
|
||||
// repost
|
||||
await ark.repost(event.id, event.pubkey);
|
||||
|
||||
// update state
|
||||
setLoading(false);
|
||||
setIsRepost(true);
|
||||
|
||||
// notify
|
||||
toast.success("You've reposted this post successfully");
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error("Repost failed, try again later");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
|
||||
large
|
||||
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RepostIcon
|
||||
className={cn("size-4", isRepost ? "text-blue-500" : "")}
|
||||
/>
|
||||
)}
|
||||
{large ? "Repost" : null}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
</DropdownMenu.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{t("note.buttons.repost")}
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => repost()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
<RepostIcon className="size-4" />
|
||||
{t("note.buttons.repost")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_editor(event.id, true)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
<QuoteIcon className="size-4" />
|
||||
{t("note.buttons.quote")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
42
apps/desktop2/src/components/note/buttons/zap.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ZapIcon } from "@lume/icons";
|
||||
import { useRouteContext, useSearch } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { ark, settings } = useRouteContext({ strict: false });
|
||||
const { account } = useSearch({ strict: false });
|
||||
|
||||
const zap = async () => {
|
||||
try {
|
||||
const nwc = await ark.load_nwc();
|
||||
if (!nwc) {
|
||||
ark.open_nwc();
|
||||
} else {
|
||||
ark.open_zap(event.id, event.pubkey, account);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings.zap) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zap()}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
large
|
||||
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
<ZapIcon className="size-4" />
|
||||
{large ? "Zap" : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
47
apps/desktop2/src/components/note/child.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from ".";
|
||||
import { InfoIcon } from "@lume/icons";
|
||||
|
||||
export function NoteChild({
|
||||
eventId,
|
||||
isRoot,
|
||||
}: {
|
||||
eventId: string;
|
||||
isRoot?: boolean;
|
||||
}) {
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-3 px-3 flex items-center gap-2">
|
||||
<div className="size-8 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||
<div className="animate-pulse rounded-md h-4 w-2/3 bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="pt-3 px-3 flex items-center gap-2">
|
||||
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
|
||||
<InfoIcon className="size-5" />
|
||||
</div>
|
||||
<p className="text-red-500 text-sm">
|
||||
Event not found with your current relay set
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||
<div className="h-14 px-3 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
116
apps/desktop2/src/components/note/content.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NOSTR_EVENTS, NOSTR_MENTIONS, cn, parser } from "@lume/utils";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
import { MentionUser } from "./mentions/user";
|
||||
import { Images } from "./preview/images";
|
||||
import { Videos } from "./preview/videos";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export function NoteContent({
|
||||
quote = true,
|
||||
mention = true,
|
||||
clean,
|
||||
className,
|
||||
}: {
|
||||
quote?: boolean;
|
||||
mention?: boolean;
|
||||
clean?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const event = useNoteContext();
|
||||
const data = useMemo(() => {
|
||||
const { content, images, videos } = parser(event.content);
|
||||
const words = content.split(/( |\n)/);
|
||||
const hashtags = words.filter((word) => word.startsWith("#"));
|
||||
const events = words.filter((word) =>
|
||||
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
||||
);
|
||||
const mentions = words.filter((word) =>
|
||||
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
|
||||
);
|
||||
|
||||
let richContent: ReactNode[] | string = content;
|
||||
|
||||
try {
|
||||
if (hashtags.length) {
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
||||
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length) {
|
||||
for (const event of events) {
|
||||
if (quote) {
|
||||
richContent = reactStringReplace(richContent, event, (_, index) => (
|
||||
<MentionNote key={event + index} eventId={event} />
|
||||
));
|
||||
}
|
||||
|
||||
if (!quote && clean) {
|
||||
richContent = reactStringReplace(richContent, event, () => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mentions.length) {
|
||||
for (const user of mentions) {
|
||||
if (mention) {
|
||||
richContent = reactStringReplace(richContent, user, (_, index) => (
|
||||
<MentionUser key={user + index} pubkey={user} />
|
||||
));
|
||||
}
|
||||
|
||||
if (!mention && clean) {
|
||||
richContent = reactStringReplace(richContent, user, () => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
richContent = reactStringReplace(
|
||||
richContent,
|
||||
/(https?:\/\/\S+)/gi,
|
||||
(match, index) => (
|
||||
<a
|
||||
key={match + index}
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="line-clamp-1 text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
),
|
||||
);
|
||||
|
||||
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||
<div key={nanoid()} className="h-3" />
|
||||
));
|
||||
|
||||
return { content: richContent, images, videos };
|
||||
} catch (e) {
|
||||
return { content, images, videos };
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"select-text text-[15px] text-balance break-words overflow-hidden",
|
||||
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{data.content}
|
||||
</div>
|
||||
{data.images.length ? <Images urls={data.images} /> : null}
|
||||
{data.videos.length ? <Videos urls={data.videos} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
apps/desktop2/src/components/note/contentLarge.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Settings } from "@lume/types";
|
||||
import {
|
||||
AUDIOS,
|
||||
IMAGES,
|
||||
NOSTR_EVENTS,
|
||||
NOSTR_MENTIONS,
|
||||
VIDEOS,
|
||||
cn,
|
||||
} from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { nanoid } from "nanoid";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
import { MentionUser } from "./mentions/user";
|
||||
import { ImagePreview } from "./preview/image";
|
||||
import { VideoPreview } from "./preview/video";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteContentLarge({
|
||||
compact = true,
|
||||
className,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { settings }: { settings: Settings } = useRouteContext({
|
||||
strict: false,
|
||||
});
|
||||
const event = useNoteContext();
|
||||
const content = useMemo(() => {
|
||||
const text = event.content.trim();
|
||||
const words = text.split(/( |\n)/);
|
||||
|
||||
// @ts-ignore, kaboom !!!
|
||||
let parsedContent: ReactNode[] = compact
|
||||
? text.replace(/\n\s*\n/g, "\n")
|
||||
: text;
|
||||
|
||||
const hashtags = words.filter((word) => word.startsWith("#"));
|
||||
const events = words.filter((word) =>
|
||||
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
||||
);
|
||||
const mentions = words.filter((word) =>
|
||||
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
|
||||
);
|
||||
|
||||
try {
|
||||
if (hashtags.length) {
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
parsedContent = reactStringReplace(parsedContent, regex, () => {
|
||||
return <Hashtag key={nanoid()} tag={hashtag} />;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length) {
|
||||
for (const event of events) {
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
event,
|
||||
(match, i) => <MentionNote key={match + i} eventId={event} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mentions.length) {
|
||||
for (const mention of mentions) {
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
mention,
|
||||
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
/(https?:\/\/\S+)/gi,
|
||||
(match, i) => {
|
||||
try {
|
||||
const url = new URL(match);
|
||||
const ext = url.pathname.split(".")[1];
|
||||
|
||||
if (!settings.enhancedPrivacy) {
|
||||
if (IMAGES.includes(ext)) {
|
||||
return <ImagePreview key={match + i} url={url.toString()} />;
|
||||
}
|
||||
|
||||
if (VIDEOS.includes(ext)) {
|
||||
return <VideoPreview key={match + i} url={url.toString()} />;
|
||||
}
|
||||
|
||||
if (AUDIOS.includes(ext)) {
|
||||
return <VideoPreview key={match + i} url={url.toString()} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
key={match + i}
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<a
|
||||
key={match + i}
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
parsedContent = reactStringReplace(parsedContent, /\n*\n/g, () => (
|
||||
<div key={nanoid()} className="h-1.5" />
|
||||
));
|
||||
}
|
||||
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
/[\r]?\n[\r]?\n/g,
|
||||
(_, index) => <br key={event.id + "_br_" + index} />,
|
||||
);
|
||||
|
||||
return parsedContent;
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("select-text", className)}>
|
||||
<div className="text-[15px] text-balance content-break leading-normal">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
apps/desktop2/src/components/note/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NoteActivity } from "./activity";
|
||||
import { NoteOpenThread } from "./buttons/open";
|
||||
import { NoteReply } from "./buttons/reply";
|
||||
import { NoteRepost } from "./buttons/repost";
|
||||
import { NoteZap } from "./buttons/zap";
|
||||
import { NoteChild } from "./child";
|
||||
import { NoteContent } from "./content";
|
||||
import { NoteContentLarge } from "./contentLarge";
|
||||
import { NoteMenu } from "./menu";
|
||||
import { NoteProvider } from "./provider";
|
||||
import { NoteRoot } from "./root";
|
||||
import { NoteUser } from "./user";
|
||||
|
||||
export const Note = {
|
||||
Provider: NoteProvider,
|
||||
Root: NoteRoot,
|
||||
User: NoteUser,
|
||||
Menu: NoteMenu,
|
||||
Reply: NoteReply,
|
||||
Repost: NoteRepost,
|
||||
Content: NoteContent,
|
||||
ContentLarge: NoteContentLarge,
|
||||
Zap: NoteZap,
|
||||
Open: NoteOpenThread,
|
||||
Child: NoteChild,
|
||||
Activity: NoteActivity,
|
||||
};
|
||||
13
apps/desktop2/src/components/note/mentions/hashtag.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export function Hashtag({ tag }: { tag: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="break-all cursor-default leading-normal group"
|
||||
>
|
||||
<span className="text-blue-500">#</span>
|
||||
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||
{tag.replace("#", "")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
78
apps/desktop2/src/components/note/mentions/note.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { LinkIcon } from "@lume/icons";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@lume/utils";
|
||||
import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
|
||||
export function MentionNote({
|
||||
eventId,
|
||||
openable = true,
|
||||
}: {
|
||||
eventId: string;
|
||||
openable?: boolean;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="mt-2 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||
{t("note.error")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-12 items-center gap-2 px-3">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
||||
<div className="inline-flex flex-1 items-center gap-2">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<User.Time
|
||||
time={data.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 select-text content-break whitespace-normal text-balance leading-normal",
|
||||
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
|
||||
)}
|
||||
>
|
||||
{data.content}
|
||||
</div>
|
||||
{openable ? (
|
||||
<div className="flex h-14 items-center justify-end px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
ark.open_event_id(data.id);
|
||||
}}
|
||||
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-neutral-100 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||
>
|
||||
View post
|
||||
<LinkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/desktop2/src/components/note/mentions/user.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useProfile } from "@lume/ark";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_profile(pubkey)}
|
||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{isLoading
|
||||
? "@anon"
|
||||
: isError
|
||||
? displayNpub(pubkey, 16)
|
||||
: `@${
|
||||
profile?.name || profile?.display_name || profile?.name || "anon"
|
||||
}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
108
apps/desktop2/src/components/note/menu.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { HorizontalDotsIcon } from "@lume/icons";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteMenu() {
|
||||
const event = useNoteContext();
|
||||
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { t } = useTranslation();
|
||||
|
||||
const copyID = async () => {
|
||||
await writeText(await ark.event_to_bech32(event.id, [""]));
|
||||
toast.success("Copied");
|
||||
};
|
||||
|
||||
const copyRaw = async () => {
|
||||
await writeText(JSON.stringify(event));
|
||||
toast.success("Copied");
|
||||
};
|
||||
|
||||
const copyNpub = async () => {
|
||||
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
|
||||
toast.success("Copied");
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
await writeText(
|
||||
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
|
||||
);
|
||||
toast.success("Copied");
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_event(event)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.viewThread")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyLink()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyLink")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyID()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyNoteId")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyNpub()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyAuthorId")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
onClick={() => ark.open_profile(event.pubkey)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.viewAuthor")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyRaw()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyRaw")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
61
apps/desktop2/src/components/note/preview/image.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
|
||||
import { downloadDir } from "@tauri-apps/api/path";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { download } from "@tauri-apps/plugin-upload";
|
||||
import { type SyntheticEvent, useState } from "react";
|
||||
|
||||
export function ImagePreview({ url }: { url: string }) {
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
|
||||
const downloadImage = async (e: { stopPropagation: () => void }) => {
|
||||
try {
|
||||
e.stopPropagation();
|
||||
|
||||
const downloadDirPath = await downloadDir();
|
||||
const filename = url.substring(url.lastIndexOf("/") + 1);
|
||||
await download(url, `${downloadDirPath}/${filename}`);
|
||||
|
||||
setDownloaded(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
const name = new URL(url).pathname.split("/").pop();
|
||||
return new WebviewWindow("image-viewer", {
|
||||
url,
|
||||
title: name,
|
||||
});
|
||||
};
|
||||
|
||||
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
event.currentTarget.src = "/fallback-image.jpg";
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div onClick={() => open()} className="group relative my-1">
|
||||
<img
|
||||
src={url}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
onError={fallback}
|
||||
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => downloadImage(e)}
|
||||
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-white/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
|
||||
>
|
||||
{downloaded ? (
|
||||
<CheckCircleIcon className="size-4" />
|
||||
) : (
|
||||
<DownloadIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
apps/desktop2/src/components/note/preview/images.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { Carousel, CarouselItem } from "@lume/ui";
|
||||
|
||||
export function Images({ urls }: { urls: string[] }) {
|
||||
const open = async (url: string) => {
|
||||
const name = new URL(url).pathname
|
||||
.split("/")
|
||||
.pop()
|
||||
.replace(/[^a-zA-Z ]/g, "");
|
||||
const label = `viewer-${name}`;
|
||||
const window = WebviewWindow.getByLabel(label);
|
||||
|
||||
if (!window) {
|
||||
const newWindow = new WebviewWindow(label, {
|
||||
url,
|
||||
title: "Image Viewer",
|
||||
width: 800,
|
||||
height: 800,
|
||||
titleBarStyle: "overlay",
|
||||
});
|
||||
|
||||
return newWindow;
|
||||
}
|
||||
|
||||
return await window.setFocus();
|
||||
};
|
||||
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="group px-3">
|
||||
<img
|
||||
src={urls[0]}
|
||||
alt={urls[0]}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(urls[0])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
items={urls}
|
||||
renderItem={({ item, isSnapPoint }) => (
|
||||
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||
<img
|
||||
src={item}
|
||||
alt={item}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(item)}
|
||||
/>
|
||||
</CarouselItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
87
apps/desktop2/src/components/note/preview/link.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useOpenGraph } from "@lume/utils";
|
||||
|
||||
function isImage(url: string) {
|
||||
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
|
||||
}
|
||||
|
||||
export function LinkPreview({ url }: { url: string }) {
|
||||
const domain = new URL(url);
|
||||
const { isLoading, isError, data } = useOpenGraph(url);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
|
||||
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
||||
{domain.hostname}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.title && !data.image && !data.description) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-block text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-block text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
|
||||
>
|
||||
{isImage(data.image) ? (
|
||||
<img
|
||||
src={data.image}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-col items-start p-3">
|
||||
<div className="flex flex-col items-start text-left">
|
||||
{data.title ? (
|
||||
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{data.title}
|
||||
</div>
|
||||
) : null}
|
||||
{data.description ? (
|
||||
<div className="content-break mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
|
||||
{data.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="break-all text-sm font-semibold text-blue-500">
|
||||
{domain.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
14
apps/desktop2/src/components/note/preview/video.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function VideoPreview({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded-xl">
|
||||
<video
|
||||
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source src={url} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
apps/desktop2/src/components/note/preview/videos.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Carousel, CarouselItem } from "@lume/ui";
|
||||
|
||||
export function Videos({ urls }: { urls: string[] }) {
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="group px-3">
|
||||
<video
|
||||
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source src={urls[0]} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
items={urls}
|
||||
renderItem={({ item, isSnapPoint }) => (
|
||||
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||
<video
|
||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls={false}
|
||||
muted
|
||||
>
|
||||
<source src={item} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</CarouselItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
apps/desktop2/src/components/note/provider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const EventContext = createContext<Event>(null);
|
||||
|
||||
export function NoteProvider({
|
||||
event,
|
||||
children,
|
||||
}: { event: Event; children: ReactNode }) {
|
||||
return (
|
||||
<EventContext.Provider value={event}>{children}</EventContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNoteContext() {
|
||||
const context = useContext(EventContext);
|
||||
if (!context) {
|
||||
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
16
apps/desktop2/src/components/note/root.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function NoteRoot({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-min w-full", className)} contentEditable={false}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
apps/desktop2/src/components/note/user.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { User } from "../user";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteUser({ className }: { className?: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
|
||||
return (
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<HoverCard.Root>
|
||||
<User.Root
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
<HoverCard.Trigger className="shrink-0">
|
||||
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
|
||||
</HoverCard.Trigger>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<div className="text-neutral-600 dark:text-neutral-400">·</div>
|
||||
<User.Time
|
||||
time={event.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
|
||||
sideOffset={5}
|
||||
side="right"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<User.Avatar className="size-11 rounded-lg object-cover" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
|
||||
<button
|
||||
onClick={() => ark.open_profile(event.pubkey)}
|
||||
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||
>
|
||||
View profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<HoverCard.Arrow className="fill-black dark:fill-white" />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
</User.Provider>
|
||||
);
|
||||
}
|
||||
32
apps/desktop2/src/components/notification.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function Notification({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
47
apps/desktop2/src/components/quote.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { QuoteIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function Quote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
const quoteEventId = event.tags.find(
|
||||
(tag) => tag[0] === "q" || tag[3] === "mention",
|
||||
)?.[1];
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Note.Child eventId={quoteEventId} isRoot />
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<QuoteIcon className="size-4" />
|
||||
Quote
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" quote={false} clean />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
85
apps/desktop2/src/components/repost.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function RepostNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: repostEvent,
|
||||
} = useQuery({
|
||||
queryKey: ["repost", event.id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
if (event.content.length > 50) {
|
||||
const embed: Event = JSON.parse(event.content);
|
||||
return embed;
|
||||
}
|
||||
|
||||
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
||||
const repostEvent = await ark.get_event(id);
|
||||
|
||||
return repostEvent;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
|
||||
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Reposted by
|
||||
</div>
|
||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
{isLoading ? (
|
||||
<div className="flex h-20 items-center justify-center gap-2">
|
||||
<Spinner />
|
||||
Loading event...
|
||||
</div>
|
||||
) : isError || !repostEvent ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
Event not found within your current relay set
|
||||
</div>
|
||||
) : (
|
||||
<Note.Provider event={repostEvent}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
)}
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
34
apps/desktop2/src/components/text.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from "@/components/note";
|
||||
|
||||
export function TextNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
15
apps/desktop2/src/components/toolbar.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "@tanstack/react-router";
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function Toolbar({ children }: { children: ReactNode }) {
|
||||
const [domReady, setDomReady] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setDomReady(true);
|
||||
}, []);
|
||||
|
||||
return domReady
|
||||
? createPortal(children, document.getElementById("toolbar"))
|
||||
: null;
|
||||
}
|
||||
12
apps/desktop2/src/components/user/about.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserAbout({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
|
||||
return (
|
||||
<div className={cn("content-break select-text", className)}>
|
||||
{user.profile?.about?.trim() || "No bio"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/desktop2/src/components/user/avatar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useMemo } from "react";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserAvatar({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
|
||||
const fallbackAvatar = useMemo(
|
||||
() =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(user.pubkey || nanoid(), 90, 50),
|
||||
)}`,
|
||||
[user],
|
||||
);
|
||||
|
||||
return (
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user.profile?.picture}
|
||||
alt={user.pubkey}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className={cn("outline-[.5px] outline-black/5", className)}
|
||||
/>
|
||||
<Avatar.Fallback delayMs={120}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={user.pubkey}
|
||||
className={cn("bg-black dark:bg-white", className)}
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
36
apps/desktop2/src/components/user/cover.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserCover({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse bg-neutral-300 dark:bg-neutral-700",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (user && !user.profile.banner) {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={user.profile.banner}
|
||||
alt="banner"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className={cn("object-cover", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
apps/desktop2/src/components/user/followButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserFollowButton({
|
||||
simple = false,
|
||||
className,
|
||||
}: {
|
||||
simple?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const user = useUserContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const toggleFollow = async () => {
|
||||
setLoading(true);
|
||||
if (!followed) {
|
||||
const add = await ark.follow(user.pubkey);
|
||||
if (add) setFollowed(true);
|
||||
} else {
|
||||
const remove = await ark.unfollow(user.pubkey);
|
||||
if (remove) setFollowed(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function status() {
|
||||
setLoading(true);
|
||||
|
||||
const contacts = await ark.get_contact_list();
|
||||
if (contacts?.includes(user.pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
status();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => toggleFollow()}
|
||||
className={cn("w-max", className)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : followed ? (
|
||||
!simple ? (
|
||||
t("user.unfollow")
|
||||
) : null
|
||||
) : (
|
||||
t("user.follow")
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
21
apps/desktop2/src/components/user/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { UserAbout } from "./about";
|
||||
import { UserAvatar } from "./avatar";
|
||||
import { UserCover } from "./cover";
|
||||
import { UserFollowButton } from "./followButton";
|
||||
import { UserName } from "./name";
|
||||
import { UserNip05 } from "./nip05";
|
||||
import { UserProvider } from "./provider";
|
||||
import { UserRoot } from "./root";
|
||||
import { UserTime } from "./time";
|
||||
|
||||
export const User = {
|
||||
Provider: UserProvider,
|
||||
Root: UserRoot,
|
||||
Avatar: UserAvatar,
|
||||
Cover: UserCover,
|
||||
Name: UserName,
|
||||
NIP05: UserNip05,
|
||||
Time: UserTime,
|
||||
About: UserAbout,
|
||||
Button: UserFollowButton,
|
||||
};
|
||||
21
apps/desktop2/src/components/user/name.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { cn, displayNpub } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserName({
|
||||
className,
|
||||
prefix,
|
||||
}: {
|
||||
className?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
const user = useUserContext();
|
||||
|
||||
return (
|
||||
<div className={cn("max-w-[12rem] truncate", className)}>
|
||||
{prefix}
|
||||
{user.profile?.display_name ||
|
||||
user.profile?.name ||
|
||||
displayNpub(user.pubkey, 16)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
apps/desktop2/src/components/user/nip05.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { VerifiedIcon } from "@lume/icons";
|
||||
import { displayLongHandle, displayNpub } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserNip05() {
|
||||
const user = useUserContext();
|
||||
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { isLoading, data: verified } = useQuery({
|
||||
queryKey: ["nip05", user?.pubkey],
|
||||
queryFn: async () => {
|
||||
if (!user.profile?.nip05) return false;
|
||||
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
|
||||
return verify;
|
||||
},
|
||||
enabled: !!user.profile,
|
||||
});
|
||||
|
||||
if (!user.profile?.nip05?.length) return;
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger>
|
||||
{!isLoading && verified ? (
|
||||
<VerifiedIcon className="size-4 text-teal-500" />
|
||||
) : null}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm font-medium text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{!user.profile?.nip05
|
||||
? displayNpub(user.pubkey, 16)
|
||||
: user.profile?.nip05.length > 50
|
||||
? displayLongHandle(user.profile?.nip05)
|
||||
: user.profile.nip05?.replace("_@", "")}
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
33
apps/desktop2/src/components/user/provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useProfile } from "@lume/ark";
|
||||
import type { Metadata } from "@lume/types";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const UserContext = createContext<{
|
||||
pubkey: string;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
profile: Metadata;
|
||||
}>(null);
|
||||
|
||||
export function UserProvider({
|
||||
pubkey,
|
||||
children,
|
||||
embedProfile,
|
||||
}: {
|
||||
pubkey: string;
|
||||
children: ReactNode;
|
||||
embedProfile?: string;
|
||||
}) {
|
||||
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserContext() {
|
||||
const context = useContext(UserContext);
|
||||
return context;
|
||||
}
|
||||
12
apps/desktop2/src/components/user/root.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function UserRoot({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn(className)}>{children}</div>;
|
||||
}
|
||||
18
apps/desktop2/src/components/user/time.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cn, formatCreatedAt } from "@lume/utils";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function UserTime({
|
||||
time,
|
||||
className,
|
||||
}: {
|
||||
time: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
||||
|
||||
return (
|
||||
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
|
||||
{createdAt}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
apps/desktop2/src/locale.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import i18n from "i18next";
|
||||
import resourcesToBackend from "i18next-resources-to-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
i18n
|
||||
.use(
|
||||
resourcesToBackend(async (language: string) => {
|
||||
const file_path = await resolveResource(`locales/${language}.json`);
|
||||
return JSON.parse(await readTextFile(file_path));
|
||||
}),
|
||||
)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
187
apps/desktop2/src/routes/$account.home.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Column } from "@/components/column";
|
||||
import { Toolbar } from "@/components/toolbar";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import type { EventColumns, LumeColumn } from "@lume/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { VList, type VListHandle } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/$account/home")({
|
||||
loader: async ({ context }) => {
|
||||
try {
|
||||
const userColumns = await context.ark.get_columns();
|
||||
if (userColumns.length > 0) {
|
||||
return userColumns;
|
||||
} else {
|
||||
const systemPath = "resources/system_columns.json";
|
||||
const resourcePath = await resolveResource(systemPath);
|
||||
const resourceFile = await readTextFile(resourcePath);
|
||||
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
|
||||
|
||||
return systemColumns;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
}
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const userSavedColumns = Route.useLoaderData();
|
||||
const vlistRef = useRef<VListHandle>(null);
|
||||
|
||||
const { account } = Route.useParams();
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [isScroll, setIsScroll] = useState(false);
|
||||
const [isResize, setIsResize] = useState(false);
|
||||
|
||||
const goLeft = () => {
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
vlistRef.current.scrollToIndex(prevIndex, {
|
||||
align: "center",
|
||||
});
|
||||
};
|
||||
|
||||
const goRight = () => {
|
||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
||||
setSelectedIndex(nextIndex);
|
||||
vlistRef.current.scrollToIndex(nextIndex, {
|
||||
align: "center",
|
||||
});
|
||||
};
|
||||
|
||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||
// update col label
|
||||
column.label = `${column.label}-${nanoid()}`;
|
||||
|
||||
// create new cols
|
||||
const cols = [...columns];
|
||||
const openColIndex = cols.findIndex((col) => col.label === "open");
|
||||
const newCols = [
|
||||
...cols.slice(0, openColIndex),
|
||||
column,
|
||||
...cols.slice(openColIndex),
|
||||
];
|
||||
|
||||
setColumns(newCols);
|
||||
setSelectedIndex(newCols.length);
|
||||
setIsScroll(true);
|
||||
|
||||
// scroll to the newest column
|
||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||
align: "end",
|
||||
});
|
||||
}, 150);
|
||||
|
||||
const remove = useDebouncedCallback((label: string) => {
|
||||
const newCols = columns.filter((t) => t.label !== label);
|
||||
|
||||
setColumns(newCols);
|
||||
setSelectedIndex(newCols.length);
|
||||
setIsScroll(true);
|
||||
|
||||
// scroll to the first column
|
||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||
align: "start",
|
||||
});
|
||||
}, 150);
|
||||
|
||||
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||
const currentColIndex = columns.findIndex((col) => col.label === label);
|
||||
|
||||
const updatedCol = Object.assign({}, columns[currentColIndex]);
|
||||
updatedCol.name = title;
|
||||
|
||||
const newCols = columns.slice();
|
||||
newCols[currentColIndex] = updatedCol;
|
||||
|
||||
setColumns(newCols);
|
||||
}, 150);
|
||||
|
||||
const startResize = useDebouncedCallback(
|
||||
() => setIsResize((prev) => !prev),
|
||||
150,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(userSavedColumns);
|
||||
}, [userSavedColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
// save state
|
||||
ark.set_columns(columns);
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
||||
if (data.payload.type === "add") add(data.payload.column);
|
||||
if (data.payload.type === "remove") remove(data.payload.label);
|
||||
if (data.payload.type === "set_title")
|
||||
updateName(data.payload.label, data.payload.title);
|
||||
});
|
||||
|
||||
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
|
||||
startResize();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenColEvent.then((f) => f());
|
||||
unlistenWindowResize.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<VList
|
||||
ref={vlistRef}
|
||||
horizontal
|
||||
tabIndex={-1}
|
||||
itemSize={440}
|
||||
overscan={3}
|
||||
onScroll={() => setIsScroll(true)}
|
||||
onScrollEnd={() => setIsScroll(false)}
|
||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
||||
cache={null}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<Column
|
||||
key={column.label}
|
||||
column={column}
|
||||
account={account}
|
||||
isScroll={isScroll}
|
||||
isResize={isResize}
|
||||
/>
|
||||
))}
|
||||
</VList>
|
||||
<Toolbar>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goLeft()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goRight()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
apps/desktop2/src/routes/$account.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
BellIcon,
|
||||
ComposeFilledIcon,
|
||||
HorizontalDotsIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "@lume/icons";
|
||||
import { type Event, Kind } from "@lume/types";
|
||||
import { User } from "@/components/user";
|
||||
import {
|
||||
cn,
|
||||
decodeZapInvoice,
|
||||
displayNpub,
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
export const Route = createFileRoute("/$account")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const accounts = await ark.get_accounts();
|
||||
|
||||
return { accounts };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark, platform } = Route.useRouteContext();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"flex h-11 shrink-0 items-center justify-between pr-2",
|
||||
platform === "macos" ? "ml-2 pl-20" : "pl-4",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Accounts />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/landing/" })}
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_editor()}
|
||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
New Post
|
||||
</button>
|
||||
<Bell />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_search()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<SearchIcon className="size-5" />
|
||||
</button>
|
||||
<div id="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Accounts() {
|
||||
const { ark, accounts } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
|
||||
const [windowWidth, setWindowWidth] = useState<number>(null);
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
const sortedList = useMemo(() => {
|
||||
const list = accounts;
|
||||
|
||||
for (const [i, item] of list.entries()) {
|
||||
if (item === account) {
|
||||
list.splice(i, 1);
|
||||
list.unshift(item);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [accounts]);
|
||||
|
||||
const changeAccount = async (npub: string) => {
|
||||
if (npub === account) {
|
||||
return await ark.open_profile(account);
|
||||
}
|
||||
|
||||
// change current account and update signer
|
||||
const select = await ark.load_account(npub);
|
||||
|
||||
if (select) {
|
||||
return navigate({ to: "/$account/home", params: { account: npub } });
|
||||
} else {
|
||||
toast.warning("Something wrong.");
|
||||
}
|
||||
};
|
||||
|
||||
const getWindowDimensions = () => {
|
||||
const { innerWidth: width, innerHeight: height } = window;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowWidth(getWindowDimensions().width);
|
||||
}
|
||||
|
||||
if (!windowWidth) setWindowWidth(getWindowDimensions().width);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||
{sortedList
|
||||
.slice(0, windowWidth > 500 ? account.length : 2)
|
||||
.map((user) => (
|
||||
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
||||
<User.Provider pubkey={user}>
|
||||
<User.Root
|
||||
className={cn(
|
||||
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
|
||||
user === account
|
||||
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<User.Avatar
|
||||
className={cn(
|
||||
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
|
||||
user === account ? "w-7" : "w-8",
|
||||
)}
|
||||
/>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
{accounts.length >= 3 && windowWidth <= 700 ? (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-neutral-950 p-1 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{sortedList.slice(2).map((user) => (
|
||||
<button
|
||||
key={user}
|
||||
type="button"
|
||||
onClick={() => changeAccount(user)}
|
||||
className="size-9 inline-flex items-center justify-center hover:bg-white/10 rounded-md"
|
||||
>
|
||||
<User.Provider pubkey={user}>
|
||||
<User.Root className="rounded-full ring-1 ring-white/10">
|
||||
<User.Avatar className="size-7 aspect-square h-auto rounded-full object-cover" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bell() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = getCurrent().listen<string>(
|
||||
"activity",
|
||||
async (payload) => {
|
||||
setCount((prevCount) => prevCount + 1);
|
||||
await invoke("set_badge", { count });
|
||||
|
||||
const event: Event = JSON.parse(payload.payload);
|
||||
const user = await ark.get_profile(event.pubkey);
|
||||
const userName =
|
||||
user.display_name || user.name || displayNpub(event.pubkey, 16);
|
||||
|
||||
switch (event.kind) {
|
||||
case Kind.Text: {
|
||||
sendNativeNotification("Mentioned you in a note", userName);
|
||||
break;
|
||||
}
|
||||
case Kind.Repost: {
|
||||
sendNativeNotification("Reposted your note", userName);
|
||||
break;
|
||||
}
|
||||
case Kind.ZapReceipt: {
|
||||
const amount = decodeZapInvoice(event.tags);
|
||||
sendNativeNotification(
|
||||
`Zapped ₿ ${amount.bitcoinFormatted}`,
|
||||
userName,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCount(0);
|
||||
ark.open_activity(account);
|
||||
}}
|
||||
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<BellIcon className="size-5" />
|
||||
{count > 0 ? (
|
||||
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
61
apps/desktop2/src/routes/__root.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Ark } from "@lume/ark";
|
||||
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
|
||||
import type { Interests, Metadata, Settings } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import type { Platform } from "@tauri-apps/plugin-os";
|
||||
import type { Descendant } from "slate";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
type EditorElement = {
|
||||
type: string;
|
||||
children: Descendant[];
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
interface RouterContext {
|
||||
// System
|
||||
ark: Ark;
|
||||
queryClient: QueryClient;
|
||||
// App info
|
||||
platform?: Platform;
|
||||
locale?: string;
|
||||
// Settings
|
||||
settings?: Settings;
|
||||
interests?: Interests;
|
||||
// Profile
|
||||
accounts?: string[];
|
||||
profile?: Metadata;
|
||||
isNewUser?: boolean;
|
||||
// Editor
|
||||
initialValue?: EditorElement[];
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: () => (
|
||||
<>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
icons={{
|
||||
success: <CheckCircleIcon className="size-5" />,
|
||||
info: <InfoCircleIcon className="size-5" />,
|
||||
error: <CancelCircleIcon className="size-5" />,
|
||||
}}
|
||||
closeButton
|
||||
theme="system"
|
||||
/>
|
||||
<Outlet />
|
||||
</>
|
||||
),
|
||||
pendingComponent: Pending,
|
||||
wrapInSuspense: true,
|
||||
});
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
apps/desktop2/src/routes/activity.$account.texts.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account/texts")({
|
||||
loader: async ({ context, params }) => {
|
||||
const ark = context.ark;
|
||||
return { data: defer(ark.get_activities(params.account, "1")) };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { data } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<Virtualizer overscan={3}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
disabled
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(events) =>
|
||||
events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Activity className="px-3" />
|
||||
<Note.Content className="px-3" quote={false} clean />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Virtualizer>
|
||||
);
|
||||
}
|
||||
50
apps/desktop2/src/routes/activity.$account.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Box, Container } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
|
||||
return (
|
||||
<Container withDrag withNavigate={false}>
|
||||
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
|
||||
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
|
||||
<div className="inline-flex h-full w-full items-center gap-1">
|
||||
<Link to="/activity/$account/texts" params={{ account }}>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
Notes
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/activity/$account/zaps" params={{ account }}>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
Zaps
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
||||
<Outlet />
|
||||
</div>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
65
apps/desktop2/src/routes/activity.$account.zaps.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { decodeZapInvoice } from "@lume/utils";
|
||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account/zaps")({
|
||||
loader: async ({ context, params }) => {
|
||||
const ark = context.ark;
|
||||
return { data: defer(ark.get_activities(params.account, "9735")) };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { data } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<Virtualizer overscan={3}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
disabled
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(events) =>
|
||||
events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex flex-col">
|
||||
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
|
||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
||||
</div>
|
||||
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-7 rounded-full shrink-0" />
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
zapped you
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Virtualizer>
|
||||
);
|
||||
}
|
||||
16
apps/desktop2/src/routes/auth.lazy.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Box, Container } from "@lume/ui";
|
||||
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
return (
|
||||
<Container withDrag>
|
||||
<Box className="px-3 pt-3">
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
200
apps/desktop2/src/routes/auth/new/backup.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { CheckIcon } from "@lume/icons";
|
||||
import type { AppRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { displayNsec } from "@lume/utils";
|
||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/new/backup")({
|
||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useSearch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [key, setKey] = useState(null);
|
||||
const [passphase, setPassphase] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (key) {
|
||||
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
|
||||
return toast.warning("You need to confirm before continue");
|
||||
}
|
||||
|
||||
return navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account },
|
||||
});
|
||||
}
|
||||
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
invoke("get_encrypted_key", {
|
||||
npub: account,
|
||||
password: passphase,
|
||||
}).then((encrypted: string) => {
|
||||
// update state
|
||||
setKey(encrypted);
|
||||
setLoading(false);
|
||||
});
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const copyKey = async () => {
|
||||
try {
|
||||
await writeText(key);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="flex flex-col text-center">
|
||||
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">
|
||||
It's use for login to Lume or other Nostr clients. You will lost
|
||||
access to your account if you lose this key.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="passphase" className="font-medium">
|
||||
Set a passphase to secure your key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="passphase"
|
||||
type="password"
|
||||
value={passphase}
|
||||
onChange={(e) => setPassphase(e.target.value)}
|
||||
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{key ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="nsec" className="font-medium">
|
||||
Copy this key and keep it in safe place
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
name="nsec"
|
||||
type="text"
|
||||
value={displayNsec(key, 36)}
|
||||
readOnly
|
||||
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyKey()}
|
||||
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="font-medium">Before you continue:</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c1}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||
}
|
||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm1"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<CheckIcon className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm1"
|
||||
>
|
||||
{t("backup.confirm1")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c2}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||
}
|
||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm2"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<CheckIcon className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm2"
|
||||
>
|
||||
{t("backup.confirm2")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c3}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
||||
}
|
||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm3"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<CheckIcon className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm3"
|
||||
>
|
||||
{t("backup.confirm3")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
apps/desktop2/src/routes/auth/new/profile.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { AvatarUploader } from "@/components/avatarUploader";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import type { Metadata } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/new/profile")({
|
||||
component: Screen,
|
||||
loader: ({ context }) => {
|
||||
return context.ark.create_keys();
|
||||
},
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const keys = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const [picture, setPicture] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (data: {
|
||||
name: string;
|
||||
about: string;
|
||||
website: string;
|
||||
}) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Save account keys
|
||||
const save = await ark.save_account(keys.nsec);
|
||||
|
||||
// Then create profile
|
||||
if (save) {
|
||||
const profile: Metadata = { ...data, picture };
|
||||
const eventId = await ark.create_profile(profile);
|
||||
|
||||
if (eventId) {
|
||||
navigate({
|
||||
to: "/auth/new/backup",
|
||||
search: { account: keys.npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt="avatar"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarUploader
|
||||
setPicture={setPicture}
|
||||
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full dark:text-black bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-8" />
|
||||
</AvatarUploader>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="display_name" className="font-medium">
|
||||
{t("user.displayName")} *
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("display_name", { required: true, minLength: 1 })}
|
||||
placeholder="e.g. Alice in Nostrland"
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="name" className="font-medium">
|
||||
{t("user.name")}
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("name")}
|
||||
placeholder="e.g. alice"
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="about" className="font-medium">
|
||||
{t("user.bio")}
|
||||
</label>
|
||||
<textarea
|
||||
{...register("about")}
|
||||
placeholder="e.g. Artist, anime-lover, and k-pop fan"
|
||||
spellCheck={false}
|
||||
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="website" className="font-medium">
|
||||
{t("user.website")}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
{...register("website")}
|
||||
placeholder="e.g. https://alice.me"
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
apps/desktop2/src/routes/auth/privkey.lazy.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth/privkey")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const [key, setKey] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!key.startsWith("nsec1"))
|
||||
return toast.warning(
|
||||
"You need to enter a valid private key starts with nsec or ncryptsec",
|
||||
);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const npub = await ark.save_account(key, password);
|
||||
|
||||
if (npub) {
|
||||
navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account: npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="key"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Private Key
|
||||
</label>
|
||||
<input
|
||||
name="key"
|
||||
type="text"
|
||||
placeholder="nsec or ncryptsec..."
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Password (Optional)
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/desktop2/src/routes/auth/remote.lazy.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth/remote")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const [uri, setUri] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!uri.startsWith("bunker://"))
|
||||
return toast.warning(
|
||||
"You need to enter a valid Connect URI starts with bunker://",
|
||||
);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const npub = await ark.nostr_connect(uri);
|
||||
|
||||
if (npub) {
|
||||
navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account: npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="uri"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Connect URI
|
||||
</label>
|
||||
<input
|
||||
name="uri"
|
||||
type="text"
|
||||
placeholder="bunker://..."
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
{loading ? (
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-sm text-center">
|
||||
Waiting confirmation...
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
apps/desktop2/src/routes/auth/settings.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { LaurelIcon } from "@lume/icons";
|
||||
import type { AppRouteSearch, Settings } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/settings")({
|
||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ context }) => {
|
||||
const permissionGranted = await isPermissionGranted(); // get notification permission
|
||||
const ark = context.ark;
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
return {
|
||||
settings: { ...settings, notification: permissionGranted },
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
pendingComponent: Pending,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { account } = Route.useSearch();
|
||||
const { t } = useTranslation();
|
||||
const { ark, settings } = Route.useRouteContext();
|
||||
|
||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const toggleNofitication = async () => {
|
||||
await requestPermission();
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
notification: !newSettings.notification,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAutoUpdate = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
autoUpdate: !newSettings.autoUpdate,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleEnhancedPrivacy = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleZap = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
zap: !newSettings.zap,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleNsfw = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
nsfw: !newSettings.nsfw,
|
||||
}));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
// publish settings
|
||||
const eventId = await ark.set_settings(newSettings);
|
||||
|
||||
if (eventId) {
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
params: { account },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="flex flex-col items-center gap-5 text-center">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
|
||||
<LaurelIcon className="size-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t("onboardingSettings.title")}
|
||||
</h1>
|
||||
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
|
||||
{t("onboardingSettings.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Push Notification</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Enabling push notifications will allow you to receive
|
||||
notifications from Lume.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.notification}
|
||||
onClick={() => toggleNofitication()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Lume will display external resources like image, video or link
|
||||
preview as plain text.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.enhancedPrivacy}
|
||||
onClick={() => toggleEnhancedPrivacy()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Auto Update</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Automatically download and install new version.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.autoUpdate}
|
||||
onClick={() => toggleAutoUpdate()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Zap</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Show the Zap button in each note and user's profile screen, use
|
||||
for send Bitcoin tip to other users.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.zap}
|
||||
onClick={() => toggleZap()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
By default, Lume will display all content which have Content
|
||||
Warning tag, it's may include NSFW content.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.nsfw}
|
||||
onClick={() => toggleNsfw()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{t("global.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<button type="button" className="size-5" disabled>
|
||||
<Spinner className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
apps/desktop2/src/routes/create-group.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { CancelIcon, CheckCircleIcon, PlusIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/create-group")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const contacts = await ark.get_contact_list();
|
||||
return contacts;
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const contacts = Route.useLoaderData();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { label, redirect } = Route.useSearch();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [npub, setNpub] = useState("");
|
||||
const [users, setUsers] = useState<string[]>([
|
||||
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", // reya
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const toggleUser = (pubkey: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.includes(pubkey)
|
||||
? prev.filter((i) => i !== pubkey)
|
||||
: [...prev, pubkey],
|
||||
);
|
||||
};
|
||||
|
||||
const addUser = () => {
|
||||
if (!npub.startsWith("npub1")) return;
|
||||
if (users.includes(npub)) return;
|
||||
|
||||
setUsers((prev) => [...prev, npub]);
|
||||
setNpub("");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const key = `lume_group_${label}`;
|
||||
const createGroup = await ark.set_nstore(key, JSON.stringify(users));
|
||||
|
||||
if (createGroup) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
Focus feeds for people you like
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Add some people for custom feeds.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col gap-3">
|
||||
<div className="w-full h-9 shrink-0 flex items-center bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="w-16 border-r border-black/10 dark:border-white/10 shrink-0 text-center text-sm font-semibold"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter a name for this group"
|
||||
className="h-full bg-transparent border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-center gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
name="npub"
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
placeholder="npub1..."
|
||||
className="h-9 w-full rounded-lg bg-black/10 dark:bg-white/10 border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addUser()}
|
||||
className="inline-flex size-9 rounded-lg items-center justify-center bg-black/20 dark:bg-white/20 shrink-0 text-white hover:bg-blue-500"
|
||||
>
|
||||
<PlusIcon className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold">Added</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{users.length ? (
|
||||
users.map((item: string) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => toggleUser(item)}
|
||||
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item}>
|
||||
<User.Root className="flex items-center gap-2.5">
|
||||
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div>
|
||||
<CancelIcon className="size-4" />
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
|
||||
Empty.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold">Contacts</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{contacts.length ? (
|
||||
contacts.map((item: string) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => toggleUser(item)}
|
||||
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item}>
|
||||
<User.Root className="flex items-center gap-2.5">
|
||||
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
|
||||
<p>
|
||||
Find more user at{" "}
|
||||
<a
|
||||
href="https://www.nostr.directory/"
|
||||
target="_blank"
|
||||
className="text-blue-600 after:content-['_↗']"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Nostr Directory
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isLoading || users.length < 1}
|
||||
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
apps/desktop2/src/routes/create-newsfeed.f2f.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed/f2f")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const navigate = Route.useNavigate();
|
||||
const { redirect } = Route.useSearch();
|
||||
|
||||
const [npub, setNpub] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!npub.startsWith("npub1"))
|
||||
return toast.warning("You must enter a valid npub.");
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const sync: boolean = await invoke("friend_to_friend", { npub });
|
||||
|
||||
if (sync) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
|
||||
<p className="font-semibold text-neutral-500">
|
||||
You already have a friend on Nostr?
|
||||
</p>
|
||||
<p>Instead of building the timeline by yourself.</p>
|
||||
<p className="font-semibold text-neutral-500">
|
||||
Just enter your friend's{" "}
|
||||
<span className="text-blue-500">npub.</span>
|
||||
</p>
|
||||
<p>
|
||||
You will have the same experience as your friend. Of course, you
|
||||
always can edit your network later.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="npub" className="font-medium text-sm">
|
||||
NPUB
|
||||
</label>
|
||||
<input
|
||||
name="npub"
|
||||
placeholder="npub1..."
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg bg-transparent border border-neutral-200 dark:border-neutral-800 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex items-center justify-center w-full rounded-lg h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/desktop2/src/routes/create-newsfeed.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
Build up your timeline.
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Follow some people to keep up to date with them.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col gap-3">
|
||||
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
|
||||
<Link to="/create-newsfeed/users" className="flex-1 h-8">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-medium rounded-md h-full flex items-center justify-center",
|
||||
isActive
|
||||
? "bg-white dark:bg-white/20 shadow"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
Users
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/create-newsfeed/f2f" className="flex-1 h-8">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md h-full flex items-center justify-center",
|
||||
isActive ? "bg-white dark:bg-white/20" : "bg-transparent",
|
||||
)}
|
||||
>
|
||||
Friend to Friend
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
apps/desktop2/src/routes/create-newsfeed.users.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Await, defer } from "@tanstack/react-router";
|
||||
import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { toast } from "sonner";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed/users")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
loader: async ({ abortController }) => {
|
||||
try {
|
||||
return {
|
||||
data: defer(
|
||||
fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||
signal: abortController.signal,
|
||||
}).then((res) => res.json()),
|
||||
),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
}
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { data } = Route.useLoaderData();
|
||||
const { redirect } = Route.useSearch();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [follows, setFollows] = useState<string[]>([]);
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
setFollows((prev) =>
|
||||
prev.includes(pubkey)
|
||||
? prev.filter((i) => i !== pubkey)
|
||||
: [...prev, pubkey],
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const newContactList = await ark.set_contact_list(follows);
|
||||
|
||||
if (newContactList) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
disabled
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(users) =>
|
||||
users.profiles.map((item: { pubkey: string }) => (
|
||||
<div
|
||||
key={item.pubkey}
|
||||
className="h-max w-full overflow-hidden mb-2 p-2 bg-white dark:bg-black/20 backdrop-blur-lg rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item.pubkey}>
|
||||
<User.Root>
|
||||
<div className="flex h-full w-full flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
|
||||
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="inline-flex h-7 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{follows.includes(item.pubkey)
|
||||
? "Unfollow"
|
||||
: "Follow"}
|
||||
</button>
|
||||
</div>
|
||||
<User.About className="line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isLoading || follows.length < 1}
|
||||
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
apps/desktop2/src/routes/create-topic.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch, Topic } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { TOPICS } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/create-topic")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { label, redirect } = Route.useSearch();
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleTopic = (topic: Topic) => {
|
||||
setTopics((prev) =>
|
||||
prev.find((item) => item.title === topic.title)
|
||||
? prev.filter((i) => i.title !== topic.title)
|
||||
: [...prev, topic],
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const key = `lume_topic_${label}`;
|
||||
const createTopic = await ark.set_nstore(key, JSON.stringify(topics));
|
||||
|
||||
if (createTopic) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
What are your interests?
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Add some topics you want to focus on.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col gap-3">
|
||||
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-3">
|
||||
<span className="text-sm font-medium">Added: {topics.length}</span>
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-center gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||
<div className="flex flex-col gap-3">
|
||||
{TOPICS.map((topic) => (
|
||||
<button
|
||||
key={topic.title}
|
||||
onClick={() => toggleTopic(topic)}
|
||||
className="h-11 px-3 flex items-center justify-between bg-white dark:bg-black/20 backdrop-blur-lg border border-transparent hover:border-blue-500 rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div>{topic.icon}</div>
|
||||
<div className="text-sm font-medium">
|
||||
<span>{topic.title}</span>
|
||||
<span className="ml-1 italic text-neutral-400 dark:text-neutral-600 font-normal">
|
||||
{topic.content.length} hashtags
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{topics.find((item) => item.title === topic.title) ? (
|
||||
<CheckCircleIcon className="text-teal-500 size-4" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isLoading || topics.length < 1}
|
||||
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
apps/desktop2/src/routes/editor/-components/media.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { AddMediaIcon } from "@lume/icons";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSlateStatic } from "slate-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function MediaButton({ className }: { className?: string }) {
|
||||
const editor = useSlateStatic();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadToNostrBuild = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload();
|
||||
insertImage(editor, image);
|
||||
|
||||
// reset loading
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(`Upload failed, error: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: UnlistenFn = undefined;
|
||||
|
||||
async function listenFileDrop() {
|
||||
const window = getCurrent();
|
||||
if (!unlisten) {
|
||||
unlisten = await window.listen("tauri://file-drop", async (event) => {
|
||||
// @ts-ignore, lfg !!!
|
||||
const items: string[] = event.payload.paths;
|
||||
// start loading
|
||||
setLoading(true);
|
||||
// upload all images
|
||||
for (const item of items) {
|
||||
if (isImagePath(item)) {
|
||||
const image = await ark.upload(item);
|
||||
insertImage(editor, image);
|
||||
}
|
||||
}
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
listenFileDrop();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadToNostrBuild()}
|
||||
disabled={loading}
|
||||
className={cn("inline-flex items-center justify-center", className)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<AddMediaIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Upload media
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
83
apps/desktop2/src/routes/editor/-components/mention.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { MentionIcon } from "@lume/icons";
|
||||
import { cn, insertMention } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useSlateStatic } from "slate-react";
|
||||
import type { Contact } from "@lume/types";
|
||||
import { toast } from "sonner";
|
||||
import { User } from "@/components/user";
|
||||
|
||||
export function MentionButton({ className }: { className?: string }) {
|
||||
const editor = useSlateStatic();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [contacts, setContacts] = useState<string[]>([]);
|
||||
|
||||
const select = async (user: string) => {
|
||||
try {
|
||||
const metadata = await ark.get_profile(user);
|
||||
const contact: Contact = { pubkey: user, profile: metadata };
|
||||
|
||||
insertMention(editor, contact);
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function getContacts() {
|
||||
const data = await ark.get_contact_list();
|
||||
setContacts(data);
|
||||
}
|
||||
|
||||
getContacts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<MentionIcon className="size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
</DropdownMenu.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Mention
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||
{contacts.map((contact) => (
|
||||
<DropdownMenu.Item
|
||||
key={contact}
|
||||
onClick={() => select(contact)}
|
||||
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
||||
>
|
||||
<User.Provider pubkey={contact}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
||||
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||