From 390d488b130b1c4e2c38eabaef2a95ad36d040e7 Mon Sep 17 00:00:00 2001 From: Bojan Mojsilovic Date: Fri, 2 Feb 2024 16:31:42 +0100 Subject: [PATCH] Add profile zap and QR codes --- package-lock.json | 22 +- package.json | 1 + src/assets/icons/qr_code.svg | 13 ++ src/components/Avatar/Avatar.module.scss | 4 +- src/components/Buttons/ButtonCopy.tsx | 7 +- src/components/Buttons/Buttons.module.scss | 19 +- src/components/CustomZap/CustomZap.tsx | 44 ++-- src/components/Note/NoteFooter/NoteFooter.tsx | 9 + .../ProfileQrCodeModal.module.scss | 195 ++++++++++++++++++ .../ProfileQrCodeModal/ProfileQrCodeModal.tsx | 135 ++++++++++++ src/components/QrCode/QrCode.module.scss | 12 ++ src/components/QrCode/QrCode.tsx | 65 ++++++ src/lib/zap.ts | 38 ++++ src/pages/Profile.module.scss | 10 + src/pages/Profile.tsx | 41 +++- 15 files changed, 588 insertions(+), 27 deletions(-) create mode 100644 src/assets/icons/qr_code.svg create mode 100644 src/components/ProfileQrCodeModal/ProfileQrCodeModal.module.scss create mode 100644 src/components/ProfileQrCodeModal/ProfileQrCodeModal.tsx create mode 100644 src/components/QrCode/QrCode.module.scss create mode 100644 src/components/QrCode/QrCode.tsx diff --git a/package-lock.json b/package-lock.json index 2cc0253..4ef0114 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "primal-web-app", - "version": "0.97.3", + "version": "0.101.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "primal-web-app", - "version": "0.97.3", + "version": "0.101.2", "license": "MIT", "dependencies": { "@cookbook/solid-intl": "0.1.2", "@jukben/emoji-search": "3.0.0", - "@kobalte/core": "^0.11.0", + "@kobalte/core": "0.11.0", "@picocss/pico": "1.5.10", "@scure/base": "1.1.3", "@solidjs/router": "0.8.3", @@ -20,7 +20,8 @@ "dompurify": "3.0.5", "medium-zoom": "1.0.8", "nostr-tools": "1.15.0", - "photoswipe": "^5.4.3", + "photoswipe": "5.4.3", + "qr-code-styling": "^1.6.0-rc.1", "sass": "1.67.0", "solid-js": "1.7.11" }, @@ -1881,6 +1882,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qr-code-styling": { + "version": "1.6.0-rc.1", + "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.6.0-rc.1.tgz", + "integrity": "sha512-ModRIiW6oUnsP18QzrRYZSc/CFKFKIdj7pUs57AEVH20ajlglRpN3HukjHk0UbNMTlKGuaYl7Gt6/O5Gg2NU2Q==", + "dependencies": { + "qrcode-generator": "^1.4.3" + } + }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==" + }, "node_modules/readdirp": { "version": "3.6.0", "license": "MIT", diff --git a/package.json b/package.json index aff8251..48f9640 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "medium-zoom": "1.0.8", "nostr-tools": "1.15.0", "photoswipe": "5.4.3", + "qr-code-styling": "^1.6.0-rc.1", "sass": "1.67.0", "solid-js": "1.7.11" } diff --git a/src/assets/icons/qr_code.svg b/src/assets/icons/qr_code.svg new file mode 100644 index 0000000..3c39bc8 --- /dev/null +++ b/src/assets/icons/qr_code.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/components/Avatar/Avatar.module.scss b/src/components/Avatar/Avatar.module.scss index 77ae426..7f1982e 100644 --- a/src/components/Avatar/Avatar.module.scss +++ b/src/components/Avatar/Avatar.module.scss @@ -119,8 +119,8 @@ .smallAvatar { @include avatar; - width: 48px; - height: 48px; + width: 44px; + height: 44px; .missingBack { width: 44px; diff --git a/src/components/Buttons/ButtonCopy.tsx b/src/components/Buttons/ButtonCopy.tsx index f1d36cd..b9cd49c 100644 --- a/src/components/Buttons/ButtonCopy.tsx +++ b/src/components/Buttons/ButtonCopy.tsx @@ -10,6 +10,7 @@ const ButtonCopy: Component<{ disabled?: boolean, label?: string, labelBeforeIcon?: boolean, + light?: boolean, }> = (props) => { const [copying, setCopying] = createSignal(false); @@ -24,7 +25,7 @@ const ButtonCopy: Component<{ return ( @@ -34,9 +35,9 @@ const ButtonCopy: Component<{ } + fallback={
} > -
+
diff --git a/src/components/Buttons/Buttons.module.scss b/src/components/Buttons/Buttons.module.scss index 055f743..49096fb 100644 --- a/src/components/Buttons/Buttons.module.scss +++ b/src/components/Buttons/Buttons.module.scss @@ -46,10 +46,16 @@ .copyIcon { width: 16px; height: 16px; - margin-inline: 8px; background-color: var(--text-tertiary-2); -webkit-mask: url(../../assets/icons/copy.svg) no-repeat 0 / 100%; mask: url(../../assets/icons/copy.svg) no-repeat 0 / 100%; + + &.left { + margin-right: 8px; + } + &.right { + margin-left: 8px; + } } @@ -57,10 +63,16 @@ width: 16px; height: 16px; display: inline-block; - margin-inline: 8px; background-color: var(--success-color); -webkit-mask: url(../../assets/icons/check.svg) no-repeat 0 / 80%; mask: url(../../assets/icons/check.svg) no-repeat 0 / 80%; + + &.left { + margin-right: 8px; + } + &.right { + margin-left: 8px; + } } color: var(--text-tertiary-2); @@ -76,6 +88,9 @@ display: flex; align-items: center; + &.light { + color: var(--text-secondary); + } &:focus { box-shadow: none; diff --git a/src/components/CustomZap/CustomZap.tsx b/src/components/CustomZap/CustomZap.tsx index dc78921..cf9e901 100644 --- a/src/components/CustomZap/CustomZap.tsx +++ b/src/components/CustomZap/CustomZap.tsx @@ -4,10 +4,10 @@ import { defaultZap, defaultZapOptions } from '../../constants'; import { useAccountContext } from '../../contexts/AccountContext'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { hookForDev } from '../../lib/devTools'; -import { zapNote } from '../../lib/zap'; +import { zapNote, zapProfile } from '../../lib/zap'; import { userName } from '../../stores/profile'; import { toastZapFail, zapCustomOption, actions as tActions, placeholders as tPlaceholders, zapCustomAmount } from '../../translations'; -import { PrimalNote, ZapOption } from '../../types/primal'; +import { PrimalNote, PrimalUser, ZapOption } from '../../types/primal'; import { debounce } from '../../utils'; import ButtonPrimary from '../Buttons/ButtonPrimary'; import Modal from '../Modal/Modal'; @@ -19,10 +19,12 @@ import styles from './CustomZap.module.scss'; const CustomZap: Component<{ id?: string, open?: boolean, - note: PrimalNote, + note?: PrimalNote, + profile?: PrimalUser, onConfirm: (zapOption?: ZapOption) => void, onSuccess: (zapOption?: ZapOption) => void, - onFail: (zapOption?: ZapOption) => void + onFail: (zapOption?: ZapOption) => void, + onCancel: (zapOption?: ZapOption) => void }> = (props) => { const toast = useToastContext(); @@ -89,13 +91,27 @@ const CustomZap: Component<{ const submit = async () => { if (account?.hasPublicKey()) { props.onConfirm(selectedValue()); - const success = await zapNote( - props.note, - account.publicKey, - selectedValue().amount || 0, - comment(), - account.relays, - ); + + let success = false; + + if (props.note) { + success = await zapNote( + props.note, + account.publicKey, + selectedValue().amount || 0, + comment(), + account.relays, + ); + } + else if (props.profile) { + success = await zapProfile( + props.profile, + account.publicKey, + selectedValue().amount || 0, + comment(), + account.relays, + ); + } if (success) { props.onSuccess(selectedValue()); @@ -111,7 +127,7 @@ const CustomZap: Component<{ }; return ( - props.onFail({ amount: 0, message: '' })}> + props.onCancel({ amount: 0, message: '' })}>
@@ -119,13 +135,13 @@ const CustomZap: Component<{ {intl.formatMessage(tActions.zap)}
-
{intl.formatMessage(zapCustomOption,{ - user: userName(props.note.user), + user: userName(props.note?.user || props.profile), })} diff --git a/src/components/Note/NoteFooter/NoteFooter.tsx b/src/components/Note/NoteFooter/NoteFooter.tsx index b5237a9..cb8bcad 100644 --- a/src/components/Note/NoteFooter/NoteFooter.tsx +++ b/src/components/Note/NoteFooter/NoteFooter.tsx @@ -437,6 +437,15 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> = setHideZapIcon(false); setZapped(props.note.post.noteActions.zapped); }} + onCancel={(zapOption: ZapOption) => { + setZappedAmount(() => -(zapOption.amount || 0)); + setZappedNow(true); + setIsCustomZap(false); + setIsZapping(false); + setShowZapAnim(false); + setHideZapIcon(false); + setZapped(props.note.post.noteActions.zapped); + }} />
diff --git a/src/components/ProfileQrCodeModal/ProfileQrCodeModal.module.scss b/src/components/ProfileQrCodeModal/ProfileQrCodeModal.module.scss new file mode 100644 index 0000000..d3a002d --- /dev/null +++ b/src/components/ProfileQrCodeModal/ProfileQrCodeModal.module.scss @@ -0,0 +1,195 @@ +.ProfileQrCodeModal { + position: fixed; + min-width: 472px; + color: var(--text-primary); + background-color: var(--background-input); + border-radius: 8px; + + display: flex; + flex-direction: column; + padding: 20px 24px 28px 24px; + + .header { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 24px; + + .userInfo { + display: flex; + justify-content: flex-start; + align-items: flex-start; + + .avatar { + display: flex; + align-items: center; + justify-content: center; + } + + .details { + display: flex; + flex-direction: column; + margin-left: 8px; + justify-content: center; + height: 44px; + + .name { + display: flex; + flex-direction: row; + align-items: center; + color: var(--text-primary); + font-size: 20px; + font-weight: 700; + line-height: 20px; + } + + .verification { + color: var(--text-tertiary); + font-size: 16px; + font-weight: 400; + line-height: 16px; + } + } + + } + + .close { + border: none; + outline: none; + padding: 0; + margin: 0; + box-shadow: none; + width: 20px; + height: 20px; + display: inline-block; + margin: 0px 0px; + background-color: var(--text-secondary); + -webkit-mask: url(../../assets/icons/close.svg) no-repeat center; + mask: url(../../assets/icons/close.svg) no-repeat center; + + &:hover { + background-color: var(--text-primary); + } + } + } + + .tabs { + position: relative; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding-inline: 8px; + width: 100%; + border-radius: 0; + padding-top: 22px; + border-bottom: 1px solid var(--devider); + border-top: none; + margin-bottom: 20px; + } + + .tab { + position: relative; + display: inline-block; + padding-inline: 14px; + padding-block: 2px; + border: none; + background: none; + width: fit-content; + height: 28px; + margin: 0; + margin-bottom: 12px; + + color: var(--text-primary); + font-size: 16px; + font-weight: 600; + line-height: 14px; + + &:focus { + outline: none; + box-shadow: none; + } + } + + .tabIndicator { + position: absolute; + height: 4px; + top: 54px; + left: 0; + border-radius: 2px 2px 0px 0px; + background: var(--accent); + transition: all 250ms; + } + + .tabContent { + @keyframes fadeIn { + from { + opacity:0; + } + + to { + opacity:1; + } + + } + animation: fadeIn 1s; + } + + .keys { + margin-top: 34px; + padding-top: 24px; + + border-top: 1px solid var(--subtile-devider); + + display: flex; + flex-direction: column; + gap: 16px; + + .keyEntry { + display: flex; + justify-content: space-between; + align-items: center; + + color: var(--text-secondary); + font-size: 16px; + font-weight: 400; + line-height: 16px; + + padding-inline: 12px; + } + } + + +} + +.zapIcon { + width: 22px; + height: 22px; + display: inline-block; + margin-right: 9px; + background: var(--sidebar-section-icon-gradient); + -webkit-mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px; + mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px; +} + +.customAmount { + width: 392px; + display: flex; + flex-flow: row-reverse; + align-items: center; + justify-content: space-between; + padding-right: 8px; + padding-bottom: 20px; + border-bottom: 1px solid var(--subtile-devider); + margin-bottom: 20px; + + label { + min-width: 120px; + margin-right: 12px; + margin-top: 4px; + color: var(--text-tertiary); + font-size: 16px; + font-weight: 400; + line-height: 20px; + } +} diff --git a/src/components/ProfileQrCodeModal/ProfileQrCodeModal.tsx b/src/components/ProfileQrCodeModal/ProfileQrCodeModal.tsx new file mode 100644 index 0000000..8964677 --- /dev/null +++ b/src/components/ProfileQrCodeModal/ProfileQrCodeModal.tsx @@ -0,0 +1,135 @@ +import { useIntl } from '@cookbook/solid-intl'; +import { Tabs } from '@kobalte/core'; +import { Component, createEffect, createSignal, For, Show } from 'solid-js'; +import { defaultZap, defaultZapOptions } from '../../constants'; +import { useAccountContext } from '../../contexts/AccountContext'; +import { useSettingsContext } from '../../contexts/SettingsContext'; +import { hookForDev } from '../../lib/devTools'; +import { truncateNumber } from '../../lib/notifications'; +import { zapNote, zapProfile } from '../../lib/zap'; +import { authorName, nip05Verification, truncateNpub, userName } from '../../stores/profile'; +import { toastZapFail, zapCustomOption, actions as tActions, placeholders as tPlaceholders, zapCustomAmount } from '../../translations'; +import { PrimalNote, PrimalUser, ZapOption } from '../../types/primal'; +import { debounce } from '../../utils'; +import Avatar from '../Avatar/Avatar'; +import ButtonCopy from '../Buttons/ButtonCopy'; +import ButtonPrimary from '../Buttons/ButtonPrimary'; +import Modal from '../Modal/Modal'; +import QrCode from '../QrCode/QrCode'; +import TextInput from '../TextInput/TextInput'; +import { useToastContext } from '../Toaster/Toaster'; +import VerificationCheck from '../VerificationCheck/VerificationCheck'; + +import styles from './ProfileQrCodeModal.module.scss'; + +const ProfileQrCodeModal: Component<{ + id?: string, + open?: boolean, + profile: PrimalUser, + onClose?: () => void, +}> = (props) => { + + const toast = useToastContext(); + const account = useAccountContext(); + const intl = useIntl(); + const settings = useSettingsContext(); + + const profileData = () => Object.entries({ + pubkey: { + title: 'Public key', + data: props.profile.npub || props.profile.pubkey, + }, + lnAddress: { + title: 'Lightning address', + data: props.profile.lud16 || props.profile.lud06, + } + }); + + return ( + +
+
+
+
+ +
+
+
+ {authorName(props.profile)} + +
+
+ + + {nip05Verification(props.profile)} + + +
+
+
+ +
+ +
+ + + + {([key, info]) => + + + {info.title} + + + } + + + + + + + {([key, info]) => + + + + + + } + + +
+ +
+ + + {([key, info]) => + +
+
+ {info.title}: +
+
+ +
+
+
+ } +
+
+
+
+ ); +} + +export default hookForDev(ProfileQrCodeModal); diff --git a/src/components/QrCode/QrCode.module.scss b/src/components/QrCode/QrCode.module.scss new file mode 100644 index 0000000..b454886 --- /dev/null +++ b/src/components/QrCode/QrCode.module.scss @@ -0,0 +1,12 @@ +.container { + display: flex; + justify-content: center; + width: 100%; + + .frame { + width: 280px; + height: 280px; + border-radius: 24px; + overflow: hidden; + } +} diff --git a/src/components/QrCode/QrCode.tsx b/src/components/QrCode/QrCode.tsx new file mode 100644 index 0000000..6c19430 --- /dev/null +++ b/src/components/QrCode/QrCode.tsx @@ -0,0 +1,65 @@ +import QRCodeStyling from 'qr-code-styling'; +import { Component, createEffect, onMount } from 'solid-js'; + +import primalLogoFire from '../../assets/icons/logo_fire.svg' +import primalLogoIce from '../../assets/icons/logo_ice.svg' +import { useSettingsContext } from '../../contexts/SettingsContext'; + +import styles from './QrCode.module.scss'; + + +const QrCode: Component<{ data: string }> = (props) => { + let qrSlot: HTMLDivElement | undefined; + + const settings = useSettingsContext(); + + const isIce = () => ['midnight', 'ice'].includes(settings?.theme || ''); + + createEffect(() => { + const qrCode = new QRCodeStyling({ + width: 280, + height: 280, + type: "svg", + data: props.data, + margin: 6, + image: isIce() ? primalLogoIce : primalLogoFire, + qrOptions: { + typeNumber: 0, + mode: "Byte", + errorCorrectionLevel :"Q", + }, + imageOptions: { + hideBackgroundDots: true, + imageSize:0.2, + margin:0, + }, + dotsOptions:{ + type: "square", + color: 'black', + }, + cornersSquareOptions: { + type: "square", + color: 'black', + }, + cornersDotOptions: { + type: "square", + color: 'black', + }, + backgroundOptions: { + color: 'white', + }, + }); + + qrCode.append(qrSlot); + }); + + return ( +
+
+
+
+
+ ); +} + +export default QrCode; diff --git a/src/lib/zap.ts b/src/lib/zap.ts index 6911118..be05bea 100644 --- a/src/lib/zap.ts +++ b/src/lib/zap.ts @@ -43,6 +43,44 @@ export const zapNote = async (note: PrimalNote, sender: string | undefined, amou } } +export const zapProfile = async (profile: PrimalUser, sender: string | undefined, amount: number, comment = '', relays: Relay[]) => { + if (!sender || !profile) { + return false; + } + + const callback = await getZapEndpoint(profile); + + if (!callback) { + return false; + } + + const sats = Math.round(amount * 1000); + + const zapReq = nip57.makeZapRequest({ + profile: profile.pubkey, + amount: sats, + comment, + relays: relays.map(r => r.url) + }); + + try { + const signedEvent = await signEvent(zapReq); + + const event = encodeURI(JSON.stringify(signedEvent)); + + const r2 = await (await fetch(`${callback}?amount=${sats}&nostr=${event}`)).json(); + const pr = r2.pr; + + await enableWebLn(); + await sendPayment(pr); + + return true; + } catch (reason) { + console.error('Failed to zap: ', reason); + return false; + } +} + export const getZapEndpoint = async (user: PrimalUser): Promise => { try { let lnurl: string = '' diff --git a/src/pages/Profile.module.scss b/src/pages/Profile.module.scss index 4ec3b76..a218c7f 100644 --- a/src/pages/Profile.module.scss +++ b/src/pages/Profile.module.scss @@ -170,6 +170,16 @@ mask: url(../assets/icons/feed_zap.svg) no-repeat 0px / 20px; } +.qrIcon { + width: 20px; + height: 20px; + display: inline-block; + margin: 0px; + background-color: var(--text-primary); + -webkit-mask: url(../assets/icons/qr_code.svg) no-repeat 0px / 20px; + mask: url(../assets/icons/qr_code.svg) no-repeat 0px / 20px; +} + .contextIcon { width: 20px; height: 20px; diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index fee6fd1..130fe6f 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -25,7 +25,7 @@ import { shortDate } from '../lib/dates'; import styles from './Profile.module.scss'; import StickySidebar from '../components/StickySidebar/StickySidebar'; import ProfileSidebar from '../components/ProfileSidebar/ProfileSidebar'; -import { MenuItem, PrimalUser, VanityProfiles } from '../types/primal'; +import { MenuItem, PrimalUser, VanityProfiles, ZapOption } from '../types/primal'; import PageTitle from '../components/PageTitle/PageTitle'; import FollowButton from '../components/FollowButton/FollowButton'; import Search from '../components/Search/Search'; @@ -43,6 +43,9 @@ import VerificationCheck from '../components/VerificationCheck/VerificationCheck import PhotoSwipeLightbox from 'photoswipe/lightbox'; import NoteImage from '../components/NoteImage/NoteImage'; import { createStore } from 'solid-js/store'; +import CustomZap from '../components/CustomZap/CustomZap'; +import Modal from '../components/Modal/Modal'; +import ProfileQrCodeModal from '../components/ProfileQrCodeModal/ProfileQrCodeModal'; const Profile: Component = () => { @@ -61,6 +64,8 @@ const Profile: Component = () => { const [showContext, setContext] = createSignal(false); const [confirmReportUser, setConfirmReportUser] = createSignal(false); const [confirmMuteUser, setConfirmMuteUser] = createSignal(false); + const [isCustomZap, setIsCustomZap] = createSignal(false); + const [openQr, setOpenQr] = createSignal(false); const lightbox = new PhotoSwipeLightbox({ gallery: '#central_header', @@ -537,14 +542,46 @@ const Profile: Component = () => { hidden={!showContext()} /> + setOpenQr(true)} + shrink={true} + > +
+
+ + setOpenQr(false)} + profile={profile?.userProfile} + /> setIsCustomZap(true)} shrink={true} >
+ + { + setIsCustomZap(false); + }} + onSuccess={(zapOption: ZapOption) => { + setIsCustomZap(false); + toaster?.sendSuccess("Profile successfully zapped") + }} + onFail={(zapOption: ZapOption) => { + setIsCustomZap(false); + toaster?.sendWarning("Zaping failed") + }} + onCancel={(zapOption: ZapOption) => { + setIsCustomZap(false); + toaster?.sendWarning("Zaping canceled") + }} + />