007ca453d2
Hop-State: A_06FPA0SKS2W3S1CARQHD1C8 Hop-Proposal: R_06FPA0RRM7YQZ4XK97CBTPR Hop-Task: T_06FP9Y3D8DT3NEWS9F7PM00 Hop-Attempt: AT_06FP9Y3D8DQE7JTQSMK0DDG
376 lines
16 KiB
TypeScript
376 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import AutoTextarea from '@/components/AutoTextarea';
|
|
import { Post, Attachment } from '@/lib/types';
|
|
import { AlertTriangle, Music2, Paperclip } from 'lucide-react';
|
|
import { VideoEmbed } from '@/components/VideoEmbed';
|
|
import { useFormattedHandle } from '@/lib/utils/handle';
|
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
|
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
|
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
|
|
import { getMediaKind } from '@/lib/media/upload-policy';
|
|
|
|
interface MediaAttachment extends Attachment {
|
|
mimeType?: string;
|
|
filename?: string;
|
|
}
|
|
|
|
interface ComposeProps {
|
|
onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void | boolean | Promise<void | boolean>;
|
|
onPosted?: () => void;
|
|
replyingTo?: Post | null;
|
|
onCancelReply?: () => void;
|
|
placeholder?: string;
|
|
isReply?: boolean;
|
|
autoFocus?: boolean;
|
|
}
|
|
|
|
export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placeholder = "What's happening?", isReply, autoFocus = false }: ComposeProps) {
|
|
const { isIdentityUnlocked } = useAuth();
|
|
const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : '';
|
|
const [content, setContent] = useState('');
|
|
const [isPosting, setIsPosting] = useState(false);
|
|
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [isCheckingStorage, setIsCheckingStorage] = useState(false);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const [storageNotice, setStorageNotice] = useState<string | null>(null);
|
|
const [pendingStorageFiles, setPendingStorageFiles] = useState<File[]>([]);
|
|
const [showStorageConfiguration, setShowStorageConfiguration] = useState(false);
|
|
const [linkPreview, setLinkPreview] = useState<any>(null);
|
|
const [fetchingPreview, setFetchingPreview] = useState(false);
|
|
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
|
|
const [isNsfw, setIsNsfw] = useState(false);
|
|
const [canPostNsfw, setCanPostNsfw] = useState(false);
|
|
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
|
const mediaInputRef = useRef<HTMLInputElement>(null);
|
|
const maxLength = 600;
|
|
const remaining = maxLength - content.length;
|
|
const previewMedia = linkPreview?.media?.length
|
|
? linkPreview.media
|
|
: linkPreview?.image
|
|
? [{ url: linkPreview.image }]
|
|
: [];
|
|
const previewImage = previewMedia[0]?.url || linkPreview?.image || null;
|
|
const isEmbeddedVideo = Boolean(linkPreview?.url?.match(/(youtube\.com|youtu\.be|vimeo\.com)/));
|
|
|
|
// Check if user can post NSFW content and if node is NSFW
|
|
useEffect(() => {
|
|
fetch('/api/settings/nsfw')
|
|
.then(res => res.ok ? res.json() : null)
|
|
.then(data => {
|
|
if (data?.nsfwEnabled) {
|
|
setCanPostNsfw(true);
|
|
}
|
|
})
|
|
.catch(() => { });
|
|
|
|
fetch('/api/node')
|
|
.then(res => res.ok ? res.json() : null)
|
|
.then(data => {
|
|
if (data?.isNsfw) {
|
|
setIsNsfwNode(true);
|
|
}
|
|
})
|
|
.catch(() => { });
|
|
}, []);
|
|
|
|
// Detect URLs in content
|
|
useEffect(() => {
|
|
const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
|
|
const matches = content.match(urlRegex);
|
|
|
|
if (matches && matches[0]) {
|
|
const url = matches[0];
|
|
if (url !== lastDetectedUrl) {
|
|
setLastDetectedUrl(url);
|
|
fetchPreview(url);
|
|
}
|
|
} else if (!content.trim()) {
|
|
setLinkPreview(null);
|
|
setLastDetectedUrl(null);
|
|
}
|
|
}, [content, lastDetectedUrl]);
|
|
|
|
const fetchPreview = async (url: string) => {
|
|
setFetchingPreview(true);
|
|
try {
|
|
const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setLinkPreview(data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Preview error', err);
|
|
} finally {
|
|
setFetchingPreview(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!content.trim() || isPosting || isUploading) return;
|
|
|
|
// With persistence, identity should be unlocked. If not, user needs to re-login
|
|
if (!isIdentityUnlocked) {
|
|
alert('Your session has expired. Please log in again.');
|
|
return;
|
|
}
|
|
|
|
setIsPosting(true);
|
|
try {
|
|
const posted = await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw);
|
|
if (posted === false) {
|
|
setIsPosting(false);
|
|
return;
|
|
}
|
|
|
|
setContent('');
|
|
setAttachments([]);
|
|
setLinkPreview(null);
|
|
setLastDetectedUrl(null);
|
|
setIsNsfw(false);
|
|
setIsPosting(false);
|
|
onPosted?.();
|
|
} catch (error) {
|
|
setIsPosting(false);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const uploadMediaFiles = async (files: File[]) => {
|
|
const remainingSlots = Math.max(0, 4 - attachments.length);
|
|
const selectedFiles = files.slice(0, remainingSlots);
|
|
if (selectedFiles.length === 0) return;
|
|
|
|
setUploadError(null);
|
|
setIsUploading(true);
|
|
|
|
const uploaded: MediaAttachment[] = [];
|
|
|
|
for (const file of selectedFiles) {
|
|
try {
|
|
const media = await uploadMediaFile(file);
|
|
|
|
uploaded.push({
|
|
id: media.id,
|
|
url: media.url,
|
|
altText: media.altText ?? null,
|
|
mimeType: media.mimeType ?? file.type,
|
|
filename: file.name,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED') {
|
|
setAttachments((prev) => [...prev, ...uploaded].slice(0, 4));
|
|
setPendingStorageFiles(selectedFiles.slice(uploaded.length));
|
|
setShowStorageConfiguration(true);
|
|
setIsUploading(false);
|
|
return;
|
|
}
|
|
console.error('Upload failed', error);
|
|
setUploadError('One or more uploads failed. Try again.');
|
|
}
|
|
}
|
|
|
|
setAttachments((prev) => [...prev, ...uploaded].slice(0, 4));
|
|
setIsUploading(false);
|
|
};
|
|
|
|
const handleMediaSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(event.target.files || []);
|
|
event.target.value = '';
|
|
if (files.length === 0) return;
|
|
await uploadMediaFiles(files);
|
|
};
|
|
|
|
const handleAddMedia = async () => {
|
|
setUploadError(null);
|
|
setStorageNotice(null);
|
|
setIsCheckingStorage(true);
|
|
try {
|
|
const provider = await getStorageProvider();
|
|
if (!provider) {
|
|
setShowStorageConfiguration(true);
|
|
return;
|
|
}
|
|
mediaInputRef.current?.click();
|
|
} catch (error) {
|
|
setUploadError(error instanceof Error ? error.message : 'Unable to check media storage');
|
|
} finally {
|
|
setIsCheckingStorage(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveAttachment = (id: string) => {
|
|
setAttachments((prev) => prev.filter((item) => item.id !== id));
|
|
};
|
|
|
|
return (
|
|
<div className={`compose ${isReply ? 'reply-compose' : ''}`}>
|
|
<StorageConfigurationPrompt
|
|
open={showStorageConfiguration}
|
|
onConfigured={async () => {
|
|
setShowStorageConfiguration(false);
|
|
const files = pendingStorageFiles;
|
|
setPendingStorageFiles([]);
|
|
if (files.length > 0) {
|
|
await uploadMediaFiles(files);
|
|
return;
|
|
}
|
|
setStorageNotice('Stuffbox connected. Choose your media to continue.');
|
|
mediaInputRef.current?.click();
|
|
}}
|
|
onCancel={() => {
|
|
setShowStorageConfiguration(false);
|
|
setPendingStorageFiles([]);
|
|
}}
|
|
/>
|
|
{replyingTo && !isReply && (
|
|
<div className="compose-reply-target">
|
|
<div className="compose-reply-info">
|
|
Replying to <span className="compose-reply-handle">{replyToHandle}</span>
|
|
</div>
|
|
<button type="button" className="compose-reply-cancel" onClick={onCancelReply}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
<AutoTextarea
|
|
className="compose-input"
|
|
placeholder={placeholder}
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
maxLength={maxLength + 50} // Allow some overflow for better UX
|
|
autoFocus={autoFocus}
|
|
/>
|
|
{attachments.length > 0 && (
|
|
<div className="compose-media-grid">
|
|
{attachments.map((item) => {
|
|
const mediaKind = getMediaKind(item.mimeType);
|
|
return (
|
|
<div className={`compose-media-item ${mediaKind === 'audio' ? 'audio' : ''}`} key={item.id}>
|
|
{mediaKind === 'video' ? (
|
|
<video src={item.url} muted playsInline preload="metadata" />
|
|
) : mediaKind === 'audio' ? (
|
|
<div className="compose-audio-preview">
|
|
<Music2 size={22} />
|
|
<span>{item.filename || 'Audio track'}</span>
|
|
</div>
|
|
) : (
|
|
<img src={item.url} alt={item.altText || 'Upload preview'} />
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="compose-media-remove"
|
|
onClick={() => handleRemoveAttachment(item.id)}
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{linkPreview && (
|
|
<div className="compose-link-preview">
|
|
<button
|
|
type="button"
|
|
className="compose-link-preview-remove"
|
|
onClick={() => setLinkPreview(null)}
|
|
>
|
|
x
|
|
</button>
|
|
<VideoEmbed url={linkPreview.url} />
|
|
{!isEmbeddedVideo && (
|
|
<div className="link-preview-card mini">
|
|
{linkPreview.type === 'video' && linkPreview.videoUrl ? (
|
|
<div className="link-preview-image">
|
|
<video
|
|
src={linkPreview.videoUrl}
|
|
poster={previewImage || undefined}
|
|
muted
|
|
playsInline
|
|
preload="metadata"
|
|
/>
|
|
</div>
|
|
) : linkPreview.type === 'gallery' && previewMedia.length > 0 ? (
|
|
<div className="link-preview-gallery compact">
|
|
{previewMedia.slice(0, 3).map((item: { url: string }, index: number) => (
|
|
<div className="link-preview-gallery-item" key={`${item.url}-${index}`}>
|
|
<img src={item.url} alt="" />
|
|
{index === Math.min(previewMedia.length, 3) - 1 && previewMedia.length > 3 && (
|
|
<span className="link-preview-gallery-more">+{previewMedia.length - 3}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : previewImage && (
|
|
<div className="link-preview-image">
|
|
<img src={previewImage} alt="" />
|
|
</div>
|
|
)}
|
|
<div className="link-preview-info">
|
|
<div className="link-preview-title">{linkPreview.title}</div>
|
|
<div className="link-preview-url">{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{uploadError && (
|
|
<div className="compose-media-error">{uploadError}</div>
|
|
)}
|
|
{storageNotice && (
|
|
<div style={{ color: 'var(--success)', fontSize: '13px', marginTop: '8px' }}>{storageNotice}</div>
|
|
)}
|
|
<div className="compose-footer">
|
|
<div className="compose-footer-left">
|
|
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
|
|
{remaining}
|
|
</span>
|
|
{canPostNsfw && !isNsfwNode && (
|
|
<label className="compose-nsfw-toggle" title="Mark as sensitive content">
|
|
<input
|
|
type="checkbox"
|
|
checked={isNsfw}
|
|
onChange={(e) => setIsNsfw(e.target.checked)}
|
|
/>
|
|
<AlertTriangle size={16} />
|
|
<span>NSFW</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
<div className="compose-actions">
|
|
<button
|
|
type="button"
|
|
className="compose-media-button"
|
|
title="Attach media"
|
|
onClick={handleAddMedia}
|
|
disabled={isUploading || isCheckingStorage || attachments.length >= 4}
|
|
>
|
|
{isUploading || isCheckingStorage ? '...' : <Paperclip size={20} />}
|
|
</button>
|
|
<input
|
|
ref={mediaInputRef}
|
|
type="file"
|
|
accept="image/*,video/mp4,video/webm,video/quicktime,audio/mpeg,audio/mp4,audio/aac,audio/wav,audio/ogg,audio/flac"
|
|
multiple
|
|
onChange={handleMediaSelect}
|
|
disabled={isUploading || attachments.length >= 4}
|
|
className="compose-media-input"
|
|
/>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleSubmit}
|
|
disabled={!content.trim() || remaining < 0 || isPosting || isUploading}
|
|
>
|
|
{isPosting ? 'Posting...' : isReply ? 'Reply' : 'Post'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|