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
@@ -0,0 +1,39 @@
import React, { useRef, useEffect } from 'react';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
interface EmojiPickerProps {
onEmojiSelect: (emoji: { native: string }) => void;
}
export function EmojiPicker({ onEmojiSelect }: EmojiPickerProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const stop = (e: KeyboardEvent) => e.stopPropagation();
el.addEventListener('keydown', stop);
return () => el.removeEventListener('keydown', stop);
}, []);
return (
<div ref={containerRef} className="emoji-picker-wrapper">
<Picker
data={data}
onEmojiSelect={onEmojiSelect}
theme="dark"
set="native"
skinTonePosition="search"
previewPosition="none"
navPosition="bottom"
perLine={9}
maxFrequentRows={2}
emojiSize={24}
emojiButtonSize={32}
categories={['frequent', 'people', 'nature', 'foods', 'activity', 'places', 'objects', 'symbols', 'flags']}
/>
</div>
);
}
@@ -0,0 +1,149 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { api } from '../../api/client';
import type { GifResult } from '@backspace/shared';
interface GifPickerProps {
onGifSelect: (url: string) => void;
}
export function GifPicker({ onGifSelect }: GifPickerProps) {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState<GifResult[]>([]);
const [loading, setLoading] = useState(true);
const [nextPos, setNextPos] = useState('');
const [loadingMore, setLoadingMore] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Debounce search query
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedQuery(query);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
// Fetch results when debounced query changes
useEffect(() => {
let cancelled = false;
setLoading(true);
setResults([]);
setNextPos('');
const fetchGifs = async () => {
try {
const data = debouncedQuery.trim()
? await api.gif.search(debouncedQuery.trim(), 30)
: await api.gif.trending(30);
if (!cancelled) {
setResults(data.results);
setNextPos(data.next);
setLoading(false);
}
} catch {
if (!cancelled) setLoading(false);
}
};
fetchGifs();
return () => { cancelled = true; };
}, [debouncedQuery]);
// Infinite scroll
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || loadingMore || !nextPos) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
setLoadingMore(true);
const fetchMore = async () => {
try {
const data = debouncedQuery.trim()
? await api.gif.search(debouncedQuery.trim(), 30, nextPos)
: await api.gif.trending(30, nextPos);
setResults((prev) => [...prev, ...data.results]);
setNextPos(data.next);
} finally {
setLoadingMore(false);
}
};
fetchMore();
}
}, [loadingMore, nextPos, debouncedQuery]);
// Prevent keyboard events from bubbling
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
};
return (
<div className="flex flex-col h-[390px]" onKeyDown={handleKeyDown}>
{/* Search */}
<div className="px-3 pt-2 pb-1.5">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search GIFs"
className="input-search w-full"
autoFocus
/>
</div>
{/* Results grid */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-1"
onScroll={handleScroll}
>
{loading ? (
<div className="grid grid-cols-2 gap-1.5 p-1">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="bg-surface-elevated rounded-lg animate-pulse"
style={{ height: 100 + Math.random() * 60 }}
/>
))}
</div>
) : results.length === 0 ? (
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
{debouncedQuery.trim() ? 'No GIFs found' : 'No trending GIFs'}
</div>
) : (
<div className="columns-2 gap-1.5 p-1">
{results.map((gif) => (
<button
key={gif.id}
onClick={() => onGifSelect(gif.url)}
className="w-full mb-1.5 rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all break-inside-avoid"
>
<img
src={gif.previewUrl}
alt={gif.title}
className="w-full object-cover rounded-lg"
loading="lazy"
style={{
aspectRatio: gif.width && gif.height ? `${gif.width}/${gif.height}` : undefined,
}}
/>
</button>
))}
</div>
)}
{loadingMore && (
<div className="flex justify-center py-2">
<div className="w-5 h-5 border-2 border-txt-tertiary border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
{/* Attribution */}
<div className="px-3 py-1 text-[10px] text-txt-tertiary text-right">
Powered by Klipy
</div>
</div>
);
}
@@ -0,0 +1,156 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { EmojiPicker } from './EmojiPicker';
import { GifPicker } from './GifPicker';
import { StickerPicker } from './StickerPicker';
import type { Sticker } from '@backspace/shared';
export type InputPopoverTab = 'emoji' | 'gif' | 'stickers';
interface InputPopoverProps {
activeTab: InputPopoverTab;
onClose: () => void;
onEmojiSelect: (emoji: { native: string }) => void;
onGifSelect: (url: string) => void;
onStickerSelect: (sticker: Sticker) => void;
anchorRef: React.RefObject<HTMLElement | null>;
gifEnabled: boolean;
stickersEnabled: boolean;
onTabChange: (tab: InputPopoverTab) => void;
}
export function InputPopover({
activeTab,
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
anchorRef,
gifEnabled,
stickersEnabled,
onTabChange,
}: InputPopoverProps) {
const floatingRef = useRef<HTMLDivElement>(null);
// Position above the anchor
const updatePosition = useCallback(() => {
const anchor = anchorRef.current;
const floating = floatingRef.current;
if (!anchor || !floating) return;
const anchorRect = anchor.getBoundingClientRect();
const floatingRect = floating.getBoundingClientRect();
const vw = window.innerWidth;
let left = anchorRect.right - floatingRect.width;
let top = anchorRect.top - floatingRect.height - 8;
// Flip below if no room above
if (top < 8) {
top = anchorRect.bottom + 8;
}
// Clamp horizontal
left = Math.max(8, Math.min(left, vw - floatingRect.width - 8));
floating.style.top = `${top}px`;
floating.style.left = `${left}px`;
}, [anchorRef]);
useEffect(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [updatePosition, activeTab]);
// Re-position after the picker renders (it may change height)
useEffect(() => {
const frame = requestAnimationFrame(updatePosition);
return () => cancelAnimationFrame(frame);
}, [activeTab, updatePosition]);
// Click outside to close
useEffect(() => {
const handler = (e: MouseEvent) => {
const floating = floatingRef.current;
const anchor = anchorRef.current;
if (!floating) return;
if (floating.contains(e.target as Node)) return;
if (anchor && anchor.contains(e.target as Node)) return;
onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose, anchorRef]);
// Escape to close
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
const availableTabs: { key: InputPopoverTab; label: string }[] = [
{ key: 'emoji', label: 'Emoji' },
];
if (gifEnabled) {
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
}
if (stickersEnabled) {
availableTabs.push({ key: 'stickers', label: 'Stickers' });
}
const showTabs = availableTabs.length > 1;
return createPortal(
<div
ref={floatingRef}
className="fixed z-[300] animate-slide-up"
style={{ top: -9999, left: -9999 }}
>
<div className="glass rounded-xl overflow-hidden flex flex-col" style={{ width: 352, maxHeight: 435 }}>
{/* Tab bar */}
{showTabs && (
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1">
{availableTabs.map((t) => (
<button
key={t.key}
onClick={() => onTabChange(t.key)}
className={`px-3 py-1 rounded-md text-[13px] font-medium transition-colors ${
activeTab === t.key
? 'bg-interactive-selected text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`}
>
{t.label}
</button>
))}
</div>
)}
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'emoji' && (
<EmojiPicker onEmojiSelect={onEmojiSelect} />
)}
{activeTab === 'gif' && gifEnabled && (
<GifPicker onGifSelect={onGifSelect} />
)}
{activeTab === 'stickers' && stickersEnabled && (
<StickerPicker onStickerSelect={onStickerSelect} />
)}
</div>
</div>
</div>,
document.body,
);
}
+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
@@ -4,8 +4,10 @@ import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '.
import { wsSend } from '../../hooks/useWebSocket';
import { MentionPopover } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator';
import { InputPopover, type InputPopoverTab } from './InputPopover';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
import { MAX_MESSAGE_LENGTH, type MemberWithUser, type Sticker } from '@backspace/shared';
import { useSettingsStore } from '../../stores/settingsStore';
interface MessageInputProps {
channelId: string;
@@ -23,15 +25,23 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const [files, setFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const [activePopover, setActivePopover] = useState<InputPopoverTab | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputContainerRef = useRef<HTMLDivElement>(null);
const popoverAnchorRef = useRef<HTMLDivElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const sendStickerMessage = useChatStore((s) => s.sendStickerMessage);
const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo);
const members = useSpaceStore((s) => s.members);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Feature flags
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
const spaces = useSpaceStore((s) => s.spaces);
const stickersEnabled = spaces.length > 0; // stickers available if user is in any space
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
const isDm = isDmChannel(channelId);
@@ -50,6 +60,11 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
}
}, [replyTo]);
// Close popover on channel change
useEffect(() => {
setActivePopover(null);
}, [channelId]);
// Filter members for the mention popover
const filteredMembers = useMemo(() => {
if (!mentionState) return [];
@@ -86,6 +101,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
setIsUploading(true);
setMentionState(null);
setActivePopover(null);
try {
// Upload files first — route to the correct instance for this channel
const attachmentIds: string[] = [];
@@ -241,6 +257,44 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
const handleEmojiSelect = useCallback((emoji: { native: string }) => {
const textarea = textareaRef.current;
if (!textarea) {
setContent((prev) => prev + emoji.native);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = content.slice(0, start);
const after = content.slice(end);
const newContent = before + emoji.native + after;
setContent(newContent);
// Restore cursor position after the emoji
const newCursorPos = start + emoji.native.length;
requestAnimationFrame(() => {
textarea.focus();
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
});
}, [content]);
const handleGifSelect = useCallback((url: string) => {
setActivePopover(null);
sendMessage(channelId, url);
}, [channelId, sendMessage]);
const handleStickerSelect = useCallback((sticker: Sticker) => {
setActivePopover(null);
sendStickerMessage(channelId, sticker.id);
}, [channelId, sendStickerMessage]);
const togglePopover = useCallback((tab: InputPopoverTab) => {
setActivePopover((prev) => prev === tab ? null : tab);
}, []);
const canSend = (content.trim() || files.length > 0) && !isOverLimit && !isUploading;
if (!canSendMessages) {
return (
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
@@ -252,8 +306,24 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
}
return (
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
<div ref={popoverAnchorRef} data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
<TypingIndicator channelId={channelId} />
{/* Input popover (emoji / gif / stickers) */}
{activePopover && (
<InputPopover
activeTab={activePopover}
onClose={() => setActivePopover(null)}
onEmojiSelect={handleEmojiSelect}
onGifSelect={handleGifSelect}
onStickerSelect={handleStickerSelect}
anchorRef={popoverAnchorRef}
gifEnabled={gifEnabled}
stickersEnabled={stickersEnabled}
onTabChange={setActivePopover}
/>
)}
{replyTo && (
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
@@ -376,25 +446,60 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
)}
{/* GIF button */}
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="GIF">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
</svg>
</button>
{gifEnabled && (
<button
onClick={() => togglePopover('gif')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="GIF"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
</svg>
</button>
)}
{/* Sticker button */}
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="Stickers">
<button
onClick={() => togglePopover('stickers')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'stickers' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Stickers"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.5 2C6.81 2 2 6.81 2 12.5S6.81 23 12.5 23c1.31 0 2.56-.25 3.73-.7l5.07-5.07c.45-1.17.7-2.42.7-3.73C22 7.81 17.19 2 12.5 2Zm0 19c-4.69 0-8.5-3.81-8.5-8.5S7.81 4 12.5 4 21 7.81 21 12.5c0 .89-.14 1.74-.4 2.54l-3.56 3.56c-.8.26-1.65.4-2.54.4ZM8 11.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10s.67 1.5 1.5 1.5Zm6 0c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5-1.5.67-1.5 1.5.67 1.5 1.5 1.5Zm-1 3.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5Z" />
</svg>
</button>
{/* Emoji button */}
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="Emoji">
<button
onClick={() => togglePopover('emoji')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Emoji"
>
<svg width="18" height="18" 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 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
</button>
{/* Send button — appears when there's content to send */}
{canSend && (
<button
onClick={handleSubmit}
disabled={isUploading}
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
aria-label="Send message"
title="Send"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
</svg>
</button>
)}
</div>
</div>
</div>
@@ -0,0 +1,140 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { api } from '../../api/client';
import type { Sticker, StickerPack } from '@backspace/shared';
interface StickerPickerProps {
onStickerSelect: (sticker: Sticker) => void;
}
interface StickerCache {
packs: StickerPack[];
fetchedAt: number;
}
let stickerCache: StickerCache | null = null;
const CACHE_TTL = 60_000; // 60s
export function StickerPicker({ onStickerSelect }: StickerPickerProps) {
const [packs, setPacks] = useState<StickerPack[]>(stickerCache?.packs ?? []);
const [loading, setLoading] = useState(!stickerCache || Date.now() - stickerCache.fetchedAt > CACHE_TTL);
const [query, setQuery] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (stickerCache && Date.now() - stickerCache.fetchedAt <= CACHE_TTL) {
setPacks(stickerCache.packs);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
api.stickers.myStickers()
.then((data) => {
if (cancelled) return;
stickerCache = { packs: data.packs, fetchedAt: Date.now() };
setPacks(data.packs);
setLoading(false);
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const filteredPacks = query.trim()
? packs
.map((pack) => ({
...pack,
stickers: pack.stickers.filter(
(s) =>
s.name.toLowerCase().includes(query.toLowerCase()) ||
s.tags.toLowerCase().includes(query.toLowerCase()),
),
}))
.filter((pack) => pack.stickers.length > 0)
: packs;
const totalStickers = packs.reduce((sum, p) => sum + p.stickers.length, 0);
// Prevent keyboard events from bubbling
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
};
const getStickerUrl = useCallback((sticker: Sticker) => {
const filename = sticker.filename;
if (filename.startsWith('http') || filename.startsWith('/')) return filename;
return `/api/uploads/${filename}`;
}, []);
return (
<div className="flex flex-col h-[390px]" onKeyDown={handleKeyDown}>
{/* Search */}
<div className="px-3 pt-2 pb-1.5">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search stickers"
className="input-search w-full"
autoFocus
/>
</div>
{/* Results */}
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
{loading ? (
<div className="grid grid-cols-4 gap-2 p-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="aspect-square bg-surface-elevated rounded-lg animate-pulse" />
))}
</div>
) : totalStickers === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="text-txt-tertiary text-sm mb-1">No stickers available</div>
<div className="text-txt-tertiary text-xs">
Space admins can add sticker packs in Space Settings.
</div>
</div>
) : filteredPacks.length === 0 ? (
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
No stickers matching "{query}"
</div>
) : (
<div className="space-y-3">
{filteredPacks.map((pack) => (
<div key={pack.id}>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider px-1 mb-1.5">
{pack.name}
</div>
<div className="grid grid-cols-4 gap-1.5">
{pack.stickers.map((sticker) => (
<button
key={sticker.id}
onClick={() => onStickerSelect(sticker)}
className="aspect-square rounded-lg overflow-hidden hover:bg-interactive-hover transition-colors p-1.5 group"
title={sticker.name}
>
<img
src={getStickerUrl(sticker)}
alt={sticker.name}
className="w-full h-full object-contain group-hover:scale-110 transition-transform"
loading="lazy"
/>
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
/** Invalidate the sticker cache (called when WS events indicate sticker changes) */
export function invalidateStickerCache(): void {
stickerCache = null;
}
@@ -10,6 +10,7 @@ import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
import { BansPanel } from './spaceSettingsPanels/BansPanel';
import { StickersPanel } from './spaceSettingsPanels/StickersPanel';
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
@@ -270,7 +271,7 @@ export function SpaceSettingsModal() {
const spaces = useSpaceStore((s) => s.spaces);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
const [tab, setTab] = useState<'overview' | 'discovery' | 'stickers' | 'members' | 'roles' | 'bans'>('overview');
const isOpen = activeModal === 'spaceSettings';
const space = spaces.find(s => s.id === currentSpaceId);
@@ -300,6 +301,11 @@ export function SpaceSettingsModal() {
Discovery
</button>
)}
{canManageSpace && (
<button onClick={() => setTab('stickers')} className={tabClass('stickers')}>
Stickers
</button>
)}
<button onClick={() => setTab('members')} className={tabClass('members')}>
Members
</button>
@@ -320,6 +326,7 @@ export function SpaceSettingsModal() {
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
{tab === 'stickers' && canManageSpace && <StickersPanel spaceId={currentSpaceId} />}
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
@@ -11,21 +11,44 @@ export function GeneralPanel() {
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
const [gifKeyDirty, setGifKeyDirty] = useState(false);
const [gifKeyDraft, setGifKeyDraft] = useState('');
useEffect(() => {
if (instanceSettings) setDraft({ ...instanceSettings });
if (instanceSettings) {
setDraft({ ...instanceSettings });
// Don't populate the input with the masked value — show empty field
setGifKeyDraft('');
setGifKeyDirty(false);
}
}, [instanceSettings]);
if (!draft) return <div className="text-sm text-txt-tertiary">Loading settings...</div>;
const hasChanges = JSON.stringify(draft) !== JSON.stringify(instanceSettings);
const baseChanges = instanceSettings && draft
? draft.instanceName !== instanceSettings.instanceName ||
draft.registrationOpen !== instanceSettings.registrationOpen ||
draft.discoveryEnabled !== instanceSettings.discoveryEnabled
: false;
const hasChanges = baseChanges || gifKeyDirty;
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
await updateInstanceSettings(draft);
const payload: Partial<InstanceAdminSettings> = {
instanceName: draft!.instanceName,
registrationOpen: draft!.registrationOpen,
discoveryEnabled: draft!.discoveryEnabled,
};
// Only include gifApiKey when the user actually modified it
if (gifKeyDirty) {
payload.gifApiKey = gifKeyDraft;
}
await updateInstanceSettings(payload);
setGifKeyDirty(false);
setGifKeyDraft('');
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
@@ -37,6 +60,8 @@ export function GeneralPanel() {
const handleReset = () => {
if (instanceSettings) setDraft({ ...instanceSettings });
setGifKeyDirty(false);
setGifKeyDraft('');
setSaveError('');
};
@@ -90,6 +115,39 @@ export function GeneralPanel() {
</div>
</div>
{/* GIF Search */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">GIF Search</div>
<p className="text-xs text-txt-tertiary mb-2">
Enable GIF search powered by Klipy. Get a free API key from the Klipy developer portal.
</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-2">
<input
type="password"
value={gifKeyDirty ? gifKeyDraft : ''}
onChange={(e) => { setGifKeyDraft(e.target.value); setGifKeyDirty(true); }}
placeholder={draft.gifEnabled ? 'Key saved — enter new key to replace' : 'Klipy API key'}
className="input-standard w-full"
autoComplete="off"
/>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 text-[11px] font-medium px-1.5 py-0.5 rounded ${
draft.gifEnabled ? 'bg-status-online/15 text-status-online' : 'bg-white/5 text-txt-tertiary'
}`}>
{draft.gifEnabled ? 'Enabled' : 'Not configured'}
</span>
{draft.gifEnabled && !gifKeyDirty && (
<button
onClick={() => { setGifKeyDraft(''); setGifKeyDirty(true); }}
className="text-[11px] text-txt-tertiary hover:text-txt-danger transition-colors"
>
Clear key
</button>
)}
</div>
</div>
</div>
{/* Status messages */}
{saveError && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
@@ -0,0 +1,301 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../../../api/client';
import { ConfirmDialog } from '../../ui/ConfirmDialog';
import type { StickerPack, Sticker } from '@backspace/shared';
interface StickersPanelProps {
spaceId: string;
}
export function StickersPanel({ spaceId }: StickersPanelProps) {
const [packs, setPacks] = useState<StickerPack[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Create pack state
const [newPackName, setNewPackName] = useState('');
const [newPackDesc, setNewPackDesc] = useState('');
const [creating, setCreating] = useState(false);
// Upload sticker state
const [uploadPackId, setUploadPackId] = useState<string | null>(null);
const [stickerName, setStickerName] = useState('');
const [stickerTags, setStickerTags] = useState('');
const [stickerFile, setStickerFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Delete confirmation
const [deleteTarget, setDeleteTarget] = useState<{ type: 'pack' | 'sticker'; id: string; name: string } | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchPacks = async () => {
try {
const { packs: data } = await api.stickers.getPacks(spaceId);
setPacks(data);
setLoading(false);
} catch {
setError('Failed to load sticker packs');
setLoading(false);
}
};
useEffect(() => {
fetchPacks();
}, [spaceId]);
const handleCreatePack = async () => {
if (!newPackName.trim()) return;
setCreating(true);
setError('');
try {
const pack = await api.stickers.createPack(spaceId, {
name: newPackName.trim(),
description: newPackDesc.trim() || undefined,
});
setPacks((prev) => [...prev, { ...pack, stickers: [] }]);
setNewPackName('');
setNewPackDesc('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create pack');
} finally {
setCreating(false);
}
};
const handleUploadSticker = async () => {
if (!uploadPackId || !stickerFile || !stickerName.trim()) return;
setUploading(true);
setError('');
try {
const sticker = await api.stickers.uploadSticker(
spaceId,
uploadPackId,
stickerFile,
stickerName.trim(),
stickerTags.trim(),
);
setPacks((prev) =>
prev.map((p) =>
p.id === uploadPackId
? { ...p, stickers: [...p.stickers, sticker] }
: p,
),
);
setStickerName('');
setStickerTags('');
setStickerFile(null);
setUploadPackId(null);
if (fileInputRef.current) fileInputRef.current.value = '';
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload sticker');
} finally {
setUploading(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
setError('');
try {
if (deleteTarget.type === 'pack') {
await api.stickers.deletePack(spaceId, deleteTarget.id);
setPacks((prev) => prev.filter((p) => p.id !== deleteTarget.id));
} else {
await api.stickers.deleteSticker(deleteTarget.id);
setPacks((prev) =>
prev.map((p) => ({
...p,
stickers: p.stickers.filter((s) => s.id !== deleteTarget.id),
})),
);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
} finally {
setDeleting(false);
setDeleteTarget(null);
}
};
const getStickerUrl = (sticker: Sticker) => {
if (sticker.filename.startsWith('http') || sticker.filename.startsWith('/'))
return sticker.filename;
return `/api/uploads/${sticker.filename}`;
};
if (loading) {
return <div className="text-sm text-txt-tertiary">Loading sticker packs...</div>;
}
return (
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
Manage sticker packs for this space. Members can use these stickers in messages.
</div>
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
</div>
)}
{/* Create Pack */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Create Sticker Pack
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-2">
<input
type="text"
value={newPackName}
onChange={(e) => setNewPackName(e.target.value.slice(0, 32))}
placeholder="Pack name"
className="input-standard w-full"
/>
<input
type="text"
value={newPackDesc}
onChange={(e) => setNewPackDesc(e.target.value.slice(0, 100))}
placeholder="Description (optional)"
className="input-standard w-full"
/>
<button
onClick={handleCreatePack}
disabled={creating || !newPackName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Pack'}
</button>
</div>
</div>
{/* Existing Packs */}
{packs.length === 0 ? (
<div className="text-sm text-txt-tertiary">No sticker packs yet.</div>
) : (
<div className="space-y-4">
{packs.map((pack) => (
<div key={pack.id} className="rounded-lg bg-white/[0.02] p-3.5">
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-sm font-medium text-txt-primary">{pack.name}</div>
{pack.description && (
<div className="text-xs text-txt-tertiary">{pack.description}</div>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setUploadPackId(uploadPackId === pack.id ? null : pack.id)}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-interactive-hover hover:bg-interactive-active rounded transition-colors"
>
{uploadPackId === pack.id ? 'Cancel' : 'Add Sticker'}
</button>
<button
onClick={() => setDeleteTarget({ type: 'pack', id: pack.id, name: pack.name })}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/20 rounded transition-colors"
>
Delete Pack
</button>
</div>
</div>
{/* Upload form for this pack */}
{uploadPackId === pack.id && (
<div className="border-t border-white/[0.06] pt-2 mt-2 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={stickerName}
onChange={(e) => setStickerName(e.target.value.slice(0, 32))}
placeholder="Sticker name"
className="input-standard flex-1"
/>
<input
type="text"
value={stickerTags}
onChange={(e) => setStickerTags(e.target.value.slice(0, 100))}
placeholder="Tags (optional)"
className="input-standard flex-1"
/>
</div>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/webp,image/gif"
onChange={(e) => setStickerFile(e.target.files?.[0] ?? null)}
className="text-sm text-txt-secondary file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-interactive-hover file:text-txt-primary hover:file:bg-interactive-active"
/>
<button
onClick={handleUploadSticker}
disabled={uploading || !stickerFile || !stickerName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-xs font-medium rounded transition-colors disabled:opacity-50 flex-shrink-0"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
<div className="text-[10px] text-txt-tertiary">
PNG, WebP, or GIF. Max 512x512px, 500KB.
</div>
</div>
)}
{/* Sticker grid */}
{pack.stickers.length > 0 && (
<div className="grid grid-cols-5 gap-2 mt-2">
{pack.stickers.map((sticker) => (
<div
key={sticker.id}
className="relative group aspect-square rounded-lg bg-surface-base overflow-hidden"
>
<img
src={getStickerUrl(sticker)}
alt={sticker.name}
className="w-full h-full object-contain p-1"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => setDeleteTarget({ type: 'sticker', id: sticker.id, name: sticker.name })}
className="p-1 text-white hover:text-txt-danger transition-colors"
title="Delete sticker"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 text-[9px] text-white truncate opacity-0 group-hover:opacity-100 transition-opacity">
{sticker.name}
</div>
</div>
))}
</div>
)}
{pack.stickers.length === 0 && (
<div className="text-xs text-txt-tertiary mt-1">No stickers in this pack yet.</div>
)}
</div>
))}
</div>
)}
{/* Delete confirmation */}
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title={`Delete ${deleteTarget?.type === 'pack' ? 'Sticker Pack' : 'Sticker'}`}
description={`Are you sure you want to delete "${deleteTarget?.name}"?${
deleteTarget?.type === 'pack' ? ' All stickers in this pack will be deleted.' : ''
} Existing messages will show "Sticker unavailable".`}
confirmLabel={deleting ? 'Deleting...' : 'Delete'}
onConfirm={handleDelete}
variant="danger"
loading={deleting}
/>
</div>
);
}