chore: Update translations
This commit is contained in:
@ -11,18 +11,19 @@ import NoteAppHandler from "../Event/Note/NoteAppHandler";
|
||||
import ProfileImage from "../User/ProfileImage";
|
||||
const LiveKitRoom = lazy(() => import("./livekit"));
|
||||
|
||||
|
||||
export function LiveEvent({ ev }: { ev: TaggedNostrEvent }) {
|
||||
const service = ev.tags.find(a => a[0] === "streaming")?.at(1);
|
||||
function inner() {
|
||||
if (service?.endsWith(".m3u8")) {
|
||||
return <LiveStreamEvent ev={ev} />
|
||||
return <LiveStreamEvent ev={ev} />;
|
||||
} else if (service?.startsWith("wss+livekit://")) {
|
||||
return <Suspense>
|
||||
<LiveKitRoom ev={ev} canJoin={true} />
|
||||
</Suspense>
|
||||
return (
|
||||
<Suspense>
|
||||
<LiveKitRoom ev={ev} canJoin={true} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return <NoteAppHandler ev={ev} />
|
||||
return <NoteAppHandler ev={ev} />;
|
||||
}
|
||||
|
||||
return inner();
|
||||
|
@ -15,103 +15,117 @@ import Avatar from "../User/Avatar";
|
||||
import { AvatarGroup } from "../User/AvatarGroup";
|
||||
import DisplayName from "../User/DisplayName";
|
||||
|
||||
export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent, canJoin?: boolean }) {
|
||||
const { stream, service, id } = extractStreamInfo(ev);
|
||||
const { publisher } = useEventPublisher();
|
||||
const [join, setJoin] = useState(false);
|
||||
const [token, setToken] = useState<string>();
|
||||
export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent; canJoin?: boolean }) {
|
||||
const { stream, service, id } = extractStreamInfo(ev);
|
||||
const { publisher } = useEventPublisher();
|
||||
const [join, setJoin] = useState(false);
|
||||
const [token, setToken] = useState<string>();
|
||||
|
||||
async function getToken() {
|
||||
if (!service || !publisher)
|
||||
return;
|
||||
const url = `${service}/api/v1/nests/${id}`;
|
||||
const auth = await publisher.generic(eb => {
|
||||
eb.kind(EventKind.HttpAuthentication);
|
||||
eb.tag(["url", url]);
|
||||
eb.tag(["u", url])
|
||||
eb.tag(["method", "GET"]);
|
||||
return eb;
|
||||
});
|
||||
const rsp = await fetch(url, {
|
||||
headers: {
|
||||
authorization: `Nostr ${window.btoa(JSON.stringify(auth))}`,
|
||||
}
|
||||
});
|
||||
async function getToken() {
|
||||
if (!service || !publisher) return;
|
||||
const url = `${service}/api/v1/nests/${id}`;
|
||||
const auth = await publisher.generic(eb => {
|
||||
eb.kind(EventKind.HttpAuthentication);
|
||||
eb.tag(["url", url]);
|
||||
eb.tag(["u", url]);
|
||||
eb.tag(["method", "GET"]);
|
||||
return eb;
|
||||
});
|
||||
const rsp = await fetch(url, {
|
||||
headers: {
|
||||
authorization: `Nostr ${window.btoa(JSON.stringify(auth))}`,
|
||||
},
|
||||
});
|
||||
|
||||
const text = await rsp.text();
|
||||
if (rsp.ok) {
|
||||
return JSON.parse(text) as { token: string };
|
||||
}
|
||||
const text = await rsp.text();
|
||||
if (rsp.ok) {
|
||||
return JSON.parse(text) as { token: string };
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (join && !token) {
|
||||
getToken().then(t => setToken(t?.token)).catch(console.error);
|
||||
}
|
||||
}, [join]);
|
||||
|
||||
if (!join) {
|
||||
return <div className="p flex flex-col gap-2">
|
||||
<RoomHeader ev={ev} />
|
||||
{(canJoin ?? false) && <AsyncButton onClick={() => setJoin(true)}>
|
||||
<FormattedMessage defaultMessage="Join Room" />
|
||||
</AsyncButton>}
|
||||
</div>
|
||||
useEffect(() => {
|
||||
if (join && !token) {
|
||||
getToken()
|
||||
.then(t => setToken(t?.token))
|
||||
.catch(console.error);
|
||||
}
|
||||
return <LiveKitRoomContext token={token} serverUrl={stream?.replace("wss+livekit://", "wss://")} connect={true}>
|
||||
<RoomAudioRenderer volume={1} />
|
||||
<ParticipantList ev={ev} />
|
||||
}, [join]);
|
||||
|
||||
if (!join) {
|
||||
return (
|
||||
<div className="p flex flex-col gap-2">
|
||||
<RoomHeader ev={ev} />
|
||||
{(canJoin ?? false) && (
|
||||
<AsyncButton onClick={() => setJoin(true)}>
|
||||
<FormattedMessage defaultMessage="Join Room" />
|
||||
</AsyncButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LiveKitRoomContext token={token} serverUrl={stream?.replace("wss+livekit://", "wss://")} connect={true}>
|
||||
<RoomAudioRenderer volume={1} />
|
||||
<ParticipantList ev={ev} />
|
||||
</LiveKitRoomContext>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomHeader({ ev }: { ev: TaggedNostrEvent }) {
|
||||
const { image, title } = extractStreamInfo(ev);
|
||||
return <div className="relative rounded-xl h-[140px] w-full overflow-hidden">
|
||||
{image ? <ProxyImg src={image} className="w-full" /> :
|
||||
<div className="absolute bg-gray-dark w-full h-full" />}
|
||||
<div className="absolute left-4 top-4 w-full flex justify-between pr-4">
|
||||
<div className="text-2xl">
|
||||
{title}
|
||||
</div>
|
||||
<div>
|
||||
<NostrParticipants ev={ev} />
|
||||
</div>
|
||||
const { image, title } = extractStreamInfo(ev);
|
||||
return (
|
||||
<div className="relative rounded-xl h-[140px] w-full overflow-hidden">
|
||||
{image ? <ProxyImg src={image} className="w-full" /> : <div className="absolute bg-gray-dark w-full h-full" />}
|
||||
<div className="absolute left-4 top-4 w-full flex justify-between pr-4">
|
||||
<div className="text-2xl">{title}</div>
|
||||
<div>
|
||||
<NostrParticipants ev={ev} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantList({ ev }: { ev: TaggedNostrEvent }) {
|
||||
const participants = useParticipants()
|
||||
return <div className="p">
|
||||
<RoomHeader ev={ev} />
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Participants" />
|
||||
</h3>
|
||||
<div className="grid grid-cols-4">
|
||||
{participants.map(a => <LiveKitUser p={a} key={a.identity} />)}
|
||||
</div>
|
||||
|
||||
const participants = useParticipants();
|
||||
return (
|
||||
<div className="p">
|
||||
<RoomHeader ev={ev} />
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Participants" />
|
||||
</h3>
|
||||
<div className="grid grid-cols-4">
|
||||
{participants.map(a => (
|
||||
<LiveKitUser p={a} key={a.identity} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NostrParticipants({ ev }: { ev: TaggedNostrEvent }) {
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
const sub = useMemo(() => {
|
||||
const sub = new RequestBuilder(`livekit-participants:${link.tagKey}`);
|
||||
sub.withFilter().replyToLink([link]).kinds([10_312 as EventKind]).since(unixNow() - 600);
|
||||
return sub;
|
||||
}, [link.tagKey]);
|
||||
const link = NostrLink.fromEvent(ev);
|
||||
const sub = useMemo(() => {
|
||||
const sub = new RequestBuilder(`livekit-participants:${link.tagKey}`);
|
||||
sub
|
||||
.withFilter()
|
||||
.replyToLink([link])
|
||||
.kinds([10_312 as EventKind])
|
||||
.since(unixNow() - 600);
|
||||
return sub;
|
||||
}, [link.tagKey]);
|
||||
|
||||
const presense = useRequestBuilder(sub);
|
||||
return <AvatarGroup ids={dedupe(presense.map(a => a.pubkey))} size={32} />
|
||||
const presense = useRequestBuilder(sub);
|
||||
return <AvatarGroup ids={dedupe(presense.map(a => a.pubkey))} size={32} />;
|
||||
}
|
||||
|
||||
function LiveKitUser({ p }: { p: RemoteParticipant | LocalParticipant }) {
|
||||
const pubkey = p.identity.startsWith("guest-") ? "anon" : p.identity
|
||||
const profile = useUserProfile(pubkey);
|
||||
return <div className="flex flex-col gap-2 items-center text-center">
|
||||
<Avatar pubkey={pubkey} className={p.isSpeaking ? "outline" : ""} user={profile} size={48} />
|
||||
<DisplayName pubkey={pubkey} user={pubkey === "anon" ? { name: "Anon" } : profile} />
|
||||
const pubkey = p.identity.startsWith("guest-") ? "anon" : p.identity;
|
||||
const profile = useUserProfile(pubkey);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-center text-center">
|
||||
<Avatar pubkey={pubkey} className={p.isSpeaking ? "outline" : ""} user={profile} size={48} />
|
||||
<DisplayName pubkey={pubkey} user={pubkey === "anon" ? { name: "Anon" } : profile} />
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -3,10 +3,17 @@ import React from "react";
|
||||
|
||||
import ProfileImage from "@/Components/User/ProfileImage";
|
||||
|
||||
export function AvatarGroup({ ids, onClick, size }: { ids: HexKey[]; onClick?: () => void, size?: number }) {
|
||||
export function AvatarGroup({ ids, onClick, size }: { ids: HexKey[]; onClick?: () => void; size?: number }) {
|
||||
return ids.map((a, index) => (
|
||||
<div className={`inline-block ${index > 0 ? "-ml-4" : ""}`} key={a} style={{ zIndex: ids.length - index }}>
|
||||
<ProfileImage link="" onClick={onClick} showFollowDistance={false} pubkey={a} size={size ?? 24} showUsername={false} />
|
||||
<ProfileImage
|
||||
link=""
|
||||
onClick={onClick}
|
||||
showFollowDistance={false}
|
||||
pubkey={a}
|
||||
size={size ?? 24}
|
||||
showUsername={false}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
@ -1,72 +1,71 @@
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
|
||||
export function getHost(ev?: TaggedNostrEvent) {
|
||||
return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? "";
|
||||
return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? "";
|
||||
}
|
||||
|
||||
export type StreamState = "live" | "ended" | "planned";
|
||||
|
||||
export interface StreamInfo {
|
||||
id?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
image?: string;
|
||||
thumbnail?: string;
|
||||
status?: StreamState;
|
||||
stream?: string;
|
||||
recording?: string;
|
||||
contentWarning?: string;
|
||||
tags: Array<string>;
|
||||
goal?: string;
|
||||
participants?: string;
|
||||
starts?: string;
|
||||
ends?: string;
|
||||
service?: string;
|
||||
host?: string;
|
||||
gameId?: string;
|
||||
id?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
image?: string;
|
||||
thumbnail?: string;
|
||||
status?: StreamState;
|
||||
stream?: string;
|
||||
recording?: string;
|
||||
contentWarning?: string;
|
||||
tags: Array<string>;
|
||||
goal?: string;
|
||||
participants?: string;
|
||||
starts?: string;
|
||||
ends?: string;
|
||||
service?: string;
|
||||
host?: string;
|
||||
gameId?: string;
|
||||
}
|
||||
|
||||
const gameTagFormat = /^[a-z-]+:[a-z0-9-]+$/i;
|
||||
export function extractStreamInfo(ev?: TaggedNostrEvent) {
|
||||
const ret = {
|
||||
host: getHost(ev),
|
||||
} as StreamInfo;
|
||||
const matchTag = (tag: Array<string>, k: string, into: (v: string) => void) => {
|
||||
if (tag[0] === k) {
|
||||
into(tag[1]);
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of ev?.tags ?? []) {
|
||||
matchTag(t, "d", v => (ret.id = v));
|
||||
matchTag(t, "title", v => (ret.title = v));
|
||||
matchTag(t, "summary", v => (ret.summary = v));
|
||||
matchTag(t, "image", v => (ret.image = v));
|
||||
matchTag(t, "thumbnail", v => (ret.thumbnail = v));
|
||||
matchTag(t, "status", v => (ret.status = v as StreamState));
|
||||
if (t[0] === "streaming") {
|
||||
matchTag(t, "streaming", v => (ret.stream = v));
|
||||
}
|
||||
matchTag(t, "recording", v => (ret.recording = v));
|
||||
matchTag(t, "url", v => (ret.recording = v));
|
||||
matchTag(t, "content-warning", v => (ret.contentWarning = v));
|
||||
matchTag(t, "current_participants", v => (ret.participants = v));
|
||||
matchTag(t, "goal", v => (ret.goal = v));
|
||||
matchTag(t, "starts", v => (ret.starts = v));
|
||||
matchTag(t, "ends", v => (ret.ends = v));
|
||||
matchTag(t, "service", v => (ret.service = v));
|
||||
const ret = {
|
||||
host: getHost(ev),
|
||||
} as StreamInfo;
|
||||
const matchTag = (tag: Array<string>, k: string, into: (v: string) => void) => {
|
||||
if (tag[0] === k) {
|
||||
into(tag[1]);
|
||||
}
|
||||
const { regularTags } = sortStreamTags(ev?.tags ?? []);
|
||||
ret.tags = regularTags;
|
||||
};
|
||||
|
||||
return ret;
|
||||
for (const t of ev?.tags ?? []) {
|
||||
matchTag(t, "d", v => (ret.id = v));
|
||||
matchTag(t, "title", v => (ret.title = v));
|
||||
matchTag(t, "summary", v => (ret.summary = v));
|
||||
matchTag(t, "image", v => (ret.image = v));
|
||||
matchTag(t, "thumbnail", v => (ret.thumbnail = v));
|
||||
matchTag(t, "status", v => (ret.status = v as StreamState));
|
||||
if (t[0] === "streaming") {
|
||||
matchTag(t, "streaming", v => (ret.stream = v));
|
||||
}
|
||||
matchTag(t, "recording", v => (ret.recording = v));
|
||||
matchTag(t, "url", v => (ret.recording = v));
|
||||
matchTag(t, "content-warning", v => (ret.contentWarning = v));
|
||||
matchTag(t, "current_participants", v => (ret.participants = v));
|
||||
matchTag(t, "goal", v => (ret.goal = v));
|
||||
matchTag(t, "starts", v => (ret.starts = v));
|
||||
matchTag(t, "ends", v => (ret.ends = v));
|
||||
matchTag(t, "service", v => (ret.service = v));
|
||||
}
|
||||
const { regularTags } = sortStreamTags(ev?.tags ?? []);
|
||||
ret.tags = regularTags;
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
export function sortStreamTags(tags: Array<string | Array<string>>) {
|
||||
const plainTags = tags.filter(a => (Array.isArray(a) ? a[0] === "t" : true)).map(a => (Array.isArray(a) ? a[1] : a));
|
||||
const plainTags = tags.filter(a => (Array.isArray(a) ? a[0] === "t" : true)).map(a => (Array.isArray(a) ? a[1] : a));
|
||||
|
||||
const regularTags = plainTags.filter(a => !a.match(gameTagFormat)) ?? [];
|
||||
const prefixedTags = plainTags.filter(a => !regularTags.includes(a));
|
||||
return { regularTags, prefixedTags };
|
||||
}
|
||||
const regularTags = plainTags.filter(a => !a.match(gameTagFormat)) ?? [];
|
||||
const prefixedTags = plainTags.filter(a => !regularTags.includes(a));
|
||||
return { regularTags, prefixedTags };
|
||||
}
|
||||
|
Reference in New Issue
Block a user