diff --git a/README.md b/README.md
index 1ca9697..0526d82 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ Arguments:
- Useful for notifying the author of a website (http anchor)
- `disable`: comma-separated string of features to disable, all enabled by default
- `likes`
+ - `votes`
- `zaps`
- `reply` (when disabled the component becomes read-only)
- `publish` (when disabled does not send event to relays, useful for testing)
@@ -106,6 +107,9 @@ document.querySelector('zap-threads').shadowRoot.appendChild(style);
Any questions or ideas, please open an issue!
+## Icons
+- [Font Awesome](https://fontawesome.com/license/free)
+
## LICENSE
This is free and unencumbered software released into the public domain.
@@ -131,4 +135,4 @@ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
-For more information, please refer to
\ No newline at end of file
+For more information, please refer to
diff --git a/src/index.tsx b/src/index.tsx
index d59cce7..9eb23db 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -11,7 +11,7 @@ import { clear as clearCache, find, findAll, save, watchAll } from "./util/db.ts
import { decode } from "nostr-tools/nip19";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import { Filter } from "nostr-tools/filter";
-import { AggregateEvent, NoteEvent, eventToNoteEvent } from "./util/models.ts";
+import { AggregateEvent, NoteEvent, eventToNoteEvent, eventToReactionEvent, voteKind } from "./util/models.ts";
import { SubCloser } from "nostr-tools";
const ZapThreads = (props: { [key: string]: string; }) => {
@@ -225,6 +225,12 @@ const ZapThreads = (props: { [key: string]: string; }) => {
}
} else if (e.kind === 7) {
newLikeIds.add(e.id);
+ if (e.content.trim()) {
+ const reactionEvent = eventToReactionEvent(e);
+ if (voteKind(reactionEvent) !== 0) { // remove this condition if you want to track all reactions
+ save('reactions', reactionEvent);
+ }
+ }
} else if (e.kind === 9735) {
const invoiceTag = e.tags.find(t => t[0] === "bolt11");
invoiceTag && invoiceTag[1] && (newZaps[e.id] = invoiceTag[1]);
@@ -369,6 +375,9 @@ const ZapThreads = (props: { [key: string]: string; }) => {
return nestedEvents().reduce((acc, n) => acc + totalChildren(n), nestedEvents().length);
};
+ const reactions = watchAll(() => ['reactions']);
+ const votes = () => reactions().filter(r => voteKind(r) !== 0);
+
const [showAdvanced, setShowAdvanced] = createSignal(false);
return <>
@@ -388,7 +397,7 @@ const ZapThreads = (props: { [key: string]: string; }) => {
{commentsLength() > 0 && `${commentsLength()} comment${commentsLength() == 1 ? '' : 's'}`}
-
+
>}
setShowAdvanced(!showAdvanced())}>{ellipsisSvg()}
diff --git a/src/reply.tsx b/src/reply.tsx
index 48deeff..eaba0e5 100644
--- a/src/reply.tsx
+++ b/src/reply.tsx
@@ -1,4 +1,4 @@
-import { defaultPicture, generateTags, satsAbbrev, shortenEncodedId, updateProfiles } from "./util/ui.ts";
+import { currentTime, defaultPicture, generateTags, satsAbbrev, shortenEncodedId, updateProfiles } from "./util/ui.ts";
import { Show, createEffect, createSignal } from "solid-js";
import { UnsignedEvent, Event } from "nostr-tools/core";
import { EventSigner, pool, signersStore, store } from "./util/stores.ts";
@@ -100,7 +100,7 @@ export const ReplyEditor = (props: { replyTo?: string; onDone?: Function; }) =>
const unsignedEvent: UnsignedEvent = {
kind: 1,
- created_at: Math.round(Date.now() / 1000),
+ created_at: currentTime(),
content: content,
pubkey: signer.pk,
tags: generateTags(content),
@@ -140,7 +140,7 @@ export const ReplyEditor = (props: { replyTo?: string; onDone?: Function; }) =>
const url = normalizeURL(anchor().value);
const unsignedRootEvent: UnsignedEvent = {
pubkey: signer.pk,
- created_at: Math.round(Date.now() / 1000),
+ created_at: currentTime(),
kind: 8812,
tags: [['r', url]],
content: `Comments on ${url} ↴`
@@ -277,4 +277,4 @@ export const RootComment = () => {
;
-};
\ No newline at end of file
+};
diff --git a/src/styles/index.css b/src/styles/index.css
index c71be7c..132ee8f 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -187,16 +187,21 @@ ul.ztr-comment-actions,
align-items: center;
list-style: none;
margin: 0;
- padding: 0 0 0 3.5em;
+ padding: 0 0 0 2.625em;
font-weight: 600;
user-select: none;
}
+ul.ztr-comment-actions {
+ margin-top: -1em;
+}
+
ul.ztr-comment-actions li {
display: inline-flex;
align-items: center;
text-align: center;
- padding: 0 2em 0 0;
+ padding: 1em;
+ margin: -0.125em;
cursor: pointer;
}
@@ -211,6 +216,31 @@ svg {
height: 1.1em;
}
+li.ztr-comment-action-upvote {
+ margin-left: 0.5em;
+ margin-right: 0.5em;
+}
+
+li.ztr-comment-action-downvote {
+ margin-left: 0.5em;
+ margin-right: 0.5em;
+}
+
+li.ztr-comment-action-reply {
+ margin-left: 0.7em;
+ margin-right: 0.7em;
+}
+
+li.ztr-comment-action-zap {
+ margin-left: 0.7em;
+ margin-right: 0.7em;
+}
+
+li.ztr-comment-action-like {
+ margin-left: 0.7em;
+ margin-right: 0.7em;
+}
+
.ztr-comment-action-reply:hover svg {
fill: #92379c;
}
@@ -235,6 +265,43 @@ svg {
color: #e35428;
}
+.ztr-comment-action-upvote:hover svg {
+ fill: #0288d1;
+}
+
+.ztr-comment-action-upvote:hover span {
+ color: #0288d1;
+}
+
+.ztr-comment-action-upvote.selected {
+ svg {
+ fill: #0288d1;
+ }
+}
+
+.ztr-comment-action-downvote:hover svg {
+ fill: #0288d1;
+}
+
+.ztr-comment-action-downvote:hover span {
+ color: #0288d1;
+}
+
+.ztr-comment-action-downvote.selected {
+ svg {
+ fill: #0288d1;
+ }
+}
+
+li.ztr-comment-action-votes {
+ justify-content: center;
+ span {
+ margin-left: -0.7em;
+ margin-right: -0.7em;
+ font-size: 0.85em;
+ }
+}
+
.ztr-reply-form {
padding: 0 0.5em 0 3.5em;
}
@@ -401,4 +468,4 @@ svg {
.ztr-comment-action-reply:hover span {
color: #96609c;
}
-}
\ No newline at end of file
+}
diff --git a/src/thread.tsx b/src/thread.tsx
index f411b74..ac77b50 100644
--- a/src/thread.tsx
+++ b/src/thread.tsx
@@ -1,15 +1,20 @@
-import { Index, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
-import { defaultPicture, parseContent, shortenEncodedId, sortByDate, svgWidth, timeAgo, totalChildren } from "./util/ui.ts";
+import { Index, Show, createEffect, createMemo, createSignal, onCleanup, batch } from "solid-js";
+import { currentTime, defaultPicture, parseContent, shortenEncodedId, sortByDate, svgWidth, timeAgo, totalChildren } from "./util/ui.ts";
import { ReplyEditor } from "./reply.tsx";
import { NestedNoteEvent } from "./util/nest.ts";
import { noteEncode, npubEncode } from "nostr-tools/nip19";
+import { UnsignedEvent, Event } from "nostr-tools/core";
+import { getEventHash, finalizeEvent } from "nostr-tools/pure";
+import { Relay } from "nostr-tools/relay";
import { createElementSize } from "@solid-primitives/resize-observer";
-import { store } from "./util/stores.ts";
-import { NoteEvent } from "./util/models.ts";
+import { EventSigner, signersStore, store } from "./util/stores.ts";
+import { NoteEvent, Profile, Pk, ReactionEvent, VoteKind, Eid, voteKind } from "./util/models.ts";
+import { remove } from "./util/db.ts";
-export const Thread = (props: { nestedEvents: () => NestedNoteEvent[]; articles: () => NoteEvent[]; }) => {
+export const Thread = (props: { nestedEvents: () => NestedNoteEvent[]; articles: () => NoteEvent[]; votes: () => ReactionEvent[]; }) => {
const anchor = () => store.anchor!;
const profiles = store.profiles!;
+ const relays = () => store.relays!;
return
@@ -20,6 +25,118 @@ export const Thread = (props: { nestedEvents: () => NestedNoteEvent[]; articles:
const [isThreadCollapsed, setThreadCollapsed] = createSignal(false);
const [showInfo, setShowInfo] = createSignal(false);
+ const [votesCount, setVotesCount] = createSignal(0);
+ const [currentUserVote, setCurrentUserVote] = createSignal(0);
+ const currentNoteVotes = () => props.votes().filter(r => r.noteId === event().id);
+ const currentNoteVotesDeduplicatedByPks = () => {
+ const grouped = new Map();
+ currentNoteVotes().forEach((r: ReactionEvent) => {
+ if (!grouped.has(r.pk)) {
+ grouped.set(r.pk, []);
+ }
+ grouped.get(r.pk)!.push(r);
+ });
+ return [...grouped.values()]
+ .map(reactionEvents => sortByDate(reactionEvents)[0]);
+ };
+
+ const getSigner = () => {
+ if (!signersStore.active) {
+ return;
+ }
+ const signer: EventSigner = signersStore.active!;
+ if (!signer?.signEvent) {
+ console.error('Error: User has no signer!');
+ return;
+ }
+ return signer;
+ }
+
+ createEffect(() => {
+ batch(() => {
+ const votes = currentNoteVotesDeduplicatedByPks();
+ const newVoteCount = votes
+ .map(r => voteKind(r) as number)
+ .reduce((sum, i) => sum + i, 0);
+ setVotesCount(newVoteCount);
+
+ const signer = getSigner();
+ const kind: VoteKind = (signer && votes.filter(r => r.pk === signer!.pk).map(r => voteKind(r))[0]) || 0;
+ setCurrentUserVote(kind);
+ });
+ });
+
+ const toggleVote = async (reaction: VoteKind, note: NoteEvent) => {
+ const s = getSigner();
+ if (!s) {
+ return;
+ }
+ const signer = s!;
+ const latestVote = currentUserVote();
+ const newVote = latestVote === reaction ? 0 : reaction;
+
+ const rootEventId = note.ro || store.version || store.rootEventIds[0];
+
+ const publishVote = async () => {
+ const tags = [];
+ if (rootEventId) {
+ tags.push(['e', rootEventId, '', 'root']);
+ }
+
+ await signAndPublishEvent({
+ kind: 7,
+ created_at: currentTime(),
+ content: newVote === -1 ? '-' : '+',
+ pubkey: signer.pk,
+ tags: [
+ ...tags,
+ ['e', note.id, '', 'reply'],
+ ['p', signer.pk],
+ ],
+ });
+ };
+
+ const unpublishOutdatedEvents = async () => {
+ const eids: Eid[] = sortByDate(currentNoteVotes().filter(r => r.pk === signer!.pk))
+ .reverse()
+ .map(i => i.eid);
+ if (eids.length === 0) {
+ return;
+ }
+ const sentRequest = await signAndPublishEvent({
+ kind: 5,
+ created_at: currentTime(),
+ content: '',
+ pubkey: signer.pk,
+ tags: eids.map(eid => ['e', eid]),
+ });
+ if (sentRequest) {
+ remove('reactions', eids);
+ }
+ };
+
+ const signAndPublishEvent = async (unsignedEvent: UnsignedEvent) => {
+ const id = getEventHash(unsignedEvent);
+ const signature = await signer.signEvent!(unsignedEvent);
+ const event: Event = { id, ...unsignedEvent, ...signature };
+ console.log(JSON.stringify(event, null, 2));
+
+ const results = await Promise.allSettled(relays().map(async (relayUrl) => {
+ const relay = await Relay.connect(relayUrl);
+ await relay.publish(event);
+ }));
+ const ok = results.filter(i => i.status === 'fulfilled').length;
+ const failures = results.length - ok;
+ console.log(`signAndPublishEvent ok=${ok} failed=${failures}`);
+ return ok > 0;
+ }
+
+ await unpublishOutdatedEvents();
+ if ([-1, 1].includes(newVote)) {
+ await publishVote();
+ }
+ };
+
const MAX_HEIGHT = 500;
const [target, setTarget] = createSignal();
const size = createElementSize(target);
@@ -133,30 +250,41 @@ export const Thread = (props: { nestedEvents: () => NestedNoteEvent[]; articles:
}
{isOpen() &&
setOpen(false)} />}
{!isThreadCollapsed() && }
;
}
@@ -174,6 +302,10 @@ const separatorSvg = () => ;
export const lightningSvg = () => ;
export const likeSvg = () => ;
+const upvoteSvg = () => ;
+const downvoteSvg = () => ;
+const upvoteSelectedSvg = () => ;
+const downvoteSelectedSvg = () => ;
const expandSvg = () => ;
export const ellipsisSvg = () => ;
diff --git a/src/util/db.ts b/src/util/db.ts
index b45dc34..746876a 100644
--- a/src/util/db.ts
+++ b/src/util/db.ts
@@ -133,6 +133,15 @@ const _saveToMemoryDatabase = , Value extends S
}
};
+const _removeFromMemoryDatabase = , IndexName extends IndexNames, Value extends StoreValue>(type: Name, query: IndexKey | IndexKey[] | IndexKey[][]) => {
+ const map = memDb[type];
+ if (map) {
+ // @ts-ignore
+ const idx = (query.lower ? query.lower : query).toString();
+ delete map[idx];
+ }
+}
+
export const save = async , Value extends StoreValue>(type: Name, model: Value, options: { immediate: boolean; } = { immediate: false }) => {
const _db = await db();
@@ -161,6 +170,25 @@ export const save = async , Value extends Store
batchFns[type](model);
};
+export const remove = async , IndexName extends IndexNames, Value extends StoreValue>(type: Name, query: IndexKey[] | IndexKey[][], options: { immediate: boolean; } = { immediate: true }) => {
+ const _db = await db();
+ let ok = true;
+ if (options.immediate) {
+ if (!_db) {
+ _removeFromMemoryDatabase(type, query);
+ } else {
+ const tx = _db.transaction(type, 'readwrite');
+ ok = !!(await Promise.all([...query.map(q => tx.store.delete(q as IDBKeyRange)), tx.done]));
+ }
+ } else {
+ throw new Error('unimplemented');
+ }
+
+ if (ok) {
+ sigStore[type] = +new Date;
+ }
+};
+
export const clear = async () => {
const _db = await db();
if (!_db) {
@@ -189,4 +217,4 @@ function createDeepSignal(value: T): Signal {
return store.value;
},
] as Signal;
-}
\ No newline at end of file
+}
diff --git a/src/util/models.ts b/src/util/models.ts
index 74300fa..fe69a5b 100644
--- a/src/util/models.ts
+++ b/src/util/models.ts
@@ -23,6 +23,28 @@ export type NoteEvent = {
tl?: string; // title
};
+export type NoteId = string;
+export type Pk = string;
+export type Eid = string;
+export type VoteKind = -1 | 0 | 1;
+export type ReactionEvent = {
+ eid: Eid;
+ noteId: NoteId;
+ content: string;
+ pk: Pk;
+ ts: number;
+};
+
+export const voteKind = (r: ReactionEvent): VoteKind => {
+ if (r.content === '-') {
+ return -1;
+ } else if (r.content === '+') {
+ return 1;
+ } else {
+ return 0;
+ }
+}
+
export type AggregateEvent = {
eid: string;
k: 7 | 9735;
@@ -58,6 +80,13 @@ export interface ZapthreadsSchema extends DBSchema {
'k': number;
};
};
+ reactions: {
+ key: string;
+ value: ReactionEvent;
+ indexes: {
+ 'by-eid': Eid;
+ };
+ };
aggregates: {
key: string[];
value: AggregateEvent;
@@ -80,6 +109,7 @@ export interface ZapthreadsSchema extends DBSchema {
export const indices: { [key in StoreNames]: any } = {
'events': 'id',
+ 'reactions': 'eid',
'aggregates': ['eid', 'k'],
'profiles': ['pk'],
'relays': ['n', 'a']
@@ -98,6 +128,9 @@ export const upgrade = async (db: IDBPDatabase, currentVersion
events.createIndex('d', 'd');
events.createIndex('k', 'k');
+ const reactions = db.createObjectStore('reactions', { keyPath: indices['reactions'] });
+ reactions.createIndex('by-eid', 'eid');
+
db.createObjectStore('aggregates', { keyPath: indices['aggregates'] });
const profiles = db.createObjectStore('profiles', { keyPath: indices['profiles'] });
@@ -141,4 +174,25 @@ export const eventToNoteEvent = (e: UnsignedEvent & { id?: string; }): NoteEvent
d,
tl,
};
-};
\ No newline at end of file
+};
+
+export const eventToReactionEvent = (e: UnsignedEvent & { id?: string; }): ReactionEvent => {
+ const nip10result = parse(e);
+
+ // extracting note id we reply to, otherwise root note id
+ const eTags = e.tags.filter(t => t.length > 1 && t[0] === 'e');
+ const tags = eTags.filter(t => t.length > 2);
+ const noteId = tags
+ .filter(t => t[3] === 'reply')
+ .concat(tags.filter(t => t[3] === 'root'))
+ .map(t => t[1])
+ .concat([eTags[0][1]])[0];
+
+ return {
+ eid: e.id ?? '',
+ noteId,
+ pk: e.pubkey,
+ content: e.content,
+ ts: e.created_at,
+ };
+}
diff --git a/src/util/stores.ts b/src/util/stores.ts
index 4751676..89b9d18 100644
--- a/src/util/stores.ts
+++ b/src/util/stores.ts
@@ -28,7 +28,7 @@ export type EventSigner = {
export type UrlPrefixesKeys = 'naddr' | 'nevent' | 'note' | 'npub' | 'nprofile' | 'tag';
-const _types = ['reply', 'likes', 'zaps', 'publish', 'watch', 'replyAnonymously', 'hideContent'] as const;
+const _types = ['reply', 'likes', 'votes', 'zaps', 'publish', 'watch', 'replyAnonymously', 'hideContent'] as const;
type DisableType = typeof _types[number];
export const isDisableType = (type: string): type is DisableType => {
return _types.includes(type as DisableType);
@@ -60,4 +60,4 @@ declare global {
signEvent: SignEvent;
};
}
-}
\ No newline at end of file
+}
diff --git a/src/util/ui.ts b/src/util/ui.ts
index 629a387..2c13c27 100644
--- a/src/util/ui.ts
+++ b/src/util/ui.ts
@@ -280,6 +280,8 @@ export const satsAbbrev = (sats: number): string => {
}
};
+export const currentTime = () => Math.round(Date.now() / 1000);
+
export const totalChildren = (event: NestedNoteEvent): number => {
return event.children.reduce((acc, c) => {
return acc + totalChildren(c);
@@ -295,4 +297,4 @@ export const normalizeURL = (url: string, removeSlashes: boolean = true): string
u.pathname = u.pathname.replace(removeSlashesRegex, '');
}
return u.toString();
-};
\ No newline at end of file
+};