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:
}
    - -
  • setOpen(!isOpen()) && setShowInfo(false)}> - {replySvg()} - {isOpen() ? 'Cancel' : 'Reply'} + { +
  • toggleVote(1, event())}> + {currentUserVote() === 1 ? upvoteSelectedSvg() : upvoteSvg()}
  • -
    - {/* +
  • + {votesCount() === 0 ? 'Vote' : votesCount()} +
  • +
  • toggleVote(-1, event())}> + {currentUserVote() === -1 ? downvoteSelectedSvg() : downvoteSvg()} +
  • +
    } + {/*
  • {lightningSvg()} 10
  • -
    - + */} + {/*
  • {likeSvg()} 27
  • */} + +
  • setOpen(!isOpen()) && setShowInfo(false)}> + {replySvg()} + {isOpen() ? 'Cancel' : 'Reply'} +
  • +
{isOpen() && setOpen(false)} />} {!isThreadCollapsed() &&
- event().children} articles={props.articles} /> + event().children} articles={props.articles} votes={props.votes} />
} ; } @@ -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 +};