diff --git a/src/app.tsx b/src/app.tsx index bd9bb5efb..28526bdd0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -123,6 +123,7 @@ const WikiHomeView = lazy(() => import("./views/wiki")); const WikiPageView = lazy(() => import("./views/wiki/page")); const WikiTopicView = lazy(() => import("./views/wiki/topic")); const WikiSearchView = lazy(() => import("./views/wiki/search")); +const WikiCompareView = lazy(() => import("./views/wiki/compare")); const overrideReactTextareaAutocompleteStyles = css` .rta__autocomplete { @@ -318,6 +319,7 @@ const router = createHashRouter([ { path: "search", element: }, { path: "topic/:topic", element: }, { path: "page/:naddr", element: }, + { path: "compare/:topic/:a/:b", element: }, { path: "", element: }, ], }, diff --git a/src/components/diff/diff-viewer.tsx b/src/components/diff/diff-viewer.tsx index 9ce21b96f..8703a7a0b 100644 --- a/src/components/diff/diff-viewer.tsx +++ b/src/components/diff/diff-viewer.tsx @@ -1,5 +1,5 @@ import { ColorModeContext, useColorMode } from "@chakra-ui/react"; -import ReactDiffViewer from "react-diff-viewer-continued"; +import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"; import computeStyles, { ReactDiffViewerStylesOverride } from "react-diff-viewer-continued/lib/src/styles"; const fixedStyles: ReactDiffViewerStylesOverride = { @@ -35,7 +35,17 @@ class FixedReactDiffViewer extends ReactDiffViewer { } } -export default function DiffViewer({ oldValue, newValue }: { oldValue: string; newValue: string }) { +export default function DiffViewer({ + oldValue, + newValue, + method = DiffMethod.WORDS, + splitView = false, +}: { + oldValue: string; + newValue: string; + method?: DiffMethod; + splitView?: boolean; +}) { const { colorMode } = useColorMode(); return ( @@ -44,9 +54,8 @@ export default function DiffViewer({ oldValue, newValue }: { oldValue: string; n newValue={newValue} useDarkTheme={colorMode === "dark"} hideLineNumbers - splitView={false} - //@ts-expect-error - compareMethod="diffWords" + splitView={splitView} + compareMethod={method} /> ); } diff --git a/src/const.ts b/src/const.ts index 7b7165092..a3c247e19 100644 --- a/src/const.ts +++ b/src/const.ts @@ -7,4 +7,5 @@ export const SEARCH_RELAYS = safeRelayUrls([ // TODO: requires NIP-42 auth // "wss://filter.nostr.wine", ]); +export const WIKI_RELAYS = safeRelayUrls(["wss://relay.wikifreedia.xyz/"]); export const COMMON_CONTACT_RELAY = safeRelayUrl("wss://purplepag.es") as string; diff --git a/src/helpers/nostr/wiki.ts b/src/helpers/nostr/wiki.ts index 0642b8d96..798c64b86 100644 --- a/src/helpers/nostr/wiki.ts +++ b/src/helpers/nostr/wiki.ts @@ -1,9 +1,10 @@ -import { NostrEvent } from "nostr-tools"; +import { NostrEvent, nip19 } from "nostr-tools"; +import { parseCoordinate } from "./event"; export const WIKI_PAGE_KIND = 30818; export function getPageTitle(page: NostrEvent) { - return page.tags.find((t) => t[0] === "title")?.[1]; + return page.tags.find((t) => t[0] === "title")?.[1] || page.tags.find((t) => t[0] === "d")?.[1]; } export function getPageTopic(page: NostrEvent) { @@ -16,3 +17,26 @@ export function getPageSummary(page: NostrEvent) { const summary = page.tags.find((t) => t[0] === "summary")?.[1]; return summary || page.content.split("\n")[0]; } + +export function getPageForks(page: NostrEvent) { + const addressFork = page.tags.find((t) => t[0] === "a" && t[1] && t[3]); + const eventFork = page.tags.find((t) => t[0] === "a" && t[1] && t[3]); + + const address = addressFork ? parseCoordinate(addressFork[1], true) ?? undefined : undefined; + const event: nip19.EventPointer | undefined = eventFork ? { id: eventFork[1] } : undefined; + + return { event, address }; +} + +export function isPageFork(page: NostrEvent) { + return page.tags.some((t) => (t[0] === "a" || t[0] === "e") && t[3] === "fork"); +} + +export function validatePage(page: NostrEvent) { + try { + getPageTopic(page); + return true; + } catch (error) { + return false; + } +} diff --git a/src/views/wiki/compare.tsx b/src/views/wiki/compare.tsx new file mode 100644 index 000000000..1c7e2ffd4 --- /dev/null +++ b/src/views/wiki/compare.tsx @@ -0,0 +1,74 @@ +import { NostrEvent } from "nostr-tools"; +import { Alert, AlertIcon, Box, ButtonGroup, Divider, Flex, Heading, Spinner, Text } from "@chakra-ui/react"; +import { Navigate, useParams } from "react-router-dom"; + +import useReplaceableEvent from "../../hooks/use-replaceable-event"; +import VerticalPageLayout from "../../components/vertical-page-layout"; +import { WIKI_PAGE_KIND, getPageTitle } from "../../helpers/nostr/wiki"; +import Timestamp from "../../components/timestamp"; +import DebugEventButton from "../../components/debug-modal/debug-event-button"; +import WikiPageHeader from "./components/wiki-page-header"; +import DiffViewer from "../../components/diff/diff-viewer"; +import { useBreakpointValue } from "../../providers/global/breakpoint-provider"; +import MarkdownContent from "./components/markdown"; +import { WIKI_RELAYS } from "../../const"; +import UserName from "../../components/user/user-name"; + +function WikiComparePage({ base, diff }: { base: NostrEvent; diff: NostrEvent }) { + const vertical = useBreakpointValue({ base: true, lg: false }) ?? false; + const identical = base.content.trim() === diff.content.trim(); + + return ( + + + + + + + + + + + + + {getPageTitle(base)} - + + + + + + + + + + + {getPageTitle(diff)} - + + + + + {identical ? ( + <> + + + Both versions are identical + + + + ) : ( + + )} + + ); +} + +export default function WikiCompareView() { + const { topic, a, b } = useParams(); + if (!topic || !a || !b) return ; + + const base = useReplaceableEvent({ kind: WIKI_PAGE_KIND, identifier: topic, pubkey: a }, WIKI_RELAYS); + const diff = useReplaceableEvent({ kind: WIKI_PAGE_KIND, identifier: topic, pubkey: b }, WIKI_RELAYS); + + if (!base || !diff) return ; + return ; +} diff --git a/src/views/wiki/components/wiki-page-header.tsx b/src/views/wiki/components/wiki-page-header.tsx new file mode 100644 index 000000000..a8f991aab --- /dev/null +++ b/src/views/wiki/components/wiki-page-header.tsx @@ -0,0 +1,17 @@ +import { Flex, Heading, Link } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; + +import WikiSearchForm from "./wiki-search-form"; + +export default function WikiPageHeader() { + return ( + + + + Wikifreedia + + + + + ); +} diff --git a/src/views/wiki/components/wiki-page-result.tsx b/src/views/wiki/components/wiki-page-result.tsx index ef1bbe343..f72b11271 100644 --- a/src/views/wiki/components/wiki-page-result.tsx +++ b/src/views/wiki/components/wiki-page-result.tsx @@ -1,27 +1,62 @@ -import { Heading, LinkBox, Text } from "@chakra-ui/react"; -import { NostrEvent } from "nostr-tools"; +import { useMemo } from "react"; +import { Box, Button, ButtonGroup, Flex, Heading, LinkBox, Text } from "@chakra-ui/react"; +import { NostrEvent, nip19 } from "nostr-tools"; import { Link as RouterLink } from "react-router-dom"; import HoverLinkOverlay from "../../../components/hover-link-overlay"; import { getSharableEventAddress } from "../../../helpers/nip19"; -import { getPageSummary, getPageTitle } from "../../../helpers/nostr/wiki"; +import { getPageForks, getPageSummary, getPageTitle, getPageTopic } from "../../../helpers/nostr/wiki"; import UserLink from "../../../components/user/user-link"; import Timestamp from "../../../components/timestamp"; +import FileSearch01 from "../../../components/icons/file-search-01"; +import GitBranch01 from "../../../components/icons/git-branch-01"; +import UserName from "../../../components/user/user-name"; + +export default function WikiPageResult({ page, compare }: { page: NostrEvent; compare?: NostrEvent }) { + const topic = getPageTopic(page); + + const { address } = useMemo(() => getPageForks(page), [page]); -export default function WikiPageResult({ page }: { page: NostrEvent }) { return ( - - - - {getPageTitle(page)} - - - - by - - - - {getPageSummary(page)} - - + + + + + + {getPageTitle(page)} + + + + by - + + + {getPageSummary(page)} + + + + + {address && ( + + )} + {compare && ( + + )} + + ); } diff --git a/src/views/wiki/hooks/use-wiki-topic-timeline.tsx b/src/views/wiki/hooks/use-wiki-topic-timeline.tsx index 25643e26f..b5c1bf7f5 100644 --- a/src/views/wiki/hooks/use-wiki-topic-timeline.tsx +++ b/src/views/wiki/hooks/use-wiki-topic-timeline.tsx @@ -1,19 +1,22 @@ import { NostrEvent } from "nostr-tools"; -import { WIKI_PAGE_KIND } from "../../../helpers/nostr/wiki"; + +import { WIKI_PAGE_KIND, validatePage } from "../../../helpers/nostr/wiki"; import { useReadRelays } from "../../../hooks/use-client-relays"; import useTimelineLoader from "../../../hooks/use-timeline-loader"; +import { WIKI_RELAYS } from "../../../const"; -function noEmptyEvent(event: NostrEvent) { +function eventFilter(event: NostrEvent) { + if (!validatePage(event)) return false; return event.content.length > 0; } export default function useWikiTopicTimeline(topic: string) { - const relays = useReadRelays(["wss://relay.wikifreedia.xyz/"]); + const relays = useReadRelays(WIKI_RELAYS); return useTimelineLoader( `wiki-${topic.toLocaleLowerCase()}-pages`, relays, [{ kinds: [WIKI_PAGE_KIND], "#d": [topic.toLocaleLowerCase()] }], - { eventFilter: noEmptyEvent }, + { eventFilter: eventFilter }, ); } diff --git a/src/views/wiki/index.tsx b/src/views/wiki/index.tsx index f022b605b..86a275f37 100644 --- a/src/views/wiki/index.tsx +++ b/src/views/wiki/index.tsx @@ -1,20 +1,28 @@ import { Box, Flex, Heading, SimpleGrid } from "@chakra-ui/react"; import { Link } from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; +import { NostrEvent } from "nostr-tools"; import VerticalPageLayout from "../../components/vertical-page-layout"; import WikiSearchForm from "./components/wiki-search-form"; -import { WIKI_PAGE_KIND } from "../../helpers/nostr/wiki"; +import { WIKI_PAGE_KIND, validatePage } from "../../helpers/nostr/wiki"; import useTimelineLoader from "../../hooks/use-timeline-loader"; import { useReadRelays } from "../../hooks/use-client-relays"; import useSubject from "../../hooks/use-subject"; import { getWebOfTrust } from "../../services/web-of-trust"; import WikiPageResult from "./components/wiki-page-result"; import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; +import { ErrorBoundary } from "../../components/error-boundary"; +import { WIKI_RELAYS } from "../../const"; + +function eventFilter(event: NostrEvent) { + if (!validatePage(event)) return false; + return event.content.length > 0; +} export default function WikiHomeView() { - const relays = useReadRelays(["wss://relay.wikifreedia.xyz/"]); - const timeline = useTimelineLoader(`wiki-recent-pages`, relays, [{ kinds: [WIKI_PAGE_KIND] }]); + const relays = useReadRelays(WIKI_RELAYS); + const timeline = useTimelineLoader(`wiki-recent-pages`, relays, [{ kinds: [WIKI_PAGE_KIND] }], { eventFilter }); const pages = useSubject(timeline.timeline).filter((p) => p.content.length > 0); const sorted = getWebOfTrust().sortByDistanceAndConnections(pages, (p) => p.pubkey); @@ -35,7 +43,9 @@ export default function WikiHomeView() { {sorted.map((page) => ( - + + + ))} diff --git a/src/views/wiki/page.tsx b/src/views/wiki/page.tsx index f28ae0d0d..229f9ef21 100644 --- a/src/views/wiki/page.tsx +++ b/src/views/wiki/page.tsx @@ -1,12 +1,22 @@ -import { NostrEvent } from "nostr-tools"; -import { Box, ButtonGroup, Divider, Flex, Heading, Link, Spinner, Text } from "@chakra-ui/react"; +import { NostrEvent, nip19 } from "nostr-tools"; +import { + Alert, + AlertIcon, + Box, + Button, + ButtonGroup, + Divider, + Heading, + SimpleGrid, + Spinner, + Text, +} from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; import useParamsAddressPointer from "../../hooks/use-params-address-pointer"; import useReplaceableEvent from "../../hooks/use-replaceable-event"; import VerticalPageLayout from "../../components/vertical-page-layout"; -import { getPageTitle, getPageTopic } from "../../helpers/nostr/wiki"; -import WikiSearchForm from "./components/wiki-search-form"; +import { getPageForks, getPageTitle, getPageTopic } from "../../helpers/nostr/wiki"; import MarkdownContent from "./components/markdown"; import UserLink from "../../components/user/user-link"; import { getWebOfTrust } from "../../services/web-of-trust"; @@ -15,45 +25,94 @@ import useWikiTopicTimeline from "./hooks/use-wiki-topic-timeline"; import WikiPageResult from "./components/wiki-page-result"; import Timestamp from "../../components/timestamp"; import DebugEventButton from "../../components/debug-modal/debug-event-button"; +import WikiPageHeader from "./components/wiki-page-header"; +import { WIKI_RELAYS } from "../../const"; +import GitBranch01 from "../../components/icons/git-branch-01"; +import { ExternalLinkIcon } from "../../components/icons"; +import FileSearch01 from "../../components/icons/file-search-01"; +import NoteZapButton from "../../components/note/note-zap-button"; +import ZapBubbles from "../../components/note/timeline-note/components/zap-bubbles"; +import QuoteRepostButton from "../../components/note/quote-repost-button"; function WikiPagePage({ page }: { page: NostrEvent }) { const topic = getPageTopic(page); const timeline = useWikiTopicTimeline(topic); const pages = useSubject(timeline.timeline).filter((p) => p.pubkey !== page.pubkey); - const sorted = getWebOfTrust().sortByDistanceAndConnections(pages, (p) => p.pubkey); + const { address } = getPageForks(page); + + const forks = getWebOfTrust().sortByDistanceAndConnections( + pages.filter((p) => getPageForks(p).address?.pubkey === page.pubkey), + (p) => p.pubkey, + ); + const other = getWebOfTrust().sortByDistanceAndConnections( + pages.filter((p) => !forks.includes(p)), + (p) => p.pubkey, + ); return ( - - - - Wikifreedia - - - - + + + {getPageTitle(page)} by - + {address && ( + + + + + + This page was forked from version + + + + + + + )} + - {sorted.length > 0 && ( + {forks.length > 0 && ( + <> + + Forks: + + + {forks.map((p) => ( + + ))} + + + )} + {other.length > 0 && ( <> Other Versions: - {sorted.slice(0, 6).map((page) => ( - - ))} + + {other.map((p) => ( + + ))} + )} @@ -62,7 +121,7 @@ function WikiPagePage({ page }: { page: NostrEvent }) { export default function WikiPageView() { const pointer = useParamsAddressPointer("naddr"); - const event = useReplaceableEvent(pointer, ["wss://relay.wikifreedia.xyz/"]); + const event = useReplaceableEvent(pointer, WIKI_RELAYS); if (!event) return ; return ;