Browse Source

bug-fixes

imwald
Silberengel 2 months ago
parent
commit
cf43968634
  1. 81
      src/components/GifPicker/index.tsx
  2. 11
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 3
      src/components/PostEditor/PostContent.tsx
  4. 82
      src/components/Profile/index.tsx
  5. 4
      src/constants.ts
  6. 11
      src/i18n/locales/en.ts
  7. 74
      src/lib/draft-event.ts
  8. 18
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  9. 219
      src/pages/secondary/ProfileEditorPage/index.tsx
  10. 17
      src/providers/NostrProvider/index.tsx
  11. 5
      src/services/gif.service.ts
  12. 2
      src/types/index.d.ts

81
src/components/GifPicker/index.tsx

@ -126,9 +126,13 @@ export default function GifPicker({ @@ -126,9 +126,13 @@ export default function GifPicker({
const isLoggedIn = !!pubkey
/** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */
const isDrawer = isSmallScreen
const content = (
<div className="flex flex-col gap-2 p-2 min-w-[280px] max-w-[360px]">
<div className="flex items-center gap-1">
<div
className={`flex flex-col gap-2 p-2 ${isDrawer ? 'w-full h-[70vh] max-h-[70vh] overflow-hidden' : 'min-w-[280px] max-w-[360px]'}`}
>
<div className="flex items-center gap-1 shrink-0">
<Input
placeholder={t('Search GIFs')}
value={searchInput}
@ -147,38 +151,49 @@ export default function GifPicker({ @@ -147,38 +151,49 @@ export default function GifPicker({
</Button>
</div>
{error && (
<p className="text-sm text-muted-foreground px-1">{error}</p>
<p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>
)}
<ScrollArea className="h-[280px] w-full rounded-md border">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
{gifs.map((gif) => (
<button
key={gif.eventId}
type="button"
className="rounded overflow-hidden border border-transparent hover:border-primary focus:border-primary focus:outline-none aspect-square"
onClick={() => handleSelect(gif)}
>
<img
src={gif.url}
alt="GIF"
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
el.style.display = 'none'
}}
/>
</button>
))}
</div>
)}
</ScrollArea>
<div className="flex flex-col gap-2 border-t pt-2">
<div
className={isDrawer ? 'flex-1 min-h-0 flex flex-col' : undefined}
{...(isDrawer && { 'data-vaul-no-drag': true })}
>
<ScrollArea
className={
isDrawer
? 'flex-1 min-h-[200px] w-full rounded-md border'
: 'h-[280px] w-full rounded-md border'
}
>
{loading ? (
<div className="flex items-center justify-center h-full min-h-[200px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
{gifs.map((gif) => (
<button
key={gif.eventId}
type="button"
className="rounded overflow-hidden border border-transparent hover:border-primary focus:border-primary focus:outline-none aspect-square"
onClick={() => handleSelect(gif)}
>
<img
src={gif.url}
alt="GIF"
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
el.style.display = 'none'
}}
/>
</button>
))}
</div>
)}
</ScrollArea>
</div>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0">
<a
href={GIFBUDDY_URL}
target="_blank"

11
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1283,6 +1283,8 @@ function parseMarkdownContent( @@ -1283,6 +1283,8 @@ function parseMarkdownContent(
// Check if there's text before the pattern (even on previous lines, as long as no paragraph break)
const hasTextBefore = text.trim().length > 0 && !text.includes('\n\n')
// For hashtags at start of line: text after on same line (e.g. "#pyramid 1.1 has..." - merge so no hard break)
let hasTextAfterOnSameLine = false
// For hashtags: check if the line contains only hashtags (and spaces)
// This handles cases like "#orly #devstr #progressreport" on one line
@ -1344,10 +1346,11 @@ function parseMarkdownContent( @@ -1344,10 +1346,11 @@ function parseMarkdownContent(
shouldMergeHashtag = lineHasOnlyHashtags || hasOtherHashtagsOnLine || hasHashtagsOnAdjacentLines || hasTextOnSameLine || hasTextBefore
// If none of the above, but there's text after the hashtag on the same line, also merge
// This handles cases where hashtag is at start of line but followed by text
// This handles cases where hashtag is at start of line but followed by text (e.g. "#pyramid 1.1 has...")
if (!shouldMergeHashtag) {
const textAfterOnSameLine = content.substring(pattern.end, lineEndIndex)
if (textAfterOnSameLine.trim().length > 0) {
hasTextAfterOnSameLine = textAfterOnSameLine.trim().length > 0
if (hasTextAfterOnSameLine) {
shouldMergeHashtag = true
}
}
@ -1438,8 +1441,8 @@ function parseMarkdownContent( @@ -1438,8 +1441,8 @@ function parseMarkdownContent(
// Also update lastIndex immediately to prevent processing of patterns in this range
lastIndex = textEndIndex
} else if (hasTextOnSameLine || hasTextBefore) {
// Hashtag is part of text - merge just this hashtag and text after it
} else if (hasTextOnSameLine || hasTextBefore || hasTextAfterOnSameLine) {
// Hashtag is part of text - merge just this hashtag and text after it (avoids hard break after #hashtag at start of line)
const patternMarkdown = content.substring(pattern.index, pattern.end)
const textAfterPattern = content.substring(pattern.end, lineEndIndex)
text = text + patternMarkdown + textAfterPattern

3
src/components/PostEditor/PostContent.tsx

@ -815,7 +815,8 @@ export default function PostContent({ @@ -815,7 +815,8 @@ export default function PostContent({
specifiedRelayUrls: relayUrls,
additionalRelayUrls: isPoll ? pollCreateData.relays : (isPrivateEvent ? privateRelayUrls : additionalRelayUrls),
minPow,
disableFallbacks: additionalRelayUrls.length > 0 || isPrivateEvent // Don't use fallbacks if user explicitly selected relays or for private events
disableFallbacks: additionalRelayUrls.length > 0 || isPrivateEvent, // Don't use fallbacks if user explicitly selected relays or for private events
addClientTag
})
// console.log('Published event:', newEvent)

82
src/components/Profile/index.tsx

@ -55,10 +55,32 @@ import type { TProfile } from '@/types' @@ -55,10 +55,32 @@ import type { TProfile } from '@/types'
type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' | 'notes'
/** Normalize authority for deduplication (e.g. lightning addresses case-insensitive) */
/**
* Normalize lightning/LUD-16 authority to a canonical form for deduplication.
* Handles "user@domain" and "user.domain" (dot variant) as the same address.
*/
function normalizeLightningAuthority(authority: string): string {
const s = authority.trim().toLowerCase()
if (!s) return s
if (s.includes('@')) return s
const firstDot = s.indexOf('.')
if (firstDot > 0) return s.slice(0, firstDot) + '@' + s.slice(firstDot + 1)
return s
}
/** Normalize authority for deduplication (canonical key per type) */
function normalizePaymentAuthority(type: string, authority: string): string {
if (type === 'lightning' && authority) return authority.toLowerCase().trim()
return authority.trim()
const t = type.toLowerCase()
if (t === 'lightning' && authority) return normalizeLightningAuthority(authority)
return authority.trim().toLowerCase()
}
/** Prefer displaying lightning address in canonical "user@domain" form when we have both variants */
function preferCanonicalLightningAuthority(a: string, b: string): string {
const hasAt = (s: string) => s.trim().includes('@')
if (hasAt(a) && !hasAt(b)) return a
if (hasAt(b) && !hasAt(a)) return b
return a
}
type MergedPaymentMethod = {
@ -71,28 +93,48 @@ type MergedPaymentMethod = { @@ -71,28 +93,48 @@ type MergedPaymentMethod = {
maxAmount?: number
}
/** Merge payment methods from kind 10133 and profile (kind 0 lightning), deduplicated */
/** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated */
function mergePaymentMethods(
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null,
profile: TProfile | null
): MergedPaymentMethod[] {
const seen = new Set<string>()
const seen = new Map<string, MergedPaymentMethod>()
const out: MergedPaymentMethod[] = []
const add = (type: string, authority: string, payto?: string, displayType?: string, extra?: { currency?: string; minAmount?: number; maxAmount?: number }) => {
const key = `${type}:${normalizePaymentAuthority(type, authority)}`
if (!authority || seen.has(key)) return
seen.add(key)
out.push({
type,
authority,
payto: payto || (type && authority ? `payto://${type}/${authority}` : undefined),
displayType: displayType || (type === 'lightning' ? 'Lightning Network' : type === 'bitcoin' ? 'Bitcoin' : type || 'Payment'),
if (!authority?.trim()) return
const normType = type.toLowerCase()
const key = `${normType}:${normalizePaymentAuthority(normType, authority)}`
const existing = seen.get(key)
if (existing) {
if (normType === 'lightning') {
existing.authority = preferCanonicalLightningAuthority(existing.authority, authority.trim())
existing.payto = existing.payto || payto || (normType && authority ? `payto://${normType}/${existing.authority}` : undefined)
}
return
}
const entry: MergedPaymentMethod = {
type: normType,
authority: authority.trim(),
payto: payto || (normType && authority ? `payto://${normType}/${authority.trim()}` : undefined),
displayType: displayType || (normType === 'lightning' ? 'Lightning Network' : normType === 'bitcoin' ? 'Bitcoin' : type || 'Payment'),
...extra
})
}
seen.set(key, entry)
out.push(entry)
}
// From kind 10133
// Aggregate: profile (kind 0) first – from lightningAddressList (tags + JSON) and single lightningAddress
const fromProfile = profile?.lightningAddressList?.length
? profile.lightningAddressList
: profile?.lightningAddress
? [profile.lightningAddress]
: []
fromProfile.forEach((addr) => {
if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network')
})
// Then kind 10133 (payto tags and JSON content)
if (paymentInfo?.methods?.length) {
paymentInfo.methods.forEach((m) => {
const authority = m.authority || m.address || ''
@ -110,16 +152,6 @@ function mergePaymentMethods( @@ -110,16 +152,6 @@ function mergePaymentMethods(
add(type, authority, paymentInfo.payto, type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment')
}
// From profile (kind 0) lightning addresses
const fromProfile = profile?.lightningAddressList?.length
? profile.lightningAddressList
: profile?.lightningAddress
? [profile.lightningAddress]
: []
fromProfile.forEach((addr) => {
if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network')
})
return out
}

4
src/constants.ts

@ -121,8 +121,10 @@ export const FAST_WRITE_RELAY_URLS = [ @@ -121,8 +121,10 @@ export const FAST_WRITE_RELAY_URLS = [
'wss://nos.lol'
]
/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish */
/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish.
* Include relay.gifbuddy.lol (GifBuddy) so we get many kind 1063 GIFs; damus/primal/thecitadel have fewer. */
export const GIF_RELAY_URLS = [
'wss://relay.gifbuddy.lol',
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://thecitadel.nostr1.com'

11
src/i18n/locales/en.ts

@ -105,6 +105,17 @@ export default { @@ -105,6 +105,17 @@ export default {
'Payment info updated': 'Payment info updated',
'Failed to publish payment info': 'Failed to publish payment info',
'Invalid tags JSON': 'Invalid tags JSON',
'Payment methods': 'Payment methods',
'NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).': 'NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).',
'Type (e.g. lightning)': 'Type (e.g. lightning)',
'Authority (e.g. user@domain.com)': 'Authority (e.g. user@domain.com)',
'Add payment method': 'Add payment method',
Remove: 'Remove',
'Additional content (JSON)': 'Additional content (JSON)',
'Show full event JSON': 'Show full event JSON',
'Tag list': 'Tag list',
'Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.': 'Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.',
'Tag value': 'Tag value',
'Saving…': 'Saving…',
'Share with Jumble': 'Share with Jumble',
'Share with Alexandria': 'Share with Alexandria',

74
src/lib/draft-event.ts

@ -150,11 +150,6 @@ export async function createShortTextNoteDraftEvent( @@ -150,11 +150,6 @@ export async function createShortTextNoteDraftEvent(
// p tags
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -260,11 +255,6 @@ export async function createCommentDraftEvent( @@ -260,11 +255,6 @@ export async function createCommentDraftEvent(
]
)
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -347,11 +337,6 @@ export async function createPublicMessageReplyDraftEvent( @@ -347,11 +337,6 @@ export async function createPublicMessageReplyDraftEvent(
...Array.from(recipients).map((pubkey) => buildPTag(pubkey))
)
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -415,11 +400,6 @@ export async function createPublicMessageDraftEvent( @@ -415,11 +400,6 @@ export async function createPublicMessageDraftEvent(
...recipients.map((pubkey) => buildPTag(pubkey))
)
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -643,11 +623,6 @@ export async function createPollDraftEvent( @@ -643,11 +623,6 @@ export async function createPollDraftEvent(
})
}
if (addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (isNsfw) {
tags.push(buildNsfwTag())
}
@ -981,7 +956,7 @@ function buildResponseTag(value: string) { @@ -981,7 +956,7 @@ function buildResponseTag(value: string) {
return ['response', value]
}
function buildClientTag(handlerPubkey?: string, handlerIdentifier?: string, relay?: string) {
export function buildClientTag(handlerPubkey?: string, handlerIdentifier?: string, relay?: string) {
// Use NIP-89 format if handler information is provided
if (handlerPubkey && handlerIdentifier) {
const aTag = `31990:${handlerPubkey}:${handlerIdentifier}`
@ -996,7 +971,7 @@ function buildClientTag(handlerPubkey?: string, handlerIdentifier?: string, rela @@ -996,7 +971,7 @@ function buildClientTag(handlerPubkey?: string, handlerIdentifier?: string, rela
return ['client', 'jumble']
}
function buildAltTag() {
export function buildAltTag() {
return ['alt', 'This event was published by https://jumble.imwald.eu.']
}
@ -1164,11 +1139,6 @@ export async function createHighlightDraftEvent( @@ -1164,11 +1139,6 @@ export async function createHighlightDraftEvent(
}
// Add optional tags
if (options?.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options?.isNsfw) {
tags.push(buildNsfwTag())
}
@ -1213,11 +1183,6 @@ export async function createVoiceDraftEvent( @@ -1213,11 +1183,6 @@ export async function createVoiceDraftEvent(
tags.push(...imetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -1300,11 +1265,6 @@ export async function createVoiceCommentDraftEvent( @@ -1300,11 +1265,6 @@ export async function createVoiceCommentDraftEvent(
]
)
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -1354,11 +1314,6 @@ export async function createPictureDraftEvent( @@ -1354,11 +1314,6 @@ export async function createPictureDraftEvent(
tags.push(...imetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -1405,11 +1360,6 @@ export async function createVideoDraftEvent( @@ -1405,11 +1360,6 @@ export async function createVideoDraftEvent(
tags.push(...imetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -1485,11 +1435,6 @@ export async function createLongFormArticleDraftEvent( @@ -1485,11 +1435,6 @@ export async function createLongFormArticleDraftEvent(
tags.push(...generateImetaTags(images))
}
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -1560,11 +1505,6 @@ export async function createWikiArticleDraftEvent( @@ -1560,11 +1505,6 @@ export async function createWikiArticleDraftEvent(
}
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -1626,11 +1566,6 @@ export async function createWikiArticleMarkdownDraftEvent( @@ -1626,11 +1566,6 @@ export async function createWikiArticleMarkdownDraftEvent(
}
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
@ -1692,11 +1627,6 @@ export async function createPublicationContentDraftEvent( @@ -1692,11 +1627,6 @@ export async function createPublicationContentDraftEvent(
}
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}

18
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -45,15 +45,6 @@ function buildNsfwTag(): string[] { @@ -45,15 +45,6 @@ function buildNsfwTag(): string[] {
return ['content-warning', '']
}
function buildClientTag(): string[] {
return ['client', 'jumble']
}
function buildAltTag(): string[] {
return ['alt', 'This event was published by https://jumble.imwald.eu.']
}
interface DynamicTopic {
id: string
label: string
@ -430,11 +421,7 @@ export default function CreateThreadDialog({ @@ -430,11 +421,7 @@ export default function CreateThreadDialog({
tags.push(buildNsfwTag())
}
// Add client tag if enabled
if (addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
// Client tag is added in publish() based on user preference
// Create the thread event (kind 11)
const threadEvent: TDraftEvent = {
@ -458,7 +445,8 @@ export default function CreateThreadDialog({ @@ -458,7 +445,8 @@ export default function CreateThreadDialog({
// Publish to all selected relays
const publishedEvent = await publish(threadEvent, {
specifiedRelayUrls: selectedRelayUrls,
minPow
minPow,
addClientTag
})

219
src/pages/secondary/ProfileEditorPage/index.tsx

@ -24,7 +24,7 @@ import { isEmail } from '@/lib/utils' @@ -24,7 +24,7 @@ import { isEmail } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { ChevronDown, Loader, Pencil, RefreshCw, Upload } from 'lucide-react'
import { ChevronDown, Loader, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -50,12 +50,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -50,12 +50,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(null)
const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false)
const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('')
const [paymentInfoEditTagsJson, setPaymentInfoEditTagsJson] = useState('[]')
/** Payment method rows for kind 10133: each is a payto tag ["payto", type, authority]. */
const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState<Array<{ type: string; authority: string }>>([])
const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false)
const [savingPaymentInfo, setSavingPaymentInfo] = useState(false)
/** Editable full profile event (whole event as JSON string); synced from profileEvent. */
const [profileEventJson, setProfileEventJson] = useState<string>('')
const [savingFullProfile, setSavingFullProfile] = useState(false)
const [refreshingCache, setRefreshingCache] = useState(false)
/** Editable tag list for kind 0 (e.g. lud16, nip05, website). Each row is [name, value]. */
const [profileTags, setProfileTags] = useState<string[][]>([])
const defaultImage = useMemo(
() => (account ? generateImageByPubkey(account.pubkey) : undefined),
[account]
@ -90,6 +94,15 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -90,6 +94,15 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}
}, [profileEvent])
// Sync tag list from profileEvent (kind 0 tags)
useEffect(() => {
if (profileEvent?.tags?.length) {
setProfileTags(profileEvent.tags.map((t) => [...t]))
} else {
setProfileTags([])
}
}, [profileEvent])
// Fetch payment info event (kind 10133) for current user
useEffect(() => {
if (!account?.pubkey) {
@ -117,31 +130,41 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -117,31 +130,41 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
? paymentInfoEvent.content
: JSON.stringify(paymentInfoEvent.content ?? '', null, 2)
)
setPaymentInfoEditTagsJson(
JSON.stringify(paymentInfoEvent.tags ?? [], null, 2)
const paytoTags = (paymentInfoEvent.tags ?? []).filter(
(tag) => Array.isArray(tag) && tag[0] === 'payto' && tag[1] != null
)
setPaymentInfoEditMethods(
paytoTags.length > 0
? paytoTags.map((tag) => ({
type: (tag[1] as string) || 'lightning',
authority: (tag[2] as string) || ''
}))
: [{ type: 'lightning', authority: '' }]
)
} else {
setPaymentInfoEditContent('{}')
setPaymentInfoEditTagsJson('[]')
setPaymentInfoEditMethods([{ type: 'lightning', authority: '' }])
}
setPaymentInfoShowFullJson(false)
setPaymentInfoEditOpen(true)
}, [paymentInfoEvent])
const savePaymentInfo = useCallback(async () => {
let tags: string[][]
try {
tags = JSON.parse(paymentInfoEditTagsJson)
if (!Array.isArray(tags)) throw new Error('Tags must be an array')
tags.forEach((t, i) => {
if (!Array.isArray(t)) throw new Error(`Tag at index ${i} must be an array of strings`)
})
} catch (e) {
toast.error(t('Invalid tags JSON'))
return
}
const tags: string[][] = paymentInfoEditMethods
.filter((m) => m.authority.trim())
.map((m) => ['payto', (m.type.trim() || 'lightning').toLowerCase(), m.authority.trim()])
setSavingPaymentInfo(true)
try {
const draft = createPaymentInfoDraftEvent(paymentInfoEditContent.trim(), tags)
const contentStr = paymentInfoEditContent.trim() || '{}'
let content = contentStr
try {
JSON.parse(contentStr)
} catch {
toast.error(t('Invalid content JSON'))
setSavingPaymentInfo(false)
return
}
const draft = createPaymentInfoDraftEvent(content, tags)
const published = await publish(draft)
await client.updatePaymentInfoCache(published)
setPaymentInfoEvent(published)
@ -152,9 +175,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -152,9 +175,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
} finally {
setSavingPaymentInfo(false)
}
}, [paymentInfoEditContent, paymentInfoEditTagsJson, publish, t])
if (!account || !profile) return null
}, [paymentInfoEditContent, paymentInfoEditMethods, publish, t])
const save = async () => {
if (nip05 && !isEmail(nip05)) {
@ -188,11 +209,14 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -188,11 +209,14 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
delete newProfileContent.lud16
}
const tagsToSave = profileTags
.filter((tag) => Array.isArray(tag) && tag.length >= 2 && tag[0].trim() && tag[1].trim())
.map((tag) => [tag[0].trim(), tag[1].trim(), ...(tag.slice(2) || [])])
setSaving(true)
setHasChanged(false)
const profileDraftEvent = createProfileDraftEvent(
JSON.stringify(newProfileContent),
profileEvent?.tags ?? []
tagsToSave
)
const newProfileEvent = await publish(profileDraftEvent)
await updateProfileEvent(newProfileEvent)
@ -229,6 +253,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -229,6 +253,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}
}, [account?.pubkey, updateProfileEvent, t])
if (!account || !profile) return null
const saveFullProfile = async () => {
let parsed: { kind?: number; content?: string; tags?: string[][] }
try {
@ -381,6 +407,65 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -381,6 +407,65 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
)}
</Item>
<Item>
<Label className="text-muted-foreground">{t('Tag list')}</Label>
<p className="text-xs text-muted-foreground">
{t('Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.')}
</p>
<div className="space-y-2">
{profileTags.map((tag, idx) => (
<div key={idx} className="flex gap-2 items-center">
<Input
placeholder={t('Tag name')}
value={tag[0] ?? ''}
onChange={(e) => {
const next = profileTags.map((t, i) => (i === idx ? [e.target.value, t[1] ?? '', ...(t.slice(2) ?? [])] : t))
setProfileTags(next)
setHasChanged(true)
}}
className="flex-1 max-w-[140px] font-mono text-sm"
/>
<Input
placeholder={t('Tag value')}
value={tag[1] ?? ''}
onChange={(e) => {
const next = profileTags.map((t, i) => (i === idx ? [t[0] ?? '', e.target.value, ...(t.slice(2) ?? [])] : t))
setProfileTags(next)
setHasChanged(true)
}}
className="flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => {
setProfileTags(profileTags.filter((_, i) => i !== idx))
setHasChanged(true)
}}
aria-label={t('Remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="gap-1"
onClick={() => {
setProfileTags([...profileTags, ['', '']])
setHasChanged(true)
}}
>
<Plus className="h-3.5 w-3.5" />
{t('Add tag')}
</Button>
</div>
</Item>
{/* Full profile event (kind 0): editable entire event as JSON */}
{profileEvent && (
<Item>
@ -464,22 +549,94 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -464,22 +549,94 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</DialogHeader>
<div className="flex-1 overflow-auto space-y-4">
<Item>
<Label htmlFor="payment-info-content">{t('Content (JSON)')}</Label>
<Textarea
<Label className="text-muted-foreground">{t('Payment methods')}</Label>
<p className="text-xs text-muted-foreground">
{t('NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).')}
</p>
<div className="space-y-2">
{paymentInfoEditMethods.map((row, idx) => (
<div key={idx} className="flex gap-2 items-center">
<Input
placeholder={t('Type (e.g. lightning)')}
value={row.type}
onChange={(e) => {
const next = [...paymentInfoEditMethods]
next[idx] = { ...next[idx], type: e.target.value }
setPaymentInfoEditMethods(next)
}}
className="flex-1 max-w-[140px] font-mono text-sm"
/>
<Input
placeholder={t('Authority (e.g. user@domain.com)')}
value={row.authority}
onChange={(e) => {
const next = [...paymentInfoEditMethods]
next[idx] = { ...next[idx], authority: e.target.value }
setPaymentInfoEditMethods(next)
}}
className="flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => {
setPaymentInfoEditMethods(paymentInfoEditMethods.filter((_, i) => i !== idx))
}}
aria-label={t('Remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="gap-1"
onClick={() => setPaymentInfoEditMethods([...paymentInfoEditMethods, { type: 'lightning', authority: '' }])}
>
<Plus className="h-3.5 w-3.5" />
{t('Add payment method')}
</Button>
</div>
</Item>
<Item>
<Label htmlFor="payment-info-content">{t('Additional content (JSON)')}</Label>
<Input
id="payment-info-content"
className="font-mono text-sm min-h-32"
className="font-mono text-sm"
value={paymentInfoEditContent}
onChange={(e) => setPaymentInfoEditContent(e.target.value)}
placeholder='{}'
/>
</Item>
<Item>
<Label htmlFor="payment-info-tags">{t('Tags (JSON array of arrays, e.g. [["payto","lightning","user@domain.com"]])')}</Label>
<Textarea
id="payment-info-tags"
className="font-mono text-sm min-h-24"
value={paymentInfoEditTagsJson}
onChange={(e) => setPaymentInfoEditTagsJson(e.target.value)}
/>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1"
onClick={() => setPaymentInfoShowFullJson((v) => !v)}
>
<ChevronDown className={`h-4 w-4 transition-transform ${paymentInfoShowFullJson ? 'rotate-180' : ''}`} />
{t('Show full event JSON')}
</Button>
{paymentInfoShowFullJson && (
<pre className="mt-2 p-3 rounded-md bg-muted text-xs overflow-auto max-h-48 break-all whitespace-pre-wrap border">
{JSON.stringify(
createPaymentInfoDraftEvent(
paymentInfoEditContent.trim() || '{}',
paymentInfoEditMethods
.filter((m) => m.authority.trim())
.map((m) => ['payto', (m.type.trim() || 'lightning').toLowerCase(), m.authority.trim()])
),
null,
2
)}
</pre>
)}
</Item>
</div>
<DialogFooter>

17
src/providers/NostrProvider/index.tsx

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
import LoginDialog from '@/components/LoginDialog'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, StorageKey } from '@/constants'
import {
buildAltTag,
buildClientTag,
createDeletionRequestDraftEvent,
createFollowListDraftEvent,
createMuteListDraftEvent,
@ -865,6 +867,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -865,6 +867,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const draft = JSON.parse(JSON.stringify(draftEvent)) as TDraftEvent
// 1) Remove any existing "client" tag so we control the only one
if (draft.tags?.length) {
draft.tags = draft.tags.filter((tag) => Array.isArray(tag) && tag[0] !== 'client')
}
// 2) If user has allowed adding a client tag, add our own
const addClientTag =
typeof options.addClientTag === 'boolean'
? options.addClientTag
: (typeof window !== 'undefined' && window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) !== 'false')
if (addClientTag) {
draft.tags = draft.tags ?? []
draft.tags.push(buildClientTag(), buildAltTag())
}
let event: VerifiedEvent
if (minPow > 0) {
const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow)

5
src/services/gif.service.ts

@ -178,11 +178,12 @@ export async function fetchGifs( @@ -178,11 +178,12 @@ export async function fetchGifs(
const filter = {
kinds: [ExtendedKind.FILE_METADATA],
limit: Math.max(limit * 3, 150)
limit: Math.max(limit * 10, 200)
}
const events = await client.fetchEvents(GIF_RELAY_URLS, filter, {
eoseTimeout: 8000
eoseTimeout: 10000,
globalTimeout: 15000
})
const seenUrls = new Set<string>()

2
src/types/index.d.ts vendored

@ -173,6 +173,8 @@ export type TPublishOptions = { @@ -173,6 +173,8 @@ export type TPublishOptions = {
additionalRelayUrls?: string[]
minPow?: number
disableFallbacks?: boolean // If true, don't use fallback relays when publishing fails
/** Override global "Add client tag" preference for this publish (default: read from localStorage) */
addClientTag?: boolean
}
export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you' | 'bookmarksAndHashtags'

Loading…
Cancel
Save