Merge branch 'feat/poll-compose' into 'main'
Add poll creation to compose toolbar and polls page FAB See merge request soapbox-pub/ditto!85
This commit is contained in:
+19
-10
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
|
||||
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
|
||||
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { ReplyComposeModal } from "@/components/ReplyComposeModal";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
@@ -56,6 +58,22 @@ const decksDef = getExtraKindDef("decks")!;
|
||||
const emojisDef = getExtraKindDef("emojis")!;
|
||||
const developmentDef = getExtraKindDef("development")!;
|
||||
|
||||
/** Polls feed page with a FAB that opens the compose modal (poll mode via + menu). */
|
||||
function PollsFeedPage() {
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<KindFeedPage
|
||||
kind={pollsDef.kind}
|
||||
title={pollsDef.label}
|
||||
icon={sidebarItemIcon("polls", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
|
||||
function ProfileRedirect() {
|
||||
const { user, metadata } = useCurrentUser();
|
||||
@@ -111,16 +129,7 @@ export function AppRouter() {
|
||||
<Route path="/vines" element={<VinesFeedPage />} />
|
||||
<Route path="/music" element={<MusicFeedPage />} />
|
||||
<Route path="/podcasts" element={<PodcastsFeedPage />} />
|
||||
<Route
|
||||
path="/polls"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={pollsDef.kind}
|
||||
title={pollsDef.label}
|
||||
icon={sidebarItemIcon("polls", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/polls" element={<PollsFeedPage />} />
|
||||
<Route path="/treasures" element={<TreasuresPage />} />
|
||||
<Route
|
||||
path="/colors"
|
||||
|
||||
+225
-31
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker } from 'lucide-react';
|
||||
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { encode as blurhashEncode } from 'blurhash';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -13,6 +13,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { EmojiPicker } from '@/components/EmojiPicker';
|
||||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
@@ -42,6 +43,11 @@ import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
|
||||
const MAX_CHARS = 5000;
|
||||
|
||||
/** Short random ID for poll options. */
|
||||
function pollOptionId(): string {
|
||||
return Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* For an image File, returns `{ dim: "WxH", blurhash: "..." }`.
|
||||
* Decodes to a small canvas (max 64px wide) for speed — large enough
|
||||
@@ -110,6 +116,8 @@ interface ComposeBoxProps {
|
||||
onHasPreviewableContentChange?: (hasContent: boolean) => void;
|
||||
/** Pre-filled content for the compose box. */
|
||||
initialContent?: string;
|
||||
/** Open directly in poll mode. */
|
||||
initialMode?: 'post' | 'poll';
|
||||
}
|
||||
|
||||
/** Circular progress ring for character count. */
|
||||
@@ -149,6 +157,7 @@ function CharRing({ count, max }: { count: number; max: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function ComposeBox({
|
||||
onSuccess,
|
||||
placeholder = "What's on your mind?",
|
||||
@@ -160,11 +169,12 @@ export function ComposeBox({
|
||||
previewMode: controlledPreviewMode,
|
||||
onHasPreviewableContentChange,
|
||||
initialContent = '',
|
||||
initialMode = 'post',
|
||||
}: ComposeBoxProps) {
|
||||
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||||
const { mutateAsync: createEvent, isPending, isPending: isPollPending } = useNostrPublish();
|
||||
const { mutateAsync: postComment, isPending: isCommentPending } = usePostComment();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
@@ -180,7 +190,18 @@ export function ComposeBox({
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickerTab, setPickerTab] = useState<'emoji' | 'gif' | 'stickers'>('emoji');
|
||||
const [trayOpen, setTrayOpen] = useState(false);
|
||||
const [internalPreviewMode, setInternalPreviewMode] = useState(false);
|
||||
|
||||
// Poll mode state
|
||||
const [mode, setMode] = useState<'post' | 'poll'>(initialMode);
|
||||
const [pollQuestion, setPollQuestion] = useState('');
|
||||
const [pollOptions, setPollOptions] = useState([
|
||||
{ id: pollOptionId(), label: '' },
|
||||
{ id: pollOptionId(), label: '' },
|
||||
]);
|
||||
const [pollType, setPollType] = useState<'singlechoice' | 'multiplechoice'>('singlechoice');
|
||||
const [pollDuration, setPollDuration] = useState<7 | 3 | 1 | 0>(7);
|
||||
const [removedEmbeds, setRemovedEmbeds] = useState<Set<string>>(new Set());
|
||||
const [_uploadedFileTags, setUploadedFileTags] = useState<string[][]>([]);
|
||||
/** Maps uploaded file URLs to their NIP-94 tags (grouped per upload). */
|
||||
@@ -953,6 +974,39 @@ export function ComposeBox({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePollSubmit = async () => {
|
||||
const filledOptions = pollOptions.filter((o) => o.label.trim());
|
||||
if (!pollQuestion.trim() || filledOptions.length < 2 || !user || isPollPending) return;
|
||||
|
||||
const tags: string[][] = [];
|
||||
for (const opt of filledOptions) {
|
||||
tags.push(['option', opt.id, opt.label.trim()]);
|
||||
}
|
||||
tags.push(['polltype', pollType]);
|
||||
if (pollDuration > 0) {
|
||||
tags.push(['endsAt', String(Math.floor(Date.now() / 1000) + pollDuration * 86_400)]);
|
||||
}
|
||||
tags.push(['alt', `Poll: ${pollQuestion.trim()}`]);
|
||||
|
||||
try {
|
||||
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
|
||||
// Reset poll state
|
||||
setMode('post');
|
||||
setPollQuestion('');
|
||||
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
|
||||
setPollType('singlechoice');
|
||||
setPollDuration(7);
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
toast({ title: 'Poll published!' });
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
toast({ title: 'Error', description: 'Failed to publish poll.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length;
|
||||
const isPollValid = pollQuestion.trim().length > 0 && pollFilledCount >= 2;
|
||||
|
||||
const isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
|
||||
|
||||
// Early return after all hooks to avoid violating Rules of Hooks
|
||||
@@ -1007,8 +1061,113 @@ export function ComposeBox({
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{!previewMode ? (
|
||||
/* Edit mode - Textarea */
|
||||
{mode === 'poll' ? (
|
||||
/* ── Inline poll builder ─────────────────────────────── */
|
||||
<div className="pt-2.5 pb-1 space-y-3">
|
||||
{/* Back to post link — hidden when poll mode is the only mode */}
|
||||
{initialMode !== 'poll' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('post')}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors -mt-0.5"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Back to post
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Question */}
|
||||
<textarea
|
||||
value={pollQuestion}
|
||||
onChange={(e) => setPollQuestion(e.target.value)}
|
||||
placeholder="Ask a question…"
|
||||
rows={2}
|
||||
maxLength={280}
|
||||
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pb-1 opacity-85 break-words"
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-1.5">
|
||||
{pollOptions.map((opt, idx) => (
|
||||
<div key={opt.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={opt.label}
|
||||
onChange={(e) =>
|
||||
setPollOptions((prev) =>
|
||||
prev.map((o) => (o.id === opt.id ? { ...o, label: e.target.value } : o)),
|
||||
)
|
||||
}
|
||||
placeholder={`Option ${idx + 1}`}
|
||||
maxLength={100}
|
||||
className="flex-1 bg-secondary/40 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-1 focus:ring-primary/40 placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pollOptions.length > 2) {
|
||||
setPollOptions((prev) => prev.filter((o) => o.id !== opt.id));
|
||||
}
|
||||
}}
|
||||
disabled={pollOptions.length <= 2}
|
||||
className="p-1 rounded-full text-muted-foreground hover:text-destructive transition-colors disabled:opacity-20"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pollOptions.length < 8 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPollOptions((prev) => [...prev, { id: pollOptionId(), label: '' }])
|
||||
}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors pt-0.5"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add option
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings row — pill toggles */}
|
||||
<div className="flex flex-wrap gap-2 pt-0.5">
|
||||
{(['singlechoice', 'multiplechoice'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setPollType(t)}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
pollType === t
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/30',
|
||||
)}
|
||||
>
|
||||
{t === 'singlechoice' ? 'Single choice' : 'Multiple choice'}
|
||||
</button>
|
||||
))}
|
||||
<div className="w-px bg-border self-stretch mx-0.5" />
|
||||
{([1, 3, 7, 0] as const).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => setPollDuration(d)}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
pollDuration === d
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-foreground/30',
|
||||
)}
|
||||
>
|
||||
{d === 0 ? <span style={{ fontSize: '15px', lineHeight: 1, position: 'relative', top: '-1px' }}>∞</span> : `${d}d`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : !previewMode ? (
|
||||
/* ── Edit mode — Textarea ────────────────────────────── */
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -1335,32 +1494,56 @@ export function ComposeBox({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Content warning (NIP-36) */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCwEnabled(!cwEnabled)}
|
||||
className={cn(
|
||||
'p-2 rounded-full transition-colors',
|
||||
cwEnabled
|
||||
? 'text-amber-500 bg-amber-500/10'
|
||||
: 'text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10',
|
||||
{/* Overflow: Poll + CW */}
|
||||
<Popover open={trayOpen} onOpenChange={setTrayOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!user}
|
||||
className={cn(
|
||||
'p-2 rounded-full transition-colors disabled:opacity-40',
|
||||
(trayOpen || mode === 'poll' || cwEnabled)
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
|
||||
)}
|
||||
>
|
||||
<Plus className="size-[18px]" strokeWidth={2.5} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{!trayOpen && <TooltipContent>More</TooltipContent>}
|
||||
</Tooltip>
|
||||
<PopoverContent side="bottom" align="start" sideOffset={6} className="w-44 p-1.5 rounded-xl border-border shadow-lg">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{!replyTo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode((m) => m === 'poll' ? 'post' : 'poll'); setTrayOpen(false); expand(); }}
|
||||
className={cn('flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors', mode === 'poll' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60')}
|
||||
>
|
||||
<BarChart3 className="size-4" /><span className="font-medium">Poll</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="size-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Content warning (NIP-36)</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCwEnabled((v) => !v); setTrayOpen(false); expand(); }}
|
||||
className={cn('flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-sm transition-colors', cwEnabled ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60')}
|
||||
>
|
||||
<AlertTriangle className="size-4" /><span className="font-medium">Spoiler</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right: char count + post button */}
|
||||
{/* Right: char count + post/poll button */}
|
||||
<div className="flex items-center gap-3">
|
||||
{charCount > 0 && (
|
||||
{mode === 'post' && charCount > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CharRing count={charCount} max={MAX_CHARS} />
|
||||
<span className={cn(
|
||||
@@ -1372,14 +1555,25 @@ export function ComposeBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
|
||||
className="rounded-full px-5 font-bold"
|
||||
size="sm"
|
||||
>
|
||||
{isPending || isCommentPending ? 'Posting...' : 'Post!'}
|
||||
</Button>
|
||||
{mode === 'poll' ? (
|
||||
<Button
|
||||
onClick={handlePollSubmit}
|
||||
disabled={!isPollValid || isPollPending || !user}
|
||||
className="rounded-full px-5 font-bold"
|
||||
size="sm"
|
||||
>
|
||||
{isPollPending ? 'Publishing...' : 'Publish poll'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
|
||||
className="rounded-full px-5 font-bold"
|
||||
size="sm"
|
||||
>
|
||||
{isPending || isCommentPending ? 'Posting...' : 'Post!'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -32,6 +32,8 @@ interface ReplyComposeModalProps {
|
||||
onSuccess?: () => void;
|
||||
/** Pre-filled content for the compose box. */
|
||||
initialContent?: string;
|
||||
/** Open directly in poll mode. */
|
||||
initialMode?: 'post' | 'poll';
|
||||
/** Override the modal title. */
|
||||
title?: string;
|
||||
/** Override the compose box placeholder text. */
|
||||
@@ -44,7 +46,7 @@ function extractImages(content: string): string[] {
|
||||
return content.match(urlRegex) || [];
|
||||
}
|
||||
|
||||
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSuccess, initialContent, title: titleOverride, placeholder: placeholderOverride }: ReplyComposeModalProps) {
|
||||
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSuccess, initialContent, initialMode, title: titleOverride, placeholder: placeholderOverride }: ReplyComposeModalProps) {
|
||||
const isUrl = event instanceof URL;
|
||||
const isReply = !!event;
|
||||
const isQuote = !!quotedEvent;
|
||||
@@ -53,7 +55,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
|
||||
const isProfileRoot = !isUrl && event instanceof Object && 'kind' in event && event.kind === 0;
|
||||
const title = titleOverride ?? (isUrl ? 'New comment' : isProfileRoot ? 'Comment on profile' : isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post');
|
||||
const title = titleOverride ?? (initialMode === 'poll' ? 'New poll' : isUrl ? 'New comment' : isProfileRoot ? 'Comment on profile' : isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post');
|
||||
const placeholder = placeholderOverride ?? (isUrl ? 'Write a comment...' : isReply ? "What's on your mind?" : isQuote ? 'Add a comment...' : "What's happening?");
|
||||
|
||||
const dialogContentRef = useCallback((node: HTMLElement | null) => {
|
||||
@@ -136,6 +138,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
previewMode={previewMode}
|
||||
onHasPreviewableContentChange={setHasPreviewableContent}
|
||||
initialContent={initialContent}
|
||||
initialMode={initialMode}
|
||||
/>
|
||||
</div>
|
||||
</PortalContainerProvider>
|
||||
|
||||
Reference in New Issue
Block a user