Compare commits

..

No commits in common. "e740d199f1231fda97cdc919b986896061c7c7a5" and "c9bd7ca7d729181ab4091cded86eca439766616d" have entirely different histories.

58 changed files with 155 additions and 3833 deletions

2760
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,9 +16,7 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"preview": "vite preview", "preview": "vite preview"
"test": "vitest run",
"test:schemas": "vitest run schemata-validation"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -95,14 +93,11 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@nostrability/schemata": "^0.3.2", "@types/node": "^22.10.2",
"@types/node": "^22.19.17",
"@types/react": "^18.3.17", "@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/uri-templates": "^0.1.34", "@types/uri-templates": "^0.1.34",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"ajv": "^8.18.0",
"ajv-errors": "^3.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
@ -115,7 +110,6 @@
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.18.1", "typescript-eslint": "^8.18.1",
"vite": "^6.0.3", "vite": "^6.0.3",
"vite-plugin-pwa": "^0.21.1", "vite-plugin-pwa": "^0.21.1"
"vitest": "^3.2.4"
} }
} }

View file

@ -1,481 +0,0 @@
/**
* Schemata validation tests for Jumble's draft event builders.
*
* Validates that every create*DraftEvent function in src/lib/draft-event.ts
* produces events conforming to @nostrability/schemata JSON schemas.
*
* Covers 21 kinds: 0, 1, 3, 5, 6, 7, 16, 17, 1111, 9802,
* 10000, 10001, 10002, 10003, 10012, 10030, 10063,
* 28934, 28936, 30002, 30078
*
* Skipped:
* - kind 1018/1068 (NIP-88) schemata e-tag schema enforces NIP-10 markers
* but NIP-88 spec uses bare e tags (https://github.com/nostrability/schemata/issues/108)
* - kind 1984 (NIP-56) schemata schema overly strict on p tag
* (https://github.com/nostrability/schemata/issues/107)
* - kind 31987 no schemata schema exists yet
*/
// ── Mocks (must be before any imports that use these services) ──────────
import { vi, describe, it, expect, beforeAll } from 'vitest'
vi.mock('@/services/client.service', () => ({
default: {
getEventHint: () => 'wss://relay.example.com',
getReplaeableEventFromCache: () => undefined,
fetchEvent: vi.fn().mockResolvedValue(undefined),
fetchRelayList: vi.fn().mockResolvedValue({ read: [], write: [] })
}
}))
vi.mock('@/services/custom-emoji.service', () => ({
default: {
getEmojiById: () => undefined
}
}))
vi.mock('@/services/media-upload.service', () => ({
default: {
getImetaTagByUrl: () => undefined
}
}))
// ── Imports ─────────────────────────────────────────────────────────────
import { createRequire } from 'node:module'
import Ajv from 'ajv'
import ajvErrors from 'ajv-errors'
import { kinds, type Event } from 'nostr-tools'
import {
createProfileDraftEvent,
createShortTextNoteDraftEvent,
createFollowListDraftEvent,
createDeletionRequestDraftEvent,
createRepostDraftEvent,
createReactionDraftEvent,
createCommentDraftEvent,
createHighlightDraftEvent,
createMuteListDraftEvent,
createRelayListDraftEvent,
createBookmarkDraftEvent,
createPinListDraftEvent,
createFavoriteRelaysDraftEvent,
createUserEmojiListDraftEvent,
createBlossomServerListDraftEvent,
createJoinDraftEvent,
createLeaveDraftEvent,
createSeenNotificationsAtDraftEvent,
createRelaySetDraftEvent,
createExternalContentReactionDraftEvent
} from '@/lib/draft-event'
import { ExtendedKind } from '@/constants'
// ── Schema loading ──────────────────────────────────────────────────────
const require_ = createRequire(import.meta.url)
const schemataBase = require_
.resolve('@nostrability/schemata')
.replace(/\/dist\/.*/, '/dist/nips')
function loadSchema(path: string): object {
return require_(`${schemataBase}/${path}`)
}
/**
* Recursively strip nested $schema, $id, and errorMessage fields
* that confuse AJV's strict mode.
*/
function stripSchemaFields(obj: unknown): unknown {
if (Array.isArray(obj)) {
return obj.map(stripSchemaFields)
}
if (obj !== null && typeof obj === 'object') {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (key === 'errorMessage') continue
if (key === '$schema' || key === '$id') continue
result[key] = stripSchemaFields(value)
}
return result
}
return obj
}
function createValidator(schema: object): ReturnType<Ajv['compile']> {
const cleaned = stripSchemaFields(schema) as Record<string, unknown>
const ajv = new Ajv({ allErrors: true, strict: false })
ajvErrors(ajv)
return ajv.compile(cleaned)
}
// ── Schema definitions (loaded at module level) ─────────────────────────
const kind0Schema = loadSchema('nip-01/kind-0/schema.json')
const kind1Schema = loadSchema('nip-01/kind-1/schema.json')
const kind3Schema = loadSchema('nip-02/kind-3/schema.json')
const kind5Schema = loadSchema('nip-09/kind-5/schema.json')
const kind6Schema = loadSchema('nip-18/kind-6/schema.json')
const kind7Schema = loadSchema('nip-25/kind-7/schema.json')
const kind16Schema = loadSchema('nip-18/kind-16/schema.json')
const kind17Schema = loadSchema('nip-25/kind-17/schema.json')
const kind1111Schema = loadSchema('nip-22/kind-1111/schema.json')
const kind9802Schema = loadSchema('nip-84/kind-9802/schema.json')
const kind10000Schema = loadSchema('nip-51/kind-10000/schema.json')
const kind10001Schema = loadSchema('nip-51/kind-10001/schema.json')
const kind10002Schema = loadSchema('nip-65/kind-10002/schema.json')
const kind10003Schema = loadSchema('nip-51/kind-10003/schema.json')
const kind10012Schema = loadSchema('nip-51/kind-10012/schema.json')
const kind10030Schema = loadSchema('nip-51/kind-10030/schema.json')
const kind10063Schema = loadSchema('nip-b7/kind-10063/schema.json')
const kind28934Schema = loadSchema('nip-43/kind-28934/schema.json')
const kind28936Schema = loadSchema('nip-43/kind-28936/schema.json')
const kind30002Schema = loadSchema('nip-51/kind-30002/schema.json')
const kind30078Schema = loadSchema('nip-78/kind-30078/schema.json')
function buildSchemaRegistry(): Map<number, object> {
const entries: [number, object][] = [
[0, kind0Schema],
[1, kind1Schema],
[3, kind3Schema],
[5, kind5Schema],
[6, kind6Schema],
[7, kind7Schema],
[16, kind16Schema],
[17, kind17Schema],
[1111, kind1111Schema],
[9802, kind9802Schema],
[10000, kind10000Schema],
[10001, kind10001Schema],
[10002, kind10002Schema],
[10003, kind10003Schema],
[10012, kind10012Schema],
[10030, kind10030Schema],
[10063, kind10063Schema],
[28934, kind28934Schema],
[28936, kind28936Schema],
[30002, kind30002Schema],
[30078, kind30078Schema]
]
return new Map(entries)
}
// ── Helpers ─────────────────────────────────────────────────────────────
const FAKE_PUBKEY = '0'.repeat(64)
const FAKE_PUBKEY_2 = '1'.repeat(64)
const FAKE_ID = 'a'.repeat(64)
const FAKE_SIG = 'b'.repeat(128)
type DraftEvent = { kind: number; content: string; tags: string[][]; created_at: number }
function validateDraftEvent(
draft: DraftEvent,
schemaRegistry: Map<number, object>
): { valid: boolean; errors: string[] } {
const schema = schemaRegistry.get(draft.kind)
if (!schema) {
return { valid: false, errors: [`No schema found for kind ${draft.kind}`] }
}
const signedEvent = {
...draft,
pubkey: FAKE_PUBKEY,
id: FAKE_ID,
sig: FAKE_SIG
}
const validate = createValidator(schema)
const valid = validate(signedEvent) as boolean
const errors = valid
? []
: (validate.errors ?? []).map((e) => `${e.instancePath || '/'}: ${e.message}`)
return { valid, errors }
}
/** Build a fake signed Event for functions that require one as input */
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 'c'.repeat(64),
pubkey: FAKE_PUBKEY_2,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'hello nostr',
sig: 'e'.repeat(128),
...overrides
}
}
// ── Tests ───────────────────────────────────────────────────────────────
describe('Schemata Schema Validation', () => {
let schemaRegistry: Map<number, object>
beforeAll(() => {
schemaRegistry = buildSchemaRegistry()
})
it('schema registry has 21 kinds', () => {
expect(schemaRegistry.size).toBe(21)
})
// Kind 0 Profile metadata
it('kind 0 (Profile) via createProfileDraftEvent', () => {
const draft = createProfileDraftEvent(
JSON.stringify({ name: 'Alice', about: 'Test profile', picture: 'https://example.com/avatar.png' })
)
expect(draft.kind).toBe(0)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 1 Short text note
it('kind 1 (Short Text Note) via createShortTextNoteDraftEvent', async () => {
const draft = await createShortTextNoteDraftEvent('Hello, Nostr!', [])
expect(draft.kind).toBe(1)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
it('kind 1 (Short Text Note reply) via createShortTextNoteDraftEvent', async () => {
const parent = makeEvent()
const draft = await createShortTextNoteDraftEvent('Nice post!', [], { parentEvent: parent })
expect(draft.kind).toBe(1)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 3 Follow list
it('kind 3 (Follow List) via createFollowListDraftEvent', () => {
const tags = [
['p', 'a'.repeat(64), 'wss://relay1.example.com', 'alice'],
['p', 'b'.repeat(64), 'wss://relay2.example.com', 'bob']
]
const draft = createFollowListDraftEvent(tags)
expect(draft.kind).toBe(kinds.Contacts)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 5 Deletion request
it('kind 5 (Deletion) via createDeletionRequestDraftEvent', () => {
const event = makeEvent({ kind: 1 })
const draft = createDeletionRequestDraftEvent(event)
expect(draft.kind).toBe(kinds.EventDeletion)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 6 Repost (text note)
it('kind 6 (Repost) via createRepostDraftEvent', () => {
const event = makeEvent({ kind: 1 })
const draft = createRepostDraftEvent(event)
expect(draft.kind).toBe(kinds.Repost)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 7 Reaction
it('kind 7 (Reaction: like) via createReactionDraftEvent', () => {
const event = makeEvent()
const draft = createReactionDraftEvent(event)
expect(draft.kind).toBe(kinds.Reaction)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
it('kind 7 (Reaction: custom emoji) via createReactionDraftEvent', () => {
const event = makeEvent()
const draft = createReactionDraftEvent(event, '🤙')
expect(draft.kind).toBe(kinds.Reaction)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 16 Generic repost (non-text)
it('kind 16 (Generic Repost) via createRepostDraftEvent', () => {
const event = makeEvent({ kind: 30023 })
const draft = createRepostDraftEvent(event)
expect(draft.kind).toBe(kinds.GenericRepost)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 17 External content reaction
it('kind 17 (External Content Reaction) via createExternalContentReactionDraftEvent', () => {
const draft = createExternalContentReactionDraftEvent('https://example.com/article')
expect(draft.kind).toBe(ExtendedKind.EXTERNAL_CONTENT_REACTION)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
it('kind 17 (External Content Reaction: custom) via createExternalContentReactionDraftEvent', () => {
const draft = createExternalContentReactionDraftEvent('https://example.com/article', '🔥')
expect(draft.kind).toBe(ExtendedKind.EXTERNAL_CONTENT_REACTION)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 1111 Comment
it('kind 1111 (Comment on event) via createCommentDraftEvent', async () => {
const parent = makeEvent({ kind: 1 })
const draft = await createCommentDraftEvent('Great post!', parent, [])
expect(draft.kind).toBe(ExtendedKind.COMMENT)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
it('kind 1111 (Comment on external content) via createCommentDraftEvent', async () => {
const draft = await createCommentDraftEvent('Interesting article', 'https://example.com/article', [])
expect(draft.kind).toBe(ExtendedKind.COMMENT)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 9802 Highlight
it('kind 9802 (Highlight from event) via createHighlightDraftEvent', () => {
const event = makeEvent({ kind: 30023, content: 'A long article with highlighted text in it.' })
const draft = createHighlightDraftEvent('highlighted text', '', event, [])
expect(draft.kind).toBe(kinds.Highlights)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10000 Mute list
it('kind 10000 (Mute List) via createMuteListDraftEvent', () => {
const tags = [
['p', 'a'.repeat(64)],
['e', 'b'.repeat(64)],
['word', 'spam'],
['t', 'nsfw']
]
const draft = createMuteListDraftEvent(tags)
expect(draft.kind).toBe(kinds.Mutelist)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10001 Pin list
it('kind 10001 (Pin List) via createPinListDraftEvent', () => {
const tags = [['e', 'a'.repeat(64)], ['e', 'b'.repeat(64)]]
const draft = createPinListDraftEvent(tags)
expect(draft.kind).toBe(kinds.Pinlist)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10002 Relay list
it('kind 10002 (Relay List) via createRelayListDraftEvent', () => {
const relays = [
{ url: 'wss://relay1.example.com', scope: 'both' as const },
{ url: 'wss://relay2.example.com', scope: 'read' as const }
]
const draft = createRelayListDraftEvent(relays)
expect(draft.kind).toBe(kinds.RelayList)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10003 Bookmark list
it('kind 10003 (Bookmarks) via createBookmarkDraftEvent', () => {
const tags = [
['e', 'a'.repeat(64)],
['a', `30023:${'b'.repeat(64)}:my-article`],
['r', 'https://example.com']
]
const draft = createBookmarkDraftEvent(tags)
expect(draft.kind).toBe(kinds.BookmarkList)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10012 Favorite relays
it('kind 10012 (Favorite Relays) via createFavoriteRelaysDraftEvent', () => {
const draft = createFavoriteRelaysDraftEvent(
['wss://relay1.example.com', 'wss://relay2.example.com'],
[]
)
expect(draft.kind).toBe(ExtendedKind.FAVORITE_RELAYS)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10030 User emoji list
it('kind 10030 (User Emoji List) via createUserEmojiListDraftEvent', () => {
const tags = [['a', `30030:${'a'.repeat(64)}:my-emojis`]]
const draft = createUserEmojiListDraftEvent(tags)
expect(draft.kind).toBe(kinds.UserEmojiList)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10063 Blossom server list
it('kind 10063 (Blossom Server List) via createBlossomServerListDraftEvent', () => {
const draft = createBlossomServerListDraftEvent([
'https://blossom1.example.com',
'https://blossom2.example.com'
])
expect(draft.kind).toBe(ExtendedKind.BLOSSOM_SERVER_LIST)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 28934 Join request (NIP-43)
it('kind 28934 (Join) via createJoinDraftEvent', () => {
const draft = createJoinDraftEvent('invite-code-123')
expect(draft.kind).toBe(28934)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 28936 Leave request (NIP-43)
it('kind 28936 (Leave) via createLeaveDraftEvent', () => {
const draft = createLeaveDraftEvent()
expect(draft.kind).toBe(28936)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 30002 Relay set
it('kind 30002 (Relay Set) via createRelaySetDraftEvent', () => {
const draft = createRelaySetDraftEvent({
id: 'my-set',
name: 'My Relay Set',
relayUrls: ['wss://relay1.example.com', 'wss://relay2.example.com']
})
expect(draft.kind).toBe(kinds.Relaysets)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 30078 Application-specific data (seen notifications)
it('kind 30078 (Application Data) via createSeenNotificationsAtDraftEvent', () => {
const draft = createSeenNotificationsAtDraftEvent()
expect(draft.kind).toBe(kinds.Application)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
})

View file

@ -1,8 +1,6 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { isInsecureUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import mediaManager from '@/services/media-manager.service' import mediaManager from '@/services/media-manager.service'
import { Minimize2, Pause, Play, X } from 'lucide-react' import { Minimize2, Pause, Play, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@ -24,12 +22,11 @@ export default function AudioPlayer({
className className
}: AudioPlayerProps) { }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const { allowInsecureConnection } = useUserPreferences()
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const seekTimeoutRef = useRef<ReturnType<typeof setTimeout>>() const seekTimeoutRef = useRef<NodeJS.Timeout>()
const isSeeking = useRef(false) const isSeeking = useRef(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -124,7 +121,7 @@ export default function AudioPlayer({
}, 300) }, 300)
} }
if (error || (!allowInsecureConnection && isInsecureUrl(src))) { if (error) {
return <ExternalLink url={src} /> return <ExternalLink url={src} />
} }

View file

@ -65,10 +65,6 @@ const clients: Record<string, { name: string; getUrl: (id: string) => string }>
name: 'Pareto', name: 'Pareto',
getUrl: (id: string) => `https://pareto.space/a/${id}` getUrl: (id: string) => `https://pareto.space/a/${id}`
}, },
shosho: {
name: 'Shosho',
getUrl: (id: string) => `https://shosho.live/live/${id}`
},
njump: { njump: {
name: 'Njump', name: 'Njump',
getUrl: (id: string) => `https://njump.me/${id}` getUrl: (id: string) => `https://njump.me/${id}`
@ -111,7 +107,7 @@ export default function ClientSelect({
case kinds.DraftLong: case kinds.DraftLong:
return ['yakihonne', 'coracle', 'habla', 'lumilumi', 'pareto', 'njump'] return ['yakihonne', 'coracle', 'habla', 'lumilumi', 'pareto', 'njump']
case kinds.LiveEvent: case kinds.LiveEvent:
return ['zapStream', 'shosho', 'nostrudel', 'njump'] return ['zapStream', 'nostrudel', 'njump']
case kinds.Date: case kinds.Date:
case kinds.Time: case kinds.Time:
return ['coracle', 'njump'] return ['coracle', 'njump']

View file

@ -1,50 +0,0 @@
import Image from '@/components/Image'
import { cn } from '@/lib/utils'
import { Heart } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function ReactionPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const reaction = useMemo(() => {
if (!event.content || event.content === '+') {
return <Heart size={14} className="inline text-red-400" />
}
const emojiName = /^:([^:]+):$/.exec(event.content)?.[1]
if (emojiName) {
const emojiTag = event.tags.find((tag) => tag[0] === 'emoji' && tag[1] === emojiName)
const emojiUrl = emojiTag?.[2]
if (emojiUrl) {
return (
<Image
image={{ url: emojiUrl, pubkey: event.pubkey }}
alt={emojiName}
className="inline-block h-4 w-4"
classNames={{ errorPlaceholder: 'bg-transparent', wrapper: 'inline-block rounded-md' }}
errorPlaceholder={<Heart size={14} className="inline text-red-400" />}
/>
)
}
}
if (event.content.length > 4) {
return <Heart size={14} className="inline text-red-400" />
}
return <span>{event.content}</span>
}, [event])
return (
<div className={cn('flex items-center gap-1 truncate', className)}>
<span className="truncate">[{t('Reaction')}]</span>
{reaction}
</div>
)
}

View file

@ -1,8 +1,5 @@
import { ExtendedKind } from '@/constants'
import { getVideoMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function VideoNotePreview({ export default function VideoNotePreview({
@ -13,18 +10,10 @@ export default function VideoNotePreview({
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const isAddressable =
event.kind === ExtendedKind.ADDRESSABLE_NORMAL_VIDEO ||
event.kind === ExtendedKind.ADDRESSABLE_SHORT_VIDEO
const metadata = useMemo(
() => (isAddressable ? getVideoMetadataFromEvent(event) : null),
[event, isAddressable]
)
return ( return (
<div className={cn('pointer-events-none', className)}> <div className={cn('pointer-events-none', className)}>
[{t('Media')}]{' '} [{t('Media')}] <span className="pr-0.5 italic">{event.content}</span>
<span className="pr-0.5 italic">{metadata?.title || event.content}</span>
</div> </div>
) )
} }

View file

@ -16,7 +16,6 @@ import LongFormArticlePreview from './LongFormArticlePreview'
import NormalContentPreview from './NormalContentPreview' import NormalContentPreview from './NormalContentPreview'
import PictureNotePreview from './PictureNotePreview' import PictureNotePreview from './PictureNotePreview'
import PollPreview from './PollPreview' import PollPreview from './PollPreview'
import ReactionPreview from './ReactionPreview'
import VideoNotePreview from './VideoNotePreview' import VideoNotePreview from './VideoNotePreview'
export default function ContentPreview({ export default function ContentPreview({
@ -83,12 +82,7 @@ export default function ContentPreview({
return <LongFormArticlePreview event={event} className={className} /> return <LongFormArticlePreview event={event} className={className} />
} }
if ( if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
event.kind === ExtendedKind.VIDEO ||
event.kind === ExtendedKind.SHORT_VIDEO ||
event.kind === ExtendedKind.ADDRESSABLE_NORMAL_VIDEO ||
event.kind === ExtendedKind.ADDRESSABLE_SHORT_VIDEO
) {
return <VideoNotePreview event={event} className={className} /> return <VideoNotePreview event={event} className={className} />
} }
@ -116,10 +110,6 @@ export default function ContentPreview({
return <FollowPackPreview event={event} className={className} /> return <FollowPackPreview event={event} className={className} />
} }
if (event.kind === kinds.Reaction || event.kind === ExtendedKind.EXTERNAL_CONTENT_REACTION) {
return <ReactionPreview event={event} className={className} />
}
return ( return (
<div className={className}> <div className={className}>
[ [

View file

@ -1,6 +1,4 @@
import { isInsecureUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { Heart } from 'lucide-react' import { Heart } from 'lucide-react'
import { HTMLAttributes, useState } from 'react' import { HTMLAttributes, useState } from 'react'
@ -15,7 +13,6 @@ export default function Emoji({
img?: string img?: string
} }
}) { }) {
const { allowInsecureConnection } = useUserPreferences()
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
if (typeof emoji === 'string') { if (typeof emoji === 'string') {
@ -26,7 +23,7 @@ export default function Emoji({
) )
} }
if (hasError || (!allowInsecureConnection && isInsecureUrl(emoji.url))) { if (hasError) {
return ( return (
<span className={cn('whitespace-nowrap', classNames?.text)}>{`:${emoji.shortcode}:`}</span> <span className={cn('whitespace-nowrap', classNames?.text)}>{`:${emoji.shortcode}:`}</span>
) )

View file

@ -1,7 +1,5 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { isInsecureUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import blossomService from '@/services/blossom.service' import blossomService from '@/services/blossom.service'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { decode } from 'blurhash' import { decode } from 'blurhash'
@ -28,24 +26,17 @@ export default function Image({
hideIfError?: boolean hideIfError?: boolean
errorPlaceholder?: React.ReactNode errorPlaceholder?: React.ReactNode
}) { }) {
const { allowInsecureConnection } = useUserPreferences()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [displaySkeleton, setDisplaySkeleton] = useState(true) const [displaySkeleton, setDisplaySkeleton] = useState(true)
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
const [imageUrl, setImageUrl] = useState<string>() const [imageUrl, setImageUrl] = useState<string>()
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => { useEffect(() => {
setIsLoading(true) setIsLoading(true)
setHasError(false) setHasError(false)
setDisplaySkeleton(true) setDisplaySkeleton(true)
if (!allowInsecureConnection && isInsecureUrl(url)) {
setHasError(true)
setIsLoading(false)
return
}
if (pubkey) { if (pubkey) {
blossomService.getValidUrl(url, pubkey).then((validUrl) => { blossomService.getValidUrl(url, pubkey).then((validUrl) => {
setImageUrl(validUrl) setImageUrl(validUrl)
@ -60,7 +51,7 @@ export default function Image({
} else { } else {
setImageUrl(url) setImageUrl(url)
} }
}, [url, allowInsecureConnection]) }, [url])
if (hideIfError && hasError) return null if (hideIfError && hasError) return null

View file

@ -122,7 +122,7 @@ export default function MarkdownContent({
) )
return ( return (
<div className="space-y-3 whitespace-normal"> <div className="space-y-3">
<Markdown <Markdown
remarkPlugins={[remarkGfm, remarkNostr, remarkInlineContent]} remarkPlugins={[remarkGfm, remarkNostr, remarkInlineContent]}
urlTransform={(url) => { urlTransform={(url) => {

View file

@ -1,33 +0,0 @@
import Emoji from '@/components/Emoji'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
export default function Reaction({
event,
className
}: {
event: Event
className?: string
}) {
const emoji = useMemo(() => {
const content = event.content
if (!content || content === '+') return '+'
const emojiName = /^:([^:]+):$/.exec(content)?.[1]
if (emojiName) {
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags)
const emojiInfo = emojiInfos.find((e) => e.shortcode === emojiName)
if (emojiInfo) return emojiInfo
}
if (content.length <= 4) return content
return '+'
}, [event])
return (
<div className={className}>
<Emoji emoji={emoji} classNames={{ text: 'text-7xl leading-none', img: 'size-20' }} />
</div>
)
}

View file

@ -1,33 +1,15 @@
import { ExtendedKind } from '@/constants'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import { getVideoMetadataFromEvent } from '@/lib/event-metadata'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import Content from '../Content' import Content from '../Content'
import { EmbeddedHashtag } from '../Embedded'
import MediaPlayer from '../MediaPlayer' import MediaPlayer from '../MediaPlayer'
export default function VideoNote({ event, className }: { event: Event; className?: string }) { export default function VideoNote({ event, className }: { event: Event; className?: string }) {
const videoInfos = useMemo(() => getImetaInfosFromEvent(event), [event]) const videoInfos = useMemo(() => getImetaInfosFromEvent(event), [event])
const isAddressable =
event.kind === ExtendedKind.ADDRESSABLE_NORMAL_VIDEO ||
event.kind === ExtendedKind.ADDRESSABLE_SHORT_VIDEO
const metadata = useMemo(
() => (isAddressable ? getVideoMetadataFromEvent(event) : null),
[event, isAddressable]
)
return ( return (
<div className={className}> <div className={className}>
{metadata?.title && <div className="font-semibold">{metadata.title}</div>}
<Content event={event} /> <Content event={event} />
{metadata && metadata.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{metadata.tags.map((tag) => (
<EmbeddedHashtag key={tag} hashtag={tag} />
))}
</div>
)}
{videoInfos.map((video) => ( {videoInfos.map((video) => (
<MediaPlayer src={video.url} key={video.url} className="mt-2" /> <MediaPlayer src={video.url} key={video.url} className="mt-2" />
))} ))}

View file

@ -2,13 +2,11 @@ import { useSecondaryPage } from '@/PageManager'
import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants' import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants'
import { getParentStuff, isNsfwEvent } from '@/lib/event' import { getParentStuff, isNsfwEvent } from '@/lib/event'
import { toExternalContent, toNote } from '@/lib/link' import { toExternalContent, toNote } from '@/lib/link'
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AudioPlayer from '../AudioPlayer' import AudioPlayer from '../AudioPlayer'
import ClientTag from '../ClientTag' import ClientTag from '../ClientTag'
import Content from '../Content' import Content from '../Content'
@ -34,7 +32,6 @@ import MutedNote from './MutedNote'
import NsfwNote from './NsfwNote' import NsfwNote from './NsfwNote'
import PictureNote from './PictureNote' import PictureNote from './PictureNote'
import Poll from './Poll' import Poll from './Poll'
import Reaction from './Reaction'
import RelayReview from './RelayReview' import RelayReview from './RelayReview'
import UnknownNote from './UnknownNote' import UnknownNote from './UnknownNote'
import VideoNote from './VideoNote' import VideoNote from './VideoNote'
@ -54,21 +51,11 @@ export default function Note({
hideParentNotePreview?: boolean hideParentNotePreview?: boolean
showFull?: boolean showFull?: boolean
}) { }) {
const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { parentEventId, parentExternalContent } = useMemo(() => { const { parentEventId, parentExternalContent } = useMemo(() => {
return getParentStuff(event) return getParentStuff(event)
}, [event]) }, [event])
const reactionTargetEventId = useMemo(() => {
if (event.kind !== kinds.Reaction && event.kind !== ExtendedKind.EXTERNAL_CONTENT_REACTION) {
return undefined
}
const aTag = event.tags.findLast(tagNameEquals('a'))
if (aTag) return generateBech32IdFromATag(aTag)
const eTag = event.tags.findLast(tagNameEquals('e'))
return eTag ? generateBech32IdFromETag(eTag) : undefined
}, [event])
const { nsfwDisplayPolicy } = useContentPolicy() const { nsfwDisplayPolicy } = useContentPolicy()
const [showNsfw, setShowNsfw] = useState(false) const [showNsfw, setShowNsfw] = useState(false)
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
@ -130,8 +117,6 @@ export default function Note({
content = <EmojiPack className="mt-2" event={event} /> content = <EmojiPack className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.FOLLOW_PACK) { } else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content = <FollowPack className="mt-2" event={event} /> content = <FollowPack className="mt-2" event={event} />
} else if (event.kind === kinds.Reaction) {
content = <Reaction className="mt-2" event={event} />
} else { } else {
content = <Content className="mt-2" event={event} enableHighlight /> content = <Content className="mt-2" event={event} enableHighlight />
} }
@ -185,17 +170,6 @@ export default function Note({
}} }}
/> />
)} )}
{reactionTargetEventId && (
<ParentNotePreview
eventId={reactionTargetEventId}
label={t('reacted to')}
className="mt-2"
onClick={(e) => {
e.stopPropagation()
push(toNote(reactionTargetEventId))
}}
/>
)}
{content} {content}
</div> </div>
) )

View file

@ -9,18 +9,15 @@ export default function ParentNotePreview({
eventId, eventId,
externalContent, externalContent,
className, className,
onClick, onClick
label
}: { }: {
eventId?: string eventId?: string
externalContent?: string externalContent?: string
className?: string className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
label?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(eventId) const { event, isFetching } = useFetchEvent(eventId)
const displayLabel = label ?? t('reply to')
if (externalContent) { if (externalContent) {
return ( return (
@ -31,7 +28,7 @@ export default function ParentNotePreview({
)} )}
onClick={onClick} onClick={onClick}
> >
<div className="shrink-0">{displayLabel}</div> <div className="shrink-0">{t('reply to')}</div>
<div className="truncate">{externalContent}</div> <div className="truncate">{externalContent}</div>
</div> </div>
) )
@ -49,7 +46,7 @@ export default function ParentNotePreview({
className className
)} )}
> >
<div className="shrink-0">{displayLabel}</div> <div className="shrink-0">{t('reply to')}</div>
<Skeleton className="h-4 w-4 rounded-full" /> <Skeleton className="h-4 w-4 rounded-full" />
<div className="flex-1 py-1"> <div className="flex-1 py-1">
<Skeleton className="h-3" /> <Skeleton className="h-3" />
@ -67,7 +64,7 @@ export default function ParentNotePreview({
)} )}
onClick={event ? onClick : undefined} onClick={event ? onClick : undefined}
> >
<div className="shrink-0">{displayLabel}</div> <div className="shrink-0">{t('reply to')}</div>
{event && <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />} {event && <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />}
<ContentPreview className="truncate" event={event} /> <ContentPreview className="truncate" event={event} />
</div> </div>

View file

@ -2,7 +2,7 @@ import { useSecondaryPage } from '@/PageManager'
import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants' import { SPECIAL_TRUST_SCORE_FILTER_ID } from '@/constants'
import { useStuff } from '@/hooks/useStuff' import { useStuff } from '@/hooks/useStuff'
import { useStuffStatsById } from '@/hooks/useStuffStatsById' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { toNote } from '@/lib/link' import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
@ -27,7 +27,6 @@ export default function ReactionList({ stuff }: { stuff: Event | string }) {
const [filteredLikes, setFilteredLikes] = useState< const [filteredLikes, setFilteredLikes] = useState<
Array<{ Array<{
id: string id: string
eventId: string
pubkey: string pubkey: string
emoji: string | TEmoji emoji: string | TEmoji
created_at: number created_at: number
@ -39,7 +38,6 @@ export default function ReactionList({ stuff }: { stuff: Event | string }) {
const likes = noteStats?.likes ?? [] const likes = noteStats?.likes ?? []
const filtered: { const filtered: {
id: string id: string
eventId: string
pubkey: string pubkey: string
created_at: number created_at: number
emoji: string | TEmoji emoji: string | TEmoji
@ -83,7 +81,7 @@ export default function ReactionList({ stuff }: { stuff: Event | string }) {
<div <div
key={like.id} key={like.id}
className="clickable flex items-center gap-3 border-b px-4 py-3 transition-colors" className="clickable flex items-center gap-3 border-b px-4 py-3 transition-colors"
onClick={() => push(toNote(like.eventId))} onClick={() => push(toProfile(like.pubkey))}
> >
<div className="flex w-6 flex-col items-center"> <div className="flex w-6 flex-col items-center">
<Emoji <Emoji

View file

@ -306,8 +306,7 @@ const UserAggregationList = forwardRef<
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
isMentioningMutedUsers, isMentioningMutedUsers,
meetsMinTrustScore, meetsMinTrustScore,
trustScoreThreshold, trustScoreThreshold
since
] ]
) )

View file

@ -1,11 +1,11 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { cn, isTouchDevice } from '@/lib/utils' import { cn, isTouchDevice } from '@/lib/utils'
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
import ProfileCard from '../ProfileCard' import ProfileCard from '../ProfileCard'

View file

@ -1,4 +1,3 @@
import { isInsecureUrl } from '@/lib/url'
import { cn, isInViewport } from '@/lib/utils' import { cn, isInViewport } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider'
@ -8,7 +7,7 @@ import ExternalLink from '../ExternalLink'
export default function VideoPlayer({ src, className }: { src: string; className?: string }) { export default function VideoPlayer({ src, className }: { src: string; className?: string }) {
const { autoplay, videoLoop } = useContentPolicy() const { autoplay, videoLoop } = useContentPolicy()
const { muteMedia, updateMuteMedia, allowInsecureConnection } = useUserPreferences() const { muteMedia, updateMuteMedia } = useUserPreferences()
const [error, setError] = useState(false) const [error, setError] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -70,7 +69,7 @@ export default function VideoPlayer({ src, className }: { src: string; className
} }
}, [muteMedia]) }, [muteMedia])
if (error || (!allowInsecureConnection && isInsecureUrl(src))) { if (error) {
return <ExternalLink url={src} /> return <ExternalLink url={src} />
} }

View file

@ -1,12 +1,10 @@
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata' import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
import { isInsecureUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { useMemo } from 'react' import { useMemo } from 'react'
import ExternalLink from '../ExternalLink'
import Image from '../Image' import Image from '../Image'
import ExternalLink from '../ExternalLink'
export default function WebPreview({ export default function WebPreview({
url, url,
@ -18,7 +16,6 @@ export default function WebPreview({
mustLoad?: boolean mustLoad?: boolean
}) { }) {
const { autoLoadMedia } = useContentPolicy() const { autoLoadMedia } = useContentPolicy()
const { allowInsecureConnection } = useUserPreferences()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { title, description, image } = useFetchWebMetadata(url) const { title, description, image } = useFetchWebMetadata(url)
@ -30,10 +27,6 @@ export default function WebPreview({
} }
}, [url]) }, [url])
if (!allowInsecureConnection && isInsecureUrl(url)) {
return null
}
if (!autoLoadMedia && !mustLoad) { if (!autoLoadMedia && !mustLoad) {
return null return null
} }

View file

@ -40,7 +40,6 @@ export const StorageKey = {
ENABLE_SINGLE_COLUMN_LAYOUT: 'enableSingleColumnLayout', ENABLE_SINGLE_COLUMN_LAYOUT: 'enableSingleColumnLayout',
FAVICON_URL_TEMPLATE: 'faviconUrlTemplate', FAVICON_URL_TEMPLATE: 'faviconUrlTemplate',
FILTER_OUT_ONION_RELAYS: 'filterOutOnionRelays', FILTER_OUT_ONION_RELAYS: 'filterOutOnionRelays',
ALLOW_INSECURE_CONNECTION: 'allowInsecureConnection',
QUICK_REACTION: 'quickReaction', QUICK_REACTION: 'quickReaction',
QUICK_REACTION_EMOJI: 'quickReactionEmoji', QUICK_REACTION_EMOJI: 'quickReactionEmoji',
NSFW_DISPLAY_POLICY: 'nsfwDisplayPolicy', NSFW_DISPLAY_POLICY: 'nsfwDisplayPolicy',
@ -125,9 +124,7 @@ export const SUPPORTED_KINDS = [
...ALLOWED_FILTER_KINDS, ...ALLOWED_FILTER_KINDS,
ExtendedKind.RELAY_REVIEW, ExtendedKind.RELAY_REVIEW,
kinds.Emojisets, kinds.Emojisets,
ExtendedKind.FOLLOW_PACK, ExtendedKind.FOLLOW_PACK
kinds.Reaction,
ExtendedKind.EXTERNAL_CONTENT_REACTION
] ]
export const URL_REGEX = export const URL_REGEX =

View file

@ -1,11 +1,8 @@
import { isInsecureUrl } from '@/lib/url'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import webService from '@/services/web.service'
import { TWebMetadata } from '@/types' import { TWebMetadata } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import webService from '@/services/web.service'
export function useFetchWebMetadata(url: string) { export function useFetchWebMetadata(url: string) {
const { allowInsecureConnection } = useUserPreferences()
const [metadata, setMetadata] = useState<TWebMetadata>({}) const [metadata, setMetadata] = useState<TWebMetadata>({})
const proxyServer = import.meta.env.VITE_PROXY_SERVER const proxyServer = import.meta.env.VITE_PROXY_SERVER
if (proxyServer) { if (proxyServer) {
@ -13,10 +10,8 @@ export function useFetchWebMetadata(url: string) {
} }
useEffect(() => { useEffect(() => {
if (!allowInsecureConnection && isInsecureUrl(url)) return
webService.fetchWebMetadata(url).then((metadata) => setMetadata(metadata)) webService.fetchWebMetadata(url).then((metadata) => setMetadata(metadata))
}, [url, allowInsecureConnection]) }, [url])
return metadata return metadata
} }

View file

@ -674,11 +674,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'الريلايات المستخدمة للبحث عن الملاحظات (NIP-50)', 'Relays used for searching notes (NIP-50)': 'الريلايات المستخدمة للبحث عن الملاحظات (NIP-50)',
'Protected event (NIP-70)': 'حدث محمي (NIP-70)', 'Protected event (NIP-70)': 'حدث محمي (NIP-70)',
'Protected': 'محمي', 'Protected': 'محمي',
'Protected event hint': 'الأحداث المحمية (NIP-70) لا يمكن نشرها إلا من قبل المؤلف. سترفض الخوادم هذه الأحداث من أطراف ثالثة، مما يمنع الآخرين من إعادة بث محتواك. ملاحظة: لا تدعم جميع الخوادم الأحداث المحمية.', 'Protected event hint': 'الأحداث المحمية (NIP-70) لا يمكن نشرها إلا من قبل المؤلف. سترفض الخوادم هذه الأحداث من أطراف ثالثة، مما يمنع الآخرين من إعادة بث محتواك. ملاحظة: لا تدعم جميع الخوادم الأحداث المحمية.'
'Allow insecure connections': 'السماح بالاتصالات غير الآمنة',
'Allow insecure connections description':
'السماح بتحميل موارد http:// والاتصال بمرحلات ws://. قد يؤدي إلى تحذيرات المحتوى المختلط في المتصفح.',
'reacted to': 'تفاعل مع',
Reaction: 'تفاعل'
} }
} }

View file

@ -698,11 +698,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'Relays für die Notizsuche (NIP-50)', 'Relays used for searching notes (NIP-50)': 'Relays für die Notizsuche (NIP-50)',
'Protected event (NIP-70)': 'Geschütztes Ereignis (NIP-70)', 'Protected event (NIP-70)': 'Geschütztes Ereignis (NIP-70)',
'Protected': 'Geschützt', 'Protected': 'Geschützt',
'Protected event hint': 'Geschützte Ereignisse (NIP-70) können nur vom Autor veröffentlicht werden. Relays lehnen diese Ereignisse von Dritten ab und verhindern so, dass andere Ihre Inhalte weiterverbreiten. Hinweis: Nicht alle Relays unterstützen geschützte Ereignisse.', 'Protected event hint': 'Geschützte Ereignisse (NIP-70) können nur vom Autor veröffentlicht werden. Relays lehnen diese Ereignisse von Dritten ab und verhindern so, dass andere Ihre Inhalte weiterverbreiten. Hinweis: Nicht alle Relays unterstützen geschützte Ereignisse.'
'Allow insecure connections': 'Unsichere Verbindungen zulassen',
'Allow insecure connections description':
'Laden von http://-Ressourcen und Verbindung zu ws://-Relays erlauben. Kann Browser-Warnungen zu gemischten Inhalten auslösen.',
'reacted to': 'reagierte auf',
Reaction: 'Reaktion'
} }
} }

View file

@ -681,11 +681,6 @@ export default {
'Protected event (NIP-70)': 'Protected event (NIP-70)', 'Protected event (NIP-70)': 'Protected event (NIP-70)',
'Protected': 'Protected', 'Protected': 'Protected',
'Protected event hint': 'Protected event hint':
'Protected events (NIP-70) can only be published by the author. Relays will reject these events from third parties, preventing others from rebroadcasting your content. Note: not all relays support protected events.', 'Protected events (NIP-70) can only be published by the author. Relays will reject these events from third parties, preventing others from rebroadcasting your content. Note: not all relays support protected events.'
'Allow insecure connections': 'Allow insecure connections',
'Allow insecure connections description':
'Allow loading http:// resources and connecting to ws:// relays. May trigger browser mixed content warnings.',
'reacted to': 'reacted to',
Reaction: 'Reaction'
} }
} }

View file

@ -691,11 +691,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'Relés utilizados para buscar notas (NIP-50)', 'Relays used for searching notes (NIP-50)': 'Relés utilizados para buscar notas (NIP-50)',
'Protected event (NIP-70)': 'Evento protegido (NIP-70)', 'Protected event (NIP-70)': 'Evento protegido (NIP-70)',
'Protected': 'Protegido', 'Protected': 'Protegido',
'Protected event hint': 'Los eventos protegidos (NIP-70) solo pueden ser publicados por el autor. Los relés rechazarán estos eventos de terceros, evitando que otros redistribuyan tu contenido. Nota: no todos los relés admiten eventos protegidos.', 'Protected event hint': 'Los eventos protegidos (NIP-70) solo pueden ser publicados por el autor. Los relés rechazarán estos eventos de terceros, evitando que otros redistribuyan tu contenido. Nota: no todos los relés admiten eventos protegidos.'
'Allow insecure connections': 'Permitir conexiones inseguras',
'Allow insecure connections description':
'Permitir cargar recursos http:// y conectar a relays ws://. Puede activar advertencias de contenido mixto del navegador.',
'reacted to': 'reaccionó a',
Reaction: 'Reacción'
} }
} }

View file

@ -686,11 +686,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'رله‌هایی که برای جستجوی یادداشت‌ها استفاده می‌شوند (NIP-50)', 'Relays used for searching notes (NIP-50)': 'رله‌هایی که برای جستجوی یادداشت‌ها استفاده می‌شوند (NIP-50)',
'Protected event (NIP-70)': 'رویداد محافظت‌شده (NIP-70)', 'Protected event (NIP-70)': 'رویداد محافظت‌شده (NIP-70)',
'Protected': 'محافظت‌شده', 'Protected': 'محافظت‌شده',
'Protected event hint': 'رویدادهای محافظت‌شده (NIP-70) فقط توسط نویسنده قابل انتشار هستند. رله‌ها این رویدادها را از اشخاص ثالث رد می‌کنند و از بازنشر محتوای شما توسط دیگران جلوگیری می‌کنند. توجه: همه رله‌ها از رویدادهای محافظت‌شده پشتیبانی نمی‌کنند.', 'Protected event hint': 'رویدادهای محافظت‌شده (NIP-70) فقط توسط نویسنده قابل انتشار هستند. رله‌ها این رویدادها را از اشخاص ثالث رد می‌کنند و از بازنشر محتوای شما توسط دیگران جلوگیری می‌کنند. توجه: همه رله‌ها از رویدادهای محافظت‌شده پشتیبانی نمی‌کنند.'
'Allow insecure connections': 'اجازه اتصالات ناامن',
'Allow insecure connections description':
'اجازه بارگذاری منابع http:// و اتصال به رله‌های ws://. ممکن است هشدارهای محتوای مختلط مرورگر را فعال کند.',
'reacted to': 'واکنش نشان داد به',
Reaction: 'واکنش'
} }
} }

View file

@ -695,11 +695,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'Relais utilisés pour rechercher des notes (NIP-50)', 'Relays used for searching notes (NIP-50)': 'Relais utilisés pour rechercher des notes (NIP-50)',
'Protected event (NIP-70)': 'Événement protégé (NIP-70)', 'Protected event (NIP-70)': 'Événement protégé (NIP-70)',
'Protected': 'Protégé', 'Protected': 'Protégé',
'Protected event hint': 'Les événements protégés (NIP-70) ne peuvent être publiés que par l\'auteur. Les relais rejetteront ces événements provenant de tiers, empêchant les autres de rediffuser votre contenu. Remarque : tous les relais ne prennent pas en charge les événements protégés.', 'Protected event hint': 'Les événements protégés (NIP-70) ne peuvent être publiés que par l\'auteur. Les relais rejetteront ces événements provenant de tiers, empêchant les autres de rediffuser votre contenu. Remarque : tous les relais ne prennent pas en charge les événements protégés.'
'Allow insecure connections': 'Autoriser les connexions non sécurisées',
'Allow insecure connections description':
'Autoriser le chargement des ressources http:// et la connexion aux relais ws://. Peut déclencher des avertissements de contenu mixte du navigateur.',
'reacted to': 'a réagi à',
Reaction: 'Réaction'
} }
} }

View file

@ -686,11 +686,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'नोट्स खोजने के लिए उपयोग किए जाने वाले रिले (NIP-50)', 'Relays used for searching notes (NIP-50)': 'नोट्स खोजने के लिए उपयोग किए जाने वाले रिले (NIP-50)',
'Protected event (NIP-70)': 'संरक्षित इवेंट (NIP-70)', 'Protected event (NIP-70)': 'संरक्षित इवेंट (NIP-70)',
'Protected': 'संरक्षित', 'Protected': 'संरक्षित',
'Protected event hint': 'संरक्षित इवेंट (NIP-70) केवल लेखक द्वारा प्रकाशित किए जा सकते हैं। रिले तीसरे पक्ष से इन इवेंट को अस्वीकार कर देंगे, जिससे दूसरों को आपकी सामग्री को पुनः प्रसारित करने से रोका जा सके। नोट: सभी रिले संरक्षित इवेंट का समर्थन नहीं करते।', 'Protected event hint': 'संरक्षित इवेंट (NIP-70) केवल लेखक द्वारा प्रकाशित किए जा सकते हैं। रिले तीसरे पक्ष से इन इवेंट को अस्वीकार कर देंगे, जिससे दूसरों को आपकी सामग्री को पुनः प्रसारित करने से रोका जा सके। नोट: सभी रिले संरक्षित इवेंट का समर्थन नहीं करते।'
'Allow insecure connections': 'असुरक्षित कनेक्शन की अनुमति दें',
'Allow insecure connections description':
'http:// संसाधन लोड करने और ws:// रिले से कनेक्ट करने की अनुमति दें। ब्राउज़र मिश्रित सामग्री चेतावनियाँ ट्रिगर हो सकती हैं।',
'reacted to': 'पर प्रतिक्रिया दी',
Reaction: 'प्रतिक्रिया'
} }
} }

View file

@ -680,11 +680,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'Jegyzetek kereséséhez használt csomópontok (NIP-50)', 'Relays used for searching notes (NIP-50)': 'Jegyzetek kereséséhez használt csomópontok (NIP-50)',
'Protected event (NIP-70)': 'Védett esemény (NIP-70)', 'Protected event (NIP-70)': 'Védett esemény (NIP-70)',
'Protected': 'Védett', 'Protected': 'Védett',
'Protected event hint': 'A védett eseményeket (NIP-70) csak a szerző teheti közzé. A csomópontok elutasítják ezeket az eseményeket harmadik felektől, megakadályozva, hogy mások újraközvetítsék a tartalmadat. Megjegyzés: nem minden csomópont támogatja a védett eseményeket.', 'Protected event hint': 'A védett eseményeket (NIP-70) csak a szerző teheti közzé. A csomópontok elutasítják ezeket az eseményeket harmadik felektől, megakadályozva, hogy mások újraközvetítsék a tartalmadat. Megjegyzés: nem minden csomópont támogatja a védett eseményeket.'
'Allow insecure connections': 'Nem biztonságos kapcsolatok engedélyezése',
'Allow insecure connections description':
'http:// erőforrások betöltésének és ws:// relékhez való csatlakozás engedélyezése. Böngésző vegyes tartalom figyelmeztetéseket válthat ki.',
'reacted to': 'reagált erre',
Reaction: 'Reakció'
} }
} }

View file

@ -691,11 +691,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'Relay utilizzati per cercare le note (NIP-50)', 'Relays used for searching notes (NIP-50)': 'Relay utilizzati per cercare le note (NIP-50)',
'Protected event (NIP-70)': 'Evento protetto (NIP-70)', 'Protected event (NIP-70)': 'Evento protetto (NIP-70)',
'Protected': 'Protetto', 'Protected': 'Protetto',
'Protected event hint': 'Gli eventi protetti (NIP-70) possono essere pubblicati solo dall\'autore. I relay rifiuteranno questi eventi da terze parti, impedendo ad altri di ridiffondere i tuoi contenuti. Nota: non tutti i relay supportano gli eventi protetti.', 'Protected event hint': 'Gli eventi protetti (NIP-70) possono essere pubblicati solo dall\'autore. I relay rifiuteranno questi eventi da terze parti, impedendo ad altri di ridiffondere i tuoi contenuti. Nota: non tutti i relay supportano gli eventi protetti.'
'Allow insecure connections': 'Consenti connessioni non sicure',
'Allow insecure connections description':
'Consenti il caricamento di risorse http:// e la connessione a relay ws://. Potrebbe attivare avvisi di contenuto misto del browser.',
'reacted to': 'ha reagito a',
Reaction: 'Reazione'
} }
} }

View file

@ -686,11 +686,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'ノート検索に使用するリレー (NIP-50)', 'Relays used for searching notes (NIP-50)': 'ノート検索に使用するリレー (NIP-50)',
'Protected event (NIP-70)': '保護されたイベント (NIP-70)', 'Protected event (NIP-70)': '保護されたイベント (NIP-70)',
'Protected': '保護', 'Protected': '保護',
'Protected event hint': '保護されたイベントNIP-70は作成者のみが公開できます。リレーは第三者からのこれらのイベントを拒否し、他者によるコンテンツの再配信を防ぎます。 注意:すべてのリレーが保護されたイベントに対応しているわけではありません。', 'Protected event hint': '保護されたイベントNIP-70は作成者のみが公開できます。リレーは第三者からのこれらのイベントを拒否し、他者によるコンテンツの再配信を防ぎます。 注意:すべてのリレーが保護されたイベントに対応しているわけではありません。'
'Allow insecure connections': '安全でない接続を許可',
'Allow insecure connections description':
'http:// リソースの読み込みと ws:// リレーへの接続を許可します。ブラウザの混合コンテンツ警告が表示される場合があります。',
'reacted to': 'にリアクションしました',
Reaction: 'リアクション'
} }
} }

View file

@ -680,11 +680,6 @@ export default {
'Relays used for searching notes (NIP-50)': '노트 검색에 사용되는 릴레이 (NIP-50)', 'Relays used for searching notes (NIP-50)': '노트 검색에 사용되는 릴레이 (NIP-50)',
'Protected event (NIP-70)': '보호된 이벤트 (NIP-70)', 'Protected event (NIP-70)': '보호된 이벤트 (NIP-70)',
'Protected': '보호됨', 'Protected': '보호됨',
'Protected event hint': '보호된 이벤트(NIP-70)는 작성자만 게시할 수 있습니다. 릴레이는 제3자의 이벤트를 거부하여 다른 사람이 콘텐츠를 재배포하는 것을 방지합니다. 참고: 모든 릴레이가 보호된 이벤트를 지원하는 것은 아닙니다.', 'Protected event hint': '보호된 이벤트(NIP-70)는 작성자만 게시할 수 있습니다. 릴레이는 제3자의 이벤트를 거부하여 다른 사람이 콘텐츠를 재배포하는 것을 방지합니다. 참고: 모든 릴레이가 보호된 이벤트를 지원하는 것은 아닙니다.'
'Allow insecure connections': '안전하지 않은 연결 허용',
'Allow insecure connections description':
'http:// 리소스 로드 및 ws:// 릴레이 연결을 허용합니다. 브라우저 혼합 콘텐츠 경고가 발생할 수 있습니다.',
'reacted to': '에 반응했습니다',
Reaction: '반응'
} }
} }

View file

@ -163,7 +163,7 @@ export default {
'Send only to r': 'Wyślij tylko do {{r}}', 'Send only to r': 'Wyślij tylko do {{r}}',
'Send only to these relays': 'Wyślij tylko do tych transmiterów', 'Send only to these relays': 'Wyślij tylko do tych transmiterów',
Explore: 'Transmitery', Explore: 'Transmitery',
'Search relays': 'Transmitery wyszukiwania', 'Search relays': 'Przekaźniki wyszukiwania',
relayInfoBadgeAuth: '✔️', relayInfoBadgeAuth: '✔️',
relayInfoBadgeSearch: 'Wyszukiwarka', relayInfoBadgeSearch: 'Wyszukiwarka',
relayInfoBadgePayment: 'Płatności', relayInfoBadgePayment: 'Płatności',
@ -671,32 +671,27 @@ export default {
'Auto-load profile pictures': 'Automatyczne ładowanie zdjęć profilowych', 'Auto-load profile pictures': 'Automatyczne ładowanie zdjęć profilowych',
'Disable live feed': 'Wyłącz kanał na żywo', 'Disable live feed': 'Wyłącz kanał na żywo',
'Enable live feed': 'Włącz kanał na żywo', 'Enable live feed': 'Włącz kanał na żywo',
'Default relays': 'Domyślne transmitery', 'Default relays': 'Domyślne przekaźniki',
'Reset to default': 'Przywróć domyślne', 'Reset to default': 'Przywróć domyślne',
'Default relays description': 'Default relays description':
'Używane do odpytywania konfiguracji transmiterów innych użytkowników i jako rozwiązanie awaryjne, gdy użytkownicy nie mają skonfigurowanych transmiterów.', 'Używane do odpytywania konfiguracji przekaźników innych użytkowników i jako rozwiązanie awaryjne, gdy użytkownicy nie mają skonfigurowanych przekaźników.',
'Default relays warning': 'Default relays warning':
'Ostrzeżenie: Nie modyfikuj tych ustawień pochopnie, może to wpłynąć na komfort użytkowania.', 'Ostrzeżenie: Nie modyfikuj tych ustawień pochopnie, może to wpłynąć na podstawowe doświadczenie.',
'Invalid relay URL': 'Nieprawidłowy adres URL transmitera', 'Invalid relay URL': 'Nieprawidłowy adres URL przekaźnika',
'Muted words': 'Wyciszone słowa', 'Muted words': 'Wyciszone słowa',
'Add muted word': 'Dodaj wyciszone słowo', 'Add muted word': 'Dodaj wyciszone słowo',
'Zap Details': 'Szczegóły zapu', 'Zap Details': 'Szczegóły zapu',
'Default trust score filter threshold ({{n}}%)': 'Default trust score filter threshold ({{n}}%)':
'Domyślny próg filtra wyniku zaufania ({{n}}%)', 'Domyślny próg filtra wyniku zaufania ({{n}}%)',
'No notes found': 'Nie znaleziono wpisów', 'No notes found': 'Nie znaleziono notatek',
'Try again later or check your connection': 'Spróbuj ponownie później lub sprawdź połączenie', 'Try again later or check your connection': 'Spróbuj ponownie później lub sprawdź połączenie',
'Hide indirect': 'Ukryj pośrednie', 'Hide indirect': 'Ukryj pośrednie',
'Copy note content': 'Kopiuj treść wpisu', 'Copy note content': 'Kopiuj treść notatki',
'Video loop': 'Zapętlanie wideo', 'Video loop': 'Zapętlanie wideo',
'Automatically replay videos when they end': 'Automatycznie powtarzaj filmy po zakończeniu', 'Automatically replay videos when they end': 'Automatycznie powtarzaj filmy po zakończeniu',
'Relays used for searching notes (NIP-50)': 'Transmitery używane do wyszukiwania wpisów (NIP-50)', 'Relays used for searching notes (NIP-50)': 'Przekaźniki używane do wyszukiwania notatek (NIP-50)',
'Protected event (NIP-70)': 'Chronione zdarzenie (NIP-70)', 'Protected event (NIP-70)': 'Chronione zdarzenie (NIP-70)',
'Protected': 'Chronione', 'Protected': 'Chronione',
'Protected event hint': 'Chronione zdarzenia (NIP-70) mogą być publikowane tylko przez autora. Transmitery odrzucą publikację tych zdarzeń przez osoby trzecie, uniemożliwiając innym retransmisję Twoich treści. Uwaga: nie wszystkie transmitery obsługują ochronę zdarzeń.', 'Protected event hint': 'Chronione zdarzenia (NIP-70) mogą być publikowane tylko przez autora. Przekaźniki odrzucą te zdarzenia od osób trzecich, uniemożliwiając innym retransmisję Twoich treści. Uwaga: nie wszystkie przekaźniki obsługują chronione zdarzenia.'
'Allow insecure connections': 'Zezwól na niezabezpieczone połączenia',
'Allow insecure connections description':
'Zezwól na ładowanie zasobów http:// i łączenie z transmiterami ws://. Może to wywołać ostrzeżenia przeglądarki o mieszanej zawartości.',
'reacted to': 'zareagował na',
Reaction: 'Reakcja'
} }
} }

View file

@ -689,11 +689,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'Relays usados para buscar notas (NIP-50)', 'Relays used for searching notes (NIP-50)': 'Relays usados para buscar notas (NIP-50)',
'Protected event (NIP-70)': 'Evento protegido (NIP-70)', 'Protected event (NIP-70)': 'Evento protegido (NIP-70)',
'Protected': 'Protegido', 'Protected': 'Protegido',
'Protected event hint': 'Eventos protegidos (NIP-70) só podem ser publicados pelo autor. Os relays rejeitarão esses eventos de terceiros, impedindo que outros retransmitam seu conteúdo. Nota: nem todos os relays suportam eventos protegidos.', 'Protected event hint': 'Eventos protegidos (NIP-70) só podem ser publicados pelo autor. Os relays rejeitarão esses eventos de terceiros, impedindo que outros retransmitam seu conteúdo. Nota: nem todos os relays suportam eventos protegidos.'
'Allow insecure connections': 'Permitir conexões inseguras',
'Allow insecure connections description':
'Permitir carregar recursos http:// e conectar a relays ws://. Pode acionar avisos de conteúdo misto do navegador.',
'reacted to': 'reagiu a',
Reaction: 'Reação'
} }
} }

View file

@ -692,11 +692,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'Relés usados para pesquisar notas (NIP-50)', 'Relays used for searching notes (NIP-50)': 'Relés usados para pesquisar notas (NIP-50)',
'Protected event (NIP-70)': 'Evento protegido (NIP-70)', 'Protected event (NIP-70)': 'Evento protegido (NIP-70)',
'Protected': 'Protegido', 'Protected': 'Protegido',
'Protected event hint': 'Eventos protegidos (NIP-70) só podem ser publicados pelo autor. Os relés rejeitarão estes eventos de terceiros, impedindo que outros retransmitam o seu conteúdo. Nota: nem todos os relés suportam eventos protegidos.', 'Protected event hint': 'Eventos protegidos (NIP-70) só podem ser publicados pelo autor. Os relés rejeitarão estes eventos de terceiros, impedindo que outros retransmitam o seu conteúdo. Nota: nem todos os relés suportam eventos protegidos.'
'Allow insecure connections': 'Permitir ligações inseguras',
'Allow insecure connections description':
'Permitir carregar recursos http:// e ligar a relays ws://. Pode acionar avisos de conteúdo misto do navegador.',
'reacted to': 'reagiu a',
Reaction: 'Reação'
} }
} }

View file

@ -691,11 +691,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'Ретрансляторы для поиска заметок (NIP-50)', 'Relays used for searching notes (NIP-50)': 'Ретрансляторы для поиска заметок (NIP-50)',
'Protected event (NIP-70)': 'Защищённое событие (NIP-70)', 'Protected event (NIP-70)': 'Защищённое событие (NIP-70)',
'Protected': 'Защищённый', 'Protected': 'Защищённый',
'Protected event hint': 'Защищённые события (NIP-70) могут быть опубликованы только автором. Ретрансляторы отклонят эти события от третьих лиц, предотвращая повторную трансляцию вашего контента. Примечание: не все ретрансляторы поддерживают защищённые события.', 'Protected event hint': 'Защищённые события (NIP-70) могут быть опубликованы только автором. Ретрансляторы отклонят эти события от третьих лиц, предотвращая повторную трансляцию вашего контента. Примечание: не все ретрансляторы поддерживают защищённые события.'
'Allow insecure connections': 'Разрешить небезопасные соединения',
'Allow insecure connections description':
'Разрешить загрузку ресурсов http:// и подключение к реле ws://. Может вызвать предупреждения браузера о смешанном содержимом.',
'reacted to': 'отреагировал на',
Reaction: 'Реакция'
} }
} }

View file

@ -676,11 +676,6 @@ export default {
'Relays used for searching notes (NIP-50)': 'รีเลย์ที่ใช้สำหรับค้นหาโน้ต (NIP-50)', 'Relays used for searching notes (NIP-50)': 'รีเลย์ที่ใช้สำหรับค้นหาโน้ต (NIP-50)',
'Protected event (NIP-70)': 'เหตุการณ์ที่ได้รับการป้องกัน (NIP-70)', 'Protected event (NIP-70)': 'เหตุการณ์ที่ได้รับการป้องกัน (NIP-70)',
'Protected': 'ป้องกัน', 'Protected': 'ป้องกัน',
'Protected event hint': 'เหตุการณ์ที่ได้รับการป้องกัน (NIP-70) สามารถเผยแพร่ได้โดยผู้เขียนเท่านั้น รีเลย์จะปฏิเสธเหตุการณ์เหล่านี้จากบุคคลที่สาม ป้องกันไม่ให้ผู้อื่นเผยแพร่เนื้อหาของคุณซ้ำ หมายเหตุ: รีเลย์บางแห่งไม่รองรับเหตุการณ์ที่ได้รับการป้องกัน', 'Protected event hint': 'เหตุการณ์ที่ได้รับการป้องกัน (NIP-70) สามารถเผยแพร่ได้โดยผู้เขียนเท่านั้น รีเลย์จะปฏิเสธเหตุการณ์เหล่านี้จากบุคคลที่สาม ป้องกันไม่ให้ผู้อื่นเผยแพร่เนื้อหาของคุณซ้ำ หมายเหตุ: รีเลย์บางแห่งไม่รองรับเหตุการณ์ที่ได้รับการป้องกัน'
'Allow insecure connections': 'อนุญาตการเชื่อมต่อที่ไม่ปลอดภัย',
'Allow insecure connections description':
'อนุญาตให้โหลดทรัพยากร http:// และเชื่อมต่อกับรีเลย์ ws:// อาจทำให้เบราว์เซอร์แสดงคำเตือนเนื้อหาแบบผสม',
'reacted to': 'รีแอคชันไปที่',
Reaction: 'รีแอคชัน'
} }
} }

View file

@ -658,11 +658,6 @@ export default {
'Relays used for searching notes (NIP-50)': '用於搜尋筆記的伺服器 (NIP-50)', 'Relays used for searching notes (NIP-50)': '用於搜尋筆記的伺服器 (NIP-50)',
'Protected event (NIP-70)': '受保護的事件 (NIP-70)', 'Protected event (NIP-70)': '受保護的事件 (NIP-70)',
'Protected': '受保護', 'Protected': '受保護',
'Protected event hint': '受保護的事件NIP-70只能由作者發布。伺服器將拒絕來自第三方的這些事件防止他人轉播你的內容。 注意:並非所有伺服器都支持受保護的事件。', 'Protected event hint': '受保護的事件NIP-70只能由作者發布。伺服器將拒絕來自第三方的這些事件防止他人轉播你的內容。 注意:並非所有伺服器都支持受保護的事件。'
'Allow insecure connections': '允許不安全的連線',
'Allow insecure connections description':
'允許載入 http:// 資源和連線 ws:// relay。可能會觸發瀏覽器混合內容警告。',
'reacted to': '回應了',
Reaction: '回應'
} }
} }

View file

@ -663,11 +663,6 @@ export default {
'Relays used for searching notes (NIP-50)': '用于搜索笔记的服务器 (NIP-50)', 'Relays used for searching notes (NIP-50)': '用于搜索笔记的服务器 (NIP-50)',
'Protected event (NIP-70)': '受保护的事件 (NIP-70)', 'Protected event (NIP-70)': '受保护的事件 (NIP-70)',
'Protected': '受保护', 'Protected': '受保护',
'Protected event hint': '受保护的事件NIP-70只能由作者发布。服务器将拒绝来自第三方的这些事件防止他人转播你的内容。 注意:并非所有服务器都支持受保护的事件。', 'Protected event hint': '受保护的事件NIP-70只能由作者发布。服务器将拒绝来自第三方的这些事件防止他人转播你的内容。 注意:并非所有服务器都支持受保护的事件。'
'Allow insecure connections': '允许不安全的连接',
'Allow insecure connections description':
'允许加载 http:// 资源和连接 ws:// relay。可能会触发浏览器混合内容警告。',
'reacted to': '回应了',
Reaction: '回应'
} }
} }

View file

@ -387,25 +387,6 @@ export function getEmojisFromEvent(event: Event): TEmoji[] {
return info.emojis return info.emojis
} }
export function getVideoMetadataFromEvent(event: Event) {
let title: string | undefined
const tags = new Set<string>()
event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'title') {
title = tagValue
} else if (tagName === 't' && tagValue && tags.size < 6) {
tags.add(tagValue.toLocaleLowerCase())
}
})
if (!title) {
title = event.tags.find(tagNameEquals('d'))?.[1]
}
return { title, tags: Array.from(tags) }
}
export function getStarsFromRelayReviewEvent(event: Event): number { export function getStarsFromRelayReviewEvent(event: Event): number {
const ratingTag = event.tags.find((t) => t[0] === 'rating') const ratingTag = event.tags.find((t) => t[0] === 'rating')
if (ratingTag) { if (ratingTag) {

View file

@ -34,10 +34,10 @@ export function containsMarkdown(content: string): boolean {
let matchCount = 0 let matchCount = 0
for (const pattern of mediumPatterns) { for (const pattern of mediumPatterns) {
const globalPattern = new RegExp(pattern.source, pattern.flags.includes('m') ? 'gm' : 'g') if (pattern.test(cleaned)) {
const occurrences = (cleaned.match(globalPattern) || []).length matchCount++
matchCount += occurrences if (matchCount >= 2) return true
if (matchCount >= 2) return true }
} }
return false return false

View file

@ -1,7 +1,5 @@
import storage from '@/services/local-storage.service'
import { SimplePool } from 'nostr-tools' import { SimplePool } from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay' import { AbstractRelay } from 'nostr-tools/abstract-relay'
import { isInsecureUrl } from './url'
const DEFAULT_CONNECTION_TIMEOUT = 10 * 1000 // 10 seconds const DEFAULT_CONNECTION_TIMEOUT = 10 * 1000 // 10 seconds
const CLEANUP_THRESHOLD = 15 // number of relays to trigger cleanup const CLEANUP_THRESHOLD = 15 // number of relays to trigger cleanup
@ -19,9 +17,6 @@ export class SmartPool extends SimplePool {
} }
ensureRelay(url: string): Promise<AbstractRelay> { ensureRelay(url: string): Promise<AbstractRelay> {
if (!storage.getAllowInsecureConnection() && isInsecureUrl(url)) {
return Promise.reject(new Error(`Insecure relay connection blocked: ${url}`))
}
// If relay is new and we have many relays, trigger cleanup // If relay is new and we have many relays, trigger cleanup
if (!this.relayIdleTracker.has(url) && this.relayIdleTracker.size > CLEANUP_THRESHOLD) { if (!this.relayIdleTracker.has(url) && this.relayIdleTracker.size > CLEANUP_THRESHOLD) {
this.cleanIdleRelays() this.cleanIdleRelays()

View file

@ -7,15 +7,6 @@ export function isWebsocketUrl(url: string): boolean {
} }
} }
export function isInsecureUrl(url: string): boolean {
try {
const protocol = new URL(url).protocol
return protocol === 'ws:' || protocol === 'http:'
} catch {
return false
}
}
export function isOnionUrl(url: string): boolean { export function isOnionUrl(url: string): boolean {
try { try {
const hostname = new URL(url).hostname const hostname = new URL(url).hostname

View file

@ -6,7 +6,6 @@ import { Switch } from '@/components/ui/switch'
import { DEFAULT_FAVICON_URL_TEMPLATE } from '@/constants' import { DEFAULT_FAVICON_URL_TEMPLATE } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { forwardRef, useState } from 'react' import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -14,7 +13,6 @@ import { useTranslation } from 'react-i18next'
const SystemSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { const SystemSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { faviconUrlTemplate, setFaviconUrlTemplate } = useContentPolicy() const { faviconUrlTemplate, setFaviconUrlTemplate } = useContentPolicy()
const { allowInsecureConnection, updateAllowInsecureConnection } = useUserPreferences()
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState( const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(
storage.getFilterOutOnionRelays() storage.getFilterOutOnionRelays()
) )
@ -47,19 +45,6 @@ const SystemSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
}} }}
/> />
</div> </div>
<div className="flex min-h-9 items-center justify-between px-4">
<Label htmlFor="allow-insecure-connection" className="text-base font-normal">
<div>{t('Allow insecure connections')}</div>
<div className="text-muted-foreground">
{t('Allow insecure connections description')}
</div>
</Label>
<Switch
id="allow-insecure-connection"
checked={allowInsecureConnection}
onCheckedChange={updateAllowInsecureConnection}
/>
</div>
<div className="space-y-2 px-4"> <div className="space-y-2 px-4">
<DefaultRelaysSetting /> <DefaultRelaysSetting />
</div> </div>

View file

@ -41,8 +41,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
publish, publish,
updateMuteListEvent, updateMuteListEvent,
nip04Decrypt, nip04Decrypt,
nip44Encrypt, nip04Encrypt
nip44Decrypt
} = useNostr() } = useNostr()
const [tags, setTags] = useState<string[][]>([]) const [tags, setTags] = useState<string[][]>([])
const [privateTags, setPrivateTags] = useState<string[][]>([]) const [privateTags, setPrivateTags] = useState<string[][]>([])
@ -57,53 +56,28 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
const [changing, setChanging] = useState(false) const [changing, setChanging] = useState(false)
const getPrivateTags = useCallback( const getPrivateTags = useCallback(
async ( async (muteListEvent: Event) => {
muteListEvent: Event if (!muteListEvent.content) return []
): Promise<{ privateTags: string[][]; wasNip04: boolean }> => {
if (!muteListEvent.content) return { privateTags: [], wasNip04: false }
try { try {
const wasNip04 = muteListEvent.content.includes('?iv=')
const storedPlainText = await indexedDb.getDecryptedContent(muteListEvent.id) const storedPlainText = await indexedDb.getDecryptedContent(muteListEvent.id)
let plainText: string let plainText: string
if (storedPlainText) { if (storedPlainText) {
console.log('[MuteList] Using cached decrypted content for event', muteListEvent.id)
plainText = storedPlainText plainText = storedPlainText
} else { } else {
console.log('[MuteList] Decrypting content with', wasNip04 ? 'NIP-04' : 'NIP-44', 'for event', muteListEvent.id) plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
plainText = wasNip04
? await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
: await nip44Decrypt(muteListEvent.pubkey, muteListEvent.content)
await indexedDb.putDecryptedContent(muteListEvent.id, plainText) await indexedDb.putDecryptedContent(muteListEvent.id, plainText)
} }
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
console.log('[MuteList] Decrypted privateTags count:', privateTags.length, 'wasNip04:', wasNip04) return privateTags
return { privateTags, wasNip04 }
} catch (error) { } catch (error) {
console.error('Failed to decrypt mute list content', error) console.error('Failed to decrypt mute list content', error)
return { privateTags: [], wasNip04: false } return []
} }
}, },
[nip04Decrypt, nip44Decrypt] [nip04Decrypt]
)
const migrateToNip44 = useCallback(
async (muteListEvent: Event, privateTags: string[][]) => {
if (!accountPubkey) return
console.log('[MuteList] Migrating from NIP-04 to NIP-44, privateTags count:', privateTags.length)
try {
const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(privateTags))
const newMuteListDraftEvent = createMuteListDraftEvent(muteListEvent.tags, cipherText)
const event = await publish(newMuteListDraftEvent)
console.log('[MuteList] Migration successful, new event id:', event.id)
await updateMuteListEvent(event, privateTags)
} catch (error) {
console.error('[MuteList] Failed to migrate to NIP-44', error)
}
},
[accountPubkey, nip44Encrypt, publish, updateMuteListEvent]
) )
useEffect(() => { useEffect(() => {
@ -114,16 +88,11 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
return return
} }
const { privateTags, wasNip04 } = await getPrivateTags(muteListEvent).catch(() => ({ const privateTags = await getPrivateTags(muteListEvent).catch(() => {
privateTags: [] as string[][], return []
wasNip04: false })
}))
setPrivateTags(privateTags) setPrivateTags(privateTags)
setTags(muteListEvent.tags) setTags(muteListEvent.tags)
if (wasNip04 && privateTags.length > 0) {
migrateToNip44(muteListEvent, privateTags)
}
} }
updateMuteTags() updateMuteTags()
}, [muteListEvent]) }, [muteListEvent])
@ -174,14 +143,8 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
return return
} }
const newTags = (muteListEvent?.tags ?? []).concat([['p', pubkey]]) const newTags = (muteListEvent?.tags ?? []).concat([['p', pubkey]])
const { privateTags } = muteListEvent const newMuteListEvent = await publishNewMuteListEvent(newTags, muteListEvent?.content)
? await getPrivateTags(muteListEvent) const privateTags = await getPrivateTags(newMuteListEvent)
: { privateTags: [] }
const cipherText =
privateTags.length > 0
? await nip44Encrypt(accountPubkey, JSON.stringify(privateTags))
: ''
const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText)
await updateMuteListEvent(newMuteListEvent, privateTags) await updateMuteListEvent(newMuteListEvent, privateTags)
} catch (error) { } catch (error) {
const errors = formatError(error) const errors = formatError(error)
@ -200,15 +163,13 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
try { try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
checkMuteListEvent(muteListEvent) checkMuteListEvent(muteListEvent)
const { privateTags } = muteListEvent const privateTags = muteListEvent ? await getPrivateTags(muteListEvent) : []
? await getPrivateTags(muteListEvent)
: { privateTags: [] as string[][] }
if (privateTags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey)) { if (privateTags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey)) {
return return
} }
const newPrivateTags = privateTags.concat([['p', pubkey]]) const newPrivateTags = privateTags.concat([['p', pubkey]])
const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent(muteListEvent?.tags ?? [], cipherText) const newMuteListEvent = await publishNewMuteListEvent(muteListEvent?.tags ?? [], cipherText)
await updateMuteListEvent(newMuteListEvent, newPrivateTags) await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} catch (error) { } catch (error) {
@ -229,11 +190,11 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return if (!muteListEvent) return
const { privateTags } = await getPrivateTags(muteListEvent) const privateTags = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
let cipherText = muteListEvent.content let cipherText = muteListEvent.content
if (newPrivateTags.length !== privateTags.length) { if (newPrivateTags.length !== privateTags.length) {
cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
} }
const newMuteListEvent = await publishNewMuteListEvent( const newMuteListEvent = await publishNewMuteListEvent(
@ -259,13 +220,13 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const muteListEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return if (!muteListEvent) return
const { privateTags } = await getPrivateTags(muteListEvent) const privateTags = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
if (newPrivateTags.length === privateTags.length) { if (newPrivateTags.length === privateTags.length) {
return return
} }
const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent( const newMuteListEvent = await publishNewMuteListEvent(
muteListEvent.tags muteListEvent.tags
.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) .filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
@ -296,11 +257,11 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
return return
} }
const { privateTags } = await getPrivateTags(muteListEvent) const privateTags = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags const newPrivateTags = privateTags
.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) .filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
.concat([['p', pubkey]]) .concat([['p', pubkey]])
const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText) const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText)
await updateMuteListEvent(newMuteListEvent, newPrivateTags) await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} catch (error) { } catch (error) {

View file

@ -68,20 +68,6 @@ export class BunkerSigner implements ISigner {
return await this.signer.nip04Decrypt(pubkey, cipherText) return await this.signer.nip04Decrypt(pubkey, cipherText)
} }
async nip44Encrypt(pubkey: string, plainText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
return await this.signer.nip44Encrypt(pubkey, plainText)
}
async nip44Decrypt(pubkey: string, cipherText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
return await this.signer.nip44Decrypt(pubkey, cipherText)
}
getClientSecretKey() { getClientSecretKey() {
return bytesToHex(this.clientSecretKey) return bytesToHex(this.clientSecretKey)
} }

View file

@ -81,8 +81,6 @@ type TNostrContext = {
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent> signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string> nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string> nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
nip44Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip44Decrypt: (pubkey: string, cipherText: string) => Promise<string>
startLogin: () => void startLogin: () => void
checkLogin: <T>(cb?: () => T) => Promise<T | void> checkLogin: <T>(cb?: () => T) => Promise<T | void>
updateRelayListEvent: (relayListEvent: Event) => Promise<void> updateRelayListEvent: (relayListEvent: Event) => Promise<void>
@ -732,14 +730,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return signer?.nip04Decrypt(pubkey, cipherText) ?? '' return signer?.nip04Decrypt(pubkey, cipherText) ?? ''
} }
const nip44Encrypt = async (pubkey: string, plainText: string) => {
return signer?.nip44Encrypt(pubkey, plainText) ?? ''
}
const nip44Decrypt = async (pubkey: string, cipherText: string) => {
return signer?.nip44Decrypt(pubkey, cipherText) ?? ''
}
const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => { const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
if (signer) { if (signer) {
return cb && cb() return cb && cb()
@ -867,8 +857,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
signHttpAuth, signHttpAuth,
nip04Encrypt, nip04Encrypt,
nip04Decrypt, nip04Decrypt,
nip44Encrypt,
nip44Decrypt,
startLogin: () => setOpenLoginDialog(true), startLogin: () => setOpenLoginDialog(true),
checkLogin, checkLogin,
signEvent, signEvent,

View file

@ -57,24 +57,4 @@ export class Nip07Signer implements ISigner {
} }
return await this.signer.nip04.decrypt(pubkey, cipherText) return await this.signer.nip04.decrypt(pubkey, cipherText)
} }
async nip44Encrypt(pubkey: string, plainText: string) {
if (!this.signer) {
throw new Error('Should call init() first')
}
if (!this.signer.nip44?.encrypt) {
throw new Error('The extension you are using does not support nip44 encryption')
}
return await this.signer.nip44.encrypt(pubkey, plainText)
}
async nip44Decrypt(pubkey: string, cipherText: string) {
if (!this.signer) {
throw new Error('Should call init() first')
}
if (!this.signer.nip44?.decrypt) {
throw new Error('The extension you are using does not support nip44 decryption')
}
return await this.signer.nip44.decrypt(pubkey, cipherText)
}
} }

View file

@ -66,20 +66,6 @@ export class NostrConnectionSigner implements ISigner {
return await this.signer.nip04Decrypt(pubkey, cipherText) return await this.signer.nip04Decrypt(pubkey, cipherText)
} }
async nip44Encrypt(pubkey: string, plainText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
return await this.signer.nip44Encrypt(pubkey, plainText)
}
async nip44Decrypt(pubkey: string, cipherText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
return await this.signer.nip44Decrypt(pubkey, cipherText)
}
getClientSecretKey() { getClientSecretKey() {
return bytesToHex(this.clientSecretKey) return bytesToHex(this.clientSecretKey)
} }

View file

@ -31,12 +31,4 @@ export class NpubSigner implements ISigner {
async nip04Decrypt(): Promise<any> { async nip04Decrypt(): Promise<any> {
throw new Error('Not logged in') throw new Error('Not logged in')
} }
async nip44Encrypt(): Promise<any> {
throw new Error('Not logged in')
}
async nip44Decrypt(): Promise<any> {
throw new Error('Not logged in')
}
} }

View file

@ -1,6 +1,5 @@
import { ISigner, TDraftEvent } from '@/types' import { ISigner, TDraftEvent } from '@/types'
import { finalizeEvent, getPublicKey as nGetPublicKey, nip04, nip19 } from 'nostr-tools' import { finalizeEvent, getPublicKey as nGetPublicKey, nip04, nip19 } from 'nostr-tools'
import { v2 as nip44 } from 'nostr-tools/nip44'
export class NsecSigner implements ISigner { export class NsecSigner implements ISigner {
private privkey: Uint8Array | null = null private privkey: Uint8Array | null = null
@ -51,20 +50,4 @@ export class NsecSigner implements ISigner {
} }
return nip04.decrypt(this.privkey, pubkey, cipherText) return nip04.decrypt(this.privkey, pubkey, cipherText)
} }
async nip44Encrypt(pubkey: string, plainText: string) {
if (!this.privkey) {
throw new Error('Not logged in')
}
const conversationKey = nip44.utils.getConversationKey(this.privkey, pubkey)
return nip44.encrypt(plainText, conversationKey)
}
async nip44Decrypt(pubkey: string, cipherText: string) {
if (!this.privkey) {
throw new Error('Not logged in')
}
const conversationKey = nip44.utils.getConversationKey(this.privkey, pubkey)
return nip44.decrypt(cipherText, conversationKey)
}
} }

View file

@ -42,8 +42,7 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
updatePinnedUsersEvent, updatePinnedUsersEvent,
publish, publish,
nip04Decrypt, nip04Decrypt,
nip44Encrypt, nip04Encrypt
nip44Decrypt
} = useNostr() } = useNostr()
const [privateTags, setPrivateTags] = useState<string[][]>([]) const [privateTags, setPrivateTags] = useState<string[][]>([])
const pinnedPubkeySet = useMemo(() => { const pinnedPubkeySet = useMemo(() => {
@ -51,23 +50,6 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
return new Set(getPubkeysFromPTags(pinnedUsersEvent.tags.concat(privateTags))) return new Set(getPubkeysFromPTags(pinnedUsersEvent.tags.concat(privateTags)))
}, [pinnedUsersEvent, privateTags]) }, [pinnedUsersEvent, privateTags])
const migrateToNip44 = useCallback(
async (event: Event, privateTags: string[][]) => {
if (!accountPubkey) return
console.log('[PinnedUsers] Migrating from NIP-04 to NIP-44, privateTags count:', privateTags.length)
try {
const cipherText = await nip44Encrypt(accountPubkey, JSON.stringify(privateTags))
const draftEvent = createPinnedUsersListDraftEvent(event.tags, cipherText)
const newEvent = await publish(draftEvent)
console.log('[PinnedUsers] Migration successful, new event id:', newEvent.id)
await updatePinnedUsersEvent(newEvent, privateTags)
} catch (error) {
console.error('[PinnedUsers] Failed to migrate to NIP-44', error)
}
},
[accountPubkey, nip44Encrypt, publish, updatePinnedUsersEvent]
)
useEffect(() => { useEffect(() => {
const updatePrivateTags = async () => { const updatePrivateTags = async () => {
if (!pinnedUsersEvent) { if (!pinnedUsersEvent) {
@ -75,48 +57,37 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
return return
} }
const { privateTags, wasNip04 } = await getPrivateTags(pinnedUsersEvent).catch(() => ({ const privateTags = await getPrivateTags(pinnedUsersEvent).catch(() => {
privateTags: [] as string[][], return []
wasNip04: false })
}))
setPrivateTags(privateTags) setPrivateTags(privateTags)
if (wasNip04 && privateTags.length > 0) {
migrateToNip44(pinnedUsersEvent, privateTags)
}
} }
updatePrivateTags() updatePrivateTags()
}, [pinnedUsersEvent]) }, [pinnedUsersEvent])
const getPrivateTags = useCallback( const getPrivateTags = useCallback(
async (event: Event): Promise<{ privateTags: string[][]; wasNip04: boolean }> => { async (event: Event) => {
if (!event.content) return { privateTags: [], wasNip04: false } if (!event.content) return []
try { try {
const wasNip04 = event.content.includes('?iv=')
const storedPlainText = await indexedDb.getDecryptedContent(event.id) const storedPlainText = await indexedDb.getDecryptedContent(event.id)
let plainText: string let plainText: string
if (storedPlainText) { if (storedPlainText) {
console.log('[PinnedUsers] Using cached decrypted content for event', event.id)
plainText = storedPlainText plainText = storedPlainText
} else { } else {
console.log('[PinnedUsers] Decrypting content with', wasNip04 ? 'NIP-04' : 'NIP-44', 'for event', event.id) plainText = await nip04Decrypt(event.pubkey, event.content)
plainText = wasNip04
? await nip04Decrypt(event.pubkey, event.content)
: await nip44Decrypt(event.pubkey, event.content)
await indexedDb.putDecryptedContent(event.id, plainText) await indexedDb.putDecryptedContent(event.id, plainText)
} }
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
console.log('[PinnedUsers] Decrypted privateTags count:', privateTags.length, 'wasNip04:', wasNip04) return privateTags
return { privateTags, wasNip04 }
} catch (error) { } catch (error) {
console.error('Failed to decrypt pinned users content', error) console.error('Failed to decrypt pinned users content', error)
return { privateTags: [], wasNip04: false } return []
} }
}, },
[nip04Decrypt, nip44Decrypt] [nip04Decrypt]
) )
const isPinned = useCallback( const isPinned = useCallback(
@ -158,7 +129,7 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
) )
let newContent = pinnedUsersEvent.content let newContent = pinnedUsersEvent.content
if (newPrivateTags.length !== privateTags.length) { if (newPrivateTags.length !== privateTags.length) {
newContent = await nip44Encrypt(pinnedUsersEvent.pubkey, JSON.stringify(newPrivateTags)) newContent = await nip04Encrypt(pinnedUsersEvent.pubkey, JSON.stringify(newPrivateTags))
} }
const draftEvent = createPinnedUsersListDraftEvent(newTags, newContent) const draftEvent = createPinnedUsersListDraftEvent(newTags, newContent)
const newEvent = await publish(draftEvent) const newEvent = await publish(draftEvent)
@ -177,7 +148,7 @@ export function PinnedUsersProvider({ children }: { children: React.ReactNode })
publish, publish,
updatePinnedUsersEvent, updatePinnedUsersEvent,
privateTags, privateTags,
nip44Encrypt nip04Encrypt
] ]
) )

View file

@ -21,9 +21,6 @@ type TUserPreferencesContext = {
quickReactionEmoji: string | TEmoji quickReactionEmoji: string | TEmoji
updateQuickReactionEmoji: (emoji: string | TEmoji) => void updateQuickReactionEmoji: (emoji: string | TEmoji) => void
allowInsecureConnection: boolean
updateAllowInsecureConnection: (allow: boolean) => void
} }
const UserPreferencesContext = createContext<TUserPreferencesContext | undefined>(undefined) const UserPreferencesContext = createContext<TUserPreferencesContext | undefined>(undefined)
@ -49,10 +46,6 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
const [quickReaction, setQuickReaction] = useState(storage.getQuickReaction()) const [quickReaction, setQuickReaction] = useState(storage.getQuickReaction())
const [quickReactionEmoji, setQuickReactionEmoji] = useState(storage.getQuickReactionEmoji()) const [quickReactionEmoji, setQuickReactionEmoji] = useState(storage.getQuickReactionEmoji())
const [allowInsecureConnection, setAllowInsecureConnection] = useState(
storage.getAllowInsecureConnection()
)
useEffect(() => { useEffect(() => {
if (!isSmallScreen && enableSingleColumnLayout) { if (!isSmallScreen && enableSingleColumnLayout) {
document.documentElement.style.setProperty('overflow-y', 'scroll') document.documentElement.style.setProperty('overflow-y', 'scroll')
@ -86,11 +79,6 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
storage.setQuickReactionEmoji(emoji) storage.setQuickReactionEmoji(emoji)
} }
const updateAllowInsecureConnection = (allow: boolean) => {
setAllowInsecureConnection(allow)
storage.setAllowInsecureConnection(allow)
}
return ( return (
<UserPreferencesContext.Provider <UserPreferencesContext.Provider
value={{ value={{
@ -105,9 +93,7 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
quickReaction, quickReaction,
updateQuickReaction, updateQuickReaction,
quickReactionEmoji, quickReactionEmoji,
updateQuickReactionEmoji, updateQuickReactionEmoji
allowInsecureConnection,
updateAllowInsecureConnection
}} }}
> >
{children} {children}

View file

@ -63,7 +63,6 @@ class LocalStorageService {
private enableSingleColumnLayout: boolean = true private enableSingleColumnLayout: boolean = true
private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE
private filterOutOnionRelays: boolean = !isTorBrowser() private filterOutOnionRelays: boolean = !isTorBrowser()
private allowInsecureConnection: boolean = false
private quickReaction: boolean = false private quickReaction: boolean = false
private quickReactionEmoji: string | TEmoji = '+' private quickReactionEmoji: string | TEmoji = '+'
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
@ -254,9 +253,6 @@ class LocalStorageService {
this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false' this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false'
} }
this.allowInsecureConnection =
window.localStorage.getItem(StorageKey.ALLOW_INSECURE_CONNECTION) === 'true'
this.quickReaction = window.localStorage.getItem(StorageKey.QUICK_REACTION) === 'true' this.quickReaction = window.localStorage.getItem(StorageKey.QUICK_REACTION) === 'true'
const quickReactionEmojiStr = const quickReactionEmojiStr =
window.localStorage.getItem(StorageKey.QUICK_REACTION_EMOJI) ?? '+' window.localStorage.getItem(StorageKey.QUICK_REACTION_EMOJI) ?? '+'
@ -654,15 +650,6 @@ class LocalStorageService {
window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString()) window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString())
} }
getAllowInsecureConnection() {
return this.allowInsecureConnection
}
setAllowInsecureConnection(allow: boolean) {
this.allowInsecureConnection = allow
window.localStorage.setItem(StorageKey.ALLOW_INSECURE_CONNECTION, allow.toString())
}
getQuickReaction() { getQuickReaction() {
return this.quickReaction return this.quickReaction
} }

View file

@ -1,10 +1,5 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import { getEventKey, getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
getEventKey,
getNoteBech32Id,
getReplaceableCoordinateFromEvent,
isReplaceableEvent
} from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { getDefaultRelayUrls } from '@/lib/relay' import { getDefaultRelayUrls } from '@/lib/relay'
import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag' import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag'
@ -15,13 +10,7 @@ import { Event, Filter, kinds } from 'nostr-tools'
export type TStuffStats = { export type TStuffStats = {
likeIdSet: Set<string> likeIdSet: Set<string>
likes: { likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
id: string
eventId: string
pubkey: string
created_at: number
emoji: TEmoji | string
}[]
repostPubkeySet: Set<string> repostPubkeySet: Set<string>
reposts: { id: string; pubkey: string; created_at: number }[] reposts: { id: string; pubkey: string; created_at: number }[]
zapPrSet: Set<string> zapPrSet: Set<string>
@ -280,13 +269,7 @@ class StuffStatsService {
} }
likeIdSet.add(evt.id) likeIdSet.add(evt.id)
likes.push({ likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
id: evt.id,
eventId: getNoteBech32Id(evt),
pubkey: evt.pubkey,
created_at: evt.created_at,
emoji
})
this.stuffStatsMap.set(targetEventKey, { ...old, likeIdSet, likes }) this.stuffStatsMap.set(targetEventKey, { ...old, likeIdSet, likes })
return targetEventKey return targetEventKey
} }
@ -315,13 +298,7 @@ class StuffStatsService {
} }
likeIdSet.add(evt.id) likeIdSet.add(evt.id)
likes.push({ likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
id: evt.id,
eventId: getNoteBech32Id(evt),
pubkey: evt.pubkey,
created_at: evt.created_at,
emoji
})
this.stuffStatsMap.set(target, { ...old, likeIdSet, likes }) this.stuffStatsMap.set(target, { ...old, likeIdSet, likes })
return target return target
} }

View file

@ -91,10 +91,6 @@ export type TNip07 = {
encrypt?: (pubkey: string, plainText: string) => Promise<string> encrypt?: (pubkey: string, plainText: string) => Promise<string>
decrypt?: (pubkey: string, cipherText: string) => Promise<string> decrypt?: (pubkey: string, cipherText: string) => Promise<string>
} }
nip44?: {
encrypt?: (pubkey: string, plainText: string) => Promise<string>
decrypt?: (pubkey: string, cipherText: string) => Promise<string>
}
} }
export interface ISigner { export interface ISigner {
@ -102,8 +98,6 @@ export interface ISigner {
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent> signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string> nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string> nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
nip44Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip44Decrypt: (pubkey: string, cipherText: string) => Promise<string>
} }
export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' | 'npub' export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' | 'npub'

View file

@ -1,19 +0,0 @@
import path from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
define: {
'import.meta.env.GIT_COMMIT': '"test"',
'import.meta.env.APP_VERSION': '"0.0.0"',
'import.meta.env.VITE_COMMUNITY_RELAY_SETS': '[]',
'import.meta.env.VITE_COMMUNITY_RELAYS': '[]'
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
test: {
include: ['src/**/*.spec.ts']
}
})