From d2c87b428eab1748ed94b9c1e57eda415a1f0ecc Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 1 Nov 2025 06:18:56 +0100 Subject: [PATCH] Improved accessibility and navigation --- src/App.tsx | 7 +- .../ArticleExportMenu/ArticleExportMenu.tsx | 27 ++++-- .../Note/DiscussionContent/index.tsx | 18 ---- .../Note/MarkdownArticle/MarkdownArticle.tsx | 83 ++++++++++++++++--- src/components/Note/index.tsx | 5 +- src/components/ParentNotePreview/index.tsx | 77 +++++++++++++++-- src/components/Tabs/index.tsx | 6 +- src/components/UserAvatar/index.tsx | 6 +- src/components/Username/index.tsx | 6 +- src/constants.ts | 7 ++ src/index.css | 48 ++++++++++- src/lib/discussion-topics.ts | 25 +++--- .../DiscussionsPage/CreateThreadDialog.tsx | 39 +++++---- .../primary/DiscussionsPage/ThreadCard.tsx | 32 +++---- .../secondary/GeneralSettingsPage/index.tsx | 19 ++++- src/providers/FontSizeProvider.tsx | 54 ++++++++++++ src/services/local-storage.service.ts | 13 +++ src/types/index.d.ts | 1 + 18 files changed, 373 insertions(+), 100 deletions(-) delete mode 100644 src/components/Note/DiscussionContent/index.tsx create mode 100644 src/providers/FontSizeProvider.tsx diff --git a/src/App.tsx b/src/App.tsx index 56b6059..f819b4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FeedProvider } from '@/providers/FeedProvider' +import { FontSizeProvider } from '@/providers/FontSizeProvider' import { FollowListProvider } from '@/providers/FollowListProvider' import { GroupListProvider } from '@/providers/GroupListProvider' import { InterestListProvider } from '@/providers/InterestListProvider' @@ -26,8 +27,9 @@ import { PageManager } from './PageManager' export default function App(): JSX.Element { return ( - - + + + @@ -64,6 +66,7 @@ export default function App(): JSX.Element { + ) } diff --git a/src/components/ArticleExportMenu/ArticleExportMenu.tsx b/src/components/ArticleExportMenu/ArticleExportMenu.tsx index f8fc3de..cfa83d3 100644 --- a/src/components/ArticleExportMenu/ArticleExportMenu.tsx +++ b/src/components/ArticleExportMenu/ArticleExportMenu.tsx @@ -7,7 +7,8 @@ import { } from '@/components/ui/dropdown-menu' import { MoreVertical, FileDown } from 'lucide-react' import logger from '@/lib/logger' -import { Event } from 'nostr-tools' +import { Event, kinds } from 'nostr-tools' +import { ExtendedKind } from '@/constants' interface ArticleExportMenuProps { event: Event @@ -15,13 +16,23 @@ interface 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 () => { try { const content = event.content - const filename = `${title}.adoc` + const format = getExportFormat() + const filename = `${title}.${format.extension}` - // Export raw AsciiDoc content - const blob = new Blob([content], { type: 'text/plain' }) + // Export raw content + const blob = new Blob([content], { type: format.mimeType }) const url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -32,24 +43,26 @@ export default function ArticleExportMenu({ event, title }: ArticleExportMenuPro document.body.removeChild(a) URL.revokeObjectURL(url) - logger.info('[ArticleExportMenu] Exported article as .adoc') + logger.info(`[ArticleExportMenu] Exported article as .${format.extension}`) } catch (error) { logger.error('[ArticleExportMenu] Error exporting article:', error) alert('Failed to export article. Please try again.') } } + const format = getExportFormat() + return ( e.stopPropagation()}> - e.stopPropagation()}> - Export as AsciiDoc + Export as {format.label} diff --git a/src/components/Note/DiscussionContent/index.tsx b/src/components/Note/DiscussionContent/index.tsx deleted file mode 100644 index 32b8e4c..0000000 --- a/src/components/Note/DiscussionContent/index.tsx +++ /dev/null @@ -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)) -} diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 7e80c7c..8f3acce 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -402,6 +402,7 @@ export default function MarkdownArticle({ .hljs { background: transparent !important; } + /* Light theme syntax highlighting */ .hljs-keyword, .hljs-selector-tag, .hljs-literal, @@ -411,7 +412,7 @@ export default function MarkdownArticle({ .hljs-type, .hljs-name, .hljs-strong { - color: #f85149 !important; + color: #dc2626 !important; /* red-600 - good contrast on light */ font-weight: bold !important; } .hljs-string, @@ -426,40 +427,100 @@ export default function MarkdownArticle({ .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id { - color: #0366d6 !important; + color: #0284c7 !important; /* sky-600 */ } .hljs-comment, .hljs-quote { - color: #8b949e !important; + color: #6b7280 !important; /* gray-500 */ } .hljs-number, .hljs-deletion { - color: #005cc5 !important; + color: #0d9488 !important; /* teal-600 */ } .hljs-variable, .hljs-template-variable, .hljs-link { - color: #e36209 !important; + color: #ea580c !important; /* orange-600 */ } .hljs-meta { - color: #6f42c1 !important; + color: #7c3aed !important; /* violet-600 */ } .hljs-built_in, .hljs-class .hljs-title { - color: #005cc5 !important; + color: #0d9488 !important; /* teal-600 */ } .hljs-params { - color: #f0f6fc !important; + color: #1f2937 !important; /* gray-800 */ } .hljs-attribute { - color: #005cc5 !important; + color: #0d9488 !important; /* teal-600 */ } .hljs-function .hljs-title { - color: #6f42c1 !important; + color: #7c3aed !important; /* violet-600 */ } .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 { font-style: italic; } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index de01511..cfe1e6a 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -20,7 +20,6 @@ import UserAvatar from '../UserAvatar' import Username from '../Username' import { MessageSquare } from 'lucide-react' import CommunityDefinition from './CommunityDefinition' -import DiscussionContent from './DiscussionContent' import GroupMetadata from './GroupMetadata' import Highlight from './Highlight' @@ -142,7 +141,7 @@ export default function Note({ content = ( <>

{title}

- + ) } else if (event.kind === ExtendedKind.POLL) { @@ -183,7 +182,7 @@ export default function Note({ onClick={(e) => { // Don't navigate if clicking on interactive elements 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 } navigateToNote(toNote(event)) diff --git a/src/components/ParentNotePreview/index.tsx b/src/components/ParentNotePreview/index.tsx index d467199..1c19aed 100644 --- a/src/components/ParentNotePreview/index.tsx +++ b/src/components/ParentNotePreview/index.tsx @@ -1,7 +1,11 @@ import { Skeleton } from '@/components/ui/skeleton' +import { SEARCHABLE_RELAY_URLS } from '@/constants' import { useFetchEvent } from '@/hooks' import { cn } from '@/lib/utils' +import client from '@/services/client.service' import { useTranslation } from 'react-i18next' +import { useCallback, useEffect, useState } from 'react' +import { Event, nip19 } from 'nostr-tools' import ContentPreview from '../ContentPreview' import UserAvatar from '../UserAvatar' @@ -16,8 +20,58 @@ export default function ParentNotePreview({ }) { const { t } = useTranslation() const { event, isFetching } = useFetchEvent(eventId) + const [fallbackEvent, setFallbackEvent] = useState(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 (
) => { + if (finalEvent) { + onClick?.(e) + } else if (!finalEvent && !finalIsFetching && eventId) { + // Retry fetch from searchable relays when clicking "Note not found" + e.stopPropagation() + fetchFromSearchableRelays() + } + } + return (
{t('reply to')}
- {event && } - + {finalEvent && } +
+ +
) } diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx index 8ca6cde..177cb77 100644 --- a/src/components/Tabs/index.tsx +++ b/src/components/Tabs/index.tsx @@ -111,14 +111,14 @@ export default function Tabs({ deepBrowsing && lastScrollTop > threshold ? '-translate-y-[calc(100%+12rem)]' : '' )} > -
-
+
+
{tabs.map((tab, index) => (
(tabRefs.current[index] = el)} 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' )} onClick={() => { diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index 6176ba8..7df4949 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -44,8 +44,12 @@ export default function UserAvatar({ return ( navigateToProfile(toProfile(pubkey))} + onClick={(e) => { + e.stopPropagation() + navigateToProfile(toProfile(pubkey)) + }} > diff --git a/src/components/Username/index.tsx b/src/components/Username/index.tsx index 76abc34..d852b03 100644 --- a/src/components/Username/index.tsx +++ b/src/components/Username/index.tsx @@ -38,9 +38,13 @@ export default function Username({ return ( navigateToProfile(toProfile(pubkey))} + onClick={(e) => { + e.stopPropagation() + navigateToProfile(toProfile(pubkey)) + }} > {showAt && '@'} {username} diff --git a/src/constants.ts b/src/constants.ts index 1d901c8..81728c4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,6 +19,7 @@ export const RECOMMENDED_BLOSSOM_SERVERS = [ export const StorageKey = { VERSION: 'version', THEME_SETTING: 'themeSetting', + FONT_SIZE: 'fontSize', RELAY_SETS: 'relaySets', ACCOUNTS: 'accounts', CURRENT_ACCOUNT: 'currentAccount', @@ -63,6 +64,12 @@ export const StorageKey = { FEED_TYPE: 'feedType' // deprecated } +export const FONT_SIZE = { + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large' +} as const + export const ApplicationDataKey = { NOTIFICATIONS_SEEN_AT: 'seen_notifications_at' } diff --git a/src/index.css b/src/index.css index cd8d258..cee049b 100644 --- a/src/index.css +++ b/src/index.css @@ -91,12 +91,12 @@ --secondary: 240 4.8% 94%; --secondary-foreground: 240 5.9% 10%; --muted: 240 4.8% 94%; - --muted-foreground: 240 3.8% 46.1%; + --muted-foreground: 240 5% 35%; --accent: 240 4.8% 94%; --accent-foreground: 240 5.9% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; + --border: 240 5.9% 85%; --input: 240 5.9% 90%; --ring: 140 70% 28%; --chart-1: 12 76% 61%; @@ -120,12 +120,12 @@ --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; + --muted-foreground: 240 5% 75%; --accent: 240 3.7% 15.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; + --border: 240 3.7% 25%; --input: 240 3.7% 15.9%; --ring: 140 70% 40%; --chart-1: 220 70% 50%; @@ -138,6 +138,46 @@ .dark input[type='datetime-local']::-webkit-calendar-picker-indicator { 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 */ diff --git a/src/lib/discussion-topics.ts b/src/lib/discussion-topics.ts index cee51bd..911e330 100644 --- a/src/lib/discussion-topics.ts +++ b/src/lib/discussion-topics.ts @@ -276,19 +276,24 @@ export function buildGroupDisplayName( groupId: string, groupRelay: string | null ): string { + let displayName: string + 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 - try { - const url = new URL(groupRelay) - const hostname = url.hostname - return `${hostname}'${groupId}` - } catch { - // Fallback to full relay URL if parsing fails - return `${groupRelay}'${groupId}` - } + // Truncate to 20 characters + return displayName.length > 20 ? displayName.substring(0, 20) : displayName } /** diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index a1be204..8297675 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -24,7 +24,7 @@ import { simplifyUrl } from '@/lib/url' import relaySelectionService from '@/services/relay-selection.service' import dayjs from 'dayjs' 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 logger from '@/lib/logger' @@ -711,25 +711,24 @@ export default function CreateThreadDialog({
{/* Preview of the content */} -
- -
+
) : (
diff --git a/src/pages/primary/DiscussionsPage/ThreadCard.tsx b/src/pages/primary/DiscussionsPage/ThreadCard.tsx index 0c6a689..ad02328 100644 --- a/src/pages/primary/DiscussionsPage/ThreadCard.tsx +++ b/src/pages/primary/DiscussionsPage/ThreadCard.tsx @@ -93,17 +93,11 @@ export default function ThreadCard({

{title}

-
+
{topicInfo.id}
- {groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( - - - {groupInfo.groupDisplayName} - - )} {allTopics.slice(0, 3).map(topic => ( @@ -111,6 +105,14 @@ export default function ThreadCard({ ))}
+ {groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( +
+ + + {groupInfo.groupDisplayName} + +
+ )}
@@ -146,17 +148,11 @@ export default function ThreadCard({ {title}
-
+
{topicInfo.label} - {groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( - - - {groupInfo.groupDisplayName} - - )} {allTopics.slice(0, 3).map(topic => ( @@ -173,6 +169,14 @@ export default function ThreadCard({ {t('last updated')}: {lastCommentAgo || lastVoteAgo || timeAgo}
+ {groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( +
+ + + {groupInfo.groupDisplayName} + +
+ )}
diff --git a/src/pages/secondary/GeneralSettingsPage/index.tsx b/src/pages/secondary/GeneralSettingsPage/index.tsx index b6e88ce..b1857b1 100644 --- a/src/pages/secondary/GeneralSettingsPage/index.tsx +++ b/src/pages/secondary/GeneralSettingsPage/index.tsx @@ -1,11 +1,12 @@ import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' 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 SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { cn, isSupportCheckConnectionType } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' +import { useFontSize } from '@/providers/FontSizeProvider' import { useTheme } from '@/providers/ThemeProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserTrust } from '@/providers/UserTrustProvider' @@ -19,6 +20,7 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index const { t, i18n } = useTranslation() const [language, setLanguage] = useState(i18n.language as TLanguage) const { themeSetting, setThemeSetting } = useTheme() + const { fontSize, setFontSize } = useFontSize() const { autoplay, setAutoplay, @@ -72,6 +74,21 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index + + + +