diff --git a/resources/lang/sources/en.yaml b/resources/lang/sources/en.yaml index 932cca4c..ab3f4c1c 100644 --- a/resources/lang/sources/en.yaml +++ b/resources/lang/sources/en.yaml @@ -110,6 +110,8 @@ launch.ucp2.check.title: "UCP2 detected!" loading: "Loading..." locked.files: "Some game files are locked, is the game running? Close the game first. If the game is not running, make sure the GUI has security access to the files in the game folder." messages: "{{count}} messages" +news.title: "News" +news.button: "News" no: "No" ok: "Ok" old.folders: "Use one of the recently used folders:" diff --git a/src/components/news/news-item-wrapper.tsx b/src/components/news/news-item-wrapper.tsx new file mode 100644 index 00000000..cef4c133 --- /dev/null +++ b/src/components/news/news-item-wrapper.tsx @@ -0,0 +1,28 @@ +import { forwardRef, Ref } from 'react'; +import { newsID } from '../../function/news/state'; +import { NewsElement } from '../../function/news/types'; +import { NewsItem } from './news-item'; + +/** + * Wrapper for a NewsItem. The Wrapper has an id which is used + * for element.scrollIntoView() behavior. + * Scroll into view occurs when the main NewsList component has + * finished rendering using a useEffect() + */ +// eslint-disable-next-line prefer-arrow-callback, import/prefer-default-export +export const NewsItemWrapper = forwardRef(function NewsItemWrapper( + props: { item: NewsElement }, + ref: Ref, +) { + const { item } = props; + return ( +
+ +
+ ); +}); diff --git a/src/components/news/news-item.tsx b/src/components/news/news-item.tsx new file mode 100644 index 00000000..a026b6ad --- /dev/null +++ b/src/components/news/news-item.tsx @@ -0,0 +1,17 @@ +import { NewsElement } from '../../function/news/types'; +import { SaferMarkdown } from '../markdown/safer-markdown'; +import { ymd } from './util'; + +// eslint-disable-next-line import/prefer-default-export +export function NewsItem(props: { item: NewsElement }) { + const { item } = props; + const { meta, content } = item; + return ( +
+ {content} +
+ {`${ymd(meta.timestamp)}`} +
+
+ ); +} diff --git a/src/components/news/news-list.tsx b/src/components/news/news-list.tsx new file mode 100644 index 00000000..c2b9d21e --- /dev/null +++ b/src/components/news/news-list.tsx @@ -0,0 +1,70 @@ +import { useAtomValue } from 'jotai'; +import { useEffect, useRef } from 'react'; +import Message from '../general/message'; +import { OverlayContentProps } from '../overlay/overlay'; +import { + NEWS_ATOM, + newsID, + SCROLL_TO_NEWS_ATOM, +} from '../../function/news/state'; +import { getStore } from '../../hooks/jotai/base'; +import { NewsItemWrapper } from './news-item-wrapper'; + +/** + * News list visual element displaying all news available. + * + * @param props properties for this overlay content + * @returns NewsList + */ +// eslint-disable-next-line import/prefer-default-export +export function NewsList(props: OverlayContentProps) { + const { closeFunc } = props; + + const scrollToNewsID = useAtomValue(SCROLL_TO_NEWS_ATOM); + const wrapperRef = useRef(null); + + const { data, isError, isFetched, isPending } = useAtomValue(NEWS_ATOM); + + const newsList = + isFetched && !isError && !isPending + ? data.map((ne) => ( + 0 && scrollToNewsID === newsID(ne) + ? wrapperRef + : undefined + } + key={`${newsID(ne)}-wrapper`} + item={ne} + /> + )) + : null; + + useEffect(() => { + if (wrapperRef.current !== null) { + wrapperRef.current.scrollIntoView(); + getStore().set(SCROLL_TO_NEWS_ATOM, ''); + } + }, []); + + return ( +
+

+ +

+
+
{newsList}
+
+ +
+ ); +} diff --git a/src/components/news/util.tsx b/src/components/news/util.tsx new file mode 100644 index 00000000..29674192 --- /dev/null +++ b/src/components/news/util.tsx @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export +export function ymd(timestamp: Date) { + return `${timestamp.toLocaleDateString()}`; +} diff --git a/src/components/top-bar/news/news-button.tsx b/src/components/top-bar/news/news-button.tsx new file mode 100644 index 00000000..38d2d6c1 --- /dev/null +++ b/src/components/top-bar/news/news-button.tsx @@ -0,0 +1,18 @@ +import Message from '../../general/message'; +import { NewsList } from '../../news/news-list'; +import { setOverlayContent } from '../../overlay/overlay'; + +// eslint-disable-next-line import/prefer-default-export +export function NewsButton() { + return ( + + ); +} diff --git a/src/components/top-bar/top-bar.tsx b/src/components/top-bar/top-bar.tsx index c0607076..70b6f537 100644 --- a/src/components/top-bar/top-bar.tsx +++ b/src/components/top-bar/top-bar.tsx @@ -5,6 +5,7 @@ import { OVERLAY_ACTIVE_ATOM } from '../overlay/overlay'; import CreditsButton from './credits/credits-button'; import { RestartButton } from './restart/restart-button'; import LanguageSelect from './language-select/language-select'; +import { NewsButton } from './news/news-button'; // eslint-disable-next-line import/prefer-default-export export function TopBar() { @@ -14,6 +15,8 @@ export function TopBar() { className="top-bar" {...{ inert: overlayActive ? '' : undefined }} // inert is not yet supported by React > + + diff --git a/src/components/ucp-tabs/overview/news-highlights.tsx b/src/components/ucp-tabs/overview/news-highlights.tsx new file mode 100644 index 00000000..46d25407 --- /dev/null +++ b/src/components/ucp-tabs/overview/news-highlights.tsx @@ -0,0 +1,126 @@ +import { useAtomValue } from 'jotai'; +import { XCircleFill } from 'react-bootstrap-icons'; +import { useState } from 'react'; +import { + NEWS_HIGHLIGHT_ATOM, + newsID, + SCROLL_TO_NEWS_ATOM, +} from '../../../function/news/state'; +import { NewsElement } from '../../../function/news/types'; +import { HIDDEN_NEWS_HIGHLIGHTS_ATOM } from '../../../function/gui-settings/settings'; +import { getStore } from '../../../hooks/jotai/base'; +import { NewsList } from '../../news/news-list'; +import { setOverlayContent } from '../../overlay/overlay'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function camelCase(s: string) { + return s.substring(0, 1).toLocaleUpperCase() + s.substring(1); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function dmy(d: Date) { + return d.toLocaleString().split(',')[0]; +} + +function Headline(props: { news: NewsElement }) { + const { news } = props; + const firstLine = news.content + .split('\n') + .filter((s) => s.length > 0 && s.trim().length > 0)[0]; + const sanitized = firstLine.startsWith('#') + ? firstLine.trim().split('#', 2)[1].trim() + : firstLine; + // const category = `${camelCase(news.meta.category)} update`; + + const [isHovered, setHovered] = useState(false); + + const id = newsID(news); + + return ( +
{ + setHovered(true); + }} + onMouseLeave={() => { + setHovered(false); + }} + > + {/* {`${category}`} */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + { + getStore().set(SCROLL_TO_NEWS_ATOM, id); + setOverlayContent(NewsList, true, true); + }} + >{`${sanitized}`} + {/* {`${dmy(news.meta.timestamp)}`} */} + + { + const hidden = getStore().get(HIDDEN_NEWS_HIGHLIGHTS_ATOM); + hidden.push(news.meta.timestamp.toISOString()); + getStore().set(HIDDEN_NEWS_HIGHLIGHTS_ATOM, [...hidden]); + }} + /> + +
+ ); +} + +// eslint-disable-next-line import/prefer-default-export, @typescript-eslint/no-unused-vars +export function NewsHighlights(props: object) { + const highlights = useAtomValue(NEWS_HIGHLIGHT_ATOM); + + const hiddenHighlights = useAtomValue(HIDDEN_NEWS_HIGHLIGHTS_ATOM); + + if (highlights.length === 0) { + return
No news today
; + } + return ( +
+

+ News +

+
+ {highlights + .filter( + (h) => + hiddenHighlights.indexOf(h.meta.timestamp.toISOString()) === -1, + ) + .map((h) => ( + + ))} +
+
+ ); +} diff --git a/src/components/ucp-tabs/overview/overview.css b/src/components/ucp-tabs/overview/overview.css index 30b0e54b..e87e0ca5 100644 --- a/src/components/ucp-tabs/overview/overview.css +++ b/src/components/ucp-tabs/overview/overview.css @@ -63,7 +63,7 @@ .overview__text-button { height: 40px; - line-height: 0; + /* line-height: 0; */ min-width: 100px; width: 40%; vertical-align: middle; diff --git a/src/components/ucp-tabs/overview/overview.tsx b/src/components/ucp-tabs/overview/overview.tsx index 8ed50c1d..b906c338 100644 --- a/src/components/ucp-tabs/overview/overview.tsx +++ b/src/components/ucp-tabs/overview/overview.tsx @@ -43,6 +43,7 @@ import Logger from '../../../util/scripts/logging'; import { hintThatGameMayBeRunning } from '../../../function/game-folder/file-locks'; import { asPercentage } from '../../../tauri/tauri-http'; import { useMessage } from '../../general/message'; +import { NewsHighlights } from './news-highlights'; const LOGGER = new Logger('overview.tsx'); @@ -437,6 +438,7 @@ export default function Overview() { toastTitle="overview.uninstall.toast.title" />
+
); } diff --git a/src/function/gui-settings/settings.ts b/src/function/gui-settings/settings.ts index 2303052d..4ad09c33 100644 --- a/src/function/gui-settings/settings.ts +++ b/src/function/gui-settings/settings.ts @@ -52,3 +52,8 @@ export const CONFIG_IGNORE_ERRORS = atomWithStorage( 'configMerging.ignoreErrors', false, ); + +export const HIDDEN_NEWS_HIGHLIGHTS_ATOM = atomWithStorage( + 'news.highlights.hidden', + [], +); diff --git a/src/function/news/fetching.ts b/src/function/news/fetching.ts new file mode 100644 index 00000000..5b5a0a87 --- /dev/null +++ b/src/function/news/fetching.ts @@ -0,0 +1,26 @@ +import { ResponseType } from '@tauri-apps/api/http'; +import { fetch } from '../../tauri/tauri-http'; +import { parseNews } from './parsing'; +import Logger from '../../util/scripts/logging'; + +const LOGGER = new Logger('news/fetching.ts'); + +// eslint-disable-next-line import/prefer-default-export +export async function fetchNews({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + queryKey: [, ucpVersion], +}: { + queryKey: [string, string]; +}) { + const request = await fetch( + 'https://raw.githubusercontent.com/UnofficialCrusaderPatch/UnofficialCrusaderPatch/refs/heads/main/NEWS.md', + { responseType: ResponseType.Text, method: 'GET' }, + ); + const raw = request.data!.toString().trim(); + const news = parseNews(raw); + + LOGGER.msg(`News: ${JSON.stringify(news)}`).debug(); + LOGGER.msg(`Raw news: ${JSON.stringify(raw)}`).debug(); + + return news; +} diff --git a/src/function/news/parsing.ts b/src/function/news/parsing.ts new file mode 100644 index 00000000..1584ed2e --- /dev/null +++ b/src/function/news/parsing.ts @@ -0,0 +1,77 @@ +import yaml from 'yaml'; +import { News, NewsElement, NewsMeta } from './types'; + +const docSeparator = /^---\n/gm; + +function parseNewsV1(content: string) { + const metas: NewsMeta[] = []; + const docs: string[] = []; + const splitContent = content + .split(docSeparator) + .filter((s) => s.trim().length > 0); + + splitContent.forEach((docString, index) => + index % 2 === 0 + ? metas.push(yaml.parseDocument(docString).toJS() as NewsMeta) + : docs.push(docString), + ); + + if (metas.length !== docs.length) { + throw Error(`Failed to parse NEWS (error 1)`); + } + + const newsElements: News = []; + metas.forEach((meta, index) => { + const obj = { meta, content: docs[index].replaceAll('---\n', '') }; + obj.meta.timestamp = new Date(obj.meta.timestamp); + newsElements.push(obj); + }); + + return newsElements; +} + +const REGEX_META = /^\[meta\]:\s*<>\s*\(([^)]*)\)/gm; + +function parseNewsV2(content: string) { + const splitContent = content + .split(docSeparator) + .filter((s) => s.trim().length > 0); + + return splitContent.map((docString) => { + let meta: NewsMeta; + + const m = new RegExp(REGEX_META).exec(docString); + if (m === null || m.length < 2) { + meta = { + category: 'community', + timestamp: new Date(), + }; + } else { + meta = yaml.parse(m[1]); + meta.timestamp = new Date(meta.timestamp); + } + + return { + meta, + content: docString, + } as NewsElement; + }); +} + +// eslint-disable-next-line import/prefer-default-export +export function parseNews(content: string): News { + try { + return parseNewsV2(content); + } catch { + try { + return parseNewsV1(content); + } catch (e) { + return [ + { + content: `# Failed to load news\n\nreason:${e}`, + meta: { category: 'error', timestamp: new Date() }, + }, + ]; + } + } +} diff --git a/src/function/news/state.ts b/src/function/news/state.ts new file mode 100644 index 00000000..7b8a5131 --- /dev/null +++ b/src/function/news/state.ts @@ -0,0 +1,59 @@ +import { atomWithQuery } from 'jotai-tanstack-query'; +import { atom } from 'jotai'; +import { UCP_VERSION_ATOM } from '../ucp-files/ucp-version'; +import { fetchNews } from './fetching'; +import { News, NewsElement } from './types'; + +// eslint-disable-next-line import/prefer-default-export +export const NEWS_ATOM = atomWithQuery((get) => ({ + queryKey: [ + 'news', + get(UCP_VERSION_ATOM).version.getMajorMinorPatchAsString(), + ] as [string, string], + queryFn: fetchNews, + retry: false, + // staleTime: Infinity, +})); + +export const NEWS_HIGHLIGHT_ATOM = atom((get) => { + const { data, isError, isLoading, isPending, error } = get(NEWS_ATOM); + + const highlights: News = []; + + if (isLoading || isPending) + return [ + { + meta: { category: 'community', timestamp: new Date() }, + content: 'Fetching News...', + }, + ]; + + if (isError) + return [ + { + meta: { + category: 'frontend', + timestamp: new Date(), + }, + content: `Could not load news: ${error.toString()}`, + }, + ]; + + const newsItemPerCategory: { [category: string]: NewsElement } = {}; + + data.forEach((n) => { + const cat = n.meta.category; + if (newsItemPerCategory[cat] === undefined) { + newsItemPerCategory[cat] = n; + highlights.push(n as NewsElement); + } + }); + + return highlights as News; +}); + +export const SCROLL_TO_NEWS_ATOM = atom(''); + +export function newsID(news: NewsElement) { + return `${news.meta.category}-${news.meta.timestamp.toISOString()}`; +} diff --git a/src/function/news/types.ts b/src/function/news/types.ts new file mode 100644 index 00000000..63cdf4bb --- /dev/null +++ b/src/function/news/types.ts @@ -0,0 +1,11 @@ +export type NewsMeta = { + timestamp: Date; + category: 'framework' | 'frontend' | 'store' | 'community' | 'error'; +}; + +export type NewsElement = { + meta: NewsMeta; + content: string; +}; + +export type News = NewsElement[];