Fix preview toggle and reduce notification signature spam

- Include SVG URLs in link preview detection (LinkPreview checks content-type to handle both images and OG pages)
- Add proper imeta tags (NIP-92) for all media URLs in posts
- Store uploaded file metadata and use full NIP-94 tags in imeta
- Reduce encrypted settings decryption spam by disabling refetchOnWindowFocus and refetchOnReconnect
- Replace Switch with modern segmented control for Edit/Preview toggle

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-18 23:20:00 -06:00
parent 9eb25d917a
commit a45838f7d2
3 changed files with 80 additions and 15 deletions
+76 -14
View File
@@ -8,8 +8,6 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { EmojiPicker } from '@/components/EmojiPicker';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
@@ -89,6 +87,7 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
const [emojiOpen, setEmojiOpen] = useState(false);
const [previewMode, setPreviewMode] = useState(false);
const [removedEmbeds, setRemovedEmbeds] = useState<Set<string>>(new Set());
const [uploadedFileTags, setUploadedFileTags] = useState<string[][]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -177,12 +176,13 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
}
}
// Detect regular URLs (but not image/video URLs)
// Detect regular URLs (but not image/video URLs that will be rendered inline)
const urlMatches = content.matchAll(/https?:\/\/[^\s]+/g);
for (const match of urlMatches) {
const url = match[0];
// Skip media URLs
if (!/\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)(\?[^\s]*)?$/i.test(url)) {
// Skip media URLs that render inline
// Note: SVGs not excluded - LinkPreview checks content-type and handles both cases
if (!/\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|avi|mkv|flv)(\?[^\s]*)?$/i.test(url)) {
embeds.push({ type: 'link', value: url, index: match.index! });
}
}
@@ -248,7 +248,11 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
const handleFileUpload = async (file: File) => {
try {
const [[, url]] = await uploadFile(file);
const tags = await uploadFile(file);
const [[, url]] = tags;
// Store the full NIP-94 tags for later use in imeta
setUploadedFileTags((prev) => [...prev, ...tags]);
setContent((prev) => (prev ? prev + '\n' + url : url));
expand();
} catch {
@@ -310,6 +314,49 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
}
}
// NIP-92: Add imeta tags for media URLs in content
const mediaUrlMatches = finalContent.matchAll(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|avi|mkv|flv)(\?[^\s]*)?/gi);
const processedUrls = new Set<string>();
for (const match of mediaUrlMatches) {
const url = match[0];
if (processedUrls.has(url)) continue;
processedUrls.add(url);
// Check if this URL was uploaded (has full metadata)
const uploadedTag = uploadedFileTags.find(tag => tag[0] === 'url' && tag[1] === url);
if (uploadedTag) {
// Use the full NIP-94 metadata from the upload
const imetaFields = uploadedFileTags
.filter(tag => {
// Get all tags for this URL
const urlTag = uploadedFileTags.find(t => t[0] === 'url' && t[1] === url);
return urlTag && uploadedFileTags.indexOf(tag) >= uploadedFileTags.indexOf(urlTag);
})
.map(tag => `${tag[0]} ${tag[1]}`);
tags.push(['imeta', ...imetaFields]);
} else {
// Fallback: basic imeta tag with URL and inferred mime type
const ext = match[1].toLowerCase();
const mimeType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
: ext === 'png' ? 'image/png'
: ext === 'gif' ? 'image/gif'
: ext === 'webp' ? 'image/webp'
: ext === 'svg' ? 'image/svg+xml'
: ext === 'mp4' ? 'video/mp4'
: ext === 'webm' ? 'video/webm'
: ext === 'mov' ? 'video/quicktime'
: ext === 'avi' ? 'video/x-msvideo'
: ext === 'mkv' ? 'video/x-matroska'
: ext === 'flv' ? 'video/x-flv'
: 'application/octet-stream';
tags.push(['imeta', `url ${url}`, `m ${mimeType}`]);
}
}
await createEvent({
kind: 1,
content: finalContent,
@@ -322,6 +369,7 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
setCwText('');
setExpanded(false);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
queryClient.invalidateQueries({ queryKey: ['feed'] });
if (replyTo) {
queryClient.invalidateQueries({ queryKey: ['replies', replyTo.id] });
@@ -496,15 +544,29 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
{/* Center: Preview toggle */}
{hasPreviewableContent && (
<div className="flex items-center gap-2">
<Switch
id="preview-mode"
checked={previewMode}
onCheckedChange={setPreviewMode}
/>
<Label htmlFor="preview-mode" className="text-xs font-medium cursor-pointer">
<div className="inline-flex items-center gap-0.5 p-1 bg-muted/50 rounded-lg">
<button
onClick={() => setPreviewMode(false)}
className={cn(
"px-3 py-1 text-xs font-medium rounded-md transition-all",
!previewMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Edit
</button>
<button
onClick={() => setPreviewMode(true)}
className={cn(
"px-3 py-1 text-xs font-medium rounded-md transition-all",
previewMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Preview
</Label>
</button>
</div>
)}
+3
View File
@@ -88,6 +88,9 @@ export function useEncryptedSettings() {
},
enabled: !!query.data && !!user,
staleTime: 30 * 60 * 1000, // 30 minutes
gcTime: 60 * 60 * 1000, // 1 hour - keep in cache
refetchOnWindowFocus: false, // Don't refetch on window focus to avoid spam
refetchOnReconnect: false, // Don't refetch on reconnect
});
// Update settings
+1 -1
View File
@@ -25,7 +25,7 @@ export interface NotificationData {
export function useNotifications(): NotificationData {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { settings, updateSettings } = useEncryptedSettings();
const { settings, updateSettings, isLoading: settingsLoading } = useEncryptedSettings();
const { data: notifications = [], isLoading } = useQuery<NostrEvent[]>({
queryKey: ['notifications', user?.pubkey ?? ''],