diff --git a/.git-hooks/commit-msg b/.git-hooks/commit-msg index c4c6ce6..5ee2d7c 100755 --- a/.git-hooks/commit-msg +++ b/.git-hooks/commit-msg @@ -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 tput setaf 2; - echo -e "${GREEN} ✔ Commit message meets Conventional Commit standards" + echo "✔ Commit message meets Conventional Commit standards" tput sgr0; exit 0 fi 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; echo "An example of a valid message is:" 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 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7c16799..ef46577 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "2.5.0", "@reduxjs/toolkit": "2.2.1", - "axios": "1.6.7", + "axios": "^1.7.4", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", "dnd-core": "16.0.1", @@ -37,6 +37,7 @@ "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-redux": "9.1.0", "react-router-dom": "6.22.1", "react-toastify": "10.0.4", @@ -2680,12 +2681,22 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "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": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", - "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -3857,6 +3868,24 @@ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", "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": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5716,6 +5745,23 @@ "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": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/package.json b/package.json index 976298b..447fdda 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite", "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: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}\"", @@ -31,7 +31,7 @@ "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "2.5.0", "@reduxjs/toolkit": "2.2.1", - "axios": "1.6.7", + "axios": "^1.7.4", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", "dnd-core": "16.0.1", @@ -47,6 +47,7 @@ "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-redux": "9.1.0", "react-router-dom": "6.22.1", "react-toastify": "10.0.4", diff --git a/src/App.scss b/src/App.scss index f5394f7..6724890 100644 --- a/src/App.scss +++ b/src/App.scss @@ -56,10 +56,16 @@ a { text-decoration: none; text-decoration-color: inherit; transition: ease 0.4s; + outline: none; + &:focus, &:hover { color: $primary-light; text-decoration: underline; text-decoration-color: inherit; } } + +input { + font-family: inherit; +} diff --git a/src/components/Container/index.tsx b/src/components/Container/index.tsx index 57857b9..5b955bb 100644 --- a/src/components/Container/index.tsx +++ b/src/components/Container/index.tsx @@ -6,6 +6,18 @@ interface ContainerProps { 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 = ({ style = {}, className = '', diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx new file mode 100644 index 0000000..0fe5c2f --- /dev/null +++ b/src/components/DisplaySigit/index.tsx @@ -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([ + ...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 ( +
+ +

{title}

+
+ {submittedBy && + (function () { + const profile = profiles[submittedBy] + return ( + + + + + + ) + })()} + {submittedBy && signers.length ? ( + + ) : null} + + {signers.map((signer) => { + const pubkey = npubToHex(signer)! + const profile = profiles[pubkey] + + return ( + + + + + + ) + })} + +
+
+ + {createdAt ? formatTimestamp(createdAt) : null} +
+
+ + {signedStatus} + + {fileExtensions.length > 0 ? ( + + {fileExtensions.length > 1 ? ( + <> + Multiple File Types + + ) : ( + getExtensionIconLabel(fileExtensions[0]) + )} + + ) : null} +
+
+ + + + + + +
+
+ ) +} diff --git a/src/components/DisplaySigit/style.module.scss b/src/components/DisplaySigit/style.module.scss new file mode 100644 index 0000000..7544fc4 --- /dev/null +++ b/src/components/DisplaySigit/style.module.scss @@ -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; +} diff --git a/src/components/DisplaySigner/index.tsx b/src/components/DisplaySigner/index.tsx new file mode 100644 index 0000000..dc4b9ce --- /dev/null +++ b/src/components/DisplaySigner/index.tsx @@ -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() + + useEffect(() => { + if (!meta) return + + const updateSignStatus = async () => { + const npub = hexToNpub(pubkey) + if (npub in meta.docSignatures) { + parseJson(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 ( + + {signStatus === SignStatus.Signed && ( + + )} + {signStatus === SignStatus.Invalid && ( + + )} + + ) + } + > + + + ) +} diff --git a/src/components/DisplaySigner/style.module.scss b/src/components/DisplaySigner/style.module.scss new file mode 100644 index 0000000..fa62cab --- /dev/null +++ b/src/components/DisplaySigner/style.module.scss @@ -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; +} diff --git a/src/components/DrawPDFFields/style.module.scss b/src/components/DrawPDFFields/style.module.scss index e3e7856..08554b2 100644 --- a/src/components/DrawPDFFields/style.module.scss +++ b/src/components/DrawPDFFields/style.module.scss @@ -30,6 +30,7 @@ border: 1px solid rgba(0, 0, 0, 0.137); padding: 5px; cursor: pointer; + -webkit-user-select: none; user-select: none; &.selected { @@ -42,15 +43,15 @@ border-color: #01aaad79; } } - } } } .pdfImageWrapper { position: relative; + -webkit-user-select: none; user-select: none; - + &.drawing { cursor: crosshair; } @@ -94,7 +95,7 @@ background-color: #fff; border: 1px solid rgb(160, 160, 160); border-radius: 50%; - color: #E74C3C; + color: #e74c3c; font-size: 10px; cursor: pointer; } @@ -110,4 +111,4 @@ background: #fff; padding: 5px 0; } -} \ No newline at end of file +} diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx new file mode 100644 index 0000000..9901fa1 --- /dev/null +++ b/src/components/Select/index.tsx @@ -0,0 +1,113 @@ +import { + FormControl, + MenuItem, + Select as SelectMui, + SelectChangeEvent, + styled, + SelectProps as SelectMuiProps, + MenuItemProps +} from '@mui/material' + +const SelectCustomized = styled(SelectMui)(() => ({ + 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)(() => ({ + 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 { + value: T + label: string +} + +interface SelectProps { + value: T + setValue: React.Dispatch> + options: SelectItemProps[] + name?: string + id?: string +} + +export function Select({ + value, + setValue, + options, + name, + id +}: SelectProps) { + const handleChange = (event: SelectChangeEvent) => { + setValue(event.target.value as T) + } + + return ( + + + {options.map((o) => { + return ( + + {o.label} + + ) + })} + + + ) +} diff --git a/src/components/TooltipChild.tsx b/src/components/TooltipChild.tsx new file mode 100644 index 0000000..4b41b72 --- /dev/null +++ b/src/components/TooltipChild.tsx @@ -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( + ({ children, ...rest }, ref) => { + return ( + + {children} + + ) + } +) diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index 65e993d..9ae60ce 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -1,12 +1,11 @@ -import { useNavigate } from 'react-router-dom' import { getProfileRoute } from '../../routes' import styles from './styles.module.scss' -import React from 'react' import { AvatarIconButton } from '../UserAvatarIconButton' +import { Link } from 'react-router-dom' interface UserAvatarProps { - name: string + name?: string pubkey: string image?: string } @@ -16,27 +15,22 @@ interface UserAvatarProps { * Clicking will navigate to the user's profile. */ export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => { - const navigate = useNavigate() - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation() - navigate(getProfileRoute(pubkey)) - } - return ( -
+ - {name ? ( - - ) : null} -
+ {name ? : null} + ) } diff --git a/src/components/UserAvatar/styles.module.scss b/src/components/UserAvatar/styles.module.scss index 6147819..fbe8cf5 100644 --- a/src/components/UserAvatar/styles.module.scss +++ b/src/components/UserAvatar/styles.module.scss @@ -2,7 +2,7 @@ display: flex; align-items: center; gap: 10px; - flex-grow: 1; + // flex-grow: 1; } .username { diff --git a/src/components/UserAvatarGroup/index.tsx b/src/components/UserAvatarGroup/index.tsx new file mode 100644 index 0000000..13f8b25 --- /dev/null +++ b/src/components/UserAvatarGroup/index.tsx @@ -0,0 +1,38 @@ +import { Children, PropsWithChildren } from 'react' + +import styles from './style.module.scss' + +interface UserAvatarGroupProps extends React.HTMLAttributes { + max: number + renderSurplus?: ((surplus: number) => React.ReactNode) | undefined +} + +const defaultSurplus = (surplus: number) => { + return +{surplus} +} + +/** + * 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) => { + const total = Children.count(children) + const surplus = total - max + 1 + + const childrenArray = Children.toArray(children) + return ( +
+ {surplus > 1 + ? childrenArray.slice(0, surplus * -1).map((c) => c) + : children} + {surplus > 1 && renderSurplus(surplus)} +
+ ) +} diff --git a/src/components/UserAvatarGroup/style.module.scss b/src/components/UserAvatarGroup/style.module.scss new file mode 100644 index 0000000..9604202 --- /dev/null +++ b/src/components/UserAvatarGroup/style.module.scss @@ -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; +} diff --git a/src/components/UserAvatarIconButton/style.module.scss b/src/components/UserAvatarIconButton/style.module.scss index f8213f0..57f2688 100644 --- a/src/components/UserAvatarIconButton/style.module.scss +++ b/src/components/UserAvatarIconButton/style.module.scss @@ -2,6 +2,6 @@ width: 40px; height: 40px; border-radius: 50%; - border-width: 3px; + border-width: 2px; overflow: hidden; } diff --git a/src/components/getExtensionIconLabel.tsx b/src/components/getExtensionIconLabel.tsx new file mode 100644 index 0000000..e8a14eb --- /dev/null +++ b/src/components/getExtensionIconLabel.tsx @@ -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 ( + <> + {extension.toUpperCase()} + + ) +} diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index e0d2d79..33f5c82 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -12,7 +12,8 @@ import { getAuthToken, getVisitedLink, saveAuthToken, - compareObjects + compareObjects, + unixNow } from '../utils' import { appPrivateRoutes } from '../routes' import { SignedEvent } from '../types' @@ -54,7 +55,7 @@ export class AuthController { }) // Nostr uses unix timestamps - const timestamp = Math.floor(Date.now() / 1000) + const timestamp = unixNow() const { hostname } = window.location const authEvent: EventTemplate = { diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index 2360275..8f4d190 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -12,7 +12,7 @@ import { import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types' import { NostrController } from '.' import { toast } from 'react-toastify' -import { queryNip05 } from '../utils' +import { queryNip05, unixNow } from '../utils' import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' import { EventEmitter } from 'tseep' import { localCache } from '../services' @@ -194,7 +194,7 @@ export class MetadataController extends EventEmitter { let signedMetadataEvent = event 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 const newMetadataEvent: Event = { @@ -265,7 +265,7 @@ export class MetadataController extends EventEmitter { // initialize job request const jobEventTemplate: EventTemplate = { content: '', - created_at: Math.round(Date.now() / 1000), + created_at: unixNow(), kind: 68001, tags: [ ['i', `${created_at * 1000}`], diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index fa558de..19182b5 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -42,6 +42,7 @@ import { import { compareObjects, getNsecBunkerDelegatedKey, + unixNow, verifySignedEvent } from '../utils' import { getDefaultRelayMap } from '../utils/relays.ts' @@ -244,7 +245,7 @@ export class NostrController extends EventEmitter { if (!firstSuccessfulPublish) { // If no publish was successful, collect the reasons for failures - const failedPublishes: any[] = [] + const failedPublishes: unknown[] = [] const fallbackRejectionReason = '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) { const nostr = this.getNostrObject() - return (await nostr.signEvent(event as NostrEvent).catch((err: any) => { - console.log('Error while signing event: ', err) + return (await nostr + .signEvent(event as NostrEvent) + .catch((err: unknown) => { + console.log('Error while signing event: ', err) - throw err - })) as Event + throw err + })) as Event } else { return Promise.reject( `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 => { const nostr = this.getNostrObject() - const pubKey = await nostr.getPublicKey().catch((err: any) => { - return Promise.reject(err.message) + const pubKey = await nostr.getPublicKey().catch((err: unknown) => { + if (err instanceof Error) { + return Promise.reject(err.message) + } else { + return Promise.reject(JSON.stringify(err)) + } }) if (!pubKey) { @@ -708,7 +715,7 @@ export class NostrController extends EventEmitter { npub: string, extraRelaysToPublish?: string[] ): Promise => { - const timestamp = Math.floor(Date.now() / 1000) + const timestamp = unixNow() const relayURIs = Object.keys(relayMap) // 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 const jobEventTemplate: EventTemplate = { content: '', - created_at: Math.round(Date.now() / 1000), + created_at: unixNow(), kind: 68001, tags: [ ['i', `${JSON.stringify(relayURIs)}`], diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx new file mode 100644 index 0000000..aebd791 --- /dev/null +++ b/src/hooks/useSigitMeta.tsx @@ -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 { + // Validated create signature event + isValid: boolean + + // Calculated status fields + 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() + const [tags, setTags] = useState() + const [created_at, setCreatedAt] = useState() + const [pubkey, setPubkey] = useState() // submittedBy, pubkey from nostr event + const [id, setId] = useState() + const [sig, setSig] = useState() + + const [signers, setSigners] = useState<`npub1${string}`[]>([]) + const [viewers, setViewers] = useState<`npub1${string}`[]>([]) + const [fileHashes, setFileHashes] = useState<{ + [user: `npub1${string}`]: string + }>({}) + const [markConfig, setMarkConfig] = useState([]) + const [title, setTitle] = useState('') + const [zipUrl, setZipUrl] = useState('') + + const [signedStatus, setSignedStatus] = useState( + 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 + 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 + } +} diff --git a/src/index.css b/src/index.css index 76373ff..7ee0eea 100644 --- a/src/index.css +++ b/src/index.css @@ -101,6 +101,15 @@ button:disabled { 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 { width: 40px; height: 40px; diff --git a/src/layouts/modal/style.module.scss b/src/layouts/modal/style.module.scss index f7dd2fc..501f171 100644 --- a/src/layouts/modal/style.module.scss +++ b/src/layouts/modal/style.module.scss @@ -5,7 +5,7 @@ $default-modal-padding: 15px 25px; .modal { position: absolute; top: 0; - left: 50%; + left: calc(50% - 10px); transform: translate(-50%, 0); background-color: $overlay-background-color; @@ -16,6 +16,8 @@ $default-modal-padding: 15px 25px; flex-direction: column; border-radius: 4px; + + margin: 25px 10px; } .header { diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index e34b06d..77b7a87 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -24,7 +24,7 @@ import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' import { Event, kinds } from 'nostr-tools' 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 { useSelector } from 'react-redux' import { useLocation, useNavigate } from 'react-router-dom' @@ -50,7 +50,7 @@ import { getHash, hexToNpub, isOnline, - now, + unixNow, npubToHex, queryNip05, sendNotification, @@ -68,7 +68,7 @@ import { Mark } from '../../types/mark.ts' export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() - const { uploadedFile } = location.state || {} + const { uploadedFiles } = location.state || {} const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') @@ -134,10 +134,10 @@ export const CreatePage = () => { }) useEffect(() => { - if (uploadedFile) { - setSelectedFiles([uploadedFile]) + if (uploadedFiles) { + setSelectedFiles([...uploadedFiles]) } - }, [uploadedFile]) + }, [uploadedFiles]) useEffect(() => { if (usersPubkey) { @@ -407,7 +407,6 @@ export const CreatePage = () => { encryptionKey: string ): Promise => { // Get the current timestamp in seconds - const unixNow = now() const blob = new Blob([encryptedArrayBuffer]) // Create a File object with the Blob data const file = new File([blob], `compressed.sigit`, { @@ -455,10 +454,9 @@ export const CreatePage = () => { const uploadFile = async ( arrayBuffer: ArrayBuffer ): Promise => { - const unixNow = now() const blob = new Blob([arrayBuffer]) // 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' }) @@ -485,7 +483,7 @@ export const CreatePage = () => { return } - saveAs(finalZipFile, `request-${now()}.sigit.zip`) + saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`) setIsLoading(false) } @@ -615,7 +613,7 @@ export const CreatePage = () => { const meta: Meta = { createSignature, keys, - modifiedAt: now(), + modifiedAt: unixNow(), docSignatures: {} } @@ -654,7 +652,7 @@ export const CreatePage = () => { const meta: Meta = { createSignature, - modifiedAt: now(), + modifiedAt: unixNow(), docSignatures: {} } @@ -979,7 +977,7 @@ const SignerRow = ({ item: () => { return { id: user.pubkey, index } }, - collect: (monitor: DragSourceMonitor) => ({ + collect: (monitor) => ({ isDragging: monitor.isDragging() }) }) diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 4ad3c3b..0f0329b 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,384 +1,267 @@ -import { CalendarMonth, Description, Upload } from '@mui/icons-material' -import { Box, Button, Tooltip, Typography } from '@mui/material' +import { Button, TextField } from '@mui/material' import JSZip from 'jszip' -import { Event, kinds, verifyEvent } from 'nostr-tools' -import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'react-toastify' -import { UserAvatar } from '../../components/UserAvatar' -import { MetadataController } from '../../controllers' import { useAppSelector } from '../../hooks' import { appPrivateRoutes, appPublicRoutes } from '../../routes' -import { CreateSignatureEventContent, Meta, ProfileMetadata } from '../../types' -import { - formatTimestamp, - hexToNpub, - npubToHex, - parseJson, - shorten -} from '../../utils' -import styles from './style.module.scss' +import { Meta } from '../../types' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faSearch } from '@fortawesome/free-solid-svg-icons' +import { Select } from '../../components/Select' +import { DisplaySigit } from '../../components/DisplaySigit' +import { useDropzone } from 'react-dropzone' 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 = () => { const navigate = useNavigate() - const fileInputRef = useRef(null) - const [sigits, setSigits] = useState([]) - const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) + const [searchParams, setSearchParams] = useSearchParams() + const q = searchParams.get('q') ?? '' + + 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) useEffect(() => { 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]) - const handleUploadClick = () => { - if (fileInputRef.current) { - fileInputRef.current.click() - } - } + const onDrop = useCallback( + async (acceptedFiles: File[]) => { + // When uploading single file check if it's .sigit.zip + if (acceptedFiles.length === 1) { + const file = acceptedFiles[0] - const handleFileChange = async ( - event: React.ChangeEvent - ) => { - const file = event.target.files?.[0] - if (file) { - // Check if the file extension is .sigit.zip - const fileName = file.name - const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters - 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 } + // Check if the file extension is .sigit.zip + const fileName = file.name + const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters + 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 }) - } - // navigate to verify page if zip contains meta.json - if ('meta.json' in zip.files) { - return navigate(appPublicRoutes.verify, { - state: { uploadedZip: file } - }) - } + if (!zip) return - toast.error('Invalid zip file') - 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 ('meta.json' in zip.files) { + return navigate(appPublicRoutes.verify, { + state: { uploadedZip: file } + }) + } + + toast.error('Invalid SiGit zip file') + return + } } // navigate to create page - navigate(appPrivateRoutes.create, { state: { uploadedFile: file } }) - } - } - - return ( - - - - Sigits - - {/* This is for desktop view */} - - - - - {/* This is for mobile view */} - - - - - - - - {sigits.map((sigit, index) => ( - - ))} - - - ) -} - -type SigitProps = { - meta: Meta - profiles: { [key: string]: ProfileMetadata } - setProfiles: Dispatch> -} - -enum SignedStatus { - Partial = 'Partially Signed', - Complete = 'Completely Signed' -} - -const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => { - const navigate = useNavigate() - - const [title, setTitle] = useState() - const [createdAt, setCreatedAt] = useState('') - const [submittedBy, setSubmittedBy] = useState() - const [signers, setSigners] = useState<`npub1${string}`[]>([]) - const [signedStatus, setSignedStatus] = useState( - SignedStatus.Partial - ) - - useEffect(() => { - const extractInfo = async () => { - const createSignatureEvent = await parseJson( - 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(appPrivateRoutes.create, { + state: { uploadedFiles: acceptedFiles } }) + }, + [navigate] + ) - if (!createSignatureEvent) return + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + onDrop, + noClick: true + }) - // created_at in nostr events are stored in seconds - // convert it to ms before formatting - setCreatedAt(formatTimestamp(createSignatureEvent.created_at * 1000)) - - const createSignatureContent = - await parseJson( - 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 } }) - } - } + const [filter, setFilter] = useState('Show all') + const [sort, setSort] = useState('desc') return ( - - - - - {title} - - {submittedBy && - (function () { - const profile = profiles[submittedBy] - return ( - + +
+
+ { + return { ...s } + })} + /> +
+
+
{ + e.preventDefault() + const searchInput = e.currentTarget.elements.namedItem( + 'q' + ) as HTMLInputElement + searchParams.set('q', searchInput.value) + setSearchParams(searchParams) + }} + > + { + // Handle the case when users click native search input's clear or x + if (e.currentTarget.value === '') { + searchParams.delete('q') + setSearchParams(searchParams) + } + }} + sx={{ + width: '100%', + fontSize: '16px', + borderTopLeftRadius: 'var(----mui-shape-borderRadius)', + borderBottomLeftRadius: 'var(----mui-shape-borderRadius)', + '& .MuiInputBase-root': { + borderTopRightRadius: 0, + borderBottomRightRadius: 0 + }, + '& .MuiInputBase-input': { + padding: '7px 14px' + }, + '& .MuiOutlinedInput-notchedOutline': { + display: 'none' + } + }} + /> + + +
+
+ +
+ {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) => ( + + ))} +
+
+ ) } diff --git a/src/pages/home/style.module.scss b/src/pages/home/style.module.scss index 132097e..63917a0 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -1,94 +1,101 @@ +@import '../../styles/colors.scss'; + .container { display: flex; flex-direction: column; gap: 25px; + container-type: inline-size; } .header { display: flex; + gap: 10px; - .title { - color: var(--mui-palette-primary-light); - flex: 1; + @container (width < 610px) { + flex-direction: column-reverse; + } +} + +.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 { - justify-content: center; - align-items: center; - gap: 10px; + &:focus-within { + outline-color: $primary-main; } } +.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 { - display: flex; - flex-direction: column; - gap: 10px; - - .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); - } - } - } - } + display: grid; + gap: 25px; + grid-template-columns: repeat(auto-fit, minmax(365px, 1fr)); } diff --git a/src/pages/nostr/index.tsx b/src/pages/nostr/index.tsx index 562f184..bd99485 100644 --- a/src/pages/nostr/index.tsx +++ b/src/pages/nostr/index.tsx @@ -51,7 +51,7 @@ export const Nostr = () => { /** * Call login function when enter is pressed */ - const handleInputKeyDown = (event: any) => { + const handleInputKeyDown = (event: React.KeyboardEvent) => { if (event.code === 'Enter' || event.code === 'NumpadEnter') { event.preventDefault() login() diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 7dd2d0d..9b2fc2d 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -12,7 +12,7 @@ import { useTheme } from '@mui/material' 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 { toast } from 'react-toastify' import { MetadataController, NostrController } from '../../../controllers' @@ -26,7 +26,7 @@ import { setMetadataEvent } from '../../../store/actions' import { LoadingSpinner } from '../../../components/LoadingSpinner' import { LoginMethods } from '../../../store/auth/types' import { SmartToy } from '@mui/icons-material' -import { getRoboHashPicture } from '../../../utils' +import { getRoboHashPicture, unixNow } from '../../../utils' import { Container } from '../../../components/Container' export const ProfileSettingsPage = () => { @@ -197,7 +197,7 @@ export const ProfileSettingsPage = () => { // Relay will reject if created_at is too late const updatedMetadataState: UnsignedEvent = { content: content, - created_at: Math.round(Date.now() / 1000), + created_at: unixNow(), kind: kinds.Metadata, pubkey: pubkey!, tags: metadataState?.tags || [] @@ -321,8 +321,8 @@ export const ProfileSettingsPage = () => { }} > { - event.target.src = getRoboHashPicture(npub!) + onError={(event: React.SyntheticEvent) => { + event.currentTarget.src = getRoboHashPicture(npub!) }} className={styles.img} src={getProfileImage(profileMetadata)} diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 3798863..712ab51 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -26,7 +26,7 @@ import { hexToNpub, isOnline, loadZip, - now, + unixNow, npubToHex, parseJson, readContentOfZipEntry, @@ -554,7 +554,7 @@ export const SignPage = () => { ...metaCopy.docSignatures, [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) } - metaCopy.modifiedAt = now() + metaCopy.modifiedAt = unixNow() return metaCopy } @@ -564,7 +564,6 @@ export const SignPage = () => { encryptionKey: string ): Promise => { // Get the current timestamp in seconds - const unixNow = now() const blob = new Blob([encryptedArrayBuffer]) // Create a File object with the Blob data const file = new File([blob], `compressed.sigit`, { @@ -614,7 +613,7 @@ export const SignPage = () => { 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' }) } @@ -758,8 +757,7 @@ export const SignPage = () => { if (!arrayBuffer) return const blob = new Blob([arrayBuffer]) - const unixNow = now() - saveAs(blob, `exported-${unixNow}.sigit.zip`) + saveAs(blob, `exported-${unixNow()}.sigit.zip`) setIsLoading(false) @@ -804,8 +802,7 @@ export const SignPage = () => { const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) if (!finalZipFile) return - const unixNow = now() - saveAs(finalZipFile, `exported-${unixNow}.sigit.zip`) + saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`) } /** diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index c5a0e8e..1f6bee1 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -28,7 +28,7 @@ import { extractZipUrlAndEncryptionKey, getHash, hexToNpub, - now, + unixNow, npubToHex, parseJson, readContentOfZipEntry, @@ -239,7 +239,7 @@ export const VerifyPage = () => { } }) } - }, [submittedBy, signers, viewers]) + }, [submittedBy, signers, viewers, metadata]) const handleVerify = async () => { if (!selectedFile) return @@ -445,7 +445,7 @@ export const VerifyPage = () => { if (!arrayBuffer) return const blob = new Blob([arrayBuffer]) - saveAs(blob, `exported-${now()}.sigit.zip`) + saveAs(blob, `exported-${unixNow()}.sigit.zip`) setIsLoading(false) } diff --git a/src/theme/index.ts b/src/theme/index.ts index 4a1e0ab..8cc008f 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -23,14 +23,6 @@ export const theme = extendTheme({ } }, components: { - MuiModal: { - styleOverrides: { - root: { - insetBlock: '25px', - insetInline: '10px' - } - } - }, MuiButton: { styleOverrides: { root: { @@ -41,6 +33,9 @@ export const theme = extendTheme({ boxShadow: 'unset', lineHeight: 'inherit', borderRadius: '4px', + ':focus': { + textDecoration: 'none' + }, ':hover': { background: 'var(--primary-light)', boxShadow: 'unset' diff --git a/src/utils/index.ts b/src/utils/index.ts index 1b0c133..ffac72d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './string' export * from './zip' export * from './utils' export * from './mark' +export * from './meta' diff --git a/src/utils/meta.ts b/src/utils/meta.ts new file mode 100644 index 0000000..b3c0c28 --- /dev/null +++ b/src/utils/meta.ts @@ -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 => { + try { + const createSignatureEvent = await parseJson(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 => { + try { + const createSignatureEventContent = + await parseJson(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) + } + } +} diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 728408c..f427b78 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -13,7 +13,7 @@ import { NostrController } from '../controllers' import { AuthState } from '../store/auth/types' import store from '../store/store' import { CreateSignatureEventContent, Meta } from '../types' -import { hexToNpub, now } from './nostr' +import { hexToNpub, unixNow } from './nostr' import { parseJson } from './string' import { hexToBytes } from '@noble/hashes/utils' @@ -28,10 +28,10 @@ export const uploadToFileStorage = async (file: File) => { const event: EventTemplate = { kind: 24242, content: 'Authorize Upload', - created_at: Math.floor(Date.now() / 1000), + created_at: unixNow(), tags: [ ['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], ['size', String(file.size)] ] @@ -78,7 +78,7 @@ export const signEventForMetaFile = async ( const event: EventTemplate = { kind: 27235, // Event type for meta file 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 } diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 7f5c60f..e9ecc8f 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -120,7 +120,8 @@ export const queryNip05 = async ( if (!match) throw new Error('Invalid nip05') // 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 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}` } -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 @@ -287,7 +303,7 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { kind: 1059, // Event kind content, // Encrypted content pubkey, // Public key of the creator - created_at: now(), // Current timestamp + created_at: unixNow(), // Current timestamp tags: [ // Tags including receiver and nonce ['p', receiver], @@ -541,7 +557,7 @@ export const updateUsersAppData = async (meta: Meta) => { const updatedEvent: UnsignedEvent = { kind: kinds.Application, pubkey: usersPubkey!, - created_at: now(), + created_at: unixNow(), tags: [['d', hash]], content: encryptedContent } @@ -607,10 +623,10 @@ const deleteBlossomFile = async (url: string, privateKey: string) => { const event: EventTemplate = { kind: 24242, content: 'Authorize Upload', - created_at: now(), + created_at: unixNow(), tags: [ ['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] ] } @@ -666,10 +682,10 @@ const uploadUserAppDataToBlossom = async ( const event: EventTemplate = { kind: 24242, content: 'Authorize Upload', - created_at: now(), + created_at: unixNow(), tags: [ ['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], ['size', String(file.size)] ] @@ -874,7 +890,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => { pubkey: usersPubkey, content: JSON.stringify(meta), tags: [], - created_at: now() + created_at: unixNow() } // Wrap the unsigned event with the receiver's information