Merge pull request 'feat: user mentions' (#9) from verbiricha/stream:feat/mentions into main

Reviewed-on: Kieran/stream#9
This commit is contained in:
Kieran 2023-06-28 17:00:28 +00:00
commit 15a068d5b4
10 changed files with 254 additions and 27 deletions

17
src/element/avatar.tsx Normal file
View File

@ -0,0 +1,17 @@
import { MetadataCache } from "@snort/system";
export function Avatar({
user,
avatarClassname,
}: {
user: MetadataCache;
avatarClassname: string;
}) {
return (
<img
className={avatarClassname}
alt={user?.name || user?.pubkey}
src={user?.picture ?? ""}
/>
);
}

25
src/element/hypertext.tsx Normal file
View File

@ -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 <NostrLink link={link} />;
} else {
<a href={link} target="_blank" rel="noreferrer">
{link}
</a>;
}
} catch {
// Ignore the error.
}
return (
<a href={link} target="_blank" rel="noreferrer">
{link}
</a>
);
}

View File

@ -105,6 +105,10 @@
color: #F838D9;
}
.live-chat .message a {
color: #F838D9;
}
.live-chat .profile img {
width: 24px;
height: 24px;

View File

@ -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;
});

20
src/element/mention.tsx Normal file
View File

@ -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 (
<a
href={`https://snort.social/p/${pubkey}`}
target="_blank"
rel="noreferrer"
>
{user?.name || pubkey}
</a>
);
}

View File

@ -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 <Mention pubkey={nav.id} relays={nav.relays} />;
} else {
<a href={link} target="_blank" rel="noreferrer" className="ext">
{link}
</a>;
}
}

View File

@ -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 <Emoji name={t[1]} url={t[2]} />;
} 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 (
<a
href={a}
onClick={(e) => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext"
>
{a}
</a>
);
}
return <HyperText link={a} />;
}
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 <span>{transformText([ev.content], ev.tags)}</span>;
}, [ev]);
return (
<span>
<Emojify content={ev.content} emoji={emojis} />
</span>
);
return <>{element}</>;
}

View File

@ -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 {
}

View File

@ -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 (
<div key={pubkey} className="user-item">
<Avatar avatarClassname="user-image" user={metadata} />
<div className="user-details">{display_name || rest.name}</div>
</div>
);
};
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 }) => (
<UserItem {...props.entity} />
),
output: (item: { pubkey: string }) =>
`@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
},
};
return (

View File

@ -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];
}
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);
}