Browse Source

insure opml import doesn't overwrite existing event feeds

make client more responsive to large font setting and small screen size
imwald
Silberengel 4 months ago
parent
commit
7c4328e334
  1. 5
      src/components/MailboxSetting/DiscoveredRelays.tsx
  2. 2
      src/components/MailboxSetting/SaveButton.tsx
  3. 3
      src/i18n/locales/de.ts
  4. 6
      src/pages/primary/SearchPage/index.tsx
  5. 8
      src/pages/secondary/RelaySettingsPage/index.tsx
  6. 102
      src/pages/secondary/RssFeedSettingsPage/index.tsx
  7. 6
      src/pages/secondary/SearchPage/index.tsx

5
src/components/MailboxSetting/DiscoveredRelays.tsx

@ -207,7 +207,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
))} ))}
</div> </div>
<div className="flex items-center justify-between gap-2 pt-2"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 pt-2">
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleSelectAll}> <Button variant="ghost" size="sm" onClick={handleSelectAll}>
{t('Select All')} {t('Select All')}
@ -219,10 +219,11 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
<Button <Button
onClick={handleAddSelected} onClick={handleAddSelected}
disabled={selectedCount === 0 || isAdding} disabled={selectedCount === 0 || isAdding}
className="text-xs px-2 py-1.5 h-auto w-full sm:w-auto"
> >
{isAdding ? ( {isAdding ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-3 w-3 animate-spin" />
{t('Adding...')} {t('Adding...')}
</> </>
) : ( ) : (

2
src/components/MailboxSetting/SaveButton.tsx

@ -74,7 +74,7 @@ export default function SaveButton({
return ( return (
<Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}> <Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}>
{pushing ? <Loader className="animate-spin" /> : <CloudUpload />} {pushing ? <Loader className="animate-spin" /> : <CloudUpload />}
Save {t('Save')}
</Button> </Button>
) )
} }

3
src/i18n/locales/de.ts

@ -375,6 +375,9 @@ export default {
'Video Posts': 'Videobeiträge', 'Video Posts': 'Videobeiträge',
'Select All': 'Alle auswählen', 'Select All': 'Alle auswählen',
'Clear All': 'Alle löschen', 'Clear All': 'Alle löschen',
'Add {{count}} Selected': '{{count}} Ausgewählte hinzufügen',
'Adding...': 'Hinzufügen...',
'Added {{count}} relay(s)': '{{count}} Relay(s) hinzugefügt',
'Set as default filter': 'Als Standardfilter festlegen', 'Set as default filter': 'Als Standardfilter festlegen',
Apply: 'Anwenden', Apply: 'Anwenden',
Reset: 'Zurücksetzen', Reset: 'Zurücksetzen',

6
src/pages/primary/SearchPage/index.tsx

@ -46,14 +46,14 @@ const SearchPage = forwardRef((_, ref) => {
> >
<div className="px-4 pt-4"> <div className="px-4 pt-4">
<div className="text-2xl font-bold mb-4">Search Nostr</div> <div className="text-2xl font-bold mb-4">Search Nostr</div>
<div className="flex items-center gap-2 mb-4 relative z-40"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative"> <div className="flex-1 relative">
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} /> <SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
</div> </div>
<div className="flex-shrink-0 relative z-50"> <div className="flex-shrink-0 relative z-50 w-full sm:w-auto">
<Button <Button
variant="ghost" variant="ghost"
className="h-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md px-3 gap-2" className="h-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md px-3 gap-2 w-full sm:w-auto"
asChild asChild
> >
<a <a

8
src/pages/secondary/RelaySettingsPage/index.tsx

@ -27,10 +27,10 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Relays and Storage Settings')}> <SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Relays and Storage Settings')}>
<Tabs value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4"> <Tabs value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4">
<TabsList> <TabsList className="flex-col sm:flex-row h-auto sm:h-9">
<TabsTrigger value="favorite-relays">{t('Favorite Relays')}</TabsTrigger> <TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger>
<TabsTrigger value="mailbox">{t('Read & Write Relays')}</TabsTrigger> <TabsTrigger value="mailbox" className="w-full sm:w-auto">{t('Read & Write Relays')}</TabsTrigger>
<TabsTrigger value="cache-relays">{t('Cache')}</TabsTrigger> <TabsTrigger value="cache-relays" className="w-full sm:w-auto">{t('Cache')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="favorite-relays"> <TabsContent value="favorite-relays">
<FavoriteRelaysSetting /> <FavoriteRelaysSetting />

102
src/pages/secondary/RssFeedSettingsPage/index.tsx

@ -16,6 +16,27 @@ import indexedDb from '@/services/indexed-db.service'
import rssFeedService from '@/services/rss-feed.service' import rssFeedService from '@/services/rss-feed.service'
import { parseOpml, generateOpml, downloadFile } from '@/lib/opml' import { parseOpml, generateOpml, downloadFile } from '@/lib/opml'
import { toast } from 'sonner' import { toast } from 'sonner'
import { normalizeHttpUrl } from '@/lib/url'
// Helper function to normalize and deduplicate feed URLs
const normalizeAndDeduplicateUrls = (urls: string[]): string[] => {
const normalizedUrls = urls
.map(url => normalizeHttpUrl(url.trim()))
.filter((url): url is string => url.length > 0) // Filter out invalid URLs
// Deduplicate by creating a Set of normalized URLs, preserving order
const seen = new Set<string>()
const unique: string[] = []
for (const url of normalizedUrls) {
if (!seen.has(url)) {
seen.add(url)
unique.push(url)
}
}
return unique
}
const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -40,7 +61,7 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
if (rssFeedListEvent) { if (rssFeedListEvent) {
try { try {
// Extract URLs from "u" tags // Extract URLs from "u" tags and normalize them
const urls = rssFeedListEvent.tags const urls = rssFeedListEvent.tags
.filter(tag => tag[0] === 'u' && tag[1]) .filter(tag => tag[0] === 'u' && tag[1])
.map(tag => tag[1] as string) .map(tag => tag[1] as string)
@ -56,10 +77,18 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
} }
return true return true
}) })
.map(url => url.trim())
// Normalize and deduplicate URLs
const normalizedUrls = normalizeAndDeduplicateUrls(urls)
if (urls.length > 0) { if (normalizedUrls.length > 0) {
setFeedUrls(urls) setFeedUrls(normalizedUrls)
logger.info('[RssFeedSettingsPage] Loaded RSS feed list from context', { count: urls.length, urls }) logger.info('[RssFeedSettingsPage] Loaded RSS feed list from context', {
count: normalizedUrls.length,
urls: normalizedUrls,
originalCount: urls.length
})
} else { } else {
logger.info('[RssFeedSettingsPage] RSS feed list is empty or contains no valid URLs') logger.info('[RssFeedSettingsPage] RSS feed list is empty or contains no valid URLs')
} }
@ -86,20 +115,25 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
const url = newFeedUrl.trim() const url = newFeedUrl.trim()
if (!url) return if (!url) return
// Basic URL validation // Normalize and deduplicate all URLs (including the new one)
try { const allUrls = [...feedUrls, url]
new URL(url) const normalizedUrls = normalizeAndDeduplicateUrls(allUrls)
} catch {
// Check if the new URL was actually added (not a duplicate)
const normalizedExistingUrls = normalizeAndDeduplicateUrls(feedUrls)
const normalizedNewUrl = normalizeHttpUrl(url)
if (!normalizedNewUrl) {
// Invalid URL // Invalid URL
return return
} }
if (feedUrls.includes(url)) { if (normalizedExistingUrls.includes(normalizedNewUrl)) {
// Feed already exists // Feed already exists
return return
} }
setFeedUrls([...feedUrls, url]) setFeedUrls(normalizedUrls)
setNewFeedUrl('') setNewFeedUrl('')
setHasChange(true) setHasChange(true)
} }
@ -126,7 +160,9 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
} }
// Extract URLs from OPML feeds // Extract URLs from OPML feeds
const urls = feeds.map(feed => feed.xmlUrl).filter((url): url is string => { const opmlUrls = feeds
.map(feed => feed.xmlUrl)
.filter((url): url is string => {
try { try {
new URL(url) new URL(url)
return true return true
@ -135,21 +171,26 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
} }
}) })
if (urls.length === 0) { if (opmlUrls.length === 0) {
toast.error(t('No valid RSS feed URLs found in OPML file')) toast.error(t('No valid RSS feed URLs found in OPML file'))
return return
} }
// Merge with existing feeds (avoid duplicates) // Merge with existing feeds and normalize/deduplicate everything
const existingUrls = new Set(feedUrls) const allUrls = [...feedUrls, ...opmlUrls]
const newUrls = urls.filter(url => !existingUrls.has(url)) const normalizedUrls = normalizeAndDeduplicateUrls(allUrls)
// Check how many new URLs were actually added
const normalizedExistingUrls = new Set(normalizeAndDeduplicateUrls(feedUrls))
const newUrls = normalizedUrls.filter(url => !normalizedExistingUrls.has(url))
if (newUrls.length === 0) { if (newUrls.length === 0) {
toast.info(t('All feeds from OPML file are already added')) toast.info(t('All feeds from OPML file are already added'))
return return
} }
setFeedUrls([...feedUrls, ...newUrls]) // Update with normalized and deduplicated URLs
setFeedUrls(normalizedUrls)
setHasChange(true) setHasChange(true)
toast.success(t('Imported {{count}} feed(s) from OPML file', { count: newUrls.length })) toast.success(t('Imported {{count}} feed(s) from OPML file', { count: newUrls.length }))
} catch (error) { } catch (error) {
@ -161,13 +202,16 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
} }
const handleExportOpml = () => { const handleExportOpml = () => {
if (feedUrls.length === 0) { // Normalize and deduplicate before exporting
const normalizedUrls = normalizeAndDeduplicateUrls(feedUrls)
if (normalizedUrls.length === 0) {
toast.error(t('No feeds to export')) toast.error(t('No feeds to export'))
return return
} }
try { try {
const opmlContent = generateOpml(feedUrls, 'Jumble RSS Feeds') const opmlContent = generateOpml(normalizedUrls, 'Jumble RSS Feeds')
const filename = `jumble-rss-feeds-${new Date().toISOString().split('T')[0]}.opml` const filename = `jumble-rss-feeds-${new Date().toISOString().split('T')[0]}.opml`
downloadFile(opmlContent, filename, 'application/xml') downloadFile(opmlContent, filename, 'application/xml')
toast.success(t('RSS feeds exported to OPML file')) toast.success(t('RSS feeds exported to OPML file'))
@ -185,13 +229,17 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
setPushing(true) setPushing(true)
try { try {
// Normalize and deduplicate URLs before saving
const normalizedUrls = normalizeAndDeduplicateUrls(feedUrls)
logger.info('[RssFeedSettingsPage] Creating RSS feed list event', { logger.info('[RssFeedSettingsPage] Creating RSS feed list event', {
pubkey: pubkey.substring(0, 8), pubkey: pubkey.substring(0, 8),
feedCount: feedUrls.length, feedCount: normalizedUrls.length,
feedUrls originalCount: feedUrls.length,
feedUrls: normalizedUrls
}) })
const event = createRssFeedListDraftEvent(feedUrls) const event = createRssFeedListDraftEvent(normalizedUrls)
// Validate the event structure before publishing // Validate the event structure before publishing
logger.info('[RssFeedSettingsPage] Draft event created', { logger.info('[RssFeedSettingsPage] Draft event created', {
@ -338,15 +386,21 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
// Dispatch custom event to notify other components (like RssFeedList) to refresh // Dispatch custom event to notify other components (like RssFeedList) to refresh
window.dispatchEvent(new CustomEvent('rssFeedListUpdated', { window.dispatchEvent(new CustomEvent('rssFeedListUpdated', {
detail: { pubkey, feedUrls, eventId: result.id } detail: { pubkey, feedUrls: normalizedUrls, eventId: result.id }
})) }))
// Trigger background refresh of feeds (don't wait for it) // Trigger background refresh of feeds (don't wait for it)
logger.info('[RssFeedSettingsPage] Triggering background refresh of RSS feeds', { feedCount: feedUrls.length }) logger.info('[RssFeedSettingsPage] Triggering background refresh of RSS feeds', { feedCount: normalizedUrls.length })
rssFeedService.backgroundRefreshFeeds(feedUrls).catch(err => { rssFeedService.backgroundRefreshFeeds(normalizedUrls).catch(err => {
logger.error('[RssFeedSettingsPage] Background refresh failed', { error: err }) logger.error('[RssFeedSettingsPage] Background refresh failed', { error: err })
}) })
// Update local state with normalized URLs if they changed
if (normalizedUrls.length !== feedUrls.length ||
JSON.stringify(normalizedUrls.sort()) !== JSON.stringify(feedUrls.sort())) {
setFeedUrls(normalizedUrls)
}
// Read relayStatuses immediately before it might be deleted // Read relayStatuses immediately before it might be deleted
const relayStatuses = (result as any).relayStatuses const relayStatuses = (result as any).relayStatuses
logger.info('[RssFeedSettingsPage] Publishing complete', { logger.info('[RssFeedSettingsPage] Publishing complete', {

6
src/pages/secondary/SearchPage/index.tsx

@ -102,14 +102,14 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
> >
<div className="px-4 pt-4"> <div className="px-4 pt-4">
<div className="text-2xl font-bold mb-4">Search Nostr</div> <div className="text-2xl font-bold mb-4">Search Nostr</div>
<div className="flex items-center gap-2 mb-4 relative z-40"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative"> <div className="flex-1 relative">
<SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} /> <SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} />
</div> </div>
<div className="flex-shrink-0 relative z-50"> <div className="flex-shrink-0 relative z-50 w-full sm:w-auto">
<Button <Button
variant="ghost" variant="ghost"
className="h-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md px-3 gap-2" className="h-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md px-3 gap-2 w-full sm:w-auto"
asChild asChild
> >
<a <a

Loading…
Cancel
Save