forked from Kieran/zap.stream
feat: mentions
This commit is contained in:
parent
0c8fbcfef1
commit
4aafb19f7e
17
src/element/avatar.tsx
Normal file
17
src/element/avatar.tsx
Normal 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
25
src/element/hypertext.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -105,6 +105,10 @@
|
||||
color: #F838D9;
|
||||
}
|
||||
|
||||
.live-chat .message a {
|
||||
color: #F838D9;
|
||||
}
|
||||
|
||||
.live-chat .profile img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
20
src/element/mention.tsx
Normal file
20
src/element/mention.tsx
Normal 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>
|
||||
);
|
||||
}
|
16
src/element/nostr-link.tsx
Normal file
16
src/element/nostr-link.tsx
Normal 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>;
|
||||
}
|
||||
}
|
@ -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 <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}</>;
|
||||
}
|
||||
|
@ -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 {
|
||||
}
|
||||
|
@ -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 (
|
||||
|
46
src/utils.ts
46
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];
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user