Browse Source

hide more advanced options on editors

imwald
Silberengel 2 weeks ago
parent
commit
ae0882240a
  1. 40
      src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx
  2. 106
      src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx
  3. 117
      src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx
  4. 13
      src/components/ConnectedRelays/active-relays-display.ts
  5. 7
      src/components/HelpAndAccountMenu.tsx
  6. 15
      src/components/PostEditor/Mentions.tsx
  7. 23
      src/components/PostEditor/PollEditor.tsx
  8. 114
      src/components/PostEditor/PostContent.tsx
  9. 189
      src/components/PostEditor/PostEditorAdvancedPanel.tsx
  10. 30
      src/components/PostEditor/PostEditorFormatToolbar.tsx
  11. 84
      src/components/PostEditor/PostOptions.tsx
  12. 2
      src/components/PostEditor/PostTextarea/index.tsx
  13. 2
      src/components/Sidebar/index.tsx
  14. 9
      src/i18n/locales/de.ts
  15. 4
      src/i18n/locales/en.ts

40
src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx

@ -1,34 +1,14 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { import {
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator DropdownMenuSeparator
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows' import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' import { ActiveRelaysIconGrid } from './ActiveRelaysIconGrid'
function rowMuted(connected: boolean) { /** Compact active-relay icons in the account (user badge) dropdown. */
return !connected
}
function rowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
return base
}
function rowClass(connected: boolean) {
return cn(rowMuted(connected) && 'opacity-45 text-muted-foreground')
}
/** Relay list block for account (or similar) dropdown menus. */
export function ActiveRelaysDropdownSection() { export function ActiveRelaysDropdownSection() {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows, connectedCount } = useRelayConnectionRows() const { rows, connectedCount } = useRelayConnectionRows()
if (rows.length === 0) return null if (rows.length === 0) return null
@ -42,17 +22,13 @@ export function ActiveRelaysDropdownSection() {
<span>{t('Active relays')}</span> <span>{t('Active relays')}</span>
<span className="tabular-nums text-muted-foreground">{countSummary}</span> <span className="tabular-nums text-muted-foreground">{countSummary}</span>
</DropdownMenuLabel> </DropdownMenuLabel>
{rows.map(({ url, connected }) => ( <div
<DropdownMenuItem className="px-2 pb-2"
key={url} onClick={(e) => e.stopPropagation()}
title={rowTitle(url, connected, t)} onPointerDown={(e) => e.stopPropagation()}
onClick={() => navigateToRelay(toRelay(url))}
className={cn('min-w-52 gap-2', rowClass(connected))}
> >
<RelayIcon url={url} /> <ActiveRelaysIconGrid />
{simplifyUrl(url)} </div>
</DropdownMenuItem>
))}
</> </>
) )
} }

106
src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx

@ -0,0 +1,106 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import {
ACTIVE_RELAYS_MAX_ICONS,
activeRelayRowMuted,
activeRelayRowTitle
} from './active-relays-display'
/**
* Compact relay status: icon buttons only (no hostname labels).
*/
export function ActiveRelaysIconGrid({ className }: { className?: string }) {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows } = useRelayConnectionRows()
const shown = rows.slice(0, ACTIVE_RELAYS_MAX_ICONS)
const overflowRows = rows.slice(ACTIVE_RELAYS_MAX_ICONS)
const overflow = overflowRows.length
if (rows.length === 0) {
return (
<p className={cn('text-xs text-muted-foreground', className)} title={t('Active relays')}>
</p>
)
}
return (
<div className={cn('flex flex-wrap gap-1', className)} title={t('Active relays')}>
{shown.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80',
activeRelayRowMuted(connected) && 'opacity-40 grayscale'
)}
title={activeRelayRowTitle(url, connected, t)}
aria-label={activeRelayRowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button>
))}
{overflow > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 min-h-7 min-w-7 shrink-0 rounded-full bg-muted px-1.5 py-0 text-[0.65rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground"
title={t('More relays', { count: overflow })}
aria-label={t('More relays', { count: overflow })}
>
+{overflow}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="right"
className="w-auto max-w-[min(18rem,calc(100vw-1.5rem))] p-2"
>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground py-1">
{t('More relays', { count: overflow })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-wrap gap-1 max-w-[16rem]">
{overflowRows.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80',
activeRelayRowMuted(connected) && 'opacity-40 grayscale'
)}
title={activeRelayRowTitle(url, connected, t)}
aria-label={activeRelayRowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
)
}

117
src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx

@ -1,117 +0,0 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
const MAX_ICONS = 14
function rowMuted(connected: boolean) {
return !connected
}
function rowMenuClass(connected: boolean) {
return cn(rowMuted(connected) && 'opacity-50 text-muted-foreground')
}
function rowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
return base
}
/**
* Desktop sidebar: relay avatars for relays with an open WebSocket in the pool.
*/
export function ConnectedRelaysSidebarStrip({ className }: { className?: string }) {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows } = useRelayConnectionRows()
const shown = rows.slice(0, MAX_ICONS)
const overflowRows = rows.slice(MAX_ICONS)
const overflow = overflowRows.length
if (rows.length === 0) {
return (
<div className={cn('px-1 py-1.5 xl:px-0', className)} title={t('Active relays')}>
<p className="text-center text-[0.6rem] font-medium text-muted-foreground xl:text-left">{t('Active relays')}</p>
<p className="mt-0.5 text-center text-[0.55rem] text-muted-foreground/80 xl:text-left"></p>
</div>
)
}
return (
<div className={cn('px-1 py-2 xl:px-0', className)} title={t('Active relays')}>
<p className="mb-1.5 text-center text-[0.65rem] font-medium leading-snug text-foreground xl:text-left">
{t('Active relays')}
</p>
<div className="flex flex-wrap justify-center gap-1 xl:justify-start">
{shown.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-5 w-5 min-h-5 min-w-5 shrink-0 rounded-full p-0 hover:bg-muted/80',
rowMuted(connected) && 'opacity-40 grayscale'
)}
title={rowTitle(url, connected, t)}
aria-label={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-5 w-5" iconSize={11} />
</Button>
))}
{overflow > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 min-h-5 min-w-5 shrink-0 rounded-full bg-muted px-1 py-0 text-[0.6rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground"
title={t('More relays', { count: overflow })}
aria-label={t('More relays', { count: overflow })}
>
+{overflow}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="right"
className="w-[min(18rem,calc(100vw-1.5rem))]"
>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
{t('More relays', { count: overflow })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{overflowRows.map(({ url, connected }) => (
<DropdownMenuItem
key={url}
className={cn('min-w-0 gap-2', rowMenuClass(connected))}
title={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-5 w-5 shrink-0" iconSize={11} />
<span className="truncate">{simplifyUrl(url)}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
)
}

13
src/components/ConnectedRelays/active-relays-display.ts

@ -0,0 +1,13 @@
import { simplifyUrl } from '@/lib/url'
export const ACTIVE_RELAYS_MAX_ICONS = 14
export function activeRelayRowMuted(connected: boolean) {
return !connected
}
export function activeRelayRowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
return base
}

7
src/components/HelpAndAccountMenu.tsx

@ -34,13 +34,11 @@ export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar'
function AccountDropdownItems({ function AccountDropdownItems({
onSwitchAccount, onSwitchAccount,
onLogoutClick, onLogoutClick,
onBrowseCache, onBrowseCache
showActiveRelays = false
}: { }: {
onSwitchAccount: () => void onSwitchAccount: () => void
onLogoutClick: () => void onLogoutClick: () => void
onBrowseCache: () => void onBrowseCache: () => void
showActiveRelays?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
@ -59,7 +57,7 @@ function AccountDropdownItems({
<Database className="size-4" /> <Database className="size-4" />
{t('Browse Cache')} {t('Browse Cache')}
</DropdownMenuItem> </DropdownMenuItem>
{showActiveRelays ? <ActiveRelaysDropdownSection /> : null} <ActiveRelaysDropdownSection />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onSwitchAccount}> <DropdownMenuItem onClick={onSwitchAccount}>
<ArrowDownUp className="size-4" /> <ArrowDownUp className="size-4" />
@ -191,7 +189,6 @@ function TitlebarAccountMenu({
onSwitchAccount={onSwitchAccount} onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick} onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache} onBrowseCache={onBrowseCache}
showActiveRelays
/> />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

15
src/components/PostEditor/Mentions.tsx

@ -17,12 +17,15 @@ export default function Mentions({
content, content,
mentions, mentions,
setMentions, setMentions,
parentEvent parentEvent,
/** When true, trigger shows only the count (section label supplies the title). */
compactTrigger = false
}: { }: {
content: string content: string
mentions: string[] mentions: string[]
setMentions: (mentions: string[]) => void setMentions: (mentions: string[]) => void
parentEvent?: Event parentEvent?: Event
compactTrigger?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
@ -75,8 +78,18 @@ export default function Mentions({
disabled={potentialMentions.length === 0} disabled={potentialMentions.length === 0}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{compactTrigger ? (
potentialMentions.length > 0 ? (
`(${mentions.length}/${potentialMentions.length})`
) : (
'—'
)
) : (
<>
{t('Mentions')}{' '} {t('Mentions')}{' '}
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`} {potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
</>
)}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[min(13rem,calc(100vw-1.5rem))] max-w-none p-0 py-1"> <PopoverContent className="w-[min(13rem,calc(100vw-1.5rem))] max-w-none p-0 py-1">

23
src/components/PostEditor/PollEditor.tsx

@ -7,18 +7,14 @@ import dayjs from 'dayjs'
import { Eraser, X } from 'lucide-react' import { Eraser, X } from 'lucide-react'
import { Dispatch, SetStateAction, useEffect, useState } from 'react' import { Dispatch, SetStateAction, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PostRelaySelector from './PostRelaySelector'
export default function PollEditor({ export default function PollEditor({
pollCreateData, pollCreateData,
setPollCreateData, setPollCreateData,
setIsPoll: _setIsPoll, setIsPoll: _setIsPoll
content = ''
}: { }: {
pollCreateData: TPollCreateData pollCreateData: TPollCreateData
setPollCreateData: Dispatch<SetStateAction<TPollCreateData>> setPollCreateData: Dispatch<SetStateAction<TPollCreateData>>
setIsPoll: Dispatch<SetStateAction<boolean>> setIsPoll: Dispatch<SetStateAction<boolean>>
content?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [isMultipleChoice, setIsMultipleChoice] = useState(pollCreateData.isMultipleChoice) const [isMultipleChoice, setIsMultipleChoice] = useState(pollCreateData.isMultipleChoice)
@ -26,15 +22,14 @@ export default function PollEditor({
const [endsAt, setEndsAt] = useState( const [endsAt, setEndsAt] = useState(
pollCreateData.endsAt ? dayjs(pollCreateData.endsAt * 1000).format('YYYY-MM-DDTHH:mm') : '' pollCreateData.endsAt ? dayjs(pollCreateData.endsAt * 1000).format('YYYY-MM-DDTHH:mm') : ''
) )
const [additionalRelayUrls, setAdditionalRelayUrls] = useState<string[]>(pollCreateData.relays)
useEffect(() => { useEffect(() => {
setPollCreateData({ setPollCreateData((prev) => ({
...prev,
isMultipleChoice, isMultipleChoice,
options, options,
endsAt: endsAt ? dayjs(endsAt).startOf('minute').unix() : undefined, endsAt: endsAt ? dayjs(endsAt).startOf('minute').unix() : undefined
relays: additionalRelayUrls }))
}) }, [isMultipleChoice, options, endsAt, setPollCreateData])
}, [isMultipleChoice, options, endsAt, additionalRelayUrls, setPollCreateData])
const handleAddOption = () => { const handleAddOption = () => {
setOptions([...options, '']) setOptions([...options, ''])
@ -110,12 +105,6 @@ export default function PollEditor({
</div> </div>
</div> </div>
<div className="space-y-2">
<PostRelaySelector
setAdditionalRelayUrls={setAdditionalRelayUrls}
content={content}
/>
</div>
</div> </div>
) )
} }

114
src/components/PostEditor/PostContent.tsx

@ -42,7 +42,6 @@ import {
ExtendedKind, ExtendedKind,
isNip71ShortVideoKind, isNip71ShortVideoKind,
isNip71StyleVideoKind, isNip71StyleVideoKind,
MAX_PUBLISH_RELAYS
} from '@/constants' } from '@/constants'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -106,10 +105,9 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import Mentions, { extractMentions } from './Mentions' import { extractMentions } from './Mentions'
import PollEditor from './PollEditor' import PollEditor from './PollEditor'
import PostOptions from './PostOptions' import PostEditorAdvancedPanel from './PostEditorAdvancedPanel'
import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import { import {
newNostrSpecAffectedKindRow, newNostrSpecAffectedKindRow,
@ -479,6 +477,13 @@ export default function PostContent({
if (isPoll) setRelayCapBlockInfo(null) if (isPoll) setRelayCapBlockInfo(null)
}, [isPoll]) }, [isPoll])
useEffect(() => {
if (!isPoll) return
setPollCreateData((prev) =>
prev.relays === additionalRelayUrls ? prev : { ...prev, relays: additionalRelayUrls }
)
}, [isPoll, additionalRelayUrls])
// Clear highlight data when initialHighlightData changes or is removed // Clear highlight data when initialHighlightData changes or is removed
useEffect(() => { useEffect(() => {
if (initialHighlightData) { if (initialHighlightData) {
@ -3086,7 +3091,9 @@ export default function PostContent({
title={t('Advanced event lab')} title={t('Advanced event lab')}
> >
<Code2 className="h-3.5 w-3.5 shrink-0" /> <Code2 className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline max-w-[9rem] truncate">{t('Advanced event lab')}</span> <span className="max-w-[9rem] truncate text-xs sm:text-sm">
{t('Advanced event lab')}
</span>
</Button> </Button>
{!parentEvent ? ( {!parentEvent ? (
<> <>
@ -3281,7 +3288,6 @@ export default function PostContent({
pollCreateData={pollCreateData} pollCreateData={pollCreateData}
setPollCreateData={setPollCreateData} setPollCreateData={setPollCreateData}
setIsPoll={setIsPoll} setIsPoll={setIsPoll}
content={text}
/> />
)} )}
{isHighlight && ( {isHighlight && (
@ -3294,24 +3300,19 @@ export default function PostContent({
{isPublicMessage && ( {isPublicMessage && (
<div className="rounded-lg border bg-muted/40 p-3"> <div className="rounded-lg border bg-muted/40 p-3">
<div className="mb-2 text-sm font-medium">{t('Recipients')}</div> <div className="mb-2 text-sm font-medium">{t('Recipients')}</div>
<div className="space-y-2">
<Mentions
content={text}
parentEvent={undefined}
mentions={extractedMentions}
setMentions={setExtractedMentions}
/>
{extractedMentions.length > 0 ? ( {extractedMentions.length > 0 ? (
<div className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('Recipients detected from your message:')} {extractedMentions.length} {t('Recipients detected from your message:')} {extractedMentions.length}
</div> {!showMoreOptions ? (
<span className="block text-xs mt-1">{t('Open Advanced to adjust mention recipients')}</span>
) : null}
</p>
) : ( ) : (
<div className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('Add recipients using nostr: mentions (e.g., nostr:npub1...) or the recipient selector above')} {t('Add recipients using nostr: mentions (e.g., nostr:npub1...) or open Advanced')}
</div> </p>
)} )}
</div> </div>
</div>
)} )}
{uploadProgresses.length > 0 && {uploadProgresses.length > 0 &&
uploadProgresses.map(({ file, progress, cancel, phase }, index) => ( uploadProgresses.map(({ file, progress, cancel, phase }, index) => (
@ -3354,43 +3355,6 @@ export default function PostContent({
</button> </button>
</div> </div>
))} ))}
{!isPoll && (
<div
className={cn(
'shrink-0',
isDiscussionThread && threadErrors.relay && 'rounded-md ring-1 ring-destructive'
)}
>
<PostRelaySelector
setAdditionalRelayUrls={setAdditionalRelayUrls}
onRelayPublishCapChange={handleRelayPublishCapChange}
parentEvent={parentEvent}
openFrom={openFrom}
content={text}
isPublicMessage={isPublicMessage}
mentions={extractedMentions}
/>
{relayCapBlockInfo && (
<p className="mt-2 text-sm text-amber-600 dark:text-amber-500" role="alert">
{relayCapBlockInfo.outboxSlotsInPublish > 0
? t('Publish relay cap hint with outbox first', {
max: MAX_PUBLISH_RELAYS,
reservedSlots: relayCapBlockInfo.outboxSlotsInPublish,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})
: t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})}
</p>
)}
{isDiscussionThread && threadErrors.relay && (
<p className="mt-1 text-sm text-destructive">{threadErrors.relay}</p>
)}
</div>
)}
{/* Hidden uploader for the "Media Note" dropdown item */} {/* Hidden uploader for the "Media Note" dropdown item */}
{!parentEvent && ( {!parentEvent && (
<Uploader <Uploader
@ -3406,8 +3370,8 @@ export default function PostContent({
<button ref={mediaUploaderBtnRef} type="button" aria-hidden="true" tabIndex={-1} /> <button ref={mediaUploaderBtnRef} type="button" aria-hidden="true" tabIndex={-1} />
</Uploader> </Uploader>
)} )}
<div className="flex flex-wrap items-center justify-between gap-2 min-w-0"> <div className="flex min-w-0 w-full items-center gap-1.5">
<div className="flex gap-2 items-center min-w-0 shrink-0"> <div className="min-w-0 flex-1 overflow-x-auto overscroll-x-contain">
<PostEditorFormatToolbar <PostEditorFormatToolbar
insertText={(txt) => textareaRef.current?.insertText(txt)} insertText={(txt) => textareaRef.current?.insertText(txt)}
insertEmoji={(em) => textareaRef.current?.insertEmoji(em)} insertEmoji={(em) => textareaRef.current?.insertEmoji(em)}
@ -3422,13 +3386,7 @@ export default function PostContent({
onToggleMoreOptions={() => setShowMoreOptions((pre) => !pre)} onToggleMoreOptions={() => setShowMoreOptions((pre) => !pre)}
/> />
</div> </div>
<div className="flex gap-2 items-center shrink-0"> <div className="flex shrink-0 items-center gap-1.5">
<Mentions
content={text}
parentEvent={parentEvent}
mentions={mentions}
setMentions={setMentions}
/>
<div className="flex gap-2 items-center max-sm:hidden"> <div className="flex gap-2 items-center max-sm:hidden">
<Button <Button
type="button" type="button"
@ -3480,15 +3438,37 @@ export default function PostContent({
</div> </div>
</div> </div>
</div> </div>
<PostOptions <PostEditorAdvancedPanel
posting={posting}
show={showMoreOptions} show={showMoreOptions}
posting={posting}
addClientTag={addClientTag} addClientTag={addClientTag}
setAddClientTag={setAddClientTag} setAddClientTag={setAddClientTag}
isNsfw={isNsfw} isNsfw={isNsfw}
setIsNsfw={setIsNsfw} setIsNsfw={setIsNsfw}
minPow={minPow} minPow={minPow}
setMinPow={setMinPow} setMinPow={setMinPow}
showMentionsPicker={!isHighlight}
mentionsContent={text}
mentionsParentEvent={isPublicMessage ? undefined : parentEvent}
mentions={isPublicMessage ? extractedMentions : mentions}
setMentions={isPublicMessage ? setExtractedMentions : setMentions}
showRelayPicker={
!isPublicationContent &&
!isCitationInternal &&
!isCitationExternal &&
!isCitationHardcopy &&
!isCitationPrompt
}
setAdditionalRelayUrls={setAdditionalRelayUrls}
onRelayPublishCapChange={handleRelayPublishCapChange}
relayParentEvent={parentEvent}
relayOpenFrom={openFrom}
relayContent={text}
relayIsPublicMessage={isPublicMessage}
relayMentions={extractedMentions}
relayCapBlockInfo={relayCapBlockInfo}
discussionThreadRelayError={threadErrors.relay}
isDiscussionThread={isDiscussionThread}
/> />
<div className="flex gap-2 items-center justify-around sm:hidden"> <div className="flex gap-2 items-center justify-around sm:hidden">
<Button <Button

189
src/components/PostEditor/PostEditorAdvancedPanel.tsx

@ -0,0 +1,189 @@
import { MAX_PUBLISH_RELAYS } from '@/constants'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import type { TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap'
import { cn } from '@/lib/utils'
import storage from '@/services/local-storage.service'
import type { Event } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Mentions from './Mentions'
import PostRelaySelector from './PostRelaySelector'
export type PostEditorAdvancedPanelProps = {
show: boolean
posting: boolean
addClientTag: boolean
setAddClientTag: Dispatch<SetStateAction<boolean>>
isNsfw: boolean
setIsNsfw: Dispatch<SetStateAction<boolean>>
minPow: number
setMinPow: Dispatch<SetStateAction<number>>
/** Relay picker + cap hints (hidden for modes that do not pick relays). */
showRelayPicker?: boolean
setAdditionalRelayUrls?: Dispatch<SetStateAction<string[]>>
onRelayPublishCapChange?: (preview: TPrePublishRelayCapPreview) => void
relayParentEvent?: Event
relayOpenFrom?: string[]
relayContent?: string
relayIsPublicMessage?: boolean
relayMentions?: string[]
relayCapBlockInfo?: {
outboxSlotsInPublish: number
selectedTotal: number
selectedContacted: number
} | null
discussionThreadRelayError?: string | null
isDiscussionThread?: boolean
/** Reply / PM mention recipient picker. */
showMentionsPicker?: boolean
mentionsContent?: string
mentionsParentEvent?: Event
mentions?: string[]
setMentions?: Dispatch<SetStateAction<string[]>>
}
export default function PostEditorAdvancedPanel({
show,
posting,
addClientTag,
setAddClientTag,
isNsfw,
setIsNsfw,
minPow,
setMinPow,
showRelayPicker = false,
setAdditionalRelayUrls,
onRelayPublishCapChange,
relayParentEvent,
relayOpenFrom,
relayContent = '',
relayIsPublicMessage = false,
relayMentions = [],
relayCapBlockInfo = null,
discussionThreadRelayError = null,
isDiscussionThread = false,
showMentionsPicker = false,
mentionsContent = '',
mentionsParentEvent,
mentions = [],
setMentions
}: PostEditorAdvancedPanelProps) {
const { t } = useTranslation()
useEffect(() => {
setAddClientTag(storage.getAddClientTag())
}, [setAddClientTag])
if (!show) return null
const onAddClientTagChange = (checked: boolean) => {
storage.setAddClientTag(checked)
setAddClientTag(checked)
}
return (
<div className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div>
<p className="text-sm font-medium">{t('Advanced')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('Post editor advanced hint')}</p>
</div>
{showMentionsPicker && setMentions ? (
<div className="space-y-2">
<Label className="text-sm font-normal">{t('Mentions')}</Label>
<Mentions
content={mentionsContent}
parentEvent={mentionsParentEvent}
mentions={mentions}
setMentions={setMentions}
compactTrigger
/>
</div>
) : null}
{showRelayPicker && setAdditionalRelayUrls ? (
<div
className={cn(
'space-y-2',
isDiscussionThread && discussionThreadRelayError && 'rounded-md ring-1 ring-destructive p-2'
)}
>
<Label className="text-sm font-normal">{t('Post to')}</Label>
<PostRelaySelector
setAdditionalRelayUrls={setAdditionalRelayUrls}
onRelayPublishCapChange={onRelayPublishCapChange}
parentEvent={relayParentEvent}
openFrom={relayOpenFrom}
content={relayContent}
isPublicMessage={relayIsPublicMessage}
mentions={relayMentions}
/>
{relayCapBlockInfo ? (
<p className="text-sm text-amber-600 dark:text-amber-500" role="alert">
{relayCapBlockInfo.outboxSlotsInPublish > 0
? t('Publish relay cap hint with outbox first', {
max: MAX_PUBLISH_RELAYS,
reservedSlots: relayCapBlockInfo.outboxSlotsInPublish,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})
: t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})}
</p>
) : null}
{isDiscussionThread && discussionThreadRelayError ? (
<p className="text-sm text-destructive">{discussionThreadRelayError}</p>
) : null}
</div>
) : null}
<div className="space-y-4 pt-1 border-t border-border">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag" className="text-sm font-normal">
{t('Add client tag')}
</Label>
<Switch
id="add-client-tag"
checked={addClientTag}
onCheckedChange={onAddClientTagChange}
disabled={posting}
/>
</div>
<p className="text-muted-foreground text-xs">{t('Show others this was sent via Imwald')}</p>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="add-nsfw-tag" className="text-sm font-normal">
{t('NSFW')}
</Label>
<Switch
id="add-nsfw-tag"
checked={isNsfw}
onCheckedChange={setIsNsfw}
disabled={posting}
/>
</div>
<div className="grid gap-2">
<Label className="text-sm font-normal">
{t('Proof of Work (difficulty {{minPow}})', { minPow })}
</Label>
<Slider
defaultValue={[0]}
value={[minPow]}
onValueChange={([pow]) => setMinPow(pow)}
max={28}
step={1}
disabled={posting}
/>
</div>
</div>
</div>
)
}

30
src/components/PostEditor/PostEditorFormatToolbar.tsx

@ -3,7 +3,7 @@ import GifPicker from '@/components/GifPicker'
import MemePicker from '@/components/MemePicker' import MemePicker from '@/components/MemePicker'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { isTouchDevice } from '@/lib/utils' import { cn, isTouchDevice } from '@/lib/utils'
import type { TEmoji } from '@/types' import type { TEmoji } from '@/types'
import { Film, ImageUp, Laugh, Mic, Settings, Smile } from 'lucide-react' import { Film, ImageUp, Laugh, Mic, Settings, Smile } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -46,8 +46,10 @@ export function PostEditorFormatToolbar({
}: PostEditorFormatToolbarProps) { }: PostEditorFormatToolbarProps) {
const { t } = useTranslation() const { t } = useTranslation()
const iconBtnClass = 'h-8 w-8 shrink-0 p-0'
return ( return (
<div className="flex flex-wrap items-center gap-2 min-w-0 shrink-0"> <div className="flex flex-nowrap items-center gap-0.5 min-w-0">
{showAudioUpload && ( {showAudioUpload && (
<Uploader <Uploader
onUploadSuccess={upload.onUploadSuccess} onUploadSuccess={upload.onUploadSuccess}
@ -63,7 +65,7 @@ export function PostEditorFormatToolbar({
variant="ghost" variant="ghost"
size="icon" size="icon"
title={audioUploadTitle} title={audioUploadTitle}
className={audioButtonHighlighted ? 'bg-accent' : ''} className={cn(iconBtnClass, audioButtonHighlighted && 'bg-accent')}
> >
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
</Button> </Button>
@ -78,11 +80,11 @@ export function PostEditorFormatToolbar({
onUploadCompressProgress={upload.onUploadCompressProgress} onUploadCompressProgress={upload.onUploadCompressProgress}
accept="image/*" accept="image/*"
> >
<Button type="button" variant="ghost" size="icon" title={t('Upload Image')}> <Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Upload Image')}>
<ImageUp /> <ImageUp />
</Button> </Button>
</Uploader> </Uploader>
<Separator orientation="vertical" className="h-6 shrink-0" /> <Separator orientation="vertical" className="mx-0.5 h-5 shrink-0 max-sm:hidden" />
{!isTouchDevice() && ( {!isTouchDevice() && (
<EmojiPickerDialog <EmojiPickerDialog
onEmojiClick={(emoji) => { onEmojiClick={(emoji) => {
@ -90,29 +92,33 @@ export function PostEditorFormatToolbar({
insertEmoji(emoji) insertEmoji(emoji)
}} }}
> >
<Button type="button" variant="ghost" size="icon" title={t('Insert emoji')}> <Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert emoji')}>
<Smile /> <Smile />
</Button> </Button>
</EmojiPickerDialog> </EmojiPickerDialog>
)} )}
<GifPicker onSelect={(gifUrl) => insertText(gifUrl)}> <GifPicker onSelect={(gifUrl) => insertText(gifUrl)}>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}> <Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert GIF')}>
<Film className="h-4 w-4" /> <Film className="h-4 w-4" />
</Button> </Button>
</GifPicker> </GifPicker>
<MemePicker onSelect={(memeUrl) => insertText(memeUrl)}> <MemePicker onSelect={(memeUrl) => insertText(memeUrl)}>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}> <Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert meme')}>
<Laugh className="h-4 w-4" /> <Laugh className="h-4 w-4" />
</Button> </Button>
</MemePicker> </MemePicker>
<Separator orientation="vertical" className="h-6 shrink-0" /> <Separator orientation="vertical" className="mx-0.5 h-5 shrink-0 max-sm:hidden" />
<MentionAndEventToolbarButtons insertAtCursor={insertText} variant="ghost" /> <MentionAndEventToolbarButtons
insertAtCursor={insertText}
variant="ghost"
buttonClassName={iconBtnClass}
/>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
title={t('More options')} title={t('Advanced')}
className={showMoreOptions ? 'bg-accent' : ''} className={cn(iconBtnClass, showMoreOptions && 'bg-accent')}
onClick={onToggleMoreOptions} onClick={onToggleMoreOptions}
> >
<Settings /> <Settings />

84
src/components/PostEditor/PostOptions.tsx

@ -1,84 +0,0 @@
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import storage from '@/services/local-storage.service'
import { Dispatch, SetStateAction, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
export default function PostOptions({
posting,
show,
addClientTag,
setAddClientTag,
isNsfw,
setIsNsfw,
minPow,
setMinPow
}: {
posting: boolean
show: boolean
addClientTag: boolean
setAddClientTag: Dispatch<SetStateAction<boolean>>
isNsfw: boolean
setIsNsfw: Dispatch<SetStateAction<boolean>>
minPow: number
setMinPow: Dispatch<SetStateAction<number>>
}) {
const { t } = useTranslation()
useEffect(() => {
setAddClientTag(storage.getAddClientTag())
}, [])
if (!show) return null
const onAddClientTagChange = (checked: boolean) => {
storage.setAddClientTag(checked)
setAddClientTag(checked)
}
const onNsfwChange = (checked: boolean) => {
setIsNsfw(checked)
}
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
<Switch
id="add-client-tag"
checked={addClientTag}
onCheckedChange={onAddClientTagChange}
disabled={posting}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('Show others this was sent via Imwald')}
</div>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="add-nsfw-tag">{t('NSFW')}</Label>
<Switch
id="add-nsfw-tag"
checked={isNsfw}
onCheckedChange={onNsfwChange}
disabled={posting}
/>
</div>
<div className="grid gap-4 pb-4">
<Label>{t('Proof of Work (difficulty {{minPow}})', { minPow })}</Label>
<Slider
defaultValue={[0]}
value={[minPow]}
onValueChange={([pow]) => setMinPow(pow)}
max={28}
step={1}
disabled={posting}
/>
</div>
</div>
)
}

2
src/components/PostEditor/PostTextarea/index.tsx

@ -282,7 +282,7 @@ const PostTextarea = forwardRef<
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{headerActions && ( {headerActions && (
<div className="flex gap-1 items-center flex-wrap"> <div className="flex min-w-0 flex-nowrap items-center justify-end gap-1 overflow-x-auto overscroll-x-contain">
{headerActions} {headerActions}
</div> </div>
)} )}

2
src/components/Sidebar/index.tsx

@ -11,7 +11,6 @@ import SearchButton from './SearchButton'
import FavoritesButton from './FavoritesButton' import FavoritesButton from './FavoritesButton'
import DiscussionsButton from './DiscussionsButton' import DiscussionsButton from './DiscussionsButton'
import SpellsButton from './SpellsButton' import SpellsButton from './SpellsButton'
import { ConnectedRelaysSidebarStrip } from '@/components/ConnectedRelays/ConnectedRelaysSidebarStrip'
import PaneModeToggle from './PaneModeToggle' import PaneModeToggle from './PaneModeToggle'
import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton' import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton'
import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' import LiveActivitiesStrip from '@/components/LiveActivitiesStrip'
@ -47,7 +46,6 @@ export default function PrimaryPageSidebar() {
<DiscussionsButton /> <DiscussionsButton />
<SpellsButton /> <SpellsButton />
<RssButton /> <RssButton />
<ConnectedRelaysSidebarStrip />
<PostButton /> <PostButton />
<div className="max-xl:hidden w-full min-w-0 space-y-2 px-1"> <div className="max-xl:hidden w-full min-w-0 space-y-2 px-1">
<LiveActivitiesStrip placement="sidebar" /> <LiveActivitiesStrip placement="sidebar" />

9
src/i18n/locales/de.ts

@ -69,7 +69,7 @@ export default {
'load more older replies': 'ältere Antworten laden', 'load more older replies': 'ältere Antworten laden',
'Write something...': 'Schreibe etwas...', 'Write something...': 'Schreibe etwas...',
Cancel: 'Abbrechen', Cancel: 'Abbrechen',
Mentions: '@', Mentions: 'Erwähnungen',
'Search for event or address…': 'Nach Event oder Adresse suchen…', 'Search for event or address…': 'Nach Event oder Adresse suchen…',
'Search notes…': 'Notizen suchen…', 'Search notes…': 'Notizen suchen…',
'No notes found': 'Keine Notizen gefunden', 'No notes found': 'Keine Notizen gefunden',
@ -765,6 +765,13 @@ export default {
'Wenn an, listet der Hinweis jedes Relay (angenommen, fehlgeschlagen, Fehlertext). Wenn aus, nur eine kurze Zusammenfassung.', 'Wenn an, listet der Hinweis jedes Relay (angenommen, fehlgeschlagen, Fehlertext). Wenn aus, nur eine kurze Zusammenfassung.',
'Publishing feedback errors note': 'Publishing feedback errors note':
'Fehlgeschlagene Veröffentlichungen und andere Fehler zeigen immer einen Hinweis — mit Kurzfassung oder Pro-Relay-Aufschlüsselung wie oben.', 'Fehlgeschlagene Veröffentlichungen und andere Fehler zeigen immer einen Hinweis — mit Kurzfassung oder Pro-Relay-Aufschlüsselung wie oben.',
Advanced: 'Erweitert',
'Post editor advanced hint':
'Relay-Ziele, Erwähnungen, Client-Tag, NSFW und Proof-of-Work.',
'Open Advanced to adjust mention recipients':
'Öffne Erweitert, um Empfänger anzupassen.',
'Add recipients using nostr: mentions (e.g., nostr:npub1...) or open Advanced':
'Erwähne nostr:npub… oder nostr:nevent… im Text oder öffne Erweitert für die Empfängerauswahl.',
'Publish successful': 'Veröffentlichung erfolgreich', 'Publish successful': 'Veröffentlichung erfolgreich',
'Media upload service': 'Medien-Upload-Service', 'Media upload service': 'Medien-Upload-Service',
BlossomUploadYourListOption: 'Blossom (eigene Liste)', BlossomUploadYourListOption: 'Blossom (eigene Liste)',

4
src/i18n/locales/en.ts

@ -293,6 +293,10 @@ export default {
Connections: 'Connections', Connections: 'Connections',
Calls: 'Calls', Calls: 'Calls',
Advanced: 'Advanced', Advanced: 'Advanced',
'Post editor advanced hint': 'Relay targets, mention recipients, client tag, NSFW, and proof of work.',
'Open Advanced to adjust mention recipients': 'Open Advanced to adjust who receives this message.',
'Add recipients using nostr: mentions (e.g., nostr:npub1...) or open Advanced':
'Add nostr:npub… or nostr:nevent… mentions in the text, or open Advanced to pick recipients.',
'Share with Imwald': 'Share with Imwald', 'Share with Imwald': 'Share with Imwald',
'Share with Alexandria': 'Share with Alexandria', 'Share with Alexandria': 'Share with Alexandria',
'Start video call': 'Start video call', 'Start video call': 'Start video call',

Loading…
Cancel
Save