Merge pull request #489 from v0l/polls

Polls (NIP-69)
This commit is contained in:
Kieran 2023-04-10 19:36:08 +01:00 committed by GitHub
commit aed79fefb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 322 additions and 71 deletions

View File

@ -376,5 +376,14 @@
stroke-linejoin="round"
/>
</symbol>
<symbol id="pie-chart" viewBox="0 0 22 22" fill="none">
<path
d="M20.2104 14.8901C19.5742 16.3946 18.5792 17.7203 17.3123 18.7514C16.0454 19.7825 14.5452 20.4875 12.9428 20.8048C11.3405 21.1222 9.68483 21.0422 8.12055 20.5719C6.55627 20.1015 5.13103 19.2551 3.96942 18.1067C2.80782 16.9583 1.94522 15.5428 1.45704 13.984C0.968859 12.4252 0.869965 10.7706 1.169 9.1647C1.46804 7.55885 2.1559 6.0507 3.17245 4.7721C4.189 3.4935 5.50329 2.48339 7.0004 1.83007M20.2392 7.17323C20.6395 8.1397 20.8851 9.16143 20.9684 10.2009C20.989 10.4577 20.9993 10.5861 20.9483 10.7018C20.9057 10.7984 20.8213 10.8898 20.7284 10.94C20.6172 11.0001 20.4783 11.0001 20.2004 11.0001H11.8004C11.5204 11.0001 11.3804 11.0001 11.2734 10.9456C11.1793 10.8976 11.1028 10.8211 11.0549 10.7271C11.0004 10.6201 11.0004 10.4801 11.0004 10.2001V1.80007C11.0004 1.5222 11.0004 1.38327 11.0605 1.27205C11.1107 1.17915 11.2021 1.09476 11.2987 1.05216C11.4144 1.00117 11.5428 1.01146 11.7996 1.03205C12.839 1.11539 13.8608 1.36095 14.8272 1.76127C16.0405 2.26382 17.1429 3.00042 18.0715 3.929C19.0001 4.85759 19.7367 5.95998 20.2392 7.17323Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -7,7 +7,6 @@
background-color: var(--modal-bg-color);
display: flex;
justify-content: center;
align-items: center;
z-index: 42;
overflow-y: auto;
}
@ -17,12 +16,7 @@
padding: 10px;
border-radius: 10px;
width: 500px;
min-height: 10vh;
}
@media (max-width: 720px) {
.modal-body {
width: 100vw;
margin: 0 10px;
}
border: 1px solid var(--font-tertiary-color);
margin-top: auto;
margin-bottom: auto;
}

View File

@ -38,6 +38,7 @@
display: flex;
align-items: center;
}
.note > .header > .info .saved svg {
margin-right: 8px;
}
@ -117,6 +118,7 @@
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.note > .footer .ctx-menu li:last-of-type {
padding-bottom: 12px;
border-bottom-left-radius: 16px;
@ -147,6 +149,36 @@
margin-left: 56px;
}
.note .poll-body {
padding: 5px;
user-select: none;
}
.note .poll-body > div {
border: 1px solid var(--font-secondary-color);
border-radius: 5px;
margin-bottom: 3px;
position: relative;
overflow: hidden;
}
.note .poll-body > div > div {
padding: 5px 10px;
z-index: 2;
}
.note .poll-body > div:hover {
cursor: pointer;
border: 1px solid var(--highlight);
}
.note .poll-body > div > .progress {
background-color: var(--gray);
height: stretch;
position: absolute;
z-index: 1;
}
.reaction-pill {
display: flex;
min-width: 1rem;

View File

@ -28,6 +28,7 @@ import useModeration from "Hooks/useModeration";
import { setPinned, setBookmarked } from "State/Login";
import type { RootState } from "State/Store";
import { UserCache } from "Cache/UserCache";
import Poll from "Element/Poll";
import messages from "./messages";
import { EventExt } from "System/EventExt";
@ -270,7 +271,8 @@ export default function Note(props: NoteProps) {
);
}
if (ev.kind !== EventKind.TextNote) {
const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
if (!canRenderAsTextNote.includes(ev.kind)) {
return (
<>
<h4>
@ -300,6 +302,12 @@ export default function Note(props: NoteProps) {
}
}
function pollOptions() {
if (ev.kind !== EventKind.Polls) return;
return <Poll ev={ev} zaps={zaps} />;
}
function content() {
if (!inView) return undefined;
return (
@ -332,6 +340,7 @@ export default function Note(props: NoteProps) {
<div className="body" onClick={e => goToEvent(e, ev, true)}>
{transformBody()}
{translation()}
{pollOptions()}
{options.showReactionsLink && (
<div className="reactions-link" onClick={() => setShowReactions(true)}>
<FormattedMessage {...messages.ReactionsLink} values={{ n: totalReactions }} />

View File

@ -17,7 +17,7 @@
resize: none;
background-color: var(--note-bg);
border-radius: 10px 10px 0 0;
min-height: 120px;
min-height: 100px;
max-width: stretch;
min-width: stretch;
max-height: 210px;
@ -41,6 +41,9 @@
}
}
.note-creator.poll textarea {
min-height: 120px;
}
.note-creator-actions {
width: 100%;
display: flex;
@ -50,19 +53,22 @@
margin-bottom: 5px;
}
.note-creator .attachment {
cursor: pointer;
position: absolute;
right: 16px;
bottom: 12px;
.note-creator .insert {
display: flex;
justify-content: flex-end;
width: stretch;
}
.note-creator .insert > button {
width: 48px;
height: 36px;
background: var(--gray-dark);
color: white;
border-radius: 100px;
border-radius: 17px;
margin-right: 5px;
display: flex;
align-items: center;
justify-content: center;
align-items: center;
}
.note-creator .attachment:hover {
@ -87,19 +93,11 @@
position: absolute;
left: 16px;
bottom: 12px;
font-color: var(--error);
color: var(--error);
margin-right: 12px;
font-size: 16px;
}
.note-creator .btn {
border-radius: 20px;
font-weight: bold;
background-color: var(--bg-color);
color: var(--font-color);
font-size: var(--font-size);
}
.note-create-button {
width: 48px;
height: 48px;
@ -114,31 +112,10 @@
justify-content: center;
}
@media (min-width: 520px) {
.note-create-button {
right: 10vw;
}
}
@media (min-width: 1020px) {
.note-create-button {
right: calc(50% - 360px);
}
}
.note-creator-modal .modal-body {
background: var(--modal-bg-color);
}
@media (max-width: 720px) {
.note-creator-modal {
align-items: flex-start;
}
.note-creator-modal .modal-body {
margin-top: 20vh;
}
}
.note-preview {
word-break: break-all;
}

View File

@ -1,7 +1,7 @@
import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl";
import { useDispatch, useSelector } from "react-redux";
import { TaggedRawEvent } from "@snort/nostr";
import { EventKind, TaggedRawEvent } from "@snort/nostr";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
@ -21,6 +21,7 @@ import {
setZapForward,
setSensitive,
reset,
setPollOptions,
} from "State/NoteCreator";
import type { RootState } from "State/Store";
import { LNURL } from "LNURL";
@ -56,6 +57,7 @@ export function NoteCreator() {
const showAdvanced = useSelector((s: RootState) => s.noteCreator.showAdvanced);
const zapForward = useSelector((s: RootState) => s.noteCreator.zapForward);
const sensitive = useSelector((s: RootState) => s.noteCreator.sensitive);
const pollOptions = useSelector((s: RootState) => s.noteCreator.pollOptions);
const dispatch = useDispatch();
async function sendNote() {
@ -81,8 +83,14 @@ export function NoteCreator() {
extraTags ??= [];
extraTags.push(["content-warning", sensitive]);
}
const ev = replyTo ? await publisher.reply(replyTo, note, extraTags) : await publisher.note(note, extraTags);
console.debug("Sending note: ", ev);
const kind = pollOptions ? EventKind.Polls : EventKind.TextNote;
if (pollOptions) {
extraTags ??= [];
extraTags.push(...pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
}
const ev = replyTo
? await publisher.reply(replyTo, note, extraTags, kind)
: await publisher.note(note, extraTags, kind);
publisher.broadcast(ev);
dispatch(reset());
}
@ -127,7 +135,7 @@ export function NoteCreator() {
async function loadPreview() {
if (preview) {
dispatch(setPreview(null));
dispatch(setPreview(undefined));
} else {
const tmpNote = await publisher.note(note);
if (tmpNote) {
@ -151,6 +159,52 @@ export function NoteCreator() {
}
}
function renderPollOptions() {
if (pollOptions) {
return (
<>
<h4>
<FormattedMessage defaultMessage="Poll Options" />
</h4>
{pollOptions?.map((a, i) => (
<div className="form-group w-max" key={`po-${i}`}>
<div>
<FormattedMessage defaultMessage="Option: {n}" values={{ n: i + 1 }} />
</div>
<div>
<input type="text" value={a} onChange={e => changePollOption(i, e.target.value)} />
{i > 1 && (
<button onClick={() => removePollOption(i)} className="ml5">
<Icon name="close" size={14} />
</button>
)}
</div>
</div>
))}
<button onClick={() => dispatch(setPollOptions([...pollOptions, ""]))}>
<Icon name="plus" size={14} />
</button>
</>
);
}
}
function changePollOption(i: number, v: string) {
if (pollOptions) {
const copy = [...pollOptions];
copy[i] = v;
dispatch(setPollOptions(copy));
}
}
function removePollOption(i: number) {
if (pollOptions) {
const copy = [...pollOptions];
copy.splice(i, 1);
dispatch(setPollOptions(copy));
}
}
return (
<>
{show && (
@ -158,8 +212,8 @@ export function NoteCreator() {
{replyTo && <NotePreview note={replyTo} />}
{preview && getPreviewNote()}
{!preview && (
<div className={`flex note-creator ${replyTo ? "note-reply" : ""}`}>
<div className="flex f-col mr10 f-grow">
<div className={`flex note-creator${replyTo ? " note-reply" : ""}${pollOptions ? " poll" : ""}`}>
<div className="flex f-col f-grow">
<Textarea
autoFocus
className={`textarea ${active ? "textarea--focused" : ""}`}
@ -172,9 +226,17 @@ export function NoteCreator() {
}
}}
/>
<button type="button" className="attachment" onClick={attachFile}>
<Icon name="attachment" />
</button>
{renderPollOptions()}
<div className="insert">
{pollOptions === undefined && !replyTo && (
<button type="button" onClick={() => dispatch(setPollOptions(["A", "B"]))}>
<Icon name="pie-chart" />
</button>
)}
<button type="button" onClick={attachFile}>
<Icon name="attachment" />
</button>
</div>
</div>
{error && <span className="error">{error}</span>}
</div>

View File

@ -0,0 +1,146 @@
import { TaggedRawEvent } from "@snort/nostr";
import { useState } from "react";
import { useSelector } from "react-redux";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { ParsedZap } from "Element/Zap";
import Text from "Element/Text";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
import { useWallet } from "Wallet";
import { useUserProfile } from "Hooks/useUserProfile";
import { LNURL } from "LNURL";
import { unwrap } from "Util";
import { formatShort } from "Number";
import Spinner from "Icons/Spinner";
import SendSats from "Element/SendSats";
interface PollProps {
ev: TaggedRawEvent;
zaps: Array<ParsedZap>;
}
export default function Poll(props: PollProps) {
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
const { wallet } = useWallet();
const prefs = useSelector((s: RootState) => s.login.preferences);
const myPubKey = useSelector((s: RootState) => s.login.publicKey);
const pollerProfile = useUserProfile(props.ev.pubkey);
const [error, setError] = useState("");
const [invoice, setInvoice] = useState("");
const [voting, setVoting] = useState<number>();
const didVote = props.zaps.some(a => a.sender === myPubKey);
const isMyPoll = props.ev.pubkey === myPubKey;
const showResults = didVote || isMyPoll;
const options = props.ev.tags.filter(a => a[0] === "poll_option").sort((a, b) => Number(a[1]) - Number(b[1]));
async function zapVote(ev: React.MouseEvent, opt: number) {
ev.stopPropagation();
if (voting) return;
const amount = prefs.defaultZapAmount;
try {
if (amount <= 0) {
throw new Error(
formatMessage(
{
defaultMessage: "Can't vote with {amount} sats, please set a different default zap amount",
},
{
amount,
}
)
);
}
setVoting(opt);
const zap = await publisher.zap(amount * 1000, props.ev.pubkey, props.ev.id, undefined, [
["poll_option", opt.toString()],
]);
if (!zap) {
throw new Error(
formatMessage({
defaultMessage: "Can't create vote, maybe you're not logged in?",
})
);
}
const lnurl = props.ev.tags.find(a => a[0] === "zap")?.[1] || pollerProfile?.lud16 || pollerProfile?.lud06;
if (!lnurl) return;
const svc = new LNURL(lnurl);
await svc.load();
if (!svc.canZap) {
throw new Error(
formatMessage({
defaultMessage: "Can't vote because LNURL service does not support zaps",
})
);
}
const invoice = await svc.getInvoice(amount, undefined, zap);
if (wallet?.isReady()) {
await wallet?.payInvoice(unwrap(invoice.pr));
} else {
setInvoice(unwrap(invoice.pr));
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError(
formatMessage({
defaultMessage: "Failed to send vote",
})
);
}
} finally {
setVoting(undefined);
}
}
const allTotal = props.zaps.filter(a => a.pollOption !== undefined).reduce((acc, v) => (acc += v.amount), 0);
return (
<>
<small>
<FormattedMessage
defaultMessage="Your are voting with {amount} sats"
values={{
amount: formatShort(prefs.defaultZapAmount),
}}
/>
</small>
<div className="poll-body">
{options.map(a => {
const opt = Number(a[1]);
const desc = a[2];
const zapsOnOption = props.zaps.filter(b => b.pollOption === opt);
const total = zapsOnOption.reduce((acc, v) => (acc += v.amount), 0);
const weight = allTotal === 0 ? 0 : total / allTotal;
return (
<div key={a[1]} className="flex" onClick={e => zapVote(e, opt)}>
<div className="f-grow">
{opt === voting ? <Spinner /> : <Text content={desc} tags={props.ev.tags} creator={props.ev.pubkey} />}
</div>
{showResults && (
<>
<div className="flex">
<FormattedNumber value={weight * 100} maximumFractionDigits={0} />% &nbsp;
<small>({formatShort(total)})</small>
</div>
<div style={{ width: `${weight * 100}%` }} className="progress"></div>
</>
)}
</div>
);
})}
{error && <b className="error">{error}</b>}
</div>
<SendSats show={invoice !== ""} onClose={() => setInvoice("")} invoice={invoice} />
</>
);
}

View File

@ -81,6 +81,7 @@ const Timeline = (props: TimelineProps) => {
case EventKind.SetMetadata: {
return <ProfilePreview actions={<></>} pubkey={e.pubkey} className="card" />;
}
case EventKind.Polls:
case EventKind.TextNote: {
const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
if (eRef) {

View File

@ -38,6 +38,7 @@ export function parseZap(zapReceipt: TaggedRawEvent, refNote?: TaggedRawEvent):
const isForwardedZap = refNote?.tags.some(a => a[0] === "zap") ?? false;
const anonZap = findTag(zapRequest, "anon");
const metaHash = sha256(innerZapJson);
const pollOpt = zapRequest.tags.find(a => a[0] === "poll_option")?.[1];
const ret: ParsedZap = {
id: zapReceipt.id,
zapService: zapReceipt.pubkey,
@ -49,6 +50,7 @@ export function parseZap(zapReceipt: TaggedRawEvent, refNote?: TaggedRawEvent):
anonZap: anonZap !== undefined,
content: zapRequest.content,
errors: [],
pollOption: pollOpt ? Number(pollOpt) : undefined,
};
if (invoice?.descriptionHash !== metaHash) {
ret.valid = false;
@ -96,6 +98,7 @@ export interface ParsedZap {
zapService: HexKey;
anonZap: boolean;
errors: Array<string>;
pollOption?: number;
}
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {

View File

@ -33,6 +33,10 @@ export default function useEventPublisher() {
const hasNip07 = "nostr" in window;
async function signEvent(ev: RawEvent): Promise<RawEvent> {
if (!pubKey) {
throw new Error("Cant sign events when logged out");
}
if (hasNip07 && !privKey) {
ev.id = await EventExt.createId(ev);
const tmpEv = (await barrierNip07(() => window.nostr.signEvent(ev))) as RawEvent;
@ -92,7 +96,7 @@ export default function useEventPublisher() {
},
broadcast: (ev: RawEvent | undefined) => {
if (ev) {
console.debug("Sending event: ", ev);
console.debug(ev);
System.BroadcastEvent(ev);
}
},
@ -176,9 +180,9 @@ export default function useEventPublisher() {
return await signEvent(ev);
}
},
note: async (msg: string, extraTags?: Array<Array<string>>) => {
note: async (msg: string, extraTags?: Array<Array<string>>, kind?: EventKind) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.TextNote);
const ev = EventExt.forPubKey(pubKey, kind ?? EventKind.TextNote);
processContent(ev, msg);
if (extraTags) {
for (const et of extraTags) {
@ -188,7 +192,16 @@ export default function useEventPublisher() {
return await signEvent(ev);
}
},
zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string) => {
/**
* Create a zap request event for a given target event/profile
* @param amount Millisats amout!
* @param author Author pubkey to tag in the zap
* @param note Note Id to tag in the zap
* @param msg Custom message to be included in the zap
* @param extraTags Any extra tags to include on the zap request event
* @returns
*/
zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string, extraTags?: Array<Array<string>>) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.ZapRequest);
if (note) {
@ -198,6 +211,7 @@ export default function useEventPublisher() {
const relayTag = ["relays", ...Object.keys(relays).map(a => a.trim())];
ev.tags.push(relayTag);
ev.tags.push(["amount", amount.toString()]);
ev.tags.push(...(extraTags ?? []));
processContent(ev, msg || "");
return await signEvent(ev);
}
@ -205,9 +219,9 @@ export default function useEventPublisher() {
/**
* Reply to a note
*/
reply: async (replyTo: TaggedRawEvent, msg: string, extraTags?: Array<Array<string>>) => {
reply: async (replyTo: TaggedRawEvent, msg: string, extraTags?: Array<Array<string>>, kind?: EventKind) => {
if (pubKey) {
const ev = EventExt.forPubKey(pubKey, EventKind.TextNote);
const ev = EventExt.forPubKey(pubKey, kind ?? EventKind.TextNote);
const thread = EventExt.extractThread(ev);
if (thread) {

View File

@ -35,7 +35,7 @@ export default function useThreadFeed(link: NostrLink) {
useEffect(() => {
if (store.data) {
const mainNotes = store.data?.filter(a => a.kind === EventKind.TextNote) ?? [];
const mainNotes = store.data?.filter(a => a.kind === EventKind.TextNote || a.kind === EventKind.Polls) ?? [];
const eTags = mainNotes.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat();
const eTagsMissing = eTags.filter(a => !mainNotes.some(b => b.id === a));

View File

@ -39,7 +39,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
}
const b = new RequestBuilder(`timeline:${subject.type}:${subject.discriminator}`);
const f = b.withFilter().kinds([EventKind.TextNote, EventKind.Repost]);
const f = b.withFilter().kinds([EventKind.TextNote, EventKind.Repost, EventKind.Polls]);
if (options.relay) {
b.withOptions({

View File

@ -6,11 +6,12 @@ interface NoteCreatorStore {
note: string;
error: string;
active: boolean;
preview: RawEvent | null;
replyTo: TaggedRawEvent | null;
preview?: RawEvent;
replyTo?: TaggedRawEvent;
showAdvanced: boolean;
zapForward: string;
sensitive: string;
pollOptions?: Array<string>;
}
const InitState: NoteCreatorStore = {
@ -18,8 +19,6 @@ const InitState: NoteCreatorStore = {
note: "",
error: "",
active: false,
preview: null,
replyTo: null,
showAdvanced: false,
zapForward: "",
sensitive: "",
@ -41,10 +40,10 @@ const NoteCreatorSlice = createSlice({
setActive: (state, action: PayloadAction<boolean>) => {
state.active = action.payload;
},
setPreview: (state, action: PayloadAction<RawEvent | null>) => {
setPreview: (state, action: PayloadAction<RawEvent | undefined>) => {
state.preview = action.payload;
},
setReplyTo: (state, action: PayloadAction<TaggedRawEvent | null>) => {
setReplyTo: (state, action: PayloadAction<TaggedRawEvent | undefined>) => {
state.replyTo = action.payload;
},
setShowAdvanced: (state, action: PayloadAction<boolean>) => {
@ -56,6 +55,9 @@ const NoteCreatorSlice = createSlice({
setSensitive: (state, action: PayloadAction<string>) => {
state.sensitive = action.payload;
},
setPollOptions: (state, action: PayloadAction<Array<string> | undefined>) => {
state.pollOptions = action.payload;
},
reset: () => InitState,
},
});
@ -70,6 +72,7 @@ export const {
setShowAdvanced,
setZapForward,
setSensitive,
setPollOptions,
reset,
} = NoteCreatorSlice.actions;

View File

@ -9,6 +9,7 @@ enum EventKind {
Repost = 6, // NIP-18
Reaction = 7, // NIP-25
BadgeAward = 8, // NIP-58
Polls = 6969, // NIP-69
Relays = 10002, // NIP-65
Ephemeral = 20_000,
Auth = 22242, // NIP-42