feat: GIF search (Klipy), stickers, emoji picker, and bug fixes

- Add GIF search powered by Klipy API with correct response mapping
  (file.sm/hd tiers, not flat files structure)
- Add sticker system: packs, upload with auto-downscale, send in messages
- Add tabbed InputPopover with emoji, GIF, and sticker pickers
- Fix GIF API key migration race condition (column-add loop vs rename)
- Fix masked API key corruption on settings save (server + client guards)
- Fix sticker packs 403 (reversed isMember parameter order)
- Fix emoji picker not filling popover width (perLine 8→9, CSS 100%)
- Add error logging for Klipy API failures
This commit is contained in:
Jannis Braun
2026-03-15 02:04:37 +01:00
parent 7113f47b17
commit 3de6e4a668
26 changed files with 2160 additions and 50 deletions
+113 -12
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { MessageWithUser } from '@backspace/shared';
import { MarkdownRenderer } from './MarkdownRenderer';
import { Avatar } from '../ui/Avatar';
@@ -9,6 +10,7 @@ import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { Embed } from './Embed';
import { Username } from '../ui/Username';
import { EmojiPicker } from './EmojiPicker';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { isSelf, resolveDisplayIdentity } from '../../utils/identity';
@@ -37,10 +39,21 @@ function formatHoverTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const GIF_URL_REGEX = /^https:\/\/(?:media\.tenor\.com|media\.klipy\.com)\/.+$/;
function isGifOnlyMessage(content: string | null): boolean {
if (!content) return false;
const trimmed = content.trim();
return GIF_URL_REGEX.test(trimmed);
}
export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content ?? '');
const [isHovered, setIsHovered] = useState(false);
const [showReactionPicker, setShowReactionPicker] = useState(false);
const reactionPickerBtnRef = useRef<HTMLButtonElement>(null);
const reactionPickerRef = useRef<HTMLDivElement>(null);
const currentUser = useAuthStore((s) => s.user);
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
@@ -83,8 +96,39 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
return acc;
}, {} as Record<string, { count: number; me: boolean }>);
const isGifOnly = isGifOnlyMessage(message.content);
const isSticker = !!(message.stickerId || (message as any).sticker);
const stickerData = (message as any).sticker ?? null;
const urlRegex = /(https?:\/\/[^\s]+)/g;
const firstUrl = message.content?.match(urlRegex)?.[0];
const firstUrl = isGifOnly ? null : message.content?.match(urlRegex)?.[0];
// Close reaction picker on outside click
useEffect(() => {
if (!showReactionPicker) return;
const handler = (e: MouseEvent) => {
if (reactionPickerRef.current?.contains(e.target as Node)) return;
if (reactionPickerBtnRef.current?.contains(e.target as Node)) return;
setShowReactionPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showReactionPicker]);
// Close reaction picker on Escape
useEffect(() => {
if (!showReactionPicker) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowReactionPicker(false);
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [showReactionPicker]);
const handleReactionEmojiSelect = useCallback((emoji: { native: string }) => {
addReaction(message.id, emoji.native);
setShowReactionPicker(false);
}, [addReaction, message.id]);
const handleUsernameClick = (e: React.MouseEvent) => {
if (!message.user) return;
@@ -245,17 +289,42 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
</div>
) : (
<div className="flex flex-col gap-1">
{message.content && (
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
<MarkdownRenderer content={message.content} />
{message.editedAt && (
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
)}
{/* Sticker rendering */}
{isSticker && stickerData ? (
<div className="mt-1" title={`${stickerData.name}`}>
<img
src={stickerData.filename.startsWith('http') || stickerData.filename.startsWith('/') ? stickerData.filename : `/api/uploads/${stickerData.filename}`}
alt={stickerData.name}
className="max-w-[160px] max-h-[160px] object-contain"
loading="lazy"
/>
</div>
)}
) : isSticker ? (
<div className="mt-1 text-txt-tertiary text-sm italic">Sticker unavailable</div>
) : isGifOnly ? (
<div className="mt-1 max-w-[350px] rounded-lg overflow-hidden">
<img
src={message.content!.trim()}
alt="GIF"
className="max-w-full max-h-[350px] object-contain rounded-lg"
loading="lazy"
/>
</div>
) : (
<>
{message.content && (
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
<MarkdownRenderer content={message.content} />
{message.editedAt && (
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
)}
</div>
)}
{/* Embeds */}
{!isEditing && firstUrl && <Embed url={firstUrl} />}
{/* Embeds */}
{!isEditing && firstUrl && <Embed url={firstUrl} />}
</>
)}
{/* Attachments */}
{message.attachments && message.attachments.length > 0 && (
@@ -327,8 +396,28 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
)}
</div>
{/* Reaction emoji picker */}
{showReactionPicker && canAddReactions && reactionPickerBtnRef.current && createPortal(
<div
ref={reactionPickerRef}
className="fixed z-[300] animate-slide-up"
style={{
top: reactionPickerBtnRef.current.getBoundingClientRect().bottom + 8,
left: Math.min(
reactionPickerBtnRef.current.getBoundingClientRect().left,
window.innerWidth - 360,
),
}}
>
<div className="glass rounded-xl overflow-hidden">
<EmojiPicker onEmojiSelect={handleReactionEmojiSelect} />
</div>
</div>,
document.body,
)}
{/* Action buttons on hover */}
{isHovered && !isEditing && (
{(isHovered || showReactionPicker) && !isEditing && (
<div className="absolute -top-[18px] right-4 flex items-center glass rounded-[10px] overflow-hidden z-10 h-8">
{canAddReactions && (
<div className="flex items-center px-1 border-r border-white/[0.06] h-full">
@@ -341,6 +430,18 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
{emoji}
</button>
))}
<button
ref={reactionPickerBtnRef}
onClick={() => setShowReactionPicker((v) => !v)}
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Add reaction"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
</svg>
</button>
</div>
)}
<button