diff --git a/package.json b/package.json index 7abebc3..e66a0d9 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-tabs": "^1.0.4", "@react-hook/resize-observer": "^1.2.6", "@snort/system-react": "^1.0.8", "@testing-library/jest-dom": "^5.14.1", diff --git a/public/index.html b/public/index.html index c003a64..a2044b9 100644 --- a/public/index.html +++ b/public/index.html @@ -12,10 +12,10 @@ Nostr stream - +
- \ No newline at end of file + diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..40e3568 --- /dev/null +++ b/src/const.ts @@ -0,0 +1,4 @@ +import { EventKind } from "@snort/system"; + +export const LIVE_STREAM = 30_311 as EventKind; +export const LIVE_STREAM_CHAT = 1_311 as EventKind; diff --git a/src/element/async-button.tsx b/src/element/async-button.tsx index 4e8153a..95d05a3 100644 --- a/src/element/async-button.tsx +++ b/src/element/async-button.tsx @@ -2,7 +2,8 @@ import "./async-button.css"; import { useState } from "react"; import Spinner from "element/spinner"; -interface AsyncButtonProps extends React.ButtonHTMLAttributes { +interface AsyncButtonProps + extends React.ButtonHTMLAttributes { disabled?: boolean; onClick(e: React.MouseEvent): Promise | void; children?: React.ReactNode; @@ -28,8 +29,15 @@ export default function AsyncButton(props: AsyncButtonProps) { } return ( - + {props.button ? ( + props.button + ) : ( + + )} - setIsOpen(false)} - /> + setIsOpen(false)} /> diff --git a/src/element/tags.tsx b/src/element/tags.tsx new file mode 100644 index 0000000..166135c --- /dev/null +++ b/src/element/tags.tsx @@ -0,0 +1,27 @@ +import moment from "moment"; +import { TaggedRawEvent } from "@snort/system"; +import { StreamState } from "index"; +import { findTag } from "utils"; + +export function Tags({ ev }: { ev: TaggedRawEvent }) { + const status = findTag(ev, "status"); + const start = findTag(ev, "starts"); + return ( +
+ {status === StreamState.Planned && ( + + {status === StreamState.Planned ? "Starts " : ""} + {moment(Number(start) * 1000).fromNow()} + + )} + {ev.tags + .filter((a) => a[0] === "t") + .map((a) => a[1]) + .map((a) => ( + + {a} + + ))} +
+ ); +} diff --git a/src/element/text.tsx b/src/element/text.tsx index e722d55..56070f3 100644 --- a/src/element/text.tsx +++ b/src/element/text.tsx @@ -1,5 +1,5 @@ import { useMemo, type ReactNode } from "react"; -import { TaggedRawEvent, validateNostrLink } from "@snort/system"; +import { validateNostrLink } from "@snort/system"; import { splitByUrl } from "utils"; import { Emoji } from "./emoji"; import { HyperText } from "./hypertext"; @@ -74,11 +74,11 @@ function extractLinks(fragments: Fragment[]) { .flat(); } -export function Text({ ev }: { ev: TaggedRawEvent }) { +export function Text({ content, tags }: { content: string; tags: string[][] }) { // todo: RTL langugage support const element = useMemo(() => { - return {transformText([ev.content], ev.tags)}; - }, [ev]); + return {transformText([content], tags)}; + }, [content, tags]); return <>{element}; } diff --git a/src/element/textarea.css b/src/element/textarea.css index fd7d919..8802138 100644 --- a/src/element/textarea.css +++ b/src/element/textarea.css @@ -34,6 +34,3 @@ height: 21px; border-radius: 100%; } - -.user-details { -} diff --git a/src/element/video-tile.tsx b/src/element/video-tile.tsx index 2f40670..6463701 100644 --- a/src/element/video-tile.tsx +++ b/src/element/video-tile.tsx @@ -6,24 +6,41 @@ import { useInView } from "react-intersection-observer"; import { StatePill } from "./state-pill"; import { StreamState } from "index"; -export function VideoTile({ ev }: { ev: NostrEvent }) { - const { inView, ref } = useInView({ triggerOnce: true }); - const id = ev.tags.find(a => a[0] === "d")?.[1]!; - const title = ev.tags.find(a => a[0] === "title")?.[1]; - const image = ev.tags.find(a => a[0] === "image")?.[1]; - const status = ev.tags.find(a => a[0] === "status")?.[1]; - const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey; +export function VideoTile({ + ev, + showAuthor = true, + showStatus = true, +}: { + ev: NostrEvent; + showAuthor?: boolean; + showStatus?: boolean; +}) { + const { inView, ref } = useInView({ triggerOnce: true }); + const id = ev.tags.find((a) => a[0] === "d")?.[1]!; + const title = ev.tags.find((a) => a[0] === "title")?.[1]; + const image = ev.tags.find((a) => a[0] === "image")?.[1]; + const status = ev.tags.find((a) => a[0] === "status")?.[1]; + const host = + ev.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey; - const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey); - return -
- -
-

{title}

-
- {inView && } -
+ const link = encodeTLV( + NostrPrefix.Address, + id, + undefined, + ev.kind, + ev.pubkey + ); + return ( + +
+ {showStatus && } +
+

{title}

+ {showAuthor &&
{inView && }
} -} \ No newline at end of file + ); +} diff --git a/src/hooks/follows.ts b/src/hooks/follows.ts new file mode 100644 index 0000000..657354d --- /dev/null +++ b/src/hooks/follows.ts @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import { EventKind, ReplaceableNoteStore, RequestBuilder } from "@snort/system"; +import { useRequestBuilder } from "@snort/system-react"; +import { System } from "index"; + +export default function useFollows(pubkey: string, leaveOpen = false) { + const sub = useMemo(() => { + const b = new RequestBuilder(`follows:${pubkey.slice(0, 12)}`); + b.withOptions({ + leaveOpen, + }) + .withFilter() + .authors([pubkey]) + .kinds([EventKind.ContactList]); + return b; + }, [pubkey, leaveOpen]); + + const { data } = useRequestBuilder( + System, + ReplaceableNoteStore, + sub + ); + + const contacts = (data?.tags ?? []).filter((t) => t.at(0) === "p"); + const relays = JSON.parse(data?.content ?? "{}"); + + return { contacts, relays }; +} diff --git a/src/hooks/live-chat.tsx b/src/hooks/live-chat.tsx index fec2c02..01a28fd 100644 --- a/src/hooks/live-chat.tsx +++ b/src/hooks/live-chat.tsx @@ -1,7 +1,13 @@ -import { NostrLink, RequestBuilder, EventKind, FlatNoteStore } from "@snort/system"; +import { + NostrLink, + RequestBuilder, + EventKind, + FlatNoteStore, +} from "@snort/system"; import { useRequestBuilder } from "@snort/system-react"; import { System } from "index"; import { useMemo } from "react"; +import { LIVE_STREAM_CHAT } from "const"; export function useLiveChatFeed(link: NostrLink) { const sub = useMemo(() => { @@ -10,11 +16,11 @@ export function useLiveChatFeed(link: NostrLink) { leaveOpen: true, }); rb.withFilter() - .kinds([EventKind.ZapReceipt, 1311 as EventKind]) + .kinds([EventKind.ZapReceipt, LIVE_STREAM_CHAT]) .tag("a", [`${link.kind}:${link.author}:${link.id}`]) .limit(100); return rb; }, [link]); return useRequestBuilder(System, FlatNoteStore, sub); -} \ No newline at end of file +} diff --git a/src/hooks/login.ts b/src/hooks/login.ts index 1829200..77c8c07 100644 --- a/src/hooks/login.ts +++ b/src/hooks/login.ts @@ -2,5 +2,8 @@ import { Login } from "index"; import { useSyncExternalStore } from "react"; export function useLogin() { - return useSyncExternalStore(c => Login.hook(c), () => Login.snapshot()); -} \ No newline at end of file + return useSyncExternalStore( + (c) => Login.hook(c), + () => Login.snapshot() + ); +} diff --git a/src/hooks/profile.ts b/src/hooks/profile.ts new file mode 100644 index 0000000..fabd01a --- /dev/null +++ b/src/hooks/profile.ts @@ -0,0 +1,67 @@ +import { useMemo } from "react"; +import { + RequestBuilder, + ReplaceableNoteStore, + FlatNoteStore, + NostrLink, + EventKind, + parseZap, +} from "@snort/system"; +import { useRequestBuilder } from "@snort/system-react"; +import { LIVE_STREAM } from "const"; +import { findTag } from "utils"; +import { System } from "index"; + +export function useProfile(link: NostrLink, leaveOpen = false) { + const sub = useMemo(() => { + const b = new RequestBuilder(`profile:${link.id.slice(0, 12)}`); + b.withOptions({ + leaveOpen, + }) + .withFilter() + .kinds([LIVE_STREAM]) + .authors([link.id]); + return b; + }, [link, leaveOpen]); + + const { data: streamsData } = useRequestBuilder( + System, + ReplaceableNoteStore, + sub + ); + + const streams = Array.isArray(streamsData) + ? streamsData + : streamsData + ? [streamsData] + : []; + + const addresses = useMemo(() => { + return streams.map((e) => `${e.kind}:${e.pubkey}:${findTag(e, "d")}`); + }, [streamsData]); + + const zapsSub = useMemo(() => { + const b = new RequestBuilder(`profile-zaps:${link.id.slice(0, 12)}`); + b.withOptions({ + leaveOpen, + }) + .withFilter() + .kinds([EventKind.ZapReceipt]) + .tag("a", addresses); + return b; + }, [link, addresses, leaveOpen]); + + const { data: zapsData } = useRequestBuilder( + System, + FlatNoteStore, + zapsSub + ); + const zaps = (zapsData ?? []) + .map((ev) => parseZap(ev, System.ProfileLoader.Cache)) + .filter((z) => z && z.valid); + + return { + streams, + zaps, + }; +} diff --git a/src/hooks/top-zappers.ts b/src/hooks/top-zappers.ts new file mode 100644 index 0000000..1ce59fc --- /dev/null +++ b/src/hooks/top-zappers.ts @@ -0,0 +1,25 @@ +import { useMemo } from "react"; +import { ParsedZap } from "@snort/system"; + +function totalZapped(pubkey: string, zaps: ParsedZap[]) { + return zaps + .filter((z) => (z.anonZap ? pubkey === "anon" : z.sender === pubkey)) + .reduce((acc, z) => acc + z.amount, 0); +} + +export default function useTopZappers(zaps: ParsedZap[]) { + const zappers = zaps + .map((z) => (z.anonZap ? "anon" : z.sender)) + .map((p) => p as string); + + const sorted = useMemo(() => { + const pubkeys = [...new Set([...zappers])]; + const result = pubkeys.map((pubkey) => { + return { pubkey, total: totalZapped(pubkey, zaps) }; + }); + result.sort((a, b) => b.total - a.total); + return result; + }, [zaps, zappers]); + + return sorted; +} diff --git a/src/icons.svg b/src/icons.svg index 7e290f8..49be568 100644 --- a/src/icons.svg +++ b/src/icons.svg @@ -3,6 +3,9 @@ + + + @@ -19,4 +22,4 @@ - \ No newline at end of file + diff --git a/src/index.tsx b/src/index.tsx index 0ee0182..a7164e9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { RootPage } from "./pages/root"; import { LayoutPage } from "pages/layout"; +import { ProfilePage } from "pages/profile-page"; import { StreamPage } from "pages/stream-page"; import { ChatPopout } from "pages/chat-popout"; import { LoginStore } from "login"; @@ -15,7 +16,7 @@ import { StreamProvidersPage } from "pages/providers"; export enum StreamState { Live = "live", Ended = "ended", - Planned = "planned" + Planned = "planned", } export const System = new NostrSystem({}); @@ -43,8 +44,8 @@ const router = createBrowserRouter([ element: , }, { - path: "/:id", - element: , + path: "/p/:npub", + element: , }, { path: "/live/:id", @@ -53,7 +54,7 @@ const router = createBrowserRouter([ { path: "/providers/:id?", element: , - } + }, ], }, { diff --git a/src/login.ts b/src/login.ts index c856704..0603211 100644 --- a/src/login.ts +++ b/src/login.ts @@ -1,29 +1,31 @@ import { ExternalStore } from "@snort/shared"; export interface LoginSession { - pubkey: string + pubkey: string; + follows: string[]; } export class LoginStore extends ExternalStore { - #session?: LoginSession; + #session?: LoginSession; - constructor() { - super(); - const json = window.localStorage.getItem("session"); - if (json) { - this.#session = JSON.parse(json); - } + constructor() { + super(); + const json = window.localStorage.getItem("session"); + if (json) { + this.#session = JSON.parse(json); } + } - loginWithPubkey(pk: string) { - this.#session = { - pubkey: pk - }; - window.localStorage.setItem("session", JSON.stringify(this.#session)); - this.notifyChange(); - } + loginWithPubkey(pk: string) { + this.#session = { + pubkey: pk, + follows: [], + }; + window.localStorage.setItem("session", JSON.stringify(this.#session)); + this.notifyChange(); + } - takeSnapshot() { - return this.#session ? { ...this.#session } : undefined; - } -} \ No newline at end of file + takeSnapshot() { + return this.#session ? { ...this.#session } : undefined; + } +} diff --git a/src/pages/layout.css b/src/pages/layout.css index 6716a20..2bf8847 100644 --- a/src/pages/layout.css +++ b/src/pages/layout.css @@ -11,8 +11,7 @@ height: 100vh; } - -.page.home { +.page.only-content { display: grid; height: 100vh; grid-template-areas: @@ -84,6 +83,10 @@ header { gap: 24px; padding: 24px 0 32px 0; } + + .page.only-content { + grid-template-rows: 88px 1fr; + } } header .logo { @@ -172,3 +175,12 @@ button span.hide-on-mobile { max-height: 85vh; padding: 25px; } + +.zap-icon { + color: #FF8D2B; +} + +.tags { + display: flex; + gap: 8px; +} diff --git a/src/pages/layout.tsx b/src/pages/layout.tsx index c6545a8..92b981d 100644 --- a/src/pages/layout.tsx +++ b/src/pages/layout.tsx @@ -70,8 +70,8 @@ export function LayoutPage() { return (
+ +
+ +

{formatSats(total)}

+
+
+ ); +} + +function TopZappers({ zaps }: { zaps: ParsedZap[] }) { + const zappers = useTopZappers(zaps); + return ( +
+ {zappers.map((z) => ( + + ))} +
+ ); +} + +const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp"; + +export function ProfilePage() { + const navigate = useNavigate(); + const params = useParams(); + const link = parseNostrLink(params.npub!); + const profile = useUserProfile(System, link.id); + const zapTarget = profile?.lud16 ?? profile?.lud06; + const { streams, zaps } = useProfile(link, true); + const liveEvent = useMemo(() => { + return streams.find((ev) => findTag(ev, "status") === StreamState.Live); + }, [streams]); + const pastStreams = useMemo(() => { + return streams.filter((ev) => findTag(ev, "status") === StreamState.Ended); + }, [streams]); + const futureStreams = useMemo(() => { + return streams.filter( + (ev) => findTag(ev, "status") === StreamState.Planned + ); + }, [streams]); + const isLive = Boolean(liveEvent); + + function goToLive() { + if (liveEvent) { + const d = + liveEvent.tags?.find((t: string[]) => t?.at(0) === "d")?.at(1) || ""; + const naddr = encodeTLV( + NostrPrefix.Address, + d, + undefined, + liveEvent.kind, + liveEvent.pubkey + ); + navigate(`/live/${naddr}`); + } + } + + return ( +
+
+ {profile?.name +
+ {profile?.picture && ( + {profile.name + )} +
+ {isLive ? ( +
+ + live +
+ ) : ( + offline + )} +
+
+ {zapTarget && ( + +
+ Zap + +
+ + } + targetName={profile?.name || link.id} + /> + )} + +
+
+ {profile?.name &&

{profile.name}

} + {profile?.about && ( +

+ +

+ )} +
+ + + + Top Zappers + + + Past Streams + + + Schedule + + + + + + +
+ {pastStreams.map((ev) => ( +
+ + + Streamed on{" "} + {moment(Number(ev.created_at) * 1000).format( + "MMM DD, YYYY" + )} + +
+ ))} +
+
+ +
+ {futureStreams.map((ev) => ( +
+ + + Scheduled for{" "} + {moment(Number(ev.created_at) * 1000).format( + "MMM DD, YYYY h:mm:ss a" + )} + +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/src/pages/root.tsx b/src/pages/root.tsx index 456edc7..4536b22 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -2,53 +2,84 @@ import "./root.css"; import { useMemo } from "react"; import { unixNow } from "@snort/shared"; -import { EventKind, ParameterizedReplaceableNoteStore, RequestBuilder } from "@snort/system"; +import { + ParameterizedReplaceableNoteStore, + RequestBuilder, +} from "@snort/system"; import { useRequestBuilder } from "@snort/system-react"; import { StreamState, System } from ".."; import { VideoTile } from "../element/video-tile"; import { findTag } from "utils"; +import { LIVE_STREAM } from "const"; export function RootPage() { - const rb = useMemo(() => { - const rb = new RequestBuilder("root"); - rb.withOptions({ - leaveOpen: true - }).withFilter() - .kinds([30_311 as EventKind]) - .since(unixNow() - 86400); - return rb; - }, []); + const rb = useMemo(() => { + const rb = new RequestBuilder("root"); + rb.withOptions({ + leaveOpen: true, + }) + .withFilter() + .kinds([LIVE_STREAM]) + .since(unixNow() - 86400); + return rb; + }, []); - const feed = useRequestBuilder(System, ParameterizedReplaceableNoteStore, rb); - const feedSorted = useMemo(() => { - if (feed.data) { - return [...feed.data].sort((a, b) => { - const aStatus = findTag(a, "status")!; - const bStatus = findTag(b, "status")!; - if (aStatus === bStatus) { - return b.created_at > a.created_at ? 1 : -1; - } else { - return aStatus === "live" ? -1 : 1; - } - }); + const feed = useRequestBuilder( + System, + ParameterizedReplaceableNoteStore, + rb + ); + const feedSorted = useMemo(() => { + if (feed.data) { + return [...feed.data].sort((a, b) => { + const aStatus = findTag(a, "status")!; + const bStatus = findTag(b, "status")!; + if (aStatus === bStatus) { + return b.created_at > a.created_at ? 1 : -1; + } else { + return aStatus === "live" ? -1 : 1; } - return []; - }, [feed.data]) + }); + } + return []; + }, [feed.data]); - const live = feedSorted.filter(a => findTag(a, "status") === StreamState.Live); - const planned = feedSorted.filter(a => findTag(a, "status") === StreamState.Planned); - const ended = feedSorted.filter(a => findTag(a, "status") === StreamState.Ended); - return
-
- {live.map(e => )} -
- {planned.length > 0 && <>

Planned

-
- {planned.map(e => )} -
} - {ended.length > 0 && <>

Ended

-
- {ended.map(e => )} -
} + const live = feedSorted.filter( + (a) => findTag(a, "status") === StreamState.Live + ); + const planned = feedSorted.filter( + (a) => findTag(a, "status") === StreamState.Planned + ); + const ended = feedSorted.filter( + (a) => findTag(a, "status") === StreamState.Ended + ); + return ( +
+
+ {live.map((e) => ( + + ))} +
+ {planned.length > 0 && ( + <> +

Planned

+
+ {planned.map((e) => ( + + ))} +
+ + )} + {ended.length > 0 && ( + <> +

Ended

+
+ {ended.map((e) => ( + + ))} +
+ + )}
-} \ No newline at end of file + ); +} diff --git a/src/pages/stream-page.css b/src/pages/stream-page.css index 0d6e83e..139f721 100644 --- a/src/pages/stream-page.css +++ b/src/pages/stream-page.css @@ -43,6 +43,7 @@ .pill.live { color: inherit; + text-transform: uppercase; } @media (min-width: 1020px) { @@ -103,11 +104,6 @@ margin: 0 0 12px 0; } -.tags { - display: flex; - gap: 8px; -} - .actions { margin: 8px 0 0 0; display: flex; diff --git a/src/pages/stream-page.tsx b/src/pages/stream-page.tsx index 5fa62c4..0e5f70b 100644 --- a/src/pages/stream-page.tsx +++ b/src/pages/stream-page.tsx @@ -1,8 +1,6 @@ import "./stream-page.css"; -import { useRef } from "react"; import { parseNostrLink, EventPublisher } from "@snort/system"; import { useNavigate, useParams } from "react-router-dom"; -import moment from "moment"; import useEventFeed from "hooks/event-feed"; import { LiveVideoPlayer } from "element/live-video-player"; @@ -16,18 +14,20 @@ import { SendZapsDialog } from "element/send-zap"; import type { NostrLink } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; import { NewStreamDialog } from "element/new-stream"; +import { Tags } from "element/tags"; import { StatePill } from "element/state-pill"; function ProfileInfo({ link }: { link: NostrLink }) { const thisEvent = useEventFeed(link, true); const login = useLogin(); const navigate = useNavigate(); - const host = thisEvent.data?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? thisEvent.data?.pubkey; + const host = + thisEvent.data?.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ?? + thisEvent.data?.pubkey; const profile = useUserProfile(System, host); const zapTarget = profile?.lud16 ?? profile?.lud06; - const status = findTag(thisEvent.data, "status"); - const start = findTag(thisEvent.data, "starts"); + const status = thisEvent?.data ? findTag(thisEvent.data, "status") : ""; const isMine = link.author === login?.pubkey; async function deleteStream() { @@ -46,22 +46,8 @@ function ProfileInfo({ link }: { link: NostrLink }) {

{findTag(thisEvent.data, "title")}

{findTag(thisEvent.data, "summary")}

-
- - {status === StreamState.Planned && ( - - Starts {moment(Number(start) * 1000).fromNow()} - - )} - {thisEvent.data?.tags - .filter((a) => a[0] === "t") - .map((a) => a[1]) - .map((a) => ( - - {a} - - ))} -
+ + {thisEvent?.data && } {isMine && (
{thisEvent.data && ( @@ -83,7 +69,10 @@ function ProfileInfo({ link }: { link: NostrLink }) { )} diff --git a/yarn.lock b/yarn.lock index b2fc368..6d10e5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1706,6 +1706,17 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-collection@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159" + integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-compose-refs@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989" @@ -1741,6 +1752,13 @@ aria-hidden "^1.1.1" react-remove-scroll "2.5.5" +"@radix-ui/react-direction@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b" + integrity sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-dismissable-layer@1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978" @@ -1803,6 +1821,22 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-slot" "1.0.2" +"@radix-ui/react-roving-focus@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974" + integrity sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-slot@1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" @@ -1811,6 +1845,21 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-compose-refs" "1.0.1" +"@radix-ui/react-tabs@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2" + integrity sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-roving-focus" "1.0.4" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-callback-ref@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"