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;
|
color: #F838D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-chat .message a {
|
||||||
|
color: #F838D9;
|
||||||
|
}
|
||||||
|
|
||||||
.live-chat .profile img {
|
.live-chat .profile img {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 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 { useMemo, type ReactNode } from "react";
|
||||||
import { TaggedRawEvent } from "@snort/system";
|
import { TaggedRawEvent, validateNostrLink } from "@snort/system";
|
||||||
import { type EmojiTag, Emojify } from "./emoji";
|
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 }) {
|
export function Text({ ev }: { ev: TaggedRawEvent }) {
|
||||||
const emojis = useMemo(() => {
|
// todo: RTL langugage support
|
||||||
return ev.tags.filter((t) => t.at(0) === "emoji").map((t) => t as EmojiTag);
|
const element = useMemo(() => {
|
||||||
|
return <span>{transformText([ev.content], ev.tags)}</span>;
|
||||||
}, [ev]);
|
}, [ev]);
|
||||||
return (
|
|
||||||
<span>
|
return <>{element}</>;
|
||||||
<Emojify content={ev.content} emoji={emojis} />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -13,21 +13,27 @@
|
|||||||
background: #F838D9;
|
background: #F838D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-item {
|
.emoji-item, .user-item {
|
||||||
color: white;
|
color: white;
|
||||||
background: #171717;
|
background: #171717;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-item:hover {
|
.emoji-item:hover, .user-item:hover {
|
||||||
color: #171717;
|
color: #171717;
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-item .emoji-image {
|
.user-image {
|
||||||
margin: 0 8px;
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import "./textarea.css";
|
import "./textarea.css";
|
||||||
|
import type { KeyboardEvent, ChangeEvent } from "react";
|
||||||
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
||||||
import "@webscopeio/react-textarea-autocomplete/style.css";
|
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 uniqWith from "lodash/uniqWith";
|
||||||
import isEqual from "lodash/isEqual";
|
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 {
|
interface EmojiItemProps {
|
||||||
name: string;
|
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 {
|
interface TextareaProps {
|
||||||
emojis: EmojiTag[];
|
emojis: EmojiTag[];
|
||||||
value: string;
|
value: string;
|
||||||
@ -30,6 +44,11 @@ interface TextareaProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Textarea({ emojis, ...props }: 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 emojiDataProvider = async (token: string) => {
|
||||||
const results = emojis
|
const results = emojis
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
@ -41,12 +60,22 @@ export function Textarea({ emojis, ...props }: TextareaProps) {
|
|||||||
.filter(({ name }) => name.toLowerCase().includes(token.toLowerCase()));
|
.filter(({ name }) => name.toLowerCase().includes(token.toLowerCase()));
|
||||||
return uniqWith(results, isEqual).slice(0, 5);
|
return uniqWith(results, isEqual).slice(0, 5);
|
||||||
};
|
};
|
||||||
|
|
||||||
const trigger = {
|
const trigger = {
|
||||||
":": {
|
":": {
|
||||||
dataProvider: emojiDataProvider,
|
dataProvider: emojiDataProvider,
|
||||||
component: EmojiItem,
|
component: EmojiItem,
|
||||||
output: (item: EmojiItemProps) => `:${item.name}:`,
|
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 (
|
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) {
|
export function findTag(e: NostrEvent | undefined, tag: string) {
|
||||||
const maybeTag = e?.tags.find(evTag => {
|
const maybeTag = e?.tags.find((evTag) => {
|
||||||
return evTag[0] === tag;
|
return evTag[0] === tag;
|
||||||
});
|
});
|
||||||
return maybeTag && maybeTag[1];
|
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