Browse Source

more refactor

imwald
Silberengel 1 month ago
parent
commit
4ac78f8d2a
  1. 97
      src/PageManager.tsx
  2. 370
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  3. 15
      src/components/NoteOptions/index.tsx
  4. 42
      src/components/NoteOptions/useMenuActions.tsx
  5. 15
      src/i18n/locales/de.ts
  6. 14
      src/i18n/locales/en.ts

97
src/PageManager.tsx

@ -238,6 +238,47 @@ function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): @@ -238,6 +238,47 @@ function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null):
return `/relays/${encodedRelayUrl}`
}
/** Path (+ query for spells) pushed when navigating primary pages — shareable URLs for faux spells. */
function buildPrimaryPageUrl(
page: TPrimaryPageName,
props?: { spell?: string } | Record<string, unknown> | null
): string {
if (page === 'home') return '/'
if (page === 'spells') {
const spell =
props && typeof (props as { spell?: unknown }).spell === 'string'
? String((props as { spell: string }).spell).trim()
: ''
if (spell) return `/spells?spell=${encodeURIComponent(spell)}`
return '/spells'
}
return `/${page}`
}
function spellPropsFromSearch(search: string): { spell: string } | undefined {
const spell = new URLSearchParams(search).get('spell')?.trim()
return spell ? { spell } : undefined
}
/** Primary URL for drawer/overlay restore when we only have pathname + optional full URL for query. */
function restoredPrimaryBrowserUrl(pathname: string, fullUrlForQuery: string): string {
const popSegments = pathname.split('/').filter(Boolean)
const popFirstSeg = popSegments[0] ?? ''
if (popSegments.length === 0 || (popSegments.length === 1 && popFirstSeg === 'home')) {
return '/'
}
if (popSegments.length === 1 && popFirstSeg === 'spells') {
try {
const sp = new URL(fullUrlForQuery, window.location.origin).searchParams.get('spell')?.trim()
return buildPrimaryPageUrl('spells', sp ? { spell: sp } : undefined)
} catch {
return '/spells'
}
}
if (popSegments.length === 1) return `/${popFirstSeg}`
return pathname
}
// Helper function to extract noteId and context from URL
function parseNoteUrl(url: string): { noteId: string; context?: string } {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
@ -624,6 +665,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -624,6 +665,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const navigationCounterRef = useRef(0)
const savedFeedStateRef = useRef<Map<TPrimaryPageName, { tab?: string }>>(new Map())
const currentTabStateRef = useRef<Map<TPrimaryPageName, string>>(new Map()) // Track current tab state for each page
const savedPrimaryPagePropsRef = useRef<object | undefined>(undefined)
const primaryPagePropsRef = useRef<Map<TPrimaryPageName, object | undefined>>(new Map())
const currentPageProps = useMemo((): object | undefined => {
const entry = primaryPages.find((p) => p.name === currentPrimaryPage)
@ -633,6 +676,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -633,6 +676,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => {
if (view && !primaryNoteView) {
// Saving current primary page before showing overlay
savedPrimaryPagePropsRef.current = primaryPages.find((p) => p.name === currentPrimaryPage)?.props as
| object
| undefined
setSavedPrimaryPage(currentPrimaryPage)
// Get current tab state from ref (updated by components via events)
@ -662,7 +708,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -662,7 +708,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// If clearing the view, restore to the saved primary page
if (!view && savedPrimaryPage) {
const newUrl = savedPrimaryPage === 'home' ? '/' : `/${savedPrimaryPage}`
const newUrl = buildPrimaryPageUrl(
savedPrimaryPage,
savedPrimaryPagePropsRef.current as { spell?: string } | undefined
)
window.history.replaceState(null, '', newUrl)
const savedFeedState = savedFeedStateRef.current.get(savedPrimaryPage)
@ -826,6 +875,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -826,6 +875,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})
} else if (pageName === 'discussions') {
navigatePrimaryPage('spells', { spell: 'discussions' })
} else if (pageName === 'spells') {
const spellProps = spellPropsFromSearch(window.location.search)
navigatePrimaryPage('spells', spellProps)
} else if (pageName in primaryMap) {
navigatePrimaryPage(pageName as TPrimaryPageName)
}
@ -898,6 +950,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -898,6 +950,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})
return
}
if (pageName === 'spells') {
const spellProps = spellPropsFromSearch(window.location.search)
navigatePrimaryPage('spells', spellProps)
return
}
if (pageName && pageName in getPrimaryPageMap()) {
// For relay page, check if there's a URL prop
if (pageName === 'relay') {
@ -934,6 +991,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -934,6 +991,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore)\/notes\/(.+)$/) ||
urlToCheck.match(/\/notes\/(.+)$/)
const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null
// Keep spells faux spell in sync with ?spell= on browser back/forward
if (!noteIdToShow) {
const syncSegs = window.location.pathname.split('/').filter(Boolean)
if (syncSegs.length === 1 && syncSegs[0] === 'spells') {
const spellProps = spellPropsFromSearch(window.location.search)
setCurrentPrimaryPage('spells')
setPrimaryPages((prev) => mergePrimaryPageEntry(prev, { name: 'spells', props: spellProps }))
}
}
// If not a note URL and drawer is open - close the drawer immediately
// Only in single-pane mode or mobile
@ -942,7 +1009,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -942,7 +1009,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setTimeout(() => {
setDrawerNoteId(null)
// Restore URL to current primary page
const pageUrl = currentPrimaryPage === 'home' ? '/' : `/${currentPrimaryPage}`
const pageUrl = buildPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
window.history.replaceState(null, '', pageUrl)
}, 350)
}
@ -993,13 +1063,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -993,13 +1063,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// On mobile or single-pane: if drawer is open, close it
if (drawerOpen && (isSmallScreen || panelMode === 'single')) {
setDrawerOpen(false)
const historyUrl = state!.url
setTimeout(() => {
setDrawerNoteId(null)
// Ensure URL matches the primary page
const pageUrl =
popSegments.length === 0 || (popSegments.length === 1 && popFirstSeg === 'home')
? '/'
: `/${popFirstSeg}`
// Ensure URL matches the primary page (preserve /spells?spell=)
const pageUrl = restoredPrimaryBrowserUrl(pathname, historyUrl)
window.history.replaceState(null, '', pageUrl)
}, 350)
}
@ -1151,9 +1219,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1151,9 +1219,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})
setCurrentPrimaryPage(page)
// Update URL for primary pages - use dedicated paths
// Home can be either / or /home, but we'll use / for home
const newUrl = page === 'home' ? '/' : `/${page}`
// Update URL for primary pages (spells uses ?spell= for faux feeds)
const newUrl = buildPrimaryPageUrl(page, props)
window.history.pushState(null, '', newUrl)
// NEVER scroll to top - feed should maintain scroll position at all times
@ -1442,7 +1509,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1442,7 +1509,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Use 350ms to ensure animation is fully done (animation is 300ms)
if (!open) {
// Restore URL to current primary page
const pageUrl = currentPrimaryPage === 'home' ? '/' : `/${currentPrimaryPage}`
const pageUrl = buildPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
window.history.replaceState(null, '', pageUrl)
setTimeout(() => setDrawerNoteId(null), 350)
}
@ -1560,7 +1630,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1560,7 +1630,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Use 350ms to ensure animation is fully done (animation is 300ms)
if (!open) {
// Restore URL to current primary page
const pageUrl = currentPrimaryPage === 'home' ? '/' : `/${currentPrimaryPage}`
const pageUrl = buildPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
window.history.replaceState(null, '', pageUrl)
setTimeout(() => setDrawerNoteId(null), 350)
}

370
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -0,0 +1,370 @@ @@ -0,0 +1,370 @@
import { Card } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import Content from '@/components/Content'
import ContentPreview from '@/components/ContentPreview'
import Highlight from '@/components/Note/Highlight'
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle'
import { ExtendedKind } from '@/constants'
import { createFakeEvent } from '@/lib/event'
import logger from '@/lib/logger'
import {
showPublishingError,
showPublishingFeedback,
showSimplePublishSuccess
} from '@/lib/publishing-feedback'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import dayjs from 'dayjs'
import { Plus, Trash2 } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
function normalizeTagRow(row: string[]): string[] | null {
const trimmed = row.map((c) => c.trim())
if (!trimmed[0]) return null
let end = trimmed.length
while (end > 1 && trimmed[end - 1] === '') end--
return trimmed.slice(0, end)
}
function tagsFromRows(rows: string[][]): string[][] {
const out: string[][] = []
for (const row of rows) {
const n = normalizeTagRow(row)
if (n) out.push(n)
}
return out
}
function StaticEventPreview({ event, className }: { event: Event; className?: string }) {
const k = event.kind
const wrap = (node: ReactNode) => (
<Card className={cn('p-3 select-text', className)}>{node}</Card>
)
if (k === ExtendedKind.POLL) {
return wrap(<ContentPreview event={event} />)
}
if (k === kinds.Highlights) {
return wrap(<Highlight event={event} />)
}
if (
k === kinds.ShortTextNote ||
k === ExtendedKind.COMMENT ||
k === ExtendedKind.VOICE_COMMENT
) {
return wrap(<MarkdownArticle event={event} hideMetadata />)
}
if (k === kinds.LongFormArticle) {
return wrap(<MarkdownArticle event={event} hideMetadata />)
}
if (k === ExtendedKind.WIKI_ARTICLE) {
return wrap(<AsciidocArticle event={event} hideImagesAndInfo={false} />)
}
if (k === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
return wrap(<MarkdownArticle event={event} hideMetadata />)
}
if (k === ExtendedKind.PUBLICATION_CONTENT) {
return wrap(<AsciidocArticle event={event} hideImagesAndInfo={false} />)
}
return wrap(<Content event={event} className="h-full" mustLoadMedia />)
}
export type TEditOrCloneMode = 'edit' | 'clone'
export default function EditOrCloneEventDialog({
open,
onOpenChange,
sourceEvent,
mode
}: {
open: boolean
onOpenChange: (open: boolean) => void
sourceEvent: Event
mode: TEditOrCloneMode
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const [content, setContent] = useState(sourceEvent.content)
const [tagRows, setTagRows] = useState<string[][]>([['', '']])
const [activeTab, setActiveTab] = useState('edit')
const [publishing, setPublishing] = useState(false)
const prevOpenRef = useRef(false)
const kind = sourceEvent.kind
useEffect(() => {
if (open && !prevOpenRef.current) {
setContent(sourceEvent.content)
setTagRows(
sourceEvent.tags?.length
? sourceEvent.tags.map((row) => [...row])
: [['', '']]
)
setActiveTab('edit')
}
prevOpenRef.current = open
}, [open, sourceEvent])
const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows])
const previewEvent = useMemo(() => {
const now = Math.floor(Date.now() / 1000)
return createFakeEvent({
kind,
content,
tags: normalizedTags,
pubkey: pubkey ?? '',
created_at: now
})
}, [kind, content, normalizedTags, pubkey])
const buildDraftJson = useCallback(() => {
const draft = {
pubkey: pubkey ?? t('Log in to publish'),
kind,
content,
tags: normalizedTags,
created_at: t('Set when you publish'),
_note: t('id and sig are assigned when you publish')
}
return JSON.stringify(draft, null, 2)
}, [pubkey, kind, content, normalizedTags, t])
const draftJson = activeTab === 'json' ? buildDraftJson() : ''
const updateRow = (i: number, j: number, value: string) => {
setTagRows((rows) => {
const next = rows.map((r) => [...r])
if (!next[i]) return rows
next[i][j] = value
return next
})
}
const addRow = () => setTagRows((rows) => [...rows, ['', '']])
const removeRow = (i: number) => {
setTagRows((rows) => (rows.length <= 1 ? [['', '']] : rows.filter((_, idx) => idx !== i)))
}
const addCell = (i: number) => {
setTagRows((rows) => {
const next = rows.map((r) => [...r])
next[i] = [...next[i], '']
return next
})
}
const removeCell = (i: number, j: number) => {
setTagRows((rows) => {
const next = rows.map((r) => [...r])
if (next[i].length <= 1) return rows
next[i] = next[i].filter((_, idx) => idx !== j)
return next
})
}
const handlePublish = async () => {
await checkLogin(async () => {
if (!pubkey) return
setPublishing(true)
try {
const draft = {
kind,
content,
tags: normalizedTags,
created_at: dayjs().unix()
}
const newEvent = await publish(draft)
if ((newEvent as any)?.relayStatuses) {
const rs = (newEvent as any).relayStatuses
showPublishingFeedback(
{
success: true,
relayStatuses: rs,
successCount: rs.filter((s: any) => s.success).length,
totalCount: rs.length
},
{ message: t('Post published'), duration: 6000 }
)
} else {
showSimplePublishSuccess(t('Post published'))
}
onOpenChange(false)
} catch (e) {
if (e instanceof AggregateError && (e as any).relayStatuses) {
const relayStatuses = (e as any).relayStatuses
const successCount = relayStatuses.filter((s: any) => s.success).length
const totalCount = relayStatuses.length
showPublishingFeedback(
{
success: successCount > 0,
relayStatuses,
successCount,
totalCount
},
{
message:
successCount > 0 ? t('Published to some relays only') : t('Failed to post'),
duration: 6000
}
)
if (successCount > 0) onOpenChange(false)
} else {
logger.error('Edit/clone publish failed', { error: e })
showPublishingError(e instanceof Error ? e : String(e))
}
} finally {
setPublishing(false)
}
})
}
const title =
mode === 'edit' ? t('Edit this event') : t('Clone or fork this event')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] max-w-3xl flex flex-col gap-0 p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-14">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">
{t('Edit content and tags, then publish a new signed event.')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 flex flex-col px-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 min-h-0 gap-2">
<TabsList className="w-auto justify-start shrink-0">
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
<TabsTrigger value="json">{t('Json')}</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
<ScrollArea className="h-[min(50vh,420px)] pr-3">
<div className="space-y-4 pb-2">
<div className="space-y-1">
<label className="text-sm font-medium">{t('Event kind')}</label>
<Input
type="number"
value={kind}
disabled
readOnly
className="font-mono text-sm"
aria-readonly
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">{t('Note content')}</label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
className="font-mono text-sm min-h-[160px]"
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">{t('Tags')}</div>
<div className="space-y-2">
{tagRows.map((row, i) => (
<div
key={i}
className="flex flex-wrap items-start gap-1 border rounded-md p-2 bg-muted/30"
>
{row.map((cell, j) => (
<div key={j} className="flex items-center gap-0.5 shrink-0">
<Input
value={cell}
onChange={(e) => updateRow(i, j, e.target.value)}
placeholder={j === 0 ? t('Tag name') : t('Value')}
className="h-8 w-[7rem] sm:w-32 font-mono text-xs"
/>
{row.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => removeCell(i, j)}
aria-label={t('Remove value')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => addCell(i)}
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t('Add field')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 ml-auto"
onClick={() => removeRow(i)}
aria-label={t('Remove tag')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button type="button" variant="secondary" size="sm" onClick={addRow}>
<Plus className="h-4 w-4 mr-1" />
{t('Add tag')}
</Button>
</div>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="preview" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
<ScrollArea className="h-[min(50vh,420px)] pr-3">
<StaticEventPreview event={previewEvent} />
</ScrollArea>
</TabsContent>
<TabsContent value="json" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
<ScrollArea className="h-[min(50vh,420px)] pr-3">
<pre className="text-xs font-mono whitespace-pre-wrap break-words border rounded-md p-3 bg-muted/40 select-text">
{draftJson}
</pre>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
<DialogFooter className="shrink-0 px-6 py-4 border-t gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button type="button" onClick={handlePublish} disabled={publishing || !pubkey}>
{publishing ? t('Loading...') : t('Publish')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

15
src/components/NoteOptions/index.tsx

@ -3,6 +3,7 @@ import { Ellipsis } from 'lucide-react' @@ -3,6 +3,7 @@ import { Ellipsis } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useState, useMemo } from 'react'
import { DesktopMenu } from './DesktopMenu'
import EditOrCloneEventDialog, { type TEditOrCloneMode } from './EditOrCloneEventDialog'
import { MobileMenu } from './MobileMenu'
import RawEventDialog from './RawEventDialog'
import ReportDialog from './ReportDialog'
@ -40,6 +41,8 @@ export default function NoteOptions({ @@ -40,6 +41,8 @@ export default function NoteOptions({
const { isSmallScreen } = useScreenSize()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false)
const [editCloneOpen, setEditCloneOpen] = useState(false)
const [editCloneMode, setEditCloneMode] = useState<TEditOrCloneMode>('clone')
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const [showSubMenu, setShowSubMenu] = useState(false)
const [activeSubMenu, setActiveSubMenu] = useState<SubMenuAction[]>([])
@ -68,7 +71,11 @@ export default function NoteOptions({ @@ -68,7 +71,11 @@ export default function NoteOptions({
setIsReportDialogOpen,
isSmallScreen,
onOpenPublicMessage,
onOpenCallInvite
onOpenCallInvite,
onOpenEditOrClone: (mode) => {
setEditCloneMode(mode)
setEditCloneOpen(true)
}
})
const trigger = useMemo(
@ -111,6 +118,12 @@ export default function NoteOptions({ @@ -111,6 +118,12 @@ export default function NoteOptions({
isOpen={isReportDialogOpen}
closeDialog={() => setIsReportDialogOpen(false)}
/>
<EditOrCloneEventDialog
open={editCloneOpen}
onOpenChange={setEditCloneOpen}
sourceEvent={event}
mode={editCloneMode}
/>
{onPostEditorClose != null && (
<PostEditor
open={isPostEditorOpen ?? false}

42
src/components/NoteOptions/useMenuActions.tsx

@ -15,7 +15,25 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' @@ -15,7 +15,25 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import { eventService, queryService } from '@/services/client.service'
import { nip66Service } from '@/services/nip66.service'
import { Bell, BellOff, Code, Copy, Link, SatelliteDish, Trash2, TriangleAlert, Pin, FileDown, Globe, BookOpen, MessageCircle, Send, Video } from 'lucide-react'
import {
Bell,
BellOff,
BookOpen,
Code,
Copy,
FileDown,
GitFork,
Globe,
Link,
MessageCircle,
PencilLine,
Pin,
SatelliteDish,
Send,
Trash2,
TriangleAlert,
Video
} from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { useMemo, useState, useEffect, useContext } from 'react'
@ -24,6 +42,7 @@ import { toast } from 'sonner' @@ -24,6 +42,7 @@ import { toast } from 'sonner'
import RelayIcon from '../RelayIcon'
import { PrimaryPageContext } from '@/PageManager'
import { showPublishingFeedback } from '@/lib/publishing-feedback'
import type { TEditOrCloneMode } from './EditOrCloneEventDialog'
export interface SubMenuAction {
label: React.ReactNode
@ -52,6 +71,8 @@ interface UseMenuActionsProps { @@ -52,6 +71,8 @@ interface UseMenuActionsProps {
onOpenPublicMessage?: (pubkey: string) => void
/** When provided, adds "Send call invite" to open composer with the call URL as content. */
onOpenCallInvite?: (url: string) => void
/** Opens edit/clone dialog (signed-in accounts only, not read-only npub). */
onOpenEditOrClone?: (mode: TEditOrCloneMode) => void
}
export function useMenuActions({
@ -63,12 +84,14 @@ export function useMenuActions({ @@ -63,12 +84,14 @@ export function useMenuActions({
isSmallScreen,
onOpenPublicMessage,
onOpenCallInvite,
onOpenEditOrClone,
}: UseMenuActionsProps) {
const { t } = useTranslation()
// Use useContext directly to avoid error if provider is not available
const primaryPageContext = useContext(PrimaryPageContext)
const currentPrimaryPage = primaryPageContext?.current ?? null
const { pubkey, profile, attemptDelete, publish } = useNostr()
const { pubkey, profile, attemptDelete, publish, account } = useNostr()
const canSignEvents = account != null && account.signerType !== 'npub'
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const relayUrls = useMemo(() => {
@ -684,6 +707,19 @@ export function useMenuActions({ @@ -684,6 +707,19 @@ export function useMenuActions({
})
}
if (canSignEvents && pubkey && onOpenEditOrClone) {
const isOwn = event.pubkey === pubkey
actions.push({
icon: isOwn ? PencilLine : GitFork,
label: isOwn ? t('Edit this event') : t('Clone or fork this event'),
onClick: () => {
closeDrawer()
onOpenEditOrClone(isOwn ? 'edit' : 'clone')
},
separator: true
})
}
actions.push({
icon: Code,
label: t('View raw event'),
@ -877,6 +913,8 @@ export function useMenuActions({ @@ -877,6 +913,8 @@ export function useMenuActions({
naddr,
onOpenPublicMessage,
onOpenCallInvite,
onOpenEditOrClone,
canSignEvents,
profile
])

15
src/i18n/locales/de.ts

@ -56,6 +56,21 @@ export default { @@ -56,6 +56,21 @@ export default {
'Copy user ID': 'Benutzer-ID kopieren',
'Send public message': 'Öffentliche Nachricht senden',
'View raw event': 'Rohdaten anzeigen',
'Edit this event': 'Dieses Event bearbeiten',
'Clone or fork this event': 'Event klonen oder forken',
'Event kind': 'Event-Kind',
'Note content': 'Inhalt',
Publish: 'Veröffentlichen',
'Post published': 'Beitrag veröffentlicht',
'Edit content and tags, then publish a new signed event.':
'Inhalt und Tags bearbeiten und als neues signiertes Event veröffentlichen.',
'Log in to publish': 'Zum Veröffentlichen anmelden',
'Set when you publish': 'wird beim Veröffentlichen gesetzt',
'id and sig are assigned when you publish':
'id und sig werden beim Veröffentlichen gesetzt',
'Published to some relays only': 'Nur an manche Relays veröffentlicht',
'Add field': 'Feld hinzufügen',
'Remove value': 'Wert entfernen',
Like: 'Gefällt mir',
'switch to light theme': 'Wechsel zum hellen Design',
'switch to dark theme': 'Wechsel zum dunklen Design',

14
src/i18n/locales/en.ts

@ -59,6 +59,20 @@ export default { @@ -59,6 +59,20 @@ export default {
'Copy user ID': 'Copy user ID',
'Send public message': 'Send public message',
'View raw event': 'View raw event',
'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork this event',
'Event kind': 'Event kind',
'Note content': 'Note content',
Publish: 'Publish',
'Post published': 'Post published',
'Edit content and tags, then publish a new signed event.':
'Edit content and tags, then publish a new signed event.',
'Log in to publish': 'Log in to publish',
'Set when you publish': 'set when you publish',
'id and sig are assigned when you publish':
'id and sig are assigned when you publish',
'Published to some relays only': 'Published to some relays only',
'Add field': 'Add field',
'View full profile': 'View full profile',
Like: 'Like',
'switch to light theme': 'switch to light theme',

Loading…
Cancel
Save