From 4aafb19f7e8b3a8b1f537b318700adc0f5da906c Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Wed, 28 Jun 2023 10:21:03 +0200 Subject: [PATCH 1/2] feat: mentions --- src/element/avatar.tsx | 17 +++++++ src/element/hypertext.tsx | 25 +++++++++++ src/element/live-chat.css | 4 ++ src/element/mention.tsx | 20 +++++++++ src/element/nostr-link.tsx | 16 +++++++ src/element/text.tsx | 90 +++++++++++++++++++++++++++++++++----- src/element/textarea.css | 14 ++++-- src/element/textarea.tsx | 33 +++++++++++++- src/utils.ts | 46 ++++++++++++++++--- 9 files changed, 243 insertions(+), 22 deletions(-) create mode 100644 src/element/avatar.tsx create mode 100644 src/element/hypertext.tsx create mode 100644 src/element/mention.tsx create mode 100644 src/element/nostr-link.tsx diff --git a/src/element/avatar.tsx b/src/element/avatar.tsx new file mode 100644 index 0000000..1d95a12 --- /dev/null +++ b/src/element/avatar.tsx @@ -0,0 +1,17 @@ +import { MetadataCache } from "@snort/system"; + +export function Avatar({ + user, + avatarClassname, +}: { + user: MetadataCache; + avatarClassname: string; +}) { + return ( + {user?.name + ); +} diff --git a/src/element/hypertext.tsx b/src/element/hypertext.tsx new file mode 100644 index 0000000..f7b5e46 --- /dev/null +++ b/src/element/hypertext.tsx @@ -0,0 +1,25 @@ +import { NostrLink } from "./nostr-link"; + +interface HyperTextProps { + link: string; +} + +export function HyperText({ link }: HyperTextProps) { + try { + const url = new URL(link); + if (url.protocol === "nostr:" || url.protocol === "web+nostr:") { + return ; + } else { + + {link} + ; + } + } catch { + // Ignore the error. + } + return ( + + {link} + + ); +} diff --git a/src/element/live-chat.css b/src/element/live-chat.css index abef9de..f517141 100644 --- a/src/element/live-chat.css +++ b/src/element/live-chat.css @@ -105,6 +105,10 @@ color: #F838D9; } +.live-chat .message a { + color: #F838D9; +} + .live-chat .profile img { width: 24px; height: 24px; diff --git a/src/element/mention.tsx b/src/element/mention.tsx new file mode 100644 index 0000000..b54ff0c --- /dev/null +++ b/src/element/mention.tsx @@ -0,0 +1,20 @@ +import { useUserProfile } from "@snort/system-react"; +import { System } from "index"; + +interface MentionProps { + pubkey: string; + relays?: string[]; +} + +export function Mention({ pubkey, relays }: MentionProps) { + const user = useUserProfile(System, pubkey); + return ( + + {user?.name || pubkey} + + ); +} diff --git a/src/element/nostr-link.tsx b/src/element/nostr-link.tsx new file mode 100644 index 0000000..a96f33e --- /dev/null +++ b/src/element/nostr-link.tsx @@ -0,0 +1,16 @@ +import { NostrPrefix, tryParseNostrLink } from "@snort/system"; +import { Mention } from "./mention"; + +export function NostrLink({ link }: { link: string }) { + const nav = tryParseNostrLink(link); + if ( + nav?.type === NostrPrefix.PublicKey || + nav?.type === NostrPrefix.Profile + ) { + return ; + } else { + + {link} + ; + } +} diff --git a/src/element/text.tsx b/src/element/text.tsx index d2f8c00..6f6f25e 100644 --- a/src/element/text.tsx +++ b/src/element/text.tsx @@ -1,14 +1,84 @@ -import { useMemo } from "react"; -import { TaggedRawEvent } from "@snort/system"; -import { type EmojiTag, Emojify } from "./emoji"; +import { useMemo, type ReactNode } from "react"; +import { TaggedRawEvent, validateNostrLink } from "@snort/system"; +import { splitByUrl } from "utils"; +import { Emoji } from "./emoji"; +import { HyperText } from "./hypertext"; + +type Fragment = string | ReactNode; + +function transformText(fragments: Fragment[], tags: string[][]) { + return extractLinks(extractEmoji(fragments, tags)); +} + +function extractEmoji(fragments: Fragment[], tags: string[][]) { + return fragments + .map((f) => { + if (typeof f === "string") { + return f.split(/:([a-zA-Z_-]):/g).map((i) => { + const t = tags.find((a) => a[0] === "emoji" && a[1] === i); + if (t) { + return ; + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +function extractLinks(fragments: Fragment[]) { + return fragments + .map((f) => { + if (typeof f === "string") { + return splitByUrl(f).map((a) => { + const validateLink = () => { + const normalizedStr = a.toLowerCase(); + + if ( + normalizedStr.startsWith("web+nostr:") || + normalizedStr.startsWith("nostr:") + ) { + return validateNostrLink(normalizedStr); + } + + return ( + normalizedStr.startsWith("http:") || + normalizedStr.startsWith("https:") || + normalizedStr.startsWith("magnet:") + ); + }; + + if (validateLink()) { + if (!a.startsWith("nostr:")) { + return ( + e.stopPropagation()} + target="_blank" + rel="noreferrer" + className="ext" + > + {a} + + ); + } + return ; + } + return a; + }); + } + return f; + }) + .flat(); +} export function Text({ ev }: { ev: TaggedRawEvent }) { - const emojis = useMemo(() => { - return ev.tags.filter((t) => t.at(0) === "emoji").map((t) => t as EmojiTag); + // todo: RTL langugage support + const element = useMemo(() => { + return {transformText([ev.content], ev.tags)}; }, [ev]); - return ( - - - - ); + + return <>{element}; } diff --git a/src/element/textarea.css b/src/element/textarea.css index 7614ab5..fd7d919 100644 --- a/src/element/textarea.css +++ b/src/element/textarea.css @@ -13,21 +13,27 @@ background: #F838D9; } -.emoji-item { +.emoji-item, .user-item { color: white; background: #171717; display: flex; flex-direction: row; + gap: 8px; align-items: center; font-size: 16px; padding: 10px; } -.emoji-item:hover { +.emoji-item:hover, .user-item:hover { color: #171717; background: white; } -.emoji-item .emoji-image { - margin: 0 8px; +.user-image { + width: 21px; + height: 21px; + border-radius: 100%; +} + +.user-details { } diff --git a/src/element/textarea.tsx b/src/element/textarea.tsx index 344b778..49921d3 100644 --- a/src/element/textarea.tsx +++ b/src/element/textarea.tsx @@ -1,10 +1,14 @@ import "./textarea.css"; +import type { KeyboardEvent, ChangeEvent } from "react"; import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete"; import "@webscopeio/react-textarea-autocomplete/style.css"; -import type { KeyboardEvent, ChangeEvent } from "react"; -import { Emoji, type EmojiTag } from "./emoji"; import uniqWith from "lodash/uniqWith"; import isEqual from "lodash/isEqual"; +import { MetadataCache, NostrPrefix } from "@snort/system"; +import { System } from "index"; +import { Emoji, type EmojiTag } from "./emoji"; +import { Avatar } from "element/avatar"; +import { hexToBech32 } from "utils"; interface EmojiItemProps { name: string; @@ -22,6 +26,16 @@ const EmojiItem = ({ entity: { name, url } }: { entity: EmojiItemProps }) => { ); }; +const UserItem = (metadata: MetadataCache) => { + const { pubkey, display_name, nip05, ...rest } = metadata; + return ( +
+ +
{display_name || rest.name}
+
+ ); +}; + interface TextareaProps { emojis: EmojiTag[]; value: string; @@ -30,6 +44,11 @@ interface TextareaProps { } export function Textarea({ emojis, ...props }: TextareaProps) { + const userDataProvider = async (token: string) => { + // @ts-expect-error: Property 'search' + return System.ProfileLoader.Cache.search(token); + }; + const emojiDataProvider = async (token: string) => { const results = emojis .map((t) => { @@ -41,12 +60,22 @@ export function Textarea({ emojis, ...props }: TextareaProps) { .filter(({ name }) => name.toLowerCase().includes(token.toLowerCase())); return uniqWith(results, isEqual).slice(0, 5); }; + const trigger = { ":": { dataProvider: emojiDataProvider, component: EmojiItem, output: (item: EmojiItemProps) => `:${item.name}:`, }, + "@": { + afterWhitespace: true, + dataProvider: userDataProvider, + component: (props: { entity: MetadataCache }) => ( + + ), + output: (item: { pubkey: string }) => + `@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`, + }, }; return ( diff --git a/src/utils.ts b/src/utils.ts index 11ce148..2aaa0a8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,42 @@ -import { NostrEvent } from "@snort/system"; +import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system"; +import * as utils from "@noble/curves/abstract/utils"; +import { bech32 } from "@scure/base"; export function findTag(e: NostrEvent | undefined, tag: string) { - const maybeTag = e?.tags.find(evTag => { - return evTag[0] === tag; - }); - return maybeTag && maybeTag[1]; -} \ No newline at end of file + const maybeTag = e?.tags.find((evTag) => { + return evTag[0] === tag; + }); + return maybeTag && maybeTag[1]; +} + +/** + * Convert hex to bech32 + */ +export function hexToBech32(hrp: string, hex?: string) { + if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) { + return ""; + } + + try { + if ( + hrp === NostrPrefix.Note || + hrp === NostrPrefix.PrivateKey || + hrp === NostrPrefix.PublicKey + ) { + const buf = utils.hexToBytes(hex); + return bech32.encode(hrp, bech32.toWords(buf)); + } else { + return encodeTLV(hrp as NostrPrefix, hex); + } + } catch (e) { + console.warn("Invalid hex", hex, e); + return ""; + } +} + +export function splitByUrl(str: string) { + const urlRegex = + /((?:http|ftp|https|nostr|web\+nostr|magnet):\/?\/?(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~()_|]))/i; + + return str.split(urlRegex); +} -- 2.45.2 From a3c486d9e4f6bda3a5a787a5e3c6db7c6b4ffcfc Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Wed, 28 Jun 2023 11:09:12 +0200 Subject: [PATCH 2/2] fix: don't add duplicate emoji tags --- src/element/live-chat.tsx | 16 +++++++++++----- src/element/text.tsx | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/element/live-chat.tsx b/src/element/live-chat.tsx index 2aa6691..ece4367 100644 --- a/src/element/live-chat.tsx +++ b/src/element/live-chat.tsx @@ -186,20 +186,26 @@ function WriteMessage({ link }: { link: NostrLink }) { async function sendChatMessage() { const pub = await EventPublisher.nip7(); if (chat.length > 1) { - let messageEmojis: string[][] = []; + let emojiNames = new Set(); + for (const name of names) { if (chat.includes(`:${name}:`)) { - const e = emojis.find((t) => t.at(1) === name); - messageEmojis.push(e as string[]); + emojiNames.add(name); } } + const reply = await pub?.generic((eb) => { + const emoji = [...emojiNames].map((name) => + emojis.find((e) => e.at(1) === name) + ); eb.kind(1311 as EventKind) .content(chat) .tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"]) .processContent(); - for (const e of messageEmojis) { - eb.tag(e); + for (const e of emoji) { + if (e) { + eb.tag(e); + } } return eb; }); diff --git a/src/element/text.tsx b/src/element/text.tsx index 6f6f25e..e722d55 100644 --- a/src/element/text.tsx +++ b/src/element/text.tsx @@ -14,7 +14,7 @@ function extractEmoji(fragments: Fragment[], tags: string[][]) { return fragments .map((f) => { if (typeof f === "string") { - return f.split(/:([a-zA-Z_-]):/g).map((i) => { + return f.split(/:([\w-]+):/g).map((i) => { const t = tags.find((a) => a[0] === "emoji" && a[1] === i); if (t) { return ; -- 2.45.2