From f21d158a8ec5fd204fd513f140cfa33b97cb5383 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 30 Jul 2024 18:04:40 +0200 Subject: [PATCH 01/23] fix: input font-family inherit --- src/App.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/App.scss b/src/App.scss index f5394f7..5461a4a 100644 --- a/src/App.scss +++ b/src/App.scss @@ -63,3 +63,7 @@ a { text-decoration-color: inherit; } } + +input { + font-family: inherit; +} From b959aa7dc254559841fe352b5777cc87fe9b7396 Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 5 Aug 2024 09:21:22 +0200 Subject: [PATCH 02/23] style: applied css prettier formatting --- src/index.css | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/index.css b/src/index.css index 6a734df..76373ff 100644 --- a/src/index.css +++ b/src/index.css @@ -111,7 +111,9 @@ button:disabled { /* Fonts */ @font-face { font-family: 'Roboto'; - src: local('Roboto Medium'), local('Roboto-Medium'), + src: + local('Roboto Medium'), + local('Roboto-Medium'), url('assets/fonts/roboto-medium.woff2') format('woff2'), url('assets/fonts/roboto-medium.woff') format('woff'); font-weight: 500; @@ -121,7 +123,9 @@ button:disabled { @font-face { font-family: 'Roboto'; - src: local('Roboto Light'), local('Roboto-Light'), + src: + local('Roboto Light'), + local('Roboto-Light'), url('assets/fonts/roboto-light.woff2') format('woff2'), url('assets/fonts/roboto-light.woff') format('woff'); font-weight: 300; @@ -131,7 +135,9 @@ button:disabled { @font-face { font-family: 'Roboto'; - src: local('Roboto Bold'), local('Roboto-Bold'), + src: + local('Roboto Bold'), + local('Roboto-Bold'), url('assets/fonts/roboto-bold.woff2') format('woff2'), url('assets/fonts/roboto-bold.woff') format('woff'); font-weight: bold; @@ -141,10 +147,12 @@ button:disabled { @font-face { font-family: 'Roboto'; - src: local('Roboto'), local('Roboto-Regular'), + src: + local('Roboto'), + local('Roboto-Regular'), url('assets/fonts/roboto-regular.woff2') format('woff2'), url('assets/fonts/roboto-regular.woff') format('woff'); font-weight: normal; font-style: normal; font-display: swap; -} \ No newline at end of file +} From 64b6f8309f361a6db6c6e91005b50969cdbfa549 Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 5 Aug 2024 09:24:14 +0200 Subject: [PATCH 03/23] fix: modal override removed Mui modal overrides affected tooltip positioning --- src/layouts/modal/style.module.scss | 4 +++- src/theme/index.ts | 8 -------- 2 files changed, 3 insertions(+), 9 deletions(-) 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/theme/index.ts b/src/theme/index.ts index 4a1e0ab..a7a37d4 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: { From 8d168314de807bfb7b5d96ddc0cf82109afdf343 Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 5 Aug 2024 09:39:46 +0200 Subject: [PATCH 04/23] feat: custom select component --- src/components/Select/index.tsx | 105 ++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/components/Select/index.tsx diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx new file mode 100644 index 0000000..4667def --- /dev/null +++ b/src/components/Select/index.tsx @@ -0,0 +1,105 @@ +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 { + setValue: React.Dispatch> + options: SelectItemProps[] +} + +export function Select({ + setValue, + options +}: SelectProps) { + const handleChange = (event: SelectChangeEvent) => { + setValue(event.target.value as T) + } + + return ( + + + {options.map((o) => { + return ( + + {o.label} + + ) + })} + + + ) +} From f51afe3b677d418cdf4c4d29132f63f9ff1bd56b Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 5 Aug 2024 09:49:56 +0200 Subject: [PATCH 05/23] fix: some linter warnings and an error --- src/layouts/Main.tsx | 2 +- src/pages/nostr/index.tsx | 2 +- src/pages/settings/profile/index.tsx | 6 +++--- src/utils/nostr.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index c8f9f27..ee82da3 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -145,7 +145,7 @@ export const MainLayout = () => { }) .finally(() => setIsLoading(false)) } - }, [authState]) + }, [authState, dispatch]) if (isLoading) return 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..7d6c923 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' @@ -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/utils/nostr.ts b/src/utils/nostr.ts index ff38faa..e27601b 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -120,7 +120,7 @@ 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 + const [, name = '_', domain] = match // Construct the URL to query the NIP-05 data const url = `https://${domain}/.well-known/nostr.json?name=${name}` From 64c48835a4ea35ec53ab61a55cd917b69558fc17 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 6 Aug 2024 12:42:21 +0200 Subject: [PATCH 06/23] refactor: sigit cards design and split components --- src/components/Container/index.tsx | 12 + src/components/DisplaySigit/index.tsx | 203 ++++++++ src/components/DisplaySigit/style.module.scss | 129 ++++++ src/components/DisplaySigner/index.tsx | 76 +++ .../DisplaySigner/style.module.scss | 23 + src/components/TooltipChild.tsx | 11 + src/components/UserAvatar/index.tsx | 28 +- src/components/UserAvatar/styles.module.scss | 2 +- src/components/UserAvatarGroup/index.tsx | 38 ++ .../UserAvatarGroup/style.module.scss | 19 + .../UserAvatarIconButton/style.module.scss | 2 +- src/hooks/useSigitMeta.tsx | 86 ++++ src/index.css | 9 + src/pages/home/index.tsx | 435 ++++++------------ src/pages/home/style.module.scss | 136 +++--- 15 files changed, 812 insertions(+), 397 deletions(-) create mode 100644 src/components/DisplaySigit/index.tsx create mode 100644 src/components/DisplaySigit/style.module.scss create mode 100644 src/components/DisplaySigner/index.tsx create mode 100644 src/components/DisplaySigner/style.module.scss create mode 100644 src/components/TooltipChild.tsx create mode 100644 src/components/UserAvatarGroup/index.tsx create mode 100644 src/components/UserAvatarGroup/style.module.scss create mode 100644 src/hooks/useSigitMeta.tsx 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..2b49f2f --- /dev/null +++ b/src/components/DisplaySigit/index.tsx @@ -0,0 +1,203 @@ +import { Dispatch, SetStateAction, useEffect } from 'react' +import { Meta, ProfileMetadata } from '../../types' +import { SignedStatus, useSigitMeta } from '../../hooks/useSigitMeta' +import { Event, kinds } from 'nostr-tools' +import { Link } from 'react-router-dom' +import { MetadataController } from '../../controllers' +import { 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, + faFilePdf +} 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' + +type SigitProps = { + meta: Meta + profiles: { [key: string]: ProfileMetadata } + setProfiles: Dispatch> +} + +// function +// const ExtensionIconMapper = new Map([ +// [ +// 'pdf', +// <> +// PDF +// +// ], +// [ +// 'csv', +// <> +// CSV +// +// ] +// ]) + +export const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => { + const { + title, + createdAt, + submittedBy, + signers, + signedStatus + // fileExtensions + } = useSigitMeta(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, profiles, setProfiles]) + + 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} +
+
+ + {signedStatus} + + + {/* {fileExtensions.map(ext => + + return + )} */} + {'PDF'} + +
+
+ + + + + + +
+ + ) +} diff --git a/src/components/DisplaySigit/style.module.scss b/src/components/DisplaySigit/style.module.scss new file mode 100644 index 0000000..a615713 --- /dev/null +++ b/src/components/DisplaySigit/style.module.scss @@ -0,0 +1,129 @@ +@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; + + cursor: pointer; + + &: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); + } + } +} + +.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; +} + +.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..a7d3f5b --- /dev/null +++ b/src/components/DisplaySigner/index.tsx @@ -0,0 +1,76 @@ +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(() => { + 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/TooltipChild.tsx b/src/components/TooltipChild.tsx new file mode 100644 index 0000000..d70f916 --- /dev/null +++ b/src/components/TooltipChild.tsx @@ -0,0 +1,11 @@ +import { forwardRef, PropsWithChildren } from 'react' + +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..5382475 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,18 @@ 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/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx new file mode 100644 index 0000000..b87f3b3 --- /dev/null +++ b/src/hooks/useSigitMeta.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react' +import { toast } from 'react-toastify' +import { CreateSignatureEventContent, Meta } from '../types' +import { parseJson, formatTimestamp } from '../utils' +import { Event } from 'nostr-tools' + +export enum SignedStatus { + Partial = 'In-Progress', + Complete = 'Completed' +} + +export const useSigitMeta = (meta: Meta) => { + const [title, setTitle] = useState() + const [createdAt, setCreatedAt] = useState('') + const [submittedBy, setSubmittedBy] = useState() + const [signers, setSigners] = useState<`npub1${string}`[]>([]) + const [signedStatus, setSignedStatus] = useState( + SignedStatus.Partial + ) + const [fileExtensions, setFileExtensions] = useState([]) + + 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 + }) + + if (!createSignatureEvent) return + + // 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 + + 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 + }, []) + + setTitle(createSignatureContent.title) + setSubmittedBy(createSignatureEvent.pubkey) + setSigners(createSignatureContent.signers) + setFileExtensions(extensions) + + 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]) + + return { + title, + createdAt, + submittedBy, + signers, + signedStatus, + fileExtensions + } +} 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/pages/home/index.tsx b/src/pages/home/index.tsx index 4ad3c3b..07304b6 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,24 +1,42 @@ -import { CalendarMonth, Description, Upload } from '@mui/icons-material' -import { Box, Button, Tooltip, Typography } from '@mui/material' +import { Button, Divider, TextField, Tooltip } from '@mui/material' import JSZip from 'jszip' -import { Event, kinds, verifyEvent } from 'nostr-tools' -import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } 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 { Meta, ProfileMetadata } from '../../types' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { - formatTimestamp, - hexToNpub, - npubToHex, - parseJson, - shorten -} from '../../utils' -import styles from './style.module.scss' + faAdd, + faFilter, + faFilterCircleXmark, + faSearch +} from '@fortawesome/free-solid-svg-icons' +import { Select } from '../../components/Select' +import { DisplaySigit } from '../../components/DisplaySigit' + import { Container } from '../../components/Container' +import styles from './style.module.scss' + +// 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() @@ -81,54 +99,112 @@ export const HomePage = () => { } } + const [filter, setFilter] = useState('Show all') + const [isFilterVisible, setIsFilterVisible] = useState(true) + const [sort, setSort] = useState('asc') + + console.log(filter, sort) + return ( - - - Sigits - - {/* This is for desktop view */} - - - - - {/* This is for mobile view */} - - - + + + + - - - + + + + + + +
+
Click or drag files to upload!
+
+
{sigits.map((sigit, index) => ( { setProfiles={setProfiles} /> ))} - +
) } - -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 - }) - - if (!createSignatureEvent) return - - // 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 } }) - } - } - - return ( - - - - - {title} - - {submittedBy && - (function () { - const profile = profiles[submittedBy] - return ( - - ) - })()} - - - {createdAt} - - - - {signers.map((signer) => { - const pubkey = npubToHex(signer)! - const profile = profiles[pubkey] - - return ( - - ) - })} - - - ) -} - -enum SignStatus { - Signed = 'Signed', - Pending = 'Pending', - Invalid = 'Invalid Sign' -} - -type DisplaySignerProps = { - meta: Meta - profile: ProfileMetadata - pubkey: string -} - -const DisplaySigner = ({ meta, profile, pubkey }: DisplaySignerProps) => { - const [signStatus, setSignedStatus] = useState() - - useEffect(() => { - 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} - - - - ) -} diff --git a/src/pages/home/style.module.scss b/src/pages/home/style.module.scss index 132097e..21aeb30 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -1,94 +1,78 @@ +@import '../../styles/colors.scss'; + .container { display: flex; flex-direction: column; gap: 25px; + container-type: inline-size; } .header { display: flex; + gap: 10px; + align-items: center; - .title { - color: var(--mui-palette-primary-light); - flex: 1; + @container (width < 610px) { + flex-wrap: wrap; + } +} + +.actionButtons { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin-left: auto; + padding: 1.5px 0; +} + +.search { + display: flex; + align-items: center; + justify-content: end; + + height: 34px; + overflow: hidden; + border-radius: 4px; + outline: solid 1px #dddddd; + background: white; + + &:focus-within { + outline-color: $primary-main; + } +} + +.dropzone { + background-color: $overlay-background-color; + height: 250px; + transition: padding ease 0.2s; + padding: 15px; + + &:hover { + padding: 10px; + + > div { + background: rgba(0, 0, 0, 0.15); + } } - .actionButtons { + > div { + transition: background-color ease 0.2s; + display: flex; + flex-direction: column; justify-content: center; align-items: center; - gap: 10px; + background: rgba(0, 0, 0, 0.1); + color: rgba(0, 0, 0, 0.25); + height: 100%; + border-radius: 2px; + border: dashed 3px rgba(0, 0, 0, 0.1); + font-size: 16px; } } .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)); } From 21caaa7009e49cd7cedc54104ab9438c330ed708 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 6 Aug 2024 12:54:01 +0200 Subject: [PATCH 07/23] fix: sigit links and outline --- src/components/DisplaySigit/style.module.scss | 3 +++ src/components/UserAvatar/index.tsx | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/DisplaySigit/style.module.scss b/src/components/DisplaySigit/style.module.scss index a615713..1b029be 100644 --- a/src/components/DisplaySigit/style.module.scss +++ b/src/components/DisplaySigit/style.module.scss @@ -6,6 +6,8 @@ background-color: $overlay-background-color; border-radius: 4px; + outline: none !important; + display: flex; padding: 15px; gap: 15px; @@ -79,6 +81,7 @@ .title { font-size: 20px; + color: $text-color; } .users { diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index 5382475..9ae60ce 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -16,7 +16,11 @@ interface UserAvatarProps { */ export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => { return ( - + Date: Tue, 6 Aug 2024 13:02:20 +0200 Subject: [PATCH 08/23] docs: add comment for TooltipChild --- src/components/TooltipChild.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/TooltipChild.tsx b/src/components/TooltipChild.tsx index d70f916..4b41b72 100644 --- a/src/components/TooltipChild.tsx +++ b/src/components/TooltipChild.tsx @@ -1,5 +1,10 @@ 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 ( From c3f60b1e643ff2e9ccf8f483a971b1970cf7d786 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 6 Aug 2024 13:29:43 +0200 Subject: [PATCH 09/23] feat: extension icon label util component --- src/components/DisplaySigit/index.tsx | 41 +++++-------- src/components/getExtensionIconLabel.tsx | 76 ++++++++++++++++++++++++ src/pages/home/index.tsx | 2 - 3 files changed, 91 insertions(+), 28 deletions(-) create mode 100644 src/components/getExtensionIconLabel.tsx diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx index 2b49f2f..99637ad 100644 --- a/src/components/DisplaySigit/index.tsx +++ b/src/components/DisplaySigit/index.tsx @@ -13,7 +13,7 @@ import { faCalendar, faCopy, faEye, - faFilePdf + faFile } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { UserAvatar } from '../UserAvatar' @@ -21,6 +21,7 @@ import { UserAvatarGroup } from '../UserAvatarGroup' import styles from './style.module.scss' import { TooltipChild } from '../TooltipChild' +import { getExtensionIconLabel } from '../getExtensionIconLabel' type SigitProps = { meta: Meta @@ -28,30 +29,14 @@ type SigitProps = { setProfiles: Dispatch> } -// function -// const ExtensionIconMapper = new Map([ -// [ -// 'pdf', -// <> -// PDF -// -// ], -// [ -// 'csv', -// <> -// CSV -// -// ] -// ]) - export const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => { const { title, createdAt, submittedBy, signers, - signedStatus - // fileExtensions + signedStatus, + fileExtensions } = useSigitMeta(meta) useEffect(() => { @@ -164,13 +149,17 @@ export const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => { {signedStatus} - - {/* {fileExtensions.map(ext => - - return - )} */} - {'PDF'} - + {fileExtensions.length > 0 ? ( + + {fileExtensions.length > 1 ? ( + <> + Multiple File Types + + ) : ( + getExtensionIconLabel(fileExtensions[0]) + )} + + ) : null}
diff --git a/src/components/getExtensionIconLabel.tsx b/src/components/getExtensionIconLabel.tsx new file mode 100644 index 0000000..d745814 --- /dev/null +++ b/src/components/getExtensionIconLabel.tsx @@ -0,0 +1,76 @@ +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 'xslx': + case 'xsl': + 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/pages/home/index.tsx b/src/pages/home/index.tsx index 07304b6..e765110 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -103,8 +103,6 @@ export const HomePage = () => { const [isFilterVisible, setIsFilterVisible] = useState(true) const [sort, setSort] = useState('asc') - console.log(filter, sort) - return (
From e4a7fa4892b05f648dfb988b898fb0658d2f22d4 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 6 Aug 2024 13:38:33 +0200 Subject: [PATCH 10/23] fix: nested a links in card --- src/components/DisplaySigit/index.tsx | 21 ++++++++++--------- src/components/DisplaySigit/style.module.scss | 10 +++++---- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx index 99637ad..a4a7418 100644 --- a/src/components/DisplaySigit/index.tsx +++ b/src/components/DisplaySigit/index.tsx @@ -80,15 +80,16 @@ export const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => { }, [submittedBy, signers, profiles, setProfiles]) return ( - +
+

{title}

{submittedBy && @@ -187,6 +188,6 @@ export const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => {
- +
) } diff --git a/src/components/DisplaySigit/style.module.scss b/src/components/DisplaySigit/style.module.scss index 1b029be..7544fc4 100644 --- a/src/components/DisplaySigit/style.module.scss +++ b/src/components/DisplaySigit/style.module.scss @@ -6,15 +6,11 @@ background-color: $overlay-background-color; border-radius: 4px; - outline: none !important; - display: flex; padding: 15px; gap: 15px; flex-direction: column; - cursor: pointer; - &:only-child { max-width: 600px; } @@ -44,6 +40,12 @@ } } +.insetLink { + position: absolute; + inset: 0; + outline: none; +} + .itemActions { display: flex; gap: 10px; From d0e3704ed6a2c08eede388e353bcf76880873411 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 6 Aug 2024 14:07:41 +0200 Subject: [PATCH 11/23] fix: missing id/name on custom select input --- src/components/Select/index.tsx | 8 +++++++- src/pages/home/index.tsx | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx index 4667def..c5c39e8 100644 --- a/src/components/Select/index.tsx +++ b/src/components/Select/index.tsx @@ -62,11 +62,15 @@ interface SelectItemProps { interface SelectProps { setValue: React.Dispatch> options: SelectItemProps[] + name?: string + id?: string } export function Select({ setValue, - options + options, + name, + id }: SelectProps) { const handleChange = (event: SelectChangeEvent) => { setValue(event.target.value as T) @@ -75,6 +79,8 @@ export function Select({ return ( { {isFilterVisible && ( <> { return { ...s } From 6b5a8a7375d528ce4f8e53dd595e1bbde27ea433 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 7 Aug 2024 11:10:32 +0200 Subject: [PATCH 12/23] fix(sigit): excel extension typo, more excel types --- src/components/getExtensionIconLabel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/getExtensionIconLabel.tsx b/src/components/getExtensionIconLabel.tsx index d745814..e8a14eb 100644 --- a/src/components/getExtensionIconLabel.tsx +++ b/src/components/getExtensionIconLabel.tsx @@ -22,8 +22,10 @@ export const getExtensionIconLabel = (extension: string) => { icon = faFilePdf break - case 'xslx': - case 'xsl': + case 'xlsx': + case 'xls': + case 'xlsb': + case 'xlsm': icon = faFileExcel break From 272fcf93c64005b06249690815bb320ad66b9798 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 7 Aug 2024 11:11:07 +0200 Subject: [PATCH 13/23] fix: search bar scaling --- src/pages/home/style.module.scss | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/home/style.module.scss b/src/pages/home/style.module.scss index 21aeb30..bd0dc08 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -19,11 +19,11 @@ .actionButtons { display: flex; - justify-content: center; + justify-content: end; align-items: center; gap: 10px; - margin-left: auto; padding: 1.5px 0; + flex-grow: 1; } .search { @@ -37,6 +37,12 @@ outline: solid 1px #dddddd; background: white; + width: 100%; + + @container (width >= 610px) { + max-width: 246px; + } + &:focus-within { outline-color: $primary-main; } From becd02153c9cecb45041ab7e0b05b8a8cfbcb08a Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 7 Aug 2024 14:15:20 +0200 Subject: [PATCH 14/23] feat(dashboard): add sigits filtering, sorting, searching --- src/components/DisplaySigit/index.tsx | 16 ++- src/components/Select/index.tsx | 4 +- src/hooks/useSigitMeta.tsx | 137 +++++++++++-------- src/pages/home/index.tsx | 183 ++++++++++++++------------ src/pages/home/style.module.scss | 8 +- 5 files changed, 203 insertions(+), 145 deletions(-) diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx index a4a7418..e930b37 100644 --- a/src/components/DisplaySigit/index.tsx +++ b/src/components/DisplaySigit/index.tsx @@ -1,10 +1,10 @@ import { Dispatch, SetStateAction, useEffect } from 'react' import { Meta, ProfileMetadata } from '../../types' -import { SignedStatus, useSigitMeta } from '../../hooks/useSigitMeta' +import { SigitInfo, SignedStatus } from '../../hooks/useSigitMeta' import { Event, kinds } from 'nostr-tools' import { Link } from 'react-router-dom' import { MetadataController } from '../../controllers' -import { hexToNpub, npubToHex, shorten } from '../../utils' +import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils' import { appPublicRoutes, appPrivateRoutes } from '../../routes' import { Button, Divider, Tooltip } from '@mui/material' import { DisplaySigner } from '../DisplaySigner' @@ -25,11 +25,17 @@ import { getExtensionIconLabel } from '../getExtensionIconLabel' type SigitProps = { meta: Meta + parsedMeta: SigitInfo profiles: { [key: string]: ProfileMetadata } setProfiles: Dispatch> } -export const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => { +export const DisplaySigit = ({ + meta, + parsedMeta, + profiles, + setProfiles +}: SigitProps) => { const { title, createdAt, @@ -37,7 +43,7 @@ export const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => { signers, signedStatus, fileExtensions - } = useSigitMeta(meta) + } = parsedMeta useEffect(() => { const hexKeys: string[] = [] @@ -144,7 +150,7 @@ export const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => {
- {createdAt} + {createdAt ? formatTimestamp(createdAt) : null}
diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx index c5c39e8..9901fa1 100644 --- a/src/components/Select/index.tsx +++ b/src/components/Select/index.tsx @@ -60,6 +60,7 @@ interface SelectItemProps { } interface SelectProps { + value: T setValue: React.Dispatch> options: SelectItemProps[] name?: string @@ -67,6 +68,7 @@ interface SelectProps { } export function Select({ + value, setValue, options, name, @@ -83,7 +85,7 @@ export function Select({ name={name} size="small" variant="outlined" - defaultValue={options[0].value as string} + value={value} onChange={handleChange} MenuProps={{ MenuListProps: { diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index b87f3b3..e798ef0 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -1,78 +1,109 @@ import { useEffect, useState } from 'react' import { toast } from 'react-toastify' import { CreateSignatureEventContent, Meta } from '../types' -import { parseJson, formatTimestamp } from '../utils' +import { parseJson } from '../utils' import { Event } from 'nostr-tools' +type npub = `npub1${string}` + export enum SignedStatus { Partial = 'In-Progress', Complete = 'Completed' } +export interface SigitInfo { + createdAt?: number + title?: string + submittedBy?: string + signers: npub[] + fileExtensions: string[] + signedStatus: SignedStatus +} + +export const extractSigitInfo = async (meta: Meta) => { + if (!meta?.createSignature) return + + const sigitInfo: SigitInfo = { + signers: [], + fileExtensions: [], + signedStatus: SignedStatus.Partial + } + + 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 + }) + + if (!createSignatureEvent) return + + // created_at in nostr events are stored in seconds + sigitInfo.createdAt = createSignatureEvent.created_at * 1000 + + const createSignatureContent = await parseJson( + createSignatureEvent.content + ).catch((err) => { + console.log(`err in parsing the createSignature event's content :>> `, err) + return + }) + + if (!createSignatureContent) return + + 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 npub[] + 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 = SignedStatus.Complete + } + + return sigitInfo +} + export const useSigitMeta = (meta: Meta) => { const [title, setTitle] = useState() - const [createdAt, setCreatedAt] = useState('') + const [createdAt, setCreatedAt] = useState() const [submittedBy, setSubmittedBy] = useState() - const [signers, setSigners] = useState<`npub1${string}`[]>([]) + const [signers, setSigners] = useState([]) const [signedStatus, setSignedStatus] = useState( SignedStatus.Partial ) const [fileExtensions, setFileExtensions] = useState([]) 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 - }) + const getSigitInfo = async () => { + const sigitInfo = await extractSigitInfo(meta) - if (!createSignatureEvent) return + if (!sigitInfo) return - // 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 - - 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 - }, []) - - setTitle(createSignatureContent.title) - setSubmittedBy(createSignatureEvent.pubkey) - setSigners(createSignatureContent.signers) - setFileExtensions(extensions) - - const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[] - const isCompletelySigned = createSignatureContent.signers.every( - (signer) => signedBy.includes(signer) - ) - if (isCompletelySigned) { - setSignedStatus(SignedStatus.Complete) - } + setTitle(sigitInfo.title) + setCreatedAt(sigitInfo.createdAt) + setSubmittedBy(sigitInfo.submittedBy) + setSigners(sigitInfo.signers) + setSignedStatus(sigitInfo.signedStatus) + setFileExtensions(sigitInfo.fileExtensions) } - extractInfo() + + getSigitInfo() }, [meta]) return { diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 37951f0..d728a78 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,4 +1,4 @@ -import { Button, Divider, TextField, Tooltip } from '@mui/material' +import { Button, TextField } from '@mui/material' import JSZip from 'jszip' import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' @@ -7,25 +7,25 @@ import { useAppSelector } from '../../hooks' import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { Meta, ProfileMetadata } from '../../types' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { - faAdd, - faFilter, - faFilterCircleXmark, - faSearch -} from '@fortawesome/free-solid-svg-icons' +import { faSearch } from '@fortawesome/free-solid-svg-icons' import { Select } from '../../components/Select' import { DisplaySigit } from '../../components/DisplaySigit' import { Container } from '../../components/Container' import styles from './style.module.scss' +import { + extractSigitInfo, + SigitInfo, + SignedStatus +} from '../../hooks/useSigitMeta' // Unsupported Filter options are commented const FILTERS = [ 'Show all', // 'Drafts', 'In-progress', - 'Completed' - // 'Archived' + 'Completed', + 'Archived' ] as const type Filter = (typeof FILTERS)[number] @@ -41,7 +41,11 @@ type Sort = (typeof SORT_BY)[number]['value'] export const HomePage = () => { const navigate = useNavigate() const fileInputRef = useRef(null) - const [sigits, setSigits] = useState([]) + + const [sigits, setSigits] = useState<{ [key: string]: Meta }>({}) + const [parsedSigits, setParsedSigits] = useState<{ + [key: string]: SigitInfo + }>({}) const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>( {} ) @@ -49,7 +53,24 @@ export const HomePage = () => { useEffect(() => { if (usersAppData) { - setSigits(Object.values(usersAppData.sigits)) + const getSigitInfo = async () => { + for (const key in usersAppData.sigits) { + if (Object.prototype.hasOwnProperty.call(usersAppData.sigits, key)) { + const sigitInfo = await extractSigitInfo(usersAppData.sigits[key]) + if (sigitInfo) { + setParsedSigits((prev) => { + return { + ...prev, + [key]: sigitInfo + } + }) + } + } + } + } + + setSigits(usersAppData.sigits) + getSigitInfo() } }, [usersAppData]) @@ -99,42 +120,46 @@ export const HomePage = () => { } } + const [search, setSearch] = useState('') const [filter, setFilter] = useState('Show all') - const [isFilterVisible, setIsFilterVisible] = useState(true) - const [sort, setSort] = useState('asc') + const [sort, setSort] = useState('desc') return (
- {isFilterVisible && ( - <> - { - return { ...s } - })} - /> - - )} +
+ { + return { ...s } + })} + /> +
{ + setSearch(e.currentTarget.value) + }} size="small" sx={{ + width: '100%', fontSize: '16px', - height: '34px', borderTopLeftRadius: 'var(----mui-shape-borderRadius)', borderBottomLeftRadius: 'var(----mui-shape-borderRadius)', '& .MuiInputBase-root': { @@ -142,7 +167,7 @@ export const HomePage = () => { borderBottomRightRadius: 0 }, '& .MuiInputBase-input': { - padding: '5.5px 14px' + padding: '7px 14px' }, '& .MuiOutlinedInput-notchedOutline': { display: 'none' @@ -152,7 +177,7 @@ export const HomePage = () => {
- - - - - - - -
-
+
+
Click or drag files to upload!
- {sigits.map((sigit, index) => ( - - ))} + {Object.keys(parsedSigits) + .filter((s) => { + const { title, signedStatus } = parsedSigits[s] + const isMatch = title?.toLowerCase().includes(search.toLowerCase()) + switch (filter) { + case 'Completed': + return signedStatus === SignedStatus.Complete && isMatch + case 'In-progress': + return signedStatus === SignedStatus.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 bd0dc08..9a1b4c0 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -10,13 +10,17 @@ .header { display: flex; gap: 10px; - align-items: center; @container (width < 610px) { - flex-wrap: wrap; + flex-direction: column-reverse; } } +.filters { + display: flex; + gap: 10px; +} + .actionButtons { display: flex; justify-content: end; From c18d0f6c131aab8972b24d6b37a5496661adea59 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 8 Aug 2024 15:46:17 +0200 Subject: [PATCH 15/23] refactor: use search submit and clean events, instead of change --- src/pages/home/index.tsx | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index d728a78..b6a801b 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -149,14 +149,27 @@ export const HomePage = () => { />
-
+
{ + e.preventDefault() + const searchInput = e.currentTarget.elements.namedItem( + 'q' + ) as HTMLInputElement + setSearch(searchInput.value) + }} + > { - setSearch(e.currentTarget.value) - }} size="small" + type="search" + onChange={(e) => { + // Handle the case when users click native search input's clear or x + if (e.currentTarget.value === '') { + setSearch(e.currentTarget.value) + } + }} sx={{ width: '100%', fontSize: '16px', @@ -175,6 +188,7 @@ export const HomePage = () => { }} /> -
+
From 83ddc1bbc810a9f0d20dbf381cca5404cb7eb4c5 Mon Sep 17 00:00:00 2001 From: enes Date: Thu, 8 Aug 2024 17:30:49 +0200 Subject: [PATCH 16/23] feat: add dropzone and multiple files support --- package-lock.json | 45 ++++++++++++++++ package.json | 3 +- src/pages/create/index.tsx | 12 ++--- src/pages/home/index.tsx | 104 ++++++++++++++++++------------------- src/utils/nostr.ts | 1 + 5 files changed, 106 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7c16799..23a0986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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,6 +2681,15 @@ "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", @@ -3857,6 +3867,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 +5744,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..a4ebe6f 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 29", "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}\"", @@ -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/pages/create/index.tsx b/src/pages/create/index.tsx index e34b06d..9218b15 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' @@ -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) { @@ -979,7 +979,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 b6a801b..3689b3e 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,6 +1,6 @@ import { Button, TextField } from '@mui/material' import JSZip from 'jszip' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { useAppSelector } from '../../hooks' @@ -10,7 +10,7 @@ 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 { @@ -24,8 +24,8 @@ const FILTERS = [ 'Show all', // 'Drafts', 'In-progress', - 'Completed', - 'Archived' + 'Completed' + // 'Archived' ] as const type Filter = (typeof FILTERS)[number] @@ -40,7 +40,6 @@ type Sort = (typeof SORT_BY)[number]['value'] export const HomePage = () => { const navigate = useNavigate() - const fileInputRef = useRef(null) const [sigits, setSigits] = useState<{ [key: string]: Meta }>({}) const [parsedSigits, setParsedSigits] = useState<{ @@ -74,51 +73,52 @@ export const HomePage = () => { } }, [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 } }) - } - } + navigate(appPrivateRoutes.create, { + state: { uploadedFiles: acceptedFiles } + }) + }, + [navigate] + ) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) const [search, setSearch] = useState('') const [filter, setFilter] = useState('Show all') @@ -202,15 +202,15 @@ export const HomePage = () => {
-
- -
Click or drag files to upload!
+
+
+ + {isDragActive ? ( +

Drop the files here ...

+ ) : ( +

Click or drag files to upload!

+ )} +
{Object.keys(parsedSigits) diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index e27601b..bec854f 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -120,6 +120,7 @@ export const queryNip05 = async ( if (!match) throw new Error('Invalid nip05') // Destructure the match result, assigning default value '_' to name if not provided + // First variable from the match destructuring is ignored const [, name = '_', domain] = match // Construct the URL to query the NIP-05 data From 93b2477839900598195bbb6ab28c82493a8abc98 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 9 Aug 2024 10:58:30 +0200 Subject: [PATCH 17/23] feat(home): add search param to address bar and sync the state with navigation --- src/pages/home/index.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 3689b3e..47a917b 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,7 +1,7 @@ import { Button, TextField } from '@mui/material' import JSZip from 'jszip' import { useCallback, useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'react-toastify' import { useAppSelector } from '../../hooks' import { appPrivateRoutes, appPublicRoutes } from '../../routes' @@ -40,6 +40,15 @@ type Sort = (typeof SORT_BY)[number]['value'] export const HomePage = () => { const navigate = useNavigate() + 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<{ @@ -120,7 +129,6 @@ export const HomePage = () => { const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) - const [search, setSearch] = useState('') const [filter, setFilter] = useState('Show all') const [sort, setSort] = useState('desc') @@ -156,18 +164,22 @@ export const HomePage = () => { const searchInput = e.currentTarget.elements.namedItem( 'q' ) as HTMLInputElement - setSearch(searchInput.value) + searchParams.set('q', searchInput.value) + setSearchParams(searchParams) }} > { // Handle the case when users click native search input's clear or x if (e.currentTarget.value === '') { - setSearch(e.currentTarget.value) + searchParams.delete('q') + setSearchParams(searchParams) } }} sx={{ @@ -216,7 +228,7 @@ export const HomePage = () => { {Object.keys(parsedSigits) .filter((s) => { const { title, signedStatus } = parsedSigits[s] - const isMatch = title?.toLowerCase().includes(search.toLowerCase()) + const isMatch = title?.toLowerCase().includes(q.toLowerCase()) switch (filter) { case 'Completed': return signedStatus === SignedStatus.Complete && isMatch From 15aa98e9db0453cceea9dba7594e1fee71eb3f52 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 9 Aug 2024 11:29:00 +0200 Subject: [PATCH 18/23] refactor(home): increase dropzone area --- .../DrawPDFFields/style.module.scss | 9 +- src/pages/home/index.tsx | 245 +++++++++--------- src/pages/home/style.module.scss | 42 +-- 3 files changed, 155 insertions(+), 141 deletions(-) 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/pages/home/index.tsx b/src/pages/home/index.tsx index 47a917b..4f5c560 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -127,134 +127,143 @@ export const HomePage = () => { [navigate] ) - const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + onDrop, + noClick: true + }) const [filter, setFilter] = useState('Show all') const [sort, setSort] = useState('desc') 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) +
+ +
+
+ - {isDragActive ? ( -

Drop the files here ...

- ) : ( -

Click or drag files to upload!

- )} +
+
+ + {isDragActive ? ( +

Drop the files here ...

+ ) : ( +

Click or drag files to upload!

+ )} +
-
-
- {Object.keys(parsedSigits) - .filter((s) => { - const { title, signedStatus } = parsedSigits[s] - const isMatch = title?.toLowerCase().includes(q.toLowerCase()) - switch (filter) { - case 'Completed': - return signedStatus === SignedStatus.Complete && isMatch - case 'In-progress': - return signedStatus === SignedStatus.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) => ( - - ))} -
- +
+ {Object.keys(parsedSigits) + .filter((s) => { + const { title, signedStatus } = parsedSigits[s] + const isMatch = title?.toLowerCase().includes(q.toLowerCase()) + switch (filter) { + case 'Completed': + return signedStatus === SignedStatus.Complete && isMatch + case 'In-progress': + return signedStatus === SignedStatus.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 9a1b4c0..6b74e76 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -53,31 +53,35 @@ } .dropzone { + position: relative; + + font-size: 16px; background-color: $overlay-background-color; height: 250px; - transition: padding ease 0.2s; - padding: 15px; + color: rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; - &:hover { - padding: 10px; - - > div { - background: rgba(0, 0, 0, 0.15); - } - } - - > div { - transition: background-color ease 0.2s; - 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); - color: rgba(0, 0, 0, 0.25); - height: 100%; border-radius: 2px; border: dashed 3px rgba(0, 0, 0, 0.1); - font-size: 16px; + inset: 15px; + } + + &.isDragActive, + &:hover { + &::before { + inset: 10px; + background: rgba(0, 0, 0, 0.15); + } } } From 72d0e065eae580611c5a9252c8521086495dd2c5 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 9 Aug 2024 12:00:36 +0200 Subject: [PATCH 19/23] fix(home): focus outlines and decorations --- src/App.scss | 2 ++ src/pages/home/index.tsx | 25 +++++++++++++------------ src/pages/home/style.module.scss | 9 +++++++++ src/theme/index.ts | 3 +++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/App.scss b/src/App.scss index 5461a4a..6724890 100644 --- a/src/App.scss +++ b/src/App.scss @@ -56,7 +56,9 @@ a { text-decoration: none; text-decoration-color: inherit; transition: ease 0.4s; + outline: none; + &:focus, &:hover { color: $primary-light; text-decoration: underline; diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 4f5c560..bb3115f 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -136,7 +136,7 @@ export const HomePage = () => { const [sort, setSort] = useState('desc') return ( -
+
@@ -212,26 +212,27 @@ export const HomePage = () => { borderBottomLeftRadius: 0 }} variant={'contained'} - aria-label="Submit Search" + aria-label="submit search" >
-
-
- - {isDragActive ? ( -

Drop the files here ...

- ) : ( -

Click or drag files to upload!

- )} -
-
+ + {isDragActive ? ( +

Drop the files here ...

+ ) : ( +

Click or drag files to upload!

+ )} +
{Object.keys(parsedSigits) .filter((s) => { diff --git a/src/pages/home/style.module.scss b/src/pages/home/style.module.scss index 6b74e76..63917a0 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -76,6 +76,7 @@ inset: 15px; } + &:focus, &.isDragActive, &:hover { &::before { @@ -83,6 +84,14 @@ 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 { diff --git a/src/theme/index.ts b/src/theme/index.ts index a7a37d4..8cc008f 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -33,6 +33,9 @@ export const theme = extendTheme({ boxShadow: 'unset', lineHeight: 'inherit', borderRadius: '4px', + ':focus': { + textDecoration: 'none' + }, ':hover': { background: 'var(--primary-light)', boxShadow: 'unset' From f896849ffd88d98c0edfae94eda2c6eed8b884ed Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 12 Aug 2024 14:26:03 +0200 Subject: [PATCH 20/23] refactor: expand useSigitMeta and add comments --- src/components/DisplaySigit/index.tsx | 75 +++++---- src/components/DisplaySigner/index.tsx | 2 + src/hooks/useSigitMeta.tsx | 222 ++++++++++++++----------- src/pages/home/index.tsx | 37 ++--- src/utils/index.ts | 1 + src/utils/meta.ts | 181 ++++++++++++++++++++ 6 files changed, 369 insertions(+), 149 deletions(-) create mode 100644 src/utils/meta.ts diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx index e930b37..0fe5c2f 100644 --- a/src/components/DisplaySigit/index.tsx +++ b/src/components/DisplaySigit/index.tsx @@ -1,6 +1,6 @@ -import { Dispatch, SetStateAction, useEffect } from 'react' +import { useEffect, useState } from 'react' import { Meta, ProfileMetadata } from '../../types' -import { SigitInfo, SignedStatus } from '../../hooks/useSigitMeta' +import { SigitCardDisplayInfo, SigitStatus } from '../../utils' import { Event, kinds } from 'nostr-tools' import { Link } from 'react-router-dom' import { MetadataController } from '../../controllers' @@ -25,17 +25,10 @@ import { getExtensionIconLabel } from '../getExtensionIconLabel' type SigitProps = { meta: Meta - parsedMeta: SigitInfo - profiles: { [key: string]: ProfileMetadata } - setProfiles: Dispatch> + parsedMeta: SigitCardDisplayInfo } -export const DisplaySigit = ({ - meta, - parsedMeta, - profiles, - setProfiles -}: SigitProps) => { +export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => { const { title, createdAt, @@ -45,51 +38,67 @@ export const DisplaySigit = ({ fileExtensions } = parsedMeta + const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>( + {} + ) + useEffect(() => { - const hexKeys: string[] = [] + const hexKeys = new Set([ + ...signers.map((signer) => npubToHex(signer)!) + ]) if (submittedBy) { - hexKeys.push(npubToHex(submittedBy)!) + hexKeys.add(npubToHex(submittedBy)!) } - hexKeys.push(...signers.map((signer) => npubToHex(signer)!)) 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)) { - 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.on(key, handleEventListener(key)) metadataController .findMetadata(key) .then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(metadataEvent) + if (metadataEvent) handleMetadataEvent(key)(metadataEvent) }) .catch((err) => { console.error(`error occurred in finding metadata for: ${key}`, err) }) } }) - }, [submittedBy, signers, profiles, setProfiles]) + + return () => { + hexKeys.forEach((key) => { + metadataController.off(key, handleEventListener(key)) + }) + } + }, [submittedBy, signers, profiles]) return (
() useEffect(() => { + if (!meta) return + const updateSignStatus = async () => { const npub = hexToNpub(pubkey) if (npub in meta.docSignatures) { diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index e798ef0..d8ef9c2 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -1,117 +1,147 @@ import { useEffect, useState } from 'react' -import { toast } from 'react-toastify' import { CreateSignatureEventContent, Meta } from '../types' -import { parseJson } from '../utils' +import { Mark } from '../types/mark' +import { + parseCreateSignatureEvent, + parseCreateSignatureEventContent, + SigitMetaParseError, + SigitStatus, + SignStatus +} from '../utils' +import { toast } from 'react-toastify' +import { verifyEvent } from 'nostr-tools' import { Event } from 'nostr-tools' -type npub = `npub1${string}` +interface FlatMeta extends Meta, CreateSignatureEventContent, Partial { + // Validated create signature event + isValid: boolean -export enum SignedStatus { - Partial = 'In-Progress', - Complete = 'Completed' -} - -export interface SigitInfo { - createdAt?: number - title?: string - submittedBy?: string - signers: npub[] - fileExtensions: string[] - signedStatus: SignedStatus -} - -export const extractSigitInfo = async (meta: Meta) => { - if (!meta?.createSignature) return - - const sigitInfo: SigitInfo = { - signers: [], - fileExtensions: [], - signedStatus: SignedStatus.Partial + // Calculated status fields + signedStatus: SigitStatus + signersStatus: { + [signer: `npub1${string}`]: SignStatus } - - 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 - }) - - if (!createSignatureEvent) return - - // created_at in nostr events are stored in seconds - sigitInfo.createdAt = createSignatureEvent.created_at * 1000 - - const createSignatureContent = await parseJson( - createSignatureEvent.content - ).catch((err) => { - console.log(`err in parsing the createSignature event's content :>> `, err) - return - }) - - if (!createSignatureContent) return - - 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 npub[] - 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 = SignedStatus.Complete - } - - return sigitInfo } -export const useSigitMeta = (meta: Meta) => { - const [title, setTitle] = useState() - const [createdAt, setCreatedAt] = useState() - const [submittedBy, setSubmittedBy] = useState() - const [signers, setSigners] = useState([]) - const [signedStatus, setSignedStatus] = useState( - SignedStatus.Partial +/** + * 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 [fileExtensions, setFileExtensions] = useState([]) + const [signersStatus, setSignersStatus] = useState<{ + [signer: `npub1${string}`]: SignStatus + }>({}) useEffect(() => { - const getSigitInfo = async () => { - const sigitInfo = await extractSigitInfo(meta) + if (!meta) return + ;(async function () { + try { + const createSignatureEvent = await parseCreateSignatureEvent( + meta.createSignature + ) - if (!sigitInfo) return + const { kind, tags, created_at, pubkey, id, sig, content } = + createSignatureEvent - setTitle(sigitInfo.title) - setCreatedAt(sigitInfo.createdAt) - setSubmittedBy(sigitInfo.submittedBy) - setSigners(sigitInfo.signers) - setSignedStatus(sigitInfo.signedStatus) - setFileExtensions(sigitInfo.fileExtensions) - } + setIsValid(verifyEvent(createSignatureEvent)) + setKind(kind) + setTags(tags) + // created_at in nostr events are stored in seconds + setCreatedAt(created_at * 1000) + setPubkey(pubkey) + setId(id) + setSig(sig) - getSigitInfo() + 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 { - title, - createdAt, - submittedBy, + 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, - fileExtensions + signersStatus } } diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index bb3115f..0f0329b 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -5,7 +5,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'react-toastify' import { useAppSelector } from '../../hooks' import { appPrivateRoutes, appPublicRoutes } from '../../routes' -import { Meta, ProfileMetadata } from '../../types' +import { Meta } from '../../types' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faSearch } from '@fortawesome/free-solid-svg-icons' import { Select } from '../../components/Select' @@ -14,10 +14,10 @@ import { useDropzone } from 'react-dropzone' import { Container } from '../../components/Container' import styles from './style.module.scss' import { - extractSigitInfo, - SigitInfo, - SignedStatus -} from '../../hooks/useSigitMeta' + extractSigitCardDisplayInfo, + SigitCardDisplayInfo, + SigitStatus +} from '../../utils' // Unsupported Filter options are commented const FILTERS = [ @@ -52,29 +52,28 @@ export const HomePage = () => { const [sigits, setSigits] = useState<{ [key: string]: Meta }>({}) const [parsedSigits, setParsedSigits] = useState<{ - [key: string]: SigitInfo + [key: string]: SigitCardDisplayInfo }>({}) - const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) const usersAppData = useAppSelector((state) => state.userAppData) useEffect(() => { if (usersAppData) { 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 extractSigitInfo(usersAppData.sigits[key]) + const sigitInfo = await extractSigitCardDisplayInfo( + usersAppData.sigits[key] + ) if (sigitInfo) { - setParsedSigits((prev) => { - return { - ...prev, - [key]: sigitInfo - } - }) + parsedSigits[key] = sigitInfo } } } + + setParsedSigits({ + ...parsedSigits + }) } setSigits(usersAppData.sigits) @@ -240,9 +239,9 @@ export const HomePage = () => { const isMatch = title?.toLowerCase().includes(q.toLowerCase()) switch (filter) { case 'Completed': - return signedStatus === SignedStatus.Complete && isMatch + return signedStatus === SigitStatus.Complete && isMatch case 'In-progress': - return signedStatus === SignedStatus.Partial && isMatch + return signedStatus === SigitStatus.Partial && isMatch case 'Show all': return isMatch default: @@ -259,8 +258,6 @@ export const HomePage = () => { key={`sigit-${key}`} parsedMeta={parsedSigits[key]} meta={sigits[key]} - profiles={profiles} - setProfiles={setProfiles} /> ))}
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..e9e3f13 --- /dev/null +++ b/src/utils/meta.ts @@ -0,0 +1,181 @@ +import { CreateSignatureEventContent, Meta } from '../types' +import { 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 = createSignatureEvent.created_at * 1000 + + 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) + } + } +} From 950593aeedc83afc98b6c2c6a8dab6a68f1d1102 Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 12 Aug 2024 16:01:56 +0200 Subject: [PATCH 21/23] chore(git-hooks): clean commit-msg script --- .git-hooks/commit-msg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 6652c6519e3384a9f08e725d9c79b7d9c3cc7142 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 13 Aug 2024 11:52:05 +0200 Subject: [PATCH 22/23] refactor(review): add date util functions --- package.json | 2 +- src/controllers/AuthController.ts | 5 +++-- src/controllers/MetadataController.ts | 6 +++--- src/controllers/NostrController.ts | 25 +++++++++++++-------- src/hooks/useSigitMeta.tsx | 3 ++- src/pages/create/index.tsx | 12 +++++------ src/pages/settings/profile/index.tsx | 4 ++-- src/pages/sign/index.tsx | 13 +++++------ src/pages/verify/index.tsx | 6 +++--- src/utils/meta.ts | 4 ++-- src/utils/misc.ts | 8 +++---- src/utils/nostr.ts | 31 ++++++++++++++++++++------- 12 files changed, 69 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index a4ebe6f..5e1619e 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 29", + "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}\"", 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 index d8ef9c2..aebd791 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { CreateSignatureEventContent, Meta } from '../types' import { Mark } from '../types/mark' import { + fromUnixTimestamp, parseCreateSignatureEvent, parseCreateSignatureEventContent, SigitMetaParseError, @@ -68,7 +69,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setKind(kind) setTags(tags) // created_at in nostr events are stored in seconds - setCreatedAt(created_at * 1000) + setCreatedAt(fromUnixTimestamp(created_at)) setPubkey(pubkey) setId(id) setSig(sig) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 9218b15..77b7a87 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -50,7 +50,7 @@ import { getHash, hexToNpub, isOnline, - now, + unixNow, npubToHex, queryNip05, sendNotification, @@ -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: {} } diff --git a/src/pages/settings/profile/index.tsx b/src/pages/settings/profile/index.tsx index 7d6c923..9b2fc2d 100644 --- a/src/pages/settings/profile/index.tsx +++ b/src/pages/settings/profile/index.tsx @@ -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 || [] 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/utils/meta.ts b/src/utils/meta.ts index e9e3f13..b3c0c28 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -1,5 +1,5 @@ import { CreateSignatureEventContent, Meta } from '../types' -import { parseJson } from '.' +import { fromUnixTimestamp, parseJson } from '.' import { Event } from 'nostr-tools' import { toast } from 'react-toastify' @@ -140,7 +140,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { ) // created_at in nostr events are stored in seconds - sigitInfo.createdAt = createSignatureEvent.created_at * 1000 + sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at) const createSignatureContent = await parseCreateSignatureEventContent( createSignatureEvent.content 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 bec854f..e9ecc8f 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -211,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 @@ -288,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], @@ -542,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 } @@ -608,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] ] } @@ -667,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)] ] @@ -875,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 From 115a3974e278c137aa2d490327bcc92e4ed0c492 Mon Sep 17 00:00:00 2001 From: enes Date: Wed, 14 Aug 2024 10:35:51 +0200 Subject: [PATCH 23/23] fix(deps): update axios Bump axios version to 1.7.4, audit reported the issues in axios >= 1.3.2 --- package-lock.json | 11 ++++++----- package.json | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 23a0986..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", @@ -2691,11 +2691,12 @@ } }, "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" } diff --git a/package.json b/package.json index 5e1619e..447fdda 100644 --- a/package.json +++ b/package.json @@ -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",