Merge pull request 'add/remove emoji pack, mute profiles and render nostr mentions in chat' (#52) from mute/block into main
Reviewed-on: Kieran/stream#52
This commit is contained in:
commit
27d1d96e20
@ -7,3 +7,11 @@ export const USER_EMOJIS = 10_030 as EventKind;
|
|||||||
export const GOAL = 9041 as EventKind;
|
export const GOAL = 9041 as EventKind;
|
||||||
export const USER_CARDS = 17_777 as EventKind;
|
export const USER_CARDS = 17_777 as EventKind;
|
||||||
export const CARD = 37_777 as EventKind;
|
export const CARD = 37_777 as EventKind;
|
||||||
|
export const MUTED = 10_000 as EventKind;
|
||||||
|
|
||||||
|
export const defaultRelays = {
|
||||||
|
"wss://relay.snort.social": { read: true, write: true },
|
||||||
|
"wss://nos.lol": { read: true, write: true },
|
||||||
|
"wss://relay.damus.io": { read: true, write: true },
|
||||||
|
"wss://nostr.wine": { read: true, write: true },
|
||||||
|
};
|
||||||
|
@ -14,7 +14,7 @@ import { EmojiPicker } from "./emoji-picker";
|
|||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { Emoji } from "./emoji";
|
import { Emoji } from "./emoji";
|
||||||
import { Profile } from "./profile";
|
import { Profile } from "./profile";
|
||||||
import { Text } from "./text";
|
import { Text } from "element/text";
|
||||||
import { SendZapsDialog } from "./send-zap";
|
import { SendZapsDialog } from "./send-zap";
|
||||||
import { findTag } from "../utils";
|
import { findTag } from "../utils";
|
||||||
import type { EmojiPack } from "../hooks/emoji";
|
import type { EmojiPack } from "../hooks/emoji";
|
||||||
@ -140,11 +140,19 @@ export function ChatMessage({
|
|||||||
onClick={() => setShowZapDialog(true)}
|
onClick={() => setShowZapDialog(true)}
|
||||||
>
|
>
|
||||||
<Profile
|
<Profile
|
||||||
icon={ev.pubkey === streamer && <Icon name="signal" size={16} />}
|
icon={
|
||||||
|
ev.pubkey === streamer && <Icon name="signal" size={16} />
|
||||||
|
// todo: styling is ready if we want to add stream badges
|
||||||
|
// <img
|
||||||
|
// className="badge-icon"
|
||||||
|
// src="https://nostr.build/i/nostr.build_4b0d4f7293eb0f2bacb5b232a8d2ef3fe7648192d636e152a3c18b9fc06142d7.png"
|
||||||
|
// alt="TODO"
|
||||||
|
// />
|
||||||
|
}
|
||||||
pubkey={ev.pubkey}
|
pubkey={ev.pubkey}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
/>
|
/>
|
||||||
<Text content={ev.content} tags={ev.tags} />
|
<Text tags={ev.tags} content={ev.content} />
|
||||||
{(hasReactions || hasZaps) && (
|
{(hasReactions || hasZaps) && (
|
||||||
<div className="message-reactions">
|
<div className="message-reactions">
|
||||||
{hasZaps && (
|
{hasZaps && (
|
||||||
|
@ -4,6 +4,10 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-pack-title .name {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-pack-title a {
|
.emoji-pack-title a {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@ -30,3 +34,7 @@
|
|||||||
.emoji-pack h4 {
|
.emoji-pack h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-pack .btn {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
@ -1,17 +1,59 @@
|
|||||||
import "./emoji-pack.css";
|
import "./emoji-pack.css";
|
||||||
import { type NostrEvent } from "@snort/system";
|
import { type NostrEvent } from "@snort/system";
|
||||||
|
|
||||||
|
import { useLogin } from "hooks/login";
|
||||||
|
import { toEmojiPack } from "hooks/emoji";
|
||||||
|
import AsyncButton from "element/async-button";
|
||||||
import { Mention } from "element/mention";
|
import { Mention } from "element/mention";
|
||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
|
import { USER_EMOJIS } from "const";
|
||||||
|
import { Login, System } from "index";
|
||||||
|
|
||||||
export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
||||||
|
const login = useLogin();
|
||||||
const name = findTag(ev, "d");
|
const name = findTag(ev, "d");
|
||||||
|
const isUsed = login.emojis.find(
|
||||||
|
(e) => e.author === ev.pubkey && e.name === name,
|
||||||
|
);
|
||||||
const emoji = ev.tags.filter((e) => e.at(0) === "emoji");
|
const emoji = ev.tags.filter((e) => e.at(0) === "emoji");
|
||||||
|
|
||||||
|
async function toggleEmojiPack() {
|
||||||
|
let newPacks = [];
|
||||||
|
if (isUsed) {
|
||||||
|
newPacks = login.emojis.filter(
|
||||||
|
(e) => e.pubkey !== ev.pubkey && e.name !== name,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newPacks = [...login.emojis, toEmojiPack(ev)];
|
||||||
|
}
|
||||||
|
const pub = login?.publisher();
|
||||||
|
if (pub) {
|
||||||
|
const ev = await pub.generic((eb) => {
|
||||||
|
eb.kind(USER_EMOJIS).content("");
|
||||||
|
for (const e of newPacks) {
|
||||||
|
eb.tag(["a", e.address]);
|
||||||
|
}
|
||||||
|
return eb;
|
||||||
|
});
|
||||||
|
console.debug(ev);
|
||||||
|
System.BroadcastEvent(ev);
|
||||||
|
Login.setEmojis(newPacks, ev.created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="emoji-pack">
|
<div className="emoji-pack">
|
||||||
<div className="emoji-pack-title">
|
<div className="emoji-pack-title">
|
||||||
<h4>{name}</h4>
|
<div>
|
||||||
<Mention pubkey={ev.pubkey} />
|
<h4>{name}</h4>
|
||||||
|
<Mention pubkey={ev.pubkey} />
|
||||||
|
</div>
|
||||||
|
<AsyncButton
|
||||||
|
className={`btn btn-primary ${isUsed ? "delete-button" : ""}`}
|
||||||
|
onClick={toggleEmojiPack}
|
||||||
|
>
|
||||||
|
{isUsed ? "Remove" : "Add"}
|
||||||
|
</AsyncButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="emoji-pack-emojis">
|
<div className="emoji-pack-emojis">
|
||||||
{emoji.map((e) => {
|
{emoji.map((e) => {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import "./emoji.css";
|
import "./emoji.css";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { EmojiTag } from "types";
|
||||||
|
|
||||||
export type EmojiProps = {
|
export type EmojiProps = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -10,8 +11,6 @@ export function Emoji({ name, url }: EmojiProps) {
|
|||||||
return <img alt={name} src={url} className="emoji" />;
|
return <img alt={name} src={url} className="emoji" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmojiTag = ["emoji", string, string];
|
|
||||||
|
|
||||||
export function Emojify({
|
export function Emojify({
|
||||||
content,
|
content,
|
||||||
emoji,
|
emoji,
|
||||||
|
@ -1,12 +1,22 @@
|
|||||||
.event-container .note {
|
.event-container .note {
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-container .goal {
|
.event-container .goal {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-container .goal .amount {
|
.event-container .goal .progress-root .amount {
|
||||||
top: -8px;
|
top: -8px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message .event-container .goal .progress-root .amount {
|
||||||
|
top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .event-container .note {
|
||||||
|
max-width: unset;
|
||||||
|
}
|
||||||
|
@ -1,7 +1,22 @@
|
|||||||
|
import { Icon } from "element/icon";
|
||||||
|
|
||||||
|
export function ExternalIconLink({ size = 32, href, ...rest }) {
|
||||||
|
return (
|
||||||
|
<span style={{ cursor: "pointer" }}>
|
||||||
|
<Icon
|
||||||
|
name="link"
|
||||||
|
size={size}
|
||||||
|
onClick={() => window.open(href, "_blank")}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExternalLink({ children, href }) {
|
export function ExternalLink({ children, href }) {
|
||||||
return (
|
return (
|
||||||
<a href={href} rel="noopener noreferrer" target="_blank">
|
<a href={href} rel="noopener noreferrer" target="_blank">
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,59 +1,52 @@
|
|||||||
import { EventKind } from "@snort/system";
|
import { EventKind } from "@snort/system";
|
||||||
import { useLogin } from "hooks/login";
|
|
||||||
import useFollows from "hooks/follows";
|
|
||||||
import AsyncButton from "element/async-button";
|
|
||||||
import { System } from "index";
|
|
||||||
|
|
||||||
export function LoggedInFollowButton({
|
import { useLogin } from "hooks/login";
|
||||||
loggedIn,
|
import AsyncButton from "element/async-button";
|
||||||
pubkey,
|
import { Login, System } from "index";
|
||||||
}: {
|
|
||||||
loggedIn: string;
|
export function LoggedInFollowButton({ pubkey }: { pubkey: string }) {
|
||||||
pubkey: string;
|
|
||||||
}) {
|
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const following = useFollows(loggedIn, true);
|
const tags = login.follows.tags;
|
||||||
const { tags, relays } = following ? following : { tags: [], relays: {} };
|
|
||||||
const follows = tags.filter((t) => t.at(0) === "p");
|
const follows = tags.filter((t) => t.at(0) === "p");
|
||||||
const isFollowing = follows.find((t) => t.at(1) === pubkey);
|
const isFollowing = follows.find((t) => t.at(1) === pubkey);
|
||||||
|
|
||||||
async function unfollow() {
|
async function unfollow() {
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
if (pub) {
|
if (pub) {
|
||||||
|
const newFollows = tags.filter((t) => t.at(1) !== pubkey);
|
||||||
const ev = await pub.generic((eb) => {
|
const ev = await pub.generic((eb) => {
|
||||||
eb.kind(EventKind.ContactList).content(JSON.stringify(relays));
|
eb.kind(EventKind.ContactList).content(login.follows.content);
|
||||||
for (const t of tags) {
|
for (const t of newFollows) {
|
||||||
const isFollow = t.at(0) === "p" && t.at(1) === pubkey;
|
eb.tag(t);
|
||||||
if (!isFollow) {
|
|
||||||
eb.tag(t);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return eb;
|
return eb;
|
||||||
});
|
});
|
||||||
console.debug(ev);
|
console.debug(ev);
|
||||||
System.BroadcastEvent(ev);
|
System.BroadcastEvent(ev);
|
||||||
|
Login.setFollows(newFollows, login.follows.content, ev.created_at);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function follow() {
|
async function follow() {
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
if (pub) {
|
if (pub) {
|
||||||
|
const newFollows = [...tags, ["p", pubkey]];
|
||||||
const ev = await pub.generic((eb) => {
|
const ev = await pub.generic((eb) => {
|
||||||
eb.kind(EventKind.ContactList).content(JSON.stringify(relays));
|
eb.kind(EventKind.ContactList).content(login.follows.content);
|
||||||
for (const tag of tags) {
|
for (const tag of newFollows) {
|
||||||
eb.tag(tag);
|
eb.tag(tag);
|
||||||
}
|
}
|
||||||
eb.tag(["p", pubkey]);
|
|
||||||
return eb;
|
return eb;
|
||||||
});
|
});
|
||||||
console.debug(ev);
|
console.debug(ev);
|
||||||
System.BroadcastEvent(ev);
|
System.BroadcastEvent(ev);
|
||||||
|
Login.setFollows(newFollows, login.follows.content, ev.created_at);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AsyncButton
|
<AsyncButton
|
||||||
disabled={!following}
|
disabled={login.follows.timestamp === 0}
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={isFollowing ? unfollow : follow}
|
onClick={isFollowing ? unfollow : follow}
|
||||||
|
@ -24,10 +24,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.live-chat .header .popout-link {
|
.live-chat .header .popout-link {
|
||||||
color: #FFFFFF80;
|
color: #ffffff80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-chat>.messages {
|
.live-chat > .messages {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
@ -37,12 +37,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1020px) {
|
@media (min-width: 1020px) {
|
||||||
.live-chat>.messages {
|
.live-chat > .messages {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-chat>.write-message {
|
.live-chat > .write-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
@ -51,7 +51,7 @@
|
|||||||
border-top: 1px solid var(--border, #171717);
|
border-top: 1px solid var(--border, #171717);
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-chat>.write-message>div:nth-child(1) {
|
.live-chat > .write-message > div:nth-child(1) {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
@ -77,15 +77,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.live-chat .message .profile {
|
.live-chat .message .profile {
|
||||||
color: #34D2FE;
|
color: #34d2fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-chat .message.streamer .profile {
|
.live-chat .message.streamer .profile {
|
||||||
color: #F838D9;
|
color: #f838d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-chat .message a {
|
.live-chat .message a {
|
||||||
color: #F838D9;
|
color: #f838d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-chat .profile img {
|
.live-chat .profile img {
|
||||||
@ -93,7 +93,7 @@
|
|||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-chat .message>span {
|
.live-chat .message > span {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
@ -172,13 +172,13 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
background: #0A0A0A;
|
background: #0a0a0a;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zap-container:before {
|
.zap-container:before {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@ -186,20 +186,28 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
margin: -1px;
|
margin: -1px;
|
||||||
background: linear-gradient(to bottom right, #FF902B, #F83838);
|
background: linear-gradient(to bottom right, #ff902b, #f83838);
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zap-container .profile {
|
.zap-container .profile {
|
||||||
color: #FF8D2B;
|
color: #ff8d2b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zap-container .zap-amount {
|
.zap-container .zap-amount {
|
||||||
color: #FF8D2B;
|
color: #ff8d2b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zap-container.big-zap:before {
|
.zap-container.big-zap:before {
|
||||||
background: linear-gradient(60deg, #2BD9FF, #8C8DED, #F838D9, #F83838, #FF902B, #DDF838);
|
background: linear-gradient(
|
||||||
|
60deg,
|
||||||
|
#2bd9ff,
|
||||||
|
#8c8ded,
|
||||||
|
#f838d9,
|
||||||
|
#f83838,
|
||||||
|
#ff902b,
|
||||||
|
#ddf838
|
||||||
|
);
|
||||||
animation: animatedgradient 3s ease alternate infinite;
|
animation: animatedgradient 3s ease alternate infinite;
|
||||||
background-size: 300% 300%;
|
background-size: 300% 300%;
|
||||||
}
|
}
|
||||||
@ -224,7 +232,7 @@
|
|||||||
|
|
||||||
.zap-pill {
|
.zap-pill {
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
background: rgba(255, 255, 255, 0.10);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@ -236,7 +244,7 @@
|
|||||||
.zap-pill-icon {
|
.zap-pill-icon {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
color: #FF8D2B;
|
color: #ff8d2b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-zap-container {
|
.message-zap-container {
|
||||||
@ -252,7 +260,7 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
transition: opacity .3s ease-out;
|
transition: opacity 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1020px) {
|
@media (min-width: 1020px) {
|
||||||
@ -271,7 +279,7 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: #FFFFFF66;
|
color: #ffffff66;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-zap-button:hover {
|
.message-zap-button:hover {
|
||||||
@ -299,7 +307,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
background: rgba(255, 255, 255, 0.10);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-reaction {
|
.message-reaction {
|
||||||
@ -311,7 +319,7 @@
|
|||||||
|
|
||||||
.zap-pill-amount {
|
.zap-pill-amount {
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
color: #FFF;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: Outfit;
|
font-family: Outfit;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@ -335,10 +343,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.write-emoji-button {
|
.write-emoji-button {
|
||||||
color: #FFFFFF80;
|
color: #ffffff80;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.write-emoji-button:hover {
|
.write-emoji-button:hover {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message .profile .badge-icon {
|
||||||
|
background: transparent;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: unset;
|
||||||
|
}
|
||||||
|
@ -26,7 +26,7 @@ import { ChatMessage } from "./chat-message";
|
|||||||
import { Goal } from "./goal";
|
import { Goal } from "./goal";
|
||||||
import { NewGoalDialog } from "./new-goal";
|
import { NewGoalDialog } from "./new-goal";
|
||||||
import { WriteMessage } from "./write-message";
|
import { WriteMessage } from "./write-message";
|
||||||
import { findTag, getHost } from "utils";
|
import { findTag, getTagValues, getHost } from "utils";
|
||||||
|
|
||||||
export interface LiveChatOptions {
|
export interface LiveChatOptions {
|
||||||
canWrite?: boolean;
|
canWrite?: boolean;
|
||||||
@ -79,10 +79,13 @@ export function LiveChat({
|
|||||||
return () => System.ProfileLoader.UntrackMetadata(pubkeys);
|
return () => System.ProfileLoader.UntrackMetadata(pubkeys);
|
||||||
}, [feed.zaps]);
|
}, [feed.zaps]);
|
||||||
|
|
||||||
const userEmojiPacks = useEmoji(login?.pubkey);
|
const mutedPubkeys = useMemo(() => {
|
||||||
|
return new Set(getTagValues(login?.muted.tags ?? [], "p"));
|
||||||
|
}, [login]);
|
||||||
|
const userEmojiPacks = login?.emojis ?? [];
|
||||||
const channelEmojiPacks = useEmoji(host);
|
const channelEmojiPacks = useEmoji(host);
|
||||||
const allEmojiPacks = useMemo(() => {
|
const allEmojiPacks = useMemo(() => {
|
||||||
return uniqBy(channelEmojiPacks.concat(userEmojiPacks), packId);
|
return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId);
|
||||||
}, [userEmojiPacks, channelEmojiPacks]);
|
}, [userEmojiPacks, channelEmojiPacks]);
|
||||||
|
|
||||||
const zaps = feed.zaps
|
const zaps = feed.zaps
|
||||||
@ -105,6 +108,9 @@ export function LiveChat({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [ev]);
|
}, [ev]);
|
||||||
|
const filteredEvents = useMemo(() => {
|
||||||
|
return events.filter((e) => !mutedPubkeys.has(e.pubkey));
|
||||||
|
}, [events, mutedPubkeys]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
||||||
@ -135,7 +141,7 @@ export function LiveChat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="messages">
|
<div className="messages">
|
||||||
{events.map((a) => {
|
{filteredEvents.map((a) => {
|
||||||
switch (a.kind) {
|
switch (a.kind) {
|
||||||
case LIVE_STREAM_CHAT: {
|
case LIVE_STREAM_CHAT: {
|
||||||
return (
|
return (
|
||||||
|
@ -1,200 +1,22 @@
|
|||||||
import "./markdown.css";
|
import "./markdown.css";
|
||||||
|
|
||||||
import { parseNostrLink } from "@snort/system";
|
import { createElement } from "react";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
import { Address } from "element/Address";
|
|
||||||
import { Event } from "element/Event";
|
|
||||||
import { Mention } from "element/mention";
|
|
||||||
import { Emoji } from "element/emoji";
|
|
||||||
import { HyperText } from "element/hypertext";
|
import { HyperText } from "element/hypertext";
|
||||||
|
import { transformText } from "element/text";
|
||||||
const MentionRegex = /(#\[\d+\])/gi;
|
|
||||||
const NostrPrefixRegex = /^nostr:/;
|
|
||||||
const EmojiRegex = /:([\w-]+):/g;
|
|
||||||
|
|
||||||
function extractEmoji(fragments: Fragment[], tags: string[][]) {
|
|
||||||
return fragments
|
|
||||||
.map((f) => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(EmojiRegex).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 extractMentions(fragments, tags) {
|
|
||||||
return fragments
|
|
||||||
.map((f) => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(MentionRegex).map((match) => {
|
|
||||||
const matchTag = match.match(/#\[(\d+)\]/);
|
|
||||||
if (matchTag && matchTag.length === 2) {
|
|
||||||
const idx = parseInt(matchTag[1]);
|
|
||||||
const ref = tags?.find((a, i) => i === idx);
|
|
||||||
if (ref) {
|
|
||||||
switch (ref[0]) {
|
|
||||||
case "p": {
|
|
||||||
return <Mention key={ref[1]} pubkey={ref[1]} />;
|
|
||||||
}
|
|
||||||
case "a": {
|
|
||||||
return <Address link={parseNostrLink(ref[1])} />;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// todo: e and t mentions
|
|
||||||
return ref[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractNprofiles(fragments) {
|
|
||||||
return fragments
|
|
||||||
.map((f) => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(/(nostr:nprofile1[a-z0-9]+)/g).map((i) => {
|
|
||||||
if (i.startsWith("nostr:nprofile1")) {
|
|
||||||
try {
|
|
||||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
|
||||||
return <Mention key={link.id} pubkey={link.id} />;
|
|
||||||
} catch (error) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractNpubs(fragments) {
|
|
||||||
return fragments
|
|
||||||
.map((f) => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(/(nostr:npub1[a-z0-9]+)/g).map((i) => {
|
|
||||||
if (i.startsWith("nostr:npub1")) {
|
|
||||||
try {
|
|
||||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
|
||||||
return <Mention key={link.id} pubkey={link.id} />;
|
|
||||||
} catch (error) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractNevents(fragments) {
|
|
||||||
return fragments
|
|
||||||
.map((f) => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(/(nostr:nevent1[a-z0-9]+)/g).map((i) => {
|
|
||||||
if (i.startsWith("nostr:nevent1")) {
|
|
||||||
try {
|
|
||||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
|
||||||
return <Event link={link} />;
|
|
||||||
} catch (error) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractNaddrs(fragments) {
|
|
||||||
return fragments
|
|
||||||
.map((f) => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(/(nostr:naddr1[a-z0-9]+)/g).map((i) => {
|
|
||||||
if (i.startsWith("nostr:naddr1")) {
|
|
||||||
try {
|
|
||||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
|
||||||
return <Address key={i} link={link} />;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractNoteIds(fragments) {
|
|
||||||
return fragments
|
|
||||||
.map((f) => {
|
|
||||||
if (typeof f === "string") {
|
|
||||||
return f.split(/(nostr:note1[a-z0-9]+)/g).map((i) => {
|
|
||||||
if (i.startsWith("nostr:note1")) {
|
|
||||||
try {
|
|
||||||
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
|
||||||
return <Event link={link} />;
|
|
||||||
} catch (error) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformText(ps, tags) {
|
|
||||||
let fragments = extractMentions(ps, tags);
|
|
||||||
fragments = extractNprofiles(fragments);
|
|
||||||
fragments = extractNevents(fragments);
|
|
||||||
fragments = extractNaddrs(fragments);
|
|
||||||
fragments = extractNoteIds(fragments);
|
|
||||||
fragments = extractNpubs(fragments);
|
|
||||||
fragments = extractEmoji(fragments, tags);
|
|
||||||
|
|
||||||
return fragments;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
children: ReactNode;
|
content: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown({ children, tags = [] }: MarkdownProps) {
|
export function Markdown({
|
||||||
|
content,
|
||||||
|
tags = [],
|
||||||
|
element = "div",
|
||||||
|
}: MarkdownProps) {
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
li: ({ children, ...props }) => {
|
li: ({ children, ...props }) => {
|
||||||
@ -202,15 +24,15 @@ export function Markdown({ children, tags = [] }: MarkdownProps) {
|
|||||||
},
|
},
|
||||||
td: ({ children }) =>
|
td: ({ children }) =>
|
||||||
children && <td>{transformText(children, tags)}</td>,
|
children && <td>{transformText(children, tags)}</td>,
|
||||||
p: ({ children }) => children && <p>{transformText(children, tags)}</p>,
|
p: ({ children }) => <p>{transformText(children, tags)}</p>,
|
||||||
a: (props) => {
|
a: (props) => {
|
||||||
return <HyperText link={props.href}>{props.children}</HyperText>;
|
return <HyperText link={props.href}>{props.children}</HyperText>;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [tags]);
|
}, [tags]);
|
||||||
return (
|
return createElement(
|
||||||
<div className="markdown">
|
element,
|
||||||
<ReactMarkdown children={children} components={components} />
|
{ className: "markdown" },
|
||||||
</div>
|
<ReactMarkdown components={components}>{content}</ReactMarkdown>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
63
src/element/mute-button.tsx
Normal file
63
src/element/mute-button.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { useLogin } from "hooks/login";
|
||||||
|
import AsyncButton from "element/async-button";
|
||||||
|
import { Login, System } from "index";
|
||||||
|
import { MUTED } from "const";
|
||||||
|
|
||||||
|
export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
|
||||||
|
const login = useLogin();
|
||||||
|
const tags = login.muted.tags;
|
||||||
|
const muted = tags.filter((t) => t.at(0) === "p");
|
||||||
|
const isMuted = muted.find((t) => t.at(1) === pubkey);
|
||||||
|
|
||||||
|
async function unmute() {
|
||||||
|
const pub = login?.publisher();
|
||||||
|
if (pub) {
|
||||||
|
const newMuted = tags.filter((t) => t.at(1) !== pubkey);
|
||||||
|
const ev = await pub.generic((eb) => {
|
||||||
|
eb.kind(MUTED).content(login.muted.content);
|
||||||
|
for (const t of newMuted) {
|
||||||
|
eb.tag(t);
|
||||||
|
}
|
||||||
|
return eb;
|
||||||
|
});
|
||||||
|
console.debug(ev);
|
||||||
|
System.BroadcastEvent(ev);
|
||||||
|
Login.setMuted(newMuted, login.muted.content, ev.created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mute() {
|
||||||
|
const pub = login?.publisher();
|
||||||
|
if (pub) {
|
||||||
|
const newMuted = [...tags, ["p", pubkey]];
|
||||||
|
const ev = await pub.generic((eb) => {
|
||||||
|
eb.kind(MUTED).content(login.muted.content);
|
||||||
|
for (const tag of newMuted) {
|
||||||
|
eb.tag(tag);
|
||||||
|
}
|
||||||
|
return eb;
|
||||||
|
});
|
||||||
|
console.debug(ev);
|
||||||
|
System.BroadcastEvent(ev);
|
||||||
|
Login.setMuted(newMuted, login.muted.content, ev.created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncButton
|
||||||
|
disabled={login.muted.timestamp === 0}
|
||||||
|
type="button"
|
||||||
|
className="btn delete-button"
|
||||||
|
onClick={isMuted ? unmute : mute}
|
||||||
|
>
|
||||||
|
{isMuted ? "Unmute" : "Mute"}
|
||||||
|
</AsyncButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MuteButton({ pubkey }: { pubkey: string }) {
|
||||||
|
const login = useLogin();
|
||||||
|
return login?.pubkey ? (
|
||||||
|
<LoggedInMuteButton loggedIn={login.pubkey} pubkey={pubkey} />
|
||||||
|
) : null;
|
||||||
|
}
|
@ -4,6 +4,11 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note .note-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.note .note-header .profile {
|
.note .note-header .profile {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@ -17,6 +22,11 @@
|
|||||||
margin-left: 30px;
|
margin-left: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note .note-content .markdown > p {
|
.note .note-content .markdown > * {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note .note-content .markdown > ul,
|
||||||
|
.note .note-content .markdown ol {
|
||||||
|
margin-left: 30px;
|
||||||
|
}
|
||||||
|
@ -2,6 +2,7 @@ import "./note.css";
|
|||||||
import { type NostrEvent } from "@snort/system";
|
import { type NostrEvent } from "@snort/system";
|
||||||
|
|
||||||
import { Markdown } from "element/markdown";
|
import { Markdown } from "element/markdown";
|
||||||
|
import { ExternalIconLink } from "element/external-link";
|
||||||
import { Profile } from "element/profile";
|
import { Profile } from "element/profile";
|
||||||
|
|
||||||
export function Note({ ev }: { ev: NostrEvent }) {
|
export function Note({ ev }: { ev: NostrEvent }) {
|
||||||
@ -9,9 +10,10 @@ export function Note({ ev }: { ev: NostrEvent }) {
|
|||||||
<div className="note">
|
<div className="note">
|
||||||
<div className="note-header">
|
<div className="note-header">
|
||||||
<Profile avatarClassname="note-avatar" pubkey={ev.pubkey} />
|
<Profile avatarClassname="note-avatar" pubkey={ev.pubkey} />
|
||||||
|
<ExternalIconLink size={25} href={`https://snort.social/e/${ev.id}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="note-content">
|
<div className="note-content">
|
||||||
<Markdown tags={ev.tags}>{ev.content}</Markdown>
|
<Markdown tags={ev.tags} content={ev.content} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -9,10 +9,10 @@ import { bytesToHex } from "@noble/curves/abstract/utils";
|
|||||||
import { formatSats } from "../number";
|
import { formatSats } from "../number";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import AsyncButton from "./async-button";
|
import AsyncButton from "./async-button";
|
||||||
import { Relays } from "index";
|
|
||||||
import QrCode from "./qr-code";
|
import QrCode from "./qr-code";
|
||||||
import { useLogin } from "hooks/login";
|
import { useLogin } from "hooks/login";
|
||||||
import Copy from "./copy";
|
import Copy from "./copy";
|
||||||
|
import { defaultRelays } from "const";
|
||||||
|
|
||||||
export interface LNURLLike {
|
export interface LNURLLike {
|
||||||
get name(): string;
|
get name(): string;
|
||||||
@ -21,7 +21,7 @@ export interface LNURLLike {
|
|||||||
getInvoice(
|
getInvoice(
|
||||||
amountInSats: number,
|
amountInSats: number,
|
||||||
comment?: string,
|
comment?: string,
|
||||||
zap?: NostrEvent
|
zap?: NostrEvent,
|
||||||
): Promise<{ pr?: string }>;
|
): Promise<{ pr?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ export function SendZaps({
|
|||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const [invoice, setInvoice] = useState("");
|
const [invoice, setInvoice] = useState("");
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
|
const relays = Object.keys(defaultRelays);
|
||||||
const name = targetName ?? svc?.name;
|
const name = targetName ?? svc?.name;
|
||||||
async function loadService(lnurl: string) {
|
async function loadService(lnurl: string) {
|
||||||
const s = new LNURL(lnurl);
|
const s = new LNURL(lnurl);
|
||||||
@ -78,7 +78,9 @@ export function SendZaps({
|
|||||||
let pub = login?.publisher();
|
let pub = login?.publisher();
|
||||||
let isAnon = false;
|
let isAnon = false;
|
||||||
if (!pub) {
|
if (!pub) {
|
||||||
pub = EventPublisher.privateKey(bytesToHex(secp256k1.utils.randomPrivateKey()));
|
pub = EventPublisher.privateKey(
|
||||||
|
bytesToHex(secp256k1.utils.randomPrivateKey()),
|
||||||
|
);
|
||||||
isAnon = true;
|
isAnon = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +90,7 @@ export function SendZaps({
|
|||||||
zap = await pub.zap(
|
zap = await pub.zap(
|
||||||
amountInSats * 1000,
|
amountInSats * 1000,
|
||||||
pubkey,
|
pubkey,
|
||||||
Relays,
|
relays,
|
||||||
undefined,
|
undefined,
|
||||||
comment,
|
comment,
|
||||||
(eb) => {
|
(eb) => {
|
||||||
@ -102,7 +104,7 @@ export function SendZaps({
|
|||||||
eb.tag(["anon", ""]);
|
eb.tag(["anon", ""]);
|
||||||
}
|
}
|
||||||
return eb;
|
return eb;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const invoice = await svc.getInvoice(amountInSats, comment, zap);
|
const invoice = await svc.getInvoice(amountInSats, comment, zap);
|
||||||
|
@ -9,10 +9,10 @@ import type { NostrEvent } from "@snort/system";
|
|||||||
|
|
||||||
import { Toggle } from "element/toggle";
|
import { Toggle } from "element/toggle";
|
||||||
import { useLogin } from "hooks/login";
|
import { useLogin } from "hooks/login";
|
||||||
import { useCards } from "hooks/cards";
|
import { useCards, useUserCards } from "hooks/cards";
|
||||||
import { CARD, USER_CARDS } from "const";
|
import { CARD, USER_CARDS } from "const";
|
||||||
import { toTag } from "utils";
|
import { toTag } from "utils";
|
||||||
import { System } from "index";
|
import { Login, System } from "index";
|
||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { ExternalLink } from "./external-link";
|
import { ExternalLink } from "./external-link";
|
||||||
@ -55,7 +55,7 @@ const CardPreview = forwardRef(
|
|||||||
) : (
|
) : (
|
||||||
<img className="card-image" src={image} alt={title} />
|
<img className="card-image" src={image} alt={title} />
|
||||||
))}
|
))}
|
||||||
<Markdown children={content} />
|
<Markdown content={content} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -130,6 +130,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
|||||||
});
|
});
|
||||||
console.debug(userCardsEv);
|
console.debug(userCardsEv);
|
||||||
System.BroadcastEvent(userCardsEv);
|
System.BroadcastEvent(userCardsEv);
|
||||||
|
Login.setCards(newTags, userCardsEv.created_at);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[canEdit, tags, identifier],
|
[canEdit, tags, identifier],
|
||||||
@ -278,18 +279,18 @@ function EditCard({ card, cards }: EditCardProps) {
|
|||||||
async function onCancel() {
|
async function onCancel() {
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
if (pub) {
|
if (pub) {
|
||||||
|
const newTags = tags.filter((t) => !t.at(1).endsWith(`:${identifier}`));
|
||||||
const userCardsEv = await pub.generic((eb) => {
|
const userCardsEv = await pub.generic((eb) => {
|
||||||
eb.kind(USER_CARDS).content("");
|
eb.kind(USER_CARDS).content("");
|
||||||
for (const tag of tags) {
|
for (const tag of newTags) {
|
||||||
if (!tag.at(1).endsWith(`:${identifier}`)) {
|
eb.tag(tag);
|
||||||
eb.tag(tag);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return eb;
|
return eb;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.debug(userCardsEv);
|
console.debug(userCardsEv);
|
||||||
System.BroadcastEvent(userCardsEv);
|
System.BroadcastEvent(userCardsEv);
|
||||||
|
Login.setCards(newTags, userCardsEv.created_at);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -381,12 +382,11 @@ function AddCard({ cards }: AddCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreamCards({ host }) {
|
export function StreamCardEditor() {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const canEdit = login?.pubkey === host;
|
const cards = useUserCards(login.pubkey, login.cards.tags, true);
|
||||||
const cards = useCards(host, canEdit);
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const components = (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="stream-cards">
|
<div className="stream-cards">
|
||||||
{cards.map((ev) => (
|
{cards.map((ev) => (
|
||||||
@ -394,17 +394,35 @@ export function StreamCards({ host }) {
|
|||||||
))}
|
))}
|
||||||
{isEditing && <AddCard cards={cards} />}
|
{isEditing && <AddCard cards={cards} />}
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
<div className="edit-container">
|
||||||
<div className="edit-container">
|
<Toggle
|
||||||
<Toggle
|
pressed={isEditing}
|
||||||
pressed={isEditing}
|
onPressedChange={setIsEditing}
|
||||||
onPressedChange={setIsEditing}
|
label="Toggle edit mode"
|
||||||
label="Toggle edit mode"
|
text="Edit cards"
|
||||||
text="Edit cards"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return <DndProvider backend={HTML5Backend}>{components}</DndProvider>;
|
}
|
||||||
|
|
||||||
|
export function ReadOnlyStreamCards({ host }) {
|
||||||
|
const cards = useCards(host);
|
||||||
|
return (
|
||||||
|
<div className="stream-cards">
|
||||||
|
{cards.map((ev) => (
|
||||||
|
<Card cards={cards} key={ev.id} ev={ev} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamCards({ host }) {
|
||||||
|
const login = useLogin();
|
||||||
|
const canEdit = login?.pubkey === host;
|
||||||
|
return (
|
||||||
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
{canEdit ? <StreamCardEditor /> : <ReadOnlyStreamCards host={host} />}
|
||||||
|
</DndProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,18 @@
|
|||||||
import { useMemo, type ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
import { validateNostrLink } from "@snort/system";
|
|
||||||
|
import { parseNostrLink, validateNostrLink } from "@snort/system";
|
||||||
|
|
||||||
|
import { Address } from "element/Address";
|
||||||
|
import { Event } from "element/Event";
|
||||||
|
import { Mention } from "element/mention";
|
||||||
|
import { Emoji } from "element/emoji";
|
||||||
|
import { HyperText } from "element/hypertext";
|
||||||
import { splitByUrl } from "utils";
|
import { splitByUrl } from "utils";
|
||||||
import { Emoji } from "./emoji";
|
|
||||||
import { HyperText } from "./hypertext";
|
|
||||||
|
|
||||||
type Fragment = string | ReactNode;
|
type Fragment = string | ReactNode;
|
||||||
|
|
||||||
function transformText(fragments: Fragment[], tags: string[][]) {
|
const NostrPrefixRegex = /^nostr:/;
|
||||||
return extractLinks(extractEmoji(fragments, tags));
|
const EmojiRegex = /:([\w-]+):/g;
|
||||||
}
|
|
||||||
|
|
||||||
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[]) {
|
function extractLinks(fragments: Fragment[]) {
|
||||||
return fragments
|
return fragments
|
||||||
@ -74,6 +60,147 @@ function extractLinks(fragments: Fragment[]) {
|
|||||||
.flat();
|
.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractEmoji(fragments: Fragment[], tags: string[][]) {
|
||||||
|
return fragments
|
||||||
|
.map((f) => {
|
||||||
|
if (typeof f === "string") {
|
||||||
|
return f.split(EmojiRegex).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 extractNprofiles(fragments: Fragment[]) {
|
||||||
|
return fragments
|
||||||
|
.map((f) => {
|
||||||
|
if (typeof f === "string") {
|
||||||
|
return f.split(/(nostr:nprofile1[a-z0-9]+)/g).map((i) => {
|
||||||
|
if (i.startsWith("nostr:nprofile1")) {
|
||||||
|
try {
|
||||||
|
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||||
|
return <Mention key={link.id} pubkey={link.id} />;
|
||||||
|
} catch (error) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNpubs(fragments: Fragment[]) {
|
||||||
|
return fragments
|
||||||
|
.map((f) => {
|
||||||
|
if (typeof f === "string") {
|
||||||
|
return f.split(/(nostr:npub1[a-z0-9]+)/g).map((i) => {
|
||||||
|
if (i.startsWith("nostr:npub1")) {
|
||||||
|
try {
|
||||||
|
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||||
|
return <Mention key={link.id} pubkey={link.id} />;
|
||||||
|
} catch (error) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNevents(fragments: Fragment[]) {
|
||||||
|
return fragments
|
||||||
|
.map((f) => {
|
||||||
|
if (typeof f === "string") {
|
||||||
|
return f.split(/(nostr:nevent1[a-z0-9]+)/g).map((i) => {
|
||||||
|
if (i.startsWith("nostr:nevent1")) {
|
||||||
|
try {
|
||||||
|
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||||
|
return <Event link={link} />;
|
||||||
|
} catch (error) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNaddrs(fragments: Fragment[]) {
|
||||||
|
return fragments
|
||||||
|
.map((f) => {
|
||||||
|
if (typeof f === "string") {
|
||||||
|
return f.split(/(nostr:naddr1[a-z0-9]+)/g).map((i) => {
|
||||||
|
if (i.startsWith("nostr:naddr1")) {
|
||||||
|
try {
|
||||||
|
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||||
|
return <Address key={i} link={link} />;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNoteIds(fragments: Fragment[]) {
|
||||||
|
return fragments
|
||||||
|
.map((f) => {
|
||||||
|
if (typeof f === "string") {
|
||||||
|
return f.split(/(nostr:note1[a-z0-9]+)/g).map((i) => {
|
||||||
|
if (i.startsWith("nostr:note1")) {
|
||||||
|
try {
|
||||||
|
const link = parseNostrLink(i.replace(NostrPrefixRegex, ""));
|
||||||
|
return <Event link={link} />;
|
||||||
|
} catch (error) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformText(ps: Fragment[], tags: Array<string[]>) {
|
||||||
|
let fragments = extractEmoji(ps, tags);
|
||||||
|
fragments = extractNprofiles(fragments);
|
||||||
|
fragments = extractNevents(fragments);
|
||||||
|
fragments = extractNaddrs(fragments);
|
||||||
|
fragments = extractNoteIds(fragments);
|
||||||
|
fragments = extractNpubs(fragments);
|
||||||
|
fragments = extractLinks(fragments);
|
||||||
|
|
||||||
|
return fragments;
|
||||||
|
}
|
||||||
|
|
||||||
export function Text({ content, tags }: { content: string; tags: string[][] }) {
|
export function Text({ content, tags }: { content: string; tags: string[][] }) {
|
||||||
// todo: RTL langugage support
|
// todo: RTL langugage support
|
||||||
const element = useMemo(() => {
|
const element = useMemo(() => {
|
||||||
|
@ -11,6 +11,67 @@ import { USER_CARDS, CARD } from "const";
|
|||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
|
|
||||||
|
export function useUserCards(
|
||||||
|
pubkey: string,
|
||||||
|
userCards: Array<string[]>,
|
||||||
|
leaveOpen = false,
|
||||||
|
) {
|
||||||
|
const related = useMemo(() => {
|
||||||
|
// filtering to only show CARD kinds for now, but in the future we could link and render anything
|
||||||
|
if (userCards?.length > 0) {
|
||||||
|
return userCards.filter(
|
||||||
|
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [userCards]);
|
||||||
|
|
||||||
|
const subRelated = useMemo(() => {
|
||||||
|
if (!pubkey) return null;
|
||||||
|
const splitted = related.map((t) => t.at(1)!.split(":"));
|
||||||
|
const authors = splitted
|
||||||
|
.map((s) => s.at(1))
|
||||||
|
.filter((s) => s)
|
||||||
|
.map((s) => s as string);
|
||||||
|
const identifiers = splitted
|
||||||
|
.map((s) => s.at(2))
|
||||||
|
.filter((s) => s)
|
||||||
|
.map((s) => s as string);
|
||||||
|
|
||||||
|
const rb = new RequestBuilder(`cards:${pubkey}`);
|
||||||
|
rb.withOptions({ leaveOpen })
|
||||||
|
.withFilter()
|
||||||
|
.kinds([CARD])
|
||||||
|
.authors(authors)
|
||||||
|
.tag("d", identifiers);
|
||||||
|
|
||||||
|
return rb;
|
||||||
|
}, [pubkey, related]);
|
||||||
|
|
||||||
|
const { data } = useRequestBuilder<NoteCollection>(
|
||||||
|
System,
|
||||||
|
NoteCollection,
|
||||||
|
subRelated,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = useMemo(() => {
|
||||||
|
return related
|
||||||
|
.map((t) => {
|
||||||
|
const [k, pubkey, identifier] = t.at(1).split(":");
|
||||||
|
const kind = Number(k);
|
||||||
|
return (data ?? []).find(
|
||||||
|
(e) =>
|
||||||
|
e.kind === kind &&
|
||||||
|
e.pubkey === pubkey &&
|
||||||
|
findTag(e, "d") === identifier,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter((e) => e);
|
||||||
|
}, [related, data]);
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
export function useCards(pubkey: string, leaveOpen = false) {
|
export function useCards(pubkey: string, leaveOpen = false) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`);
|
const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`);
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import uniqBy from "lodash.uniqby";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RequestBuilder,
|
RequestBuilder,
|
||||||
ReplaceableNoteStore,
|
ReplaceableNoteStore,
|
||||||
@ -6,29 +9,15 @@ import {
|
|||||||
} from "@snort/system";
|
} from "@snort/system";
|
||||||
import { useRequestBuilder } from "@snort/system-react";
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
import { EMOJI_PACK, USER_EMOJIS } from "const";
|
import { EMOJI_PACK, USER_EMOJIS } from "const";
|
||||||
import type { EmojiTag } from "../element/emoji";
|
import { EmojiPack } from "types";
|
||||||
import uniqBy from "lodash.uniqby";
|
|
||||||
|
|
||||||
export interface Emoji {
|
|
||||||
native?: string;
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmojiPack {
|
|
||||||
address: string;
|
|
||||||
name: string;
|
|
||||||
author: string;
|
|
||||||
emojis: EmojiTag[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanShortcode(shortcode?: string) {
|
function cleanShortcode(shortcode?: string) {
|
||||||
return shortcode?.replace(/\s+/g, "_").replace(/_$/, "");
|
return shortcode?.replace(/\s+/g, "_").replace(/_$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toEmojiPack(ev: NostrEvent): EmojiPack {
|
export function toEmojiPack(ev: NostrEvent): EmojiPack {
|
||||||
const d = findTag(ev, "d") || "";
|
const d = findTag(ev, "d") || "";
|
||||||
return {
|
return {
|
||||||
address: `${ev.kind}:${ev.pubkey}:${d}`,
|
address: `${ev.kind}:${ev.pubkey}:${d}`,
|
||||||
@ -44,25 +33,10 @@ export function packId(pack: EmojiPack): string {
|
|||||||
return `${pack.author}:${pack.name}`;
|
return `${pack.author}:${pack.name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useEmoji(pubkey?: string) {
|
export function useUserEmojiPacks(pubkey?: string, userEmoji: Array<string[]>) {
|
||||||
const sub = useMemo(() => {
|
|
||||||
if (!pubkey) return null;
|
|
||||||
const rb = new RequestBuilder(`emoji:${pubkey}`);
|
|
||||||
|
|
||||||
rb.withFilter().authors([pubkey]).kinds([USER_EMOJIS]);
|
|
||||||
|
|
||||||
return rb;
|
|
||||||
}, [pubkey]);
|
|
||||||
|
|
||||||
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
|
|
||||||
System,
|
|
||||||
ReplaceableNoteStore,
|
|
||||||
sub,
|
|
||||||
);
|
|
||||||
|
|
||||||
const related = useMemo(() => {
|
const related = useMemo(() => {
|
||||||
if (userEmoji) {
|
if (userEmoji?.length > 0) {
|
||||||
return userEmoji.tags.filter(
|
return userEmoji.filter(
|
||||||
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`),
|
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -101,8 +75,29 @@ export default function useEmoji(pubkey?: string) {
|
|||||||
}, [relatedData]);
|
}, [relatedData]);
|
||||||
|
|
||||||
const emojis = useMemo(() => {
|
const emojis = useMemo(() => {
|
||||||
return uniqBy(emojiPacks.map(toEmojiPack), packId);
|
const packs = emojiPacks.map(toEmojiPack);
|
||||||
|
return uniqBy(packs, packId);
|
||||||
}, [emojiPacks]);
|
}, [emojiPacks]);
|
||||||
|
|
||||||
return emojis;
|
return emojis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function useEmoji(pubkey?: string) {
|
||||||
|
const sub = useMemo(() => {
|
||||||
|
if (!pubkey) return null;
|
||||||
|
const rb = new RequestBuilder(`emoji:${pubkey}`);
|
||||||
|
|
||||||
|
rb.withFilter().authors([pubkey]).kinds([USER_EMOJIS]);
|
||||||
|
|
||||||
|
return rb;
|
||||||
|
}, [pubkey]);
|
||||||
|
|
||||||
|
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
|
||||||
|
System,
|
||||||
|
ReplaceableNoteStore,
|
||||||
|
sub,
|
||||||
|
);
|
||||||
|
|
||||||
|
const emojis = useUserEmojiPacks(pubkey, userEmoji?.tags ?? []);
|
||||||
|
return emojis;
|
||||||
|
}
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { EventKind, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
|
|
||||||
import { useRequestBuilder } from "@snort/system-react";
|
|
||||||
import { System } from "index";
|
|
||||||
|
|
||||||
export default function useFollows(pubkey: string, leaveOpen = false) {
|
|
||||||
const sub = useMemo(() => {
|
|
||||||
const b = new RequestBuilder(`follows:${pubkey.slice(0, 12)}`);
|
|
||||||
b.withOptions({
|
|
||||||
leaveOpen,
|
|
||||||
})
|
|
||||||
.withFilter()
|
|
||||||
.authors([pubkey])
|
|
||||||
.kinds([EventKind.ContactList]);
|
|
||||||
return b;
|
|
||||||
}, [pubkey, leaveOpen]);
|
|
||||||
|
|
||||||
const { data } = useRequestBuilder<ReplaceableNoteStore>(
|
|
||||||
System,
|
|
||||||
ReplaceableNoteStore,
|
|
||||||
sub,
|
|
||||||
);
|
|
||||||
|
|
||||||
const relays = JSON.parse(data?.content.length > 0 ? data?.content : "{}");
|
|
||||||
return data ? { tags: data.tags, relays } : null;
|
|
||||||
}
|
|
@ -1,17 +1,76 @@
|
|||||||
import { Login } from "index";
|
import { useSyncExternalStore, useMemo, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
|
||||||
|
import { useRequestBuilder } from "@snort/system-react";
|
||||||
|
|
||||||
|
import { useUserEmojiPacks } from "hooks/emoji";
|
||||||
|
import { MUTED, USER_CARDS, USER_EMOJIS } from "const";
|
||||||
|
import { System, Login } from "index";
|
||||||
import { getPublisher } from "login";
|
import { getPublisher } from "login";
|
||||||
import { useSyncExternalStore } from "react";
|
|
||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
const session = useSyncExternalStore(
|
const session = useSyncExternalStore(
|
||||||
(c) => Login.hook(c),
|
(c) => Login.hook(c),
|
||||||
() => Login.snapshot()
|
() => Login.snapshot(),
|
||||||
);
|
);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
publisher: () => {
|
publisher: () => {
|
||||||
return getPublisher(session);
|
return getPublisher(session);
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoginEvents(pubkey?: string, leaveOpen = false) {
|
||||||
|
const [userEmojis, setUserEmojis] = useState([]);
|
||||||
|
const session = useSyncExternalStore(
|
||||||
|
(c) => Login.hook(c),
|
||||||
|
() => Login.snapshot(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sub = useMemo(() => {
|
||||||
|
if (!pubkey) return null;
|
||||||
|
const b = new RequestBuilder(`login:${pubkey.slice(0, 12)}`);
|
||||||
|
b.withOptions({
|
||||||
|
leaveOpen,
|
||||||
|
})
|
||||||
|
.withFilter()
|
||||||
|
.authors([pubkey])
|
||||||
|
.kinds([EventKind.ContactList, MUTED, USER_EMOJIS, USER_CARDS]);
|
||||||
|
return b;
|
||||||
|
}, [pubkey, leaveOpen]);
|
||||||
|
|
||||||
|
const { data } = useRequestBuilder<NoteCollection>(
|
||||||
|
System,
|
||||||
|
NoteCollection,
|
||||||
|
sub,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const ev of data) {
|
||||||
|
if (ev?.kind === USER_EMOJIS) {
|
||||||
|
setUserEmojis(ev.tags);
|
||||||
|
}
|
||||||
|
if (ev?.kind === USER_CARDS) {
|
||||||
|
Login.setCards(ev.tags, ev.created_at);
|
||||||
|
}
|
||||||
|
if (ev?.kind === MUTED) {
|
||||||
|
Login.setMuted(ev.tags, ev.content, ev.created_at);
|
||||||
|
}
|
||||||
|
if (ev?.kind === EventKind.ContactList) {
|
||||||
|
Login.setFollows(ev.tags, ev.content, ev.created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const emojis = useUserEmojiPacks(pubkey, userEmojis);
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) {
|
||||||
|
Login.setEmojis(emojis);
|
||||||
|
}
|
||||||
|
}, [emojis]);
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,14 @@ import ReactDOM from "react-dom/client";
|
|||||||
import { NostrSystem } from "@snort/system";
|
import { NostrSystem } from "@snort/system";
|
||||||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
import { RootPage } from "./pages/root";
|
import { RootPage } from "pages/root";
|
||||||
import { LayoutPage } from "pages/layout";
|
import { LayoutPage } from "pages/layout";
|
||||||
import { ProfilePage } from "pages/profile-page";
|
import { ProfilePage } from "pages/profile-page";
|
||||||
import { StreamPage } from "pages/stream-page";
|
import { StreamPage } from "pages/stream-page";
|
||||||
import { ChatPopout } from "pages/chat-popout";
|
import { ChatPopout } from "pages/chat-popout";
|
||||||
import { LoginStore } from "login";
|
import { LoginStore } from "login";
|
||||||
import { StreamProvidersPage } from "pages/providers";
|
import { StreamProvidersPage } from "pages/providers";
|
||||||
|
import { defaultRelays } from "const";
|
||||||
|
|
||||||
export enum StreamState {
|
export enum StreamState {
|
||||||
Live = "live",
|
Live = "live",
|
||||||
@ -23,14 +24,10 @@ export enum StreamState {
|
|||||||
export const System = new NostrSystem({});
|
export const System = new NostrSystem({});
|
||||||
export const Login = new LoginStore();
|
export const Login = new LoginStore();
|
||||||
|
|
||||||
export const Relays = [
|
Object.entries(defaultRelays).forEach((params) => {
|
||||||
"wss://relay.snort.social",
|
const [relay, settings] = params;
|
||||||
"wss://nos.lol",
|
System.ConnectToRelay(relay, settings);
|
||||||
"wss://relay.damus.io",
|
});
|
||||||
"wss://nostr.wine",
|
|
||||||
];
|
|
||||||
|
|
||||||
Relays.forEach((r) => System.ConnectToRelay(r, { read: true, write: true }));
|
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -64,10 +61,10 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById("root") as HTMLDivElement
|
document.getElementById("root") as HTMLDivElement,
|
||||||
);
|
);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
69
src/login.ts
69
src/login.ts
@ -2,27 +2,46 @@ import { bytesToHex } from "@noble/curves/abstract/utils";
|
|||||||
import { schnorr } from "@noble/curves/secp256k1";
|
import { schnorr } from "@noble/curves/secp256k1";
|
||||||
import { ExternalStore } from "@snort/shared";
|
import { ExternalStore } from "@snort/shared";
|
||||||
import { EventPublisher, Nip7Signer, PrivateKeySigner } from "@snort/system";
|
import { EventPublisher, Nip7Signer, PrivateKeySigner } from "@snort/system";
|
||||||
|
import type { EmojiPack } from "types";
|
||||||
|
|
||||||
export enum LoginType {
|
export enum LoginType {
|
||||||
Nip7 = "nip7",
|
Nip7 = "nip7",
|
||||||
PrivateKey = "private-key",
|
PrivateKey = "private-key",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReplaceableTags {
|
||||||
|
tags: Array<string[]>;
|
||||||
|
content?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginSession {
|
export interface LoginSession {
|
||||||
type: LoginType;
|
type: LoginType;
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
privateKey?: string;
|
privateKey?: string;
|
||||||
follows: string[];
|
follows: ReplaceableTags;
|
||||||
|
muted: ReplaceableTags;
|
||||||
|
cards: ReplaceableTags;
|
||||||
|
emojis: Array<EmojiPack>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
follows: { tags: [], timestamp: 0 },
|
||||||
|
muted: { tags: [], timestamp: 0 },
|
||||||
|
cards: { tags: [], timestamp: 0 },
|
||||||
|
emojis: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSION_KEY = "session";
|
||||||
|
|
||||||
export class LoginStore extends ExternalStore<LoginSession | undefined> {
|
export class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||||
#session?: LoginSession;
|
#session?: LoginSession;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const json = window.localStorage.getItem("session");
|
const json = window.localStorage.getItem(SESSION_KEY);
|
||||||
if (json) {
|
if (json) {
|
||||||
this.#session = JSON.parse(json);
|
this.#session = { ...initialState, ...JSON.parse(json) };
|
||||||
if (this.#session) {
|
if (this.#session) {
|
||||||
this.#session.type ??= LoginType.Nip7;
|
this.#session.type ??= LoginType.Nip7;
|
||||||
}
|
}
|
||||||
@ -33,7 +52,7 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
|
|||||||
this.#session = {
|
this.#session = {
|
||||||
type,
|
type,
|
||||||
pubkey: pk,
|
pubkey: pk,
|
||||||
follows: [],
|
...initialState,
|
||||||
};
|
};
|
||||||
this.#save();
|
this.#save();
|
||||||
}
|
}
|
||||||
@ -43,7 +62,7 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
|
|||||||
type: LoginType.PrivateKey,
|
type: LoginType.PrivateKey,
|
||||||
pubkey: bytesToHex(schnorr.getPublicKey(key)),
|
pubkey: bytesToHex(schnorr.getPublicKey(key)),
|
||||||
privateKey: key,
|
privateKey: key,
|
||||||
follows: [],
|
...initialState,
|
||||||
};
|
};
|
||||||
this.#save();
|
this.#save();
|
||||||
}
|
}
|
||||||
@ -57,11 +76,45 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
|
|||||||
return this.#session ? { ...this.#session } : undefined;
|
return this.#session ? { ...this.#session } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setFollows(follows: Array<string>, content: string, ts: number) {
|
||||||
|
if (this.#session.follows.timestamp >= ts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#session.follows.tags = follows;
|
||||||
|
this.#session.follows.content = content;
|
||||||
|
this.#session.follows.timestamp = ts;
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmojis(emojis: Array<EmojiPack>) {
|
||||||
|
this.#session.emojis = emojis;
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
|
setMuted(muted: Array<string[]>, content: string, ts: number) {
|
||||||
|
if (this.#session.muted.timestamp >= ts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#session.muted.tags = muted;
|
||||||
|
this.#session.muted.content = content;
|
||||||
|
this.#session.muted.timestamp = ts;
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCards(cards: Array<string[]>, ts: number) {
|
||||||
|
if (this.#session.cards.timestamp >= ts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#session.cards.tags = cards;
|
||||||
|
this.#session.cards.timestamp = ts;
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
#save() {
|
#save() {
|
||||||
if (this.#session) {
|
if (this.#session) {
|
||||||
window.localStorage.setItem("session", JSON.stringify(this.#session));
|
window.localStorage.setItem(SESSION_KEY, JSON.stringify(this.#session));
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem("session");
|
window.localStorage.removeItem(SESSION_KEY);
|
||||||
}
|
}
|
||||||
this.notifyChange();
|
this.notifyChange();
|
||||||
}
|
}
|
||||||
@ -75,7 +128,7 @@ export function getPublisher(session: LoginSession) {
|
|||||||
case LoginType.PrivateKey: {
|
case LoginType.PrivateKey: {
|
||||||
return new EventPublisher(
|
return new EventPublisher(
|
||||||
new PrivateKeySigner(session.privateKey!),
|
new PrivateKeySigner(session.privateKey!),
|
||||||
session.pubkey
|
session.pubkey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import { Outlet, useNavigate } from "react-router-dom";
|
|||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
|
||||||
import { Icon } from "element/icon";
|
import { Icon } from "element/icon";
|
||||||
import { useLogin } from "hooks/login";
|
import { useLogin, useLoginEvents } from "hooks/login";
|
||||||
import { Profile } from "element/profile";
|
import { Profile } from "element/profile";
|
||||||
import { NewStreamDialog } from "element/new-stream";
|
import { NewStreamDialog } from "element/new-stream";
|
||||||
import { LoginSignup } from "element/login-signup";
|
import { LoginSignup } from "element/login-signup";
|
||||||
@ -17,6 +17,7 @@ export function LayoutPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const [showLogin, setShowLogin] = useState(false);
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
|
useLoginEvents(login?.pubkey, true);
|
||||||
|
|
||||||
function loggedIn() {
|
function loggedIn() {
|
||||||
if (!login) return;
|
if (!login) return;
|
||||||
@ -105,4 +106,4 @@ export function LayoutPage() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ import { Icon } from "element/icon";
|
|||||||
import { SendZapsDialog } from "element/send-zap";
|
import { SendZapsDialog } from "element/send-zap";
|
||||||
import { VideoTile } from "element/video-tile";
|
import { VideoTile } from "element/video-tile";
|
||||||
import { FollowButton } from "element/follow-button";
|
import { FollowButton } from "element/follow-button";
|
||||||
|
import { MuteButton } from "element/mute-button";
|
||||||
import { useProfile } from "hooks/profile";
|
import { useProfile } from "hooks/profile";
|
||||||
import useTopZappers from "hooks/top-zappers";
|
import useTopZappers from "hooks/top-zappers";
|
||||||
import { Text } from "element/text";
|
import { Text } from "element/text";
|
||||||
@ -131,6 +132,7 @@ export function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<FollowButton pubkey={link.id} />
|
<FollowButton pubkey={link.id} />
|
||||||
|
<MuteButton pubkey={link.id} />
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-information">
|
<div className="profile-information">
|
||||||
{profile?.name && <h1 className="name">{profile.name}</h1>}
|
{profile?.name && <h1 className="name">{profile.name}</h1>}
|
||||||
|
22
src/types.ts
Normal file
22
src/types.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export interface RelaySettings {
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Relays {
|
||||||
|
[key: string]: RelaySettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmojiTag = ["emoji", string, string];
|
||||||
|
|
||||||
|
export interface Emoji {
|
||||||
|
native?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmojiPack {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
emojis: EmojiTag[];
|
||||||
|
}
|
@ -90,3 +90,7 @@ export async function openFile(): Promise<File | undefined> {
|
|||||||
elm.click();
|
elm.click();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTagValues(tags: Array<string[]>, tag: string) {
|
||||||
|
return tags.filter((t) => t.at(0) === tag).map((t) => t.at(1));
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user