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 (
+
+ );
+}
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/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/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..e722d55 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(/:([\w-]+):/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);
+}