Polish desktop dialogs, presence, and diff viewer (#44)

This commit is contained in:
Wes
2026-03-12 14:15:57 -07:00
committed by GitHub
parent 1feab79543
commit b6fde6fec4
18 changed files with 658 additions and 352 deletions
+1 -3
View File
@@ -19,7 +19,6 @@
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.8",
@@ -30,10 +29,9 @@
"@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff2html": "^3.4.56",
"dompurify": "^3.3.3",
"lucide-react": "^0.577.0",
"react": "^19.1.0",
"react-diff-view": "^3.3.2",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"remark-breaks": "^4.0.0",
+61 -115
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -41,18 +38,15 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
diff2html:
specifier: ^3.4.56
version: 3.4.56
dompurify:
specifier: ^3.3.3
version: 3.3.3
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
react:
specifier: ^19.1.0
version: 19.2.4
react-diff-view:
specifier: ^3.3.2
version: 3.3.2(react@19.2.4)
react-dom:
specifier: ^19.1.0
version: 19.2.4(react@19.2.4)
@@ -441,16 +435,6 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
'@monaco-editor/react@4.7.0':
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
peerDependencies:
monaco-editor: '>= 0.25.0 < 1'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@noble/ciphers@2.1.1':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'}
@@ -480,10 +464,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@profoundlogic/hogan@3.0.4':
resolution: {integrity: sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==}
hasBin: true
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@@ -861,6 +841,7 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
@@ -871,6 +852,7 @@ packages:
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
@@ -888,6 +870,7 @@ packages:
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
@@ -904,17 +887,18 @@ packages:
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
@@ -926,7 +910,6 @@ packages:
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1090,9 +1073,6 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -1108,9 +1088,6 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@@ -1182,6 +1159,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1229,23 +1209,12 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
diff2html@3.4.56:
resolution: {integrity: sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==}
engines: {node: '>=12'}
diff@8.0.3:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
dompurify@3.3.3:
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
electron-to-chromium@1.5.307:
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
@@ -1312,6 +1281,9 @@ packages:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
gitdiff-parser@0.3.1:
resolution: {integrity: sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1330,10 +1302,6 @@ packages:
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -1400,9 +1368,16 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -1414,11 +1389,6 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@@ -1559,9 +1529,6 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1576,10 +1543,6 @@ packages:
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
nopt@1.0.10:
resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -1685,6 +1648,11 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
react-diff-view@3.3.2:
resolution: {integrity: sha512-wPVq4ktTcGOHbhnWKU/gHLtd3N2Xd+OZ/XQWcKA06dsxlSsESePAumQILwHtiak2nMCMiWcIfBpqZ5OiharUPA==}
peerDependencies:
react: '>=16.14.0'
react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies:
@@ -1780,6 +1748,9 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
shallow-equal@3.1.0:
resolution: {integrity: sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1787,9 +1758,6 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -1946,6 +1914,9 @@ packages:
yaml:
optional: true
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -2222,17 +2193,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@monaco-editor/loader@1.7.0':
dependencies:
state-local: 1.0.7
'@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@monaco-editor/loader': 1.7.0
monaco-editor: 0.55.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@noble/ciphers@2.1.1': {}
'@noble/curves@2.0.1':
@@ -2257,10 +2217,6 @@ snapshots:
dependencies:
playwright: 1.58.2
'@profoundlogic/hogan@3.0.4':
dependencies:
nopt: 1.0.10
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
@@ -2776,9 +2732,6 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -2797,8 +2750,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
abbrev@1.1.1: {}
any-promise@1.3.0: {}
anymatch@3.1.3:
@@ -2869,6 +2820,8 @@ snapshots:
dependencies:
clsx: 2.1.1
classnames@2.5.1: {}
clsx@2.1.1: {}
comma-separated-tokens@2.0.3: {}
@@ -2899,25 +2852,10 @@ snapshots:
didyoumean@1.2.2: {}
diff2html@3.4.56:
dependencies:
'@profoundlogic/hogan': 3.0.4
diff: 8.0.3
optionalDependencies:
highlight.js: 11.11.1
diff@8.0.3: {}
diff-match-patch@1.0.5: {}
dlv@1.1.3: {}
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
dompurify@3.3.3:
optionalDependencies:
'@types/trusted-types': 2.0.7
electron-to-chromium@1.5.307: {}
esbuild@0.27.3:
@@ -2991,6 +2929,8 @@ snapshots:
get-nonce@1.0.1: {}
gitdiff-parser@0.3.1: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -3027,9 +2967,6 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
highlight.js@11.11.1:
optional: true
html-url-attributes@3.0.1: {}
inline-style-parser@0.2.7: {}
@@ -3075,8 +3012,14 @@ snapshots:
lines-and-columns@1.2.4: {}
lodash@4.17.23: {}
longest-streak@3.1.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -3087,8 +3030,6 @@ snapshots:
markdown-table@3.0.4: {}
marked@14.0.0: {}
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -3445,11 +3386,6 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
marked: 14.0.0
ms@2.1.3: {}
mz@2.7.0:
@@ -3462,10 +3398,6 @@ snapshots:
node-releases@2.0.36: {}
nopt@1.0.10:
dependencies:
abbrev: 1.1.1
normalize-path@3.0.0: {}
nostr-tools@2.23.3(typescript@5.8.3):
@@ -3557,6 +3489,16 @@ snapshots:
queue-microtask@1.2.3: {}
react-diff-view@3.3.2(react@19.2.4):
dependencies:
classnames: 2.5.1
diff-match-patch: 1.0.5
gitdiff-parser: 0.3.1
lodash: 4.17.23
react: 19.2.4
shallow-equal: 3.1.0
warning: 4.0.3
react-dom@19.2.4(react@19.2.4):
dependencies:
react: 19.2.4
@@ -3706,12 +3648,12 @@ snapshots:
semver@6.3.1: {}
shallow-equal@3.1.0: {}
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}
state-local@1.0.7: {}
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -3876,6 +3818,10 @@ snapshots:
jiti: 1.21.7
yaml: 2.8.2
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
yallist@3.1.1: {}
yaml@2.8.2: {}
+1 -1
View File
@@ -306,7 +306,7 @@ export function AppShell() {
return (
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
<SidebarTrigger className="fixed left-[80px] top-[9px] z-50 h-6 w-6" />
<SidebarTrigger className="fixed left-[80px] top-[9px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<AppSidebar
channels={memberChannels}
currentPubkey={identityQuery.data?.pubkey}
@@ -20,6 +20,9 @@ import {
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button";
const BROWSE_CHANNELS_SHORTCUT_KEY = "o";
const BROWSE_CHANNELS_SHORTCUT_HINT = "\u21E7\u2318O";
function formatRelativeTime(isoString: string | null) {
if (!isoString) {
return "No activity";
@@ -128,7 +131,7 @@ export function ChannelBrowserDialog({
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (
event.key.toLowerCase() !== "b" ||
event.key.toLowerCase() !== BROWSE_CHANNELS_SHORTCUT_KEY ||
!(event.metaKey || event.ctrlKey) ||
event.altKey ||
!event.shiftKey
@@ -250,9 +253,9 @@ export function ChannelBrowserDialog({
ref={inputRef}
value={query}
/>
<div className="hidden items-center gap-1 rounded-lg border border-border bg-background px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground sm:flex">
&#x21E7;&#x2318;B
</div>
<span className="hidden shrink-0 text-xs text-muted-foreground/50 sm:block">
{BROWSE_CHANNELS_SHORTCUT_HINT}
</span>
</div>
</DialogHeader>
+1 -1
View File
@@ -57,7 +57,7 @@ export function ChatHeader({
}: ChatHeaderProps) {
return (
<header
className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 py-3 sm:px-6"
className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 pb-3 pt-8 sm:px-6"
data-testid="chat-header"
onPointerDown={handlePointerDown}
>
+66 -44
View File
@@ -1,52 +1,74 @@
import { parse } from "diff2html";
import {
isDelete,
isInsert,
parseDiff,
type DiffType,
type FileData,
} from "react-diff-view";
type ParsedDiffResult = {
files: FileData[];
parseError: boolean;
};
function isRenderableFile(file: FileData) {
return file.hunks.length > 0 || Boolean(file.oldPath || file.newPath);
}
export function parseUnifiedDiff(content: string): ParsedDiffResult {
if (!content.trim()) {
return { files: [], parseError: false };
}
export function parseDiffToOldNew(unifiedDiff: string): {
original: string;
modified: string;
} {
try {
const files = parse(unifiedDiff);
const files = parseDiff(content).filter(isRenderableFile);
if (!files.length) {
// diff2html couldn't parse any files — show raw diff as fallback
return { original: "", modified: unifiedDiff };
return { files: [], parseError: true };
}
const originalLines: string[] = [];
const modifiedLines: string[] = [];
for (const file of files) {
// Add file header separator for multi-file diffs
if (files.length > 1) {
const header = `// ── ${file.newName || file.oldName || "unknown"} ──`;
originalLines.push(header);
modifiedLines.push(header);
}
for (const block of file.blocks) {
for (const line of block.lines) {
if (line.content.startsWith("\\ ")) continue;
if (line.type === "context" || line.type === "delete") {
originalLines.push(line.content.slice(1));
}
if (line.type === "context" || line.type === "insert") {
modifiedLines.push(line.content.slice(1));
}
}
}
// Add blank line between files
if (files.length > 1) {
originalLines.push("");
modifiedLines.push("");
}
}
return {
original: originalLines.join("\n"),
modified: modifiedLines.join("\n"),
};
return { files, parseError: false };
} catch {
// Malformed diff — return raw content as fallback
return { original: "", modified: unifiedDiff };
return { files: [], parseError: true };
}
}
export function getDiffFileLabel(
file: FileData,
fallbackFilePath?: string,
): string {
if (file.oldPath && file.newPath && file.oldPath !== file.newPath) {
return `${file.oldPath} -> ${file.newPath}`;
}
return file.newPath || file.oldPath || fallbackFilePath || "diff";
}
export function countDiffFileChanges(file: FileData) {
let additions = 0;
let deletions = 0;
for (const hunk of file.hunks) {
for (const change of hunk.changes) {
if (isInsert(change)) {
additions += 1;
} else if (isDelete(change)) {
deletions += 1;
}
}
}
return { additions, deletions };
}
export function normalizeDiffType(type: string | undefined): DiffType {
switch (type) {
case "add":
case "copy":
case "delete":
case "rename":
return type;
default:
return "modify";
}
}
@@ -1,49 +1,7 @@
import { html } from "diff2html";
import "diff2html/bundles/css/diff2html.min.css";
import DOMPurify from "dompurify";
import { FileDiff, Maximize2 } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/shared/ui/button";
/**
* Override diff2html styles that break in constrained containers.
* The default CSS uses position:absolute for line numbers which causes
* overflow and misalignment in narrow message bubbles.
*/
const diffStyleOverrides = `
.d2h-wrapper { text-align: left; }
.d2h-file-wrapper { border: none; margin: 0; border-radius: 0; }
.d2h-file-header { display: none; }
.d2h-diff-table { font-size: 12px; width: 100%; table-layout: fixed; }
.d2h-code-linenumber {
position: static;
display: table-cell;
width: 3em;
min-width: 3em;
max-width: 3em;
padding: 0 0.4em;
text-align: right;
vertical-align: top;
user-select: none;
border-right: 1px solid var(--d2h-line-border-color, #eee);
font-size: 11px;
}
.d2h-code-line {
display: table-cell;
padding: 0 0.5em;
white-space: pre-wrap;
word-break: break-all;
width: auto;
}
.d2h-code-line-ctn {
white-space: pre-wrap;
word-break: break-all;
}
.d2h-info {
padding: 0.2em 0.5em;
}
`;
import { DiffViewer } from "./DiffViewer";
type DiffMessageProps = {
content: string;
@@ -73,37 +31,6 @@ function getHostname(url: string): string {
}
}
const ALLOWED_TAGS = [
"div",
"span",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"pre",
"code",
"ins",
"del",
"a",
"i",
"em",
"strong",
"small",
];
const ALLOWED_ATTR = [
"class",
"id",
"data-line-number",
"href",
"title",
"aria-label",
];
const ALLOWED_URI_REGEXP = /^https?:\/\//i;
export function DiffMessage({
content,
repoUrl,
@@ -113,24 +40,6 @@ export function DiffMessage({
truncated,
onExpand,
}: DiffMessageProps) {
const { diffHtml, renderError } = useMemo(() => {
try {
const rawHtml = html(content, {
drawFileList: false,
matching: "lines",
outputFormat: "line-by-line",
});
const sanitized = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOWED_URI_REGEXP,
});
return { diffHtml: sanitized, renderError: false };
} catch {
return { diffHtml: "", renderError: true };
}
}, [content]);
const safeRepoUrl = isSafeUrl(repoUrl) ? repoUrl : undefined;
const commitUrl =
@@ -197,20 +106,12 @@ export function DiffMessage({
{/* Diff content — max 400px height, scrollable */}
<div className="max-h-[400px] overflow-auto text-xs">
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: static CSS overrides */}
<style dangerouslySetInnerHTML={{ __html: diffStyleOverrides }} />
{renderError ? (
<pre className="p-3 whitespace-pre-wrap font-mono text-muted-foreground">
{content}
</pre>
) : diffHtml ? (
// biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized by DOMPurify
<div dangerouslySetInnerHTML={{ __html: diffHtml }} />
) : (
<div className="p-3 text-xs text-muted-foreground italic">
No diff content
</div>
)}
<DiffViewer
className="p-3"
content={content}
fallbackFilePath={filePath}
viewType="unified"
/>
</div>
{/* Truncation warning */}
@@ -1,7 +1,8 @@
import { DiffEditor } from "@monaco-editor/react";
import { useMemo } from "react";
import { Rows3, SplitSquareVertical } from "lucide-react";
import { useState } from "react";
import { parseDiffToOldNew } from "@/features/messages/lib/parseDiff";
import { DiffViewer } from "@/features/messages/ui/DiffViewer";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
@@ -11,21 +12,16 @@ import {
type DiffMessageExpandedProps = {
content: string;
language?: string;
filePath?: string;
onClose: () => void;
};
export default function DiffMessageExpanded({
content,
language,
filePath,
onClose,
}: DiffMessageExpandedProps) {
const { original, modified } = useMemo(
() => parseDiffToOldNew(content),
[content],
);
const [viewType, setViewType] = useState<"split" | "unified">("unified");
return (
<Dialog
@@ -35,28 +31,44 @@ export default function DiffMessageExpanded({
open
>
<DialogContent className="max-w-5xl w-full h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-4 py-3 border-b border-border/50 shrink-0">
<DialogTitle className="text-sm font-mono font-medium truncate">
{filePath ?? "Diff Viewer"}{" "}
<span className="text-muted-foreground text-xs font-normal">
(hunks-only preview shows changed regions, not full file)
</span>
</DialogTitle>
<DialogHeader className="shrink-0 border-b border-border/50 px-4 py-3 pr-14">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle className="min-w-0 flex-1 truncate font-mono text-sm font-medium">
{filePath ?? "Diff Viewer"}
</DialogTitle>
<div className="flex items-center gap-1 rounded-lg border border-border/60 bg-muted/30 p-1">
<Button
className="h-7 px-2"
onClick={() => {
setViewType("unified");
}}
size="sm"
type="button"
variant={viewType === "unified" ? "secondary" : "ghost"}
>
<Rows3 className="h-3.5 w-3.5" />
Unified
</Button>
<Button
className="h-7 px-2"
onClick={() => {
setViewType("split");
}}
size="sm"
type="button"
variant={viewType === "split" ? "secondary" : "ghost"}
>
<SplitSquareVertical className="h-3.5 w-3.5" />
Split
</Button>
</div>
</div>
</DialogHeader>
<div className="flex-1 min-h-0">
<DiffEditor
height="100%"
language={language ?? "plaintext"}
modified={modified}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
}}
original={original}
theme="vs-dark"
<div className="min-h-0 flex-1 overflow-auto px-4 py-4">
<DiffViewer
content={content}
fallbackFilePath={filePath}
viewType={viewType}
/>
</div>
</DialogContent>
@@ -0,0 +1,98 @@
:root {
--sprout-diff-insert-gutter-bg: 143 58% 91%;
--sprout-diff-insert-code-bg: 140 56% 96%;
--sprout-diff-insert-edit-bg: 142 44% 82%;
--sprout-diff-insert-text: 143 63% 20%;
--sprout-diff-delete-gutter-bg: 352 73% 93%;
--sprout-diff-delete-code-bg: 356 87% 97%;
--sprout-diff-delete-edit-bg: 353 64% 84%;
--sprout-diff-delete-text: 356 55% 24%;
}
.dark {
--sprout-diff-insert-gutter-bg: 143 33% 20%;
--sprout-diff-insert-code-bg: 143 29% 15%;
--sprout-diff-insert-edit-bg: 143 30% 27%;
--sprout-diff-insert-text: 143 47% 78%;
--sprout-diff-delete-gutter-bg: 353 28% 20%;
--sprout-diff-delete-code-bg: 353 25% 15%;
--sprout-diff-delete-edit-bg: 353 31% 27%;
--sprout-diff-delete-text: 352 71% 83%;
}
.sprout-diff-theme {
--diff-background-color: transparent;
--diff-text-color: hsl(var(--foreground));
--diff-font-family:
"SFMono-Regular", "Geist Mono", ui-monospace, "Cascadia Code",
"Source Code Pro", Menlo, Consolas, monospace;
--diff-selection-background-color: hsl(var(--accent) / 0.9);
--diff-selection-text-color: hsl(var(--accent-foreground));
--diff-gutter-insert-background-color: hsl(
var(--sprout-diff-insert-gutter-bg)
);
--diff-gutter-insert-text-color: hsl(var(--sprout-diff-insert-text));
--diff-gutter-delete-background-color: hsl(
var(--sprout-diff-delete-gutter-bg)
);
--diff-gutter-delete-text-color: hsl(var(--sprout-diff-delete-text));
--diff-gutter-selected-background-color: hsl(var(--accent) / 0.45);
--diff-gutter-selected-text-color: hsl(var(--foreground));
--diff-code-insert-background-color: hsl(var(--sprout-diff-insert-code-bg));
--diff-code-insert-text-color: hsl(var(--sprout-diff-insert-text));
--diff-code-delete-background-color: hsl(var(--sprout-diff-delete-code-bg));
--diff-code-delete-text-color: hsl(var(--sprout-diff-delete-text));
--diff-code-insert-edit-background-color: hsl(
var(--sprout-diff-insert-edit-bg)
);
--diff-code-insert-edit-text-color: hsl(var(--sprout-diff-insert-text));
--diff-code-delete-edit-background-color: hsl(
var(--sprout-diff-delete-edit-bg)
);
--diff-code-delete-edit-text-color: hsl(var(--sprout-diff-delete-text));
--diff-code-selected-background-color: hsl(var(--accent) / 0.35);
--diff-code-selected-text-color: hsl(var(--foreground));
--diff-omit-gutter-line-color: hsl(var(--border));
}
.sprout-diff-theme .diff {
font-size: 12px;
}
.sprout-diff-theme .diff td {
border: 0;
}
.sprout-diff-theme .sprout-diff-line {
line-height: 1.35;
}
.sprout-diff-theme .sprout-diff-gutter {
min-width: 3.5rem;
padding: 0.125rem 0.5rem;
border-right: 1px solid hsl(var(--border) / 0.65);
color: hsl(var(--muted-foreground));
font-size: 11px;
}
.sprout-diff-theme .sprout-diff-code {
padding: 0.125rem 0.75rem 0.125rem 0.5rem;
white-space: pre-wrap;
word-break: normal;
overflow-wrap: anywhere;
}
.sprout-diff-theme .diff-code-edit {
border-radius: 0.2rem;
}
.sprout-diff-theme .diff-decoration-content {
padding: 0.2rem 0.75rem;
background: hsl(var(--muted) / 0.35);
color: hsl(var(--muted-foreground));
font-size: 11px;
}
.sprout-diff-theme .diff-gutter-omit::before {
margin-left: 2.65rem;
}
@@ -0,0 +1,156 @@
import { Diff, Hunk, type ViewType } from "react-diff-view";
import "react-diff-view/style/index.css";
import { useMemo } from "react";
import {
countDiffFileChanges,
getDiffFileLabel,
normalizeDiffType,
parseUnifiedDiff,
} from "@/features/messages/lib/parseDiff";
import { cn } from "@/shared/lib/cn";
import "./DiffViewer.css";
type DiffViewerProps = {
content: string;
fallbackFilePath?: string;
viewType?: ViewType;
className?: string;
};
const DIFF_TYPE_LABELS = {
add: "New file",
copy: "Copied",
delete: "Deleted",
modify: "Modified",
rename: "Renamed",
} as const;
function FileChangeBadge({
tone,
value,
}: {
tone: "positive" | "negative";
value: string;
}) {
return (
<span
className={cn(
"rounded-md px-1.5 py-0.5 font-mono text-[10px] font-semibold",
tone === "positive"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
)}
>
{value}
</span>
);
}
export function DiffViewer({
content,
fallbackFilePath,
viewType = "unified",
className,
}: DiffViewerProps) {
const { files, parseError } = useMemo(
() => parseUnifiedDiff(content),
[content],
);
if (parseError) {
return (
<pre className="p-3 whitespace-pre-wrap font-mono text-xs text-muted-foreground">
{content}
</pre>
);
}
if (!files.length) {
return (
<div className="p-3 text-xs italic text-muted-foreground">
No diff content
</div>
);
}
return (
<div className={cn("sprout-diff-theme", className)}>
<div className="space-y-3">
{files.map((file) => {
const label = getDiffFileLabel(file, fallbackFilePath);
const showFileHeader =
files.length > 1 || !fallbackFilePath || label !== fallbackFilePath;
const { additions, deletions } = countDiffFileChanges(file);
const diffType = normalizeDiffType(file.type);
const fileKey = [
file.oldPath || "",
file.newPath || "",
file.oldRevision || "",
file.newRevision || "",
].join(":");
return (
<section
className="overflow-hidden rounded-xl border border-border/60 bg-background/40"
key={fileKey || label}
>
{showFileHeader ? (
<div className="flex items-center gap-2 border-b border-border/60 bg-muted/35 px-3 py-2">
<span className="truncate font-mono text-[11px] text-foreground/85">
{label}
</span>
<span className="rounded-md border border-border/60 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
{DIFF_TYPE_LABELS[diffType]}
</span>
<div className="ml-auto flex items-center gap-1.5">
{additions > 0 ? (
<FileChangeBadge
tone="positive"
value={`+${additions}`}
/>
) : null}
{deletions > 0 ? (
<FileChangeBadge
tone="negative"
value={`-${deletions}`}
/>
) : null}
</div>
</div>
) : null}
{file.hunks.length > 0 ? (
<Diff
className={cn(
"sprout-diff-table",
viewType === "split" ? "min-w-[780px]" : "w-full",
)}
codeClassName="sprout-diff-code"
diffType={diffType}
gutterClassName="sprout-diff-gutter"
hunks={file.hunks}
lineClassName="sprout-diff-line"
viewType={viewType}
>
{(hunks) =>
hunks.map((hunk) => (
<Hunk
hunk={hunk}
key={`${fileKey}:${hunk.oldStart}:${hunk.newStart}:${hunk.content}`}
/>
))
}
</Diff>
) : (
<div className="px-3 py-3 text-xs text-muted-foreground">
No textual hunks in this diff.
</div>
)}
</section>
);
})}
</div>
</div>
);
}
@@ -78,7 +78,7 @@ function MessageRow({ message }: { message: TimelineMessage }) {
{message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-9 w-9 shrink-0 rounded-xl border border-border/80 object-cover shadow-sm"
className="h-9 w-9 shrink-0 rounded-xl object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
@@ -131,7 +131,6 @@ function MessageRow({ message }: { message: TimelineMessage }) {
<DiffMessageExpanded
content={message.body}
filePath={getTag("file")}
language={getTag("l")}
onClose={() => {
setExpandedDiffId(null);
}}
+184 -19
View File
@@ -6,7 +6,12 @@ import { getPresence, setPresence } from "@/shared/api/tauri";
import type { PresenceLookup, PresenceStatus } from "@/shared/api/types";
const PRESENCE_HEARTBEAT_INTERVAL_MS = 60_000;
const PRESENCE_IDLE_TIMEOUT_MS = 5 * 60_000;
const PRESENCE_STATUS_TICK_INTERVAL_MS = 30_000;
const PRESENCE_TTL_SECONDS = 90;
const PRESENCE_PREFERENCE_STORAGE_KEY = "sprout-presence-preference";
type PresencePreference = "auto" | "away" | "offline" | null;
function normalizePubkeys(pubkeys: string[]) {
return [...new Set(pubkeys.map((pubkey) => pubkey.trim().toLowerCase()))]
@@ -18,6 +23,51 @@ function presenceQueryKey(pubkeys: string[]) {
return ["presence", ...normalizePubkeys(pubkeys)] as const;
}
function presencePreferenceStorageKey(pubkey: string) {
return `${PRESENCE_PREFERENCE_STORAGE_KEY}:${pubkey}`;
}
function readStoredPresencePreference(pubkey: string): PresencePreference {
if (typeof window === "undefined" || pubkey.length === 0) {
return null;
}
const value = window.localStorage.getItem(
presencePreferenceStorageKey(pubkey),
);
return value === "auto" || value === "away" || value === "offline"
? value
: null;
}
function writeStoredPresencePreference(
pubkey: string,
preference: PresencePreference,
) {
if (typeof window === "undefined" || pubkey.length === 0) {
return;
}
if (preference === null) {
window.localStorage.removeItem(presencePreferenceStorageKey(pubkey));
return;
}
window.localStorage.setItem(presencePreferenceStorageKey(pubkey), preference);
}
function resolveAutomaticPresenceStatus(
isDocumentHidden: boolean,
lastActivityAt: number,
now: number,
): PresenceStatus {
if (isDocumentHidden) {
return "away";
}
return now - lastActivityAt >= PRESENCE_IDLE_TIMEOUT_MS ? "away" : "online";
}
export function usePresenceQuery(
pubkeys: string[],
options?: {
@@ -87,65 +137,180 @@ export function usePresenceSession(pubkey?: string) {
{ enabled: normalizedPubkey.length > 0 },
);
const setPresenceMutation = useSetPresenceMutation(normalizedPubkey);
const [desiredStatus, setDesiredStatus] =
React.useState<PresenceStatus | null>(null);
const previousPubkeyRef = React.useRef(normalizedPubkey);
const [presencePreference, setPresencePreference] =
React.useState<PresencePreference>(() =>
readStoredPresencePreference(normalizedPubkey),
);
const [lastActivityAt, setLastActivityAt] = React.useState(() => Date.now());
const [statusClock, setStatusClock] = React.useState(() => Date.now());
const [isDocumentHidden, setIsDocumentHidden] = React.useState(() =>
typeof document === "undefined" ? false : document.hidden,
);
const skipNextSyncRef = React.useRef<PresenceStatus | null>(null);
React.useEffect(() => {
if (previousPubkeyRef.current === normalizedPubkey) {
const now = Date.now();
setPresencePreference(readStoredPresencePreference(normalizedPubkey));
setLastActivityAt(now);
setStatusClock(now);
setIsDocumentHidden(
typeof document === "undefined" ? false : document.hidden,
);
}, [normalizedPubkey]);
React.useEffect(() => {
writeStoredPresencePreference(normalizedPubkey, presencePreference);
}, [normalizedPubkey, presencePreference]);
const recordActivity = React.useEffectEvent(() => {
const now = Date.now();
setLastActivityAt(now);
setStatusClock(now);
});
React.useEffect(() => {
if (normalizedPubkey.length === 0) {
return;
}
previousPubkeyRef.current = normalizedPubkey;
setDesiredStatus(null);
});
function handleUserActivity() {
if (typeof document !== "undefined" && document.hidden) {
return;
}
recordActivity();
}
function handleFocus() {
setIsDocumentHidden(false);
recordActivity();
}
function handleVisibilityChange() {
const hidden = document.hidden;
setIsDocumentHidden(hidden);
if (!hidden) {
recordActivity();
}
}
window.addEventListener("pointerdown", handleUserActivity, true);
window.addEventListener("keydown", handleUserActivity, true);
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("pointerdown", handleUserActivity, true);
window.removeEventListener("keydown", handleUserActivity, true);
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [normalizedPubkey]);
React.useEffect(() => {
if (normalizedPubkey.length === 0) {
return;
}
const intervalId = window.setInterval(() => {
setStatusClock(Date.now());
}, PRESENCE_STATUS_TICK_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [normalizedPubkey]);
const automaticStatus = React.useMemo(
() =>
resolveAutomaticPresenceStatus(
isDocumentHidden,
lastActivityAt,
statusClock,
),
[isDocumentHidden, lastActivityAt, statusClock],
);
const relayStatus =
normalizedPubkey.length > 0
? (presenceQuery.data?.[normalizedPubkey] ?? "offline")
: "offline";
const currentStatus = desiredStatus ?? relayStatus;
const currentStatus =
normalizedPubkey.length === 0
? "offline"
: presencePreference === "offline"
? "offline"
: presencePreference === "away"
? "away"
: presencePreference === "auto"
? automaticStatus
: relayStatus;
const updatePresence = React.useCallback(
async (status: PresenceStatus) => {
const previousStatus = currentStatus;
setDesiredStatus(status);
const previousPreference = presencePreference;
const nextPreference: PresencePreference =
status === "online" ? "auto" : status;
if (nextPreference === "auto") {
const now = Date.now();
setLastActivityAt(now);
setStatusClock(now);
setIsDocumentHidden(
typeof document === "undefined" ? false : document.hidden,
);
}
setPresencePreference(nextPreference);
skipNextSyncRef.current = status;
try {
await setPresenceMutation.mutateAsync(status);
} catch (error) {
setDesiredStatus(
previousStatus === relayStatus ? null : previousStatus,
);
skipNextSyncRef.current = null;
setPresencePreference(previousPreference);
throw error;
}
},
[currentStatus, relayStatus, setPresenceMutation],
[presencePreference, setPresenceMutation],
);
const sendHeartbeat = React.useEffectEvent((status: PresenceStatus) => {
const syncPresence = React.useEffectEvent((status: PresenceStatus) => {
void setPresenceMutation.mutateAsync(status).catch(() => {
return;
});
});
React.useEffect(() => {
if (normalizedPubkey.length === 0 || presencePreference === null) {
return;
}
if (skipNextSyncRef.current === currentStatus) {
skipNextSyncRef.current = null;
return;
}
syncPresence(currentStatus);
}, [currentStatus, normalizedPubkey, presencePreference]);
React.useEffect(() => {
if (
normalizedPubkey.length === 0 ||
desiredStatus === null ||
desiredStatus === "offline"
presencePreference === null ||
currentStatus === "offline"
) {
return;
}
const intervalId = window.setInterval(() => {
sendHeartbeat(desiredStatus);
syncPresence(currentStatus);
}, PRESENCE_HEARTBEAT_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [desiredStatus, normalizedPubkey]);
}, [currentStatus, normalizedPubkey, presencePreference]);
return {
currentStatus,
@@ -30,7 +30,7 @@ export function ProfileAvatar({
.slice(0, 2)
.toUpperCase();
const baseClassName = cn(
"flex shrink-0 items-center justify-center overflow-hidden border border-border/80 bg-primary/10 text-primary shadow-sm",
"flex shrink-0 items-center justify-center overflow-hidden bg-primary/10 text-primary shadow-sm",
className,
);
@@ -1,7 +1,6 @@
import * as React from "react";
import {
ArrowRight,
Command,
FileText,
Hash,
LoaderCircle,
@@ -270,9 +269,9 @@ export function SearchDialog({
ref={inputRef}
value={query}
/>
<div className="hidden items-center gap-1 rounded-lg border border-border bg-background px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground sm:flex">
<Command className="h-3 w-3" />K
</div>
<span className="hidden shrink-0 text-xs text-muted-foreground/50 sm:block">
&#x2318;K
</span>
</div>
</DialogHeader>
@@ -71,17 +71,19 @@ const presenceOptions: Array<{
{
value: "online",
label: "Online",
description: "Visible as active and refreshed while the app is open.",
description:
"Automatically active while you use the app and away when idle.",
},
{
value: "away",
label: "Away",
description: "Visible as idle and refreshed while the app is open.",
description:
"Forces this desktop session to appear idle until you change it.",
},
{
value: "offline",
label: "Offline",
description: "Clears your relay presence entry immediately.",
description: "Hides this desktop session and stops presence heartbeats.",
},
];
@@ -254,8 +256,9 @@ function PresenceSettingsCard({
className="mt-4 text-sm text-muted-foreground"
data-testid="presence-help"
>
Online and away are refreshed automatically every minute while Sprout is
running. The relay expires presence after 90 seconds.
Sprout refreshes presence every minute while it is running. Online will
switch to away after a few minutes of inactivity or when the app is
hidden. The relay expires presence after 90 seconds.
</p>
</section>
);
@@ -408,7 +408,7 @@ export function AppSidebar({
variant="sidebar"
>
<SidebarHeader
className="gap-3 pt-12"
className="gap-3 pt-10"
onPointerDown={handleDragPointerDown}
>
<Button
@@ -497,7 +497,7 @@ export function AppSidebar({
selectedChannelId={selectedChannelId}
unreadChannelIds={unreadChannelIds}
/>
<SidebarMenu>
<SidebarMenu className="px-2">
<SidebarMenuItem>
<SidebarMenuButton
className="text-sidebar-foreground/60 hover:text-sidebar-foreground"
+16 -14
View File
@@ -32,20 +32,22 @@ const DialogContent = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-1/2 top-[12vh] z-50 grid w-[calc(100vw-2rem)] max-w-2xl -translate-x-1/2 gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
ref={ref}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
<DialogPrimitive.Content
className={cn(
"pointer-events-auto relative grid w-[calc(100vw-2rem)] max-w-2xl gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
ref={ref}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</div>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
+3 -1
View File
@@ -20,7 +20,9 @@ test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await page.keyboard.press("Meta+Shift+B");
await page.keyboard.press(
process.platform === "darwin" ? "Meta+Shift+O" : "Control+Shift+O",
);
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
});