diff --git a/src/components/AudioPlayer/index.tsx b/src/components/AudioPlayer/index.tsx index 08a9ae01..cae97bdf 100644 --- a/src/components/AudioPlayer/index.tsx +++ b/src/components/AudioPlayer/index.tsx @@ -95,7 +95,14 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) { {/* Progress Section */}
- +
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 031aa8bc..1d0c728d 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -4,7 +4,8 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { createCommentDraftEvent, createPollDraftEvent, - createShortTextNoteDraftEvent + createShortTextNoteDraftEvent, + deleteDraftEventCache } from '@/lib/draft-event' import { isTouchDevice } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' @@ -56,6 +57,7 @@ export default function PostContent({ endsAt: undefined, relays: [] }) + const [minPow, setMinPow] = useState(0) const isFirstRender = useRef(true) const canPost = !!pubkey && @@ -133,10 +135,12 @@ export default function PostContent({ const newEvent = await publish(draftEvent, { specifiedRelayUrls, - additionalRelayUrls: isPoll ? pollCreateData.relays : [] + additionalRelayUrls: isPoll ? pollCreateData.relays : [], + minPow }) - addReplies([newEvent]) postEditorCache.clearPostCache({ defaultContent, parentEvent }) + deleteDraftEventCache(draftEvent) + addReplies([newEvent]) close() } catch (error) { if (error instanceof AggregateError) { @@ -311,11 +315,14 @@ export default function PostContent({
@@ -52,7 +60,24 @@ export default function PostOptions({
- + +
+ +
+ + setMinPow(pow)} + max={28} + step={1} + disabled={posting} + />
) diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index d4b549bb..50a9ddde 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -5,8 +5,11 @@ import { cn } from '@/lib/utils' const Slider = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & { hideThumb?: boolean } ->(({ className, ...props }, ref) => { + React.ComponentPropsWithoutRef & { + hideThumb?: boolean + enableHoverAnimation?: boolean + } +>(({ className, hideThumb, enableHoverAnimation, ...props }, ref) => { const [isHovered, setIsHovered] = React.useState(false) return ( @@ -22,17 +25,18 @@ const Slider = React.forwardRef< - + - {/* */} + {!hideThumb && ( + + )} ) }) diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index efe177ba..c843a901 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -399,6 +399,7 @@ export default { Detailed: 'تفصيلي', Compact: 'مضغوط', 'Submit Relay': 'إرسال ريلاي', - Homepage: 'الصفحة الرئيسية' + Homepage: 'الصفحة الرئيسية', + 'Proof of Work (difficulty {{minPow}})': 'إثبات العمل (الصعوبة {{minPow}})' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 46f12514..cffe474c 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -409,6 +409,7 @@ export default { Detailed: 'Detailliert', Compact: 'Kompakt', 'Submit Relay': 'Relay einreichen', - Homepage: 'Homepage' + Homepage: 'Homepage', + 'Proof of Work (difficulty {{minPow}})': 'Arbeitsnachweis (Schwierigkeit {{minPow}})' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0cddf203..d1c6ce95 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -398,6 +398,7 @@ export default { Detailed: 'Detailed', Compact: 'Compact', 'Submit Relay': 'Submit Relay', - Homepage: 'Homepage' + Homepage: 'Homepage', + 'Proof of Work (difficulty {{minPow}})': 'Proof of Work (difficulty {{minPow}})' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 587bffa2..e8de485a 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -404,6 +404,7 @@ export default { Detailed: 'Detallado', Compact: 'Compacto', 'Submit Relay': 'Enviar relé', - Homepage: 'Página principal' + Homepage: 'Página principal', + 'Proof of Work (difficulty {{minPow}})': 'Prueba de Trabajo (dificultad {{minPow}})' } } diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index a0325d0f..a511c468 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -400,6 +400,7 @@ export default { Detailed: 'تفصیلی', Compact: 'فشرده', 'Submit Relay': 'ارسال رله', - Homepage: 'صفحه اصلی' + Homepage: 'صفحه اصلی', + 'Proof of Work (difficulty {{minPow}})': 'اثبات کار (دشواری {{minPow}})' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 37f32ad4..fc048f53 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -409,6 +409,7 @@ export default { Detailed: 'Détaillé', Compact: 'Compact', 'Submit Relay': 'Soumettre un relais', - Homepage: 'Page d’accueil' + Homepage: 'Page d’accueil', + 'Proof of Work (difficulty {{minPow}})': 'Preuve de travail (difficulté {{minPow}})' } } diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index 101bd267..7fe6a348 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -403,6 +403,7 @@ export default { Detailed: 'विस्तृत', Compact: 'संक्षिप्त', 'Submit Relay': 'रिले सबमिट करें', - Homepage: 'होमपेज' + Homepage: 'होमपेज', + 'Proof of Work (difficulty {{minPow}})': 'कार्य प्रमाण (कठिनाई {{minPow}})' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index eb8fe0ab..9a8232f4 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -404,6 +404,7 @@ export default { Detailed: 'Dettagliato', Compact: 'Compatto', 'Submit Relay': 'Invia Relay', - Homepage: 'Homepage' + Homepage: 'Homepage', + 'Proof of Work (difficulty {{minPow}})': 'Proof of Work (difficoltà {{minPow}})' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index b6c3ad75..183b712d 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -401,6 +401,7 @@ export default { Detailed: '詳細', Compact: 'コンパクト', 'Submit Relay': 'リレーを提出', - Homepage: 'ホームページ' + Homepage: 'ホームページ', + 'Proof of Work (difficulty {{minPow}})': 'プルーフオブワーク (難易度 {{minPow}})' } } diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 5158ec1d..58245c83 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -401,6 +401,7 @@ export default { Detailed: '상세', Compact: '간단', 'Submit Relay': '릴레이 제출', - Homepage: '홈페이지' + Homepage: '홈페이지', + 'Proof of Work (difficulty {{minPow}})': '작업 증명 (난이도 {{minPow}})' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 4c982cb6..3131ff5c 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -405,6 +405,7 @@ export default { Detailed: 'Szczegółowy', Compact: 'Zwięzły', 'Submit Relay': 'Prześlij przekaźnik', - Homepage: 'Strona główna' + Homepage: 'Strona główna', + 'Proof of Work (difficulty {{minPow}})': 'Dowód pracy (trudność {{minPow}})' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 1e1a171b..8b0bd72c 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -401,6 +401,7 @@ export default { Detailed: 'Detalhado', Compact: 'Compacto', 'Submit Relay': 'Enviar Relay', - Homepage: 'Página inicial' + Homepage: 'Página inicial', + 'Proof of Work (difficulty {{minPow}})': 'Prova de Trabalho (dificuldade {{minPow}})' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 6f2e9920..f3109719 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -404,6 +404,7 @@ export default { Detailed: 'Detalhado', Compact: 'Compacto', 'Submit Relay': 'Enviar Relay', - Homepage: 'Página inicial' + Homepage: 'Página inicial', + 'Proof of Work (difficulty {{minPow}})': 'Prova de Trabalho (dificuldade {{minPow}})' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 4dc449a8..587df222 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -406,6 +406,7 @@ export default { Detailed: 'Подробный', Compact: 'Компактный', 'Submit Relay': 'Отправить релей', - Homepage: 'Домашняя страница' + Homepage: 'Домашняя страница', + 'Proof of Work (difficulty {{minPow}})': 'Доказательство работы (сложность {{minPow}})' } } diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 75aaaa40..ae563b69 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -396,6 +396,7 @@ export default { Detailed: 'รายละเอียด', Compact: 'กะทัดรัด', 'Submit Relay': 'ส่งรีเลย์', - Homepage: 'หน้าแรก' + Homepage: 'หน้าแรก', + 'Proof of Work (difficulty {{minPow}})': 'หลักฐานการทำงาน (ความยาก {{minPow}})' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 3c559977..c5f422ab 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -394,6 +394,7 @@ export default { Detailed: '详细', Compact: '紧凑', 'Submit Relay': '提交服务器', - Homepage: '主页' + Homepage: '主页', + 'Proof of Work (difficulty {{minPow}})': '工作量证明 (难度 {{minPow}})' } } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index d4854862..e98984c4 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -10,6 +10,7 @@ import { TPollCreateData, TRelaySet } from '@/types' +import { sha256 } from '@noble/hashes/sha2' import dayjs from 'dayjs' import { Event, kinds, nip19 } from 'nostr-tools' import { @@ -22,6 +23,39 @@ import { import { randomString } from './random' import { generateBech32IdFromETag, tagNameEquals } from './tag' +const draftEventCache: Map = new Map() + +export function deleteDraftEventCache(draftEvent: TDraftEvent) { + const key = generateDraftEventCacheKey(draftEvent) + draftEventCache.delete(key) +} + +function setDraftEventCache(baseDraft: Omit): TDraftEvent { + const cacheKey = generateDraftEventCacheKey(baseDraft) + const cache = draftEventCache.get(cacheKey) + if (cache) { + return JSON.parse(cache) + } + const draftEvent = { ...baseDraft, created_at: dayjs().unix() } + draftEventCache.set(cacheKey, JSON.stringify(draftEvent)) + + return draftEvent +} + +function generateDraftEventCacheKey(draft: Omit) { + const str = JSON.stringify({ + content: draft.content, + kind: draft.kind, + tags: draft.tags + }) + + const encoder = new TextEncoder() + const data = encoder.encode(str) + const hashBuffer = sha256(data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') +} + // https://github.com/nostr-protocol/nips/blob/master/25.md export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent { const tags: string[][] = [] @@ -68,7 +102,6 @@ export function createRepostDraftEvent(event: Event): TDraftEvent { } } -const shortTextNoteDraftEventCache: Map = new Map() export async function createShortTextNoteDraftEvent( content: string, mentions: string[], @@ -125,15 +158,7 @@ export async function createShortTextNoteDraftEvent( content: transformedEmojisContent, tags } - const cacheKey = JSON.stringify(baseDraft) - const cache = shortTextNoteDraftEventCache.get(cacheKey) - if (cache) { - return cache - } - const draftEvent = { ...baseDraft, created_at: dayjs().unix() } - shortTextNoteDraftEventCache.set(cacheKey, draftEvent) - - return draftEvent + return setDraftEventCache(baseDraft) } // https://github.com/nostr-protocol/nips/blob/master/51.md @@ -150,7 +175,6 @@ export function createRelaySetDraftEvent(relaySet: Omit): TDr } } -const commentDraftEventCache: Map = new Map() export async function createCommentDraftEvent( content: string, parentEvent: Event, @@ -228,15 +252,7 @@ export async function createCommentDraftEvent( content: transformedEmojisContent, tags } - const cacheKey = JSON.stringify(baseDraft) - const cache = commentDraftEventCache.get(cacheKey) - if (cache) { - return cache - } - const draftEvent = { ...baseDraft, created_at: dayjs().unix() } - commentDraftEventCache.set(cacheKey, draftEvent) - - return draftEvent + return setDraftEventCache(baseDraft) } export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent { @@ -325,7 +341,6 @@ export function createBlossomServerListDraftEvent(servers: string[]): TDraftEven } } -const pollDraftEventCache: Map = new Map() export async function createPollDraftEvent( author: string, question: string, @@ -389,15 +404,7 @@ export async function createPollDraftEvent( kind: ExtendedKind.POLL, tags } - const cacheKey = JSON.stringify(baseDraft) - const cache = pollDraftEventCache.get(cacheKey) - if (cache) { - return cache - } - const draftEvent = { ...baseDraft, created_at: dayjs().unix() } - pollDraftEventCache.set(cacheKey, draftEvent) - - return draftEvent + return setDraftEventCache(baseDraft) } export function createPollResponseDraftEvent( diff --git a/src/lib/event.ts b/src/lib/event.ts index 73b68088..858867fb 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -2,11 +2,12 @@ import { EMBEDDED_MENTION_REGEX, ExtendedKind } from '@/constants' import client from '@/services/client.service' import { TImetaInfo } from '@/types' import { LRUCache } from 'lru-cache' -import { Event, kinds, nip19 } from 'nostr-tools' +import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools' +import { fastEventHash, getPow } from 'nostr-tools/nip13' import { - getImetaInfoFromImetaTag, generateBech32IdFromATag, generateBech32IdFromETag, + getImetaInfoFromImetaTag, tagNameEquals } from './tag' @@ -256,6 +257,47 @@ export function createFakeEvent(event: Partial): Event { } } +export async function minePow( + unsigned: UnsignedEvent, + difficulty: number +): Promise> { + let count = 0 + + const event = unsigned as Omit + const tag = ['nonce', count.toString(), difficulty.toString()] + + event.tags.push(tag) + + return new Promise((resolve) => { + const mine = () => { + let iterations = 0 + + while (iterations < 1000) { + const now = Math.floor(new Date().getTime() / 1000) + + if (now !== event.created_at) { + count = 0 + event.created_at = now + } + + tag[1] = (++count).toString() + event.id = fastEventHash(event) + + if (getPow(event.id) >= difficulty) { + resolve(event) + return + } + + iterations++ + } + + setTimeout(mine, 0) + } + + mine() + }) +} + // Legacy compare function for sorting compatibility // If return 0, it means the two events are equal. // If return a negative number, it means `b` should be retained, and `a` should be discarded. diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 79ae0225..a4071971 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -7,7 +7,12 @@ import { createRelayListDraftEvent, createSeenNotificationsAtDraftEvent } from '@/lib/draft-event' -import { getLatestEvent, getReplaceableEventIdentifier, isProtectedEvent } from '@/lib/event' +import { + getLatestEvent, + getReplaceableEventIdentifier, + isProtectedEvent, + minePow +} from '@/lib/event' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import client from '@/services/client.service' @@ -594,12 +599,22 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { return event as VerifiedEvent } - const publish = async (draftEvent: TDraftEvent, options: TPublishOptions = {}) => { + const publish = async ( + draftEvent: TDraftEvent, + { minPow = 0, ...options }: TPublishOptions = {} + ) => { if (!account || !signer || account.signerType === 'npub') { throw new Error('You need to login first') } - const event = await signEvent(draftEvent) + const draft = JSON.parse(JSON.stringify(draftEvent)) as TDraftEvent + let event: VerifiedEvent + if (minPow > 0) { + const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow) + event = await signEvent(unsignedEvent) + } else { + event = await signEvent(draft) + } if (event.kind !== kinds.Application && event.pubkey !== account.pubkey) { const eventAuthor = await client.fetchProfile(event.pubkey) diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 42814a0e..d68dce77 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -121,6 +121,7 @@ export type TImetaInfo = { export type TPublishOptions = { specifiedRelayUrls?: string[] additionalRelayUrls?: string[] + minPow?: number } export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you'