feat: render polls
This commit is contained in:
parent
92dac6a699
commit
bbc7e443df
@ -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;
|
||||
|
@ -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 }} />
|
||||
|
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: {
|
||||
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) {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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));
|
||||
|
@ -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({
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user