Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
fdaae33aa1 | |||
746338465d | |||
|
736dafce94 | ||
60b3a28435 | |||
|
48f85f54c8 | ||
|
14c103dd40 | ||
|
0d93e16f3a | ||
|
3a09d4c595 | ||
|
c4c0ecba4a | ||
|
95f5398736 | ||
|
01bb68d87b | ||
|
006ed7b548 | ||
|
4f9fdd19b0 | ||
|
4e54d12175 | ||
|
e8a53bc73e | ||
|
0ea6ba0033 | ||
|
3615de70ad | ||
|
5c24c5bde0 | ||
|
458de18f12 | ||
|
0cc1a32059 | ||
|
2248128001 | ||
|
3c061d5920 |
@ -6,19 +6,19 @@ Welcome to Sigit! We are thrilled that you are interested in contributing to thi
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
If you encounter a bug while using Sigit, please [open an issue](https://git.sigit.io/g/web/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug.
|
||||
If you encounter a bug while using Sigit, please [open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug.
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.sigit.io/g/web/issues/new) to suggest an enhancement.
|
||||
If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) to suggest an enhancement.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
We welcome pull requests from contributors! To contribute code changes:
|
||||
|
||||
1. Fork the repository and create your branch from `main`.
|
||||
1. Fork the repository and create your branch from `staging`.
|
||||
2. Make your changes and ensure they pass any existing tests.
|
||||
3. Write meaningful commit messages.
|
||||
3. Write meaningful commit messages (conventional commit standard)
|
||||
4. Submit a pull request, describing your changes in detail and referencing any related issues.
|
||||
|
||||
## Development Setup
|
||||
@ -35,4 +35,14 @@ All contributions, including pull requests, undergo code review. Code review ens
|
||||
|
||||
## Contact
|
||||
|
||||
If you have questions or need further assistance, you can reach out to [maintainer's email].
|
||||
If you have questions or need further assistance, you can reach out to `npub1d0csynrrxcynkcedktdzrdj6gnras2psg48mf46kxjazs8skrjgq9uzhlq`
|
||||
|
||||
## Testing
|
||||
|
||||
The following items should be tested with each release:
|
||||
|
||||
- Create a SIGit with at least 3 signers
|
||||
- Create a SIGit where the creator is not the first signer
|
||||
- Create a SIGit where one co-signer has no marks
|
||||
- Create a SIGit using a file other than a PDF
|
||||
- Use several login mechanisms, browsers, operating systems whilst testing
|
||||
|
@ -1,181 +0,0 @@
|
||||
<mxfile host="drawio-plugin" modified="2024-12-24T14:10:11.548Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" etag="j0ni0qfsydyzknoEy44Z" version="22.1.22" type="embed">
|
||||
<diagram id="ADjf0_COJFV7FXKRQUJh" name="Page-1">
|
||||
<mxGraphModel dx="1130" dy="824" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" background="#ffffff" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="13" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="11" target="12" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="11" value="User login" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="30" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="15" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="12" target="14" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="12" value="Find all blossom servers provided by a user" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="150" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="14" value="Fetch all SIGITS from each blossom server and display them all" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="280" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="22" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="17" target="21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="17" value="User opens a SIGIT" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="320" y="30" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="24" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="21" target="23" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="21" value="Display a blossom server where this SIGIT was found" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="345" y="150" width="120" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="23" value="Display other blossom servers where SIGIT is not found, and button which will publish to a blossom server" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="345" y="250" width="120" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="26" value="" style="endArrow=none;dashed=1;html=1;dashPattern=1 3;strokeWidth=2;rounded=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="270" y="380" as="sourcePoint" />
|
||||
<mxPoint x="270" y="30" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="27" value="" style="endArrow=none;dashed=1;html=1;dashPattern=1 3;strokeWidth=2;rounded=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="580" y="380" as="sourcePoint" />
|
||||
<mxPoint x="580" y="30" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="30" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="28" target="29" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="28" value="Settings page (settings/servers)" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="640" y="30" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="32" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="29" target="31" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="29" value="Allow user to add and remove Blossom servers" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="665" y="150" width="120" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="31" value="Show suggested Blossom servers" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="665" y="250" width="120" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="33" value="" style="endArrow=none;dashed=1;html=1;dashPattern=1 3;strokeWidth=2;rounded=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="40" y="426" as="sourcePoint" />
|
||||
<mxPoint x="820" y="426" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="39" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="34" target="38" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="34" value="User 1" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="620" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="35" value="<span style="color: rgb(0, 0, 0); font-family: Helvetica; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;">Servers: A, B, C</span>" style="text;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
|
||||
<mxGeometry x="70" y="570" width="110" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="42" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="36" target="41" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="36" value="User 2" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="320" y="620" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="37" value="<span style="color: rgb(0, 0, 0); font-family: Helvetica; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;">Servers: D, E</span>" style="text;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
|
||||
<mxGeometry x="350" y="570" width="110" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="38" target="36" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="38" value="Creates document, sign and publish to A, B, C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="770" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="44" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="41" target="43" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="41" value="Reads the files From A, B, C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="320" y="770" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="43" value="Sign, update the meta.json and publish to D, E" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="320" y="920" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="45" value="<span style="color: rgb(0, 0, 0); font-family: Helvetica; font-size: 18px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;">File Servers (Blossom)</span>" style="text;whiteSpace=wrap;html=1;fontSize=18;" parent="1" vertex="1">
|
||||
<mxGeometry x="330" y="440" width="190" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="46" value="<span style="color: rgb(0, 0, 0); font-family: Helvetica; font-size: 18px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;">Sigits</span>" style="text;whiteSpace=wrap;html=1;fontSize=18;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="510" width="50" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="47" value="<span style="color: rgb(0, 0, 0); font-family: Helvetica; font-size: 18px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;">User App Data</span>" style="text;whiteSpace=wrap;html=1;fontSize=18;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="1050" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="52" value="Loads the page" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="49" target="51" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="49" value="User" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="1290" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="50" value="<span style="color: rgb(0, 0, 0); font-family: Helvetica; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;">Servers: A, B, C</span>" style="text;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
|
||||
<mxGeometry x="70" y="1240" width="110" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="61" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="51" target="60" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="51" value="Fetche the app data from A, B, C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="1450" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="64" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="58" target="63" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="58" value="List the sigits found in the app data" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="1750" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="62" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="60" target="58" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="60" value="We have 3 data sources, we merge all 3 sets into 1 object" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="1600" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="63" value="User opens a SIGIT (flow is on the top)" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="65" y="1920" width="120" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="65" value="<p style="margin:0px;margin-top:4px;text-align:center;text-decoration:underline;"><b>interface UserAppData</b></p><hr><p style="margin: 0px 0px 0px 8px; font-size: 14px;">sigits<br style="border-color: var(--border-color); text-align: center;"><span style="text-align: center;">blossomVersions</span><br style="border-color: var(--border-color); text-align: center;"><span style="text-align: center;">processedGiftWrapps</span><br style="border-color: var(--border-color); text-align: center;"><span style="text-align: center;">keyPair</span><br></p>" style="verticalAlign=top;align=left;overflow=fill;fontSize=12;fontFamily=Helvetica;html=1;whiteSpace=wrap;" parent="1" vertex="1">
|
||||
<mxGeometry x="40" y="1100" width="160" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="68" value="Creates a SIGIT" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="66" target="67" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="66" value="User" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="320" y="1290" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="70" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="67" target="69" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="67" value="Publish the SIGIT to A,B,C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="320" y="1450" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="72" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="69" target="71" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="69" value="Capture the SIGIT urls and publish them in UserAppData to the servers A,B,C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="320" y="1610" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="74" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="71" target="73">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="71" value="Keep last&nbsp; 10 versions of blossom which includes SIGIT" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="320" y="1770" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="79" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="73" target="77">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="73" value="Every blossom version can have multiple links that user added" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="345" y="1920" width="120" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="77" value="Get latest version, if it has multiple URLs choose the first one which matches the hash" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="320" y="2060" width="170" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
102
package-lock.json
generated
102
package-lock.json
generated
@ -20,12 +20,14 @@
|
||||
"@mui/lab": "5.0.0-alpha.166",
|
||||
"@mui/material": "5.15.11",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@nostr-dev-kit/ndk": "2.5.0",
|
||||
"@nostr-dev-kit/ndk": "2.10.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@reduxjs/toolkit": "2.2.1",
|
||||
"axios": "^1.7.4",
|
||||
"crypto-hash": "3.0.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dexie": "4.0.8",
|
||||
"dnd-core": "16.0.1",
|
||||
"file-saver": "2.0.5",
|
||||
"idb": "8.0.0",
|
||||
@ -1712,65 +1714,79 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostr-dev-kit/ndk": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.5.0.tgz",
|
||||
"integrity": "sha512-A2nRgjjLScDhGZGPWx8xUIJM66dJWScdWQoCn/tI1Gtwpple+C2Jp7C9t3mb0oF3bwd2nsV6qwS//wdrH8QvYQ==",
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.0.tgz",
|
||||
"integrity": "sha512-TqCAAo6ylORraAXrzRkCGFN2xTMiFbdER8Y8CtUT0HwOpFG/Wn+PBNeDeDmqkl/6LaPdeyXmVwCWj2KcUjIwYA==",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.4.0",
|
||||
"@noble/hashes": "^1.3.1",
|
||||
"@noble/secp256k1": "^2.0.0",
|
||||
"@scure/base": "^1.1.1",
|
||||
"debug": "^4.3.4",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"node-fetch": "^3.3.1",
|
||||
"nostr-tools": "^1.15.0",
|
||||
"nostr-tools": "^2.7.1",
|
||||
"tseep": "^1.1.1",
|
||||
"typescript-lru-cache": "^2.0.0",
|
||||
"utf8-buffer": "^1.0.0",
|
||||
"websocket-polyfill": "^0.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@nostr-dev-kit/ndk/node_modules/@noble/ciphers": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz",
|
||||
"integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
"node_modules/@nostr-dev-kit/ndk-cache-dexie": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.1.tgz",
|
||||
"integrity": "sha512-tUwEy68bd9GL5JVuZIjcpdwuDEBnaXen3WJ64/GRDtbyE1RB01Y6hHC7IQC9bcQ6SC7XBGyPd+2nuTyR7+Mffg==",
|
||||
"dependencies": {
|
||||
"@nostr-dev-kit/ndk": "2.10.0",
|
||||
"debug": "^4.3.4",
|
||||
"dexie": "^4.0.2",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"typescript-lru-cache": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nostr-dev-kit/ndk/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz",
|
||||
"integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
"@noble/hashes": "1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz",
|
||||
"integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz",
|
||||
"integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==",
|
||||
"version": "2.10.4",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz",
|
||||
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "0.2.0",
|
||||
"@noble/curves": "1.1.0",
|
||||
"@noble/ciphers": "^0.5.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
@ -1780,6 +1796,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/hashes": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/fontkit": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
|
||||
@ -3859,6 +3908,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dexie": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.8.tgz",
|
||||
"integrity": "sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ=="
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
|
@ -30,12 +30,14 @@
|
||||
"@mui/lab": "5.0.0-alpha.166",
|
||||
"@mui/material": "5.15.11",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@nostr-dev-kit/ndk": "2.5.0",
|
||||
"@nostr-dev-kit/ndk": "2.10.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@reduxjs/toolkit": "2.2.1",
|
||||
"axios": "^1.7.4",
|
||||
"crypto-hash": "3.0.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dexie": "4.0.8",
|
||||
"dnd-core": "16.0.1",
|
||||
"file-saver": "2.0.5",
|
||||
"idb": "8.0.0",
|
||||
|
@ -1,3 +0,0 @@
|
||||
{
|
||||
"SIGIT_BLOSSOM": "https://blossom.sigit.io"
|
||||
}
|
17
src/App.scss
17
src/App.scss
@ -135,7 +135,7 @@ li {
|
||||
// Consistent styling for every file mark
|
||||
// Reverts some of the design defaults for font
|
||||
.file-mark {
|
||||
font-family: 'Roboto', serif;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
letter-spacing: normal;
|
||||
@ -169,18 +169,3 @@ li {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
width: 100%;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 4px 0 rgb(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 15px;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
15
src/App.tsx
15
src/App.tsx
@ -1,17 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAppSelector } from './hooks'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthController } from './controllers'
|
||||
|
||||
import { useAppSelector, useAuth } from './hooks'
|
||||
|
||||
import { MainLayout } from './layouts/Main'
|
||||
|
||||
import { appPrivateRoutes, appPublicRoutes } from './routes'
|
||||
import './App.scss'
|
||||
import {
|
||||
privateRoutes,
|
||||
publicRoutes,
|
||||
recursiveRouteRenderer
|
||||
} from './routes/util'
|
||||
|
||||
import './App.scss'
|
||||
|
||||
const App = () => {
|
||||
const { checkSession } = useAuth()
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
|
||||
useEffect(() => {
|
||||
@ -22,9 +26,8 @@ const App = () => {
|
||||
window.location.hostname = 'localhost'
|
||||
}
|
||||
|
||||
const authController = new AuthController()
|
||||
authController.checkSession()
|
||||
}, [])
|
||||
checkSession()
|
||||
}, [checkSession])
|
||||
|
||||
const handleRootRedirect = () => {
|
||||
if (authState.loggedIn) return appPrivateRoutes.homePage
|
||||
|
@ -37,30 +37,19 @@ export const AppBar = () => {
|
||||
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
const metadataState = useAppSelector((state) => state.metadata)
|
||||
const userRobotImage = useAppSelector((state) => state.userRobotImage)
|
||||
const userProfile = useAppSelector((state) => state.user.profile)
|
||||
const userRobotImage = useAppSelector((state) => state.user.robotImage)
|
||||
|
||||
useEffect(() => {
|
||||
if (metadataState) {
|
||||
if (metadataState.content) {
|
||||
const profileMetadata = JSON.parse(metadataState.content)
|
||||
const { picture } = profileMetadata
|
||||
|
||||
if (picture || userRobotImage) {
|
||||
setUserAvatar(picture || userRobotImage)
|
||||
}
|
||||
|
||||
const npub = authState.usersPubkey
|
||||
? hexToNpub(authState.usersPubkey)
|
||||
: ''
|
||||
|
||||
setUsername(getProfileUsername(npub, profileMetadata))
|
||||
} else {
|
||||
setUserAvatar(userRobotImage || '')
|
||||
setUsername('')
|
||||
}
|
||||
const npub = authState.usersPubkey ? hexToNpub(authState.usersPubkey) : ''
|
||||
if (userProfile) {
|
||||
setUserAvatar(userProfile.image || userRobotImage || '')
|
||||
setUsername(getProfileUsername(npub, userProfile))
|
||||
} else {
|
||||
setUserAvatar('')
|
||||
setUsername(getProfileUsername(npub))
|
||||
}
|
||||
}, [metadataState, userRobotImage, authState.usersPubkey])
|
||||
}, [userRobotImage, authState.usersPubkey, userProfile])
|
||||
|
||||
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorElUser(event.currentTarget)
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import styles from './style.module.scss'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types'
|
||||
import { User, UserRole, KeyboardCode } from '../../types'
|
||||
import { MouseState, DrawnField, DrawTool } from '../../types/drawing'
|
||||
import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
|
||||
import { SigitFile } from '../../utils/file'
|
||||
@ -27,6 +27,7 @@ const MINIMUM_RECT_SIZE = {
|
||||
width: 10,
|
||||
height: 10
|
||||
} as const
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
|
||||
const DEFAULT_START_SIZE = {
|
||||
width: 140,
|
||||
@ -42,7 +43,7 @@ type FieldIndexer = [...PageIndexer, field: number]
|
||||
|
||||
interface DrawPdfFieldsProps {
|
||||
users: User[]
|
||||
metadata: { [key: string]: ProfileMetadata }
|
||||
userProfiles: { [key: string]: NDKUserProfile }
|
||||
sigitFiles: SigitFile[]
|
||||
updateSigitFiles: Updater<SigitFile[]>
|
||||
selectedTool?: DrawTool
|
||||
@ -50,7 +51,7 @@ interface DrawPdfFieldsProps {
|
||||
|
||||
export const DrawPDFFields = ({
|
||||
selectedTool,
|
||||
metadata,
|
||||
userProfiles,
|
||||
sigitFiles,
|
||||
updateSigitFiles,
|
||||
users
|
||||
@ -678,17 +679,17 @@ export const DrawPDFFields = ({
|
||||
renderValue={(value) => (
|
||||
<Counterpart
|
||||
npub={value}
|
||||
metadata={metadata}
|
||||
userProfiles={userProfiles}
|
||||
signers={signers}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{signers.map((signer, index) => {
|
||||
const npub = hexToNpub(signer.pubkey)
|
||||
const profileMetadata = metadata[signer.pubkey]
|
||||
const profile = userProfiles[signer.pubkey]
|
||||
const displayValue = getProfileUsername(
|
||||
npub,
|
||||
profileMetadata
|
||||
profile
|
||||
)
|
||||
// make current signers dropdown visible
|
||||
if (
|
||||
@ -707,7 +708,7 @@ export const DrawPDFFields = ({
|
||||
<MenuItem key={index} value={npub}>
|
||||
<ListItemIcon>
|
||||
<AvatarIconButton
|
||||
src={profileMetadata?.picture}
|
||||
src={profile?.image}
|
||||
hexKey={signer.pubkey}
|
||||
aria-label={`account of user ${displayValue}`}
|
||||
color="inherit"
|
||||
|
@ -1,31 +1,32 @@
|
||||
import React from 'react'
|
||||
import { ProfileMetadata, User } from '../../../types'
|
||||
import { User } from '../../../types'
|
||||
import _ from 'lodash'
|
||||
import { npubToHex, getProfileUsername } from '../../../utils'
|
||||
import { AvatarIconButton } from '../../UserAvatarIconButton'
|
||||
import styles from './Counterpart.module.scss'
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
|
||||
interface CounterpartProps {
|
||||
npub: string
|
||||
metadata: {
|
||||
[key: string]: ProfileMetadata
|
||||
userProfiles: {
|
||||
[key: string]: NDKUserProfile
|
||||
}
|
||||
signers: User[]
|
||||
}
|
||||
|
||||
export const Counterpart = React.memo(
|
||||
({ npub, metadata, signers }: CounterpartProps) => {
|
||||
({ npub, userProfiles, signers }: CounterpartProps) => {
|
||||
let displayValue = _.truncate(npub, { length: 16 })
|
||||
|
||||
const signer = signers.find((u) => u.pubkey === npubToHex(npub))
|
||||
if (signer) {
|
||||
const signerMetadata = metadata[signer.pubkey]
|
||||
displayValue = getProfileUsername(npub, signerMetadata)
|
||||
const profile = userProfiles[signer.pubkey]
|
||||
displayValue = getProfileUsername(npub, profile)
|
||||
|
||||
return (
|
||||
<div className={styles.counterpartSelectValue}>
|
||||
<AvatarIconButton
|
||||
src={signerMetadata.picture}
|
||||
src={profile?.image}
|
||||
hexKey={signer.pubkey || undefined}
|
||||
sx={{
|
||||
padding: 0,
|
||||
|
@ -39,13 +39,9 @@ export const SignatureStrategy: MarkStrategy = {
|
||||
|
||||
if (await isOnline()) {
|
||||
try {
|
||||
const urls = await uploadToFileStorage(file)
|
||||
console.info(
|
||||
`${file.name} uploaded to following file storages: ${urls.join(', ')}`
|
||||
)
|
||||
// This bit was returning an url, and return of this function is being set to mark.value, so it kind of
|
||||
// does not make sense to return an url to the file storage
|
||||
return value
|
||||
const url = await uploadToFileStorage(file)
|
||||
console.info(`${file.name} uploaded to file storage`)
|
||||
return url
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
@ -55,7 +51,7 @@ export const SignatureStrategy: MarkStrategy = {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: offline
|
||||
// TOOD: offline
|
||||
}
|
||||
|
||||
return value
|
||||
|
@ -20,13 +20,10 @@ import {
|
||||
faFileDownload,
|
||||
faPen
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { Typography } from '@mui/material'
|
||||
import styles from '../UsersDetails.tsx/style.module.scss'
|
||||
|
||||
interface PdfMarkingProps {
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
files: CurrentUserFile[]
|
||||
noFiles?: boolean
|
||||
handleExport: () => void
|
||||
handleEncryptedExport: () => void
|
||||
handleSign: () => void
|
||||
@ -45,7 +42,6 @@ interface PdfMarkingProps {
|
||||
const PdfMarking = (props: PdfMarkingProps) => {
|
||||
const {
|
||||
files,
|
||||
noFiles,
|
||||
currentUserMarks,
|
||||
setCurrentUserMarks,
|
||||
setUpdatedMarks,
|
||||
@ -135,18 +131,6 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
setSelectedMarkValue(value)
|
||||
}
|
||||
|
||||
const renderRightColumn = () => {
|
||||
if (meta !== null) return <UsersDetails meta={meta} />
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
<Typography>No meta found</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className={signPageStyles.container}>
|
||||
@ -164,7 +148,7 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={renderRightColumn()}
|
||||
right={meta !== null && <UsersDetails meta={meta} />}
|
||||
leftIcon={faFileDownload}
|
||||
centerIcon={faPen}
|
||||
rightIcon={faCircleInfo}
|
||||
@ -172,32 +156,21 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
<PdfView
|
||||
currentFile={currentFile}
|
||||
files={files}
|
||||
noFiles={noFiles}
|
||||
handleMarkClick={handleMarkClick}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
selectedMark={selectedMark}
|
||||
currentUserMarks={currentUserMarks}
|
||||
otherUserMarks={otherUserMarks}
|
||||
/>
|
||||
{noFiles ? (
|
||||
<Typography textAlign="center">
|
||||
We were not able to retrieve the files.
|
||||
</Typography>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</StickySideColumns>
|
||||
|
||||
{!noFiles && (
|
||||
<MarkFormField
|
||||
handleSubmit={handleSubmit}
|
||||
handleSelectedMarkValueChange={handleChange}
|
||||
selectedMark={selectedMark}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
currentUserMarks={currentUserMarks}
|
||||
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
|
||||
/>
|
||||
)}
|
||||
<MarkFormField
|
||||
handleSubmit={handleSubmit}
|
||||
handleSelectedMarkValueChange={handleChange}
|
||||
selectedMark={selectedMark}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
currentUserMarks={currentUserMarks}
|
||||
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
|
@ -10,7 +10,6 @@ interface PdfViewProps {
|
||||
currentFile: CurrentUserFile | null
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
files: CurrentUserFile[]
|
||||
noFiles?: boolean
|
||||
handleMarkClick: (id: number) => void
|
||||
otherUserMarks: Mark[]
|
||||
selectedMark: CurrentUserMark | null
|
||||
@ -22,7 +21,6 @@ interface PdfViewProps {
|
||||
*/
|
||||
const PdfView = ({
|
||||
files,
|
||||
noFiles,
|
||||
currentUserMarks,
|
||||
handleMarkClick,
|
||||
selectedMarkValue,
|
||||
@ -78,8 +76,6 @@ const PdfView = ({
|
||||
<FileDivider key={`separator-${i}`} />,
|
||||
curr
|
||||
])
|
||||
) : noFiles ? (
|
||||
''
|
||||
) : (
|
||||
<LoadingSpinner variant="small" />
|
||||
)}
|
||||
|
@ -23,7 +23,7 @@ export const UserAvatar = ({
|
||||
}: UserAvatarProps) => {
|
||||
const profile = useProfileMetadata(pubkey)
|
||||
const name = getProfileUsername(pubkey, profile)
|
||||
const image = profile?.picture
|
||||
const image = profile?.image
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
@ -19,7 +19,6 @@ import {
|
||||
faCheck,
|
||||
faClock,
|
||||
faEye,
|
||||
faServer,
|
||||
faFile,
|
||||
faFileCircleExclamation
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
@ -41,7 +40,6 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
signers,
|
||||
viewers,
|
||||
fileHashes,
|
||||
zipUrls,
|
||||
signersStatus,
|
||||
createdAt,
|
||||
completedAt,
|
||||
@ -102,18 +100,6 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to parse the base URL from Blossom server full path
|
||||
*/
|
||||
const getBaseUrl = (url: string): string => {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
return `${parsedUrl.protocol}//${parsedUrl.host}`
|
||||
} catch (error) {
|
||||
return 'Invalid URL'
|
||||
}
|
||||
}
|
||||
|
||||
return submittedBy ? (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
@ -284,20 +270,6 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<FontAwesomeIcon icon={faFileCircleExclamation} /> —
|
||||
</>
|
||||
)}
|
||||
<span className={styles.detailsItem}>
|
||||
<FontAwesomeIcon icon={faEye} /> {signedStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<p>File Servers</p>
|
||||
|
||||
{zipUrls &&
|
||||
zipUrls.map((url) => (
|
||||
<span className={styles.detailsItem} key={url}>
|
||||
<FontAwesomeIcon icon={faServer} /> {getBaseUrl(url)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
|
299
src/contexts/NDKContext.tsx
Normal file
299
src/contexts/NDKContext.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
import NDK, {
|
||||
getRelayListForUser,
|
||||
Hexpubkey,
|
||||
NDKEvent,
|
||||
NDKFilter,
|
||||
NDKRelayList,
|
||||
NDKRelaySet,
|
||||
NDKSubscriptionCacheUsage,
|
||||
NDKSubscriptionOptions,
|
||||
NDKUser,
|
||||
NDKUserProfile
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
|
||||
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie'
|
||||
|
||||
import { Dexie } from 'dexie'
|
||||
import { createContext, ReactNode, useEffect, useMemo } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { UserRelaysType } from '../types'
|
||||
import {
|
||||
DEFAULT_LOOK_UP_RELAY_LIST,
|
||||
hexToNpub,
|
||||
orderEventsChronologically,
|
||||
SIGIT_RELAY,
|
||||
timeout
|
||||
} from '../utils'
|
||||
|
||||
export interface NDKContextType {
|
||||
ndk: NDK
|
||||
fetchEvents: (
|
||||
filter: NDKFilter,
|
||||
opts?: NDKSubscriptionOptions
|
||||
) => Promise<NDKEvent[]>
|
||||
fetchEvent: (
|
||||
filter: NDKFilter,
|
||||
opts?: NDKSubscriptionOptions
|
||||
) => Promise<NDKEvent | null>
|
||||
fetchEventsFromUserRelays: (
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType,
|
||||
opts?: NDKSubscriptionOptions
|
||||
) => Promise<NDKEvent[]>
|
||||
fetchEventFromUserRelays: (
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType,
|
||||
opts?: NDKSubscriptionOptions
|
||||
) => Promise<NDKEvent | null>
|
||||
findMetadata: (
|
||||
pubkey: string,
|
||||
opts?: NDKSubscriptionOptions
|
||||
) => Promise<NDKUserProfile | null>
|
||||
getNDKRelayList: (pubkey: Hexpubkey) => Promise<NDKRelayList>
|
||||
publish: (event: NDKEvent, explicitRelayUrls?: string[]) => Promise<string[]>
|
||||
}
|
||||
|
||||
// Create the context with an initial value of `null`
|
||||
export const NDKContext = createContext<NDKContextType | null>(null)
|
||||
|
||||
// Create a provider component to wrap around parts of your app
|
||||
export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
useEffect(() => {
|
||||
window.onunhandledrejection = async (event: PromiseRejectionEvent) => {
|
||||
event.preventDefault()
|
||||
if (event.reason?.name === Dexie.errnames.DatabaseClosed) {
|
||||
console.log(
|
||||
'Could not open Dexie DB, probably version change. Deleting old DB and reloading...'
|
||||
)
|
||||
await Dexie.delete('degmod-db')
|
||||
// Must reload to open a brand new DB
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ndk = useMemo(() => {
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
localStorage.setItem('debug', '*')
|
||||
}
|
||||
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'sigit-db' })
|
||||
dexieAdapter.locking = true
|
||||
const ndk = new NDK({
|
||||
enableOutboxModel: true,
|
||||
autoConnectUserRelays: true,
|
||||
autoFetchUserMutelist: true,
|
||||
explicitRelayUrls: [...DEFAULT_LOOK_UP_RELAY_LIST],
|
||||
cacheAdapter: dexieAdapter
|
||||
})
|
||||
ndk.connect()
|
||||
|
||||
return ndk
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves multiple event based on a provided filter.
|
||||
*
|
||||
* @param filter - The filter criteria to find the event.
|
||||
* @returns Returns a promise that resolves to the found event or null if not found.
|
||||
*/
|
||||
const fetchEvents = async (
|
||||
filter: NDKFilter,
|
||||
opts?: NDKSubscriptionOptions
|
||||
): Promise<NDKEvent[]> => {
|
||||
return ndk
|
||||
.fetchEvents(filter, {
|
||||
closeOnEose: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
|
||||
...opts
|
||||
})
|
||||
.then((ndkEventSet) => {
|
||||
const ndkEvents = Array.from(ndkEventSet)
|
||||
return orderEventsChronologically(ndkEvents)
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log the error and show a notification if fetching fails
|
||||
console.error('An error occurred in fetching events', err)
|
||||
toast.error('An error occurred in fetching events') // Show error notification
|
||||
return [] // Return an empty array in case of an error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves an event based on a provided filter.
|
||||
*
|
||||
* @param filter - The filter criteria to find the event.
|
||||
* @returns Returns a promise that resolves to the found event or null if not found.
|
||||
*/
|
||||
const fetchEvent = async (
|
||||
filter: NDKFilter,
|
||||
opts?: NDKSubscriptionOptions
|
||||
) => {
|
||||
const events = await fetchEvents(filter, opts)
|
||||
if (events.length === 0) return null
|
||||
return events[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves multiple events from the user's relays based on a specified filter.
|
||||
* The function first retrieves the user's relays, and then fetches the events using the provided filter.
|
||||
*
|
||||
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
|
||||
* @param hexKey - The hexadecimal representation of the user's public key.
|
||||
* @param userRelaysType - The type of relays to search (e.g., write, read).
|
||||
* @returns A promise that resolves with an array of events.
|
||||
*/
|
||||
const fetchEventsFromUserRelays = async (
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType,
|
||||
opts?: NDKSubscriptionOptions
|
||||
): Promise<NDKEvent[]> => {
|
||||
// Find the user's relays (10s timeout).
|
||||
const relayUrls = await Promise.race([
|
||||
getRelayListForUser(hexKey, ndk),
|
||||
timeout(3000)
|
||||
])
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList[userRelaysType]
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`An error occurred in fetching user's (${hexKey}) ${userRelaysType}`,
|
||||
err
|
||||
)
|
||||
return [] as string[]
|
||||
})
|
||||
|
||||
if (!relayUrls.includes(SIGIT_RELAY)) {
|
||||
relayUrls.push(SIGIT_RELAY)
|
||||
}
|
||||
|
||||
return ndk
|
||||
.fetchEvents(
|
||||
filter,
|
||||
{
|
||||
closeOnEose: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
|
||||
...opts
|
||||
},
|
||||
relayUrls.length
|
||||
? NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
|
||||
: undefined
|
||||
)
|
||||
.then((ndkEventSet) => {
|
||||
const ndkEvents = Array.from(ndkEventSet)
|
||||
return orderEventsChronologically(ndkEvents)
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log the error and show a notification if fetching fails
|
||||
console.error('An error occurred in fetching events', err)
|
||||
toast.error('An error occurred in fetching events') // Show error notification
|
||||
return [] // Return an empty array in case of an error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an event from the user's relays based on a specified filter.
|
||||
* The function first retrieves the user's relays, and then fetches the event using the provided filter.
|
||||
*
|
||||
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
|
||||
* @param hexKey - The hexadecimal representation of the user's public key.
|
||||
* @param userRelaysType - The type of relays to search (e.g., write, read).
|
||||
* @returns A promise that resolves to the fetched event or null if the operation fails.
|
||||
*/
|
||||
const fetchEventFromUserRelays = async (
|
||||
filter: NDKFilter | NDKFilter[],
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType,
|
||||
opts?: NDKSubscriptionOptions
|
||||
) => {
|
||||
const events = await fetchEventsFromUserRelays(
|
||||
filter,
|
||||
hexKey,
|
||||
userRelaysType,
|
||||
opts
|
||||
)
|
||||
if (events.length === 0) return null
|
||||
return events[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds metadata for a given pubkey.
|
||||
*
|
||||
* @param hexKey - The pubkey to search for metadata.
|
||||
* @returns A promise that resolves to the metadata event.
|
||||
*/
|
||||
const findMetadata = async (
|
||||
pubkey: string,
|
||||
opts?: NDKSubscriptionOptions
|
||||
): Promise<NDKUserProfile | null> => {
|
||||
const npub = hexToNpub(pubkey)
|
||||
|
||||
const user = new NDKUser({ npub })
|
||||
user.ndk = ndk
|
||||
|
||||
return await user.fetchProfile({
|
||||
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
|
||||
...(opts || {})
|
||||
})
|
||||
}
|
||||
|
||||
const getNDKRelayList = async (pubkey: Hexpubkey) => {
|
||||
const ndkRelayList = await Promise.race([
|
||||
getRelayListForUser(pubkey, ndk),
|
||||
timeout(10000)
|
||||
]).catch(() => {
|
||||
const relayList = new NDKRelayList(ndk)
|
||||
relayList.bothRelayUrls = [SIGIT_RELAY]
|
||||
return relayList
|
||||
})
|
||||
|
||||
return ndkRelayList
|
||||
}
|
||||
|
||||
const publish = async (
|
||||
event: NDKEvent,
|
||||
explicitRelayUrls?: string[]
|
||||
): Promise<string[]> => {
|
||||
if (!event.sig) throw new Error('Before publishing first sign the event!')
|
||||
|
||||
let ndkRelaySet: NDKRelaySet | undefined
|
||||
|
||||
if (explicitRelayUrls && explicitRelayUrls.length > 0) {
|
||||
if (!explicitRelayUrls.includes(SIGIT_RELAY)) {
|
||||
explicitRelayUrls = [...explicitRelayUrls, SIGIT_RELAY]
|
||||
}
|
||||
ndkRelaySet = NDKRelaySet.fromRelayUrls(explicitRelayUrls, ndk)
|
||||
}
|
||||
|
||||
return await Promise.race([event.publish(ndkRelaySet), timeout(3000)])
|
||||
.then((res) => {
|
||||
const relaysPublishedOn = Array.from(res)
|
||||
return relaysPublishedOn.map((relay) => relay.url)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`An error occurred in publishing event`, err)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<NDKContext.Provider
|
||||
value={{
|
||||
ndk,
|
||||
fetchEvents,
|
||||
fetchEvent,
|
||||
fetchEventsFromUserRelays,
|
||||
fetchEventFromUserRelays,
|
||||
findMetadata,
|
||||
getNDKRelayList,
|
||||
publish
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NDKContext.Provider>
|
||||
)
|
||||
}
|
@ -1,169 +0,0 @@
|
||||
import { EventTemplate } from 'nostr-tools'
|
||||
import { MetadataController, NostrController } from '.'
|
||||
import { appPrivateRoutes } from '../routes'
|
||||
import {
|
||||
setAuthState,
|
||||
setMetadataEvent,
|
||||
setRelayMapAction
|
||||
} from '../store/actions'
|
||||
import store from '../store/store'
|
||||
import { SignedEvent } from '../types'
|
||||
import {
|
||||
base64DecodeAuthToken,
|
||||
base64EncodeSignedEvent,
|
||||
compareObjects,
|
||||
getAuthToken,
|
||||
getRelayMap,
|
||||
saveAuthToken,
|
||||
unixNow
|
||||
} from '../utils'
|
||||
import { getFileServerMap } from '../utils/file-servers.ts'
|
||||
import { setServerMapAction } from '../store/servers/action.ts'
|
||||
|
||||
export class AuthController {
|
||||
private nostrController: NostrController
|
||||
private metadataController: MetadataController
|
||||
|
||||
constructor() {
|
||||
this.nostrController = NostrController.getInstance()
|
||||
this.metadataController = MetadataController.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* Function will authenticate user by signing an auth event
|
||||
* which is done by calling the sign() function, where appropriate
|
||||
* method will be chosen (extension or keys)
|
||||
*
|
||||
* @param pubkey of the user trying to login
|
||||
* @returns url to redirect if authentication successfull
|
||||
* or error if otherwise
|
||||
*/
|
||||
async authAndGetMetadataAndRelaysMap(pubkey: string) {
|
||||
const emptyMetadata = this.metadataController.getEmptyMetadataEvent()
|
||||
|
||||
this.metadataController
|
||||
.findMetadata(pubkey)
|
||||
.then((event) => {
|
||||
if (event) {
|
||||
store.dispatch(setMetadataEvent(event))
|
||||
} else {
|
||||
store.dispatch(setMetadataEvent(emptyMetadata))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Error occurred while finding metadata', err)
|
||||
|
||||
store.dispatch(setMetadataEvent(emptyMetadata))
|
||||
})
|
||||
|
||||
// Nostr uses unix timestamps
|
||||
const timestamp = unixNow()
|
||||
const { href } = window.location
|
||||
|
||||
const authEvent: EventTemplate = {
|
||||
kind: 27235,
|
||||
tags: [
|
||||
['u', href],
|
||||
['method', 'GET']
|
||||
],
|
||||
content: '',
|
||||
created_at: timestamp
|
||||
}
|
||||
|
||||
const signedAuthEvent = await this.nostrController.signEvent(authEvent)
|
||||
this.createAndSaveAuthToken(signedAuthEvent)
|
||||
|
||||
store.dispatch(
|
||||
setAuthState({
|
||||
loggedIn: true,
|
||||
usersPubkey: pubkey
|
||||
})
|
||||
)
|
||||
|
||||
const relayMapPromise = getRelayMap(pubkey)
|
||||
const serverMapPromise = getFileServerMap(pubkey)
|
||||
|
||||
const [relayMap, serverMap] = await Promise.all([
|
||||
relayMapPromise,
|
||||
serverMapPromise
|
||||
])
|
||||
|
||||
// Navigate user to relays page if relay map is empty
|
||||
if (Object.keys(relayMap).length < 1) {
|
||||
return Promise.resolve(appPrivateRoutes.relays)
|
||||
}
|
||||
|
||||
// Navigate user to servers page if server map is empty
|
||||
if (Object.keys(serverMap).length < 1) {
|
||||
return Promise.resolve(appPrivateRoutes.servers)
|
||||
}
|
||||
|
||||
if (store.getState().auth.loggedIn) {
|
||||
if (!compareObjects(store.getState().relays?.map, relayMap.map))
|
||||
store.dispatch(setRelayMapAction(relayMap.map))
|
||||
|
||||
if (!compareObjects(store.getState().servers?.map, serverMap.map))
|
||||
store.dispatch(setServerMapAction(serverMap.map))
|
||||
}
|
||||
|
||||
/**
|
||||
* This block was added before we started using the `nostr-login` package
|
||||
* At this point it seems it's not needed anymore and it's even blocking the flow (reloading on /verify)
|
||||
* TODO to remove this if app works fine
|
||||
*/
|
||||
// const currentLocation = window.location.hash.replace('#', '')
|
||||
|
||||
// if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
|
||||
// // Since verify is both public and private route, we don't use the `visitedLink`
|
||||
// // value for it. Otherwise, when linking to /verify/:id we get redirected
|
||||
// // to the root `/`
|
||||
// if (currentLocation.includes(appPublicRoutes.verify)) {
|
||||
// return Promise.resolve(currentLocation)
|
||||
// }
|
||||
//
|
||||
// // User did change the location to one of the private routes
|
||||
// const visitedLink = getVisitedLink()
|
||||
//
|
||||
// if (visitedLink) {
|
||||
// const { pathname, search } = visitedLink
|
||||
//
|
||||
// return Promise.resolve(`${pathname}${search}`)
|
||||
// } else {
|
||||
// // Navigate user in
|
||||
// return Promise.resolve(appPrivateRoutes.homePage)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
checkSession() {
|
||||
const savedAuthToken = getAuthToken()
|
||||
|
||||
if (savedAuthToken) {
|
||||
const signedEvent = base64DecodeAuthToken(savedAuthToken)
|
||||
|
||||
store.dispatch(
|
||||
setAuthState({
|
||||
loggedIn: true,
|
||||
usersPubkey: signedEvent.pubkey
|
||||
})
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
setAuthState({
|
||||
loggedIn: false,
|
||||
usersPubkey: undefined
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private createAndSaveAuthToken(signedAuthEvent: SignedEvent) {
|
||||
const base64Encoded = base64EncodeSignedEvent(signedAuthEvent)
|
||||
|
||||
// save newly created auth token (base64 nostr singed event) in local storage along with expiry time
|
||||
saveAuthToken(base64Encoded)
|
||||
return base64Encoded
|
||||
}
|
||||
}
|
@ -1,219 +0,0 @@
|
||||
import {
|
||||
Event,
|
||||
Filter,
|
||||
VerifiedEvent,
|
||||
kinds,
|
||||
validateEvent,
|
||||
verifyEvent
|
||||
} from 'nostr-tools'
|
||||
import { toast } from 'react-toastify'
|
||||
import { EventEmitter } from 'tseep'
|
||||
import { NostrController, relayController } from '.'
|
||||
import { localCache } from '../services'
|
||||
import { ProfileMetadata, RelaySet } from '../types'
|
||||
import {
|
||||
findRelayListAndUpdateCache,
|
||||
findRelayListInCache,
|
||||
getDefaultRelaySet,
|
||||
getUserRelaySet,
|
||||
isOlderThanOneDay,
|
||||
unixNow
|
||||
} from '../utils'
|
||||
import { DEFAULT_LOOK_UP_RELAY_LIST } from '../utils/const'
|
||||
|
||||
export class MetadataController extends EventEmitter {
|
||||
private static instance: MetadataController
|
||||
private nostrController: NostrController
|
||||
private specialMetadataRelay = 'wss://purplepag.es'
|
||||
private pendingFetches = new Map<string, Promise<Event | null>>() // Track pending fetches
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.nostrController = NostrController.getInstance()
|
||||
}
|
||||
|
||||
public static getInstance(): MetadataController {
|
||||
if (!MetadataController.instance) {
|
||||
MetadataController.instance = new MetadataController()
|
||||
}
|
||||
return MetadataController.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously checks for more recent metadata events authored by a specific key.
|
||||
* If a more recent metadata event is found, it is handled and returned.
|
||||
* If no more recent event is found, the current event is returned.
|
||||
* @param hexKey The hexadecimal key of the author to filter metadata events.
|
||||
* @param currentEvent The current metadata event, if any, to compare with newer events.
|
||||
* @returns A promise resolving to the most recent metadata event found, or null if none is found.
|
||||
*/
|
||||
private async checkForMoreRecentMetadata(
|
||||
hexKey: string,
|
||||
currentEvent: Event | null
|
||||
): Promise<Event | null> {
|
||||
// Return the ongoing fetch promise if one exists for the same hexKey
|
||||
if (this.pendingFetches.has(hexKey)) {
|
||||
return this.pendingFetches.get(hexKey)!
|
||||
}
|
||||
|
||||
// Define the event filter to only include metadata events authored by the given key
|
||||
const eventFilter: Filter = {
|
||||
kinds: [kinds.Metadata],
|
||||
authors: [hexKey]
|
||||
}
|
||||
|
||||
const fetchPromise = relayController
|
||||
.fetchEvent(eventFilter, DEFAULT_LOOK_UP_RELAY_LIST)
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingFetches.delete(hexKey)
|
||||
})
|
||||
|
||||
this.pendingFetches.set(hexKey, fetchPromise)
|
||||
|
||||
const metadataEvent = await fetchPromise
|
||||
|
||||
if (
|
||||
metadataEvent &&
|
||||
validateEvent(metadataEvent) &&
|
||||
verifyEvent(metadataEvent)
|
||||
) {
|
||||
if (
|
||||
!currentEvent ||
|
||||
metadataEvent.created_at >= currentEvent.created_at
|
||||
) {
|
||||
this.handleNewMetadataEvent(metadataEvent)
|
||||
}
|
||||
return metadataEvent
|
||||
}
|
||||
|
||||
// todo/implement: if no valid metadata event is found in DEFAULT_LOOK_UP_RELAY_LIST
|
||||
// try to query user relay list
|
||||
|
||||
// if current event is null we should cache empty metadata event for provided hexKey
|
||||
if (!currentEvent) {
|
||||
const emptyMetadata = this.getEmptyMetadataEvent(hexKey)
|
||||
this.handleNewMetadataEvent(emptyMetadata as VerifiedEvent)
|
||||
}
|
||||
|
||||
return currentEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new metadata events and emit them to subscribers
|
||||
*/
|
||||
private async handleNewMetadataEvent(event: VerifiedEvent) {
|
||||
// update the event in local cache
|
||||
localCache.addUserMetadata(event)
|
||||
// Emit the event to subscribers.
|
||||
this.emit(event.pubkey, event.kind, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds metadata for a given hexadecimal key.
|
||||
*
|
||||
* @param hexKey - The hexadecimal key to search for metadata.
|
||||
* @returns A promise that resolves to the metadata event.
|
||||
*/
|
||||
public findMetadata = async (hexKey: string): Promise<Event | null> => {
|
||||
// Attempt to retrieve the metadata event from the local cache
|
||||
const cachedMetadataEvent = await localCache.getUserMetadata(hexKey)
|
||||
|
||||
// If cached metadata is found, check its validity
|
||||
if (cachedMetadataEvent) {
|
||||
// Check if the cached metadata is older than one day
|
||||
if (isOlderThanOneDay(cachedMetadataEvent.cachedAt)) {
|
||||
// If older than one week, find the metadata from relays in background
|
||||
this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event)
|
||||
}
|
||||
|
||||
// Return the cached metadata event
|
||||
return cachedMetadataEvent.event
|
||||
}
|
||||
|
||||
// If no cached metadata is found, retrieve it from relays
|
||||
return this.checkForMoreRecentMetadata(hexKey, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on the hexKey of the current user, this method attempts to retrieve a relay set.
|
||||
* @func findRelayListInCache first checks if there is already an up-to-date
|
||||
* relay list available in cache; if not -
|
||||
* @func findRelayListAndUpdateCache checks if the relevant relay event is available from
|
||||
* the purple pages relay;
|
||||
* @func findRelayListAndUpdateCache will run again if the previous two calls return null and
|
||||
* check if the relevant relay event can be obtained from 'most popular relays'
|
||||
* If relay event is found, it will be saved in cache for future use
|
||||
* @param hexKey of the current user
|
||||
* @return RelaySet which will contain either relays extracted from the user Relay Event
|
||||
* or a fallback RelaySet with Sigit's Relay
|
||||
*/
|
||||
public findRelayListMetadata = async (hexKey: string): Promise<RelaySet> => {
|
||||
const relayEvent =
|
||||
(await findRelayListInCache(hexKey)) ||
|
||||
(await findRelayListAndUpdateCache(DEFAULT_LOOK_UP_RELAY_LIST, hexKey))
|
||||
|
||||
return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet()
|
||||
}
|
||||
|
||||
public extractProfileMetadataContent = (event: Event) => {
|
||||
try {
|
||||
if (!event.content) return {}
|
||||
return JSON.parse(event.content) as ProfileMetadata
|
||||
} catch (error) {
|
||||
console.log('error in parsing metadata event content :>> ', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function will not sign provided event if the SIG exists
|
||||
*/
|
||||
public publishMetadataEvent = async (event: Event) => {
|
||||
let signedMetadataEvent = event
|
||||
|
||||
if (event.sig.length < 1) {
|
||||
const timestamp = unixNow()
|
||||
|
||||
// Metadata event to publish to the wss://purplepag.es relay
|
||||
const newMetadataEvent: Event = {
|
||||
...event,
|
||||
created_at: timestamp
|
||||
}
|
||||
|
||||
signedMetadataEvent =
|
||||
await this.nostrController.signEvent(newMetadataEvent)
|
||||
}
|
||||
|
||||
await relayController
|
||||
.publish(signedMetadataEvent, [this.specialMetadataRelay])
|
||||
.then((relays) => {
|
||||
if (relays.length) {
|
||||
toast.success(`Metadata event published on: ${relays.join('\n')}`)
|
||||
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent)
|
||||
} else {
|
||||
toast.error('Could not publish metadata event to any relay!')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
public validate = (event: Event) => validateEvent(event) && verifyEvent(event)
|
||||
|
||||
public getEmptyMetadataEvent = (pubkey?: string): Event => {
|
||||
return {
|
||||
content: '',
|
||||
created_at: new Date().valueOf(),
|
||||
id: '',
|
||||
kind: 0,
|
||||
pubkey: pubkey || '',
|
||||
sig: '',
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
}
|
@ -1,306 +0,0 @@
|
||||
import { Event, Filter, Relay } from 'nostr-tools'
|
||||
import {
|
||||
settleAllFullfilfedPromises,
|
||||
normalizeWebSocketURL,
|
||||
timeout
|
||||
} from '../utils'
|
||||
import { SIGIT_RELAY } from '../utils/const'
|
||||
|
||||
/**
|
||||
* Singleton class to manage relay operations.
|
||||
*/
|
||||
export class RelayController {
|
||||
private static instance: RelayController
|
||||
private pendingConnections = new Map<string, Promise<Relay | null>>() // Track pending connections
|
||||
public connectedRelays = new Map<string, Relay>()
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Provides the singleton instance of RelayController.
|
||||
*
|
||||
* @returns The singleton instance of RelayController.
|
||||
*/
|
||||
public static getInstance(): RelayController {
|
||||
if (!RelayController.instance) {
|
||||
RelayController.instance = new RelayController()
|
||||
}
|
||||
return RelayController.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to a relay server if not already connected.
|
||||
*
|
||||
* This method checks if a relay with the given URL is already in the list of connected relays.
|
||||
* If it is not connected, it attempts to establish a new connection.
|
||||
* On successful connection, the relay is added to the list of connected relays and returned.
|
||||
* If the connection fails, an error is logged and `null` is returned.
|
||||
*
|
||||
* @param relayUrl - The URL of the relay server to connect to.
|
||||
* @returns A promise that resolves to the connected relay object if successful, or `null` if the connection fails.
|
||||
*/
|
||||
public connectRelay = async (relayUrl: string): Promise<Relay | null> => {
|
||||
const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl)
|
||||
const relay = this.connectedRelays.get(normalizedWebSocketURL)
|
||||
|
||||
if (relay) {
|
||||
if (relay.connected) return relay
|
||||
|
||||
// If relay is found in connectedRelay map but not connected,
|
||||
// remove it from map and call connectRelay method again
|
||||
this.connectedRelays.delete(relayUrl)
|
||||
return this.connectRelay(relayUrl)
|
||||
}
|
||||
|
||||
// Check if there's already a pending connection for this relay URL
|
||||
if (this.pendingConnections.has(relayUrl)) {
|
||||
// Return the existing promise to avoid making another connection
|
||||
return this.pendingConnections.get(relayUrl)!
|
||||
}
|
||||
|
||||
// Create a new connection promise and store it in pendingConnections
|
||||
const connectionPromise = Relay.connect(relayUrl)
|
||||
.then((relay) => {
|
||||
if (relay.connected) {
|
||||
// Add the newly connected relay to the connected relays map
|
||||
this.connectedRelays.set(relayUrl, relay)
|
||||
|
||||
// Return the newly connected relay
|
||||
return relay
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log an error message if the connection fails
|
||||
console.error(`Relay connection failed: ${relayUrl}`, err)
|
||||
|
||||
// Return null to indicate connection failure
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
// Remove the connection from pendingConnections once it settles
|
||||
this.pendingConnections.delete(relayUrl)
|
||||
})
|
||||
|
||||
this.pendingConnections.set(relayUrl, connectionPromise)
|
||||
return connectionPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves multiple event from a set of relays based on a provided filter.
|
||||
* If no relays are specified, it defaults to using connected relays.
|
||||
*
|
||||
* @param filter - The filter criteria to find the event.
|
||||
* @param relays - An optional array of relay URLs to search for the event.
|
||||
* @returns Returns a promise that resolves with an array of events.
|
||||
*/
|
||||
fetchEvents = async (
|
||||
filter: Filter,
|
||||
relayUrls: string[] = []
|
||||
): Promise<Event[]> => {
|
||||
if (!relayUrls.includes(SIGIT_RELAY)) {
|
||||
/**
|
||||
* NOTE: To avoid side-effects on external relayUrls array passed as argument
|
||||
* re-assigned relayUrls with added sigit relay instead of just appending to same array
|
||||
*/
|
||||
|
||||
relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
|
||||
}
|
||||
|
||||
// connect to all specified relays
|
||||
const relays = await settleAllFullfilfedPromises(
|
||||
relayUrls,
|
||||
this.connectRelay
|
||||
)
|
||||
|
||||
// Check if any relays are connected
|
||||
if (relays.length === 0) {
|
||||
throw new Error('No relay is connected to fetch events!')
|
||||
}
|
||||
|
||||
const events: Event[] = []
|
||||
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
|
||||
|
||||
// Create a promise for each relay subscription
|
||||
const subPromises = relays.map((relay) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!relay.connected) {
|
||||
console.log(`${relay.url} : Not connected!`, 'Skipping subscription')
|
||||
return resolve()
|
||||
}
|
||||
|
||||
// Subscribe to the relay with the specified filter
|
||||
const sub = relay.subscribe([filter], {
|
||||
// Handle incoming events
|
||||
onevent: (e) => {
|
||||
// Add the event to the array if it's not a duplicate
|
||||
if (!eventIds.has(e.id)) {
|
||||
eventIds.add(e.id) // Record the event ID
|
||||
events.push(e) // Add the event to the array
|
||||
}
|
||||
},
|
||||
// Handle the End-Of-Stream (EOSE) message
|
||||
oneose: () => {
|
||||
sub.close() // Close the subscription
|
||||
resolve() // Resolve the promise when EOSE is received
|
||||
}
|
||||
})
|
||||
|
||||
// add a 30 sec of timeout to subscription
|
||||
setTimeout(() => {
|
||||
if (!sub.closed) {
|
||||
sub.close()
|
||||
resolve()
|
||||
}
|
||||
}, 30 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for all subscriptions to complete
|
||||
await Promise.allSettled(subPromises)
|
||||
|
||||
// It is possible that different relays will send different events and events array may contain more events then specified limit in filter
|
||||
// To fix this issue we'll first sort these events and then return only limited events
|
||||
if (filter.limit) {
|
||||
// Sort events by creation date in descending order
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
return events.slice(0, filter.limit)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves an event from a set of relays based on a provided filter.
|
||||
* If no relays are specified, it defaults to using connected relays.
|
||||
*
|
||||
* @param filter - The filter criteria to find the event.
|
||||
* @param relays - An optional array of relay URLs to search for the event.
|
||||
* @returns Returns a promise that resolves to the found event or null if not found.
|
||||
*/
|
||||
fetchEvent = async (
|
||||
filter: Filter,
|
||||
relays: string[] = []
|
||||
): Promise<Event | null> => {
|
||||
const events = await this.fetchEvents(filter, relays)
|
||||
|
||||
// Sort events by creation date in descending order
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// Return the most recent event, or null if no events were received
|
||||
return events[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to events from multiple relays.
|
||||
*
|
||||
* This method connects to the specified relay URLs and subscribes to events
|
||||
* using the provided filter. It handles incoming events through the given
|
||||
* `eventHandler` callback and manages the subscription lifecycle.
|
||||
*
|
||||
* @param filter - The filter criteria to apply when subscribing to events.
|
||||
* @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`SIGIT_RELAY`) is added automatically.
|
||||
* @param eventHandler - A callback function to handle incoming events. It receives an `Event` object.
|
||||
*
|
||||
*/
|
||||
subscribeForEvents = async (
|
||||
filter: Filter,
|
||||
relayUrls: string[] = [],
|
||||
eventHandler: (event: Event) => void
|
||||
) => {
|
||||
if (!relayUrls.includes(SIGIT_RELAY)) {
|
||||
/**
|
||||
* NOTE: To avoid side-effects on external relayUrls array passed as argument
|
||||
* re-assigned relayUrls with added sigit relay instead of just appending to same array
|
||||
*/
|
||||
relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
|
||||
}
|
||||
|
||||
// connect to all specified relays
|
||||
const relays = await settleAllFullfilfedPromises(
|
||||
relayUrls,
|
||||
this.connectRelay
|
||||
)
|
||||
|
||||
// Check if any relays are connected
|
||||
if (relays.length === 0) {
|
||||
throw new Error('No relay is connected to fetch events!')
|
||||
}
|
||||
|
||||
const processedEvents: string[] = [] // To keep track of processed events
|
||||
|
||||
// Create a promise for each relay subscription
|
||||
const subPromises = relays.map((relay) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Subscribe to the relay with the specified filter
|
||||
const sub = relay.subscribe([filter], {
|
||||
// Handle incoming events
|
||||
onevent: (e) => {
|
||||
// Process event only if it hasn't been processed before
|
||||
if (!processedEvents.includes(e.id)) {
|
||||
processedEvents.push(e.id)
|
||||
eventHandler(e) // Call the event handler with the event
|
||||
}
|
||||
},
|
||||
// Handle the End-Of-Stream (EOSE) message
|
||||
oneose: () => {
|
||||
sub.close() // Close the subscription
|
||||
resolve() // Resolve the promise when EOSE is received
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for all subscriptions to complete
|
||||
await Promise.allSettled(subPromises)
|
||||
}
|
||||
|
||||
publish = async (
|
||||
event: Event,
|
||||
relayUrls: string[] = []
|
||||
): Promise<string[]> => {
|
||||
if (!relayUrls.includes(SIGIT_RELAY)) {
|
||||
/**
|
||||
* NOTE: To avoid side-effects on external relayUrls array passed as argument
|
||||
* re-assigned relayUrls with added sigit relay instead of just appending to same array
|
||||
*/
|
||||
relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
|
||||
}
|
||||
|
||||
// connect to all specified relays
|
||||
const relays = await settleAllFullfilfedPromises(
|
||||
relayUrls,
|
||||
this.connectRelay
|
||||
)
|
||||
|
||||
// Check if any relays are connected
|
||||
if (relays.length === 0) {
|
||||
throw new Error('No relay is connected to publish event!')
|
||||
}
|
||||
|
||||
const publishedOnRelays: string[] = [] // List to track which relays successfully published the event
|
||||
|
||||
// Create a promise for publishing the event to each connected relay
|
||||
const publishPromises = relays.map(async (relay) => {
|
||||
try {
|
||||
await Promise.race([
|
||||
relay.publish(event), // Publish the event to the relay
|
||||
timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long
|
||||
])
|
||||
publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
|
||||
} catch (err) {
|
||||
console.error(`Failed to publish event on relay: ${relay.url}`, err)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for all publish operations to complete (either fulfilled or rejected)
|
||||
await Promise.allSettled(publishPromises)
|
||||
|
||||
// Return the list of relay URLs where the event was published
|
||||
return publishedOnRelays
|
||||
}
|
||||
}
|
||||
|
||||
export const relayController = RelayController.getInstance()
|
@ -1,4 +1 @@
|
||||
export * from './AuthController'
|
||||
export * from './MetadataController'
|
||||
export * from './NostrController'
|
||||
export * from './RelayController'
|
||||
|
@ -1,2 +1,7 @@
|
||||
export * from './store'
|
||||
export * from './useAuth'
|
||||
export * from './useDidMount'
|
||||
export * from './useDvm'
|
||||
export * from './useLogout'
|
||||
export * from './useNDK'
|
||||
export * from './useNDKContext'
|
||||
|
127
src/hooks/useAuth.ts
Normal file
127
src/hooks/useAuth.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { EventTemplate } from 'nostr-tools'
|
||||
import { useCallback } from 'react'
|
||||
import { NostrController } from '../controllers'
|
||||
import { appPrivateRoutes } from '../routes'
|
||||
import {
|
||||
setAuthState,
|
||||
setRelayMapAction,
|
||||
setUserProfile
|
||||
} from '../store/actions'
|
||||
import {
|
||||
base64DecodeAuthToken,
|
||||
compareObjects,
|
||||
createAndSaveAuthToken,
|
||||
getAuthToken,
|
||||
getRelayMapFromNDKRelayList,
|
||||
unixNow
|
||||
} from '../utils'
|
||||
import { useAppDispatch, useAppSelector } from './store'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
import { useDvm } from './useDvm'
|
||||
|
||||
export const useAuth = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { getRelayInfo } = useDvm()
|
||||
const { findMetadata, getNDKRelayList } = useNDKContext()
|
||||
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
const relaysState = useAppSelector((state) => state.relays)
|
||||
|
||||
const checkSession = useCallback(() => {
|
||||
const savedAuthToken = getAuthToken()
|
||||
|
||||
if (savedAuthToken) {
|
||||
const signedEvent = base64DecodeAuthToken(savedAuthToken)
|
||||
|
||||
dispatch(
|
||||
setAuthState({
|
||||
loggedIn: true,
|
||||
usersPubkey: signedEvent.pubkey
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(
|
||||
setAuthState({
|
||||
loggedIn: false,
|
||||
usersPubkey: undefined
|
||||
})
|
||||
)
|
||||
}, [dispatch])
|
||||
|
||||
/**
|
||||
* Function will authenticate user by signing an auth event
|
||||
* which is done by calling the sign() function, where appropriate
|
||||
* method will be chosen (extension or keys)
|
||||
*
|
||||
* @param pubkey of the user trying to login
|
||||
* @returns url to redirect if authentication successfull
|
||||
* or error if otherwise
|
||||
*/
|
||||
const authAndGetMetadataAndRelaysMap = useCallback(
|
||||
async (pubkey: string) => {
|
||||
try {
|
||||
const profile = await findMetadata(pubkey)
|
||||
dispatch(setUserProfile(profile))
|
||||
} catch (err) {
|
||||
console.warn('Error occurred while finding metadata', err)
|
||||
}
|
||||
|
||||
const timestamp = unixNow()
|
||||
const { href } = window.location
|
||||
|
||||
const authEvent: EventTemplate = {
|
||||
kind: 27235,
|
||||
tags: [
|
||||
['u', href],
|
||||
['method', 'GET']
|
||||
],
|
||||
content: '',
|
||||
created_at: timestamp
|
||||
}
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
const signedAuthEvent = await nostrController.signEvent(authEvent)
|
||||
createAndSaveAuthToken(signedAuthEvent)
|
||||
|
||||
dispatch(
|
||||
setAuthState({
|
||||
loggedIn: true,
|
||||
usersPubkey: pubkey
|
||||
})
|
||||
)
|
||||
|
||||
const ndkRelayList = await getNDKRelayList(pubkey)
|
||||
const relays = ndkRelayList.relays
|
||||
|
||||
if (relays.length < 1) {
|
||||
// Navigate user to relays page if relay map is empty
|
||||
return appPrivateRoutes.relays
|
||||
}
|
||||
|
||||
getRelayInfo(relays)
|
||||
|
||||
const relayMap = getRelayMapFromNDKRelayList(ndkRelayList)
|
||||
|
||||
if (authState.loggedIn && !compareObjects(relaysState?.map, relayMap)) {
|
||||
dispatch(setRelayMapAction(relayMap))
|
||||
}
|
||||
|
||||
return appPrivateRoutes.homePage
|
||||
},
|
||||
[
|
||||
dispatch,
|
||||
findMetadata,
|
||||
getNDKRelayList,
|
||||
getRelayInfo,
|
||||
authState,
|
||||
relaysState
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
authAndGetMetadataAndRelaysMap,
|
||||
checkSession
|
||||
}
|
||||
}
|
98
src/hooks/useDvm.ts
Normal file
98
src/hooks/useDvm.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
|
||||
import { EventTemplate } from 'nostr-tools'
|
||||
import { NostrController } from '../controllers'
|
||||
import { setRelayInfoAction } from '../store/actions'
|
||||
import { RelayInfoObject } from '../types'
|
||||
import { compareObjects, unixNow } from '../utils'
|
||||
import { useAppDispatch, useAppSelector } from './store'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
|
||||
export const useDvm = () => {
|
||||
const dvmRelays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relayable.org'
|
||||
]
|
||||
|
||||
const relayInfo = useAppSelector((state) => state.relays.info)
|
||||
|
||||
const { ndk, publish } = useNDKContext()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
/**
|
||||
* Sets information about relays into relays.info app state.
|
||||
* @param relayURIs - relay URIs to get information about
|
||||
*/
|
||||
const getRelayInfo = async (relayURIs: string[]) => {
|
||||
// initialize job request
|
||||
const jobEventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: unixNow(),
|
||||
kind: 68001,
|
||||
tags: [
|
||||
['i', `${JSON.stringify(relayURIs)}`],
|
||||
['j', 'relay-info']
|
||||
]
|
||||
}
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
// sign job request event
|
||||
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
|
||||
|
||||
// publish job request
|
||||
const ndkEvent = new NDKEvent(ndk, jobSignedEvent)
|
||||
await publish(ndkEvent, dvmRelays)
|
||||
|
||||
const subscribeWithTimeout = (
|
||||
subscription: NDKSubscription,
|
||||
timeoutMs: number
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const eventHandler = (event: NDKEvent) => {
|
||||
subscription.stop()
|
||||
resolve(event.content)
|
||||
}
|
||||
|
||||
subscription.on('event', eventHandler)
|
||||
|
||||
// Set up a timeout to stop the subscription after a specified time
|
||||
const timeout = setTimeout(() => {
|
||||
subscription.stop() // Stop the subscription
|
||||
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
|
||||
}, timeoutMs)
|
||||
|
||||
// Handle subscription close event
|
||||
subscription.on('close', () => clearTimeout(timeout))
|
||||
})
|
||||
}
|
||||
|
||||
// filter for getting DVM job's result
|
||||
const sub = ndk.subscribe({
|
||||
kinds: [68002 as number],
|
||||
'#e': [jobSignedEvent.id],
|
||||
'#p': [jobSignedEvent.pubkey]
|
||||
})
|
||||
|
||||
// asynchronously get relay info from dvm job with 20 seconds timeout
|
||||
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
|
||||
|
||||
if (!dvmJobResult) {
|
||||
return Promise.reject(`Relay(s) information wasn't received`)
|
||||
}
|
||||
|
||||
let newRelaysInfo: RelayInfoObject
|
||||
|
||||
try {
|
||||
newRelaysInfo = JSON.parse(dvmJobResult)
|
||||
} catch (error) {
|
||||
return Promise.reject(`Invalid relay(s) information.`)
|
||||
}
|
||||
|
||||
if (newRelaysInfo && !compareObjects(relayInfo, newRelaysInfo)) {
|
||||
dispatch(setRelayInfoAction(newRelaysInfo))
|
||||
}
|
||||
}
|
||||
|
||||
return { getRelayInfo }
|
||||
}
|
512
src/hooks/useNDK.ts
Normal file
512
src/hooks/useNDK.ts
Normal file
@ -0,0 +1,512 @@
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import {
|
||||
NDKEvent,
|
||||
NDKFilter,
|
||||
NDKKind,
|
||||
NDKRelaySet,
|
||||
NDKSubscriptionCacheUsage
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import _ from 'lodash'
|
||||
import {
|
||||
Event,
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
kinds,
|
||||
UnsignedEvent
|
||||
} from 'nostr-tools'
|
||||
|
||||
import { useAppDispatch, useAppSelector, useNDKContext } from '.'
|
||||
import { NostrController } from '../controllers'
|
||||
import {
|
||||
updateProcessedGiftWraps,
|
||||
updateUserAppData as updateUserAppDataAction
|
||||
} from '../store/actions'
|
||||
import { Keys } from '../store/auth/types'
|
||||
import {
|
||||
isSigitNotification,
|
||||
Meta,
|
||||
SigitNotification,
|
||||
UserAppData,
|
||||
UserRelaysType
|
||||
} from '../types'
|
||||
import {
|
||||
countLeadingZeroes,
|
||||
createWrap,
|
||||
deleteBlossomFile,
|
||||
fetchMetaFromFileStorage,
|
||||
getDTagForUserAppData,
|
||||
getUserAppDataFromBlossom,
|
||||
hexToNpub,
|
||||
parseJson,
|
||||
SIGIT_RELAY,
|
||||
unixNow,
|
||||
uploadUserAppDataToBlossom
|
||||
} from '../utils'
|
||||
|
||||
export const useNDK = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const {
|
||||
ndk,
|
||||
fetchEvent,
|
||||
fetchEventsFromUserRelays,
|
||||
publish,
|
||||
getNDKRelayList
|
||||
} = useNDKContext()
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
const appData = useAppSelector((state) => state.userAppData)
|
||||
const processedEvents = useAppSelector(
|
||||
(state) => state.userAppData?.processedGiftWraps
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches user application data based on user's public key.
|
||||
*
|
||||
* @returns The user application data or null if an error occurs or no data is found.
|
||||
*/
|
||||
const getUsersAppData = useCallback(async (): Promise<UserAppData | null> => {
|
||||
if (!usersPubkey) return null
|
||||
|
||||
// Get an instance of the NostrController
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
// Decryption can fail down in the code if extension options changed
|
||||
// Forcefully log out the user if we detect missmatch between pubkeys
|
||||
if (usersPubkey !== (await nostrController.capturePublicKey())) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Generate an identifier for the user's nip78
|
||||
const dTag = await getDTagForUserAppData()
|
||||
if (!dTag) return null
|
||||
|
||||
// Define a filter for fetching events
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.AppSpecificData],
|
||||
authors: [usersPubkey],
|
||||
'#d': [dTag]
|
||||
}
|
||||
|
||||
const encryptedContent = await fetchEvent(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY
|
||||
})
|
||||
.then((event) => {
|
||||
if (event) return event.content
|
||||
|
||||
// If no event is found, return an empty stringified object
|
||||
return '{}'
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log error and show a toast notification if fetching event fails
|
||||
console.log(`An error occurred in finding kind 30078 event`, err)
|
||||
toast.error(
|
||||
'An error occurred in finding kind 30078 event for data storage'
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
// Return null if encrypted content retrieval fails
|
||||
if (!encryptedContent) return null
|
||||
|
||||
// Handle case where the encrypted content is an empty object
|
||||
if (encryptedContent === '{}') {
|
||||
// Generate ephemeral key pair
|
||||
const secret = generateSecretKey()
|
||||
const pubKey = getPublicKey(secret)
|
||||
|
||||
return {
|
||||
sigits: {},
|
||||
processedGiftWraps: [],
|
||||
blossomUrls: [],
|
||||
keyPair: {
|
||||
private: bytesToHex(secret),
|
||||
public: pubKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt the encrypted content
|
||||
const decrypted = await nostrController
|
||||
.nip04Decrypt(usersPubkey, encryptedContent)
|
||||
.catch((err) => {
|
||||
// Log error and show a toast notification if decryption fails
|
||||
console.log('An error occurred while decrypting app data', err)
|
||||
toast.error('An error occurred while decrypting app data')
|
||||
return null
|
||||
})
|
||||
|
||||
// Return null if decryption fails
|
||||
if (!decrypted) return null
|
||||
|
||||
// Parse the decrypted content
|
||||
const parsedContent = await parseJson<{
|
||||
blossomUrls: string[]
|
||||
keyPair: Keys
|
||||
}>(decrypted).catch((err) => {
|
||||
// Log error and show a toast notification if parsing fails
|
||||
console.log(
|
||||
'An error occurred in parsing the content of kind 30078 event',
|
||||
err
|
||||
)
|
||||
toast.error(
|
||||
'An error occurred in parsing the content of kind 30078 event'
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
// Return null if parsing fails
|
||||
if (!parsedContent) return null
|
||||
|
||||
const { blossomUrls, keyPair } = parsedContent
|
||||
|
||||
// Return null if no blossom URLs are found
|
||||
if (blossomUrls.length === 0) return null
|
||||
|
||||
// Fetch additional user app data from the first blossom URL
|
||||
const dataFromBlossom = await getUserAppDataFromBlossom(
|
||||
blossomUrls[0],
|
||||
keyPair.private
|
||||
)
|
||||
|
||||
// Return null if fetching data from blossom fails
|
||||
if (!dataFromBlossom) return null
|
||||
|
||||
const { sigits, processedGiftWraps } = dataFromBlossom
|
||||
|
||||
// Return the final user application data
|
||||
return {
|
||||
blossomUrls,
|
||||
keyPair,
|
||||
sigits,
|
||||
processedGiftWraps
|
||||
}
|
||||
}, [usersPubkey, fetchEvent])
|
||||
|
||||
const updateUsersAppData = useCallback(
|
||||
async (metaArray: Meta[]) => {
|
||||
if (!appData || !appData.keyPair || !usersPubkey) return null
|
||||
|
||||
const sigits = _.cloneDeep(appData.sigits)
|
||||
let isUpdated = false
|
||||
|
||||
for (const meta of metaArray) {
|
||||
const createSignatureEvent = await parseJson<Event>(
|
||||
meta.createSignature
|
||||
).catch((err) => {
|
||||
console.log('Error in parsing the createSignature event:', err)
|
||||
toast.error(
|
||||
err.message ||
|
||||
'Error occurred in parsing the create signature event'
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!createSignatureEvent) continue
|
||||
|
||||
const id = createSignatureEvent.id
|
||||
|
||||
// Check if sigit already exists
|
||||
if (id in sigits) {
|
||||
// Update meta only if incoming meta is more recent
|
||||
const existingMeta = sigits[id]
|
||||
if (existingMeta.modifiedAt < meta.modifiedAt) {
|
||||
sigits[id] = meta
|
||||
isUpdated = true
|
||||
}
|
||||
} else {
|
||||
sigits[id] = meta
|
||||
isUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUpdated) return null
|
||||
|
||||
const blossomUrls = [...appData.blossomUrls]
|
||||
|
||||
const newBlossomUrl = await uploadUserAppDataToBlossom(
|
||||
sigits,
|
||||
appData.processedGiftWraps,
|
||||
appData.keyPair.private
|
||||
).catch((err) => {
|
||||
console.log(
|
||||
'Error uploading user app data file to Blossom server:',
|
||||
err
|
||||
)
|
||||
toast.error(
|
||||
'Error occurred in uploading user app data file to Blossom server'
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!newBlossomUrl) return null
|
||||
|
||||
// Insert new blossom URL at the start of the array
|
||||
blossomUrls.unshift(newBlossomUrl)
|
||||
|
||||
// Keep only the last 10 Blossom URLs, delete older ones
|
||||
if (blossomUrls.length > 10) {
|
||||
const filesToDelete = blossomUrls.splice(10)
|
||||
filesToDelete.forEach((url) => {
|
||||
deleteBlossomFile(url, appData.keyPair!.private).catch((err) => {
|
||||
console.log('Error removing old file from Blossom server:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Encrypt content for storing in kind 30078 event
|
||||
const nostrController = NostrController.getInstance()
|
||||
const encryptedContent = await nostrController
|
||||
.nip04Encrypt(
|
||||
usersPubkey,
|
||||
JSON.stringify({
|
||||
blossomUrls,
|
||||
keyPair: appData.keyPair
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
console.log('Error encrypting content for app data:', err)
|
||||
toast.error(err.message || 'Error encrypting content for app data')
|
||||
return null
|
||||
})
|
||||
|
||||
if (!encryptedContent) return null
|
||||
|
||||
// Generate the identifier for user's appData event
|
||||
const dTag = await getDTagForUserAppData()
|
||||
if (!dTag) return null
|
||||
|
||||
const updatedEvent: UnsignedEvent = {
|
||||
kind: kinds.Application,
|
||||
pubkey: usersPubkey,
|
||||
created_at: unixNow(),
|
||||
tags: [['d', dTag]],
|
||||
content: encryptedContent
|
||||
}
|
||||
|
||||
const signedEvent = await nostrController
|
||||
.signEvent(updatedEvent)
|
||||
.catch((err) => {
|
||||
console.log('Error signing event:', err)
|
||||
toast.error(err.message || 'Error signing event')
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) return null
|
||||
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishResult = await publish(ndkEvent)
|
||||
|
||||
if (publishResult.length === 0 || !publishResult) {
|
||||
toast.error('Unexpected error occurred in publishing updated app data')
|
||||
return null
|
||||
}
|
||||
|
||||
console.count('updateUserAppData useNDK')
|
||||
|
||||
// Update Redux store
|
||||
dispatch(
|
||||
updateUserAppDataAction({
|
||||
sigits,
|
||||
blossomUrls,
|
||||
processedGiftWraps: [...appData.processedGiftWraps],
|
||||
keyPair: {
|
||||
...appData.keyPair
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return signedEvent
|
||||
},
|
||||
[appData, dispatch, ndk, publish, usersPubkey]
|
||||
)
|
||||
|
||||
const processReceivedEvents = useCallback(
|
||||
async (events: NDKEvent[], difficulty: number = 5) => {
|
||||
if (!processedEvents) return
|
||||
|
||||
const validMetaArray: Meta[] = [] // Array to store valid Meta objects
|
||||
const updatedProcessedEvents = [...processedEvents] // Keep track of processed event IDs
|
||||
|
||||
for (const event of events) {
|
||||
// Skip already processed events
|
||||
if (processedEvents.includes(event.id)) continue
|
||||
|
||||
// Validate PoW
|
||||
const leadingZeroes = countLeadingZeroes(event.id)
|
||||
if (leadingZeroes < difficulty) continue
|
||||
|
||||
// Decrypt the content of the gift wrap event
|
||||
const nostrController = NostrController.getInstance()
|
||||
const decrypted = await nostrController
|
||||
.nip44Decrypt(event.pubkey, event.content)
|
||||
.catch((err) => {
|
||||
console.log('An error occurred in decrypting event content', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!decrypted) continue
|
||||
|
||||
const internalUnsignedEvent = await parseJson<UnsignedEvent>(
|
||||
decrypted
|
||||
).catch((err) => {
|
||||
console.log(
|
||||
'An error occurred in parsing the internal unsigned event',
|
||||
err
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938)
|
||||
continue
|
||||
|
||||
const parsedContent = await parseJson<Meta | SigitNotification>(
|
||||
internalUnsignedEvent.content
|
||||
).catch((err) => {
|
||||
console.log('An error occurred in parsing event content', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!parsedContent) continue
|
||||
|
||||
let meta: Meta
|
||||
|
||||
if (isSigitNotification(parsedContent)) {
|
||||
const notification = parsedContent
|
||||
if (!notification.keys || !usersPubkey) continue
|
||||
|
||||
let encryptionKey: string | undefined
|
||||
const { sender, keys } = notification.keys
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
|
||||
if (usersNpub in keys) {
|
||||
encryptionKey = await nostrController
|
||||
.nip04Decrypt(sender, keys[usersNpub])
|
||||
.catch((err) => {
|
||||
console.log(
|
||||
'An error occurred in decrypting encryption key',
|
||||
err
|
||||
)
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
meta = await fetchMetaFromFileStorage(
|
||||
notification.metaUrl,
|
||||
encryptionKey
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'An error occurred fetching meta file from storage',
|
||||
error
|
||||
)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
meta = parsedContent
|
||||
}
|
||||
|
||||
validMetaArray.push(meta) // Add valid Meta to the array
|
||||
updatedProcessedEvents.push(event.id) // Mark event as processed
|
||||
}
|
||||
|
||||
// Update processed events in the Redux store
|
||||
dispatch(updateProcessedGiftWraps(updatedProcessedEvents))
|
||||
|
||||
// Pass the array of Meta objects to updateUsersAppData
|
||||
if (validMetaArray.length > 0) {
|
||||
await updateUsersAppData(validMetaArray)
|
||||
}
|
||||
},
|
||||
[dispatch, processedEvents, updateUsersAppData, usersPubkey]
|
||||
)
|
||||
|
||||
const subscribeForSigits = useCallback(
|
||||
async (pubkey: string) => {
|
||||
// Define the filter for the subscription
|
||||
const filter: NDKFilter = {
|
||||
kinds: [1059 as NDKKind],
|
||||
'#p': [pubkey]
|
||||
}
|
||||
|
||||
// Process the received event synchronously
|
||||
const events = await fetchEventsFromUserRelays(
|
||||
filter,
|
||||
pubkey,
|
||||
UserRelaysType.Read,
|
||||
{
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY
|
||||
}
|
||||
)
|
||||
|
||||
await processReceivedEvents(events)
|
||||
},
|
||||
[fetchEventsFromUserRelays, processReceivedEvents]
|
||||
)
|
||||
|
||||
/**
|
||||
* Function to send a notification to a specified receiver.
|
||||
* @param receiver - The recipient's public key.
|
||||
* @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt.
|
||||
*/
|
||||
const sendNotification = useCallback(
|
||||
async (receiver: string, notification: SigitNotification) => {
|
||||
if (!usersPubkey) return
|
||||
|
||||
// Create an unsigned event object with the provided metadata
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: 938,
|
||||
pubkey: usersPubkey,
|
||||
content: JSON.stringify(notification),
|
||||
tags: [],
|
||||
created_at: unixNow()
|
||||
}
|
||||
|
||||
// Wrap the unsigned event with the receiver's information
|
||||
const wrappedEvent = createWrap(unsignedEvent, receiver)
|
||||
|
||||
// Publish the notification event to the recipient's read relays
|
||||
const ndkEvent = new NDKEvent(ndk, wrappedEvent)
|
||||
|
||||
const ndkRelayList = await getNDKRelayList(receiver)
|
||||
|
||||
const readRelayUrls: string[] = []
|
||||
|
||||
if (ndkRelayList?.readRelayUrls) {
|
||||
readRelayUrls.push(...ndkRelayList.readRelayUrls)
|
||||
}
|
||||
|
||||
if (!readRelayUrls.includes(SIGIT_RELAY)) {
|
||||
readRelayUrls.push(SIGIT_RELAY)
|
||||
}
|
||||
|
||||
await ndkEvent
|
||||
.publish(NDKRelaySet.fromRelayUrls(readRelayUrls, ndk, true))
|
||||
.then((publishedOnRelays) => {
|
||||
if (publishedOnRelays.size === 0) {
|
||||
throw new Error('Could not publish to any relay')
|
||||
}
|
||||
|
||||
return publishedOnRelays
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log an error if publishing the notification event fails
|
||||
console.log(
|
||||
`An error occurred while publishing notification event for ${hexToNpub(receiver)}`,
|
||||
err
|
||||
)
|
||||
throw err
|
||||
})
|
||||
},
|
||||
[ndk, usersPubkey, getNDKRelayList]
|
||||
)
|
||||
|
||||
return {
|
||||
getUsersAppData,
|
||||
subscribeForSigits,
|
||||
updateUsersAppData,
|
||||
sendNotification
|
||||
}
|
||||
}
|
13
src/hooks/useNDKContext.ts
Normal file
13
src/hooks/useNDKContext.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { NDKContext, NDKContextType } from '../contexts/NDKContext'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useNDKContext = () => {
|
||||
const ndkContext = useContext(NDKContext)
|
||||
|
||||
if (!ndkContext)
|
||||
throw new Error(
|
||||
'NDKContext should not be used in out component tree hierarchy'
|
||||
)
|
||||
|
||||
return { ...ndkContext } as NDKContextType
|
||||
}
|
@ -1,33 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ProfileMetadata } from '../types/profile'
|
||||
import { MetadataController } from '../controllers/MetadataController'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
|
||||
export const useProfileMetadata = (pubkey: string) => {
|
||||
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
|
||||
const { findMetadata } = useNDKContext()
|
||||
|
||||
const [userProfile, setUserProfile] = useState<NDKUserProfile>()
|
||||
|
||||
useEffect(() => {
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
}
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
metadataController.on(pubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
metadataController
|
||||
.findMetadata(pubkey)
|
||||
.then((metadataEvent) => {
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
findMetadata(pubkey)
|
||||
.then((profile) => {
|
||||
if (profile) setUserProfile(profile)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
@ -36,11 +21,7 @@ export const useProfileMetadata = (pubkey: string) => {
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [pubkey, findMetadata])
|
||||
|
||||
return () => {
|
||||
metadataController.off(pubkey, handleMetadataEvent)
|
||||
}
|
||||
}, [pubkey])
|
||||
|
||||
return profileMetadata
|
||||
return userProfile
|
||||
}
|
||||
|
@ -86,7 +86,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
}>({})
|
||||
const [markConfig, setMarkConfig] = useState<Mark[]>([])
|
||||
const [title, setTitle] = useState<string>('')
|
||||
const [zipUrls, setZipUrls] = useState<string[]>([])
|
||||
const [zipUrl, setZipUrl] = useState<string>('')
|
||||
|
||||
const [parsedSignatureEvents, setParsedSignatureEvents] = useState<{
|
||||
[signer: `npub1${string}`]: DocSignatureEvent
|
||||
@ -133,7 +133,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
setId(id)
|
||||
setSig(sig)
|
||||
|
||||
const { title, signers, viewers, fileHashes, markConfig, zipUrls } =
|
||||
const { title, signers, viewers, fileHashes, markConfig, zipUrl } =
|
||||
await parseCreateSignatureEventContent(content)
|
||||
|
||||
setTitle(title)
|
||||
@ -141,7 +141,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
setViewers(viewers)
|
||||
setFileHashes(fileHashes)
|
||||
setMarkConfig(markConfig)
|
||||
setZipUrls(zipUrls)
|
||||
setZipUrl(zipUrl)
|
||||
|
||||
let encryptionKey: string | undefined
|
||||
if (meta.keys) {
|
||||
@ -322,7 +322,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
fileHashes,
|
||||
markConfig,
|
||||
title,
|
||||
zipUrls,
|
||||
zipUrl,
|
||||
parsedSignatureEvents,
|
||||
completedAt,
|
||||
signedStatus,
|
||||
|
@ -1,40 +1,49 @@
|
||||
import { Event, getPublicKey, kinds, nip19 } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||
|
||||
import { init as initNostrLogin } from 'nostr-login'
|
||||
import { NostrLoginAuthOptions } from 'nostr-login/dist/types'
|
||||
|
||||
import { AppBar } from '../components/AppBar/AppBar'
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner'
|
||||
|
||||
import { NostrController } from '../controllers'
|
||||
|
||||
import {
|
||||
AuthController,
|
||||
MetadataController,
|
||||
NostrController
|
||||
} from '../controllers'
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useAuth,
|
||||
useLogout,
|
||||
useNDK,
|
||||
useNDKContext
|
||||
} from '../hooks'
|
||||
|
||||
import {
|
||||
restoreState,
|
||||
setMetadataEvent,
|
||||
setUserProfile,
|
||||
updateKeyPair,
|
||||
updateLoginMethod,
|
||||
updateNostrLoginAuthMethod,
|
||||
updateUserAppData
|
||||
updateUserAppData,
|
||||
setUserRobotImage
|
||||
} from '../store/actions'
|
||||
import { setUserRobotImage } from '../store/userRobotImage/action'
|
||||
import {
|
||||
getRoboHashPicture,
|
||||
getUsersAppData,
|
||||
loadState,
|
||||
subscribeForSigits
|
||||
} from '../utils'
|
||||
import { useAppDispatch, useAppSelector } from '../hooks'
|
||||
import styles from './style.module.scss'
|
||||
import { useLogout } from '../hooks/useLogout'
|
||||
import { LoginMethod } from '../store/auth/types'
|
||||
import { NostrLoginAuthOptions } from 'nostr-login/dist/types'
|
||||
import { init as initNostrLogin } from 'nostr-login'
|
||||
|
||||
import { getRoboHashPicture, loadState } from '../utils'
|
||||
|
||||
import styles from './style.module.scss'
|
||||
|
||||
export const MainLayout = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const logout = useLogout()
|
||||
const { findMetadata } = useNDKContext()
|
||||
const { authAndGetMetadataAndRelaysMap } = useAuth()
|
||||
const { getUsersAppData, subscribeForSigits } = useNDK()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`)
|
||||
const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn)
|
||||
@ -61,11 +70,9 @@ export const MainLayout = () => {
|
||||
dispatch(updateLoginMethod(LoginMethod.nostrLogin))
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
const authController = new AuthController()
|
||||
const pubkey = await nostrController.capturePublicKey()
|
||||
|
||||
const redirectPath =
|
||||
await authController.authAndGetMetadataAndRelaysMap(pubkey)
|
||||
const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey)
|
||||
|
||||
if (redirectPath) {
|
||||
navigateAfterLogin(redirectPath)
|
||||
@ -105,13 +112,10 @@ export const MainLayout = () => {
|
||||
)
|
||||
dispatch(updateLoginMethod(LoginMethod.privateKey))
|
||||
|
||||
const authController = new AuthController()
|
||||
authController
|
||||
.authAndGetMetadataAndRelaysMap(publickey)
|
||||
.catch((err) => {
|
||||
console.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
authAndGetMetadataAndRelaysMap(publickey).catch((err) => {
|
||||
console.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Error decoding the nsec. ${err}`)
|
||||
}
|
||||
@ -151,8 +155,6 @@ export const MainLayout = () => {
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const restoredState = loadState()
|
||||
if (restoredState) {
|
||||
dispatch(restoreState(restoredState))
|
||||
@ -162,19 +164,8 @@ export const MainLayout = () => {
|
||||
if (loggedIn) {
|
||||
if (!loginMethod || !usersPubkey) return logout()
|
||||
|
||||
// Update user profile metadata, old state might be outdated
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
dispatch(setMetadataEvent(event))
|
||||
}
|
||||
|
||||
metadataController.on(usersPubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
metadataController.findMetadata(usersPubkey).then((metadataEvent) => {
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
findMetadata(usersPubkey).then((profile) => {
|
||||
dispatch(setUserProfile(profile))
|
||||
})
|
||||
} else {
|
||||
setIsLoading(false)
|
||||
@ -201,7 +192,7 @@ export const MainLayout = () => {
|
||||
hasSubscribed.current = true
|
||||
}
|
||||
}
|
||||
}, [authState, isLoggedIn, usersAppData])
|
||||
}, [authState, isLoggedIn, usersAppData, subscribeForSigits])
|
||||
|
||||
/**
|
||||
* When authState change user logged in / or app reloaded
|
||||
|
11
src/main.tsx
11
src/main.tsx
@ -11,15 +11,14 @@ import './index.css'
|
||||
import store from './store/store.ts'
|
||||
import { theme } from './theme'
|
||||
import { saveState } from './utils'
|
||||
import { NDKContextProvider } from './contexts/NDKContext'
|
||||
|
||||
store.subscribe(
|
||||
_.throttle(() => {
|
||||
saveState({
|
||||
auth: store.getState().auth,
|
||||
metadata: store.getState().metadata,
|
||||
userRobotImage: store.getState().userRobotImage,
|
||||
relays: store.getState().relays,
|
||||
servers: store.getState().servers
|
||||
user: store.getState().user,
|
||||
relays: store.getState().relays
|
||||
})
|
||||
}, 1000)
|
||||
)
|
||||
@ -29,7 +28,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<CssVarsProvider theme={theme}>
|
||||
<HashRouter>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
<NDKContextProvider>
|
||||
<App />
|
||||
</NDKContextProvider>
|
||||
<ToastContainer />
|
||||
</Provider>
|
||||
</HashRouter>
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
import type { Identifier, XYCoord } from 'dnd-core'
|
||||
import saveAs from 'file-saver'
|
||||
import JSZip from 'jszip'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd'
|
||||
import { MultiBackend } from 'react-dnd-multi-backend'
|
||||
@ -20,20 +19,16 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { UserAvatar } from '../../components/UserAvatar'
|
||||
import {
|
||||
MetadataController,
|
||||
NostrController,
|
||||
RelayController
|
||||
} from '../../controllers'
|
||||
import { NostrController } from '../../controllers'
|
||||
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
|
||||
import {
|
||||
CreateSignatureEventContent,
|
||||
KeyboardCode,
|
||||
Meta,
|
||||
ProfileMetadata,
|
||||
SigitNotification,
|
||||
SignedEvent,
|
||||
User,
|
||||
UserRelaysType,
|
||||
UserRole
|
||||
} from '../../types'
|
||||
import {
|
||||
@ -48,15 +43,11 @@ import {
|
||||
unixNow,
|
||||
npubToHex,
|
||||
queryNip05,
|
||||
sendNotification,
|
||||
signEventForMetaFile,
|
||||
updateUsersAppData,
|
||||
uploadToFileStorage,
|
||||
DEFAULT_TOOLBOX,
|
||||
settleAllFullfilfedPromises,
|
||||
DEFAULT_LOOK_UP_RELAY_LIST,
|
||||
uploadMetaToFileStorage,
|
||||
isValidNip05
|
||||
uploadMetaToFileStorage
|
||||
} from '../../utils'
|
||||
import { Container } from '../../components/Container'
|
||||
import fileListStyles from '../../components/FileList/style.module.scss'
|
||||
@ -84,13 +75,19 @@ import { Autocomplete } from '@mui/material'
|
||||
import _, { truncate } from 'lodash'
|
||||
import * as React from 'react'
|
||||
import { AvatarIconButton } from '../../components/UserAvatarIconButton'
|
||||
import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk'
|
||||
import { useNDKContext } from '../../hooks/useNDKContext.ts'
|
||||
import { useNDK } from '../../hooks/useNDK.ts'
|
||||
import { useImmer } from 'use-immer'
|
||||
|
||||
type FoundUser = Event & { npub: string }
|
||||
type FoundUser = NostrEvent & { npub: string }
|
||||
|
||||
export const CreatePage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { findMetadata, fetchEventsFromUserRelays } = useNDKContext()
|
||||
const { updateUsersAppData, sendNotification } = useNDK()
|
||||
|
||||
const { uploadedFiles } = location.state || {}
|
||||
const [currentFile, setCurrentFile] = useState<File>()
|
||||
const isActive = (file: File) => file.name === currentFile?.name
|
||||
@ -122,9 +119,10 @@ export const CreatePage = () => {
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
|
||||
{}
|
||||
)
|
||||
const [userProfiles, setUserProfiles] = useState<{
|
||||
[key: string]: NDKUserProfile
|
||||
}>({})
|
||||
|
||||
const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([])
|
||||
const [parsingPdf, setIsParsing] = useState<boolean>(false)
|
||||
|
||||
@ -171,32 +169,20 @@ export const CreatePage = () => {
|
||||
|
||||
setSearchUsersLoading(true)
|
||||
|
||||
const relayController = RelayController.getInstance()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const relaySet = await metadataController.findRelayListMetadata(usersPubkey)
|
||||
|
||||
DEFAULT_LOOK_UP_RELAY_LIST.forEach((relay) => {
|
||||
if (!relaySet.write.includes(relay)) relaySet.write.push(relay)
|
||||
if (!relaySet.read.includes(relay)) relaySet.read.push(relay)
|
||||
})
|
||||
|
||||
const uniqueReadRelaySet = [...new Set(relaySet.read)]
|
||||
|
||||
const searchTerm = searchString.trim()
|
||||
|
||||
relayController
|
||||
.fetchEvents(
|
||||
{
|
||||
kinds: [0],
|
||||
search: searchTerm
|
||||
},
|
||||
uniqueReadRelaySet
|
||||
)
|
||||
fetchEventsFromUserRelays(
|
||||
{
|
||||
kinds: [0],
|
||||
search: searchTerm
|
||||
},
|
||||
usersPubkey,
|
||||
UserRelaysType.Write
|
||||
)
|
||||
.then((events) => {
|
||||
console.log('events', events)
|
||||
const nostrEvents = events.map((event) => event.rawEvent())
|
||||
|
||||
const fineFilteredEvents: FoundUser[] = events
|
||||
const fineFilteredEvents = nostrEvents
|
||||
.filter((event) => {
|
||||
const lowercaseContent = event.content.toLowerCase()
|
||||
|
||||
@ -213,15 +199,15 @@ export const CreatePage = () => {
|
||||
lowercaseContent.includes(`"nip05":"${searchTerm.toLowerCase()}"`)
|
||||
)
|
||||
})
|
||||
.reduce((uniqueEvents: FoundUser[], event: Event) => {
|
||||
if (!uniqueEvents.some((e: Event) => e.pubkey === event.pubkey)) {
|
||||
.reduce((uniqueEvents, event) => {
|
||||
if (!uniqueEvents.some((e) => e.pubkey === event.pubkey)) {
|
||||
uniqueEvents.push({
|
||||
...event,
|
||||
npub: hexToNpub(event.pubkey)
|
||||
})
|
||||
}
|
||||
return uniqueEvents
|
||||
}, [])
|
||||
}, [] as FoundUser[])
|
||||
|
||||
console.info('fineFilteredEvents', fineFilteredEvents)
|
||||
setFoundUsers(fineFilteredEvents)
|
||||
@ -265,7 +251,8 @@ export const CreatePage = () => {
|
||||
// Otherwize if search already provided some results, user must manually click the search button
|
||||
if (!foundUsers.length) {
|
||||
// If it's NIP05 (includes @ or is a valid domain) send request to .well-known
|
||||
if (isValidNip05(userSearchInput)) {
|
||||
const domainRegex = /^[a-zA-Z0-9@.-]+\.[a-zA-Z]{2,}$/
|
||||
if (domainRegex.test(userSearchInput)) {
|
||||
setSearchUsersLoading(true)
|
||||
|
||||
const pubkey = await handleSearchUserNip05(userSearchInput)
|
||||
@ -344,29 +331,15 @@ export const CreatePage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
users.forEach((user) => {
|
||||
if (!(user.pubkey in metadata)) {
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
if (metadataContent)
|
||||
setMetadata((prev) => ({
|
||||
...prev,
|
||||
[user.pubkey]: metadataContent
|
||||
}))
|
||||
}
|
||||
|
||||
metadataController.on(user.pubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
metadataController
|
||||
.findMetadata(user.pubkey)
|
||||
.then((metadataEvent) => {
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
if (!(user.pubkey in userProfiles)) {
|
||||
findMetadata(user.pubkey)
|
||||
.then((profile) => {
|
||||
if (profile) {
|
||||
setUserProfiles((prev) => ({
|
||||
...prev,
|
||||
[user.pubkey]: profile
|
||||
}))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
@ -376,7 +349,7 @@ export const CreatePage = () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [metadata, users])
|
||||
}, [userProfiles, users, findMetadata])
|
||||
|
||||
useEffect(() => {
|
||||
if (usersPubkey) {
|
||||
@ -756,10 +729,10 @@ export const CreatePage = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
// Upload the file to the storage/s
|
||||
const uploadFiles = async (
|
||||
// Upload the file to the storage
|
||||
const uploadFile = async (
|
||||
arrayBuffer: ArrayBuffer
|
||||
): Promise<string[] | null> => {
|
||||
): Promise<string | null> => {
|
||||
const blob = new Blob([arrayBuffer])
|
||||
// Create a File object with the Blob data
|
||||
const file = new File([blob], `compressed-${unixNow()}.sigit`, {
|
||||
@ -818,14 +791,14 @@ export const CreatePage = () => {
|
||||
fileHashes: {
|
||||
[key: string]: string
|
||||
},
|
||||
zipUrls: string[]
|
||||
zipUrl: string
|
||||
) => {
|
||||
const content: CreateSignatureEventContent = {
|
||||
signers: signers.map((signer) => hexToNpub(signer.pubkey)),
|
||||
viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)),
|
||||
fileHashes,
|
||||
markConfig,
|
||||
zipUrls,
|
||||
zipUrl,
|
||||
title
|
||||
}
|
||||
|
||||
@ -888,15 +861,15 @@ export const CreatePage = () => {
|
||||
|
||||
const markConfig = createMarks(fileHashes)
|
||||
|
||||
setLoadingSpinnerDesc('Uploading files.zip to file storages')
|
||||
const fileUrls = await uploadFiles(encryptedArrayBuffer)
|
||||
if (!fileUrls) return
|
||||
setLoadingSpinnerDesc('Uploading files.zip to file storage')
|
||||
const fileUrl = await uploadFile(encryptedArrayBuffer)
|
||||
if (!fileUrl) return
|
||||
|
||||
setLoadingSpinnerDesc('Generating create signature')
|
||||
const createSignature = await generateCreateSignature(
|
||||
markConfig,
|
||||
fileHashes,
|
||||
fileUrls
|
||||
fileUrl
|
||||
)
|
||||
if (!createSignature) return
|
||||
|
||||
@ -931,14 +904,14 @@ export const CreatePage = () => {
|
||||
|
||||
setLoadingSpinnerDesc('Updating user app data')
|
||||
|
||||
const event = await updateUsersAppData(meta)
|
||||
const event = await updateUsersAppData([meta])
|
||||
if (!event) return
|
||||
|
||||
const metaUrls = await uploadMetaToFileStorage(meta, encryptionKey)
|
||||
const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
|
||||
|
||||
setLoadingSpinnerDesc('Sending notifications to counterparties')
|
||||
const promises = sendNotifications({
|
||||
metaUrls,
|
||||
metaUrl,
|
||||
keys: meta.keys
|
||||
})
|
||||
|
||||
@ -971,7 +944,7 @@ export const CreatePage = () => {
|
||||
const createSignature = await generateCreateSignature(
|
||||
markConfig,
|
||||
fileHashes,
|
||||
[]
|
||||
''
|
||||
)
|
||||
if (!createSignature) return
|
||||
|
||||
@ -1045,7 +1018,7 @@ export const CreatePage = () => {
|
||||
setUserSearchInput(value)
|
||||
}
|
||||
|
||||
const parseContent = (event: Event) => {
|
||||
const parseContent = (event: NostrEvent) => {
|
||||
try {
|
||||
return JSON.parse(event.content)
|
||||
} catch (e) {
|
||||
@ -1154,7 +1127,7 @@ export const CreatePage = () => {
|
||||
key={option.pubkey}
|
||||
>
|
||||
<AvatarIconButton
|
||||
src={contentJson.picture}
|
||||
src={contentJson.picture || contentJson.image}
|
||||
hexKey={option.pubkey}
|
||||
color="inherit"
|
||||
sx={{
|
||||
@ -1269,7 +1242,7 @@ export const CreatePage = () => {
|
||||
>
|
||||
<DrawPDFFields
|
||||
users={users}
|
||||
metadata={metadata}
|
||||
userProfiles={userProfiles}
|
||||
selectedTool={selectedTool}
|
||||
sigitFiles={drawnFiles}
|
||||
updateSigitFiles={updateDrawnFiles}
|
||||
|
@ -58,7 +58,7 @@ export const HomePage = () => {
|
||||
const usersAppData = useAppSelector((state) => state.userAppData)
|
||||
|
||||
useEffect(() => {
|
||||
if (usersAppData) {
|
||||
if (usersAppData?.sigits) {
|
||||
const getSigitInfo = async () => {
|
||||
const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {}
|
||||
for (const key in usersAppData.sigits) {
|
||||
@ -80,7 +80,7 @@ export const HomePage = () => {
|
||||
setSigits(usersAppData.sigits)
|
||||
getSigitInfo()
|
||||
}
|
||||
}, [usersAppData])
|
||||
}, [usersAppData?.sigits])
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
|
@ -1,28 +1,28 @@
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { Button, Divider, TextField } from '@mui/material'
|
||||
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAppDispatch } from '../../hooks/store'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { AuthController } from '../../controllers'
|
||||
import { updateKeyPair, updateLoginMethod } from '../../store/actions'
|
||||
import { KeyboardCode } from '../../types'
|
||||
import { LoginMethod } from '../../store/auth/types'
|
||||
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { useAppDispatch, useAuth } from '../../hooks'
|
||||
import { updateKeyPair, updateLoginMethod } from '../../store/actions'
|
||||
import { LoginMethod } from '../../store/auth/types'
|
||||
import { KeyboardCode } from '../../types'
|
||||
|
||||
import styles from './styles.module.scss'
|
||||
|
||||
export const Nostr = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { authAndGetMetadataAndRelaysMap } = useAuth()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const authController = new AuthController()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
@ -102,12 +102,12 @@ export const Nostr = () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authAndGetMetadataAndRelaysMap(publickey)
|
||||
.catch((err) => {
|
||||
const redirectPath = await authAndGetMetadataAndRelaysMap(publickey).catch(
|
||||
(err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (redirectPath) navigateAfterLogin(redirectPath)
|
||||
|
||||
|
@ -1,48 +1,49 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material'
|
||||
import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
import { Container } from '../../components/Container'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { MetadataController } from '../../controllers'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
|
||||
import { getProfileSettingsRoute } from '../../routes'
|
||||
import { NostrJoiningBlock, ProfileMetadata } from '../../types'
|
||||
|
||||
import {
|
||||
getNostrJoiningBlockNumber,
|
||||
getProfileUsername,
|
||||
getRoboHashPicture,
|
||||
hexToNpub,
|
||||
shorten
|
||||
} from '../../utils'
|
||||
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
import { useNDKContext } from '../../hooks'
|
||||
import styles from './style.module.scss'
|
||||
import { Container } from '../../components/Container'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
|
||||
export const ProfilePage = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { npub } = useParams()
|
||||
|
||||
const metadataController = useMemo(() => MetadataController.getInstance(), [])
|
||||
const { ndk, findMetadata } = useNDKContext()
|
||||
|
||||
const [pubkey, setPubkey] = useState<string>()
|
||||
const [nostrJoiningBlock, setNostrJoiningBlock] =
|
||||
useState<NostrJoiningBlock | null>(null)
|
||||
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
|
||||
const metadataState = useAppSelector((state) => state.metadata)
|
||||
const [userProfile, setUserProfile] = useState<NDKUserProfile | null>(null)
|
||||
|
||||
const userRobotImage = useAppSelector((state) => state.user.robotImage)
|
||||
const currentUserProfile = useAppSelector((state) => state.user.profile)
|
||||
const { usersPubkey } = useAppSelector((state) => state.auth)
|
||||
const userRobotImage = useAppSelector((state) => state.userRobotImage)
|
||||
|
||||
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc] = useState('Fetching metadata')
|
||||
|
||||
const profileName = pubkey && getProfileUsername(pubkey, profileMetadata)
|
||||
|
||||
useEffect(() => {
|
||||
if (npub) {
|
||||
try {
|
||||
@ -57,60 +58,26 @@ export const ProfilePage = () => {
|
||||
}, [npub, usersPubkey])
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) {
|
||||
getNostrJoiningBlockNumber(pubkey)
|
||||
.then((res) => {
|
||||
setNostrJoiningBlock(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
// todo: handle error
|
||||
console.log('err :>> ', err)
|
||||
})
|
||||
}
|
||||
|
||||
if (isUsersOwnProfile && metadataState) {
|
||||
const metadataContent = metadataController.extractProfileMetadataContent(
|
||||
metadataState as VerifiedEvent
|
||||
)
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
setIsLoading(false)
|
||||
}
|
||||
if (isUsersOwnProfile && currentUserProfile) {
|
||||
setUserProfile(currentUserProfile)
|
||||
setIsLoading(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
const getMetadata = async (pubkey: string) => {
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
}
|
||||
}
|
||||
|
||||
metadataController.on(pubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
findMetadata(pubkey)
|
||||
.then((profile) => {
|
||||
setUserProfile(profile)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
const metadataEvent = await metadataController
|
||||
.findMetadata(pubkey)
|
||||
.catch((err) => {
|
||||
toast.error(err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
getMetadata(pubkey)
|
||||
}
|
||||
}, [isUsersOwnProfile, metadataState, pubkey, metadataController])
|
||||
}, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata])
|
||||
|
||||
/**
|
||||
* Rendering text with button which copies the provided text
|
||||
@ -146,29 +113,32 @@ export const ProfilePage = () => {
|
||||
*
|
||||
* @returns robohash image url
|
||||
*/
|
||||
const getProfileImage = (metadata: ProfileMetadata) => {
|
||||
if (!metadata) return ''
|
||||
const getProfileImage = (profile: NDKUserProfile | null) => {
|
||||
if (!profile) return getRoboHashPicture(npub)
|
||||
|
||||
if (!isUsersOwnProfile) {
|
||||
return metadata.picture || getRoboHashPicture(npub!)
|
||||
return profile.image || getRoboHashPicture(npub!)
|
||||
}
|
||||
|
||||
// userRobotImage is used only when visiting own profile
|
||||
// while kind 0 picture is not set
|
||||
return metadata.picture || userRobotImage || getRoboHashPicture(npub!)
|
||||
return profile.image || userRobotImage || getRoboHashPicture(npub!)
|
||||
}
|
||||
|
||||
const profileName =
|
||||
pubkey && getProfileUsername(pubkey, userProfile || undefined)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
{pubkey && (
|
||||
<Container className={styles.container}>
|
||||
<Box
|
||||
className={`${styles.banner} ${!profileMetadata || !profileMetadata.banner ? styles.noImage : ''}`}
|
||||
className={`${styles.banner} ${!userProfile || !userProfile.banner ? styles.noImage : ''}`}
|
||||
>
|
||||
{profileMetadata && profileMetadata.banner ? (
|
||||
{userProfile && userProfile.banner ? (
|
||||
<img
|
||||
src={profileMetadata.banner}
|
||||
src={userProfile.banner}
|
||||
alt={`banner image for ${profileName}`}
|
||||
/>
|
||||
) : (
|
||||
@ -189,24 +159,12 @@ export const ProfilePage = () => {
|
||||
>
|
||||
<img
|
||||
className={styles['image-placeholder']}
|
||||
src={getProfileImage(profileMetadata!)}
|
||||
src={getProfileImage(userProfile)}
|
||||
alt={profileName}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
<Box className={styles.middle}>
|
||||
<Typography
|
||||
component={Link}
|
||||
to={`https://njump.me/${nostrJoiningBlock?.encodedEventPointer || ''}`}
|
||||
target="_blank"
|
||||
className={`${styles.nostrSince} ${styles.link}`}
|
||||
variant="caption"
|
||||
>
|
||||
{nostrJoiningBlock
|
||||
? `On nostr since ${nostrJoiningBlock.block.toLocaleString()}`
|
||||
: 'On nostr since: unknown'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.right}>
|
||||
{isUsersOwnProfile && (
|
||||
<IconButton
|
||||
@ -224,15 +182,13 @@ export const ProfilePage = () => {
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
{profileMetadata && (
|
||||
<Typography
|
||||
sx={{ margin: '5px 0 5px 0' }}
|
||||
variant="h6"
|
||||
className={styles.bold}
|
||||
>
|
||||
{profileName}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
sx={{ margin: '5px 0 5px 0' }}
|
||||
variant="h6"
|
||||
className={styles.bold}
|
||||
>
|
||||
{profileName}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
{textElementWithCopyIcon(
|
||||
@ -242,42 +198,34 @@ export const ProfilePage = () => {
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{profileMetadata?.nip05 &&
|
||||
textElementWithCopyIcon(
|
||||
profileMetadata.nip05,
|
||||
undefined,
|
||||
15
|
||||
)}
|
||||
{userProfile?.nip05 &&
|
||||
textElementWithCopyIcon(userProfile.nip05, undefined, 15)}
|
||||
</Box>
|
||||
<Box>
|
||||
{profileMetadata?.lud16 &&
|
||||
textElementWithCopyIcon(
|
||||
profileMetadata.lud16,
|
||||
undefined,
|
||||
15
|
||||
)}
|
||||
{userProfile?.lud16 &&
|
||||
textElementWithCopyIcon(userProfile.lud16, undefined, 15)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{profileMetadata?.website && (
|
||||
{userProfile?.website && (
|
||||
<Typography
|
||||
sx={{ marginTop: '10px' }}
|
||||
variant="caption"
|
||||
component={Link}
|
||||
to={profileMetadata.website}
|
||||
to={userProfile.website}
|
||||
target="_blank"
|
||||
className={`${styles.website} ${styles.link} ${styles.captionWrapper}`}
|
||||
>
|
||||
{profileMetadata.website}
|
||||
{userProfile.website}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
{profileMetadata?.about && (
|
||||
{userProfile?.about && (
|
||||
<Typography mt={1} className={styles.about}>
|
||||
{profileMetadata.about}
|
||||
{userProfile.about}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
@ -2,13 +2,12 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle'
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'
|
||||
import CachedIcon from '@mui/icons-material/Cached'
|
||||
import RouterIcon from '@mui/icons-material/Router'
|
||||
import StorageIcon from '@mui/icons-material/Storage'
|
||||
import { ListItem, useTheme } from '@mui/material'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import ListSubheader from '@mui/material/ListSubheader'
|
||||
import { useAppSelector } from '../../hooks'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes'
|
||||
import { Container } from '../../components/Container'
|
||||
@ -75,12 +74,6 @@ export const SettingsPage = () => {
|
||||
</ListItemIcon>
|
||||
{listItem('Relays')}
|
||||
</ListItem>
|
||||
<ListItem component={Link} to={appPrivateRoutes.servers}>
|
||||
<ListItemIcon>
|
||||
<StorageIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Servers')}
|
||||
</ListItem>
|
||||
<ListItem component={Link} to={appPrivateRoutes.cacheSettings}>
|
||||
<ListItemIcon>
|
||||
<CachedIcon />
|
||||
|
@ -1,4 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { SmartToy } from '@mui/icons-material'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import LaunchIcon from '@mui/icons-material/Launch'
|
||||
import { LoadingButton } from '@mui/lab'
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
@ -7,59 +14,48 @@ import {
|
||||
ListItem,
|
||||
ListSubheader,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useTheme
|
||||
Tooltip
|
||||
} from '@mui/material'
|
||||
import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { MetadataController, NostrController } from '../../../controllers'
|
||||
import { NostrJoiningBlock, ProfileMetadata } from '../../../types'
|
||||
import styles from './style.module.scss'
|
||||
|
||||
import { NDKEvent, NDKUserProfile, serializeProfile } from '@nostr-dev-kit/ndk'
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
|
||||
import { NostrController } from '../../../controllers'
|
||||
|
||||
import { useNDKContext } from '../../../hooks'
|
||||
import { useAppDispatch, useAppSelector } from '../../../hooks/store'
|
||||
|
||||
import { LoadingButton } from '@mui/lab'
|
||||
import { Dispatch } from '../../../store/store'
|
||||
import { setMetadataEvent } from '../../../store/actions'
|
||||
import { LoadingSpinner } from '../../../components/LoadingSpinner'
|
||||
import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types'
|
||||
import { SmartToy } from '@mui/icons-material'
|
||||
import {
|
||||
getNostrJoiningBlockNumber,
|
||||
getRoboHashPicture,
|
||||
unixNow
|
||||
} from '../../../utils'
|
||||
import { getRoboHashPicture, unixNow } from '../../../utils'
|
||||
|
||||
import { Container } from '../../../components/Container'
|
||||
import { Footer } from '../../../components/Footer/Footer'
|
||||
import LaunchIcon from '@mui/icons-material/Launch'
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
import { LoadingSpinner } from '../../../components/LoadingSpinner'
|
||||
|
||||
import { setUserProfile as updateUserProfile } from '../../../store/actions'
|
||||
import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types'
|
||||
import { Dispatch } from '../../../store/store'
|
||||
|
||||
import styles from './style.module.scss'
|
||||
|
||||
export const ProfileSettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
|
||||
const { npub } = useParams()
|
||||
|
||||
const dispatch: Dispatch = useAppDispatch()
|
||||
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const nostrController = NostrController.getInstance()
|
||||
const { npub } = useParams()
|
||||
const { ndk, findMetadata, publish } = useNDKContext()
|
||||
|
||||
const [pubkey, setPubkey] = useState<string>()
|
||||
const [nostrJoiningBlock, setNostrJoiningBlock] =
|
||||
useState<NostrJoiningBlock | null>(null)
|
||||
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
|
||||
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
|
||||
const metadataState = useAppSelector((state) => state.metadata)
|
||||
const [userProfile, setUserProfile] = useState<NDKUserProfile | null>(null)
|
||||
|
||||
const userRobotImage = useAppSelector((state) => state.user.robotImage)
|
||||
const currentUserProfile = useAppSelector((state) => state.user.profile)
|
||||
const keys = useAppSelector((state) => state.auth?.keyPair)
|
||||
const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useAppSelector(
|
||||
(state) => state.auth
|
||||
)
|
||||
const userRobotImage = useAppSelector((state) => state.userRobotImage)
|
||||
|
||||
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
|
||||
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc] = useState('Fetching metadata')
|
||||
|
||||
@ -79,63 +75,30 @@ export const ProfileSettingsPage = () => {
|
||||
}, [npub, usersPubkey])
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) {
|
||||
getNostrJoiningBlockNumber(pubkey)
|
||||
.then((res) => {
|
||||
setNostrJoiningBlock(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
// todo: handle error
|
||||
console.log('err :>> ', err)
|
||||
})
|
||||
}
|
||||
if (isUsersOwnProfile && currentUserProfile) {
|
||||
setUserProfile(currentUserProfile)
|
||||
|
||||
if (isUsersOwnProfile && metadataState) {
|
||||
const metadataContent = metadataController.extractProfileMetadataContent(
|
||||
metadataState as VerifiedEvent
|
||||
)
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
const getMetadata = async (pubkey: string) => {
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
}
|
||||
}
|
||||
|
||||
metadataController.on(pubkey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
findMetadata(pubkey)
|
||||
.then((profile) => {
|
||||
setUserProfile(profile)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
const metadataEvent = await metadataController
|
||||
.findMetadata(pubkey)
|
||||
.catch((err) => {
|
||||
toast.error(err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
getMetadata(pubkey)
|
||||
}
|
||||
}, [isUsersOwnProfile, metadataState, pubkey, metadataController])
|
||||
}, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata])
|
||||
|
||||
const editItem = (
|
||||
key: keyof ProfileMetadata,
|
||||
key: keyof NDKUserProfile,
|
||||
label: string,
|
||||
multiline = false,
|
||||
rows = 1,
|
||||
@ -145,7 +108,7 @@ export const ProfileSettingsPage = () => {
|
||||
<TextField
|
||||
label={label}
|
||||
id={label.split(' ').join('-')}
|
||||
value={profileMetadata![key] || ''}
|
||||
value={userProfile![key] || ''}
|
||||
size="small"
|
||||
multiline={multiline}
|
||||
rows={rows}
|
||||
@ -155,7 +118,7 @@ export const ProfileSettingsPage = () => {
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target
|
||||
|
||||
setProfileMetadata((prev) => ({
|
||||
setUserProfile((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}))
|
||||
@ -197,34 +160,47 @@ export const ProfileSettingsPage = () => {
|
||||
)
|
||||
|
||||
const handleSaveMetadata = async () => {
|
||||
if (!userProfile) return
|
||||
|
||||
setSavingProfileMetadata(true)
|
||||
|
||||
const content = JSON.stringify(profileMetadata)
|
||||
const serializedProfile = serializeProfile(userProfile)
|
||||
|
||||
// We need to omit cachedAt and create new event
|
||||
// Relay will reject if created_at is too late
|
||||
const updatedMetadataState: UnsignedEvent = {
|
||||
content: content,
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: serializedProfile,
|
||||
created_at: unixNow(),
|
||||
kind: kinds.Metadata,
|
||||
pubkey: pubkey!,
|
||||
tags: metadataState?.tags || []
|
||||
tags: []
|
||||
}
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
const signedEvent = await nostrController
|
||||
.signEvent(updatedMetadataState)
|
||||
.signEvent(unsignedEvent)
|
||||
.catch((error) => {
|
||||
toast.error(`Error saving profile metadata. ${error}`)
|
||||
return null
|
||||
})
|
||||
|
||||
if (signedEvent) {
|
||||
if (!metadataController.validate(signedEvent)) {
|
||||
toast.error(`Metadata is not valid.`)
|
||||
}
|
||||
if (!signedEvent) {
|
||||
setSavingProfileMetadata(false)
|
||||
return
|
||||
}
|
||||
|
||||
await metadataController.publishMetadataEvent(signedEvent)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
dispatch(setMetadataEvent(signedEvent))
|
||||
// Handle cases where publishing failed or succeeded
|
||||
if (publishedOnRelays.length === 0) {
|
||||
toast.error('Failed to publish event on any relay')
|
||||
} else {
|
||||
toast.success(
|
||||
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
|
||||
'\n'
|
||||
)}`
|
||||
)
|
||||
|
||||
dispatch(updateUserProfile(userProfile))
|
||||
}
|
||||
|
||||
setSavingProfileMetadata(false)
|
||||
@ -241,7 +217,7 @@ export const ProfileSettingsPage = () => {
|
||||
|
||||
const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current)
|
||||
|
||||
setProfileMetadata((prev) => ({
|
||||
setUserProfile((prev) => ({
|
||||
...prev,
|
||||
picture: robotAvatarLink
|
||||
}))
|
||||
@ -267,14 +243,14 @@ export const ProfileSettingsPage = () => {
|
||||
*
|
||||
* @returns robohash image url
|
||||
*/
|
||||
const getProfileImage = (metadata: ProfileMetadata) => {
|
||||
const getProfileImage = (profile: NDKUserProfile) => {
|
||||
if (!isUsersOwnProfile) {
|
||||
return metadata.picture || getRoboHashPicture(npub!)
|
||||
return profile.image || getRoboHashPicture(npub!)
|
||||
}
|
||||
|
||||
// userRobotImage is used only when visiting own profile
|
||||
// while kind 0 picture is not set
|
||||
return metadata.picture || userRobotImage || getRoboHashPicture(npub!)
|
||||
return profile.image || userRobotImage || getRoboHashPicture(npub!)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -300,7 +276,7 @@ export const ProfileSettingsPage = () => {
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
{profileMetadata && (
|
||||
{userProfile && (
|
||||
<div>
|
||||
<ListItem
|
||||
sx={{
|
||||
@ -309,10 +285,10 @@ export const ProfileSettingsPage = () => {
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{profileMetadata.banner ? (
|
||||
{userProfile.banner ? (
|
||||
<img
|
||||
className={styles.bannerImg}
|
||||
src={profileMetadata.banner}
|
||||
src={userProfile.banner}
|
||||
alt="Banner Image"
|
||||
/>
|
||||
) : (
|
||||
@ -334,32 +310,17 @@ export const ProfileSettingsPage = () => {
|
||||
event.currentTarget.src = getRoboHashPicture(npub!)
|
||||
}}
|
||||
className={styles.img}
|
||||
src={getProfileImage(profileMetadata)}
|
||||
src={getProfileImage(userProfile)}
|
||||
alt="Profile Image"
|
||||
/>
|
||||
|
||||
{nostrJoiningBlock && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: theme.palette.getContrastText(
|
||||
theme.palette.background.paper
|
||||
)
|
||||
}}
|
||||
component={Link}
|
||||
to={`https://njump.me/${nostrJoiningBlock.encodedEventPointer}`}
|
||||
target="_blank"
|
||||
>
|
||||
On nostr since {nostrJoiningBlock.block.toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
{editItem('picture', 'Picture URL', undefined, undefined, {
|
||||
{editItem('image', 'Picture URL', undefined, undefined, {
|
||||
endAdornment: isUsersOwnProfile ? robohashButton() : undefined
|
||||
})}
|
||||
|
||||
{editItem('name', 'Username')}
|
||||
{editItem('display_name', 'Display Name')}
|
||||
{editItem('displayName', 'Display Name')}
|
||||
{editItem('nip05', 'Nostr Address (nip05)')}
|
||||
{editItem('lud16', 'Lightning Address (lud16)')}
|
||||
{editItem('about', 'About', true, 4)}
|
||||
@ -368,6 +329,7 @@ export const ProfileSettingsPage = () => {
|
||||
<>
|
||||
{usersPubkey &&
|
||||
copyItem(nip19.npubEncode(usersPubkey), 'Public Key')}
|
||||
|
||||
{loginMethod === LoginMethod.privateKey &&
|
||||
keys &&
|
||||
keys.private &&
|
||||
|
@ -13,27 +13,40 @@ import Switch from '@mui/material/Switch'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Container } from '../../../components/Container'
|
||||
import { relayController } from '../../../controllers'
|
||||
import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks'
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useDidMount,
|
||||
useDvm,
|
||||
useNDKContext
|
||||
} from '../../../hooks'
|
||||
import { setRelayMapAction } from '../../../store/actions'
|
||||
import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types'
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
compareObjects,
|
||||
getRelayInfo,
|
||||
getRelayMap,
|
||||
getRelayMapFromNDKRelayList,
|
||||
hexToNpub,
|
||||
isValidRelayUri,
|
||||
publishRelayMap,
|
||||
shorten
|
||||
shorten,
|
||||
timeout
|
||||
} from '../../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { Footer } from '../../../components/Footer/Footer'
|
||||
import {
|
||||
getRelayListForUser,
|
||||
NDKRelayList,
|
||||
NDKRelayStatus
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
|
||||
export const RelaysPage = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { ndk, publish } = useNDKContext()
|
||||
const { getRelayInfo } = useDvm()
|
||||
|
||||
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const [ndkRelayList, setNDKRelayList] = useState<NDKRelayList | null>(null)
|
||||
|
||||
const [newRelayURI, setNewRelayURI] = useState<string>()
|
||||
const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
|
||||
@ -43,22 +56,51 @@ export const RelaysPage = () => {
|
||||
|
||||
const webSocketPrefix = 'wss://'
|
||||
|
||||
useDidMount(() => {
|
||||
// fetch relay list from relays
|
||||
useEffect(() => {
|
||||
if (usersPubkey) {
|
||||
getRelayMap(usersPubkey).then((newRelayMap) => {
|
||||
if (!compareObjects(relayMap, newRelayMap.map)) {
|
||||
dispatch(setRelayMapAction(newRelayMap.map))
|
||||
}
|
||||
})
|
||||
Promise.race([getRelayListForUser(usersPubkey, ndk), timeout(10000)])
|
||||
.then((res) => {
|
||||
setNDKRelayList(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
`An error occurred in fetching user relay list: ${
|
||||
err.message || err
|
||||
}`
|
||||
)
|
||||
setNDKRelayList(new NDKRelayList(ndk))
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [usersPubkey, ndk])
|
||||
|
||||
// construct the RelayMap from newly received NDKRelayList event
|
||||
// and compare it with existing relay map in redux store
|
||||
// if there are any differences then update the redux store with
|
||||
// new relay map
|
||||
useEffect(() => {
|
||||
if (ndkRelayList) {
|
||||
const newRelayMap = getRelayMapFromNDKRelayList(ndkRelayList)
|
||||
|
||||
if (!compareObjects(relayMap, newRelayMap)) {
|
||||
dispatch(setRelayMapAction(newRelayMap))
|
||||
}
|
||||
}
|
||||
|
||||
// we want to run this effect only when ndkRelayList is changed
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ndkRelayList])
|
||||
|
||||
useEffect(() => {
|
||||
if (!relayMap) return
|
||||
|
||||
// Display notification if an empty relay map has been received
|
||||
if (relayMap && Object.keys(relayMap).length === 0) {
|
||||
if (Object.keys(relayMap).length === 0) {
|
||||
relayRequirementWarning()
|
||||
} else {
|
||||
getRelayInfo(Object.keys(relayMap))
|
||||
}
|
||||
}, [relayMap])
|
||||
}, [relayMap, getRelayInfo])
|
||||
|
||||
const relayRequirementWarning = () =>
|
||||
toast.warning('At least one write relay is needed for SIGit to work.')
|
||||
@ -86,7 +128,8 @@ export const RelaysPage = () => {
|
||||
const relayMapPublishingRes = await publishRelayMap(
|
||||
relayMapCopy,
|
||||
usersPubkey,
|
||||
[relay]
|
||||
ndk,
|
||||
publish
|
||||
).catch((err) => handlePublishRelayMapError(err))
|
||||
|
||||
if (relayMapPublishingRes) {
|
||||
@ -133,7 +176,9 @@ export const RelaysPage = () => {
|
||||
// Publish updated relay map
|
||||
const relayMapPublishingRes = await publishRelayMap(
|
||||
relayMapCopy,
|
||||
usersPubkey
|
||||
usersPubkey,
|
||||
ndk,
|
||||
publish
|
||||
).catch((err) => handlePublishRelayMapError(err))
|
||||
|
||||
if (relayMapPublishingRes) {
|
||||
@ -150,16 +195,22 @@ export const RelaysPage = () => {
|
||||
const relayURI = `${webSocketPrefix}${newRelayURI?.trim().replace(webSocketPrefix, '')}`
|
||||
|
||||
// Check if new relay URI is a valid string
|
||||
if (relayURI && !isValidRelayUri(relayURI)) {
|
||||
if (
|
||||
relayURI &&
|
||||
!/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
|
||||
relayURI
|
||||
)
|
||||
) {
|
||||
if (relayURI !== webSocketPrefix) {
|
||||
setNewRelayURIerror(
|
||||
'New relay URI is not valid. Example of valid relay URI: wss://sigit.relay.io'
|
||||
)
|
||||
}
|
||||
} else if (relayURI && usersPubkey) {
|
||||
const relay = await relayController.connectRelay(relayURI)
|
||||
const ndkRelay = ndk.pool.getRelay(relayURI)
|
||||
await ndkRelay.connect(5000)
|
||||
|
||||
if (relay && relay.connected) {
|
||||
if (ndkRelay.status >= NDKRelayStatus.CONNECTED) {
|
||||
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
|
||||
|
||||
relayMapCopy[relayURI] = { write: true, read: true }
|
||||
@ -167,7 +218,9 @@ export const RelaysPage = () => {
|
||||
// Publish updated relay map
|
||||
const relayMapPublishingRes = await publishRelayMap(
|
||||
relayMapCopy,
|
||||
usersPubkey
|
||||
usersPubkey,
|
||||
ndk,
|
||||
publish
|
||||
).catch((err) => handlePublishRelayMapError(err))
|
||||
|
||||
if (relayMapPublishingRes) {
|
||||
@ -252,19 +305,36 @@ const RelayItem = ({
|
||||
handleLeaveRelay,
|
||||
handleRelayWriteChange
|
||||
}: RelayItemProp) => {
|
||||
const { ndk } = useNDKContext()
|
||||
|
||||
const [relayConnectionStatus, setRelayConnectionStatus] =
|
||||
useState<RelayConnectionState>()
|
||||
|
||||
const [displayRelayInfo, setDisplayRelayInfo] = useState(false)
|
||||
|
||||
useDidMount(() => {
|
||||
relayController.connectRelay(relayURI).then((relay) => {
|
||||
if (relay && relay.connected) {
|
||||
const ndkPool = ndk.pool
|
||||
|
||||
ndkPool.on('relay:connect', (relay) => {
|
||||
if (relay.url === relayURI) {
|
||||
setRelayConnectionStatus(RelayConnectionState.Connected)
|
||||
} else {
|
||||
}
|
||||
})
|
||||
|
||||
ndkPool.on('relay:disconnect', (relay) => {
|
||||
if (relay.url === relayURI) {
|
||||
setRelayConnectionStatus(RelayConnectionState.NotConnected)
|
||||
}
|
||||
})
|
||||
|
||||
const relay = ndkPool.getRelay(relayURI)
|
||||
if (relay) {
|
||||
setRelayConnectionStatus(
|
||||
relay.status >= NDKRelayStatus.CONNECTED
|
||||
? RelayConnectionState.Connected
|
||||
: RelayConnectionState.NotConnected
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
|
@ -1,261 +0,0 @@
|
||||
import styles from './style.module.scss'
|
||||
import { Container } from '../../../components/Container'
|
||||
import { Footer } from '../../../components/Footer/Footer.tsx'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
TextField
|
||||
} from '@mui/material'
|
||||
import StorageIcon from '@mui/icons-material/Storage'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { FileServerMap, KeyboardCode } from '../../../types'
|
||||
import {
|
||||
getFileServerMap,
|
||||
publishFileServer
|
||||
} from '../../../utils/file-servers.ts'
|
||||
import { useAppSelector } from '../../../hooks'
|
||||
import { useDidMount } from '../../../hooks'
|
||||
import { isValidUrl, MAXIMUM_BLOSSOMS_LENGTH } from '../../../utils'
|
||||
import axios from 'axios'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
const protocol = 'https://'
|
||||
|
||||
const errors = {
|
||||
urlNotValid:
|
||||
'New server URL is not valid. Example of valid server URL: blossom.sigit.io'
|
||||
}
|
||||
|
||||
export const ServersPage = () => {
|
||||
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
|
||||
|
||||
const [newServerURL, setNewServerURL] = useState<string>('')
|
||||
const [newRelayURLerror, setNewRelayURLerror] = useState<string>()
|
||||
const [loadingServers, setLoadingServers] = useState<boolean>(true)
|
||||
|
||||
const [blossomServersMap, setBlossomServersMap] = useState<FileServerMap>({})
|
||||
|
||||
useDidMount(() => {
|
||||
fetchFileServers()
|
||||
})
|
||||
|
||||
const fetchFileServers = async () => {
|
||||
if (usersPubkey) {
|
||||
await getFileServerMap(usersPubkey).then((res) => {
|
||||
if (res.map) {
|
||||
if (Object.keys(res.map).length === 0) {
|
||||
serverRequirementWarning()
|
||||
}
|
||||
|
||||
setBlossomServersMap(res.map)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
noUserKeyWarning()
|
||||
}
|
||||
|
||||
setLoadingServers(false)
|
||||
}
|
||||
|
||||
const noUserKeyWarning = () => toast.warning('No user key available.')
|
||||
|
||||
const serverRequirementWarning = () =>
|
||||
toast.warning('At least one Blossom server is needed for SIGit to work.')
|
||||
|
||||
const handleAddNewServer = async () => {
|
||||
if (!newServerURL.length) {
|
||||
setNewRelayURLerror(errors.urlNotValid)
|
||||
return
|
||||
}
|
||||
|
||||
if (Object.keys(blossomServersMap).length === MAXIMUM_BLOSSOMS_LENGTH) {
|
||||
return toast.warning(
|
||||
`You can only add a maximum of ${MAXIMUM_BLOSSOMS_LENGTH} blossom servers.`
|
||||
)
|
||||
}
|
||||
|
||||
const serverURL = `${protocol}${newServerURL?.trim().replace(protocol, '')}`
|
||||
if (!serverURL) return
|
||||
|
||||
// Check if new server is a valid URL
|
||||
if (!isValidUrl(serverURL)) {
|
||||
if (serverURL !== protocol) {
|
||||
setNewRelayURLerror(errors.urlNotValid)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(blossomServersMap).includes(serverURL))
|
||||
return toast.warning('This server is already added.')
|
||||
|
||||
const valid = await validateFileServer(serverURL).catch(() => null)
|
||||
if (!valid)
|
||||
return toast.warning(
|
||||
`Server URL ${serverURL} does not seem to be a valid file server.`
|
||||
)
|
||||
|
||||
setNewRelayURLerror('')
|
||||
const tempBlossomServersMap = blossomServersMap
|
||||
tempBlossomServersMap[serverURL] = { write: true, read: true }
|
||||
setBlossomServersMap(tempBlossomServersMap)
|
||||
setNewServerURL('')
|
||||
|
||||
publishFileServersList(tempBlossomServersMap)
|
||||
}
|
||||
|
||||
const handleDeleteServer = (serverURL: string) => {
|
||||
if (Object.keys(blossomServersMap).length === 1)
|
||||
return serverRequirementWarning()
|
||||
|
||||
// Remove server from the list
|
||||
const tempBlossomServersMap = cloneDeep(blossomServersMap)
|
||||
delete tempBlossomServersMap[serverURL]
|
||||
|
||||
setBlossomServersMap(tempBlossomServersMap)
|
||||
// Publish new list to the relays
|
||||
publishFileServersList(tempBlossomServersMap)
|
||||
}
|
||||
|
||||
const publishFileServersList = (fileServersMap: FileServerMap) => {
|
||||
if (!usersPubkey)
|
||||
return toast.warning(
|
||||
'No user key available, please reload and try again.'
|
||||
)
|
||||
|
||||
publishFileServer(fileServersMap, usersPubkey)
|
||||
.then((res) => {
|
||||
toast.success(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const handleInputKeydown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.code === KeyboardCode.Enter ||
|
||||
event.code === KeyboardCode.NumpadEnter
|
||||
) {
|
||||
event.preventDefault()
|
||||
handleAddNewServer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the file server is up and valid
|
||||
* For now check will just include sending a GET request and checking if
|
||||
* returned HTML includes word `Blossom`.
|
||||
*
|
||||
* Probably later, there will be appropriate sepc universal to all file servers
|
||||
* which would include some kind of "check" endpoint.
|
||||
* @param serverURL
|
||||
*/
|
||||
const validateFileServer = (serverURL: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.get(serverURL)
|
||||
.then((res) => {
|
||||
if (res && res.data?.toLowerCase().includes('blossom server')) {
|
||||
resolve(true)
|
||||
} else {
|
||||
reject(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className={`settings-container ${styles.container}`}>
|
||||
<Box className={styles.serverAddContainer}>
|
||||
<TextField
|
||||
label="Add new blossom server"
|
||||
value={newServerURL}
|
||||
onKeyDown={handleInputKeydown}
|
||||
onChange={(e) => setNewServerURL(e.target.value)}
|
||||
helperText={newRelayURLerror}
|
||||
error={!!newRelayURLerror}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">{protocol}</InputAdornment>
|
||||
)
|
||||
}}
|
||||
className={styles.serverURItextfield}
|
||||
/>
|
||||
<Button variant="contained" onClick={() => handleAddNewServer()}>
|
||||
Add
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className={styles.sectionTitle}>
|
||||
<StorageIcon className={styles.sectionIcon} />
|
||||
<span>YOUR BLOSSOM SERVERS</span>
|
||||
</Box>
|
||||
|
||||
{loadingServers && (
|
||||
<div className="text-center">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blossomServersMap && (
|
||||
<Box className={styles.serversContainer}>
|
||||
{Object.keys(blossomServersMap).map((key) => (
|
||||
<ServerItem
|
||||
key={key}
|
||||
serverURL={key}
|
||||
preventDelete={Object.keys(blossomServersMap).length === 1}
|
||||
handleDeleteServer={handleDeleteServer}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Footer />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerItemProps {
|
||||
serverURL: string
|
||||
preventDelete?: boolean
|
||||
handleDeleteServer?: (serverURL: string) => void
|
||||
}
|
||||
|
||||
const ServerItem = ({
|
||||
serverURL,
|
||||
handleDeleteServer,
|
||||
preventDelete
|
||||
}: ServerItemProps) => {
|
||||
return (
|
||||
<Box className={styles.server}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<span
|
||||
className={[
|
||||
styles.connectionStatus,
|
||||
styles.connectionStatusConnected
|
||||
].join(' ')}
|
||||
/>
|
||||
|
||||
<ListItemText primary={serverURL} />
|
||||
|
||||
<Box
|
||||
onClick={() => handleDeleteServer && handleDeleteServer(serverURL)}
|
||||
className={`${styles.leaveServerContainer} ${preventDelete ? styles.disabled : ''}`}
|
||||
>
|
||||
<DeleteIcon />
|
||||
<span>Remove</span>
|
||||
</Box>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
@import '../../../styles/colors.scss';
|
||||
|
||||
.container {
|
||||
color: $text-color;
|
||||
|
||||
.serverURItextfield {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.serverAddContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sectionIcon {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-top: 35px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.serversContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.server {
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
|
||||
.relayDivider {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.leaveServerContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.showInfo {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.showInfoIcon {
|
||||
margin-right: 3px;
|
||||
margin-bottom: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.relayInfoContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
.relayInfoTitle {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.relayInfoSubTitle {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.copyItem {
|
||||
margin-left: 10px;
|
||||
color: #34495e;
|
||||
vertical-align: bottom;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.connectionStatus {
|
||||
border-radius: 9999px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.connectionStatusConnected {
|
||||
background-color: $relay-status-connected;
|
||||
}
|
||||
|
||||
.connectionStatusNotConnected {
|
||||
background-color: $relay-status-notconnected;
|
||||
}
|
||||
|
||||
.connectionStatusUnknown {
|
||||
background-color: $input-text-color;
|
||||
}
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ import saveAs from 'file-saver'
|
||||
import JSZip from 'jszip'
|
||||
import _ from 'lodash'
|
||||
import { Event, verifyEvent } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useAppSelector } from '../../hooks'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
@ -32,12 +32,10 @@ import {
|
||||
parseJson,
|
||||
processMarks,
|
||||
readContentOfZipEntry,
|
||||
sendNotification,
|
||||
signEventForMetaFile,
|
||||
timeout,
|
||||
unixNow,
|
||||
updateMarks,
|
||||
updateUsersAppData,
|
||||
uploadMetaToFileStorage
|
||||
} from '../../utils'
|
||||
import { CurrentUserMark, Mark } from '../../types/mark.ts'
|
||||
@ -49,47 +47,49 @@ import {
|
||||
} from '../../utils/file.ts'
|
||||
import { generateTimestamp } from '../../utils/opentimestamps.ts'
|
||||
import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
|
||||
import { useNDK } from '../../hooks/useNDK.ts'
|
||||
import { getLastSignersSig } from '../../utils/sign.ts'
|
||||
|
||||
export const SignPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
const { updateUsersAppData, sendNotification } = useNDK()
|
||||
|
||||
const usersAppData = useAppSelector((state) => state.userAppData)
|
||||
|
||||
/**
|
||||
* In the online mode, Sigit ID can be obtained either from the router state
|
||||
* using location or from UsersAppData
|
||||
*/
|
||||
const metaInNavState = useMemo(() => {
|
||||
if (usersAppData) {
|
||||
const sigitCreateId = params.id
|
||||
|
||||
if (sigitCreateId) {
|
||||
const sigit = usersAppData.sigits[sigitCreateId]
|
||||
|
||||
if (sigit) {
|
||||
return sigit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return location?.state?.meta || undefined
|
||||
}, [location, usersAppData, params.id])
|
||||
|
||||
/**
|
||||
* Received from `location.state`
|
||||
*
|
||||
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains keys.json
|
||||
* arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode
|
||||
* meta (metaInNavState) will be received in navigation from create & home page in online mode
|
||||
*/
|
||||
let metaInNavState = location?.state?.meta || undefined
|
||||
const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || {
|
||||
decryptedArrayBuffer: undefined,
|
||||
uploadedZip: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* If userAppData (redux) is available, and we have the route param (sigit id)
|
||||
* which is actually a `createEventId`, we will fetch a `sigit`
|
||||
* based on the provided route ID and set fetched `sigit` to the `metaInNavState`
|
||||
*/
|
||||
if (usersAppData) {
|
||||
const sigitCreateId = params.id
|
||||
|
||||
if (sigitCreateId) {
|
||||
const sigit = usersAppData.sigits[sigitCreateId]
|
||||
|
||||
if (sigit) {
|
||||
metaInNavState = sigit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
|
||||
const [noFiles, setNoFiles] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
@ -168,7 +168,6 @@ export const SignPage = () => {
|
||||
createSignatureContent.markConfig,
|
||||
usersPubkey!
|
||||
)
|
||||
// TODO figure out why markConfig does not contain the usersPubkey when multiple signer
|
||||
const signedMarks = extractMarksFromSignedMeta(meta)
|
||||
const currentUserMarks = getCurrentUserMarks(metaMarks, signedMarks)
|
||||
const otherUserMarks = findOtherUserMarks(signedMarks, usersPubkey!)
|
||||
@ -277,7 +276,6 @@ export const SignPage = () => {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// online mode - from create and home page views
|
||||
if (metaInNavState) {
|
||||
const processSigit = async () => {
|
||||
setIsLoading(true)
|
||||
@ -289,53 +287,38 @@ export const SignPage = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const { zipUrls, encryptionKey } = res
|
||||
const { zipUrl, encryptionKey } = res
|
||||
|
||||
if (!zipUrls || zipUrls.length === 0) {
|
||||
toast.warning('No zip files found in the zipUrls')
|
||||
setIsLoading(false)
|
||||
setNoFiles(true)
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < zipUrls.length; i++) {
|
||||
const zipUrl = zipUrls[i]
|
||||
const isLastZipUrl = i === zipUrls.length - 1
|
||||
|
||||
setLoadingSpinnerDesc('Fetching file from file server')
|
||||
|
||||
const res = await axios
|
||||
.get(zipUrl, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`error occurred in getting file from ${zipUrls}`,
|
||||
err
|
||||
)
|
||||
toast.error(
|
||||
err.message || `error occurred in getting file from ${zipUrls}`
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
if (res) {
|
||||
setLoadingSpinnerDesc('Fetching file from file server')
|
||||
axios
|
||||
.get(zipUrl, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
.then((res) => {
|
||||
handleArrayBufferFromBlossom(res.data, encryptionKey)
|
||||
setMeta(metaInNavState)
|
||||
break
|
||||
} else {
|
||||
// No data returned, break from the loop
|
||||
if (isLastZipUrl) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`error occurred in getting file from ${zipUrl}`, err)
|
||||
toast.error(
|
||||
err.message || `error occurred in getting file from ${zipUrl}`
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
processSigit()
|
||||
} else if (decryptedArrayBuffer) {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// online mode - from create and home page views
|
||||
|
||||
if (decryptedArrayBuffer) {
|
||||
handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() =>
|
||||
setIsLoading(false)
|
||||
)
|
||||
@ -354,7 +337,7 @@ export const SignPage = () => {
|
||||
} else {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [decryptedArrayBuffer, uploadedZip, metaInNavState, decrypt])
|
||||
}, [decryptedArrayBuffer, uploadedZip, decrypt])
|
||||
|
||||
const handleArrayBufferFromBlossom = async (
|
||||
arrayBuffer: ArrayBuffer,
|
||||
@ -496,10 +479,6 @@ export const SignPage = () => {
|
||||
setMeta(parsedMetaJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the signing process
|
||||
* When user signs, files will automatically be published to all user preferred servers
|
||||
*/
|
||||
const handleSign = async () => {
|
||||
if (Object.entries(files).length === 0 || !meta) return
|
||||
|
||||
@ -675,15 +654,15 @@ export const SignPage = () => {
|
||||
encryptionKey: string | undefined
|
||||
) => {
|
||||
setLoadingSpinnerDesc('Updating users app data')
|
||||
const updatedEvent = await updateUsersAppData(meta)
|
||||
const updatedEvent = await updateUsersAppData([meta])
|
||||
if (!updatedEvent) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let metaUrls: string[]
|
||||
let metaUrl: string
|
||||
try {
|
||||
metaUrls = await uploadMetaToFileStorage(meta, encryptionKey)
|
||||
metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message)
|
||||
@ -725,10 +704,7 @@ export const SignPage = () => {
|
||||
setLoadingSpinnerDesc('Sending notifications')
|
||||
const users = Array.from(userSet)
|
||||
const promises = users.map((user) =>
|
||||
sendNotification(npubToHex(user)!, {
|
||||
metaUrls: metaUrls,
|
||||
keys: meta.keys
|
||||
})
|
||||
sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys })
|
||||
)
|
||||
await Promise.all(promises)
|
||||
.then(() => {
|
||||
@ -883,7 +859,6 @@ export const SignPage = () => {
|
||||
handleEncryptedExport={handleEncryptedExport}
|
||||
otherUserMarks={otherUserMarks}
|
||||
meta={meta}
|
||||
noFiles={noFiles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import {
|
||||
Meta,
|
||||
ProfileMetadata,
|
||||
SignedEventContent,
|
||||
User,
|
||||
UserRole
|
||||
} from '../../../types'
|
||||
Cancel,
|
||||
CheckCircle,
|
||||
Download,
|
||||
HourglassTop
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
@ -20,22 +22,19 @@ import {
|
||||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Download,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
HourglassTop
|
||||
} from '@mui/icons-material'
|
||||
|
||||
import saveAs from 'file-saver'
|
||||
import { kinds, Event } from 'nostr-tools'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
import { UserAvatar } from '../../../components/UserAvatar'
|
||||
import { MetadataController } from '../../../controllers'
|
||||
import { npubToHex, hexToNpub, parseJson } from '../../../utils'
|
||||
import styles from '../style.module.scss'
|
||||
|
||||
import { Meta, SignedEventContent, User, UserRole } from '../../../types'
|
||||
import { hexToNpub, npubToHex, parseJson } from '../../../utils'
|
||||
import { SigitFile } from '../../../utils/file'
|
||||
|
||||
import styles from '../style.module.scss'
|
||||
|
||||
type DisplayMetaProps = {
|
||||
meta: Meta
|
||||
files: { [fileName: string]: SigitFile }
|
||||
@ -67,9 +66,6 @@ export const DisplayMeta = ({
|
||||
theme.palette.background.paper
|
||||
)
|
||||
|
||||
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
|
||||
{}
|
||||
)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
@ -104,45 +100,6 @@ export const DisplayMeta = ({
|
||||
})
|
||||
}, [signers, viewers])
|
||||
|
||||
useEffect(() => {
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const hexKeys: string[] = [
|
||||
npubToHex(submittedBy)!,
|
||||
...users.map((user) => user.pubkey)
|
||||
]
|
||||
|
||||
hexKeys.forEach((key) => {
|
||||
if (!(key in metadata)) {
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
|
||||
if (metadataContent)
|
||||
setMetadata((prev) => ({
|
||||
...prev,
|
||||
[key]: metadataContent
|
||||
}))
|
||||
}
|
||||
|
||||
metadataController.on(key, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
metadataController
|
||||
.findMetadata(key)
|
||||
.then((metadataEvent) => {
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`error occurred in finding metadata for: ${key}`, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [users, submittedBy, metadata])
|
||||
|
||||
const downloadFile = async (fileName: string) => {
|
||||
const file = files[fileName]
|
||||
saveAs(file)
|
||||
@ -229,7 +186,6 @@ export const DisplayMeta = ({
|
||||
key={user.pubkey}
|
||||
meta={meta}
|
||||
user={user}
|
||||
metadata={metadata}
|
||||
signedBy={signedBy}
|
||||
nextSigner={nextSigner}
|
||||
getPrevSignersSig={getPrevSignersSig}
|
||||
@ -258,7 +214,6 @@ enum UserStatus {
|
||||
type DisplayUserProps = {
|
||||
meta: Meta
|
||||
user: User
|
||||
metadata: { [key: string]: ProfileMetadata }
|
||||
signedBy: `npub1${string}`[]
|
||||
nextSigner?: string
|
||||
getPrevSignersSig: (usersNpub: string) => string | null
|
||||
|
@ -21,9 +21,7 @@ import {
|
||||
readContentOfZipEntry,
|
||||
signEventForMetaFile,
|
||||
getCurrentUserFiles,
|
||||
updateUsersAppData,
|
||||
npubToHex,
|
||||
sendNotification,
|
||||
generateEncryptionKey,
|
||||
encryptArrayBuffer,
|
||||
generateKeysFile,
|
||||
@ -35,7 +33,7 @@ import styles from './style.module.scss'
|
||||
import { useLocation, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
|
||||
import { useAppSelector } from '../../hooks'
|
||||
import { useAppSelector, useNDK } from '../../hooks'
|
||||
import { getLastSignersSig } from '../../utils/sign.ts'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { Container } from '../../components/Container'
|
||||
@ -65,7 +63,6 @@ import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx'
|
||||
|
||||
interface PdfViewProps {
|
||||
files: CurrentUserFile[]
|
||||
noFiles?: boolean
|
||||
currentFile: CurrentUserFile | null
|
||||
parsedSignatureEvents: {
|
||||
[signer: `npub1${string}`]: DocSignatureEvent
|
||||
@ -74,7 +71,6 @@ interface PdfViewProps {
|
||||
|
||||
const SlimPdfView = ({
|
||||
files,
|
||||
noFiles,
|
||||
currentFile,
|
||||
parsedSignatureEvents
|
||||
}: PdfViewProps) => {
|
||||
@ -164,8 +160,6 @@ const SlimPdfView = ({
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
) : noFiles ? (
|
||||
''
|
||||
) : (
|
||||
<LoadingSpinner variant="small" />
|
||||
)}
|
||||
@ -176,6 +170,7 @@ const SlimPdfView = ({
|
||||
export const VerifyPage = () => {
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
const { updateUsersAppData, sendNotification } = useNDK()
|
||||
|
||||
const usersAppData = useAppSelector((state) => state.userAppData)
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
@ -220,7 +215,7 @@ export const VerifyPage = () => {
|
||||
|
||||
const {
|
||||
submittedBy,
|
||||
zipUrls,
|
||||
zipUrl,
|
||||
encryptionKey,
|
||||
signers,
|
||||
viewers,
|
||||
@ -358,7 +353,7 @@ export const VerifyPage = () => {
|
||||
updatedMeta.timestamps = [...finalTimestamps]
|
||||
updatedMeta.modifiedAt = unixNow()
|
||||
|
||||
const updatedEvent = await updateUsersAppData(updatedMeta)
|
||||
const updatedEvent = await updateUsersAppData([updatedMeta])
|
||||
if (!updatedEvent) return
|
||||
|
||||
const metaUrl = await uploadMetaToFileStorage(
|
||||
@ -380,7 +375,7 @@ export const VerifyPage = () => {
|
||||
const users = Array.from(userSet)
|
||||
const promises = users.map((user) =>
|
||||
sendNotification(npubToHex(user)!, {
|
||||
metaUrls: metaUrl,
|
||||
metaUrl,
|
||||
keys: meta.keys!
|
||||
})
|
||||
)
|
||||
@ -407,62 +402,35 @@ export const VerifyPage = () => {
|
||||
const processSigit = async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
// We have multiple zipUrls, we should fetch one by one and take the first one which successfully decrypts
|
||||
// If a file is altered decrytption will fail
|
||||
setLoadingSpinnerDesc('Fetching file from file server')
|
||||
try {
|
||||
const res = await axios.get(zipUrl, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
|
||||
if (!zipUrls || zipUrls.length === 0) {
|
||||
toast.warning('No zip files found in the zipUrls')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
const fileName = zipUrl.split('/').pop()
|
||||
const file = new File([res.data], fileName!)
|
||||
|
||||
for (let i = 0; i < zipUrls.length; i++) {
|
||||
const zipUrl = zipUrls[i]
|
||||
const isLastZipUrl = i === zipUrls.length - 1
|
||||
const encryptedArrayBuffer = await file.arrayBuffer()
|
||||
const arrayBuffer = await decryptArrayBuffer(
|
||||
encryptedArrayBuffer,
|
||||
encryptionKey
|
||||
).catch((err) => {
|
||||
console.log('err in decryption:>> ', err)
|
||||
toast.error(err.message || 'An error occurred in decrypting file.')
|
||||
return null
|
||||
})
|
||||
|
||||
try {
|
||||
// Fetch zip data
|
||||
const res = await axios.get(zipUrl, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
|
||||
// Prepare file from response
|
||||
const fileName = zipUrl.split('/').pop()
|
||||
const file = new File([res.data], fileName!)
|
||||
const encryptedArrayBuffer = await file.arrayBuffer()
|
||||
|
||||
// Decrypt the array buffer
|
||||
const arrayBuffer = await decryptArrayBuffer(
|
||||
encryptedArrayBuffer,
|
||||
encryptionKey
|
||||
).catch((err) => {
|
||||
const error = err.message
|
||||
? `Decryption error: ${err.message}`
|
||||
: 'An error occurred in decrypting file.'
|
||||
console.error('Error in decryption:>> ', err)
|
||||
toast.error(error)
|
||||
return null // Continue iteration for next zipUrl
|
||||
})
|
||||
|
||||
if (!arrayBuffer) {
|
||||
if (!isLastZipUrl) continue // Skip to next zipUrl if decryption fails
|
||||
break // If last zipUrl break out of loop
|
||||
}
|
||||
|
||||
// Load zip archive
|
||||
if (arrayBuffer) {
|
||||
const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => {
|
||||
console.error('Error in loading zip file :>> ', err)
|
||||
console.log('err in loading zip file :>> ', err)
|
||||
toast.error(
|
||||
err.message || 'An error occurred in loading zip file.'
|
||||
)
|
||||
return null // Skip to next zipUrl
|
||||
return null
|
||||
})
|
||||
|
||||
if (!zip) {
|
||||
if (!isLastZipUrl) continue // Skip to next zipUrl
|
||||
break // If last zipUrl break out of loop
|
||||
}
|
||||
if (!zip) return
|
||||
|
||||
const files: { [fileName: string]: SigitFile } = {}
|
||||
const fileHashes: { [key: string]: string | null } = {}
|
||||
@ -470,44 +438,47 @@ export const VerifyPage = () => {
|
||||
(entry) => entry.name
|
||||
)
|
||||
|
||||
// Generate hashes for all entries in the files folder of zipArchive
|
||||
for (const entryFileName of fileNames) {
|
||||
const entryArrayBuffer = await readContentOfZipEntry(
|
||||
// generate hashes for all entries in files folder of zipArchive
|
||||
// these hashes can be used to verify the originality of files
|
||||
for (const fileName of fileNames) {
|
||||
const arrayBuffer = await readContentOfZipEntry(
|
||||
zip,
|
||||
entryFileName,
|
||||
fileName,
|
||||
'arraybuffer'
|
||||
)
|
||||
if (entryArrayBuffer) {
|
||||
files[entryFileName] = await convertToSigitFile(
|
||||
entryArrayBuffer,
|
||||
entryFileName
|
||||
|
||||
if (arrayBuffer) {
|
||||
files[fileName] = await convertToSigitFile(
|
||||
arrayBuffer,
|
||||
fileName!
|
||||
)
|
||||
const hash = await getHash(entryArrayBuffer)
|
||||
const hash = await getHash(arrayBuffer)
|
||||
|
||||
if (hash) {
|
||||
fileHashes[entryFileName.replace(/^files\//, '')] = hash
|
||||
fileHashes[fileName.replace(/^files\//, '')] = hash
|
||||
}
|
||||
} else {
|
||||
fileHashes[entryFileName.replace(/^files\//, '')] = null
|
||||
fileHashes[fileName.replace(/^files\//, '')] = null
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentFileHashes(fileHashes)
|
||||
setFiles(files)
|
||||
setIsLoading(false)
|
||||
} catch (err) {
|
||||
const message = `error occurred in getting file from ${zipUrl}`
|
||||
console.error(message, err)
|
||||
if (err instanceof Error) toast.error(err.message)
|
||||
else toast.error(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = `error occurred in getting file from ${zipUrl}`
|
||||
console.error(message, err)
|
||||
if (err instanceof Error) toast.error(err.message)
|
||||
else toast.error(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
processSigit()
|
||||
}
|
||||
}, [encryptionKey, metaInNavState, zipUrls])
|
||||
}, [encryptionKey, metaInNavState, zipUrl])
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!selectedFile) return
|
||||
@ -799,15 +770,7 @@ export const VerifyPage = () => {
|
||||
currentFile={currentFile}
|
||||
files={getCurrentUserFiles(files, currentFileHashes, fileHashes)}
|
||||
parsedSignatureEvents={parsedSignatureEvents}
|
||||
noFiles={!zipUrls || zipUrls.length === 0}
|
||||
/>
|
||||
{!zipUrls || zipUrls.length === 0 ? (
|
||||
<Typography textAlign="center">
|
||||
We were not able to retrieve the files.
|
||||
</Typography>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</StickySideColumns>
|
||||
)}
|
||||
</Container>
|
||||
|
@ -8,7 +8,6 @@ export const appPrivateRoutes = {
|
||||
profileSettings: '/settings/profile/:npub',
|
||||
cacheSettings: '/settings/cache',
|
||||
relays: '/settings/relays',
|
||||
servers: '/settings/servers',
|
||||
nostrLogin: '/settings/nostrLogin'
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,6 @@ import { RelaysPage } from '../pages/settings/relays'
|
||||
import { SettingsPage } from '../pages/settings/Settings'
|
||||
import { SignPage } from '../pages/sign'
|
||||
import { VerifyPage } from '../pages/verify'
|
||||
import { ServersPage } from '../pages/settings/servers'
|
||||
|
||||
/**
|
||||
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
|
||||
@ -97,10 +96,6 @@ export const privateRoutes = [
|
||||
path: appPrivateRoutes.relays,
|
||||
element: <RelaysPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.servers,
|
||||
element: <ServersPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.nostrLogin,
|
||||
element: <NostrLoginPage />
|
||||
|
@ -1,54 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import { ILocalConfig } from '../../types/config.ts'
|
||||
|
||||
class LocalConfig {
|
||||
// Static property to hold the single instance of LocalCache
|
||||
private static instance: LocalConfig | null = null
|
||||
|
||||
private config: ILocalConfig
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
// Set default config
|
||||
this.config = {
|
||||
SIGIT_BLOSSOM: 'https://blossom.sigit.io'
|
||||
}
|
||||
}
|
||||
|
||||
// Method to initialize the database
|
||||
private async init() {
|
||||
axios
|
||||
.get<ILocalConfig>('/config.json')
|
||||
.then((response) => {
|
||||
console.log('response', response)
|
||||
|
||||
if (typeof response.data === 'object') {
|
||||
this.config = response.data
|
||||
} else {
|
||||
throw 'Failed to load config.json: File not found'
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load config.json:', error)
|
||||
console.warn('Default config will be used.')
|
||||
})
|
||||
}
|
||||
|
||||
// Static method to get the single instance of LocalCache
|
||||
public static getInstance(): LocalConfig {
|
||||
// If the instance doesn't exist, create it
|
||||
if (!LocalConfig.instance) {
|
||||
LocalConfig.instance = new LocalConfig()
|
||||
LocalConfig.instance.init()
|
||||
}
|
||||
// Return the single instance of LocalCache
|
||||
return LocalConfig.instance
|
||||
}
|
||||
|
||||
public getConfig() {
|
||||
return this.config
|
||||
}
|
||||
}
|
||||
|
||||
// Export the single instance of LocalCache
|
||||
export const localConfig = LocalConfig.getInstance()
|
@ -7,13 +7,10 @@ export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
|
||||
export const UPDATE_NOSTR_LOGIN_AUTH_METHOD = 'UPDATE_NOSTR_LOGIN_AUTH_METHOD'
|
||||
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
|
||||
|
||||
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'
|
||||
export const SET_USER_PROFILE = 'SET_USER_PROFILE'
|
||||
|
||||
export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE'
|
||||
|
||||
export const SET_SERVER_MAP = 'SET_SERVER_MAP'
|
||||
export const SET_SERVER_MAP_UPDATED = 'SET_SERVER_MAP_UPDATED'
|
||||
|
||||
export const SET_RELAY_MAP = 'SET_RELAY_MAP'
|
||||
export const SET_RELAY_INFO = 'SET_RELAY_INFO'
|
||||
export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED'
|
||||
|
@ -2,7 +2,7 @@ import * as ActionTypes from './actionTypes'
|
||||
import { State } from './rootReducer'
|
||||
|
||||
export * from './auth/action'
|
||||
export * from './metadata/action'
|
||||
export * from './user/action'
|
||||
export * from './relays/action'
|
||||
export * from './userAppData/action'
|
||||
|
||||
|
@ -1,8 +0,0 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { SetMetadataEvent } from './types'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
export const setMetadataEvent = (payload: Event): SetMetadataEvent => ({
|
||||
type: ActionTypes.SET_METADATA_EVENT,
|
||||
payload
|
||||
})
|
@ -1,25 +0,0 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { MetadataDispatchTypes } from './types'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
const initialState: Event | null = null
|
||||
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: MetadataDispatchTypes
|
||||
): Event | null => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_METADATA_EVENT:
|
||||
return {
|
||||
...action.payload
|
||||
}
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.metadata || initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer
|
@ -1,10 +0,0 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { RestoreState } from '../actions'
|
||||
|
||||
export interface SetMetadataEvent {
|
||||
type: typeof ActionTypes.SET_METADATA_EVENT
|
||||
payload: Event
|
||||
}
|
||||
|
||||
export type MetadataDispatchTypes = SetMetadataEvent | RestoreState
|
@ -1,43 +1,32 @@
|
||||
import { Event } from 'nostr-tools'
|
||||
import { combineReducers } from 'redux'
|
||||
import { UserAppData } from '../types'
|
||||
import * as ActionTypes from './actionTypes'
|
||||
import authReducer from './auth/reducer'
|
||||
import { AuthDispatchTypes, AuthState } from './auth/types'
|
||||
import metadataReducer from './metadata/reducer'
|
||||
import userReducer from './user/reducer'
|
||||
import relaysReducer from './relays/reducer'
|
||||
import { RelaysDispatchTypes, RelaysState } from './relays/types'
|
||||
import UserAppDataReducer from './userAppData/reducer'
|
||||
import userRobotImageReducer from './userRobotImage/reducer'
|
||||
import serversReducer from './servers/reducer'
|
||||
import { MetadataDispatchTypes } from './metadata/types'
|
||||
import { UserAppDataDispatchTypes } from './userAppData/types'
|
||||
import { UserRobotImageDispatchTypes } from './userRobotImage/types'
|
||||
import { ServersDispatchTypes, ServersState } from './servers/types.ts'
|
||||
import { UserDispatchTypes, UserState } from './user/types'
|
||||
|
||||
export interface State {
|
||||
auth: AuthState
|
||||
metadata?: Event
|
||||
userRobotImage?: string
|
||||
user: UserState
|
||||
relays: RelaysState
|
||||
servers: ServersState
|
||||
userAppData?: UserAppData
|
||||
}
|
||||
|
||||
type AppActions =
|
||||
| AuthDispatchTypes
|
||||
| MetadataDispatchTypes
|
||||
| UserRobotImageDispatchTypes
|
||||
| UserDispatchTypes
|
||||
| RelaysDispatchTypes
|
||||
| ServersDispatchTypes
|
||||
| UserAppDataDispatchTypes
|
||||
|
||||
export const appReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
metadata: metadataReducer,
|
||||
userRobotImage: userRobotImageReducer,
|
||||
user: userReducer,
|
||||
relays: relaysReducer,
|
||||
servers: serversReducer,
|
||||
userAppData: UserAppDataReducer
|
||||
})
|
||||
|
||||
|
@ -1,12 +0,0 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { SetServerMapAction, SetServerMapUpdatedAction } from './types'
|
||||
import { ServerMap } from '../../types'
|
||||
|
||||
export const setServerMapAction = (payload: ServerMap): SetServerMapAction => ({
|
||||
type: ActionTypes.SET_SERVER_MAP,
|
||||
payload
|
||||
})
|
||||
|
||||
export const setServerMapUpdatedAction = (): SetServerMapUpdatedAction => ({
|
||||
type: ActionTypes.SET_SERVER_MAP_UPDATED
|
||||
})
|
@ -1,28 +0,0 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { ServersDispatchTypes, ServersState } from './types'
|
||||
|
||||
const initialState: ServersState = {
|
||||
map: undefined,
|
||||
mapUpdated: undefined
|
||||
}
|
||||
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: ServersDispatchTypes
|
||||
): ServersState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_SERVER_MAP:
|
||||
return { ...state, map: action.payload, mapUpdated: Date.now() }
|
||||
|
||||
case ActionTypes.SET_SERVER_MAP_UPDATED:
|
||||
return { ...state, mapUpdated: Date.now() }
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.servers || initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer
|
@ -1,22 +0,0 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { RestoreState } from '../actions'
|
||||
import { ServerMap } from '../../types'
|
||||
|
||||
export type ServersState = {
|
||||
map?: ServerMap
|
||||
mapUpdated?: number
|
||||
}
|
||||
|
||||
export interface SetServerMapAction {
|
||||
type: typeof ActionTypes.SET_SERVER_MAP
|
||||
payload: ServerMap
|
||||
}
|
||||
|
||||
export interface SetServerMapUpdatedAction {
|
||||
type: typeof ActionTypes.SET_SERVER_MAP_UPDATED
|
||||
}
|
||||
|
||||
export type ServersDispatchTypes =
|
||||
| SetServerMapAction
|
||||
| SetServerMapUpdatedAction
|
||||
| RestoreState
|
17
src/store/user/action.ts
Normal file
17
src/store/user/action.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { SetUserProfile, SetUserRobotImage } from './types'
|
||||
|
||||
export const setUserRobotImage = (
|
||||
payload: string | null
|
||||
): SetUserRobotImage => ({
|
||||
type: ActionTypes.SET_USER_ROBOT_IMAGE,
|
||||
payload
|
||||
})
|
||||
|
||||
export const setUserProfile = (
|
||||
payload: NDKUserProfile | null
|
||||
): SetUserProfile => ({
|
||||
type: ActionTypes.SET_USER_PROFILE,
|
||||
payload
|
||||
})
|
34
src/store/user/reducer.ts
Normal file
34
src/store/user/reducer.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { UserDispatchTypes, UserState } from './types'
|
||||
|
||||
const initialState: UserState = {
|
||||
robotImage: null,
|
||||
profile: null
|
||||
}
|
||||
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: UserDispatchTypes
|
||||
): UserState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_USER_ROBOT_IMAGE:
|
||||
return {
|
||||
...state,
|
||||
robotImage: action.payload
|
||||
}
|
||||
|
||||
case ActionTypes.SET_USER_PROFILE:
|
||||
return {
|
||||
...state,
|
||||
profile: action.payload
|
||||
}
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.user || initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer
|
23
src/store/user/types.ts
Normal file
23
src/store/user/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { RestoreState } from '../actions'
|
||||
|
||||
export interface UserState {
|
||||
robotImage: string | null
|
||||
profile: NDKUserProfile | null
|
||||
}
|
||||
|
||||
export interface SetUserRobotImage {
|
||||
type: typeof ActionTypes.SET_USER_ROBOT_IMAGE
|
||||
payload: string | null
|
||||
}
|
||||
|
||||
export interface SetUserProfile {
|
||||
type: typeof ActionTypes.SET_USER_PROFILE
|
||||
payload: NDKUserProfile | null
|
||||
}
|
||||
|
||||
export type UserDispatchTypes =
|
||||
| SetUserRobotImage
|
||||
| SetUserProfile
|
||||
| RestoreState
|
@ -5,7 +5,7 @@ import { UserAppDataDispatchTypes } from './types'
|
||||
const initialState: UserAppData = {
|
||||
sigits: {},
|
||||
processedGiftWraps: [],
|
||||
blossomVersions: []
|
||||
blossomUrls: []
|
||||
}
|
||||
|
||||
const reducer = (
|
||||
|
@ -1,9 +0,0 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { SetUserRobotImage } from './types'
|
||||
|
||||
export const setUserRobotImage = (
|
||||
payload: string | null
|
||||
): SetUserRobotImage => ({
|
||||
type: ActionTypes.SET_USER_ROBOT_IMAGE,
|
||||
payload
|
||||
})
|
@ -1,22 +0,0 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { UserRobotImageDispatchTypes } from './types'
|
||||
|
||||
const initialState: string | null = null
|
||||
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: UserRobotImageDispatchTypes
|
||||
): string | null | undefined => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_USER_ROBOT_IMAGE:
|
||||
return action.payload
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.userRobotImage || initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer
|
@ -1,9 +0,0 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { RestoreState } from '../actions'
|
||||
|
||||
export interface SetUserRobotImage {
|
||||
type: typeof ActionTypes.SET_USER_ROBOT_IMAGE
|
||||
payload: string | null
|
||||
}
|
||||
|
||||
export type UserRobotImageDispatchTypes = SetUserRobotImage | RestoreState
|
@ -1,3 +0,0 @@
|
||||
export interface ILocalConfig {
|
||||
SIGIT_BLOSSOM: string
|
||||
}
|
@ -27,7 +27,7 @@ export interface CreateSignatureEventContent {
|
||||
fileHashes: { [key: string]: string }
|
||||
markConfig: Mark[]
|
||||
title: string
|
||||
zipUrls: string[]
|
||||
zipUrl: string
|
||||
}
|
||||
|
||||
export interface SignedEventContent {
|
||||
@ -75,14 +75,9 @@ export interface UserAppData {
|
||||
*/
|
||||
keyPair?: Keys
|
||||
/**
|
||||
* Array for storing Urls for the files which stores all sigits and processedGiftWraps on file servers (blossom).
|
||||
* We keep the last 10 versions
|
||||
* Array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom.
|
||||
*/
|
||||
blossomVersions: BlossomVersion[]
|
||||
}
|
||||
|
||||
export interface BlossomVersion {
|
||||
urls: string[]
|
||||
blossomUrls: string[]
|
||||
}
|
||||
|
||||
export interface DocSignatureEvent extends Event {
|
||||
@ -90,10 +85,10 @@ export interface DocSignatureEvent extends Event {
|
||||
}
|
||||
|
||||
export interface SigitNotification {
|
||||
metaUrls: string[]
|
||||
metaUrl: string
|
||||
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
|
||||
}
|
||||
|
||||
export function isSigitNotification(obj: unknown): obj is SigitNotification {
|
||||
return typeof (obj as SigitNotification).metaUrls === 'object'
|
||||
return typeof (obj as SigitNotification).metaUrl === 'string'
|
||||
}
|
||||
|
@ -6,8 +6,7 @@ export enum MetaStorageErrorType {
|
||||
'FETCH_FAILED' = 'Fetching meta.json requires an encryption key.',
|
||||
'HASH_VERIFICATION_FAILED' = 'Unable to verify meta.json.',
|
||||
'DECRYPTION_FAILED' = 'Error decryping meta.json.',
|
||||
'UNHANDLED_FETCH_ERROR' = 'Unable to fetch meta.json. Something went wrong.',
|
||||
'NO_URLS_PROCESSED_SUCCESSFULLY' = 'No URLs were available to process.'
|
||||
'UNHANDLED_FETCH_ERROR' = 'Unable to fetch meta.json. Something went wrong.'
|
||||
}
|
||||
|
||||
export class MetaStorageError extends Error {
|
||||
|
@ -1,14 +0,0 @@
|
||||
export type FileServerMap = {
|
||||
[key: string]: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileServerPutResponse {
|
||||
sha256: string
|
||||
size: number
|
||||
uploaded: number
|
||||
type: string
|
||||
url: string
|
||||
}
|
@ -1,9 +1,6 @@
|
||||
export * from './cache'
|
||||
export * from './core'
|
||||
export * from './nostr'
|
||||
export * from './profile'
|
||||
export * from './relay'
|
||||
export * from './zip'
|
||||
export * from './event'
|
||||
export * from './server'
|
||||
export * from './file-server.ts'
|
||||
|
@ -1,12 +0,0 @@
|
||||
export interface ProfileMetadata {
|
||||
name?: string
|
||||
display_name?: string
|
||||
/** @deprecated use name instead */
|
||||
username?: string
|
||||
picture?: string
|
||||
banner?: string
|
||||
about?: string
|
||||
website?: string
|
||||
nip05?: string
|
||||
lud16?: string
|
||||
}
|
@ -1,3 +1,9 @@
|
||||
export enum UserRelaysType {
|
||||
Read = 'readRelayUrls',
|
||||
Write = 'writeRelayUrls',
|
||||
Both = 'bothRelayUrls'
|
||||
}
|
||||
|
||||
export interface RelaySet {
|
||||
read: string[]
|
||||
write: string[]
|
||||
|
@ -1,6 +0,0 @@
|
||||
export type ServerMap = {
|
||||
[key: string]: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
}
|
44
src/utils/auth.ts
Normal file
44
src/utils/auth.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Event } from 'nostr-tools'
|
||||
import { SignedEvent } from '../types'
|
||||
import { saveAuthToken } from './localStorage'
|
||||
|
||||
export const base64EncodeSignedEvent = (event: SignedEvent) => {
|
||||
try {
|
||||
const authEventSerialized = JSON.stringify(event)
|
||||
const token = btoa(authEventSerialized)
|
||||
return token
|
||||
} catch (error) {
|
||||
throw new Error('An error occurred in JSON.stringify of signedAuthEvent')
|
||||
}
|
||||
}
|
||||
|
||||
export const base64DecodeAuthToken = (authToken: string): SignedEvent => {
|
||||
const decodedToken = atob(authToken)
|
||||
|
||||
try {
|
||||
const signedEvent = JSON.parse(decodedToken)
|
||||
return signedEvent
|
||||
} catch (error) {
|
||||
throw new Error('An error occurred in JSON.parse of the auth token')
|
||||
}
|
||||
}
|
||||
|
||||
export const createAndSaveAuthToken = (signedAuthEvent: SignedEvent) => {
|
||||
const base64Encoded = base64EncodeSignedEvent(signedAuthEvent)
|
||||
|
||||
// save newly created auth token (base64 nostr signed event) in local storage along with expiry time
|
||||
saveAuthToken(base64Encoded)
|
||||
return base64Encoded
|
||||
}
|
||||
|
||||
export const getEmptyMetadataEvent = (pubkey?: string): Event => {
|
||||
return {
|
||||
content: '',
|
||||
created_at: new Date().valueOf(),
|
||||
id: '',
|
||||
kind: 0,
|
||||
pubkey: pubkey || '',
|
||||
sig: '',
|
||||
tags: []
|
||||
}
|
||||
}
|
@ -1,12 +1,7 @@
|
||||
import { localConfig } from '../services/config'
|
||||
import { ILocalConfig } from '../types/config.ts'
|
||||
|
||||
export const EMPTY: string = ''
|
||||
export const ARRAY_BUFFER = 'arraybuffer'
|
||||
export const DEFLATE = 'DEFLATE'
|
||||
|
||||
const config: ILocalConfig = localConfig.getConfig()
|
||||
|
||||
/**
|
||||
* Number of milliseconds in one week.
|
||||
*/
|
||||
@ -19,7 +14,7 @@ export const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export const SIGIT_RELAY = 'wss://relay.sigit.io'
|
||||
|
||||
export const SIGIT_BLOSSOM = config.SIGIT_BLOSSOM
|
||||
export const SIGIT_BLOSSOM = 'https://blossom.sigit.io'
|
||||
|
||||
export const DEFAULT_LOOK_UP_RELAY_LIST = [
|
||||
SIGIT_RELAY,
|
||||
@ -27,8 +22,6 @@ export const DEFAULT_LOOK_UP_RELAY_LIST = [
|
||||
'wss://purplepag.es'
|
||||
]
|
||||
|
||||
export const MAXIMUM_BLOSSOMS_LENGTH = 3
|
||||
|
||||
// Uses the https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types list
|
||||
// Updated on 2024/08/22
|
||||
export const MOST_COMMON_MEDIA_TYPES = new Map([
|
||||
|
228
src/utils/dvm.ts
228
src/utils/dvm.ts
@ -1,228 +0,0 @@
|
||||
import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools'
|
||||
import { compareObjects, queryNip05, unixNow } from '.'
|
||||
import {
|
||||
MetadataController,
|
||||
NostrController,
|
||||
relayController
|
||||
} from '../controllers'
|
||||
import { NostrJoiningBlock, RelayInfoObject } from '../types'
|
||||
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
|
||||
import store from '../store/store'
|
||||
import { setRelayInfoAction } from '../store/actions'
|
||||
|
||||
export const getNostrJoiningBlockNumber = async (
|
||||
hexKey: string
|
||||
): Promise<NostrJoiningBlock | null> => {
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const relaySet = await metadataController.findRelayListMetadata(hexKey)
|
||||
|
||||
const userRelays: string[] = []
|
||||
|
||||
// find user's relays
|
||||
if (relaySet.write.length > 0) {
|
||||
userRelays.push(...relaySet.write)
|
||||
} else {
|
||||
const metadata = await metadataController.findMetadata(hexKey)
|
||||
if (!metadata) return null
|
||||
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(metadata)
|
||||
|
||||
if (metadataContent?.nip05) {
|
||||
const nip05Profile = await queryNip05(metadataContent.nip05)
|
||||
|
||||
if (nip05Profile && nip05Profile.pubkey === hexKey) {
|
||||
userRelays.push(...nip05Profile.relays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userRelays.length === 0) return null
|
||||
|
||||
// filter for finding user's first kind 0 event
|
||||
const eventFilter: Filter = {
|
||||
kinds: [kinds.Metadata],
|
||||
authors: [hexKey]
|
||||
}
|
||||
|
||||
// find user's kind 0 event published on user's relays
|
||||
const event = await relayController.fetchEvent(eventFilter, userRelays)
|
||||
|
||||
if (event) {
|
||||
const { created_at } = event
|
||||
|
||||
// initialize job request
|
||||
const jobEventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: unixNow(),
|
||||
kind: 68001,
|
||||
tags: [
|
||||
['i', `${created_at * 1000}`],
|
||||
['j', 'blockChain-block-number']
|
||||
]
|
||||
}
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
// sign job request event
|
||||
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
|
||||
|
||||
const relays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relayable.org'
|
||||
]
|
||||
|
||||
await relayController.publish(jobSignedEvent, relays).catch((err) => {
|
||||
console.error(
|
||||
'Error occurred in publish blockChain-block-number DVM job',
|
||||
err
|
||||
)
|
||||
})
|
||||
|
||||
const subscribeWithTimeout = (
|
||||
subscription: NDKSubscription,
|
||||
timeoutMs: number
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const eventHandler = (event: NDKEvent) => {
|
||||
subscription.stop()
|
||||
resolve(event.content)
|
||||
}
|
||||
|
||||
subscription.on('event', eventHandler)
|
||||
|
||||
// Set up a timeout to stop the subscription after a specified time
|
||||
const timeout = setTimeout(() => {
|
||||
subscription.stop() // Stop the subscription
|
||||
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
|
||||
}, timeoutMs)
|
||||
|
||||
// Handle subscription close event
|
||||
subscription.on('close', () => clearTimeout(timeout))
|
||||
})
|
||||
}
|
||||
|
||||
const dvmNDK = new NDK({
|
||||
explicitRelayUrls: relays
|
||||
})
|
||||
|
||||
await dvmNDK.connect(2000)
|
||||
|
||||
// filter for getting DVM job's result
|
||||
const sub = dvmNDK.subscribe({
|
||||
kinds: [68002 as number],
|
||||
'#e': [jobSignedEvent.id],
|
||||
'#p': [jobSignedEvent.pubkey]
|
||||
})
|
||||
|
||||
// asynchronously get block number from dvm job with 20 seconds timeout
|
||||
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
|
||||
|
||||
const encodedEventPointer = nip19.neventEncode({
|
||||
id: event.id,
|
||||
relays: userRelays,
|
||||
author: event.pubkey,
|
||||
kind: event.kind
|
||||
})
|
||||
|
||||
return {
|
||||
block: parseInt(dvmJobResult),
|
||||
encodedEventPointer
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets information about relays into relays.info app state.
|
||||
* @param relayURIs - relay URIs to get information about
|
||||
*/
|
||||
export const getRelayInfo = async (relayURIs: string[]) => {
|
||||
// initialize job request
|
||||
const jobEventTemplate: EventTemplate = {
|
||||
content: '',
|
||||
created_at: unixNow(),
|
||||
kind: 68001,
|
||||
tags: [
|
||||
['i', `${JSON.stringify(relayURIs)}`],
|
||||
['j', 'relay-info']
|
||||
]
|
||||
}
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
// sign job request event
|
||||
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
|
||||
|
||||
const relays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relayable.org'
|
||||
]
|
||||
|
||||
// publish job request
|
||||
await relayController.publish(jobSignedEvent, relays)
|
||||
|
||||
console.log('jobSignedEvent :>> ', jobSignedEvent)
|
||||
|
||||
const subscribeWithTimeout = (
|
||||
subscription: NDKSubscription,
|
||||
timeoutMs: number
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const eventHandler = (event: NDKEvent) => {
|
||||
subscription.stop()
|
||||
resolve(event.content)
|
||||
}
|
||||
|
||||
subscription.on('event', eventHandler)
|
||||
|
||||
// Set up a timeout to stop the subscription after a specified time
|
||||
const timeout = setTimeout(() => {
|
||||
subscription.stop() // Stop the subscription
|
||||
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
|
||||
}, timeoutMs)
|
||||
|
||||
// Handle subscription close event
|
||||
subscription.on('close', () => clearTimeout(timeout))
|
||||
})
|
||||
}
|
||||
|
||||
const dvmNDK = new NDK({
|
||||
explicitRelayUrls: relays
|
||||
})
|
||||
|
||||
await dvmNDK.connect(2000)
|
||||
|
||||
// filter for getting DVM job's result
|
||||
const sub = dvmNDK.subscribe({
|
||||
kinds: [68002 as number],
|
||||
'#e': [jobSignedEvent.id],
|
||||
'#p': [jobSignedEvent.pubkey]
|
||||
})
|
||||
|
||||
// asynchronously get block number from dvm job with 20 seconds timeout
|
||||
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
|
||||
|
||||
if (!dvmJobResult) {
|
||||
return Promise.reject(`Relay(s) information wasn't received`)
|
||||
}
|
||||
|
||||
let relaysInfo: RelayInfoObject
|
||||
|
||||
try {
|
||||
relaysInfo = JSON.parse(dvmJobResult)
|
||||
} catch (error) {
|
||||
return Promise.reject(`Invalid relay(s) information.`)
|
||||
}
|
||||
|
||||
if (
|
||||
relaysInfo &&
|
||||
!compareObjects(store.getState().relays?.info, relaysInfo)
|
||||
) {
|
||||
store.dispatch(setRelayInfoAction(relaysInfo))
|
||||
}
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
import { FileServerMap } from '../types'
|
||||
import { NostrController, relayController } from '../controllers'
|
||||
import { DEFAULT_LOOK_UP_RELAY_LIST, SIGIT_BLOSSOM } from './const.ts'
|
||||
import { unixNow } from './nostr.ts'
|
||||
import { Filter, UnsignedEvent, kinds } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Fetches the relays to get preferred file servers for the given npub
|
||||
* @param npub hex pubkey
|
||||
*/
|
||||
const getFileServerMap = async (
|
||||
npub: string
|
||||
): Promise<{ map: FileServerMap; mapUpdated?: number }> => {
|
||||
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/96.md
|
||||
const eventFilter: Filter = {
|
||||
kinds: [kinds.FileServerPreference],
|
||||
authors: [npub]
|
||||
}
|
||||
|
||||
const event = await relayController
|
||||
.fetchEvent(eventFilter, DEFAULT_LOOK_UP_RELAY_LIST)
|
||||
.catch((err) => {
|
||||
return Promise.reject(err)
|
||||
})
|
||||
|
||||
if (event) {
|
||||
// Handle found event 10096
|
||||
const fileServersMap: FileServerMap = {}
|
||||
|
||||
const serverTags = event.tags.filter((tag) => tag[0] === 'server')
|
||||
|
||||
serverTags.forEach((tag) => {
|
||||
const url = tag[1]
|
||||
const serverType = tag[2]
|
||||
|
||||
// if 3rd element of server tag is undefined, server is WRITE and READ
|
||||
fileServersMap[url] = {
|
||||
write: serverType ? serverType === 'write' : true,
|
||||
read: serverType ? serverType === 'read' : true
|
||||
}
|
||||
})
|
||||
|
||||
return Promise.resolve({
|
||||
map: fileServersMap,
|
||||
mapUpdated: event.created_at
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve({ map: getDefaultFileServerMap() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a preferred file servers list for the given npub
|
||||
* @param serverMap list of preferred servers
|
||||
* @param npub hex pubkey
|
||||
* @param extraRelaysToPublish additional relay on which to publish
|
||||
*/
|
||||
const publishFileServer = async (
|
||||
serverMap: FileServerMap,
|
||||
npub: string,
|
||||
extraRelaysToPublish?: string[]
|
||||
): Promise<string> => {
|
||||
const timestamp = unixNow()
|
||||
const serverURLs = Object.keys(serverMap)
|
||||
|
||||
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
|
||||
const tags: string[][] = serverURLs.map((serverURL) => {
|
||||
const serverTag = ['server', serverURL]
|
||||
|
||||
return serverTag.filter((value) => value !== '')
|
||||
})
|
||||
|
||||
const newRelayMapEvent: UnsignedEvent = {
|
||||
kind: kinds.FileServerPreference,
|
||||
tags,
|
||||
content: '',
|
||||
pubkey: npub,
|
||||
created_at: timestamp
|
||||
}
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
const signedEvent = await nostrController.signEvent(newRelayMapEvent)
|
||||
|
||||
let relaysToPublish = serverURLs
|
||||
|
||||
// Add extra relays if provided
|
||||
if (extraRelaysToPublish) {
|
||||
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
|
||||
}
|
||||
|
||||
// If a relay map is empty, use the most popular relay URIs
|
||||
if (!relaysToPublish.length) {
|
||||
relaysToPublish = DEFAULT_LOOK_UP_RELAY_LIST
|
||||
}
|
||||
const publishResult = await relayController.publish(
|
||||
signedEvent,
|
||||
relaysToPublish
|
||||
)
|
||||
|
||||
if (publishResult && publishResult.length) {
|
||||
return Promise.resolve(
|
||||
`Preferred file servers published on: ${publishResult.join('\n')}`
|
||||
)
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
'Publishing updated preferred file servers was unsuccessful.'
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultFileServerMap = (): FileServerMap => ({
|
||||
[SIGIT_BLOSSOM]: { write: true, read: true }
|
||||
})
|
||||
|
||||
export { getFileServerMap, publishFileServer }
|
@ -1,5 +1,6 @@
|
||||
export * from './auth'
|
||||
export * from './const'
|
||||
export * from './crypto'
|
||||
export * from './dvm'
|
||||
export * from './hash'
|
||||
export * from './localStorage'
|
||||
export * from './mark'
|
||||
@ -11,4 +12,3 @@ export * from './string'
|
||||
export * from './url'
|
||||
export * from './utils'
|
||||
export * from './zip'
|
||||
export * from './const'
|
||||
|
@ -167,72 +167,47 @@ export const uploadMetaToFileStorage = async (
|
||||
|
||||
// Create the encrypted json file from array buffer and hash
|
||||
const file = new File([encryptedArrayBuffer], `${hash}.json`)
|
||||
const urls = await uploadToFileStorage(file)
|
||||
const url = await uploadToFileStorage(file)
|
||||
|
||||
return urls
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the meta from one or more file storages, one by one, and it will take the first one, which has matching hash
|
||||
* @param urls urls of meta files
|
||||
* @param encryptionKey
|
||||
*/
|
||||
export const fetchMetaFromFileStorage = async (
|
||||
urls: string[],
|
||||
url: string,
|
||||
encryptionKey: string | undefined
|
||||
): Promise<Meta> => {
|
||||
) => {
|
||||
if (!encryptionKey) {
|
||||
throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED)
|
||||
}
|
||||
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const url = urls[i]
|
||||
const isLastUrl = i === urls.length - 1
|
||||
const encryptedArrayBuffer = await axios.get(url, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
const encryptedArrayBuffer = await axios.get(url, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
|
||||
// Verify hash
|
||||
const parts = url.split('/')
|
||||
const urlHash = parts[parts.length - 1]
|
||||
const hash = await getHash(encryptedArrayBuffer.data)
|
||||
if (hash !== urlHash) {
|
||||
// If no more urls left to try and hash check failed, throw an error
|
||||
if (isLastUrl)
|
||||
throw new MetaStorageError(
|
||||
MetaStorageErrorType.HASH_VERIFICATION_FAILED
|
||||
)
|
||||
// Otherwise, skip to the next url to fetch
|
||||
continue
|
||||
}
|
||||
|
||||
const arrayBuffer = await decryptArrayBuffer(
|
||||
encryptedArrayBuffer.data,
|
||||
encryptionKey
|
||||
).catch((err) => {
|
||||
if (isLastUrl) {
|
||||
throw new MetaStorageError(MetaStorageErrorType.DECRYPTION_FAILED, {
|
||||
cause: err
|
||||
})
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
if (arrayBuffer) {
|
||||
// Decode meta.json and parse
|
||||
const decoder = new TextDecoder()
|
||||
const json = decoder.decode(arrayBuffer)
|
||||
const meta = await parseJson<Meta>(json)
|
||||
return meta
|
||||
} else if (!isLastUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR)
|
||||
// Verify hash
|
||||
const parts = url.split('/')
|
||||
const urlHash = parts[parts.length - 1]
|
||||
const hash = await getHash(encryptedArrayBuffer.data)
|
||||
if (hash !== urlHash) {
|
||||
throw new MetaStorageError(MetaStorageErrorType.HASH_VERIFICATION_FAILED)
|
||||
}
|
||||
|
||||
throw new MetaStorageError(
|
||||
MetaStorageErrorType.NO_URLS_PROCESSED_SUCCESSFULLY
|
||||
)
|
||||
const arrayBuffer = await decryptArrayBuffer(
|
||||
encryptedArrayBuffer.data,
|
||||
encryptionKey
|
||||
).catch((err) => {
|
||||
throw new MetaStorageError(MetaStorageErrorType.DECRYPTION_FAILED, {
|
||||
cause: err
|
||||
})
|
||||
})
|
||||
|
||||
if (arrayBuffer) {
|
||||
// Decode meta.json and parse
|
||||
const decoder = new TextDecoder()
|
||||
const json = decoder.decode(arrayBuffer)
|
||||
const meta = await parseJson<Meta>(json)
|
||||
return meta
|
||||
}
|
||||
|
||||
throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import axios, { AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
@ -11,11 +11,7 @@ import {
|
||||
import { toast } from 'react-toastify'
|
||||
import { NostrController } from '../controllers'
|
||||
import store from '../store/store'
|
||||
import {
|
||||
CreateSignatureEventContent,
|
||||
FileServerPutResponse,
|
||||
Meta
|
||||
} from '../types'
|
||||
import { CreateSignatureEventContent, Meta } from '../types'
|
||||
import { hexToNpub, unixNow } from './nostr'
|
||||
import { parseJson } from './string'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
@ -23,23 +19,18 @@ import { getHash } from './hash.ts'
|
||||
import { SIGIT_BLOSSOM } from './const.ts'
|
||||
|
||||
/**
|
||||
* Uploads a file to one or more file storage services.
|
||||
* Uploads a file to a file storage service.
|
||||
* @param blob The Blob object representing the file to upload.
|
||||
* @param nostrController The NostrController instance for handling authentication.
|
||||
* @returns The array of URLs of the uploaded file.
|
||||
* @returns The URL of the uploaded file.
|
||||
*/
|
||||
export const uploadToFileStorage = async (file: File): Promise<string[]> => {
|
||||
export const uploadToFileStorage = async (file: File) => {
|
||||
// Define event metadata for authorization
|
||||
const hash = await getHash(await file.arrayBuffer())
|
||||
if (!hash) {
|
||||
throw new Error("Can't get file hash.")
|
||||
}
|
||||
|
||||
const preferredServersMap = store.getState().servers.map || {}
|
||||
const preferredServers = Object.keys(preferredServersMap)
|
||||
// If no servers found, use SIGIT as fallback
|
||||
if (!preferredServers.length) preferredServers.push(SIGIT_BLOSSOM)
|
||||
|
||||
const event: EventTemplate = {
|
||||
kind: 24242,
|
||||
content: 'Authorize Upload',
|
||||
@ -63,28 +54,16 @@ export const uploadToFileStorage = async (file: File): Promise<string[]> => {
|
||||
// Sign the authorization event using the dedicated key stored in user app data
|
||||
const authEvent = finalizeEvent(event, hexToBytes(key))
|
||||
|
||||
const uploadPromises: Promise<AxiosResponse<FileServerPutResponse>>[] = []
|
||||
// Upload the file to the file storage service using Axios
|
||||
const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, {
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header
|
||||
'Content-Type': 'application/sigit' // Set content type header
|
||||
}
|
||||
})
|
||||
|
||||
// Upload the file to the file storage services using Axios
|
||||
for (const preferredServer of preferredServers) {
|
||||
const uploadPromise = axios.put<FileServerPutResponse>(
|
||||
`${preferredServer}/upload`,
|
||||
file,
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header
|
||||
'Content-Type': 'application/sigit' // Set content type header
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
uploadPromises.push(uploadPromise)
|
||||
}
|
||||
|
||||
const responses = await Promise.all(uploadPromises)
|
||||
|
||||
// Return the URLs of the uploaded files
|
||||
return responses.map((response) => response.data.url) as string[]
|
||||
// Return the URL of the uploaded file
|
||||
return response.data.url as string
|
||||
}
|
||||
|
||||
/**
|
||||
@ -249,7 +228,7 @@ export const extractZipUrlAndEncryptionKey = async (meta: Meta) => {
|
||||
if (!createSignatureContent) return null
|
||||
|
||||
// Extract the ZIP URL from the create signature content
|
||||
const zipUrls = createSignatureContent.zipUrls
|
||||
const zipUrl = createSignatureContent.zipUrl
|
||||
|
||||
// Retrieve the user's public key from the state
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
@ -280,7 +259,7 @@ export const extractZipUrlAndEncryptionKey = async (meta: Meta) => {
|
||||
return {
|
||||
createSignatureEvent,
|
||||
createSignatureContent,
|
||||
zipUrls: zipUrls,
|
||||
zipUrl,
|
||||
encryptionKey: decrypted
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,15 @@
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import axios, { AxiosResponse } from 'axios'
|
||||
import _, { truncate } from 'lodash'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
import axios from 'axios'
|
||||
import { truncate } from 'lodash'
|
||||
import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
Filter,
|
||||
UnsignedEvent,
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getEventHash,
|
||||
getPublicKey,
|
||||
kinds,
|
||||
nip04,
|
||||
nip19,
|
||||
nip44,
|
||||
@ -18,38 +17,16 @@ import {
|
||||
} from 'nostr-tools'
|
||||
import { toast } from 'react-toastify'
|
||||
import { NIP05_REGEX } from '../constants'
|
||||
import {
|
||||
MetadataController,
|
||||
NostrController,
|
||||
relayController
|
||||
} from '../controllers'
|
||||
import {
|
||||
updateProcessedGiftWraps,
|
||||
updateUserAppData as updateUserAppDataAction
|
||||
} from '../store/actions'
|
||||
import { Keys } from '../store/auth/types'
|
||||
import store from '../store/store'
|
||||
import {
|
||||
BlossomVersion,
|
||||
FileServerPutResponse,
|
||||
isSigitNotification,
|
||||
Meta,
|
||||
ProfileMetadata,
|
||||
SigitNotification,
|
||||
SignedEvent,
|
||||
UserAppData
|
||||
} from '../types'
|
||||
import { getDefaultRelayMap } from './relays'
|
||||
import { parseJson, removeLeadingSlash } from './string'
|
||||
import { timeout } from './utils'
|
||||
import { getHash } from './hash'
|
||||
import { Meta, SignedEvent } from '../types'
|
||||
import { SIGIT_BLOSSOM } from './const.ts'
|
||||
import { fetchMetaFromFileStorage } from './meta.ts'
|
||||
import { getHash } from './hash'
|
||||
import { parseJson, removeLeadingSlash } from './string'
|
||||
|
||||
/**
|
||||
* Generates a `d` tag for userAppData
|
||||
*/
|
||||
const getDTagForUserAppData = async (): Promise<string | null> => {
|
||||
export const getDTagForUserAppData = async (): Promise<string | null> => {
|
||||
const isLoggedIn = store.getState().auth.loggedIn
|
||||
const pubkey = store.getState().auth?.usersPubkey
|
||||
|
||||
@ -208,27 +185,6 @@ export const queryNip05 = async (
|
||||
}
|
||||
}
|
||||
|
||||
export const base64EncodeSignedEvent = (event: SignedEvent) => {
|
||||
try {
|
||||
const authEventSerialized = JSON.stringify(event)
|
||||
const token = btoa(authEventSerialized)
|
||||
return token
|
||||
} catch (error) {
|
||||
throw new Error('An error occurred in JSON.stringify of signedAuthEvent')
|
||||
}
|
||||
}
|
||||
|
||||
export const base64DecodeAuthToken = (authToken: string): SignedEvent => {
|
||||
const decodedToken = atob(authToken)
|
||||
|
||||
try {
|
||||
const signedEvent = JSON.parse(decodedToken)
|
||||
return signedEvent
|
||||
} catch (error) {
|
||||
throw new Error('An error occurred in JSON.parse of the auth token')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param pubkey in hex or npub format
|
||||
* @returns robohash.org url for the avatar
|
||||
@ -359,339 +315,7 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user application data based on user's public key and stored metadata.
|
||||
*
|
||||
* @returns The user application data or null if an error occurs or no data is found.
|
||||
*/
|
||||
export const getUsersAppData = async (): Promise<UserAppData | null> => {
|
||||
// Get an instance of the NostrController
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
// Initialize an array to hold relay URLs
|
||||
const relays: string[] = []
|
||||
|
||||
// Retrieve the user's public key and relay map from the Redux store
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
|
||||
// Decryption can fail down in the code if extension options changed
|
||||
// Forcefully log out the user if we detect missmatch between pubkeys
|
||||
if (usersPubkey !== (await nostrController.capturePublicKey())) {
|
||||
return null
|
||||
}
|
||||
|
||||
const relayMap = store.getState().relays?.map
|
||||
|
||||
// Check if relayMap is undefined in the Redux store
|
||||
if (!relayMap) {
|
||||
// If relayMap is not present, fetch relay list metadata
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const relaySet = await metadataController
|
||||
.findRelayListMetadata(usersPubkey)
|
||||
.catch((err) => {
|
||||
// Log error and return null if fetching metadata fails
|
||||
console.log(
|
||||
`An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`,
|
||||
err
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
// Return null if metadata retrieval failed
|
||||
if (!relaySet) return null
|
||||
|
||||
// Ensure that the relay list is not empty
|
||||
if (relaySet.write.length === 0) return null
|
||||
|
||||
// Add write relays to the relays array
|
||||
relays.push(...relaySet.write)
|
||||
} else {
|
||||
// If relayMap exists, filter and add write relays from the stored map
|
||||
const writeRelays = Object.keys(relayMap).filter(
|
||||
(key) => relayMap[key].write
|
||||
)
|
||||
relays.push(...writeRelays)
|
||||
}
|
||||
|
||||
// Generate an identifier for the user's nip78
|
||||
const dTag = await getDTagForUserAppData()
|
||||
if (!dTag) return null
|
||||
|
||||
// Define a filter for fetching events
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Application],
|
||||
'#d': [dTag]
|
||||
}
|
||||
|
||||
const encryptedContent = await relayController
|
||||
.fetchEvent(filter, relays)
|
||||
.then((event) => {
|
||||
if (event) return event.content
|
||||
|
||||
// If no event is found, return an empty stringified object
|
||||
return '{}'
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log error and show a toast notification if fetching event fails
|
||||
console.log(`An error occurred in finding kind 30078 event`, err)
|
||||
toast.error(
|
||||
'An error occurred in finding kind 30078 event for data storage'
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
// Return null if encrypted content retrieval fails
|
||||
if (!encryptedContent) return null
|
||||
|
||||
// Handle a case where the encrypted content is an empty object
|
||||
if (encryptedContent === '{}') {
|
||||
// Generate an ephemeral key pair
|
||||
const secret = generateSecretKey()
|
||||
const pubKey = getPublicKey(secret)
|
||||
|
||||
return {
|
||||
sigits: {},
|
||||
processedGiftWraps: [],
|
||||
blossomVersions: [],
|
||||
keyPair: {
|
||||
private: bytesToHex(secret),
|
||||
public: pubKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt the encrypted content
|
||||
const decrypted = await nostrController
|
||||
.nip04Decrypt(usersPubkey, encryptedContent)
|
||||
.catch((err) => {
|
||||
// Log error and show a toast notification if decryption fails
|
||||
console.log('An error occurred while decrypting app data', err)
|
||||
toast.error('An error occurred while decrypting app data')
|
||||
return null
|
||||
})
|
||||
|
||||
// Return null if decryption fails
|
||||
if (!decrypted) return null
|
||||
|
||||
// Parse the decrypted content
|
||||
const parsedContent = await parseJson<{
|
||||
blossomVersions: BlossomVersion[]
|
||||
blossomUrls: string[]
|
||||
keyPair: Keys
|
||||
}>(decrypted).catch((err) => {
|
||||
// Log error and show a toast notification if parsing fails
|
||||
console.log(
|
||||
'An error occurred in parsing the content of kind 30078 event',
|
||||
err
|
||||
)
|
||||
toast.error('An error occurred in parsing the content of kind 30078 event')
|
||||
return null
|
||||
})
|
||||
|
||||
// Return null if parsing fails
|
||||
if (!parsedContent) return null
|
||||
|
||||
// If old property blossomUrls is found, convert it to new appraoch blossomVersions
|
||||
if (parsedContent.blossomUrls) {
|
||||
parsedContent.blossomVersions = parsedContent.blossomUrls.map((url) => {
|
||||
return {
|
||||
urls: [url]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const { blossomVersions, keyPair } = parsedContent
|
||||
|
||||
// Return null if no blossom URLs are found
|
||||
if (blossomVersions.length === 0) return null
|
||||
|
||||
// Fetch additional user app data from the last blossom version urls
|
||||
const dataFromBlossom = await getUserAppDataFromBlossom(
|
||||
blossomVersions[0],
|
||||
keyPair.private
|
||||
)
|
||||
|
||||
// Return null if fetching data from blossom fails
|
||||
if (!dataFromBlossom) return null
|
||||
|
||||
const { sigits, processedGiftWraps } = dataFromBlossom
|
||||
|
||||
// Return the final user application data
|
||||
return {
|
||||
blossomVersions: blossomVersions,
|
||||
keyPair,
|
||||
sigits,
|
||||
processedGiftWraps
|
||||
}
|
||||
}
|
||||
|
||||
export const updateUsersAppData = async (meta: Meta) => {
|
||||
const appData = store.getState().userAppData
|
||||
if (!appData || !appData.keyPair) return null
|
||||
|
||||
const sigits = _.cloneDeep(appData.sigits)
|
||||
|
||||
const createSignatureEvent = await parseJson<Event>(
|
||||
meta.createSignature
|
||||
).catch((err) => {
|
||||
console.log('err in parsing the createSignature event:>> ', err)
|
||||
toast.error(
|
||||
err.message || 'error occurred in parsing the create signature event'
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!createSignatureEvent) return null
|
||||
|
||||
const id = createSignatureEvent.id
|
||||
let isUpdated = false
|
||||
|
||||
// check if sigit already exists
|
||||
if (id in sigits) {
|
||||
// update meta only if incoming meta is more recent
|
||||
// than already existing one
|
||||
const existingMeta = sigits[id]
|
||||
if (existingMeta.modifiedAt < meta.modifiedAt) {
|
||||
sigits[id] = meta
|
||||
isUpdated = true
|
||||
}
|
||||
} else {
|
||||
sigits[id] = meta
|
||||
isUpdated = true
|
||||
}
|
||||
|
||||
if (!isUpdated) return null
|
||||
|
||||
const blossomVersions = [...appData.blossomVersions]
|
||||
|
||||
const newBlossomUrls = await uploadUserAppDataToBlossom(
|
||||
sigits,
|
||||
appData.processedGiftWraps,
|
||||
appData.keyPair.private
|
||||
).catch((err) => {
|
||||
console.log(
|
||||
'An error occurred in uploading user app data file to blossom server',
|
||||
err
|
||||
)
|
||||
toast.error(
|
||||
'An error occurred in uploading user app data file to blossom server'
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!newBlossomUrls) return null
|
||||
|
||||
// insert new server (blossom) urls at the start of the array
|
||||
blossomVersions.unshift({
|
||||
urls: newBlossomUrls
|
||||
})
|
||||
|
||||
// only keep last 10 blossom versions (urls), delete older ones
|
||||
// Every version can be uploaded to multiple servers
|
||||
if (blossomVersions.length > 10) {
|
||||
const versionsToDelete = blossomVersions.splice(10)
|
||||
versionsToDelete.forEach((version) => {
|
||||
for (const url of version.urls) {
|
||||
deleteBlossomFile(url, appData.keyPair!.private).catch((err) => {
|
||||
console.log(
|
||||
`An error occurred while removing an old file of user app data from the file server: ${url}`,
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
|
||||
// encrypt content for storing in kind 30078 event
|
||||
const nostrController = NostrController.getInstance()
|
||||
const encryptedContent = await nostrController
|
||||
.nip04Encrypt(
|
||||
usersPubkey,
|
||||
JSON.stringify({
|
||||
blossomVersions: blossomVersions,
|
||||
keyPair: appData.keyPair
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
console.log(
|
||||
'An error occurred in encryption of content for app data',
|
||||
err
|
||||
)
|
||||
toast.error(
|
||||
err.message || 'An error occurred in encryption of content for app data'
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!encryptedContent) return null
|
||||
|
||||
// generate the identifier for user's appData event
|
||||
const dTag = await getDTagForUserAppData()
|
||||
if (!dTag) return null
|
||||
|
||||
const updatedEvent: UnsignedEvent = {
|
||||
kind: kinds.Application,
|
||||
pubkey: usersPubkey!,
|
||||
created_at: unixNow(),
|
||||
tags: [['d', dTag]],
|
||||
content: encryptedContent
|
||||
}
|
||||
|
||||
const signedEvent = await nostrController
|
||||
.signEvent(updatedEvent)
|
||||
.catch((err) => {
|
||||
console.log('An error occurred in signing event', err)
|
||||
toast.error(err.message || 'An error occurred in signing event')
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) return null
|
||||
|
||||
const relayMap = store.getState().relays.map || getDefaultRelayMap()
|
||||
const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write)
|
||||
|
||||
const publishResult = await Promise.race([
|
||||
relayController.publish(signedEvent, writeRelays),
|
||||
timeout(40 * 1000)
|
||||
]).catch((err) => {
|
||||
console.log('err :>> ', err)
|
||||
if (err.message === 'Timeout') {
|
||||
toast.error('Timeout occurred in publishing updated app data')
|
||||
} else if (Array.isArray(err)) {
|
||||
err.forEach((errResult) => {
|
||||
toast.error(
|
||||
`Publishing to ${errResult.relay} caused the following error: ${errResult.error}`
|
||||
)
|
||||
})
|
||||
} else {
|
||||
toast.error(
|
||||
'An unexpected error occurred in publishing updated app data '
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
if (!publishResult) return null
|
||||
|
||||
// update redux store
|
||||
store.dispatch(
|
||||
updateUserAppDataAction({
|
||||
sigits,
|
||||
blossomVersions: blossomVersions,
|
||||
processedGiftWraps: [...appData.processedGiftWraps],
|
||||
keyPair: {
|
||||
...appData.keyPair
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return signedEvent
|
||||
}
|
||||
|
||||
const deleteBlossomFile = async (url: string, privateKey: string) => {
|
||||
export const deleteBlossomFile = async (url: string, privateKey: string) => {
|
||||
const pathname = new URL(url).pathname
|
||||
const hash = removeLeadingSlash(pathname)
|
||||
|
||||
@ -720,22 +344,17 @@ const deleteBlossomFile = async (url: string, privateKey: string) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to upload user application data to the user preferred File (Blossom) servers.
|
||||
* Function to upload user application data to the Blossom server.
|
||||
* @param sigits - An object containing metadata for the user application data.
|
||||
* @param processedGiftWraps - An array of processed gift wrap IDs.
|
||||
* @param privateKey - The private key used for encryption.
|
||||
* @returns A promise that resolves to the URL of the uploaded file.
|
||||
*/
|
||||
const uploadUserAppDataToBlossom = async (
|
||||
export const uploadUserAppDataToBlossom = async (
|
||||
sigits: { [key: string]: Meta },
|
||||
processedGiftWraps: string[],
|
||||
privateKey: string
|
||||
) => {
|
||||
const preferredServersMap = store.getState().servers.map || {}
|
||||
const preferredServers = Object.keys(preferredServersMap)
|
||||
// If no servers found, use SIGIT as fallback
|
||||
if (!preferredServers.length) preferredServers.push(SIGIT_BLOSSOM)
|
||||
|
||||
// Create an object containing the sigits and processed gift wraps
|
||||
const obj = {
|
||||
sigits,
|
||||
@ -782,48 +401,33 @@ const uploadUserAppDataToBlossom = async (
|
||||
// Finalize the event with the private key
|
||||
const authEvent = finalizeEvent(event, hexToBytes(privateKey))
|
||||
|
||||
const uploadPromises: Promise<AxiosResponse<FileServerPutResponse>>[] = []
|
||||
// Upload the file to the file storage service using Axios
|
||||
const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, {
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header
|
||||
}
|
||||
})
|
||||
|
||||
// Upload the file to the file storage services using Axios
|
||||
for (const preferredServer of preferredServers) {
|
||||
const uploadPromise = axios.put<FileServerPutResponse>(
|
||||
`${preferredServer}/upload`,
|
||||
file,
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
uploadPromises.push(uploadPromise)
|
||||
}
|
||||
|
||||
const responses = await Promise.all(uploadPromises)
|
||||
|
||||
// Return the URLs of the uploaded files
|
||||
return responses.map((response) => response.data.url) as string[]
|
||||
// Return the URL of the uploaded file
|
||||
return response.data.url as string
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to retrieve and decrypt user application data from file (Blossom) servers.
|
||||
* Since we pull from multiple servers, we will take the first one
|
||||
* @param blossomVersion - The URL to fetch the encrypted data from.
|
||||
* Function to retrieve and decrypt user application data from Blossom server.
|
||||
* @param url - The URL to fetch the encrypted data from.
|
||||
* @param privateKey - The private key used for decryption.
|
||||
* @returns A promise that resolves to the decrypted and parsed user application data.
|
||||
*/
|
||||
const getUserAppDataFromBlossom = async (
|
||||
blossomVersion: BlossomVersion,
|
||||
export const getUserAppDataFromBlossom = async (
|
||||
url: string,
|
||||
privateKey: string
|
||||
) => {
|
||||
// Initialize errorCode to track HTTP error codes
|
||||
let errorCode = 0
|
||||
|
||||
const blossomUrl = blossomVersion.urls[0]
|
||||
|
||||
// Fetch the encrypted data from the provided URL
|
||||
const encrypted = await axios
|
||||
.get(blossomUrl, {
|
||||
.get(url, {
|
||||
responseType: 'blob' // Expect a blob response
|
||||
})
|
||||
.then(async (res) => {
|
||||
@ -835,13 +439,8 @@ const getUserAppDataFromBlossom = async (
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log and display an error message if the request fails
|
||||
console.error(
|
||||
`error occurred in getting file from ${blossomVersion}`,
|
||||
err
|
||||
)
|
||||
toast.error(
|
||||
err.message || `error occurred in getting file from ${blossomVersion}`
|
||||
)
|
||||
console.error(`error occurred in getting file from ${url}`, err)
|
||||
toast.error(err.message || `error occurred in getting file from ${url}`)
|
||||
|
||||
// Set errorCode to the HTTP status code if available
|
||||
if (err.request) {
|
||||
@ -891,189 +490,6 @@ const getUserAppDataFromBlossom = async (
|
||||
return parsedContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to subscribe to sigits notifications for a specified public key.
|
||||
* @param pubkey - The public key to subscribe to.
|
||||
* @returns A promise that resolves when the subscription is successful.
|
||||
*/
|
||||
export const subscribeForSigits = async (pubkey: string) => {
|
||||
// Instantiate the MetadataController to retrieve relay list metadata
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const relaySet = await metadataController
|
||||
.findRelayListMetadata(pubkey)
|
||||
.catch((err) => {
|
||||
// Log an error if retrieving relay list metadata fails
|
||||
console.log(
|
||||
`An error occurred while finding relay list metadata for ${hexToNpub(pubkey)}`,
|
||||
err
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
// Return if metadata retrieval failed
|
||||
if (!relaySet) return
|
||||
|
||||
// Ensure relay list is not empty
|
||||
if (relaySet.read.length === 0) return
|
||||
|
||||
// Define the filter for the subscription
|
||||
const filter: Filter = {
|
||||
kinds: [1059],
|
||||
'#p': [pubkey]
|
||||
}
|
||||
|
||||
// Process the received event synchronously
|
||||
const events = await relayController.fetchEvents(filter, relaySet.read)
|
||||
for (const e of events) {
|
||||
await processReceivedEvent(e)
|
||||
}
|
||||
|
||||
// Async processing of the events has a race condition
|
||||
// relayController.subscribeForEvents(filter, relaySet.read, (event) => {
|
||||
// processReceivedEvent(event)
|
||||
// })
|
||||
}
|
||||
|
||||
const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
|
||||
const processedEvents = store.getState().userAppData?.processedGiftWraps
|
||||
|
||||
// Abort processing if userAppData is undefined
|
||||
if (!processedEvents) return
|
||||
|
||||
if (processedEvents.includes(event.id)) return
|
||||
|
||||
store.dispatch(updateProcessedGiftWraps([...processedEvents, event.id]))
|
||||
|
||||
// validate PoW
|
||||
// Count the number of leading zero bits in the hash
|
||||
const leadingZeroes = countLeadingZeroes(event.id)
|
||||
if (leadingZeroes < difficulty) return
|
||||
|
||||
// decrypt the content of gift wrap event
|
||||
const nostrController = NostrController.getInstance()
|
||||
const decrypted = await nostrController.nip44Decrypt(
|
||||
event.pubkey,
|
||||
event.content
|
||||
)
|
||||
|
||||
const internalUnsignedEvent = await parseJson<UnsignedEvent>(decrypted).catch(
|
||||
(err) => {
|
||||
console.log(
|
||||
'An error occurred in parsing the internal unsigned event',
|
||||
err
|
||||
)
|
||||
return null
|
||||
}
|
||||
)
|
||||
|
||||
if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return
|
||||
|
||||
const parsedContent = await parseJson<Meta | SigitNotification>(
|
||||
internalUnsignedEvent.content
|
||||
).catch((err) => {
|
||||
console.log('An error occurred in parsing the internal unsigned event', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!parsedContent) return
|
||||
let meta: Meta
|
||||
if (isSigitNotification(parsedContent)) {
|
||||
const notification = parsedContent
|
||||
let encryptionKey: string | undefined
|
||||
if (!notification.keys) return
|
||||
|
||||
const { sender, keys } = notification.keys
|
||||
|
||||
// Retrieve the user's public key from the state
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
|
||||
// Check if the user's public key is in the keys object
|
||||
if (usersNpub in keys) {
|
||||
// Instantiate the NostrController to decrypt the encryption key
|
||||
const nostrController = NostrController.getInstance()
|
||||
const decrypted = await nostrController
|
||||
.nip04Decrypt(sender, keys[usersNpub])
|
||||
.catch((err) => {
|
||||
console.log('An error occurred in decrypting encryption key', err)
|
||||
return undefined
|
||||
})
|
||||
|
||||
encryptionKey = decrypted
|
||||
}
|
||||
try {
|
||||
meta = await fetchMetaFromFileStorage(
|
||||
notification.metaUrls,
|
||||
encryptionKey
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`An error occured fetching meta file from storage`, error)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
meta = parsedContent
|
||||
}
|
||||
|
||||
await updateUsersAppData(meta)
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to send a notification to a specified receiver.
|
||||
* @param receiver - The recipient's public key.
|
||||
* @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt.
|
||||
*/
|
||||
export const sendNotification = async (
|
||||
receiver: string,
|
||||
notification: SigitNotification
|
||||
) => {
|
||||
// Retrieve the user's public key from the state
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
|
||||
// Create an unsigned event object with the provided metadata
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: 938,
|
||||
pubkey: usersPubkey,
|
||||
content: JSON.stringify(notification),
|
||||
tags: [],
|
||||
created_at: unixNow()
|
||||
}
|
||||
|
||||
// Wrap the unsigned event with the receiver's information
|
||||
const wrappedEvent = createWrap(unsignedEvent, receiver)
|
||||
|
||||
// Instantiate the MetadataController to retrieve relay list metadata
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const relaySet = await metadataController
|
||||
.findRelayListMetadata(receiver)
|
||||
.catch((err) => {
|
||||
// Log an error if retrieving relay list metadata fails
|
||||
console.log(
|
||||
`An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`,
|
||||
err
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
// Return if metadata retrieval failed
|
||||
if (!relaySet) return
|
||||
|
||||
// Ensure relay list is not empty
|
||||
if (relaySet.read.length === 0) return
|
||||
|
||||
// Publish the notification event to the recipient's read relays
|
||||
await Promise.race([
|
||||
relayController.publish(wrappedEvent, relaySet.read),
|
||||
timeout(40 * 1000)
|
||||
]).catch((err) => {
|
||||
// Log an error if publishing the notification event fails
|
||||
console.log(
|
||||
`An error occurred while publishing notification event for ${hexToNpub(receiver)}`,
|
||||
err
|
||||
)
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user's name, first available in order: display_name, name, or npub as fallback
|
||||
* @param npub User identifier, it can be either pubkey or npub1 (we only show npub)
|
||||
@ -1081,8 +497,29 @@ export const sendNotification = async (
|
||||
*/
|
||||
export const getProfileUsername = (
|
||||
npub: `npub1${string}` | string,
|
||||
profile?: ProfileMetadata
|
||||
profile?: NDKUserProfile
|
||||
) =>
|
||||
truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
|
||||
truncate(profile?.displayName || profile?.name || hexToNpub(npub), {
|
||||
length: 16
|
||||
})
|
||||
|
||||
/**
|
||||
* Orders an array of NDKEvent objects chronologically based on their `created_at` property.
|
||||
*
|
||||
* @param events - The array of NDKEvent objects to be sorted.
|
||||
* @param reverse - Optional flag to reverse the sorting order.
|
||||
* If true, sorts in ascending order (oldest first), otherwise sorts in descending order (newest first).
|
||||
*
|
||||
* @returns The sorted array of events.
|
||||
*/
|
||||
export function orderEventsChronologically(
|
||||
events: NDKEvent[],
|
||||
reverse: boolean = false
|
||||
): NDKEvent[] {
|
||||
events.sort((e1: NDKEvent, e2: NDKEvent) => {
|
||||
if (reverse) return e1.created_at! - e2.created_at!
|
||||
else return e2.created_at! - e1.created_at!
|
||||
})
|
||||
|
||||
return events
|
||||
}
|
||||
|
@ -1,171 +1,43 @@
|
||||
import { Event, Filter, kinds, UnsignedEvent } from 'nostr-tools'
|
||||
import { RelayList } from 'nostr-tools/kinds'
|
||||
import { getRelayInfo, unixNow } from '.'
|
||||
import { NostrController, relayController } from '../controllers'
|
||||
import { localCache } from '../services'
|
||||
import { RelayMap, RelaySet } from '../types'
|
||||
import {
|
||||
DEFAULT_LOOK_UP_RELAY_LIST,
|
||||
ONE_DAY_IN_MS,
|
||||
ONE_WEEK_IN_MS,
|
||||
SIGIT_RELAY
|
||||
} from './const'
|
||||
import NDK, { NDKEvent, NDKRelayList } from '@nostr-dev-kit/ndk'
|
||||
import { kinds, UnsignedEvent } from 'nostr-tools'
|
||||
import { normalizeWebSocketURL, unixNow } from '.'
|
||||
import { NostrController } from '../controllers'
|
||||
import { RelayMap } from '../types'
|
||||
import { SIGIT_RELAY } from './const'
|
||||
|
||||
const READ_MARKER = 'read'
|
||||
const WRITE_MARKER = 'write'
|
||||
export const getRelayMapFromNDKRelayList = (ndkRelayList: NDKRelayList) => {
|
||||
const relayMap: RelayMap = {}
|
||||
|
||||
/**
|
||||
* Attempts to find a relay list from the provided lookUpRelays.
|
||||
* If the relay list is found, it will be added to the user relay list metadata.
|
||||
* @param lookUpRelays
|
||||
* @param hexKey
|
||||
* @return found relay list or null
|
||||
*/
|
||||
const findRelayListAndUpdateCache = async (
|
||||
lookUpRelays: string[],
|
||||
hexKey: string
|
||||
): Promise<Event | null> => {
|
||||
try {
|
||||
const eventFilter: Filter = {
|
||||
kinds: [RelayList],
|
||||
authors: [hexKey]
|
||||
ndkRelayList.readRelayUrls.forEach((relayUrl) => {
|
||||
const normalizedUrl = normalizeWebSocketURL(relayUrl)
|
||||
|
||||
relayMap[normalizedUrl] = {
|
||||
read: true,
|
||||
write: false
|
||||
}
|
||||
})
|
||||
|
||||
const event = await relayController.fetchEvent(eventFilter, lookUpRelays)
|
||||
if (event) {
|
||||
await localCache.addUserRelayListMetadata(event)
|
||||
ndkRelayList.writeRelayUrls.forEach((relayUrl) => {
|
||||
const normalizedUrl = normalizeWebSocketURL(relayUrl)
|
||||
|
||||
const existing = relayMap[normalizedUrl]
|
||||
if (existing) {
|
||||
existing.write = true
|
||||
} else {
|
||||
relayMap[normalizedUrl] = {
|
||||
read: false,
|
||||
write: true
|
||||
}
|
||||
}
|
||||
return event
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
return relayMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to find a relay list in cache. If it is present, it will check that the cached event is not
|
||||
* older than one week.
|
||||
* @param hexKey
|
||||
* @return RelayList event if it's not older than a week; otherwise null
|
||||
*/
|
||||
const findRelayListInCache = async (hexKey: string): Promise<Event | null> => {
|
||||
try {
|
||||
// Attempt to retrieve the metadata event from the local cache
|
||||
const cachedRelayListMetadataEvent =
|
||||
await localCache.getUserRelayListMetadata(hexKey)
|
||||
|
||||
// Check if the cached event is not older than one week
|
||||
if (
|
||||
cachedRelayListMetadataEvent &&
|
||||
!isOlderThanOneWeek(cachedRelayListMetadataEvent.cachedAt)
|
||||
) {
|
||||
return cachedRelayListMetadataEvent.event
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a list of relay tags from a Nostr Event to a RelaySet.
|
||||
* @param tags
|
||||
*/
|
||||
const getUserRelaySet = (tags: string[][]): RelaySet => {
|
||||
return tags
|
||||
.filter(isRelayTag)
|
||||
.reduce<RelaySet>(toRelaySet, getDefaultRelaySet())
|
||||
}
|
||||
|
||||
const getDefaultRelaySet = (): RelaySet => ({
|
||||
read: DEFAULT_LOOK_UP_RELAY_LIST,
|
||||
write: DEFAULT_LOOK_UP_RELAY_LIST
|
||||
})
|
||||
|
||||
const getDefaultRelayMap = (): RelayMap => ({
|
||||
export const getDefaultRelayMap = (): RelayMap => ({
|
||||
[SIGIT_RELAY]: { write: true, read: true }
|
||||
})
|
||||
|
||||
const isOlderThanOneWeek = (cachedAt: number) => {
|
||||
return Date.now() - cachedAt > ONE_WEEK_IN_MS
|
||||
}
|
||||
|
||||
const isOlderThanOneDay = (cachedAt: number) => {
|
||||
return Date.now() - cachedAt > ONE_DAY_IN_MS
|
||||
}
|
||||
|
||||
const isRelayTag = (tag: string[]): boolean => tag[0] === 'r'
|
||||
|
||||
const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
|
||||
if (tag.length >= 3) {
|
||||
const marker = tag[2]
|
||||
|
||||
if (marker === READ_MARKER) {
|
||||
obj.read.push(tag[1])
|
||||
} else if (marker === WRITE_MARKER) {
|
||||
obj.write.push(tag[1])
|
||||
}
|
||||
}
|
||||
if (tag.length === 2) {
|
||||
obj.read.push(tag[1])
|
||||
obj.write.push(tag[1])
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides relay map.
|
||||
* @param npub - user's npub
|
||||
* @returns - promise that resolves into relay map and a timestamp when it has been updated.
|
||||
*/
|
||||
const getRelayMap = async (
|
||||
npub: string
|
||||
): Promise<{ map: RelayMap; mapUpdated?: number }> => {
|
||||
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
|
||||
const eventFilter: Filter = {
|
||||
kinds: [kinds.RelayList],
|
||||
authors: [npub]
|
||||
}
|
||||
|
||||
const event = await relayController
|
||||
.fetchEvent(eventFilter, DEFAULT_LOOK_UP_RELAY_LIST)
|
||||
.catch((err) => {
|
||||
return Promise.reject(err)
|
||||
})
|
||||
|
||||
if (event) {
|
||||
// Handle founded 10002 event
|
||||
const relaysMap: RelayMap = {}
|
||||
|
||||
// 'r' stands for 'relay'
|
||||
const relayTags = event.tags.filter((tag) => tag[0] === 'r')
|
||||
|
||||
relayTags.forEach((tag) => {
|
||||
const uri = tag[1]
|
||||
const relayType = tag[2]
|
||||
|
||||
// if 3rd element of relay tag is undefined, relay is WRITE and READ
|
||||
relaysMap[uri] = {
|
||||
write: relayType ? relayType === 'write' : true,
|
||||
read: relayType ? relayType === 'read' : true
|
||||
}
|
||||
})
|
||||
|
||||
Object.keys(relaysMap).forEach((relayUrl) =>
|
||||
relayController.connectRelay(relayUrl)
|
||||
)
|
||||
|
||||
getRelayInfo(Object.keys(relaysMap))
|
||||
|
||||
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
|
||||
} else {
|
||||
return Promise.resolve({ map: getDefaultRelayMap() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes relay map.
|
||||
* @param relayMap - relay map.
|
||||
@ -173,10 +45,11 @@ const getRelayMap = async (
|
||||
* @param extraRelaysToPublish - optional relays to publish relay map.
|
||||
* @returns - promise that resolves into a string representing publishing result.
|
||||
*/
|
||||
const publishRelayMap = async (
|
||||
export const publishRelayMap = async (
|
||||
relayMap: RelayMap,
|
||||
npub: string,
|
||||
extraRelaysToPublish?: string[]
|
||||
ndk: NDK,
|
||||
publish: (event: NDKEvent) => Promise<string[]>
|
||||
): Promise<string> => {
|
||||
const timestamp = unixNow()
|
||||
const relayURIs = Object.keys(relayMap)
|
||||
@ -205,21 +78,8 @@ const publishRelayMap = async (
|
||||
const nostrController = NostrController.getInstance()
|
||||
const signedEvent = await nostrController.signEvent(newRelayMapEvent)
|
||||
|
||||
let relaysToPublish = relayURIs
|
||||
|
||||
// Add extra relays if provided
|
||||
if (extraRelaysToPublish) {
|
||||
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
|
||||
}
|
||||
|
||||
// If relay map is empty, use most popular relay URIs
|
||||
if (!relaysToPublish.length) {
|
||||
relaysToPublish = DEFAULT_LOOK_UP_RELAY_LIST
|
||||
}
|
||||
const publishResult = await relayController.publish(
|
||||
signedEvent,
|
||||
relaysToPublish
|
||||
)
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishResult = await publish(ndkEvent)
|
||||
|
||||
if (publishResult && publishResult.length) {
|
||||
return Promise.resolve(
|
||||
@ -229,15 +89,3 @@ const publishRelayMap = async (
|
||||
|
||||
return Promise.reject('Publishing updated relay map was unsuccessful.')
|
||||
}
|
||||
|
||||
export {
|
||||
findRelayListAndUpdateCache,
|
||||
findRelayListInCache,
|
||||
getDefaultRelayMap,
|
||||
getDefaultRelaySet,
|
||||
getRelayMap,
|
||||
getUserRelaySet,
|
||||
isOlderThanOneDay,
|
||||
isOlderThanOneWeek,
|
||||
publishRelayMap
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { TimeoutError } from '../types/errors/TimeoutError.ts'
|
||||
import { CurrentUserFile } from '../types/file.ts'
|
||||
import { SigitFile } from './file.ts'
|
||||
import { NIP05_REGEX } from '../constants.ts'
|
||||
|
||||
export const debounceCustom = <T extends (...args: never[]) => void>(
|
||||
fn: T,
|
||||
@ -144,25 +143,3 @@ export const isPromiseRejected = <T>(
|
||||
): result is PromiseRejectedResult => {
|
||||
return result.status === 'rejected'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if it's valid {protocol}{domain}
|
||||
* @param url
|
||||
*/
|
||||
export const isValidUrl = (url: string) => {
|
||||
return /^(https?:\/\/)(?!-)([A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,6}$/gim.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if it's a valid domain or nip05 format
|
||||
* @param value
|
||||
*/
|
||||
export const isValidNip05 = (value: string) => {
|
||||
return NIP05_REGEX.test(value)
|
||||
}
|
||||
|
||||
export const isValidRelayUri = (value: string) => {
|
||||
return /^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
|
||||
value
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user