Browse Source

Improved accessibility and navigation

imwald
Silberengel 5 months ago
parent
commit
d2c87b428e
  1. 7
      src/App.tsx
  2. 27
      src/components/ArticleExportMenu/ArticleExportMenu.tsx
  3. 18
      src/components/Note/DiscussionContent/index.tsx
  4. 83
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 5
      src/components/Note/index.tsx
  6. 77
      src/components/ParentNotePreview/index.tsx
  7. 6
      src/components/Tabs/index.tsx
  8. 6
      src/components/UserAvatar/index.tsx
  9. 6
      src/components/Username/index.tsx
  10. 7
      src/constants.ts
  11. 48
      src/index.css
  12. 25
      src/lib/discussion-topics.ts
  13. 39
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  14. 32
      src/pages/primary/DiscussionsPage/ThreadCard.tsx
  15. 19
      src/pages/secondary/GeneralSettingsPage/index.tsx
  16. 54
      src/providers/FontSizeProvider.tsx
  17. 13
      src/services/local-storage.service.ts
  18. 1
      src/types/index.d.ts

7
src/App.tsx

@ -7,6 +7,7 @@ import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider' import { FeedProvider } from '@/providers/FeedProvider'
import { FontSizeProvider } from '@/providers/FontSizeProvider'
import { FollowListProvider } from '@/providers/FollowListProvider' import { FollowListProvider } from '@/providers/FollowListProvider'
import { GroupListProvider } from '@/providers/GroupListProvider' import { GroupListProvider } from '@/providers/GroupListProvider'
import { InterestListProvider } from '@/providers/InterestListProvider' import { InterestListProvider } from '@/providers/InterestListProvider'
@ -26,8 +27,9 @@ import { PageManager } from './PageManager'
export default function App(): JSX.Element { export default function App(): JSX.Element {
return ( return (
<ThemeProvider> <ThemeProvider>
<ContentPolicyProvider> <FontSizeProvider>
<ScreenSizeProvider> <ContentPolicyProvider>
<ScreenSizeProvider>
<DeletedEventProvider> <DeletedEventProvider>
<NostrProvider> <NostrProvider>
<ZapProvider> <ZapProvider>
@ -64,6 +66,7 @@ export default function App(): JSX.Element {
</DeletedEventProvider> </DeletedEventProvider>
</ScreenSizeProvider> </ScreenSizeProvider>
</ContentPolicyProvider> </ContentPolicyProvider>
</FontSizeProvider>
</ThemeProvider> </ThemeProvider>
) )
} }

27
src/components/ArticleExportMenu/ArticleExportMenu.tsx

@ -7,7 +7,8 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { MoreVertical, FileDown } from 'lucide-react' import { MoreVertical, FileDown } from 'lucide-react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { Event } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants'
interface ArticleExportMenuProps { interface ArticleExportMenuProps {
event: Event event: Event
@ -15,13 +16,23 @@ interface ArticleExportMenuProps {
} }
export default function ArticleExportMenu({ event, title }: ArticleExportMenuProps) { export default function ArticleExportMenu({ event, title }: ArticleExportMenuProps) {
// Determine export format based on event kind
const getExportFormat = () => {
if (event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
return { extension: 'md', mimeType: 'text/markdown', label: 'Markdown' }
}
// For 30818, 30041, 30040 - use AsciiDoc
return { extension: 'adoc', mimeType: 'text/plain', label: 'AsciiDoc' }
}
const exportArticle = async () => { const exportArticle = async () => {
try { try {
const content = event.content const content = event.content
const filename = `${title}.adoc` const format = getExportFormat()
const filename = `${title}.${format.extension}`
// Export raw AsciiDoc content // Export raw content
const blob = new Blob([content], { type: 'text/plain' }) const blob = new Blob([content], { type: format.mimeType })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
@ -32,24 +43,26 @@ export default function ArticleExportMenu({ event, title }: ArticleExportMenuPro
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
logger.info('[ArticleExportMenu] Exported article as .adoc') logger.info(`[ArticleExportMenu] Exported article as .${format.extension}`)
} catch (error) { } catch (error) {
logger.error('[ArticleExportMenu] Error exporting article:', error) logger.error('[ArticleExportMenu] Error exporting article:', error)
alert('Failed to export article. Please try again.') alert('Failed to export article. Please try again.')
} }
} }
const format = getExportFormat()
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="shrink-0"> <Button variant="ghost" size="icon" className="shrink-0" aria-label="Export article">
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}> <DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={exportArticle}> <DropdownMenuItem onClick={exportArticle}>
<FileDown className="mr-2 h-4 w-4" /> <FileDown className="mr-2 h-4 w-4" />
Export as AsciiDoc Export as {format.label}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

18
src/components/Note/DiscussionContent/index.tsx

@ -1,18 +0,0 @@
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx'
import { cn } from '@/lib/utils'
export default function DiscussionContent({
event,
className
}: {
event: Event
className?: string
}) {
const parsedContent = useMemo(() => {
return parseNostrContent(event.content, event)
}, [event.content, event])
return renderNostrContent(parsedContent, cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full', className))
}

83
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -402,6 +402,7 @@ export default function MarkdownArticle({
.hljs { .hljs {
background: transparent !important; background: transparent !important;
} }
/* Light theme syntax highlighting */
.hljs-keyword, .hljs-keyword,
.hljs-selector-tag, .hljs-selector-tag,
.hljs-literal, .hljs-literal,
@ -411,7 +412,7 @@ export default function MarkdownArticle({
.hljs-type, .hljs-type,
.hljs-name, .hljs-name,
.hljs-strong { .hljs-strong {
color: #f85149 !important; color: #dc2626 !important; /* red-600 - good contrast on light */
font-weight: bold !important; font-weight: bold !important;
} }
.hljs-string, .hljs-string,
@ -426,40 +427,100 @@ export default function MarkdownArticle({
.hljs-selector-attr, .hljs-selector-attr,
.hljs-selector-class, .hljs-selector-class,
.hljs-selector-id { .hljs-selector-id {
color: #0366d6 !important; color: #0284c7 !important; /* sky-600 */
} }
.hljs-comment, .hljs-comment,
.hljs-quote { .hljs-quote {
color: #8b949e !important; color: #6b7280 !important; /* gray-500 */
} }
.hljs-number, .hljs-number,
.hljs-deletion { .hljs-deletion {
color: #005cc5 !important; color: #0d9488 !important; /* teal-600 */
} }
.hljs-variable, .hljs-variable,
.hljs-template-variable, .hljs-template-variable,
.hljs-link { .hljs-link {
color: #e36209 !important; color: #ea580c !important; /* orange-600 */
} }
.hljs-meta { .hljs-meta {
color: #6f42c1 !important; color: #7c3aed !important; /* violet-600 */
} }
.hljs-built_in, .hljs-built_in,
.hljs-class .hljs-title { .hljs-class .hljs-title {
color: #005cc5 !important; color: #0d9488 !important; /* teal-600 */
} }
.hljs-params { .hljs-params {
color: #f0f6fc !important; color: #1f2937 !important; /* gray-800 */
} }
.hljs-attribute { .hljs-attribute {
color: #005cc5 !important; color: #0d9488 !important; /* teal-600 */
} }
.hljs-function .hljs-title { .hljs-function .hljs-title {
color: #6f42c1 !important; color: #7c3aed !important; /* violet-600 */
} }
.hljs-subst { .hljs-subst {
color: #f0f6fc !important; color: #1f2937 !important; /* gray-800 */
} }
/* Dark theme syntax highlighting */
.dark .hljs-keyword,
.dark .hljs-selector-tag,
.dark .hljs-literal,
.dark .hljs-title,
.dark .hljs-section,
.dark .hljs-doctag,
.dark .hljs-type,
.dark .hljs-name,
.dark .hljs-strong {
color: #f87171 !important; /* red-400 */
}
.dark .hljs-string,
.dark .hljs-title.class_,
.dark .hljs-attr,
.dark .hljs-symbol,
.dark .hljs-bullet,
.dark .hljs-addition,
.dark .hljs-code,
.dark .hljs-regexp,
.dark .hljs-selector-pseudo,
.dark .hljs-selector-attr,
.dark .hljs-selector-class,
.dark .hljs-selector-id {
color: #38bdf8 !important; /* sky-400 */
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #9ca3af !important; /* gray-400 */
}
.dark .hljs-number,
.dark .hljs-deletion {
color: #5eead4 !important; /* teal-300 */
}
.dark .hljs-variable,
.dark .hljs-template-variable,
.dark .hljs-link {
color: #fb923c !important; /* orange-400 */
}
.dark .hljs-meta {
color: #a78bfa !important; /* violet-400 */
}
.dark .hljs-built_in,
.dark .hljs-class .hljs-title {
color: #5eead4 !important; /* teal-300 */
}
.dark .hljs-params {
color: #e5e7eb !important; /* gray-200 */
}
.dark .hljs-attribute {
color: #5eead4 !important; /* teal-300 */
}
.dark .hljs-function .hljs-title {
color: #a78bfa !important; /* violet-400 */
}
.dark .hljs-subst {
color: #e5e7eb !important; /* gray-200 */
}
.hljs-emphasis { .hljs-emphasis {
font-style: italic; font-style: italic;
} }

5
src/components/Note/index.tsx

@ -20,7 +20,6 @@ import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
import { MessageSquare } from 'lucide-react' import { MessageSquare } from 'lucide-react'
import CommunityDefinition from './CommunityDefinition' import CommunityDefinition from './CommunityDefinition'
import DiscussionContent from './DiscussionContent'
import GroupMetadata from './GroupMetadata' import GroupMetadata from './GroupMetadata'
import Highlight from './Highlight' import Highlight from './Highlight'
@ -142,7 +141,7 @@ export default function Note({
content = ( content = (
<> <>
<h3 className="mt-2 text-lg font-semibold leading-tight break-words">{title}</h3> <h3 className="mt-2 text-lg font-semibold leading-tight break-words">{title}</h3>
<DiscussionContent className="mt-2" event={event} /> <MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
</> </>
) )
} else if (event.kind === ExtendedKind.POLL) { } else if (event.kind === ExtendedKind.POLL) {
@ -183,7 +182,7 @@ export default function Note({
onClick={(e) => { onClick={(e) => {
// Don't navigate if clicking on interactive elements // Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]')) { if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]') || target.closest('[data-user-avatar]') || target.closest('[data-username]')) {
return return
} }
navigateToNote(toNote(event)) navigateToNote(toNote(event))

77
src/components/ParentNotePreview/index.tsx

@ -1,7 +1,11 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { useFetchEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import { Event, nip19 } from 'nostr-tools'
import ContentPreview from '../ContentPreview' import ContentPreview from '../ContentPreview'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
@ -16,8 +20,58 @@ export default function ParentNotePreview({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(eventId) const { event, isFetching } = useFetchEvent(eventId)
const [fallbackEvent, setFallbackEvent] = useState<Event | undefined>(undefined)
const [isFetchingFallback, setIsFetchingFallback] = useState(false)
if (isFetching) { // Helper function to decode event ID
const getHexEventId = (id: string): string | null => {
if (/^[0-9a-f]{64}$/.test(id)) {
return id
}
try {
const { type, data } = nip19.decode(id)
if (type === 'note') {
return data
} else if (type === 'nevent') {
return data.id
}
// Can't fetch naddr with fetchEventWithExternalRelays
return null
} catch (err) {
// Invalid bech32 or already hex
return null
}
}
// Helper function to fetch from searchable relays
const fetchFromSearchableRelays = useCallback(async () => {
const hexEventId = getHexEventId(eventId)
if (!hexEventId) return
setIsFetchingFallback(true)
try {
const foundEvent = await client.fetchEventWithExternalRelays(hexEventId, SEARCHABLE_RELAY_URLS)
if (foundEvent) {
setFallbackEvent(foundEvent)
}
} catch (error) {
console.warn('Fallback fetch from searchable relays failed:', error)
} finally {
setIsFetchingFallback(false)
}
}, [eventId])
// If the initial fetch fails, try fetching from searchable relays automatically
useEffect(() => {
if (!isFetching && !event && !fallbackEvent && !isFetchingFallback && eventId) {
fetchFromSearchableRelays()
}
}, [isFetching, event, eventId, fallbackEvent, isFetchingFallback, fetchFromSearchableRelays])
const finalEvent = event || fallbackEvent
const finalIsFetching = isFetching || isFetchingFallback
if (finalIsFetching) {
return ( return (
<div <div
data-parent-note-preview data-parent-note-preview
@ -35,19 +89,32 @@ export default function ParentNotePreview({
) )
} }
// Handle click for retry when event not found
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (finalEvent) {
onClick?.(e)
} else if (!finalEvent && !finalIsFetching && eventId) {
// Retry fetch from searchable relays when clicking "Note not found"
e.stopPropagation()
fetchFromSearchableRelays()
}
}
return ( return (
<div <div
data-parent-note-preview data-parent-note-preview
className={cn( className={cn(
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground', 'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground',
event && 'hover:text-foreground cursor-pointer', (finalEvent || (!finalEvent && !finalIsFetching)) && 'hover:text-foreground cursor-pointer',
className className
)} )}
onClick={event ? onClick : undefined} onClick={handleClick}
> >
<div className="shrink-0">{t('reply to')}</div> <div className="shrink-0">{t('reply to')}</div>
{event && <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />} {finalEvent && <UserAvatar className="shrink-0" userId={finalEvent.pubkey} size="tiny" />}
<ContentPreview className="truncate" event={event} /> <div className="truncate flex-1 min-w-0">
<ContentPreview className="pointer-events-none" event={finalEvent} />
</div>
</div> </div>
) )
} }

6
src/components/Tabs/index.tsx

@ -111,14 +111,14 @@ export default function Tabs({
deepBrowsing && lastScrollTop > threshold ? '-translate-y-[calc(100%+12rem)]' : '' deepBrowsing && lastScrollTop > threshold ? '-translate-y-[calc(100%+12rem)]' : ''
)} )}
> >
<div className="flex-1 w-0 overflow-hidden"> <div className="flex-1 w-0 overflow-x-auto scrollbar-hide">
<div className="flex w-fit relative"> <div className="flex w-fit relative min-w-full">
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<div <div
key={tab.value} key={tab.value}
ref={(el) => (tabRefs.current[index] = el)} ref={(el) => (tabRefs.current[index] = el)}
className={cn( className={cn(
`w-fit text-center py-2 px-6 my-1 font-semibold whitespace-nowrap clickable cursor-pointer rounded-lg`, `w-fit text-center py-2 px-6 my-1 font-semibold whitespace-nowrap clickable cursor-pointer rounded-lg shrink-0`,
value === tab.value ? '' : 'text-muted-foreground' value === tab.value ? '' : 'text-muted-foreground'
)} )}
onClick={() => { onClick={() => {

6
src/components/UserAvatar/index.tsx

@ -44,8 +44,12 @@ export default function UserAvatar({
return ( return (
<Avatar <Avatar
data-user-avatar
className={cn('shrink-0 cursor-pointer', UserAvatarSizeCnMap[size], className)} className={cn('shrink-0 cursor-pointer', UserAvatarSizeCnMap[size], className)}
onClick={() => navigateToProfile(toProfile(pubkey))} onClick={(e) => {
e.stopPropagation()
navigateToProfile(toProfile(pubkey))
}}
> >
<AvatarImage src={avatar} className="object-cover object-center" /> <AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback> <AvatarFallback>

6
src/components/Username/index.tsx

@ -38,9 +38,13 @@ export default function Username({
return ( return (
<span <span
data-username
className={cn('truncate hover:underline cursor-pointer', className)} className={cn('truncate hover:underline cursor-pointer', className)}
style={{ verticalAlign: 'baseline', ...style }} style={{ verticalAlign: 'baseline', ...style }}
onClick={() => navigateToProfile(toProfile(pubkey))} onClick={(e) => {
e.stopPropagation()
navigateToProfile(toProfile(pubkey))
}}
> >
{showAt && '@'} {showAt && '@'}
{username} {username}

7
src/constants.ts

@ -19,6 +19,7 @@ export const RECOMMENDED_BLOSSOM_SERVERS = [
export const StorageKey = { export const StorageKey = {
VERSION: 'version', VERSION: 'version',
THEME_SETTING: 'themeSetting', THEME_SETTING: 'themeSetting',
FONT_SIZE: 'fontSize',
RELAY_SETS: 'relaySets', RELAY_SETS: 'relaySets',
ACCOUNTS: 'accounts', ACCOUNTS: 'accounts',
CURRENT_ACCOUNT: 'currentAccount', CURRENT_ACCOUNT: 'currentAccount',
@ -63,6 +64,12 @@ export const StorageKey = {
FEED_TYPE: 'feedType' // deprecated FEED_TYPE: 'feedType' // deprecated
} }
export const FONT_SIZE = {
SMALL: 'small',
MEDIUM: 'medium',
LARGE: 'large'
} as const
export const ApplicationDataKey = { export const ApplicationDataKey = {
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at' NOTIFICATIONS_SEEN_AT: 'seen_notifications_at'
} }

48
src/index.css

@ -91,12 +91,12 @@
--secondary: 240 4.8% 94%; --secondary: 240 4.8% 94%;
--secondary-foreground: 240 5.9% 10%; --secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 94%; --muted: 240 4.8% 94%;
--muted-foreground: 240 3.8% 46.1%; --muted-foreground: 240 5% 35%;
--accent: 240 4.8% 94%; --accent: 240 4.8% 94%;
--accent-foreground: 240 5.9% 10%; --accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%; --border: 240 5.9% 85%;
--input: 240 5.9% 90%; --input: 240 5.9% 90%;
--ring: 140 70% 28%; --ring: 140 70% 28%;
--chart-1: 12 76% 61%; --chart-1: 12 76% 61%;
@ -120,12 +120,12 @@
--secondary: 240 3.7% 15.9%; --secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%; --muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%; --muted-foreground: 240 5% 75%;
--accent: 240 3.7% 15.9%; --accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%; --border: 240 3.7% 25%;
--input: 240 3.7% 15.9%; --input: 240 3.7% 15.9%;
--ring: 140 70% 40%; --ring: 140 70% 40%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
@ -138,6 +138,46 @@
.dark input[type='datetime-local']::-webkit-calendar-picker-indicator { .dark input[type='datetime-local']::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.5); filter: invert(1) brightness(1.5);
} }
/* Focus indicators for accessibility */
*:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
border-radius: 2px;
}
/* Ensure proper contrast for interactive elements */
button:not(:disabled),
a,
[role="button"] {
transition: opacity 0.2s;
}
button:disabled,
[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
}
/* Font Size Adjustments */
.font-size-small {
font-size: 87.5% !important; /* 14px base */
}
.font-size-medium {
font-size: 100% !important; /* 16px base */
}
.font-size-large {
font-size: 112.5% !important; /* 18px base */
}
/* Apply font size to content areas */
.font-size-small .prose,
.font-size-medium .prose,
.font-size-large .prose {
font-size: var(--content-font-size, 1rem);
} }
/* AsciiDoc Table of Contents Styling */ /* AsciiDoc Table of Contents Styling */

25
src/lib/discussion-topics.ts

@ -276,19 +276,24 @@ export function buildGroupDisplayName(
groupId: string, groupId: string,
groupRelay: string | null groupRelay: string | null
): string { ): string {
let displayName: string
if (!groupRelay) { if (!groupRelay) {
return groupId displayName = groupId
} else {
// Extract hostname from relay URL for cleaner display
try {
const url = new URL(groupRelay)
const hostname = url.hostname
displayName = `${hostname}'${groupId}`
} catch {
// Fallback to full relay URL if parsing fails
displayName = `${groupRelay}'${groupId}`
}
} }
// Extract hostname from relay URL for cleaner display // Truncate to 20 characters
try { return displayName.length > 20 ? displayName.substring(0, 20) : displayName
const url = new URL(groupRelay)
const hostname = url.hostname
return `${hostname}'${groupId}`
} catch {
// Fallback to full relay URL if parsing fails
return `${groupRelay}'${groupId}`
}
} }
/** /**

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

@ -24,7 +24,7 @@ import { simplifyUrl } from '@/lib/url'
import relaySelectionService from '@/services/relay-selection.service' import relaySelectionService from '@/services/relay-selection.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics'
import DiscussionContent from '@/components/Note/DiscussionContent' import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import RelayIcon from '@/components/RelayIcon' import RelayIcon from '@/components/RelayIcon'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -711,25 +711,24 @@ export default function CreateThreadDialog({
</div> </div>
</div> </div>
{/* Preview of the content */} {/* Preview of the content */}
<div className="prose prose-sm max-w-none dark:prose-invert"> <MarkdownArticle
<DiscussionContent event={{
event={{ id: 'preview',
id: 'preview', pubkey: pubkey || '',
pubkey: pubkey || '', created_at: Math.floor(Date.now() / 1000),
created_at: Math.floor(Date.now() / 1000), kind: 11,
kind: 11, tags: [
tags: [ ['title', title],
['title', title], ['t', selectedTopic],
['t', selectedTopic], ...(isReadingGroup ? [['t', 'readings']] : []),
...(isReadingGroup ? [['t', 'readings']] : []), ...(author ? [['author', author]] : []),
...(author ? [['author', author]] : []), ...(subject ? [['subject', subject]] : [])
...(subject ? [['subject', subject]] : []) ],
], content: content,
content: content, sig: ''
sig: '' }}
}} hideMetadata={true}
/> />
</div>
</div> </div>
) : ( ) : (
<div className="text-center text-muted-foreground py-8"> <div className="text-center text-muted-foreground py-8">

32
src/pages/primary/DiscussionsPage/ThreadCard.tsx

@ -93,17 +93,11 @@ export default function ThreadCard({
<h3 className="font-semibold text-lg leading-tight line-clamp-2 mb-2 break-words"> <h3 className="font-semibold text-lg leading-tight line-clamp-2 mb-2 break-words">
{title} {title}
</h3> </h3>
<div className="flex items-center flex-wrap gap-2 text-sm text-muted-foreground"> <div className="flex items-center flex-wrap gap-2 text-sm text-muted-foreground mb-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<topicInfo.icon className="w-4 h-4" /> <topicInfo.icon className="w-4 h-4" />
<span className="text-xs">{topicInfo.id}</span> <span className="text-xs">{topicInfo.id}</span>
</div> </div>
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && (
<Badge variant="outline" className="text-xs">
<Users className="w-3 h-3 mr-1" />
{groupInfo.groupDisplayName}
</Badge>
)}
{allTopics.slice(0, 3).map(topic => ( {allTopics.slice(0, 3).map(topic => (
<Badge key={topic} variant="outline" className="text-xs"> <Badge key={topic} variant="outline" className="text-xs">
<Hash className="w-3 h-3 mr-1" /> <Hash className="w-3 h-3 mr-1" />
@ -111,6 +105,14 @@ export default function ThreadCard({
</Badge> </Badge>
))} ))}
</div> </div>
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && (
<div className="mb-2">
<Badge variant="outline" className="text-xs">
<Users className="w-3 h-3 mr-1" />
{groupInfo.groupDisplayName}
</Badge>
</div>
)}
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
@ -146,17 +148,11 @@ export default function ThreadCard({
{title} {title}
</h3> </h3>
</div> </div>
<div className="flex items-center flex-wrap gap-2 text-sm text-muted-foreground"> <div className="flex items-center flex-wrap gap-2 text-sm text-muted-foreground mb-2">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<topicInfo.icon className="w-3 h-3 mr-1" /> <topicInfo.icon className="w-3 h-3 mr-1" />
{topicInfo.label} {topicInfo.label}
</Badge> </Badge>
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && (
<Badge variant="outline" className="text-xs">
<Users className="w-3 h-3 mr-1" />
{groupInfo.groupDisplayName}
</Badge>
)}
{allTopics.slice(0, 3).map(topic => ( {allTopics.slice(0, 3).map(topic => (
<Badge key={topic} variant="outline" className="text-xs"> <Badge key={topic} variant="outline" className="text-xs">
<Hash className="w-3 h-3 mr-1" /> <Hash className="w-3 h-3 mr-1" />
@ -173,6 +169,14 @@ export default function ThreadCard({
{t('last updated')}: {lastCommentAgo || lastVoteAgo || timeAgo} {t('last updated')}: {lastCommentAgo || lastVoteAgo || timeAgo}
</div> </div>
</div> </div>
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && (
<div className="mb-2">
<Badge variant="outline" className="text-xs">
<Users className="w-3 h-3 mr-1" />
{groupInfo.groupDisplayName}
</Badge>
</div>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground shrink-0"> <div className="flex items-center gap-2 text-sm text-muted-foreground shrink-0">

19
src/pages/secondary/GeneralSettingsPage/index.tsx

@ -1,11 +1,12 @@
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE } from '@/constants' import { FONT_SIZE, MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE } from '@/constants'
import { LocalizedLanguageNames, TLanguage } from '@/i18n' import { LocalizedLanguageNames, TLanguage } from '@/i18n'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { cn, isSupportCheckConnectionType } from '@/lib/utils' import { cn, isSupportCheckConnectionType } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useFontSize } from '@/providers/FontSizeProvider'
import { useTheme } from '@/providers/ThemeProvider' import { useTheme } from '@/providers/ThemeProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
@ -19,6 +20,7 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage) const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
const { themeSetting, setThemeSetting } = useTheme() const { themeSetting, setThemeSetting } = useTheme()
const { fontSize, setFontSize } = useFontSize()
const { const {
autoplay, autoplay,
setAutoplay, setAutoplay,
@ -72,6 +74,21 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
</SelectContent> </SelectContent>
</Select> </Select>
</SettingItem> </SettingItem>
<SettingItem>
<Label htmlFor="font-size" className="text-base font-normal">
{t('Font size')}
</Label>
<Select defaultValue={FONT_SIZE.MEDIUM} value={fontSize} onValueChange={setFontSize}>
<SelectTrigger id="font-size" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={FONT_SIZE.SMALL}>{t('Small')}</SelectItem>
<SelectItem value={FONT_SIZE.MEDIUM}>{t('Medium')}</SelectItem>
<SelectItem value={FONT_SIZE.LARGE}>{t('Large')}</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem> <SettingItem>
<Label htmlFor="notification-list-style" className="text-base font-normal"> <Label htmlFor="notification-list-style" className="text-base font-normal">
<div>{t('Notification list style')}</div> <div>{t('Notification list style')}</div>

54
src/providers/FontSizeProvider.tsx

@ -0,0 +1,54 @@
import { createContext, useContext, useEffect, useState } from 'react'
import storage from '@/services/local-storage.service'
import { TFontSize } from '@/types'
type FontSizeContextType = {
fontSize: TFontSize
setFontSize: (fontSize: TFontSize) => void
}
const FontSizeContext = createContext<FontSizeContextType | undefined>(undefined)
export const useFontSize = () => {
const context = useContext(FontSizeContext)
if (!context) {
throw new Error('useFontSize must be used within a FontSizeProvider')
}
return context
}
export function FontSizeProvider({ children }: { children: React.ReactNode }) {
const [fontSize, setFontSizeState] = useState<TFontSize>(storage.getFontSize())
useEffect(() => {
// Apply font size to CSS root
const root = document.documentElement
// Remove old font size classes
root.classList.remove('font-size-small', 'font-size-medium', 'font-size-large')
// Add new font size class
root.classList.add(`font-size-${fontSize}`)
// Also set CSS variable for content font size
const sizes = {
small: '0.875rem',
medium: '1rem',
large: '1.125rem'
}
root.style.setProperty('--content-font-size', sizes[fontSize])
}, [fontSize])
const setFontSize = (newFontSize: TFontSize) => {
storage.setFontSize(newFontSize)
setFontSizeState(newFontSize)
}
return (
<FontSizeContext.Provider value={{ fontSize, setFontSize }}>
{children}
</FontSizeContext.Provider>
)
}

13
src/services/local-storage.service.ts

@ -13,6 +13,7 @@ import {
TAccount, TAccount,
TAccountPointer, TAccountPointer,
TFeedInfo, TFeedInfo,
TFontSize,
TMediaAutoLoadPolicy, TMediaAutoLoadPolicy,
TMediaUploadServiceConfig, TMediaUploadServiceConfig,
TNoteListMode, TNoteListMode,
@ -27,6 +28,7 @@ class LocalStorageService {
private relaySets: TRelaySet[] = [] private relaySets: TRelaySet[] = []
private themeSetting: TThemeSetting = 'system' private themeSetting: TThemeSetting = 'system'
private fontSize: TFontSize = 'medium'
private accounts: TAccount[] = [] private accounts: TAccount[] = []
private currentAccount: TAccount | null = null private currentAccount: TAccount | null = null
private noteListMode: TNoteListMode = 'posts' private noteListMode: TNoteListMode = 'posts'
@ -69,6 +71,8 @@ class LocalStorageService {
init() { init() {
this.themeSetting = this.themeSetting =
(window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system' (window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system'
this.fontSize =
(window.localStorage.getItem(StorageKey.FONT_SIZE) as TFontSize) ?? 'medium'
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS) const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
this.accounts = accountsStr ? JSON.parse(accountsStr) : [] this.accounts = accountsStr ? JSON.parse(accountsStr) : []
const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT) const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT)
@ -293,6 +297,15 @@ class LocalStorageService {
this.themeSetting = themeSetting this.themeSetting = themeSetting
} }
getFontSize() {
return this.fontSize
}
setFontSize(fontSize: TFontSize) {
window.localStorage.setItem(StorageKey.FONT_SIZE, fontSize)
this.fontSize = fontSize
}
getNoteListMode() { getNoteListMode() {
return this.noteListMode return this.noteListMode
} }

1
src/types/index.d.ts vendored

@ -73,6 +73,7 @@ export type TConfig = {
export type TThemeSetting = 'light' | 'dark' | 'system' export type TThemeSetting = 'light' | 'dark' | 'system'
export type TTheme = 'light' | 'dark' export type TTheme = 'light' | 'dark'
export type TFontSize = 'small' | 'medium' | 'large'
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'> export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>

Loading…
Cancel
Save