mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-28 20:43:33 +02:00
Merge branch 'next'
This commit is contained in:
5
.changeset/beige-waves-wash.md
Normal file
5
.changeset/beige-waves-wash.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Show streamer cards in stream view on desktop
|
5
.changeset/stupid-dodos-design.md
Normal file
5
.changeset/stupid-dodos-design.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix bug where mentioning npub would freeze app
|
@@ -5,7 +5,7 @@ import QuoteNote from "../note/quote-note";
|
|||||||
import { UserLink } from "../user-link";
|
import { UserLink } from "../user-link";
|
||||||
import { Link } from "@chakra-ui/react";
|
import { Link } from "@chakra-ui/react";
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import { getMatchHashtag, getMatchNostrLink } from "../../helpers/regexp";
|
import { getMatchHashtag, getMatchNostrLink, stripInvisibleChar } from "../../helpers/regexp";
|
||||||
|
|
||||||
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
|
// nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9
|
||||||
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
|
// nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum
|
||||||
@@ -59,7 +59,10 @@ export function embedNostrMentions(content: EmbedableContent, event: NostrEvent
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function embedNostrHashtags(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
|
export function embedNostrHashtags(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
|
||||||
const hashtags = event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]?.toLowerCase()) as string[];
|
const hashtags = event.tags
|
||||||
|
.filter((t) => t[0] === "t" && t[1])
|
||||||
|
.map((t) => t[1]?.toLowerCase())
|
||||||
|
.map(stripInvisibleChar);
|
||||||
|
|
||||||
return embedJSX(content, {
|
return embedJSX(content, {
|
||||||
name: "nostr-hashtag",
|
name: "nostr-hashtag",
|
||||||
|
@@ -1,16 +1,28 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { IconButton } from "@chakra-ui/react";
|
import { IconButton } from "@chakra-ui/react";
|
||||||
|
import { Kind } from "nostr-tools";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { NostrEvent } from "../../../types/nostr-event";
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
import { QuoteRepostIcon } from "../../icons";
|
import { QuoteRepostIcon } from "../../icons";
|
||||||
import { PostModalContext } from "../../../providers/post-modal-provider";
|
import { PostModalContext } from "../../../providers/post-modal-provider";
|
||||||
import { buildQuoteRepost } from "../../../helpers/nostr/event";
|
|
||||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||||
|
import { getSharableNoteId } from "../../../helpers/nip19";
|
||||||
|
|
||||||
export function QuoteRepostButton({ event }: { event: NostrEvent }) {
|
export function QuoteRepostButton({ event }: { event: NostrEvent }) {
|
||||||
const account = useCurrentAccount();
|
const account = useCurrentAccount();
|
||||||
const { openModal } = useContext(PostModalContext);
|
const { openModal } = useContext(PostModalContext);
|
||||||
|
|
||||||
const handleClick = () => openModal(buildQuoteRepost(event));
|
const handleClick = () => {
|
||||||
|
const nevent = getSharableNoteId(event.id);
|
||||||
|
const draft = {
|
||||||
|
kind: Kind.Text,
|
||||||
|
tags: [],
|
||||||
|
content: "nostr:" + nevent,
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
};
|
||||||
|
openModal(draft);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@@ -1,24 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { IconButton } from "@chakra-ui/react";
|
|
||||||
import { NostrEvent } from "../../../types/nostr-event";
|
|
||||||
import { ReplyIcon } from "../../icons";
|
|
||||||
import { PostModalContext } from "../../../providers/post-modal-provider";
|
|
||||||
import { buildReply } from "../../../helpers/nostr/event";
|
|
||||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
|
||||||
|
|
||||||
export function ReplyButton({ event }: { event: NostrEvent }) {
|
|
||||||
const account = useCurrentAccount();
|
|
||||||
const { openModal } = useContext(PostModalContext);
|
|
||||||
|
|
||||||
const reply = () => openModal(buildReply(event));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
icon={<ReplyIcon />}
|
|
||||||
title="Reply"
|
|
||||||
aria-label="Reply"
|
|
||||||
onClick={reply}
|
|
||||||
isDisabled={account?.readonly ?? true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -18,11 +18,12 @@ import {
|
|||||||
embedEmoji,
|
embedEmoji,
|
||||||
renderOpenGraphUrl,
|
renderOpenGraphUrl,
|
||||||
embedImageGallery,
|
embedImageGallery,
|
||||||
|
renderGenericUrl,
|
||||||
} from "../embed-types";
|
} from "../embed-types";
|
||||||
import { LightboxProvider } from "../lightbox-provider";
|
import { LightboxProvider } from "../lightbox-provider";
|
||||||
import { renderRedditUrl } from "../embed-types/reddit";
|
import { renderRedditUrl } from "../embed-types/reddit";
|
||||||
|
|
||||||
function buildContents(event: NostrEvent | DraftNostrEvent) {
|
function buildContents(event: NostrEvent | DraftNostrEvent, simpleLinks = false) {
|
||||||
let content: EmbedableContent = [event.content.trim()];
|
let content: EmbedableContent = [event.content.trim()];
|
||||||
|
|
||||||
// image gallery
|
// image gallery
|
||||||
@@ -39,7 +40,7 @@ function buildContents(event: NostrEvent | DraftNostrEvent) {
|
|||||||
renderTidalUrl,
|
renderTidalUrl,
|
||||||
renderImageUrl,
|
renderImageUrl,
|
||||||
renderVideoUrl,
|
renderVideoUrl,
|
||||||
renderOpenGraphUrl,
|
simpleLinks ? renderGenericUrl : renderOpenGraphUrl,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// bitcoin
|
// bitcoin
|
||||||
@@ -56,10 +57,12 @@ function buildContents(event: NostrEvent | DraftNostrEvent) {
|
|||||||
|
|
||||||
export type NoteContentsProps = {
|
export type NoteContentsProps = {
|
||||||
event: NostrEvent | DraftNostrEvent;
|
event: NostrEvent | DraftNostrEvent;
|
||||||
|
noOpenGraphLinks?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoteContents = React.memo(({ event, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
|
export const NoteContents = React.memo(
|
||||||
const content = buildContents(event);
|
({ event, noOpenGraphLinks, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
|
||||||
|
const content = buildContents(event, noOpenGraphLinks);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LightboxProvider>
|
<LightboxProvider>
|
||||||
@@ -68,4 +71,5 @@ export const NoteContents = React.memo(({ event, ...props }: NoteContentsProps &
|
|||||||
</Box>
|
</Box>
|
||||||
</LightboxProvider>
|
</LightboxProvider>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
@@ -14,6 +14,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||||
import { getReferences } from "../../helpers/nostr/event";
|
import { getReferences } from "../../helpers/nostr/event";
|
||||||
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
|
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
|
||||||
@@ -24,7 +25,8 @@ import { NoteLink } from "../note-link";
|
|||||||
import { NoteContents } from "../note/note-contents";
|
import { NoteContents } from "../note/note-contents";
|
||||||
import { PublishDetails } from "../publish-details";
|
import { PublishDetails } from "../publish-details";
|
||||||
import { TrustProvider } from "../../providers/trust";
|
import { TrustProvider } from "../../providers/trust";
|
||||||
import { finalizeNote } from "../../helpers/nostr/post";
|
import { ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../helpers/nostr/post";
|
||||||
|
import { UserAvatarStack } from "../user-avatar-stack";
|
||||||
|
|
||||||
function emptyDraft(): DraftNostrEvent {
|
function emptyDraft(): DraftNostrEvent {
|
||||||
return {
|
return {
|
||||||
@@ -77,7 +79,9 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setWaiting(true);
|
setWaiting(true);
|
||||||
const updatedDraft = finalizeNote(draft);
|
let updatedDraft = finalizeNote(draft);
|
||||||
|
const contentMentions = getContentMentions(draft.content);
|
||||||
|
updatedDraft = ensureNotifyPubkeys(updatedDraft, contentMentions);
|
||||||
const signed = await requestSignature(updatedDraft);
|
const signed = await requestSignature(updatedDraft);
|
||||||
setWaiting(false);
|
setWaiting(false);
|
||||||
if (!signed) return;
|
if (!signed) return;
|
||||||
@@ -144,6 +148,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
|
|||||||
isLoading={uploading}
|
isLoading={uploading}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<UserAvatarStack label="Mentions" users={getContentMentions(draft.content)} />
|
||||||
{draft.content.length > 0 && <Button onClick={togglePreview}>Preview</Button>}
|
{draft.content.length > 0 && <Button onClick={togglePreview}>Preview</Button>}
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<Button colorScheme="blue" type="submit" isLoading={waiting} onClick={handleSubmit} isDisabled={!canSubmit}>
|
<Button colorScheme="blue" type="submit" isLoading={waiting} onClick={handleSubmit} isDisabled={!canSubmit}>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { bech32 } from "bech32";
|
import { bech32 } from "bech32";
|
||||||
import { nip19 } from "nostr-tools";
|
import { getPublicKey, nip19 } from "nostr-tools";
|
||||||
import { getEventRelays } from "../services/event-relays";
|
import { getEventRelays } from "../services/event-relays";
|
||||||
import relayScoreboardService from "../services/relay-scoreboard";
|
import relayScoreboardService from "../services/relay-scoreboard";
|
||||||
|
|
||||||
@@ -69,6 +69,18 @@ export function safeDecode(str: string) {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPubkey(result: nip19.DecodeResult){
|
||||||
|
switch(result.type){
|
||||||
|
case 'naddr':
|
||||||
|
case 'nprofile':
|
||||||
|
return result.data.pubkey
|
||||||
|
case 'npub':
|
||||||
|
return result.data;
|
||||||
|
case 'nsec':
|
||||||
|
return getPublicKey(result.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeToBech32(key: string, prefix: Bech32Prefix = Bech32Prefix.Pubkey) {
|
export function normalizeToBech32(key: string, prefix: Bech32Prefix = Bech32Prefix.Pubkey) {
|
||||||
if (isHex(key)) return hexToBech32(key, prefix);
|
if (isHex(key)) return hexToBech32(key, prefix);
|
||||||
if (isBech32Key(key)) return key;
|
if (isBech32Key(key)) return key;
|
||||||
|
@@ -2,10 +2,8 @@ import dayjs from "dayjs";
|
|||||||
import { getEventRelays } from "../../services/event-relays";
|
import { getEventRelays } from "../../services/event-relays";
|
||||||
import { DraftNostrEvent, isETag, isPTag, NostrEvent, RTag, Tag } from "../../types/nostr-event";
|
import { DraftNostrEvent, isETag, isPTag, NostrEvent, RTag, Tag } from "../../types/nostr-event";
|
||||||
import { RelayConfig, RelayMode } from "../../classes/relay";
|
import { RelayConfig, RelayMode } from "../../classes/relay";
|
||||||
import accountService from "../../services/account";
|
|
||||||
import { Kind, nip19 } from "nostr-tools";
|
import { Kind, nip19 } from "nostr-tools";
|
||||||
import { getMatchNostrLink } from "../regexp";
|
import { getMatchNostrLink } from "../regexp";
|
||||||
import { getSharableNoteId } from "../nip19";
|
|
||||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||||
import { getAddr } from "../../services/replaceable-event-requester";
|
import { getAddr } from "../../services/replaceable-event-requester";
|
||||||
|
|
||||||
@@ -134,37 +132,6 @@ export function getReferences(event: NostrEvent | DraftNostrEvent) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReply(event: NostrEvent, account = accountService.current.value): DraftNostrEvent {
|
|
||||||
const refs = getReferences(event);
|
|
||||||
const relay = getEventRelays(event.id).value?.[0] ?? "";
|
|
||||||
|
|
||||||
const tags: NostrEvent["tags"] = [];
|
|
||||||
|
|
||||||
const rootId = refs.rootId ?? event.id;
|
|
||||||
const replyId = event.id;
|
|
||||||
|
|
||||||
tags.push(["e", rootId, relay, "root"]);
|
|
||||||
if (replyId !== rootId) {
|
|
||||||
tags.push(["e", replyId, relay, "reply"]);
|
|
||||||
}
|
|
||||||
// add all ptags
|
|
||||||
// TODO: omit my own pubkey
|
|
||||||
const ptags = event.tags.filter(isPTag).filter((t) => !account || t[1] !== account.pubkey);
|
|
||||||
tags.push(...ptags);
|
|
||||||
// add the original authors pubkey if its not already there
|
|
||||||
if (!ptags.some((t) => t[1] === event.pubkey)) {
|
|
||||||
tags.push(["p", event.pubkey]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: Kind.Text,
|
|
||||||
// TODO: be smarter about picking relay
|
|
||||||
tags,
|
|
||||||
content: "",
|
|
||||||
created_at: dayjs().unix(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRepost(event: NostrEvent): DraftNostrEvent {
|
export function buildRepost(event: NostrEvent): DraftNostrEvent {
|
||||||
const relays = getEventRelays(event.id).value;
|
const relays = getEventRelays(event.id).value;
|
||||||
const topRelay = relayScoreboardService.getRankedRelays(relays)[0] ?? "";
|
const topRelay = relayScoreboardService.getRankedRelays(relays)[0] ?? "";
|
||||||
@@ -180,17 +147,6 @@ export function buildRepost(event: NostrEvent): DraftNostrEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildQuoteRepost(event: NostrEvent): DraftNostrEvent {
|
|
||||||
const nevent = getSharableNoteId(event.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: Kind.Text,
|
|
||||||
tags: [],
|
|
||||||
content: "nostr:" + nevent,
|
|
||||||
created_at: dayjs().unix(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDeleteEvent(eventIds: string[], reason = ""): DraftNostrEvent {
|
export function buildDeleteEvent(eventIds: string[], reason = ""): DraftNostrEvent {
|
||||||
return {
|
return {
|
||||||
kind: Kind.EventDeletion,
|
kind: Kind.EventDeletion,
|
||||||
@@ -210,3 +166,19 @@ export function parseRTag(tag: RTag): RelayConfig {
|
|||||||
return { url: tag[1], mode: RelayMode.ALL };
|
return { url: tag[1], mode: RelayMode.ALL };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseCoordinate(a: string) {
|
||||||
|
const parts = a.split(":") as (string | undefined)[];
|
||||||
|
const kind = parts[0] && parseInt(parts[0]);
|
||||||
|
const pubkey = parts[1];
|
||||||
|
const d = parts[2];
|
||||||
|
|
||||||
|
if (!kind) return null;
|
||||||
|
if (!pubkey) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
pubkey,
|
||||||
|
d,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import { DraftNostrEvent, NostrEvent, PTag, Tag } from "../../types/nostr-event";
|
import { DraftNostrEvent, NostrEvent, PTag, Tag } from "../../types/nostr-event";
|
||||||
import { getMatchHashtag, getMentionNpubOrNote } from "../regexp";
|
import { getMatchHashtag } from "../regexp";
|
||||||
import { normalizeToHex } from "../nip19";
|
|
||||||
import { getReferences } from "./event";
|
import { getReferences } from "./event";
|
||||||
import { getEventRelays } from "../../services/event-relays";
|
import { getEventRelays } from "../../services/event-relays";
|
||||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||||
|
import { getPubkey, safeDecode } from "../nip19";
|
||||||
|
|
||||||
function addTag(tags: Tag[], tag: Tag, overwrite = false) {
|
function addTag(tags: Tag[], tag: Tag, overwrite = false) {
|
||||||
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) {
|
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) {
|
||||||
@@ -50,7 +50,7 @@ export function addReplyTags(draft: DraftNostrEvent, replyTo: NostrEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** ensure a list of pubkeys are present on an event */
|
/** ensure a list of pubkeys are present on an event */
|
||||||
export function ensureNotifyUsers(draft: DraftNostrEvent, pubkeys: string[]) {
|
export function ensureNotifyPubkeys(draft: DraftNostrEvent, pubkeys: string[]) {
|
||||||
const updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
const updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||||
|
|
||||||
for (const pubkey of pubkeys) {
|
for (const pubkey of pubkeys) {
|
||||||
@@ -60,30 +60,19 @@ export function ensureNotifyUsers(draft: DraftNostrEvent, pubkeys: string[]) {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceAtMentions(draft: DraftNostrEvent) {
|
export function getContentMentions(content: string) {
|
||||||
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
const matched = content.matchAll(/nostr:(npub1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})/gi);
|
||||||
|
return Array.from(matched)
|
||||||
// replace all occurrences of @npub and @note
|
.map((m) => {
|
||||||
while (true) {
|
const parsed = safeDecode(m[1]);
|
||||||
const match = getMentionNpubOrNote().exec(updatedDraft.content);
|
return parsed && getPubkey(parsed);
|
||||||
if (!match || match.index === undefined) break;
|
})
|
||||||
|
.filter(Boolean) as string[];
|
||||||
const hex = normalizeToHex(match[1]);
|
|
||||||
if (!hex) continue;
|
|
||||||
const mentionType = match[2] === "npub1" ? "p" : "e";
|
|
||||||
|
|
||||||
// TODO: find the best relay for this user or note
|
|
||||||
const existingMention = updatedDraft.tags.find((t) => t[0] === mentionType && t[1] === hex);
|
|
||||||
const index = existingMention
|
|
||||||
? updatedDraft.tags.indexOf(existingMention)
|
|
||||||
: updatedDraft.tags.push([mentionType, hex, "", "mention"]) - 1;
|
|
||||||
|
|
||||||
// replace the npub1 or note1 with a mention tag #[0]
|
|
||||||
const c = updatedDraft.content;
|
|
||||||
updatedDraft.content = c.slice(0, match.index) + `#[${index}]` + c.slice(match.index + match[0].length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedDraft;
|
export function ensureNotifyContentMentions(draft: DraftNostrEvent) {
|
||||||
|
const mentions = getContentMentions(draft.content);
|
||||||
|
return mentions.length > 0 ? ensureNotifyPubkeys(draft, mentions) : draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHashtagTags(draft: DraftNostrEvent) {
|
export function createHashtagTags(draft: DraftNostrEvent) {
|
||||||
@@ -103,7 +92,6 @@ export function createHashtagTags(draft: DraftNostrEvent) {
|
|||||||
|
|
||||||
export function finalizeNote(draft: DraftNostrEvent) {
|
export function finalizeNote(draft: DraftNostrEvent) {
|
||||||
let updated = draft;
|
let updated = draft;
|
||||||
updated = replaceAtMentions(updated);
|
|
||||||
updated = createHashtagTags(updated);
|
updated = createHashtagTags(updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@ import dayjs from "dayjs";
|
|||||||
import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
|
import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
|
||||||
import { unique } from "../array";
|
import { unique } from "../array";
|
||||||
import { getAddr } from "../../services/replaceable-event-requester";
|
import { getAddr } from "../../services/replaceable-event-requester";
|
||||||
|
import { ensureNotifyContentMentions } from "./post";
|
||||||
|
|
||||||
export const STREAM_KIND = 30311;
|
export const STREAM_KIND = 30311;
|
||||||
export const STREAM_CHAT_MESSAGE_KIND = 1311;
|
export const STREAM_CHAT_MESSAGE_KIND = 1311;
|
||||||
@@ -83,12 +84,14 @@ export function getATag(stream: ParsedStream) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildChatMessage(stream: ParsedStream, content: string) {
|
export function buildChatMessage(stream: ParsedStream, content: string) {
|
||||||
const template: DraftNostrEvent = {
|
let draft: DraftNostrEvent = {
|
||||||
tags: [["a", getATag(stream), "", "root"]],
|
tags: [["a", getATag(stream), "", "root"]],
|
||||||
content,
|
content,
|
||||||
created_at: dayjs().unix(),
|
created_at: dayjs().unix(),
|
||||||
kind: STREAM_CHAT_MESSAGE_KIND,
|
kind: STREAM_CHAT_MESSAGE_KIND,
|
||||||
};
|
};
|
||||||
|
|
||||||
return template;
|
draft = ensureNotifyContentMentions(draft);
|
||||||
|
|
||||||
|
return draft;
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
export const getMentionNpubOrNote = () =>
|
|
||||||
/(?:\s|^)(@|nostr:)?((npub1|note1)[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})(?:\s|$)/gi;
|
|
||||||
export const getMatchNostrLink = () =>
|
export const getMatchNostrLink = () =>
|
||||||
/(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi;
|
/(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi;
|
||||||
export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}]+)/gu;
|
export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}]+)/gu;
|
||||||
export const getMatchLink = () =>
|
export const getMatchLink = () =>
|
||||||
/https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:]*)/gu;
|
/https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:]*)/gu;
|
||||||
|
|
||||||
|
// read more https://www.regular-expressions.info/unicode.html#category
|
||||||
|
export function stripInvisibleChar(str?: string) {
|
||||||
|
return str && str.replaceAll(/[\p{Cf}\p{Zs}]/gu, "");
|
||||||
|
}
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
export type ETag = ["e", string] | ["e", string, string] | ["e", string, string, string];
|
export type ETag = ["e", string] | ["e", string, string] | ["e", string, string, string];
|
||||||
|
export type ATag = ["a", string] | ["a", string, string];
|
||||||
export type PTag = ["p", string] | ["p", string, string] | ["p", string, string, string];
|
export type PTag = ["p", string] | ["p", string, string] | ["p", string, string, string];
|
||||||
export type RTag = ["r", string] | ["r", string, string];
|
export type RTag = ["r", string] | ["r", string, string];
|
||||||
export type DTag = ["d"] | ["d", string];
|
export type DTag = ["d"] | ["d", string];
|
||||||
export type Tag = string[] | ETag | PTag | RTag | DTag;
|
export type Tag = string[] | ETag | PTag | RTag | DTag | ATag;
|
||||||
|
|
||||||
export type NostrEvent = {
|
export type NostrEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,3 +35,6 @@ export function isRTag(tag: Tag): tag is RTag {
|
|||||||
export function isDTag(tag: Tag): tag is DTag {
|
export function isDTag(tag: Tag): tag is DTag {
|
||||||
return tag[0] === "d";
|
return tag[0] === "d";
|
||||||
}
|
}
|
||||||
|
export function isATag(tag: Tag): tag is ATag {
|
||||||
|
return tag[0] === "a" && tag[1] !== undefined;
|
||||||
|
}
|
||||||
|
@@ -8,11 +8,13 @@ import { NostrEvent } from "../../../types/nostr-event";
|
|||||||
import { UserAvatarStack } from "../../../components/user-avatar-stack";
|
import { UserAvatarStack } from "../../../components/user-avatar-stack";
|
||||||
import { ThreadItem, getThreadMembers } from "../../../helpers/thread";
|
import { ThreadItem, getThreadMembers } from "../../../helpers/thread";
|
||||||
import { NoteContents } from "../../../components/note/note-contents";
|
import { NoteContents } from "../../../components/note/note-contents";
|
||||||
import { addReplyTags, ensureNotifyUsers, finalizeNote } from "../../../helpers/nostr/post";
|
import { addReplyTags, ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../../helpers/nostr/post";
|
||||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||||
import { useSigningContext } from "../../../providers/signing-provider";
|
import { useSigningContext } from "../../../providers/signing-provider";
|
||||||
import { useWriteRelayUrls } from "../../../hooks/use-client-relays";
|
import { useWriteRelayUrls } from "../../../hooks/use-client-relays";
|
||||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||||
|
import { getContentTagRefs } from "../../../helpers/nostr/event";
|
||||||
|
import { unique } from "../../../helpers/array";
|
||||||
|
|
||||||
function NoteContentPreview({ content }: { content: string }) {
|
function NoteContentPreview({ content }: { content: string }) {
|
||||||
const draft = useMemo(
|
const draft = useMemo(
|
||||||
@@ -32,6 +34,7 @@ export type ReplyFormProps = {
|
|||||||
export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProps) {
|
export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProps) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const account = useCurrentAccount();
|
const account = useCurrentAccount();
|
||||||
|
const showPreview = useDisclosure();
|
||||||
const { requestSignature } = useSigningContext();
|
const { requestSignature } = useSigningContext();
|
||||||
const writeRelays = useWriteRelayUrls();
|
const writeRelays = useWriteRelayUrls();
|
||||||
|
|
||||||
@@ -41,8 +44,8 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
|
|||||||
content: "",
|
content: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const contentMentions = getContentMentions(getValues().content);
|
||||||
const showPreview = useDisclosure();
|
const notifyPubkeys = unique([...threadMembers, ...contentMentions]);
|
||||||
|
|
||||||
watch("content");
|
watch("content");
|
||||||
|
|
||||||
@@ -50,13 +53,12 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
|
|||||||
try {
|
try {
|
||||||
let draft = finalizeNote({ kind: Kind.Text, content: values.content, created_at: dayjs().unix(), tags: [] });
|
let draft = finalizeNote({ kind: Kind.Text, content: values.content, created_at: dayjs().unix(), tags: [] });
|
||||||
draft = addReplyTags(draft, item.event);
|
draft = addReplyTags(draft, item.event);
|
||||||
draft = ensureNotifyUsers(draft, threadMembers);
|
draft = ensureNotifyPubkeys(draft, notifyPubkeys);
|
||||||
|
|
||||||
const signed = await requestSignature(draft);
|
const signed = await requestSignature(draft);
|
||||||
if (!signed) return;
|
if (!signed) return;
|
||||||
// TODO: write to other users inbox relays
|
// TODO: write to other users inbox relays
|
||||||
const pub = new NostrPublishAction("Reply", writeRelays, signed);
|
const pub = new NostrPublishAction("Reply", writeRelays, signed);
|
||||||
await pub.onComplete;
|
|
||||||
|
|
||||||
if (onSubmitted) onSubmitted(signed);
|
if (onSubmitted) onSubmitted(signed);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -78,7 +80,7 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
|
|||||||
<Button onClick={onCancel}>Cancel</Button>
|
<Button onClick={onCancel}>Cancel</Button>
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<UserAvatarStack label="Notify" users={threadMembers} />
|
<UserAvatarStack label="Notify" users={notifyPubkeys} />
|
||||||
{getValues().content.length > 0 && (
|
{getValues().content.length > 0 && (
|
||||||
<Button size="sm" ml="auto" onClick={showPreview.onToggle}>
|
<Button size="sm" ml="auto" onClick={showPreview.onToggle}>
|
||||||
Preview
|
Preview
|
||||||
|
@@ -13,6 +13,7 @@ import {
|
|||||||
LinkBox,
|
LinkBox,
|
||||||
LinkOverlay,
|
LinkOverlay,
|
||||||
Spacer,
|
Spacer,
|
||||||
|
Tag,
|
||||||
Text,
|
Text,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
@@ -52,7 +53,7 @@ export default function StreamCard({ stream, ...props }: CardProps & { stream: P
|
|||||||
{stream.tags.length > 0 && (
|
{stream.tags.length > 0 && (
|
||||||
<Flex gap="2" wrap="wrap">
|
<Flex gap="2" wrap="wrap">
|
||||||
{stream.tags.map((tag) => (
|
{stream.tags.map((tag) => (
|
||||||
<Badge key={tag}>{tag}</Badge>
|
<Tag key={tag}>{tag}</Tag>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
90
src/views/streams/components/streamer-cards.tsx
Normal file
90
src/views/streams/components/streamer-cards.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
|
||||||
|
import { useRelaySelectionRelays } from "../../../providers/relay-selection-provider";
|
||||||
|
import replaceableEventLoaderService from "../../../services/replaceable-event-requester";
|
||||||
|
import useSubject from "../../../hooks/use-subject";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
CardProps,
|
||||||
|
Code,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Image,
|
||||||
|
Link,
|
||||||
|
LinkBox,
|
||||||
|
LinkOverlay,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { NoteContents } from "../../../components/note/note-contents";
|
||||||
|
import { isATag } from "../../../types/nostr-event";
|
||||||
|
import {} from "nostr-tools";
|
||||||
|
import { parseCoordinate } from "../../../helpers/nostr/event";
|
||||||
|
|
||||||
|
export const STREAMER_CARDS_TYPE = 17777;
|
||||||
|
export const STREAMER_CARD_TYPE = 37777;
|
||||||
|
|
||||||
|
function useStreamerCardsCords(pubkey: string, relays: string[]) {
|
||||||
|
const sub = useMemo(
|
||||||
|
() => replaceableEventLoaderService.requestEvent(relays, STREAMER_CARDS_TYPE, pubkey),
|
||||||
|
[pubkey, relays.join("|")],
|
||||||
|
);
|
||||||
|
const streamerCards = useSubject(sub);
|
||||||
|
|
||||||
|
return streamerCards?.tags.filter(isATag) ?? [];
|
||||||
|
}
|
||||||
|
function useStreamerCard(cord: string, relays: string[]) {
|
||||||
|
const sub = useMemo(() => {
|
||||||
|
const parsed = parseCoordinate(cord);
|
||||||
|
if (!parsed || !parsed.d || parsed.kind !== STREAMER_CARD_TYPE) return;
|
||||||
|
|
||||||
|
return replaceableEventLoaderService.requestEvent(relays, STREAMER_CARD_TYPE, parsed.pubkey, parsed.d);
|
||||||
|
}, [cord, relays.join("|")]);
|
||||||
|
return useSubject(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamerCard({ cord, relay, ...props }: { cord: string; relay?: string } & CardProps) {
|
||||||
|
const contextRelays = useRelaySelectionRelays();
|
||||||
|
const readRelays = useReadRelayUrls(relay ? [...contextRelays, relay] : contextRelays);
|
||||||
|
|
||||||
|
const card = useStreamerCard(cord, readRelays);
|
||||||
|
if (!card) return null;
|
||||||
|
|
||||||
|
const title = card.tags.find((t) => t[0] === "title")?.[1];
|
||||||
|
const image = card.tags.find((t) => t[0] === "image")?.[1];
|
||||||
|
const link = card.tags.find((t) => t[0] === "r")?.[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card as={LinkBox} {...props}>
|
||||||
|
{image && <Image src={image} />}
|
||||||
|
{title && (
|
||||||
|
<CardHeader p="2">
|
||||||
|
<Heading size="md">{title}</Heading>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardBody p="2">
|
||||||
|
<NoteContents event={card} noOpenGraphLinks />
|
||||||
|
{link && (
|
||||||
|
<LinkOverlay isExternal href={link} color="blue.500">
|
||||||
|
{link}
|
||||||
|
</LinkOverlay>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StreamerCards({ pubkey }: { pubkey: string }) {
|
||||||
|
const contextRelays = useRelaySelectionRelays();
|
||||||
|
const readRelays = useReadRelayUrls(contextRelays);
|
||||||
|
|
||||||
|
const cardCords = useStreamerCardsCords(pubkey, readRelays);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex wrap="wrap" gap="2">
|
||||||
|
{cardCords.map(([_, cord, relay]) => (
|
||||||
|
<StreamerCard key={cord} cord={cord} relay={relay} maxW="lg" />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,6 +1,17 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useScroll } from "react-use";
|
import { useScroll } from "react-use";
|
||||||
import { Box, Button, ButtonGroup, Flex, Heading, Spacer, Spinner, Text, useBreakpointValue } from "@chakra-ui/react";
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Spacer,
|
||||||
|
Spinner,
|
||||||
|
Tag,
|
||||||
|
Text,
|
||||||
|
useBreakpointValue,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
import { useParams, Navigate, useSearchParams, useNavigate } from "react-router-dom";
|
import { useParams, Navigate, useSearchParams, useNavigate } from "react-router-dom";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
import { Global, css } from "@emotion/react";
|
import { Global, css } from "@emotion/react";
|
||||||
@@ -21,6 +32,7 @@ import replaceableEventLoaderService from "../../../services/replaceable-event-r
|
|||||||
import useSubject from "../../../hooks/use-subject";
|
import useSubject from "../../../hooks/use-subject";
|
||||||
import RelaySelectionButton from "../../../components/relay-selection/relay-selection-button";
|
import RelaySelectionButton from "../../../components/relay-selection/relay-selection-button";
|
||||||
import RelaySelectionProvider from "../../../providers/relay-selection-provider";
|
import RelaySelectionProvider from "../../../providers/relay-selection-provider";
|
||||||
|
import StreamerCards from "../components/streamer-cards";
|
||||||
|
|
||||||
function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode?: ChatDisplayMode }) {
|
function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode?: ChatDisplayMode }) {
|
||||||
const vertical = useBreakpointValue({ base: true, lg: false });
|
const vertical = useBreakpointValue({ base: true, lg: false });
|
||||||
@@ -92,7 +104,7 @@ function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!displayMode && (
|
{!displayMode && (
|
||||||
<Flex gap={vertical ? "2" : "4"} direction="column" flexGrow={vertical ? 0 : 1}>
|
<Flex gap={vertical ? "2" : "4"} direction="column" flexGrow={vertical ? 0 : 1} pb="4">
|
||||||
<LiveVideoPlayer
|
<LiveVideoPlayer
|
||||||
stream={stream.streaming || stream.recording}
|
stream={stream.streaming || stream.recording}
|
||||||
autoPlay={!!stream.streaming}
|
autoPlay={!!stream.streaming}
|
||||||
@@ -113,6 +125,14 @@ function StreamPage({ stream, displayMode }: { stream: ParsedStream; displayMode
|
|||||||
<Button onClick={() => navigate(-1)}>Back</Button>
|
<Button onClick={() => navigate(-1)}>Back</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
<StreamSummaryContent stream={stream} px={vertical ? "2" : 0} />
|
<StreamSummaryContent stream={stream} px={vertical ? "2" : 0} />
|
||||||
|
{stream.tags.length > 0 && (
|
||||||
|
<Flex gap="2" wrap="wrap">
|
||||||
|
{stream.tags.map((tag) => (
|
||||||
|
<Tag key={tag}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
{!vertical && <StreamerCards pubkey={stream.host} />}
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
<StreamChat
|
<StreamChat
|
||||||
|
@@ -41,6 +41,7 @@ import useUserMuteList from "../../../../hooks/use-user-mute-list";
|
|||||||
import { NostrEvent, isPTag } from "../../../../types/nostr-event";
|
import { NostrEvent, isPTag } from "../../../../types/nostr-event";
|
||||||
import { useCurrentAccount } from "../../../../hooks/use-current-account";
|
import { useCurrentAccount } from "../../../../hooks/use-current-account";
|
||||||
import NostrPublishAction from "../../../../classes/nostr-publish-action";
|
import NostrPublishAction from "../../../../classes/nostr-publish-action";
|
||||||
|
import { ensureNotifyContentMentions } from "../../../../helpers/nostr/post";
|
||||||
|
|
||||||
const hideScrollbar = css`
|
const hideScrollbar = css`
|
||||||
scrollbar-width: 0;
|
scrollbar-width: 0;
|
||||||
|
Reference in New Issue
Block a user