goToEvent(e, ev, true)}>
{transformBody()}
{translation()}
+ {pollOptions()}
{options.showReactionsLink && (
setShowReactions(true)}>
diff --git a/packages/app/src/Element/NoteCreator.css b/packages/app/src/Element/NoteCreator.css
index d2909f27..b19f6d48 100644
--- a/packages/app/src/Element/NoteCreator.css
+++ b/packages/app/src/Element/NoteCreator.css
@@ -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;
}
diff --git a/packages/app/src/Element/NoteCreator.tsx b/packages/app/src/Element/NoteCreator.tsx
index 69c22104..a338c5a2 100644
--- a/packages/app/src/Element/NoteCreator.tsx
+++ b/packages/app/src/Element/NoteCreator.tsx
@@ -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 (
+ <>
+
+
+
+ {pollOptions?.map((a, i) => (
+
+
+
+
+
+ changePollOption(i, e.target.value)} />
+ {i > 1 && (
+
+ )}
+
+
+ ))}
+
+ >
+ );
+ }
+ }
+
+ 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 &&
}
{preview && getPreviewNote()}
{!preview && (
-
-
+
+
-
+ {renderPollOptions()}
+
+ {pollOptions === undefined && !replyTo && (
+
+ )}
+
+
{error &&
{error}}
diff --git a/packages/app/src/Element/Poll.tsx b/packages/app/src/Element/Poll.tsx
new file mode 100644
index 00000000..6744d1c6
--- /dev/null
+++ b/packages/app/src/Element/Poll.tsx
@@ -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
;
+}
+
+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();
+ 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 (
+ <>
+
+
+
+
+ {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 (
+
zapVote(e, opt)}>
+
+ {opt === voting ? : }
+
+ {showResults && (
+ <>
+
+ %
+ ({formatShort(total)})
+
+
+ >
+ )}
+
+ );
+ })}
+ {error &&
{error}}
+
+
+ setInvoice("")} invoice={invoice} />
+ >
+ );
+}
diff --git a/packages/app/src/Element/Timeline.tsx b/packages/app/src/Element/Timeline.tsx
index a74c0c5d..54893e0a 100644
--- a/packages/app/src/Element/Timeline.tsx
+++ b/packages/app/src/Element/Timeline.tsx
@@ -81,6 +81,7 @@ const Timeline = (props: TimelineProps) => {
case EventKind.SetMetadata: {
return >} pubkey={e.pubkey} className="card" />;
}
+ case EventKind.Polls:
case EventKind.TextNote: {
const eRef = e.tags.find(tagFilterOfTextRepost(e))?.at(1);
if (eRef) {
diff --git a/packages/app/src/Element/Zap.tsx b/packages/app/src/Element/Zap.tsx
index 2f5ac6cc..89a9a344 100644
--- a/packages/app/src/Element/Zap.tsx
+++ b/packages/app/src/Element/Zap.tsx
@@ -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;
+ pollOption?: number;
}
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {
diff --git a/packages/app/src/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts
index f83a9d1e..c97aa6a6 100644
--- a/packages/app/src/Feed/EventPublisher.ts
+++ b/packages/app/src/Feed/EventPublisher.ts
@@ -33,6 +33,10 @@ export default function useEventPublisher() {
const hasNip07 = "nostr" in window;
async function signEvent(ev: RawEvent): Promise {
+ 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>) => {
+ note: async (msg: string, extraTags?: Array>, 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>) => {
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>) => {
+ reply: async (replyTo: TaggedRawEvent, msg: string, extraTags?: Array>, 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) {
diff --git a/packages/app/src/Feed/ThreadFeed.ts b/packages/app/src/Feed/ThreadFeed.ts
index 66b47488..66596257 100644
--- a/packages/app/src/Feed/ThreadFeed.ts
+++ b/packages/app/src/Feed/ThreadFeed.ts
@@ -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));
diff --git a/packages/app/src/Feed/TimelineFeed.ts b/packages/app/src/Feed/TimelineFeed.ts
index a8b198f2..dc80ddeb 100644
--- a/packages/app/src/Feed/TimelineFeed.ts
+++ b/packages/app/src/Feed/TimelineFeed.ts
@@ -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({
diff --git a/packages/app/src/State/NoteCreator.ts b/packages/app/src/State/NoteCreator.ts
index 27b3c816..c547f8c9 100644
--- a/packages/app/src/State/NoteCreator.ts
+++ b/packages/app/src/State/NoteCreator.ts
@@ -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;
}
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) => {
state.active = action.payload;
},
- setPreview: (state, action: PayloadAction) => {
+ setPreview: (state, action: PayloadAction) => {
state.preview = action.payload;
},
- setReplyTo: (state, action: PayloadAction) => {
+ setReplyTo: (state, action: PayloadAction) => {
state.replyTo = action.payload;
},
setShowAdvanced: (state, action: PayloadAction) => {
@@ -56,6 +55,9 @@ const NoteCreatorSlice = createSlice({
setSensitive: (state, action: PayloadAction) => {
state.sensitive = action.payload;
},
+ setPollOptions: (state, action: PayloadAction | undefined>) => {
+ state.pollOptions = action.payload;
+ },
reset: () => InitState,
},
});
@@ -70,6 +72,7 @@ export const {
setShowAdvanced,
setZapForward,
setSensitive,
+ setPollOptions,
reset,
} = NoteCreatorSlice.actions;
diff --git a/packages/nostr/src/legacy/EventKind.ts b/packages/nostr/src/legacy/EventKind.ts
index 236b1c36..516ffb4f 100644
--- a/packages/nostr/src/legacy/EventKind.ts
+++ b/packages/nostr/src/legacy/EventKind.ts
@@ -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