"use client"; import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Text } from "@mantine/core"; import { IconAlertTriangle, IconClock, IconServerOff } from "@tabler/icons-react"; import dayjs from "dayjs"; import type { RouterInputs, RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { BaseWidgetError } from "../errors/base-component"; import classes from "./component.module.scss"; const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { const query = clientApi.widget.rssFeed.getFeeds.useQuery(input); if (query.error && query.data === undefined) { return { hasError: true as const, refetch: query.refetch }; } // Persisted query caches can still contain the array response used before failed-feed metadata was added. const normalizedData = query.data as typeof query.data | RouterOutputs["widget"]["rssFeed"]["getFeeds"]["entries"]; if (Array.isArray(normalizedData)) { return { hasError: false as const, entries: normalizedData, failedFeedCount: 0, isStale: Boolean(query.error), }; } return { hasError: false as const, entries: normalizedData?.entries ?? [], failedFeedCount: normalizedData?.failedFeedCount ?? 0, isStale: Boolean(query.error), }; }; export default function RssFeed({ options, width, height }: WidgetComponentProps<"rssFeed">) { const feed = useLiveFeed({ urls: options.feedUrls, maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100, }); const board = useRequiredBoard(); const t = useScopedI18n("widget.rssFeed"); if (feed.hasError) { return ( void feed.refetch()} /> ); } const warning = feed.isStale ? t("warning.stale") : feed.failedFeedCount > 0 ? t("warning.partial", { count: feed.failedFeedCount }) : undefined; const languageDir = options.enableRtl ? "RTL" : "LTR"; const isDense = width < 420 || height < 180; const isTiny = width < 260 || height < 110; const isRoomy = width >= 420 && height >= 240; const columns = width >= 720 && height >= 260 ? 2 : 1; const descriptionLines = isRoomy ? Math.max(options.textLinesClamp, 6) : isDense ? 1 : options.textLinesClamp; const spacing = isRoomy ? "sm" : "xs"; return ( {warning && ( } p="xs" mb="xs"> {warning} )} {feed.entries.map((feedEntry) => { const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl }); return ( {feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && ( )} {feedEntry.title} {!options.hideDescription && feedEntry.description && !isTiny && ( {feedDescriptionToText(feedEntry.description)} )} ); })} ); } export const feedDescriptionToText = (description: string): string => { if (typeof DOMParser !== "undefined") { return new DOMParser().parseFromString(description, "text/html").body.textContent?.trim() ?? ""; } return description .replace(/<[^>]*>/g, " ") .replace(/\s+/g, " ") .trim(); }; export const getHostname = (url: string): string => { try { return new URL(url).hostname; } catch { return url; } }; const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => ( {date && } {date && ( {date} )} {date && source && •} {source && ( {source} )} );