Merge pull request #56 from v0l/mentions

feat: mentions
This commit is contained in:
Kieran 2023-01-15 09:59:07 +00:00 committed by GitHub
commit 679f139c68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 210 additions and 24 deletions

View File

@ -13,6 +13,8 @@
"@types/node": "^18.11.18",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"bech32": "^2.0.0",
"light-bolt11-decoder": "^2.1.0",
"qr-code-styling": "^1.6.0-rc.1",

View File

@ -2,14 +2,11 @@
margin-bottom: 10px;
background-color: var(--gray);
border-radius: 10px;
overflow: hidden;
}
.note-creator textarea {
resize: none;
outline: none;
min-height: 40px;
max-height: 300px;
border-radius: 10px 10px 0 0;
max-width: -webkit-fill-available;
max-width: -moz-available;
@ -18,6 +15,11 @@
min-width: -webkit-fill-available;
min-width: -moz-available;
min-width: fill-available;
transition: min-height .5s;
}
.note-creator .textarea--focused {
min-height: 120px;
}
.note-creator .actions {

View File

@ -1,11 +1,15 @@
import "./NoteCreator.css";
import { useState } from "react";
import useEventPublisher from "../feed/EventPublisher";
import { useState, Component } from "react";
import { useSelector } from "react-redux";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPaperclip } from "@fortawesome/free-solid-svg-icons";
import "./NoteCreator.css";
import useEventPublisher from "../feed/EventPublisher";
import { openFile } from "../Util";
import VoidUpload from "../feed/VoidUpload";
import { FileExtensionRegex } from "../Const";
import Textarea from "../element/Textarea";
export function NoteCreator(props) {
const replyTo = props.replyTo;
@ -15,15 +19,14 @@ export function NoteCreator(props) {
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [active, setActive] = useState(false);
const users = useSelector((state) => state.users.users)
async function sendNote() {
let ev = replyTo ?
await publisher.reply(replyTo, note)
: await publisher.note(note);
let ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note);
console.debug("Sending note: ", ev);
publisher.broadcast(ev);
setNote("");
setActive(false);
if (typeof onSend === "function") {
onSend();
}
@ -44,20 +47,35 @@ export function NoteCreator(props) {
}
}
function onChange(ev) {
const { value } = ev.target
if (value) {
setNote(value)
setActive(true)
} else {
setActive(false)
}
}
if (!show) return false;
return (
<>
{replyTo ? <small>{`Reply to: ${replyTo.Id.substring(0, 8)}`}</small> : null}
<div className="flex note-creator" onClick={() => setActive(true)}>
<div className="textarea flex f-col mr10 f-grow">
<textarea className="textarea w-max" placeholder="Say something!" value={note} onChange={(e) => setNote(e.target.value)} />
{active ? <div className="actions flex f-row">
<div className="flex note-creator">
<div className="flex f-col mr10 f-grow">
<Textarea
className={`textarea ${active ? "textarea--focused" : ""}`}
users={users}
onChange={onChange}
onFocus={() => setActive(true)}
/>
<div className="actions flex f-row">
<div className="attachment flex f-row">
{error.length > 0 ? <b className="error">{error}</b> : null}
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
</div>
<div className="btn" onClick={() => sendNote()}>Send</div>
</div> : null}
</div>
</div>
</div>
</>

View File

@ -38,11 +38,11 @@ function transformHttpLink(a) {
return <a key={url} href={url} onClick={(e) => e.stopPropagation()}>{url.toString()}</a>
}
} else if (tweetId) {
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />
</div>
)
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />
</div>
)
} else if (youtubeId) {
return (
<>
@ -81,7 +81,7 @@ function extractLinks(fragments) {
}).flat();
}
export function extractMentions(fragments, tags, users) {
function extractMentions(fragments, tags, users) {
return fragments.map(f => {
if (typeof f === "string") {
return f.split(MentionRegex).map((match) => {
@ -172,4 +172,3 @@ export default function Text({ content, tags, users }) {
}, [content]);
return <ReactMarkdown className="text" components={components}>{content}</ReactMarkdown>
}

47
src/element/Textarea.css Normal file
View File

@ -0,0 +1,47 @@
.rta__entity {
background: var(--gray);
}
.rta__entity--selected {
color: var(--font-color);
background: var(--gray-secondary);
}
.rta__list {
border: 1px solid var(--gray-tertiary);
}
.rta__item:not(:last-child) {
border-bottom: 1px solid var(--gray-tertiary);
}
.user-item {
display: flex;
flex-direction: row;
align-items: center;
font-size: 16px;
padding: 10px;
}
.user-item .picture {
width: 30px;
height: 30px;
border-radius: 100%;
}
.user-picture {
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
}
.user-details {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.nip05 {
font-size: 12px;
}

65
src/element/Textarea.tsx Normal file
View File

@ -0,0 +1,65 @@
import { Component } from "react";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
// @ts-expect-error
import Nip05, { useIsVerified } from "./Nip05";
import "@webscopeio/react-textarea-autocomplete/style.css";
import "./Textarea.css";
// @ts-expect-error
import Nostrich from "../nostrich.jpg";
// @ts-expect-error
import { hexToBech32 } from "../Util";
import type { User } from "../nostr/types";
function searchUsers(query: string, users: Record<string, User>) {
const q = query.toLowerCase()
return Object.values(users).filter(({ name, display_name, about, nip05 }) => {
return name?.toLowerCase().includes(q)
|| display_name?.toLowerCase().includes(q)
|| about?.toLowerCase().includes(q)
|| nip05?.toLowerCase().includes(q)
}).slice(0, 3)
}
const UserItem = ({ pubkey, display_name, picture, nip05, ...rest }: User) => {
const { isVerified, couldNotVerify, name, domain } = useIsVerified(nip05, pubkey)
return (
<div key={pubkey} className="user-item">
<div className="user-picture">
{picture && <img src={picture ? picture : Nostrich} className="picture" />}
</div>
<div className="user-details">
<strong>{display_name || rest.name}</strong>
<Nip05 name={name} domain={domain} isVerified={isVerified} couldNotVerify={couldNotVerify} />
</div>
</div>
)
}
export default class Textarea extends Component {
render() {
// @ts-expect-error
const { users, onChange, ...rest } = this.props
return (
<ReactTextareaAutocomplete
{...rest}
loadingComponent={() => <span>Loading....</span>}
placeholder="Say something!"
ref={rta => {
// @ts-expect-error
this.rta = rta;
}}
onChange={onChange}
trigger={{
"@": {
afterWhitespace: true,
dataProvider: token => searchUsers(token, users),
component: (props: any) => <UserItem {...props.entity} />,
output: (item: any) => `@${hexToBech32("npub", item.pubkey)}`
}
}}
/>
)
}
}

View File

@ -1,8 +1,10 @@
import { useSelector } from "react-redux";
import { System } from "..";
import Event from "../nostr/Event";
import EventKind from "../nostr/EventKind";
import Tag from "../nostr/Tag";
import { bech32ToHex } from "../Util"
export default function useEventPublisher() {
const pubKey = useSelector(s => s.login.publicKey);
@ -28,6 +30,23 @@ export default function useEventPublisher() {
return ev;
}
function processMentions(ev, msg) {
const replaceNpub = (match) => {
const npub = match.slice(1);
try {
const hex = bech32ToHex(npub);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["p", hex], idx));
return `#[${idx}]`
} catch (error) {
return match
}
}
let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub)
ev.Content = content;
}
return {
broadcast: (ev) => {
console.debug("Sending event: ", ev);
@ -45,7 +64,7 @@ export default function useEventPublisher() {
}
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
ev.Content = msg;
processMentions(ev, msg);
return await signEvent(ev);
},
/**
@ -60,7 +79,6 @@ export default function useEventPublisher() {
}
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.TextNote;
ev.Content = msg;
let thread = replyTo.Thread;
if (thread) {
@ -81,6 +99,7 @@ export default function useEventPublisher() {
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], 0));
ev.Tags.push(new Tag(["p", replyTo.PubKey], 1));
}
processMentions(ev, msg);
return await signEvent(ev);
},
react: async (evRef, content = "+") => {

9
src/nostr/types.tsx Normal file
View File

@ -0,0 +1,9 @@
export type User = {
name?: string
about?: string
display_name?: string
nip05?: string
pubkey: string
picture?: string
}

View File

@ -2121,6 +2121,13 @@
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
"@types/webscopeio__react-textarea-autocomplete@^4.7.2":
version "4.7.2"
resolved "https://registry.yarnpkg.com/@types/webscopeio__react-textarea-autocomplete/-/webscopeio__react-textarea-autocomplete-4.7.2.tgz#605e8a6b4194fb4b6e55df8a19bc8fcd56319cfa"
integrity sha512-e1DZGD+eH19BnllTWCGXAdrMa2kI53wEMuhn/d+wUmnu8//ZI6BiuK/EPdw07fI4+tlyo5qdPZdXdpkoXHJVOw==
dependencies:
"@types/react" "*"
"@types/ws@^8.5.1":
version "8.5.4"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.4.tgz#bb10e36116d6e570dd943735f86c933c1587b8a5"
@ -2358,6 +2365,14 @@
"@webassemblyjs/ast" "1.11.1"
"@xtuc/long" "4.2.2"
"@webscopeio/react-textarea-autocomplete@^4.9.2":
version "4.9.2"
resolved "https://registry.yarnpkg.com/@webscopeio/react-textarea-autocomplete/-/react-textarea-autocomplete-4.9.2.tgz#b39e57d8048ad2e8790d70073afe63eafa877345"
integrity sha512-9l5lbyA709d5HHvI/COflSnblBJeYGxB2/0ghP3m3YViLzXRMzJwaXqnqz6oA96y7QdR3pQWYtVmkUKA0AUVAA==
dependencies:
custom-event "^1.0.1"
textarea-caret "3.0.2"
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@ -3522,6 +3537,11 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9"
integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
custom-event@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@ -8919,6 +8939,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
textarea-caret@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/textarea-caret/-/textarea-caret-3.0.2.tgz#f360c48699aa1abf718680a43a31a850665c2caf"
integrity sha512-gRzeti2YS4did7UJnPQ47wrjD+vp+CJIe9zbsu0bJ987d8QVLvLNG9757rqiQTIy4hGIeFauTTJt5Xkn51UkXg==
throat@^6.0.1:
version "6.0.2"
resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.2.tgz#51a3fbb5e11ae72e2cf74861ed5c8020f89f29fe"