Polls (NIP-69) #489
@ -38,6 +38,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note > .header > .info .saved svg {
|
.note > .header > .info .saved svg {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
@ -117,6 +118,7 @@
|
|||||||
border-top-left-radius: 16px;
|
border-top-left-radius: 16px;
|
||||||
border-top-right-radius: 16px;
|
border-top-right-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note > .footer .ctx-menu li:last-of-type {
|
.note > .footer .ctx-menu li:last-of-type {
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
border-bottom-left-radius: 16px;
|
border-bottom-left-radius: 16px;
|
||||||
@ -147,6 +149,36 @@
|
|||||||
margin-left: 56px;
|
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 {
|
.reaction-pill {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 1rem;
|
min-width: 1rem;
|
||||||
|
@ -28,6 +28,7 @@ import useModeration from "Hooks/useModeration";
|
|||||||
import { setPinned, setBookmarked } from "State/Login";
|
import { setPinned, setBookmarked } from "State/Login";
|
||||||
import type { RootState } from "State/Store";
|
import type { RootState } from "State/Store";
|
||||||
import { UserCache } from "Cache/UserCache";
|
import { UserCache } from "Cache/UserCache";
|
||||||
|
import Poll from "Element/Poll";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
import { EventExt } from "System/EventExt";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4>
|
<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() {
|
function content() {
|
||||||
if (!inView) return undefined;
|
if (!inView) return undefined;
|
||||||
return (
|
return (
|
||||||
@ -332,6 +340,7 @@ export default function Note(props: NoteProps) {
|
|||||||
<div className="body" onClick={e => goToEvent(e, ev, true)}>
|
<div className="body" onClick={e => goToEvent(e, ev, true)}>
|
||||||
{transformBody()}
|
{transformBody()}
|
||||||
{translation()}
|
{translation()}
|
||||||
|
{pollOptions()}
|
||||||
{options.showReactionsLink && (
|
{options.showReactionsLink && (
|
||||||
<div className="reactions-link" onClick={() => setShowReactions(true)}>
|
<div className="reactions-link" onClick={() => setShowReactions(true)}>
|
||||||
<FormattedMessage {...messages.ReactionsLink} values={{ n: totalReactions }} />
|
<FormattedMessage {...messages.ReactionsLink} values={{ n: totalReactions }} />
|
||||||
|
103
packages/app/src/Element/Poll.tsx
Normal file
103
packages/app/src/Element/Poll.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { TaggedRawEvent } from "@snort/nostr";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { 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 pollerProfile = useUserProfile(props.ev.pubkey);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [invoice, setInvoice] = useState("");
|
||||||
|
const [voting, setVoting] = useState<number>();
|
||||||
|
|
||||||
|
const options = props.ev.tags.filter(a => a[0] === "poll_option").sort((a, b) => Number(a[1]) - Number(b[1]));
|
||||||
|
async function zapVote(opt: number) {
|
||||||
|
const amount = prefs.defaultZapAmount;
|
||||||
|
try {
|
||||||
|
setVoting(opt);
|
||||||
|
const zap = await publisher.zap(amount, props.ev.pubkey, props.ev.id, undefined, [
|
||||||
|
["poll_option", opt.toString()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
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("Cant 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);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<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 = total / allTotal;
|
||||||
|
const percent = `${Math.floor(weight * 100)}%`;
|
||||||
|
return (
|
||||||
|
<div key={a[1]} className="flex" onClick={() => zapVote(opt)}>
|
||||||
|
<div className="f-grow">
|
||||||
|
{opt === voting ? <Spinner /> : <Text content={desc} tags={props.ev.tags} creator={props.ev.pubkey} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
{percent}
|
||||||
|
|
||||||
|
<small>({formatShort(total)})</small>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: percent }} className="progress"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{error && <b className="error">{error}</b>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SendSats show={invoice !== ""} onClose={() => setInvoice("")} invoice={invoice} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -81,6 +81,7 @@ const Timeline = (props: TimelineProps) => {
|
|||||||
case EventKind.SetMetadata: {
|
case EventKind.SetMetadata: {
|
||||||
return <ProfilePreview actions={<></>} pubkey={e.pubkey} className="card" />;
|
return <ProfilePreview actions={<></>} pubkey={e.pubkey} className="card" />;
|
||||||
}
|
}
|
||||||
|
case EventKind.Polls:
|
||||||
case EventKind.TextNote: {
|
case EventKind.TextNote: {
|
||||||
const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
|
const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
|
||||||
if (eRef) {
|
if (eRef) {
|
||||||
|
@ -38,6 +38,7 @@ export function parseZap(zapReceipt: TaggedRawEvent, refNote?: TaggedRawEvent):
|
|||||||
const isForwardedZap = refNote?.tags.some(a => a[0] === "zap") ?? false;
|
const isForwardedZap = refNote?.tags.some(a => a[0] === "zap") ?? false;
|
||||||
const anonZap = findTag(zapRequest, "anon");
|
const anonZap = findTag(zapRequest, "anon");
|
||||||
const metaHash = sha256(innerZapJson);
|
const metaHash = sha256(innerZapJson);
|
||||||
|
const pollOpt = zapRequest.tags.find(a => a[0] === "poll_option")?.[1];
|
||||||
const ret: ParsedZap = {
|
const ret: ParsedZap = {
|
||||||
id: zapReceipt.id,
|
id: zapReceipt.id,
|
||||||
zapService: zapReceipt.pubkey,
|
zapService: zapReceipt.pubkey,
|
||||||
@ -49,6 +50,7 @@ export function parseZap(zapReceipt: TaggedRawEvent, refNote?: TaggedRawEvent):
|
|||||||
anonZap: anonZap !== undefined,
|
anonZap: anonZap !== undefined,
|
||||||
content: zapRequest.content,
|
content: zapRequest.content,
|
||||||
errors: [],
|
errors: [],
|
||||||
|
pollOption: pollOpt ? Number(pollOpt) : undefined,
|
||||||
};
|
};
|
||||||
if (invoice?.descriptionHash !== metaHash) {
|
if (invoice?.descriptionHash !== metaHash) {
|
||||||
ret.valid = false;
|
ret.valid = false;
|
||||||
@ -96,6 +98,7 @@ export interface ParsedZap {
|
|||||||
zapService: HexKey;
|
zapService: HexKey;
|
||||||
anonZap: boolean;
|
anonZap: boolean;
|
||||||
errors: Array<string>;
|
errors: Array<string>;
|
||||||
|
pollOption?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {
|
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {
|
||||||
|
@ -188,7 +188,7 @@ export default function useEventPublisher() {
|
|||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string) => {
|
zap: async (amount: number, author: HexKey, note?: HexKey, msg?: string, extraTags?: Array<Array<string>>) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
const ev = EventExt.forPubKey(pubKey, EventKind.ZapRequest);
|
const ev = EventExt.forPubKey(pubKey, EventKind.ZapRequest);
|
||||||
if (note) {
|
if (note) {
|
||||||
@ -198,6 +198,7 @@ export default function useEventPublisher() {
|
|||||||
const relayTag = ["relays", ...Object.keys(relays).map(a => a.trim())];
|
const relayTag = ["relays", ...Object.keys(relays).map(a => a.trim())];
|
||||||
ev.tags.push(relayTag);
|
ev.tags.push(relayTag);
|
||||||
ev.tags.push(["amount", amount.toString()]);
|
ev.tags.push(["amount", amount.toString()]);
|
||||||
|
ev.tags.push(...(extraTags ?? []));
|
||||||
processContent(ev, msg || "");
|
processContent(ev, msg || "");
|
||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ export default function useThreadFeed(link: NostrLink) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (store.data) {
|
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 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));
|
const eTagsMissing = eTags.filter(a => !mainNotes.some(b => b.id === a));
|
||||||
|
@ -39,7 +39,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
|||||||
}
|
}
|
||||||
|
|
||||||
const b = new RequestBuilder(`timeline:${subject.type}:${subject.discriminator}`);
|
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) {
|
if (options.relay) {
|
||||||
b.withOptions({
|
b.withOptions({
|
||||||
|
@ -9,6 +9,7 @@ enum EventKind {
|
|||||||
Repost = 6, // NIP-18
|
Repost = 6, // NIP-18
|
||||||
Reaction = 7, // NIP-25
|
Reaction = 7, // NIP-25
|
||||||
BadgeAward = 8, // NIP-58
|
BadgeAward = 8, // NIP-58
|
||||||
|
Polls = 6969, // NIP-69
|
||||||
Relays = 10002, // NIP-65
|
Relays = 10002, // NIP-65
|
||||||
Ephemeral = 20_000,
|
Ephemeral = 20_000,
|
||||||
Auth = 22242, // NIP-42
|
Auth = 22242, // NIP-42
|
||||||
|
Loading…
Reference in New Issue
Block a user