feat(design): home page new design and functionality #135

Merged
enes merged 24 commits from issue-121 into staging 2024-08-14 08:44:09 +00:00
36 changed files with 1544 additions and 532 deletions

View File

@ -5,15 +5,15 @@ commit_message=$(cat "$1")
if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9 \-]+\))?!?: .+$") then if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9 \-]+\))?!?: .+$") then
tput setaf 2; tput setaf 2;
echo -e "${GREEN} ✔ Commit message meets Conventional Commit standards" echo "✔ Commit message meets Conventional Commit standards"
tput sgr0; tput sgr0;
exit 0 exit 0
fi fi
tput setaf 1; tput setaf 1;
echo -e "${RED}❌ Commit message does not meet the Conventional Commit standard!" echo "❌ Commit message does not meet the Conventional Commit standard!"
tput sgr0; tput sgr0;
echo "An example of a valid message is:" echo "An example of a valid message is:"
echo " feat(login): add the 'remember me' button" echo " feat(login): add the 'remember me' button"
echo " More details at: https://www.conventionalcommits.org/en/v1.0.0/#summary" echo "📝 More details at: https://www.conventionalcommits.org/en/v1.0.0/#summary"
exit 1 exit 1

56
package-lock.json generated
View File

@ -21,7 +21,7 @@
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0", "@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1", "@reduxjs/toolkit": "2.2.1",
"axios": "1.6.7", "axios": "^1.7.4",
"crypto-hash": "3.0.0", "crypto-hash": "3.0.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dnd-core": "16.0.1", "dnd-core": "16.0.1",
@ -37,6 +37,7 @@
"react-dnd": "16.0.1", "react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1", "react-dnd-html5-backend": "16.0.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-redux": "9.1.0", "react-redux": "9.1.0",
"react-router-dom": "6.22.1", "react-router-dom": "6.22.1",
"react-toastify": "10.0.4", "react-toastify": "10.0.4",
@ -2680,12 +2681,22 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"node_modules/attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
"integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/axios": { "node_modules/axios": {
"version": "1.6.7", "version": "1.7.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
"integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
"license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.4", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
@ -3857,6 +3868,24 @@
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
}, },
"node_modules/file-selector": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
"integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/file-selector/node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
"license": "0BSD"
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -5716,6 +5745,23 @@
"react": "^18.2.0" "react": "^18.2.0"
} }
}, },
"node_modules/react-dropzone": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
"integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.2",
"file-selector": "^0.6.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.2.0", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",

View File

@ -7,7 +7,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 32", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 25",
"lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:staged": "eslint --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:staged": "eslint --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", "formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
@ -31,7 +31,7 @@
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0", "@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1", "@reduxjs/toolkit": "2.2.1",
"axios": "1.6.7", "axios": "^1.7.4",
"crypto-hash": "3.0.0", "crypto-hash": "3.0.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dnd-core": "16.0.1", "dnd-core": "16.0.1",
@ -47,6 +47,7 @@
"react-dnd": "16.0.1", "react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1", "react-dnd-html5-backend": "16.0.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-redux": "9.1.0", "react-redux": "9.1.0",
"react-router-dom": "6.22.1", "react-router-dom": "6.22.1",
"react-toastify": "10.0.4", "react-toastify": "10.0.4",

View File

@ -56,10 +56,16 @@ a {
text-decoration: none; text-decoration: none;
text-decoration-color: inherit; text-decoration-color: inherit;
transition: ease 0.4s; transition: ease 0.4s;
outline: none;
&:focus,
&:hover { &:hover {
color: $primary-light; color: $primary-light;
text-decoration: underline; text-decoration: underline;
text-decoration-color: inherit; text-decoration-color: inherit;
} }
} }
input {
font-family: inherit;
}

View File

@ -6,6 +6,18 @@ interface ContainerProps {
className?: string className?: string
} }
/**
* Container component with pre-defined width, padding and margins for top level layout.
*
* **Important:** To avoid conflicts with `defaultStyle` (changing the `width`, `max-width`, `padding-inline`, and/or `margin-inline`) make sure to either:
* - When using *className* override, that styles are imported after the actual `Container` component
* ```
* import { Container } from './components/Container'
* import styles from './style.module.scss'
* ```
* - or add *!important* to imported styles
* - or override styles with *CSSProperties* object
*/
export const Container = ({ export const Container = ({
style = {}, style = {},
className = '', className = '',

View File

@ -0,0 +1,208 @@
import { useEffect, useState } from 'react'
import { Meta, ProfileMetadata } from '../../types'
import { SigitCardDisplayInfo, SigitStatus } from '../../utils'
import { Event, kinds } from 'nostr-tools'
import { Link } from 'react-router-dom'
import { MetadataController } from '../../controllers'
import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils'
import { appPublicRoutes, appPrivateRoutes } from '../../routes'
import { Button, Divider, Tooltip } from '@mui/material'
import { DisplaySigner } from '../DisplaySigner'
import {
faArchive,
faCalendar,
faCopy,
faEye,
faFile
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { UserAvatar } from '../UserAvatar'
import { UserAvatarGroup } from '../UserAvatarGroup'
import styles from './style.module.scss'
import { TooltipChild } from '../TooltipChild'
import { getExtensionIconLabel } from '../getExtensionIconLabel'
type SigitProps = {
meta: Meta
parsedMeta: SigitCardDisplayInfo
}
export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
const {
title,
createdAt,
submittedBy,
signers,
signedStatus,
fileExtensions
} = parsedMeta
const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
useEffect(() => {
const hexKeys = new Set<string>([
...signers.map((signer) => npubToHex(signer)!)
])
if (submittedBy) {
hexKeys.add(npubToHex(submittedBy)!)
}
const metadataController = new MetadataController()
const handleMetadataEvent = (key: string) => (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent) {
setProfiles((prev) => ({
...prev,
[key]: metadataContent
}))
}
}
const handleEventListener =
(key: string) => (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(key)(event)
}
}
hexKeys.forEach((key) => {
if (!(key in profiles)) {
metadataController.on(key, handleEventListener(key))
metadataController
.findMetadata(key)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(key)(metadataEvent)
})
.catch((err) => {
console.error(`error occurred in finding metadata for: ${key}`, err)
})
}
})
return () => {
hexKeys.forEach((key) => {
metadataController.off(key, handleEventListener(key))
})
}
}, [submittedBy, signers, profiles])
return (
<div className={styles.itemWrapper}>
<Link
to={
signedStatus === SigitStatus.Complete
? appPublicRoutes.verify
: appPrivateRoutes.sign
}
state={{ meta }}
className={styles.insetLink}
></Link>
<p className={`line-clamp-2 ${styles.title}`}>{title}</p>
<div className={styles.users}>
{submittedBy &&
(function () {
const profile = profiles[submittedBy]
return (
<Tooltip
title={
profile?.display_name ||
profile?.name ||
shorten(hexToNpub(submittedBy))
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<UserAvatar pubkey={submittedBy} image={profile?.picture} />
</TooltipChild>
</Tooltip>
)
})()}
{submittedBy && signers.length ? (
<Divider orientation="vertical" flexItem />
) : null}
<UserAvatarGroup className={styles.signers} max={7}>
{signers.map((signer) => {
const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
return (
<Tooltip
key={signer}
title={
profile?.display_name || profile?.name || shorten(pubkey)
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<DisplaySigner
meta={meta}
profile={profile}
pubkey={pubkey}
/>
</TooltipChild>
</Tooltip>
)
})}
</UserAvatarGroup>
</div>
<div className={`${styles.details} ${styles.date} ${styles.iconLabel}`}>
<FontAwesomeIcon icon={faCalendar} />
{createdAt ? formatTimestamp(createdAt) : null}
</div>
<div className={`${styles.details} ${styles.status}`}>
<span className={styles.iconLabel}>
<FontAwesomeIcon icon={faEye} /> {signedStatus}
</span>
{fileExtensions.length > 0 ? (
<span className={styles.iconLabel}>
{fileExtensions.length > 1 ? (
<>
<FontAwesomeIcon icon={faFile} /> Multiple File Types
</>
) : (
getExtensionIconLabel(fileExtensions[0])
)}
</span>
) : null}
</div>
<div className={styles.itemActions}>
<Tooltip title="Duplicate" arrow placement="top" disableInteractive>
<Button
sx={{
color: 'var(--primary-main)',
minWidth: '34px',
padding: '10px'
}}
variant={'text'}
>
<FontAwesomeIcon icon={faCopy} />
</Button>
</Tooltip>
<Tooltip title="Archive" arrow placement="top" disableInteractive>
<Button
sx={{
color: 'var(--primary-main)',
minWidth: '34px',
padding: '10px'
}}
variant={'text'}
>
<FontAwesomeIcon icon={faArchive} />
</Button>
</Tooltip>
</div>
</div>
)
}

View File

@ -0,0 +1,134 @@
@import '../../styles/colors.scss';
.itemWrapper {
position: relative;
overflow: hidden;
background-color: $overlay-background-color;
border-radius: 4px;
display: flex;
padding: 15px;
gap: 15px;
flex-direction: column;
&:only-child {
max-width: 600px;
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
transition: opacity ease 0.2s;
opacity: 0;
width: 4px;
background-color: $primary-main;
pointer-events: none;
}
&:hover,
&:focus-within {
&::before {
opacity: 1;
}
.itemActions {
transform: translateX(0);
}
}
}
.insetLink {
position: absolute;
inset: 0;
outline: none;
}
.itemActions {
display: flex;
gap: 10px;
padding: 10px;
> * {
flex-grow: 1;
}
@media (hover: hover) {
transition: ease 0.2s;
transform: translateX(100%);
position: absolute;
right: 0;
top: 0;
bottom: 0;
flex-direction: column;
background: $overlay-background-color;
border-left: solid 1px rgba(0, 0, 0, 0.1);
&:hover,
&:focus-within {
transform: translateX(0);
}
}
@media (hover: none) {
border-top: solid 1px rgba(0, 0, 0, 0.1);
padding-top: 10px;
margin-inline: -15px;
margin-bottom: -15px;
}
}
.title {
font-size: 20px;
color: $text-color;
}
.users {
margin-top: auto;
display: flex;
grid-gap: 10px;
}
.signers {
padding: 0 0 0 10px;
> * {
transition: margin ease 0.2s;
margin: 0 0 0 -10px;
position: relative;
z-index: 1;
&:first-child {
margin-left: -10px !important;
}
}
> *:hover,
> *:focus-within {
margin: 0 15px 0 5px;
z-index: 2;
}
}
.details {
color: rgba(0, 0, 0, 0.3);
font-size: 14px;
}
.iconLabel {
display: flex;
grid-gap: 10px;
align-items: center;
}
.status {
display: flex;
grid-gap: 25px;
}
a.itemWrapper:hover {
text-decoration: none;
}

View File

@ -0,0 +1,78 @@
import { Badge } from '@mui/material'
import { Event, verifyEvent } from 'nostr-tools'
import { useState, useEffect } from 'react'
import { Meta, ProfileMetadata } from '../../types'
import { hexToNpub, parseJson } from '../../utils'
import styles from './style.module.scss'
import { UserAvatar } from '../UserAvatar'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck, faExclamation } from '@fortawesome/free-solid-svg-icons'
enum SignStatus {
Signed = 'Signed',
Pending = 'Pending',
Invalid = 'Invalid Sign'
}
type DisplaySignerProps = {
meta: Meta
profile: ProfileMetadata
pubkey: string
}
export const DisplaySigner = ({
meta,
profile,
pubkey
}: DisplaySignerProps) => {
const [signStatus, setSignedStatus] = useState<SignStatus>()
useEffect(() => {
if (!meta) return
const updateSignStatus = async () => {
const npub = hexToNpub(pubkey)
if (npub in meta.docSignatures) {
parseJson<Event>(meta.docSignatures[npub])
.then((event) => {
const isValidSignature = verifyEvent(event)
if (isValidSignature) {
setSignedStatus(SignStatus.Signed)
} else {
setSignedStatus(SignStatus.Invalid)
}
})
.catch((err) => {
console.log(`err in parsing the docSignatures for ${npub}:>> `, err)
setSignedStatus(SignStatus.Invalid)
})
} else {
setSignedStatus(SignStatus.Pending)
}
}
updateSignStatus()
}, [meta, pubkey])
return (
<Badge
className={styles.signer}
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
badgeContent={
signStatus !== SignStatus.Pending && (
<div className={styles.statusBadge}>
{signStatus === SignStatus.Signed && (
<FontAwesomeIcon icon={faCheck} />
)}
{signStatus === SignStatus.Invalid && (
<FontAwesomeIcon icon={faExclamation} />
)}
</div>
)
}
>
<UserAvatar pubkey={pubkey} image={profile?.picture} />
</Badge>
)
}

View File

@ -0,0 +1,23 @@
@import '../../styles/colors.scss';
.statusBadge {
width: 22px;
height: 22px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
background-color: $primary-main;
}
.signer {
background-color: white;
border-radius: 50%;
z-index: 1;
}

View File

@ -30,6 +30,7 @@
border: 1px solid rgba(0, 0, 0, 0.137); border: 1px solid rgba(0, 0, 0, 0.137);
padding: 5px; padding: 5px;
cursor: pointer; cursor: pointer;
-webkit-user-select: none;
user-select: none; user-select: none;
&.selected { &.selected {
@ -42,15 +43,15 @@
border-color: #01aaad79; border-color: #01aaad79;
} }
} }
} }
} }
} }
.pdfImageWrapper { .pdfImageWrapper {
position: relative; position: relative;
-webkit-user-select: none;
user-select: none; user-select: none;
&.drawing { &.drawing {
cursor: crosshair; cursor: crosshair;
} }
@ -94,7 +95,7 @@
background-color: #fff; background-color: #fff;
border: 1px solid rgb(160, 160, 160); border: 1px solid rgb(160, 160, 160);
border-radius: 50%; border-radius: 50%;
color: #E74C3C; color: #e74c3c;
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
} }
@ -110,4 +111,4 @@
background: #fff; background: #fff;
padding: 5px 0; padding: 5px 0;
} }
} }

View File

@ -0,0 +1,113 @@
import {
FormControl,
MenuItem,
Select as SelectMui,
SelectChangeEvent,
styled,
SelectProps as SelectMuiProps,
MenuItemProps
} from '@mui/material'
const SelectCustomized = styled(SelectMui)<SelectMuiProps>(() => ({
backgroundColor: 'var(--primary-main)',
fontSize: '14px',
fontWeight: '500',
color: 'white',
':hover': {
backgroundColor: 'var(--primary-light)'
},
'& .MuiSelect-select:focus': {
backgroundColor: 'var(--primary-light)'
},
'& .MuiSvgIcon-root': {
color: 'white'
},
'& .MuiOutlinedInput-notchedOutline': {
border: 'none'
}
}))
const MenuItemCustomized = styled(MenuItem)<MenuItemProps>(() => ({
marginInline: '5px',
borderRadius: '4px',
'&:hover': {
background: 'var(--primary-light)',
color: 'white'
},
'&.Mui-selected': {
background: 'var(--primary-dark)',
color: 'white'
},
'&.Mui-selected:hover': {
background: 'var(--primary-light)'
},
'&.Mui-selected.Mui-focusVisible': {
background: 'var(--primary-light)',
color: 'white'
},
'&.Mui-focusVisible': {
background: 'var(--primary-light)',
color: 'white'
},
'& + *': {
marginTop: '5px'
}
}))
interface SelectItemProps<T extends string> {
value: T
label: string
}
interface SelectProps<T extends string> {
value: T
setValue: React.Dispatch<React.SetStateAction<T>>
options: SelectItemProps<T>[]
name?: string
id?: string
}
export function Select<T extends string>({
value,
setValue,
options,
name,
id
}: SelectProps<T>) {
const handleChange = (event: SelectChangeEvent<unknown>) => {
setValue(event.target.value as T)
}
return (
<FormControl>
<SelectCustomized
id={id}
name={name}
size="small"
variant="outlined"
value={value}
onChange={handleChange}
MenuProps={{
MenuListProps: {
sx: {
paddingBlock: '5px'
}
},
PaperProps: {
sx: {
boxShadow: '0 0 4px 0 rgb(0, 0, 0, 0.1)'
}
}
}}
>
{options.map((o) => {
return (
<MenuItemCustomized key={o.label} value={o.value as string}>
{o.label}
</MenuItemCustomized>
)
})}
</SelectCustomized>
</FormControl>
)
}

View File

@ -0,0 +1,16 @@
import { forwardRef, PropsWithChildren } from 'react'
/**
* Helper wrapper for custom child components when using `@mui/material/tooltips`.
* Mui Tooltip works out-the-box with other `@mui` components but when using custom they require ref.
* @source https://mui.com/material-ui/react-tooltip/#custom-child-element
*/
export const TooltipChild = forwardRef<HTMLSpanElement, PropsWithChildren>(
({ children, ...rest }, ref) => {
return (
<span ref={ref} {...rest}>
{children}
</span>
)
}
)

View File

@ -1,12 +1,11 @@
import { useNavigate } from 'react-router-dom'
import { getProfileRoute } from '../../routes' import { getProfileRoute } from '../../routes'
import styles from './styles.module.scss' import styles from './styles.module.scss'
import React from 'react'
import { AvatarIconButton } from '../UserAvatarIconButton' import { AvatarIconButton } from '../UserAvatarIconButton'
import { Link } from 'react-router-dom'
interface UserAvatarProps { interface UserAvatarProps {
name: string name?: string
pubkey: string pubkey: string
image?: string image?: string
} }
@ -16,27 +15,22 @@ interface UserAvatarProps {
* Clicking will navigate to the user's profile. * Clicking will navigate to the user's profile.
*/ */
export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => { export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => {
const navigate = useNavigate()
const handleClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
navigate(getProfileRoute(pubkey))
}
return ( return (
<div className={styles.container}> <Link
to={getProfileRoute(pubkey)}
className={styles.container}
tabIndex={-1}
>
<AvatarIconButton <AvatarIconButton
src={image} src={image}
hexKey={pubkey} hexKey={pubkey}
aria-label={`account of user ${name}`} aria-label={`account of user ${name || pubkey}`}
color="inherit" color="inherit"
onClick={handleClick} sx={{
padding: 0
}}
/> />
{name ? ( {name ? <label className={styles.username}>{name}</label> : null}
<label onClick={handleClick} className={styles.username}> </Link>
{name}
</label>
) : null}
</div>
) )
} }

View File

@ -2,7 +2,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-grow: 1; // flex-grow: 1;
} }
.username { .username {

View File

@ -0,0 +1,38 @@
import { Children, PropsWithChildren } from 'react'
import styles from './style.module.scss'
interface UserAvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
max: number
renderSurplus?: ((surplus: number) => React.ReactNode) | undefined
}
const defaultSurplus = (surplus: number) => {
return <span className={styles.icon}>+{surplus}</span>
}
/**
* Renders children with the `max` limit (including surplus if available).
* The children are wrapped with a `div` (accepts standard `HTMLDivElement` attributes)
* @param max The maximum number of children rendered in a div.
* @param renderSurplus Custom render for surplus children (accepts surplus number).
*/
export const UserAvatarGroup = ({
max,
renderSurplus = defaultSurplus,
children,
...rest
}: PropsWithChildren<UserAvatarGroupProps>) => {
const total = Children.count(children)
const surplus = total - max + 1
const childrenArray = Children.toArray(children)
return (
<div {...rest}>
{surplus > 1
? childrenArray.slice(0, surplus * -1).map((c) => c)
: children}
{surplus > 1 && renderSurplus(surplus)}
</div>
)
}

View File

@ -0,0 +1,19 @@
@import '../../styles/colors.scss';
.icon {
width: 40px;
height: 40px;
border-radius: 50%;
border-width: 2px;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
background: white;
color: rgba(0, 0, 0, 0.5);
font-weight: bold;
font-size: 14px;
border: solid 2px $primary-main;
}

View File

@ -2,6 +2,6 @@
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
border-width: 3px; border-width: 2px;
overflow: hidden; overflow: hidden;
} }

View File

@ -0,0 +1,78 @@
import {
faFilePdf,
faFileExcel,
faFileWord,
faFilePowerpoint,
faFileZipper,
faFileCsv,
faFileLines,
faFileImage,
faFile,
IconDefinition
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
export const getExtensionIconLabel = (extension: string) => {
let icon: IconDefinition
switch (extension.toLowerCase()) {
case 'pdf':
icon = faFilePdf
break
case 'json':
icon = faFilePdf
break
case 'xlsx':
case 'xls':
case 'xlsb':
case 'xlsm':
icon = faFileExcel
break
case 'doc':
case 'docx':
icon = faFileWord
break
case 'ppt':
case 'pptx':
icon = faFilePowerpoint
break
case 'zip':
case '7z':
case 'rar':
case 'tar':
case 'gz':
icon = faFileZipper
break
case 'csv':
icon = faFileCsv
break
case 'txt':
icon = faFileLines
break
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'svg':
case 'bmp':
case 'ico':
icon = faFileImage
break
default:
icon = faFile
return
}
return (
<>
<FontAwesomeIcon icon={icon} /> {extension.toUpperCase()}
</>
)
}

View File

@ -12,7 +12,8 @@ import {
getAuthToken, getAuthToken,
getVisitedLink, getVisitedLink,
saveAuthToken, saveAuthToken,
compareObjects compareObjects,
unixNow
} from '../utils' } from '../utils'
import { appPrivateRoutes } from '../routes' import { appPrivateRoutes } from '../routes'
import { SignedEvent } from '../types' import { SignedEvent } from '../types'
@ -54,7 +55,7 @@ export class AuthController {
}) })
// Nostr uses unix timestamps // Nostr uses unix timestamps
const timestamp = Math.floor(Date.now() / 1000) const timestamp = unixNow()
const { hostname } = window.location const { hostname } = window.location
const authEvent: EventTemplate = { const authEvent: EventTemplate = {

View File

@ -12,7 +12,7 @@ import {
import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types' import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types'
import { NostrController } from '.' import { NostrController } from '.'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { queryNip05 } from '../utils' import { queryNip05, unixNow } from '../utils'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { localCache } from '../services' import { localCache } from '../services'
@ -194,7 +194,7 @@ export class MetadataController extends EventEmitter {
let signedMetadataEvent = event let signedMetadataEvent = event
if (event.sig.length < 1) { if (event.sig.length < 1) {
const timestamp = Math.floor(Date.now() / 1000) const timestamp = unixNow()
// Metadata event to publish to the wss://purplepag.es relay // Metadata event to publish to the wss://purplepag.es relay
const newMetadataEvent: Event = { const newMetadataEvent: Event = {
@ -265,7 +265,7 @@ export class MetadataController extends EventEmitter {
// initialize job request // initialize job request
const jobEventTemplate: EventTemplate = { const jobEventTemplate: EventTemplate = {
content: '', content: '',
created_at: Math.round(Date.now() / 1000), created_at: unixNow(),
kind: 68001, kind: 68001,
tags: [ tags: [
['i', `${created_at * 1000}`], ['i', `${created_at * 1000}`],

View File

@ -42,6 +42,7 @@ import {
import { import {
compareObjects, compareObjects,
getNsecBunkerDelegatedKey, getNsecBunkerDelegatedKey,
unixNow,
verifySignedEvent verifySignedEvent
} from '../utils' } from '../utils'
import { getDefaultRelayMap } from '../utils/relays.ts' import { getDefaultRelayMap } from '../utils/relays.ts'
@ -244,7 +245,7 @@ export class NostrController extends EventEmitter {
if (!firstSuccessfulPublish) { if (!firstSuccessfulPublish) {
// If no publish was successful, collect the reasons for failures // If no publish was successful, collect the reasons for failures
const failedPublishes: any[] = [] const failedPublishes: unknown[] = []
const fallbackRejectionReason = const fallbackRejectionReason =
'Attempt to publish an event has been rejected with unknown reason.' 'Attempt to publish an event has been rejected with unknown reason.'
@ -504,11 +505,13 @@ export class NostrController extends EventEmitter {
} else if (loginMethod === LoginMethods.extension) { } else if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject() const nostr = this.getNostrObject()
return (await nostr.signEvent(event as NostrEvent).catch((err: any) => { return (await nostr
console.log('Error while signing event: ', err) .signEvent(event as NostrEvent)
.catch((err: unknown) => {
console.log('Error while signing event: ', err)
throw err throw err
})) as Event })) as Event
} else { } else {
return Promise.reject( return Promise.reject(
`We could not sign the event, none of the signing methods are available` `We could not sign the event, none of the signing methods are available`
@ -625,8 +628,12 @@ export class NostrController extends EventEmitter {
*/ */
capturePublicKey = async (): Promise<string> => { capturePublicKey = async (): Promise<string> => {
const nostr = this.getNostrObject() const nostr = this.getNostrObject()
const pubKey = await nostr.getPublicKey().catch((err: any) => { const pubKey = await nostr.getPublicKey().catch((err: unknown) => {
return Promise.reject(err.message) if (err instanceof Error) {
return Promise.reject(err.message)
} else {
return Promise.reject(JSON.stringify(err))
}
}) })
if (!pubKey) { if (!pubKey) {
@ -708,7 +715,7 @@ export class NostrController extends EventEmitter {
npub: string, npub: string,
extraRelaysToPublish?: string[] extraRelaysToPublish?: string[]
): Promise<string> => { ): Promise<string> => {
const timestamp = Math.floor(Date.now() / 1000) const timestamp = unixNow()
const relayURIs = Object.keys(relayMap) const relayURIs = Object.keys(relayMap)
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md // More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
@ -810,7 +817,7 @@ export class NostrController extends EventEmitter {
// initialize job request // initialize job request
const jobEventTemplate: EventTemplate = { const jobEventTemplate: EventTemplate = {
content: '', content: '',
created_at: Math.round(Date.now() / 1000), created_at: unixNow(),
kind: 68001, kind: 68001,
tags: [ tags: [
['i', `${JSON.stringify(relayURIs)}`], ['i', `${JSON.stringify(relayURIs)}`],

148
src/hooks/useSigitMeta.tsx Normal file
View File

@ -0,0 +1,148 @@
import { useEffect, useState } from 'react'
import { CreateSignatureEventContent, Meta } from '../types'
import { Mark } from '../types/mark'
import {
fromUnixTimestamp,
parseCreateSignatureEvent,
parseCreateSignatureEventContent,
SigitMetaParseError,
SigitStatus,
SignStatus
} from '../utils'
import { toast } from 'react-toastify'
import { verifyEvent } from 'nostr-tools'
import { Event } from 'nostr-tools'
interface FlatMeta extends Meta, CreateSignatureEventContent, Partial<Event> {
// Validated create signature event
isValid: boolean
// Calculated status fields

Should SigitInfo returned by hook also contain other information available in Meta, specifically in the createSignature string, i.e. CreateSIgnatureEventContent? Similarly, should the data from docSignatures, represented by SignedEventContent be returned as well?

I beleive you would need to merge the current staging branch into yours to get a hold of some of these data types

Should `SigitInfo` returned by hook also contain other information available in `Meta`, specifically in the `createSignature` string, i.e. `CreateSIgnatureEventContent`? Similarly, should the data from `docSignatures`, represented by `SignedEventContent` be returned as well? I beleive you would need to merge the current staging branch into yours to get a hold of some of these data types
Outdated
Review

I've returned most of the things I found, we can add if something is missing later down the line 👍

I've returned most of the things I found, we can add if something is missing later down the line 👍
signedStatus: SigitStatus
signersStatus: {
[signer: `npub1${string}`]: SignStatus
}
}
/**
* Custom use hook for parsing the Sigit Meta
* @param meta Sigit Meta
* @returns flattened Meta object with calculated signed status
*/
export const useSigitMeta = (meta: Meta): FlatMeta => {
const [isValid, setIsValid] = useState(false)
const [kind, setKind] = useState<number>()
const [tags, setTags] = useState<string[][]>()
const [created_at, setCreatedAt] = useState<number>()
const [pubkey, setPubkey] = useState<string>() // submittedBy, pubkey from nostr event
const [id, setId] = useState<string>()
const [sig, setSig] = useState<string>()
const [signers, setSigners] = useState<`npub1${string}`[]>([])
const [viewers, setViewers] = useState<`npub1${string}`[]>([])
const [fileHashes, setFileHashes] = useState<{
[user: `npub1${string}`]: string
}>({})
const [markConfig, setMarkConfig] = useState<Mark[]>([])
const [title, setTitle] = useState<string>('')
const [zipUrl, setZipUrl] = useState<string>('')
const [signedStatus, setSignedStatus] = useState<SigitStatus>(
SigitStatus.Partial
)
const [signersStatus, setSignersStatus] = useState<{
[signer: `npub1${string}`]: SignStatus
}>({})
useEffect(() => {
if (!meta) return
;(async function () {
try {
const createSignatureEvent = await parseCreateSignatureEvent(
meta.createSignature
)
const { kind, tags, created_at, pubkey, id, sig, content } =
createSignatureEvent
setIsValid(verifyEvent(createSignatureEvent))
setKind(kind)
setTags(tags)
// created_at in nostr events are stored in seconds
Review

created_at * 1000 should be made into a utility function and used in other similar places (there is at least one that I could find).

Similarly, there is a reverse action, created_at / 1000, which should also be a utility.

`created_at * 1000` should be made into a utility function and used in other similar places (there is at least one that I could find). Similarly, there is a reverse action, `created_at / 1000`, which should also be a utility.
setCreatedAt(fromUnixTimestamp(created_at))
setPubkey(pubkey)
setId(id)
setSig(sig)
const { title, signers, viewers, fileHashes, markConfig, zipUrl } =
await parseCreateSignatureEventContent(content)
setTitle(title)
setSigners(signers)
setViewers(viewers)
setFileHashes(fileHashes)
setMarkConfig(markConfig)
setZipUrl(zipUrl)
// Parse each signature event and set signer status
for (const npub in meta.docSignatures) {
try {
const event = await parseCreateSignatureEvent(
meta.docSignatures[npub as `npub1${string}`]
)
const isValidSignature = verifyEvent(event)
setSignersStatus((prev) => {
return {
...prev,
[npub]: isValidSignature
? SignStatus.Signed
: SignStatus.Invalid
}
})
} catch (error) {
setSignersStatus((prev) => {
return {
...prev,
[npub]: SignStatus.Invalid
}
})
}
}
const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[]
const isCompletelySigned = signers.every((signer) =>
signedBy.includes(signer)
)
setSignedStatus(
isCompletelySigned ? SigitStatus.Complete : SigitStatus.Partial
)
} catch (error) {
if (error instanceof SigitMetaParseError) {
toast.error(error.message)
}
console.error(error)
}
})()
}, [meta])
return {
modifiedAt: meta.modifiedAt,
createSignature: meta.createSignature,
docSignatures: meta.docSignatures,
keys: meta.keys,
isValid,
kind,
tags,
created_at,
pubkey,
id,
sig,
signers,
viewers,
fileHashes,
markConfig,
title,
zipUrl,
signedStatus,
signersStatus
}
}

View File

@ -101,6 +101,15 @@ button:disabled {
color: inherit !important; color: inherit !important;
} }
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.profile-image { .profile-image {
width: 40px; width: 40px;
height: 40px; height: 40px;

View File

@ -5,7 +5,7 @@ $default-modal-padding: 15px 25px;
.modal { .modal {
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: calc(50% - 10px);
transform: translate(-50%, 0); transform: translate(-50%, 0);
background-color: $overlay-background-color; background-color: $overlay-background-color;
@ -16,6 +16,8 @@ $default-modal-padding: 15px 25px;
flex-direction: column; flex-direction: column;
border-radius: 4px; border-radius: 4px;
margin: 25px 10px;
} }
.header { .header {

View File

@ -24,7 +24,7 @@ import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input' import { MuiFileInput } from 'mui-file-input'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { DndProvider, DragSourceMonitor, useDrag, useDrop } from 'react-dnd' import { DndProvider, useDrag, useDrop } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend' import { HTML5Backend } from 'react-dnd-html5-backend'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
@ -50,7 +50,7 @@ import {
getHash, getHash,
hexToNpub, hexToNpub,
isOnline, isOnline,
now, unixNow,
npubToHex, npubToHex,
queryNip05, queryNip05,
sendNotification, sendNotification,
@ -68,7 +68,7 @@ import { Mark } from '../../types/mark.ts'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { uploadedFile } = location.state || {} const { uploadedFiles } = location.state || {}
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -134,10 +134,10 @@ export const CreatePage = () => {
}) })
useEffect(() => { useEffect(() => {
if (uploadedFile) { if (uploadedFiles) {
setSelectedFiles([uploadedFile]) setSelectedFiles([...uploadedFiles])
} }
}, [uploadedFile]) }, [uploadedFiles])
useEffect(() => { useEffect(() => {
if (usersPubkey) { if (usersPubkey) {
@ -407,7 +407,6 @@ export const CreatePage = () => {
encryptionKey: string encryptionKey: string
): Promise<File | null> => { ): Promise<File | null> => {
// Get the current timestamp in seconds // Get the current timestamp in seconds
const unixNow = now()
const blob = new Blob([encryptedArrayBuffer]) const blob = new Blob([encryptedArrayBuffer])
// Create a File object with the Blob data // Create a File object with the Blob data
const file = new File([blob], `compressed.sigit`, { const file = new File([blob], `compressed.sigit`, {
@ -455,10 +454,9 @@ export const CreatePage = () => {
const uploadFile = async ( const uploadFile = async (
arrayBuffer: ArrayBuffer arrayBuffer: ArrayBuffer
): Promise<string | null> => { ): Promise<string | null> => {
const unixNow = now()
const blob = new Blob([arrayBuffer]) const blob = new Blob([arrayBuffer])
// Create a File object with the Blob data // Create a File object with the Blob data
const file = new File([blob], `compressed-${unixNow}.sigit`, { const file = new File([blob], `compressed-${unixNow()}.sigit`, {
type: 'application/sigit' type: 'application/sigit'
}) })
@ -485,7 +483,7 @@ export const CreatePage = () => {
return return
} }
saveAs(finalZipFile, `request-${now()}.sigit.zip`) saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`)
setIsLoading(false) setIsLoading(false)
} }
@ -615,7 +613,7 @@ export const CreatePage = () => {
const meta: Meta = { const meta: Meta = {
createSignature, createSignature,
keys, keys,
modifiedAt: now(), modifiedAt: unixNow(),
docSignatures: {} docSignatures: {}
} }
@ -654,7 +652,7 @@ export const CreatePage = () => {
const meta: Meta = { const meta: Meta = {
createSignature, createSignature,
modifiedAt: now(), modifiedAt: unixNow(),
docSignatures: {} docSignatures: {}
} }
@ -979,7 +977,7 @@ const SignerRow = ({
item: () => { item: () => {
return { id: user.pubkey, index } return { id: user.pubkey, index }
}, },
collect: (monitor: DragSourceMonitor) => ({ collect: (monitor) => ({
isDragging: monitor.isDragging() isDragging: monitor.isDragging()
}) })
}) })

View File

@ -1,384 +1,267 @@
import { CalendarMonth, Description, Upload } from '@mui/icons-material' import { Button, TextField } from '@mui/material'
import { Box, Button, Tooltip, Typography } from '@mui/material'
import JSZip from 'jszip' import JSZip from 'jszip'
import { Event, kinds, verifyEvent } from 'nostr-tools' import { useCallback, useEffect, useState } from 'react'
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { UserAvatar } from '../../components/UserAvatar'
import { MetadataController } from '../../controllers'
import { useAppSelector } from '../../hooks' import { useAppSelector } from '../../hooks'
import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import { CreateSignatureEventContent, Meta, ProfileMetadata } from '../../types' import { Meta } from '../../types'
import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
formatTimestamp, import { faSearch } from '@fortawesome/free-solid-svg-icons'
hexToNpub, import { Select } from '../../components/Select'
npubToHex, import { DisplaySigit } from '../../components/DisplaySigit'
parseJson, import { useDropzone } from 'react-dropzone'
shorten
} from '../../utils'
import styles from './style.module.scss'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import styles from './style.module.scss'
import {
extractSigitCardDisplayInfo,
SigitCardDisplayInfo,
SigitStatus
} from '../../utils'
// Unsupported Filter options are commented
const FILTERS = [
'Show all',
// 'Drafts',
'In-progress',
'Completed'
// 'Archived'
] as const
type Filter = (typeof FILTERS)[number]
const SORT_BY = [
{
label: 'Newest',
value: 'desc'
},
{ label: 'Oldest', value: 'asc' }
] as const
type Sort = (typeof SORT_BY)[number]['value']
export const HomePage = () => { export const HomePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null) const [searchParams, setSearchParams] = useSearchParams()
const [sigits, setSigits] = useState<Meta[]>([]) const q = searchParams.get('q') ?? ''
const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>(
{} useEffect(() => {
) const searchInput = document.getElementById('q') as HTMLInputElement | null
if (searchInput) {
searchInput.value = q
}
}, [q])
const [sigits, setSigits] = useState<{ [key: string]: Meta }>({})
const [parsedSigits, setParsedSigits] = useState<{
[key: string]: SigitCardDisplayInfo
}>({})
const usersAppData = useAppSelector((state) => state.userAppData) const usersAppData = useAppSelector((state) => state.userAppData)
useEffect(() => { useEffect(() => {
if (usersAppData) { if (usersAppData) {
setSigits(Object.values(usersAppData.sigits)) const getSigitInfo = async () => {
const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {}
for (const key in usersAppData.sigits) {
if (Object.prototype.hasOwnProperty.call(usersAppData.sigits, key)) {
const sigitInfo = await extractSigitCardDisplayInfo(
usersAppData.sigits[key]
)
if (sigitInfo) {
parsedSigits[key] = sigitInfo
}
}
}
setParsedSigits({
...parsedSigits
})
}
setSigits(usersAppData.sigits)
getSigitInfo()
} }
}, [usersAppData]) }, [usersAppData])
const handleUploadClick = () => { const onDrop = useCallback(
if (fileInputRef.current) { async (acceptedFiles: File[]) => {
fileInputRef.current.click() // When uploading single file check if it's .sigit.zip
} if (acceptedFiles.length === 1) {
} const file = acceptedFiles[0]
const handleFileChange = async ( // Check if the file extension is .sigit.zip
event: React.ChangeEvent<HTMLInputElement> const fileName = file.name
) => { const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters
const file = event.target.files?.[0] if (fileExtension === '.sigit.zip') {
if (file) { const zip = await JSZip.loadAsync(file).catch((err) => {
// Check if the file extension is .sigit.zip console.log('err in loading zip file :>> ', err)
const fileName = file.name toast.error(err.message || 'An error occurred in loading zip file.')
const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters return null
if (fileExtension === '.sigit.zip') {
const zip = await JSZip.loadAsync(file).catch((err) => {
console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.')
return null
})
if (!zip) return
// navigate to sign page if zip contains keys.json
if ('keys.json' in zip.files) {
return navigate(appPrivateRoutes.sign, {
state: { uploadedZip: file }
}) })
}
// navigate to verify page if zip contains meta.json if (!zip) return
if ('meta.json' in zip.files) {
return navigate(appPublicRoutes.verify, {
state: { uploadedZip: file }
})
}
toast.error('Invalid zip file') // navigate to sign page if zip contains keys.json
return if ('keys.json' in zip.files) {
return navigate(appPrivateRoutes.sign, {
state: { uploadedZip: file }
})
}
// navigate to verify page if zip contains meta.json
if ('meta.json' in zip.files) {
return navigate(appPublicRoutes.verify, {
state: { uploadedZip: file }
})
}
toast.error('Invalid SiGit zip file')
return
}
} }
// navigate to create page // navigate to create page
navigate(appPrivateRoutes.create, { state: { uploadedFile: file } }) navigate(appPrivateRoutes.create, {
} state: { uploadedFiles: acceptedFiles }
}
return (
<Container className={styles.container}>
<Box className={styles.header}>
<Typography variant="h3" className={styles.title}>
Sigits
</Typography>
{/* This is for desktop view */}
<Box
className={styles.actionButtons}
sx={{
display: {
xs: 'none',
md: 'flex'
}
}}
>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<Button
variant="outlined"
startIcon={<Upload />}
onClick={handleUploadClick}
>
Upload
</Button>
</Box>
{/* This is for mobile view */}
<Box
className={styles.actionButtons}
sx={{
display: {
xs: 'flex',
md: 'none'
}
}}
>
<Tooltip title="Upload" arrow>
<Button variant="outlined" onClick={handleUploadClick}>
<Upload />
</Button>
</Tooltip>
</Box>
</Box>
<Box className={styles.submissions}>
{sigits.map((sigit, index) => (
<DisplaySigit
key={`sigit-${index}`}
meta={sigit}
profiles={profiles}
setProfiles={setProfiles}
/>
))}
</Box>
</Container>
)
}
type SigitProps = {
meta: Meta
profiles: { [key: string]: ProfileMetadata }
setProfiles: Dispatch<SetStateAction<{ [key: string]: ProfileMetadata }>>
}
enum SignedStatus {
Partial = 'Partially Signed',
Complete = 'Completely Signed'
}
const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => {
const navigate = useNavigate()
const [title, setTitle] = useState<string>()
const [createdAt, setCreatedAt] = useState('')
const [submittedBy, setSubmittedBy] = useState<string>()
const [signers, setSigners] = useState<`npub1${string}`[]>([])
const [signedStatus, setSignedStatus] = useState<SignedStatus>(
SignedStatus.Partial
)
useEffect(() => {
const extractInfo = async () => {
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
}) })
},
[navigate]
)
if (!createSignatureEvent) return const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
onDrop,
noClick: true
})
// created_at in nostr events are stored in seconds const [filter, setFilter] = useState<Filter>('Show all')
// convert it to ms before formatting const [sort, setSort] = useState<Sort>('desc')
setCreatedAt(formatTimestamp(createSignatureEvent.created_at * 1000))
const createSignatureContent =
await parseJson<CreateSignatureEventContent>(
createSignatureEvent.content
).catch((err) => {
console.log(
`err in parsing the createSignature event's content :>> `,
err
)
return null
})
if (!createSignatureContent) return
setTitle(createSignatureContent.title)
setSubmittedBy(createSignatureEvent.pubkey)
setSigners(createSignatureContent.signers)
const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[]
const isCompletelySigned = createSignatureContent.signers.every(
(signer) => signedBy.includes(signer)
)
if (isCompletelySigned) {
setSignedStatus(SignedStatus.Complete)
}
}
extractInfo()
}, [meta])
useEffect(() => {
const hexKeys: string[] = []
if (submittedBy) {
hexKeys.push(npubToHex(submittedBy)!)
}
hexKeys.push(...signers.map((signer) => npubToHex(signer)!))
const metadataController = new MetadataController()
hexKeys.forEach((key) => {
if (!(key in profiles)) {
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setProfiles((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)
})
}
})
}, [submittedBy, signers])
const handleNavigation = () => {
if (signedStatus === SignedStatus.Complete) {
navigate(appPublicRoutes.verify, { state: { meta } })
} else {
navigate(appPrivateRoutes.sign, { state: { meta } })
}
}
return ( return (
<Box <div {...getRootProps()} tabIndex={-1}>
className={styles.item} <Container className={styles.container}>
sx={{ <div className={styles.header}>
flexDirection: { <div className={styles.filters}>
xs: 'column', <Select
md: 'row' name={'filter-select'}
} value={filter}
}} setValue={setFilter}
onClick={handleNavigation} options={FILTERS.map((f) => {
> return {
<Box label: f,
className={styles.titleBox} value: f
sx={{
borderBottomLeftRadius: {
xs: 'initial',
md: 'inherit'
},
borderTopRightRadius: {
xs: 'inherit',
md: 'initial'
}
}}
>
<Typography variant="body1" className={styles.title}>
<Description />
{title}
</Typography>
{submittedBy &&
(function () {
const profile = profiles[submittedBy]
return (
<UserAvatar
pubkey={submittedBy}
name={
profile?.display_name ||
profile?.name ||
shorten(hexToNpub(submittedBy))
} }
image={profile?.picture} })}
/>
)
})()}
<Typography variant="body2" className={styles.date}>
<CalendarMonth />
{createdAt}
</Typography>
</Box>
<Box className={styles.signers}>
{signers.map((signer) => {
const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
return (
<DisplaySigner
key={signer}
meta={meta}
profile={profile}
pubkey={pubkey}
/> />
) <Select
})} name={'sort-select'}
</Box> value={sort}
</Box> setValue={setSort}
) options={SORT_BY.map((s) => {
} return { ...s }
})}
enum SignStatus { />
Signed = 'Signed', </div>
Pending = 'Pending', <div className={styles.actionButtons}>
Invalid = 'Invalid Sign' <form
} className={styles.search}
onSubmit={(e) => {
type DisplaySignerProps = { e.preventDefault()
meta: Meta const searchInput = e.currentTarget.elements.namedItem(
profile: ProfileMetadata 'q'
pubkey: string ) as HTMLInputElement
} searchParams.set('q', searchInput.value)
setSearchParams(searchParams)
const DisplaySigner = ({ meta, profile, pubkey }: DisplaySignerProps) => { }}
const [signStatus, setSignedStatus] = useState<SignStatus>() >
<TextField
useEffect(() => { id="q"
const updateSignStatus = async () => { name="q"
const npub = hexToNpub(pubkey) placeholder="Search"
if (npub in meta.docSignatures) { size="small"
parseJson<Event>(meta.docSignatures[npub]) type="search"
.then((event) => { defaultValue={q}
const isValidSignature = verifyEvent(event) onChange={(e) => {
if (isValidSignature) { // Handle the case when users click native search input's clear or x
setSignedStatus(SignStatus.Signed) if (e.currentTarget.value === '') {
} else { searchParams.delete('q')
setSignedStatus(SignStatus.Invalid) setSearchParams(searchParams)
} }
}) }}
.catch((err) => { sx={{
console.log(`err in parsing the docSignatures for ${npub}:>> `, err) width: '100%',
setSignedStatus(SignStatus.Invalid) fontSize: '16px',
}) borderTopLeftRadius: 'var(----mui-shape-borderRadius)',
} else { borderBottomLeftRadius: 'var(----mui-shape-borderRadius)',
setSignedStatus(SignStatus.Pending) '& .MuiInputBase-root': {
} borderTopRightRadius: 0,
} borderBottomRightRadius: 0
},
updateSignStatus() '& .MuiInputBase-input': {
}, [meta, pubkey]) padding: '7px 14px'
},
return ( '& .MuiOutlinedInput-notchedOutline': {
<Box className={styles.signerItem}> display: 'none'
<Typography variant="button" className={styles.status}> }
{signStatus} }}
</Typography> />
<UserAvatar <Button
pubkey={pubkey} type="submit"
name={ sx={{
profile?.display_name || minWidth: '44px',
profile?.name || padding: '11.5px 12px',
shorten(hexToNpub(pubkey), 5) borderTopLeftRadius: 0,
} borderBottomLeftRadius: 0
image={profile?.picture} }}
/> variant={'contained'}
</Box> aria-label="submit search"
>
<FontAwesomeIcon icon={faSearch} />
</Button>
</form>
</div>
</div>
<button
className={`${styles.dropzone} ${isDragActive ? styles.isDragActive : ''}`}
tabIndex={0}
onClick={open}
type="button"
aria-label="upload files"
>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop the files here ...</p>
) : (
<p>Click or drag files to upload!</p>
)}
</button>
<div className={styles.submissions}>
{Object.keys(parsedSigits)
.filter((s) => {
const { title, signedStatus } = parsedSigits[s]
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
switch (filter) {
case 'Completed':
return signedStatus === SigitStatus.Complete && isMatch
case 'In-progress':
return signedStatus === SigitStatus.Partial && isMatch
case 'Show all':
return isMatch
default:
console.error('Filter case not handled.')
}
})
.sort((a, b) => {
const x = parsedSigits[a].createdAt ?? 0
const y = parsedSigits[b].createdAt ?? 0
return sort === 'desc' ? y - x : x - y
})
.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))}
</div>
</Container>
</div>
) )
} }

View File

@ -1,94 +1,101 @@
@import '../../styles/colors.scss';
.container { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 25px; gap: 25px;
container-type: inline-size;
} }
.header { .header {
display: flex; display: flex;
gap: 10px;
.title { @container (width < 610px) {
color: var(--mui-palette-primary-light); flex-direction: column-reverse;
flex: 1; }
}
.filters {
display: flex;
gap: 10px;
}
.actionButtons {
display: flex;
justify-content: end;
align-items: center;
gap: 10px;
padding: 1.5px 0;
flex-grow: 1;
}
.search {
display: flex;
align-items: center;
justify-content: end;
height: 34px;
overflow: hidden;
border-radius: 4px;
outline: solid 1px #dddddd;
background: white;
width: 100%;
@container (width >= 610px) {
max-width: 246px;
} }
.actionButtons { &:focus-within {
justify-content: center; outline-color: $primary-main;
align-items: center;
gap: 10px;
} }
} }
.dropzone {
position: relative;
font-size: 16px;
background-color: $overlay-background-color;
height: 250px;
color: rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
&::before {
content: '';
position: absolute;
transition:
background-color ease 0.2s,
inset ease 0.2s;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
border: dashed 3px rgba(0, 0, 0, 0.1);
inset: 15px;
}
&:focus,
&.isDragActive,
&:hover {
&::before {
inset: 10px;
background: rgba(0, 0, 0, 0.15);
}
}
// Override button styles
padding: 0;
border: none;
outline: none;
letter-spacing: 1px;
font-weight: 500;
font-family: inherit;
}
.submissions { .submissions {
display: flex; display: grid;
flex-direction: column; gap: 25px;
gap: 10px; grid-template-columns: repeat(auto-fit, minmax(365px, 1fr));
.item {
display: flex;
gap: 10px;
background-color: #efeae6;
border-radius: 1rem;
cursor: pointer;
.titleBox {
display: flex;
flex: 4;
flex-direction: column;
align-items: center;
overflow-wrap: anywhere;
gap: 10px;
padding: 10px;
background-color: #cdc8c499;
border-top-left-radius: inherit;
.title {
display: flex;
justify-content: center;
align-items: center;
color: var(--mui-palette-primary-light);
font-size: 1.5rem;
svg {
font-size: 1.5rem;
}
}
.date {
display: flex;
justify-content: center;
align-items: center;
color: var(--mui-palette-primary-light);
font-size: 1rem;
svg {
font-size: 1rem;
}
}
}
.signers {
display: flex;
flex-direction: column;
flex: 6;
justify-content: center;
gap: 10px;
padding: 10px;
color: var(--mui-palette-primary-light);
.signerItem {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
.status {
border-radius: 2rem;
width: 100px;
text-align: center;
background-color: var(--mui-palette-info-light);
}
}
}
}
} }

View File

@ -51,7 +51,7 @@ export const Nostr = () => {
/** /**
* Call login function when enter is pressed * Call login function when enter is pressed
*/ */
const handleInputKeyDown = (event: any) => { const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') { if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault() event.preventDefault()
login() login()

View File

@ -12,7 +12,7 @@ import {
useTheme useTheme
} from '@mui/material' } from '@mui/material'
import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools' import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { MetadataController, NostrController } from '../../../controllers' import { MetadataController, NostrController } from '../../../controllers'
@ -26,7 +26,7 @@ import { setMetadataEvent } from '../../../store/actions'
import { LoadingSpinner } from '../../../components/LoadingSpinner' import { LoadingSpinner } from '../../../components/LoadingSpinner'
import { LoginMethods } from '../../../store/auth/types' import { LoginMethods } from '../../../store/auth/types'
import { SmartToy } from '@mui/icons-material' import { SmartToy } from '@mui/icons-material'
import { getRoboHashPicture } from '../../../utils' import { getRoboHashPicture, unixNow } from '../../../utils'
import { Container } from '../../../components/Container' import { Container } from '../../../components/Container'
export const ProfileSettingsPage = () => { export const ProfileSettingsPage = () => {
@ -197,7 +197,7 @@ export const ProfileSettingsPage = () => {
// Relay will reject if created_at is too late // Relay will reject if created_at is too late
const updatedMetadataState: UnsignedEvent = { const updatedMetadataState: UnsignedEvent = {
content: content, content: content,
created_at: Math.round(Date.now() / 1000), created_at: unixNow(),
kind: kinds.Metadata, kind: kinds.Metadata,
pubkey: pubkey!, pubkey: pubkey!,
tags: metadataState?.tags || [] tags: metadataState?.tags || []
@ -321,8 +321,8 @@ export const ProfileSettingsPage = () => {
}} }}
> >
<img <img
onError={(event: any) => { onError={(event: React.SyntheticEvent<HTMLImageElement>) => {
event.target.src = getRoboHashPicture(npub!) event.currentTarget.src = getRoboHashPicture(npub!)
}} }}
className={styles.img} className={styles.img}
src={getProfileImage(profileMetadata)} src={getProfileImage(profileMetadata)}

View File

@ -26,7 +26,7 @@ import {
hexToNpub, hexToNpub,
isOnline, isOnline,
loadZip, loadZip,
now, unixNow,
npubToHex, npubToHex,
parseJson, parseJson,
readContentOfZipEntry, readContentOfZipEntry,
@ -554,7 +554,7 @@ export const SignPage = () => {
...metaCopy.docSignatures, ...metaCopy.docSignatures,
[hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2)
} }
metaCopy.modifiedAt = now() metaCopy.modifiedAt = unixNow()
return metaCopy return metaCopy
} }
@ -564,7 +564,6 @@ export const SignPage = () => {
encryptionKey: string encryptionKey: string
): Promise<File | null> => { ): Promise<File | null> => {
// Get the current timestamp in seconds // Get the current timestamp in seconds
const unixNow = now()
const blob = new Blob([encryptedArrayBuffer]) const blob = new Blob([encryptedArrayBuffer])
// Create a File object with the Blob data // Create a File object with the Blob data
const file = new File([blob], `compressed.sigit`, { const file = new File([blob], `compressed.sigit`, {
@ -614,7 +613,7 @@ export const SignPage = () => {
if (!arraybuffer) return null if (!arraybuffer) return null
return new File([new Blob([arraybuffer])], `${unixNow}.sigit.zip`, { return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, {
type: 'application/zip' type: 'application/zip'
}) })
} }
@ -758,8 +757,7 @@ export const SignPage = () => {
if (!arrayBuffer) return if (!arrayBuffer) return
const blob = new Blob([arrayBuffer]) const blob = new Blob([arrayBuffer])
const unixNow = now() saveAs(blob, `exported-${unixNow()}.sigit.zip`)
saveAs(blob, `exported-${unixNow}.sigit.zip`)
setIsLoading(false) setIsLoading(false)
@ -804,8 +802,7 @@ export const SignPage = () => {
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) return if (!finalZipFile) return
const unixNow = now() saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
saveAs(finalZipFile, `exported-${unixNow}.sigit.zip`)
} }
/** /**

View File

@ -28,7 +28,7 @@ import {
extractZipUrlAndEncryptionKey, extractZipUrlAndEncryptionKey,
getHash, getHash,
hexToNpub, hexToNpub,
now, unixNow,
npubToHex, npubToHex,
parseJson, parseJson,
readContentOfZipEntry, readContentOfZipEntry,
@ -239,7 +239,7 @@ export const VerifyPage = () => {
} }
}) })
} }
}, [submittedBy, signers, viewers]) }, [submittedBy, signers, viewers, metadata])
const handleVerify = async () => { const handleVerify = async () => {
if (!selectedFile) return if (!selectedFile) return
@ -445,7 +445,7 @@ export const VerifyPage = () => {
if (!arrayBuffer) return if (!arrayBuffer) return
const blob = new Blob([arrayBuffer]) const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${now()}.sigit.zip`) saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false) setIsLoading(false)
} }

View File

@ -23,14 +23,6 @@ export const theme = extendTheme({
} }
}, },
components: { components: {
MuiModal: {
styleOverrides: {
root: {
insetBlock: '25px',
insetInline: '10px'
}
}
},
MuiButton: { MuiButton: {
styleOverrides: { styleOverrides: {
root: { root: {
@ -41,6 +33,9 @@ export const theme = extendTheme({
boxShadow: 'unset', boxShadow: 'unset',
lineHeight: 'inherit', lineHeight: 'inherit',
borderRadius: '4px', borderRadius: '4px',
':focus': {
textDecoration: 'none'
},
':hover': { ':hover': {
background: 'var(--primary-light)', background: 'var(--primary-light)',
boxShadow: 'unset' boxShadow: 'unset'

View File

@ -7,3 +7,4 @@ export * from './string'
export * from './zip' export * from './zip'
export * from './utils' export * from './utils'
export * from './mark' export * from './mark'
export * from './meta'

181
src/utils/meta.ts Normal file
View File

@ -0,0 +1,181 @@
import { CreateSignatureEventContent, Meta } from '../types'
import { fromUnixTimestamp, parseJson } from '.'
import { Event } from 'nostr-tools'
import { toast } from 'react-toastify'
export enum SignStatus {
Signed = 'Signed',
Pending = 'Pending',
Invalid = 'Invalid Sign'
}
export enum SigitStatus {
Partial = 'In-Progress',
Complete = 'Completed'
}
type Jsonable =
| string
| number
| boolean
| null
| undefined
| readonly Jsonable[]
| { readonly [key: string]: Jsonable }
| { toJSON(): Jsonable }
export class SigitMetaParseError extends Error {
public readonly context?: Jsonable
constructor(
message: string,
options: { cause?: Error; context?: Jsonable } = {}
) {
const { cause, context } = options
super(message, { cause })
this.name = this.constructor.name
this.context = context
}
}
/**
* Handle meta errors
* Wraps the errors without message property and stringify to a message so we can use it later
* @param error
* @returns
*/
function handleError(error: unknown): Error {
if (error instanceof Error) return error
// No message error, wrap it and stringify
let stringified = 'Unable to stringify the thrown value'
try {
stringified = JSON.stringify(error)
} catch (error) {
console.error(stringified, error)
}
return new Error(`[SiGit Error]: ${stringified}`)
}
// Reuse common error messages for meta parsing
export enum SigitMetaParseErrorType {
'PARSE_ERROR_SIGNATURE_EVENT' = 'error occurred in parsing the create signature event',
'PARSE_ERROR_SIGNATURE_EVENT_CONTENT' = "err in parsing the createSignature event's content"
}
export interface SigitCardDisplayInfo {
createdAt?: number
title?: string
submittedBy?: string
signers: `npub1${string}`[]
fileExtensions: string[]
signedStatus: SigitStatus
}
/**
* Wrapper for createSignatureEvent parse that throws custom SigitMetaParseError with cause and context
* @param raw Raw string for parsing
* @returns parsed Event
*/
export const parseCreateSignatureEvent = async (
raw: string
): Promise<Event> => {
try {
const createSignatureEvent = await parseJson<Event>(raw)
return createSignatureEvent
} catch (error) {
throw new SigitMetaParseError(
SigitMetaParseErrorType.PARSE_ERROR_SIGNATURE_EVENT,
{
cause: handleError(error),
context: raw
}
)
}
}
/**
* Wrapper for event content parser that throws custom SigitMetaParseError with cause and context
* @param raw Raw string for parsing
* @returns parsed CreateSignatureEventContent
*/
export const parseCreateSignatureEventContent = async (
raw: string
): Promise<CreateSignatureEventContent> => {
try {
const createSignatureEventContent =
await parseJson<CreateSignatureEventContent>(raw)
return createSignatureEventContent
} catch (error) {
throw new SigitMetaParseError(
SigitMetaParseErrorType.PARSE_ERROR_SIGNATURE_EVENT_CONTENT,
{
cause: handleError(error),
context: raw
}
)
}
}
/**
* Extracts only necessary metadata for the card display
* @param meta Sigit metadata
* @returns SigitCardDisplayInfo
*/
export const extractSigitCardDisplayInfo = async (meta: Meta) => {
if (!meta?.createSignature) return
const sigitInfo: SigitCardDisplayInfo = {
signers: [],
fileExtensions: [],
signedStatus: SigitStatus.Partial
}
try {
const createSignatureEvent = await parseCreateSignatureEvent(
meta.createSignature
)
// created_at in nostr events are stored in seconds
sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at)
const createSignatureContent = await parseCreateSignatureEventContent(
createSignatureEvent.content
)
const files = Object.keys(createSignatureContent.fileHashes)
const extensions = files.reduce((result: string[], file: string) => {
const extension = file.split('.').pop()
if (extension) {
result.push(extension)
}
return result
}, [])
const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[]
const isCompletelySigned = createSignatureContent.signers.every((signer) =>
signedBy.includes(signer)
)
sigitInfo.title = createSignatureContent.title
sigitInfo.submittedBy = createSignatureEvent.pubkey
sigitInfo.signers = createSignatureContent.signers
sigitInfo.fileExtensions = extensions
if (isCompletelySigned) {
sigitInfo.signedStatus = SigitStatus.Complete
}
return sigitInfo
} catch (error) {
if (error instanceof SigitMetaParseError) {
toast.error(error.message)
console.error(error.name, error.message, error.cause, error.context)
} else {
console.error('Unexpected error', error)
}
}
}

View File

@ -13,7 +13,7 @@ import { NostrController } from '../controllers'
import { AuthState } from '../store/auth/types' import { AuthState } from '../store/auth/types'
import store from '../store/store' import store from '../store/store'
import { CreateSignatureEventContent, Meta } from '../types' import { CreateSignatureEventContent, Meta } from '../types'
import { hexToNpub, now } from './nostr' import { hexToNpub, unixNow } from './nostr'
import { parseJson } from './string' import { parseJson } from './string'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
@ -28,10 +28,10 @@ export const uploadToFileStorage = async (file: File) => {
const event: EventTemplate = { const event: EventTemplate = {
kind: 24242, kind: 24242,
content: 'Authorize Upload', content: 'Authorize Upload',
created_at: Math.floor(Date.now() / 1000), created_at: unixNow(),
tags: [ tags: [
['t', 'upload'], ['t', 'upload'],
['expiration', String(now() + 60 * 5)], // Set expiration time to 5 minutes from now ['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now
['name', file.name], ['name', file.name],
['size', String(file.size)] ['size', String(file.size)]
] ]
@ -78,7 +78,7 @@ export const signEventForMetaFile = async (
const event: EventTemplate = { const event: EventTemplate = {
kind: 27235, // Event type for meta file kind: 27235, // Event type for meta file
content: content, // content for event content: content, // content for event
created_at: Math.floor(Date.now() / 1000), // Current timestamp created_at: unixNow(), // Current timestamp
tags: [['-']] // For understanding why "-" tag is used here see: https://github.com/nostr-protocol/nips/blob/protected-events-tag/70.md tags: [['-']] // For understanding why "-" tag is used here see: https://github.com/nostr-protocol/nips/blob/protected-events-tag/70.md
} }

View File

@ -120,7 +120,8 @@ export const queryNip05 = async (
if (!match) throw new Error('Invalid nip05') if (!match) throw new Error('Invalid nip05')
// Destructure the match result, assigning default value '_' to name if not provided // Destructure the match result, assigning default value '_' to name if not provided
const [name = '_', domain] = match // First variable from the match destructuring is ignored
const [, name = '_', domain] = match
// Construct the URL to query the NIP-05 data // Construct the URL to query the NIP-05 data
const url = `https://${domain}/.well-known/nostr.json?name=${name}` const url = `https://${domain}/.well-known/nostr.json?name=${name}`
@ -210,7 +211,22 @@ export const getRoboHashPicture = (
return `https://robohash.org/${npub}.png?set=set${set}` return `https://robohash.org/${npub}.png?set=set${set}`
} }
export const now = () => Math.round(Date.now() / 1000) export const unixNow = () => Math.round(Date.now() / 1000)
export const toUnixTimestamp = (date: number | Date) => {
let time
if (typeof date === 'number') {
time = Math.round(date / 1000)
} else if (date instanceof Date) {
time = Math.round(date.getTime() / 1000)
} else {
throw Error('Unsupported type when converting to unix timestamp')
}
return time
}
export const fromUnixTimestamp = (unix: number) => {
return unix * 1000
}
/** /**
* Generate nip44 conversation key * Generate nip44 conversation key
@ -287,7 +303,7 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
kind: 1059, // Event kind kind: 1059, // Event kind
content, // Encrypted content content, // Encrypted content
pubkey, // Public key of the creator pubkey, // Public key of the creator
created_at: now(), // Current timestamp created_at: unixNow(), // Current timestamp
tags: [ tags: [
// Tags including receiver and nonce // Tags including receiver and nonce
['p', receiver], ['p', receiver],
@ -541,7 +557,7 @@ export const updateUsersAppData = async (meta: Meta) => {
const updatedEvent: UnsignedEvent = { const updatedEvent: UnsignedEvent = {
kind: kinds.Application, kind: kinds.Application,
pubkey: usersPubkey!, pubkey: usersPubkey!,
created_at: now(), created_at: unixNow(),
tags: [['d', hash]], tags: [['d', hash]],
content: encryptedContent content: encryptedContent
} }
@ -607,10 +623,10 @@ const deleteBlossomFile = async (url: string, privateKey: string) => {
const event: EventTemplate = { const event: EventTemplate = {
kind: 24242, kind: 24242,
content: 'Authorize Upload', content: 'Authorize Upload',
created_at: now(), created_at: unixNow(),
tags: [ tags: [
['t', 'delete'], ['t', 'delete'],
['expiration', String(now() + 60 * 5)], // Set expiration time to 5 minutes from now ['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now
['x', hash] ['x', hash]
] ]
} }
@ -666,10 +682,10 @@ const uploadUserAppDataToBlossom = async (
const event: EventTemplate = { const event: EventTemplate = {
kind: 24242, kind: 24242,
content: 'Authorize Upload', content: 'Authorize Upload',
created_at: now(), created_at: unixNow(),
tags: [ tags: [
['t', 'upload'], ['t', 'upload'],
['expiration', String(now() + 60 * 5)], // Set expiration time to 5 minutes from now ['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now
['name', file.name], ['name', file.name],
['size', String(file.size)] ['size', String(file.size)]
] ]
@ -874,7 +890,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
pubkey: usersPubkey, pubkey: usersPubkey,
content: JSON.stringify(meta), content: JSON.stringify(meta),
tags: [], tags: [],
created_at: now() created_at: unixNow()
} }
// Wrap the unsigned event with the receiver's information // Wrap the unsigned event with the receiver's information