feat: render polls

This commit is contained in:
Kieran 2023-04-05 16:10:14 +01:00
parent 92dac6a699
commit bbc7e443df
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
9 changed files with 154 additions and 4 deletions

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

@ -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}
&nbsp;
<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} />
</>
);
}

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

@ -188,7 +188,7 @@ export default function useEventPublisher() {
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) {
const ev = EventExt.forPubKey(pubKey, EventKind.ZapRequest);
if (note) {
@ -198,6 +198,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);
}

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

@ -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