Skip to content

Commit dcf8f55

Browse files
authored
Unify note actions across thread view and add poll support (#269)
* feat: unify note actions across thread view and add poll support Create shared NoteActionBar component that composes existing action primitives (NoteTotalLikes, NoteRepost, NoteTotalZaps, NoteTotalComments, ZapModal) into one reusable bar with variant support. Thread view changes: - Main note, parent notes, replies, and nested replies all use NoteActionBar — adds missing Repost and fixes Zap wiring - Replace ThreadCommentActions with NoteActionBar throughout - Move main note action bar outside flex justify-between wrapper so dropdowns render correctly - Remove page-level ZapModal and dead zapModal state Poll support in thread view: - Add PollDisplay rendering for kind 1068 events across all note surfaces (main, parent, reply, nested reply) - Change text-only poll layout from vertical stack to 2-column grid for a compact, scannable quad layout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Copilot review feedback on unified note actions - Move PollDisplay out of <a> tag for parent notes so poll buttons don't trigger navigation instead of voting - Add kind 1068 to fetchParentThread kinds filter so poll parent notes are actually fetched from relays - Declare reactive variables (isCompact, isFull, iconWrapClass, zapWrapClass) with explicit let before reactive assignment - Handle zap-complete event from ZapModal with optimisticZapUpdate + fetchEngagement so zap totals update after modal zap - Remove unused fetchEngagement import from [nip19] page
1 parent 37db8d3 commit dcf8f55

File tree

4 files changed

+233
-66
lines changed

4 files changed

+233
-66
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zap.cooking",
33
"license": "MIT",
4-
"version": "4.2.115",
4+
"version": "4.2.117",
55
"private": true,
66
"scripts": {
77
"dev": "vite dev",
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<script lang="ts">
2+
import type { NDKEvent } from '@nostr-dev-kit/ndk';
3+
import NoteTotalLikes from './NoteTotalLikes.svelte';
4+
import NoteTotalComments from './NoteTotalComments.svelte';
5+
import NoteRepost from './NoteRepost.svelte';
6+
import NoteTotalZaps from './NoteTotalZaps.svelte';
7+
import ZapModal from './ZapModal.svelte';
8+
import { ndk, userPublickey } from '$lib/nostr';
9+
import { fetchEngagement, optimisticZapUpdate } from '$lib/engagementCache';
10+
11+
export let event: NDKEvent;
12+
13+
/**
14+
* 'full' — feed-style: all actions, hover wrappers, zap pills support
15+
* 'compact' — thread parent/nested reply: smaller, tighter spacing
16+
* 'default' — standard detail view
17+
*/
18+
export let variant: 'full' | 'compact' | 'default' = 'default';
19+
20+
/** Show the zap pills row above the action icons (feed-style) */
21+
export let showZapPills: boolean = false;
22+
23+
/** Show the comment count/toggle button */
24+
export let showComments: boolean = true;
25+
26+
/** Show the repost/quote button */
27+
export let showRepost: boolean = true;
28+
29+
let zapModalOpen = false;
30+
31+
function openZapModal() {
32+
if (!$userPublickey) {
33+
window.location.href = '/login';
34+
return;
35+
}
36+
zapModalOpen = true;
37+
}
38+
39+
function handleZapComplete(e: CustomEvent<{ amount: number }>) {
40+
if (event) {
41+
optimisticZapUpdate(event.id, (e.detail.amount || 0) * 1000, $userPublickey);
42+
fetchEngagement($ndk, event.id, $userPublickey);
43+
}
44+
}
45+
46+
let isCompact: boolean;
47+
let isFull: boolean;
48+
let iconWrapClass: string;
49+
let zapWrapClass: string;
50+
51+
$: isCompact = variant === 'compact';
52+
$: isFull = variant === 'full';
53+
$: iconWrapClass = isFull
54+
? 'hover:bg-accent-gray rounded-full p-1.5 transition-colors'
55+
: isCompact
56+
? 'rounded p-0.5 transition-colors'
57+
: 'hover:bg-accent-gray rounded px-1 py-0.5 transition-colors';
58+
$: zapWrapClass = isFull
59+
? 'hover:bg-amber-50/50 rounded-full p-1 transition-colors'
60+
: iconWrapClass;
61+
</script>
62+
63+
<div class="note-action-bar" class:compact={isCompact} class:full={isFull}>
64+
{#if showZapPills}
65+
<div class="zap-pills-row">
66+
<NoteTotalZaps
67+
{event}
68+
onZapClick={openZapModal}
69+
showPills={true}
70+
onlyPills={true}
71+
maxPills={10}
72+
/>
73+
</div>
74+
{/if}
75+
76+
<div class="action-row">
77+
<div class={iconWrapClass}>
78+
<NoteTotalLikes {event} />
79+
</div>
80+
81+
{#if showComments}
82+
<div class={iconWrapClass}>
83+
<NoteTotalComments {event} />
84+
</div>
85+
{/if}
86+
87+
{#if showRepost}
88+
<div class={iconWrapClass}>
89+
<NoteRepost {event} />
90+
</div>
91+
{/if}
92+
93+
<div class={zapWrapClass}>
94+
<NoteTotalZaps {event} onZapClick={openZapModal} />
95+
</div>
96+
</div>
97+
</div>
98+
99+
{#if zapModalOpen}
100+
<ZapModal bind:open={zapModalOpen} {event} on:zap-complete={handleZapComplete} />
101+
{/if}
102+
103+
<style>
104+
.note-action-bar {
105+
display: flex;
106+
flex-direction: column;
107+
gap: 0.25rem;
108+
}
109+
110+
.action-row {
111+
display: flex;
112+
align-items: center;
113+
gap: 0.25rem;
114+
color: var(--color-text-secondary);
115+
}
116+
117+
.compact .action-row {
118+
gap: 0.125rem;
119+
}
120+
121+
.zap-pills-row {
122+
padding: 0 0.5rem;
123+
}
124+
125+
.full .zap-pills-row {
126+
padding: 0;
127+
}
128+
</style>

src/components/PollDisplay.svelte

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -227,16 +227,21 @@
227227
{/each}
228228
</div>
229229
{:else}
230-
<!-- ═══ TEXT-ONLY LIST LAYOUT (unchanged) ═══ -->
231-
<div class="space-y-2">
232-
{#each pollData.options as option (option.id)}
230+
<!-- ═══ TEXT GRID LAYOUT ═══ -->
231+
<div class="poll-text-grid">
232+
{#each pollData.options as option, i (option.id)}
233233
{@const voteCount = results.counts.get(option.id) || 0}
234234
{@const pct = results.totalVoters === 0 ? 0 : Math.round((voteCount / results.totalVoters) * 100)}
235235
{@const isUserChoice = userSelectedOptions.includes(option.id)}
236236
{@const isSelected = selectedOptions.has(option.id)}
237+
{@const isLast = i === pollData.options.length - 1 && pollData.options.length % 2 !== 0}
237238

238239
{#if displayResults}
239-
<div class="poll-result-bar" class:poll-result-user={isUserChoice}>
240+
<div
241+
class="poll-result-bar"
242+
class:poll-result-user={isUserChoice}
243+
class:poll-text-card-last-odd={isLast}
244+
>
240245
<div class="poll-result-fill" style="width: {pct}%"></div>
241246
<div class="poll-result-content">
242247
<span class="poll-result-label">
@@ -245,13 +250,15 @@
245250
<span class="poll-result-check">✓</span>
246251
{/if}
247252
</span>
248-
<span class="poll-result-pct">{pct}% ({voteCount})</span>
253+
<span class="poll-result-pct">{pct}%</span>
249254
</div>
255+
<div class="poll-result-votes">{voteCount}</div>
250256
</div>
251257
{:else}
252258
<button
253259
class="poll-option-btn"
254260
class:poll-option-selected={isSelected}
261+
class:poll-text-card-last-odd={isLast}
255262
on:click={() => toggleOption(option.id)}
256263
disabled={expired || voting}
257264
>
@@ -262,7 +269,7 @@
262269
<span class="poll-checkbox" class:poll-checkbox-checked={isSelected}></span>
263270
{/if}
264271
</span>
265-
<span>{option.label}</span>
272+
<span class="poll-option-label">{option.label}</span>
266273
</button>
267274
{/if}
268275
{/each}
@@ -514,24 +521,48 @@
514521
}
515522
516523
/* ═══════════════════════════════════════════
517-
TEXT-ONLY LIST LAYOUT
524+
TEXT GRID LAYOUT
518525
═══════════════════════════════════════════ */
519526
527+
.poll-text-grid {
528+
display: grid;
529+
grid-template-columns: 1fr 1fr;
530+
gap: 0.5rem;
531+
}
532+
533+
.poll-text-card-last-odd {
534+
grid-column: 1 / -1;
535+
max-width: 50%;
536+
justify-self: center;
537+
}
538+
539+
@media (max-width: 360px) {
540+
.poll-text-grid {
541+
grid-template-columns: 1fr;
542+
}
543+
.poll-text-card-last-odd {
544+
max-width: 100%;
545+
}
546+
}
547+
520548
/* Vote mode buttons */
521549
.poll-option-btn {
522550
display: flex;
551+
flex-direction: column;
523552
align-items: center;
524-
gap: 0.5rem;
553+
justify-content: center;
554+
gap: 0.375rem;
525555
width: 100%;
526-
padding: 0.5rem 0.75rem;
527-
border: 1px solid var(--color-input-border);
528-
border-radius: 0.5rem;
556+
padding: 0.75rem 0.5rem;
557+
border: 2px solid var(--color-input-border);
558+
border-radius: 0.625rem;
529559
background: transparent;
530560
color: var(--color-text-primary);
531-
font-size: 0.875rem;
561+
font-size: 0.8125rem;
532562
cursor: pointer;
533563
transition: all 0.15s;
534-
text-align: left;
564+
text-align: center;
565+
min-height: 3.5rem;
535566
}
536567
537568
.poll-option-btn:hover:not(:disabled) {
@@ -546,12 +577,18 @@
546577
.poll-option-selected {
547578
border-color: var(--color-primary, #f97316);
548579
background: rgba(249, 115, 22, 0.05);
580+
box-shadow: 0 0 0 1px var(--color-primary, #f97316);
549581
}
550582
551583
.poll-option-indicator {
552584
flex-shrink: 0;
553585
}
554586
587+
.poll-option-label {
588+
line-height: 1.3;
589+
word-break: break-word;
590+
}
591+
555592
.poll-radio,
556593
.poll-checkbox {
557594
display: inline-block;
@@ -583,10 +620,14 @@
583620
/* Results mode */
584621
.poll-result-bar {
585622
position: relative;
586-
padding: 0.5rem 0.75rem;
587-
border: 1px solid var(--color-input-border);
588-
border-radius: 0.5rem;
623+
display: flex;
624+
flex-direction: column;
625+
justify-content: center;
626+
padding: 0.625rem 0.5rem;
627+
border: 2px solid var(--color-input-border);
628+
border-radius: 0.625rem;
589629
overflow: hidden;
630+
min-height: 3.5rem;
590631
}
591632
592633
.poll-result-user {
@@ -607,14 +648,17 @@
607648
display: flex;
608649
justify-content: space-between;
609650
align-items: center;
610-
font-size: 0.875rem;
651+
font-size: 0.8125rem;
652+
line-height: 1.3;
611653
}
612654
613655
.poll-result-label {
614656
color: var(--color-text-primary);
615657
display: flex;
616658
align-items: center;
617659
gap: 0.25rem;
660+
word-break: break-word;
661+
min-width: 0;
618662
}
619663
620664
.poll-result-check {
@@ -624,7 +668,16 @@
624668
625669
.poll-result-pct {
626670
color: var(--color-caption);
627-
font-size: 0.8125rem;
671+
font-size: 0.75rem;
628672
flex-shrink: 0;
673+
font-weight: 600;
674+
}
675+
676+
.poll-result-votes {
677+
position: relative;
678+
font-size: 0.6875rem;
679+
color: var(--color-caption);
680+
text-align: center;
681+
margin-top: 0.125rem;
629682
}
630683
</style>

0 commit comments

Comments
 (0)