diff --git a/dumpster b/dumpster index f0acb8b..f725d25 100755 Binary files a/dumpster and b/dumpster differ diff --git a/internal/db/db.go b/internal/db/db.go index b23f228..ef58a31 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -277,6 +277,15 @@ CREATE TABLE IF NOT EXISTS conversation_messages ( CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id); CREATE INDEX IF NOT EXISTS idx_conversation_messages_conv_created ON conversation_messages(conversation_id, created_at DESC); +CREATE TABLE IF NOT EXISTS conversation_reactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES conversation_messages(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(message_id, user_id, emoji) +); + -- Moderation CREATE TABLE IF NOT EXISTS bans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/internal/dm/handlers.go b/internal/dm/handlers.go index 610eded..6e172d0 100644 --- a/internal/dm/handlers.go +++ b/internal/dm/handlers.go @@ -5,10 +5,12 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "log/slog" "net/http" "sort" "strconv" + "strings" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" @@ -37,6 +39,10 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/", h.Get) r.Get("/messages", h.ListMessages) r.Post("/messages", h.SendMessage) + r.Route("/messages/{messageID}/reactions", func(r chi.Router) { + r.Post("/", h.AddReaction) + r.Delete("/{emoji}", h.RemoveReaction) + }) }) } @@ -275,8 +281,9 @@ type messageResponse struct { AuthorName string `json:"author_username"` DisplayName *string `json:"author_display_name"` Content string `json:"content"` - EditedAt *string `json:"edited_at"` - CreatedAt string `json:"created_at"` + EditedAt *string `json:"edited_at"` + CreatedAt string `json:"created_at"` + Reactions []emojiGroup `json:"reactions"` } // ListMessages lists messages in a conversation. @@ -309,7 +316,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { FROM conversation_messages m JOIN users u ON u.id = m.author_id WHERE m.conversation_id = $1 AND m.created_at < (SELECT created_at FROM conversation_messages WHERE id = $2) - ORDER BY m.created_at ASC + ORDER BY m.created_at DESC LIMIT $3 `, convID, before, limit) } else { @@ -318,7 +325,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { FROM conversation_messages m JOIN users u ON u.id = m.author_id WHERE m.conversation_id = $1 - ORDER BY m.created_at ASC + ORDER BY m.created_at DESC LIMIT $2 `, convID, limit) } @@ -347,6 +354,13 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { messages = append(messages, msg) } + messages = h.attachReactions(r.Context(), messages) + + // Reverse: query returns DESC (newest first), client expects ASC (oldest first). + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) } @@ -403,6 +417,8 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { msg.DisplayName = &displayName.String } + msg = h.attachReactions(r.Context(), []messageResponse{msg})[0] + if h.hub != nil { h.hub.BroadcastToConversation(convID, gateway.Event{ Type: gateway.EventMessageCreate, @@ -457,3 +473,185 @@ func (h *Handler) MemberIDs(ctx context.Context, convID string) ([]string, error } return ids, nil } + +type emojiGroup struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Users []string `json:"users"` + Details []reactionResponse `json:"details"` +} + +func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse { + if len(messages) == 0 { + return messages + } + + msgIDs := make([]string, len(messages)) + msgMap := make(map[string]int) + for i, msg := range messages { + msgIDs[i] = msg.ID + msgMap[msg.ID] = i + messages[i].Reactions = make([]emojiGroup, 0) + } + + placeholders := make([]string, len(msgIDs)) + args := make([]interface{}, len(msgIDs)) + for i, id := range msgIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + + query := fmt.Sprintf(` + SELECT id, message_id, user_id, emoji, created_at::text + FROM conversation_reactions + WHERE message_id IN (%s) + ORDER BY created_at ASC + `, strings.Join(placeholders, ", ")) + + rows, err := h.db.QueryContext(ctx, query, args...) + if err != nil { + return messages + } + defer rows.Close() + + reactionsMap := make(map[string]map[string]*emojiGroup) + emojiOrderMap := make(map[string][]string) + + for rows.Next() { + var reaction reactionResponse + var createdAt sql.NullString + if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil { + continue + } + reaction.CreatedAt = createdAt.String + + msgID := reaction.MessageID + if _, ok := reactionsMap[msgID]; !ok { + reactionsMap[msgID] = make(map[string]*emojiGroup) + emojiOrderMap[msgID] = make([]string, 0) + } + + group, ok := reactionsMap[msgID][reaction.Emoji] + if !ok { + group = &emojiGroup{ + Emoji: reaction.Emoji, + Users: []string{}, + Details: []reactionResponse{}, + } + reactionsMap[msgID][reaction.Emoji] = group + emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji) + } + group.Count++ + group.Users = append(group.Users, reaction.UserID) + group.Details = append(group.Details, reaction) + } + + for msgID, emojisMap := range reactionsMap { + idx, ok := msgMap[msgID] + if !ok { + continue + } + order := emojiOrderMap[msgID] + for _, emoji := range order { + messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji]) + } + } + + return messages +} + +type reactionResponse struct { + ID string `json:"id"` + MessageID string `json:"message_id"` + ConversationID string `json:"conversation_id"` + UserID string `json:"user_id"` + Emoji string `json:"emoji"` + CreatedAt string `json:"created_at"` +} + +// @Summary Add a reaction to a DM +// @Router /conversations/{conversationID}/messages/{messageID}/reactions [post] +func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) { + convID := chi.URLParam(r, "conversationID") + messageID := chi.URLParam(r, "messageID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok || !h.isMember(r.Context(), convID, userID) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req struct { + Emoji string `json:"emoji"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Emoji == "" { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + var reaction reactionResponse + reaction.ConversationID = convID + var createdAt sql.NullString + err := h.db.QueryRowContext(r.Context(), ` + INSERT INTO conversation_reactions (message_id, user_id, emoji) + VALUES ($1, $2, $3) + ON CONFLICT (message_id, user_id, emoji) DO UPDATE SET emoji = EXCLUDED.emoji + RETURNING id, message_id, user_id, emoji, created_at::text + `, messageID, userID, req.Emoji).Scan( + &reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt, + ) + if err != nil { + h.logger.Error("failed to add dm reaction", "error", err) + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + reaction.CreatedAt = createdAt.String + + h.hub.BroadcastToConversation(convID, gateway.Event{ + Type: gateway.EventReactionAdd, + Data: map[string]interface{}{ + "reaction": reaction, + "conversation_id": convID, + "message_id": messageID, + }, + }) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(reaction) +} + +// @Summary Remove a reaction from a DM +// @Router /conversations/{conversationID}/messages/{messageID}/reactions/{emoji} [delete] +func (h *Handler) RemoveReaction(w http.ResponseWriter, r *http.Request) { + convID := chi.URLParam(r, "conversationID") + messageID := chi.URLParam(r, "messageID") + emoji := chi.URLParam(r, "emoji") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok || !h.isMember(r.Context(), convID, userID) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + result, err := h.db.ExecContext(r.Context(), ` + DELETE FROM conversation_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3 + `, messageID, userID, emoji) + if err != nil { + h.logger.Error("failed to remove dm reaction", "error", err) + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected > 0 { + h.hub.BroadcastToConversation(convID, gateway.Event{ + Type: gateway.EventReactionRemove, + Data: map[string]interface{}{ + "conversation_id": convID, + "message_id": messageID, + "user_id": userID, + "emoji": emoji, + }, + }) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/web/package-lock.json b/web/package-lock.json index 1f32593..a52901c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,9 @@ "name": "dumpster-web", "version": "0.0.0", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.3.0", + "@fortawesome/free-solid-svg-icons": "^7.3.0", + "@fortawesome/react-fontawesome": "^3.3.1", "@livekit/components-react": "^2.9.21", "@livekit/track-processors": "^0.7.2", "emoji-picker-react": "^4.19.1", @@ -748,6 +751,52 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.0.tgz", + "integrity": "sha512-X/vND0Y1l9fVJ9O79UgtZnXSpz4aNF3bXlDxiJAEAm6kgeSftp9wjjBPgqzazJV8YlmxfRoeXNfSCJ48sf/Hhw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.0.tgz", + "integrity": "sha512-MFbTNLDWkLJwbozDvHOZ7hwyDjQcBMBattlcOQ6ZmV5YD9bBrqdl1rNtmVjQ/lzqveXXX3sMz2Ew6fAgXoxmkw==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.0.tgz", + "integrity": "sha512-YxI/CuwWeI3nPIoYU//vkDS+3ige/67DPZ6XwMATpYEFESzO9L8zfJOKllGRgIlpT/uebrZCcvAzp3peD7GmTw==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.3.1.tgz", + "integrity": "sha512-wGnAPhfzivDwBWYmEG8MSrEXPruoiMMo48NnsRkj1NZkoaawgOijPNAiSHKMYEoCsqTBSgLTzL6EqTTWGaUR4w==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~6 || ~7", + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/web/package.json b/web/package.json index db7abd7..8b7d735 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.3.0", + "@fortawesome/free-solid-svg-icons": "^7.3.0", + "@fortawesome/react-fontawesome": "^3.3.1", "@livekit/components-react": "^2.9.21", "@livekit/track-processors": "^0.7.2", "emoji-picker-react": "^4.19.1", diff --git a/web/src/App.tsx b/web/src/App.tsx index 2f994c6..5a1da80 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import { DMChat } from './components/DMChat.tsx'; import { ForgotPasswordPage } from './components/ForgotPasswordPage.tsx'; import { ResetPasswordPage } from './components/ResetPasswordPage.tsx'; import { useAuthStore } from './stores/auth.ts'; +import { useWebSocketStore } from './stores/ws.ts'; import { InstallBanner } from './components/InstallBanner.tsx'; import { ConnectionStatus } from './components/ConnectionStatus.tsx'; @@ -22,12 +23,20 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { function App() { const fetchMe = useAuthStore((state) => state.fetchMe); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const [init, setInit] = useState(false); useEffect(() => { fetchMe().finally(() => setInit(true)); }, [fetchMe]); + // Connect WebSocket for real-time messages once authenticated + useEffect(() => { + if (isAuthenticated) { + useWebSocketStore.getState().connect(); + } + }, [isAuthenticated]); + if (!init) { return
Loading...
; } diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index e0f5cb3..fa175b0 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -7,7 +7,9 @@ import { useTypingStore } from "../stores/typing.ts"; import { useAuthStore } from "../stores/auth.ts"; import { useMemberStore } from "../stores/member.ts"; import { useThreadStore } from "../stores/thread.ts"; -import { GiphyPicker, type Gif } from "./GiphyPicker"; +import { type Gif } from "./GiphyPicker"; +import { EmojiPicker as KaomojiPicker } from "./EmojiPicker"; +import Picker, { Theme } from 'emoji-picker-react'; import { CommandDropdown } from "./CommandDropdown"; import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands"; import { PollDisplay, CreatePollModal } from "./Poll.tsx"; @@ -20,10 +22,9 @@ import { useContextMenu } from "./ContextMenu.tsx"; import { PinnedMessages } from "./PinnedMessages.tsx"; import { FeatureRequestsPanel } from "./FeatureRequestsPanel.tsx"; import { ReactionBar } from "./ReactionBar.tsx"; -import { EmojiPicker as KaomojiPicker } from "./EmojiPicker.tsx"; -import Picker, { Theme } from 'emoji-picker-react'; -import { FormatToolbar } from "./FormatToolbar.tsx"; +import { MessageInput } from "./MessageInput.tsx"; import { ReplyBar } from "./ReplyBar.tsx"; +import { ExpandableImage } from "./ExpandableImage.tsx"; import { useLayoutStore } from "../stores/layout.ts"; import { ForumView } from "./ForumView.tsx"; import { CalendarView } from "./CalendarView.tsx"; @@ -96,19 +97,13 @@ function renderContent(content: string, memberUsernames: Set) { th: ({ ...props }) => , td: ({ ...props }) => , p: ({ ...props }) => , - img: ({ src, ...props }) => { - const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/") - ? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}` - : src; - return ( - - ); - }, + img: ({ src, alt, ...props }) => ( + + ), }} > {trimmed} @@ -230,7 +225,6 @@ const MessageItem = memo(({ {/* Message reactions */} { const usernames = r.users.map((uid: string) => { const m = members.find((member: any) => member.id === uid); @@ -243,7 +237,13 @@ const MessageItem = memo(({ reacted: r.users.includes(currentUserId), }; })} - onRefresh={() => {}} + onToggle={async (emoji, isReacted) => { + if (isReacted) { + await api.delete(`/messages/${message.id}/reactions/${encodeURIComponent(emoji)}`); + } else { + await api.post(`/messages/${message.id}/reactions`, { emoji }); + } + }} /> {/* Kaomoji picker popover */} @@ -288,9 +288,6 @@ export function ChatArea() { const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []); const [input, setInput] = useState(""); const [error, setError] = useState(null); - const [showGifPicker, setShowGifPicker] = useState(false); - const [showKaomoji, setShowKaomoji] = useState(false); - const [showNativeEmoji, setShowNativeEmoji] = useState(false); const [showSearch, setShowSearch] = useState(false); const [mentionQuery, setMentionQuery] = useState(null); const [commandQuery, setCommandQuery] = useState(null); @@ -307,7 +304,7 @@ export function ChatArea() { const [profileUserId, setProfileUserId] = useState(null); const bottomRef = useRef(null); const scrollContainerRef = useRef(null); - const inputRef = useRef(null); + const inputRef = useRef(null); const lastTypingRef = useRef(0); const dropdownRef = useRef({ mentionQuery: null as string | null, commandQuery: null as string | null, dropdownIndex: 0 }); // ponytail: keep ref in sync with state so the stable keydown listener reads current values @@ -354,8 +351,6 @@ export function ChatArea() { const handleAddReaction = useCallback(async (messageId: string, emoji: string) => { try { await api.post(`/messages/${messageId}/reactions`, { emoji }); - setActiveReactionMessageId(null); - setActiveNativeReactionMessageId(null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to add reaction"); } @@ -464,7 +459,7 @@ export function ChatArea() { return () => clearInterval(t); }, [slowmodeRemaining]); - const handlePaste = async (e: React.ClipboardEvent) => { + const handlePaste = async (e: React.ClipboardEvent) => { const items = e.clipboardData.items; let imageFile: File | null = null; @@ -516,41 +511,6 @@ export function ChatArea() { } }; - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - const cursor = e.target.selectionStart ?? value.length; - setInput(value); - - // ponytail: throttle typing indicator to once per 3s - if (activeChannelId && value.length > 0) { - const now = Date.now(); - if (now - lastTypingRef.current > 3000) { - sendTypingStart(activeChannelId); - lastTypingRef.current = now; - } - } - - const beforeCursor = value.slice(0, cursor); - // Slash command detection (only at start of input) - if (beforeCursor.startsWith('/') && !beforeCursor.includes(' ')) { - setCommandQuery(beforeCursor.slice(1)); - setMentionQuery(null); - return; - } - setCommandQuery(null); - const atIndex = beforeCursor.lastIndexOf("@"); - if (atIndex === -1) { - setMentionQuery(null); - return; - } - const between = beforeCursor.slice(atIndex + 1); - if (between.includes(" ") || between.includes("\n")) { - setMentionQuery(null); - return; - } - setMentionQuery(between); - }; - const handleMentionSelect = (username: string) => { const inputEl = inputRef.current; if (!inputEl) { @@ -578,14 +538,9 @@ export function ChatArea() { inputEl.setSelectionRange(pos, pos); }; - // ponytail: window listener matches existing pattern in codebase (CommandDropdown, modals) useEffect(() => { const handler = (e: KeyboardEvent) => { - if (e.ctrlKey && e.key === 'e') { - e.preventDefault(); - setShowKaomoji((prev) => !prev); - return; - } + if (e.ctrlKey && e.key === 'e') return; // handled by MessageInput const { mentionQuery: mq, commandQuery: cq, dropdownIndex: di } = dropdownRef.current; const isDropdownOpen = mq !== null || cq !== null; @@ -632,6 +587,12 @@ export function ChatArea() { setInput(''); return; } + if (cmd.name === 'emoji') { + setInput(''); + setCommandQuery(null); + setDropdownIndex(0); + return; + } const inputEl = inputRef.current; const curVal = inputEl?.value || ''; const args = curVal.startsWith('/' + cmd.name + ' ') @@ -653,26 +614,27 @@ export function ChatArea() { return () => window.removeEventListener('keydown', handler); }, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]); - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - // ponytail: don't submit when a dropdown is handling keyboard input + const handleSubmit = useCallback(async () => { if (mentionQuery !== null || commandQuery !== null) return; if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return; setError(null); try { let messageText = input.trim(); - // Slash command transform if (messageText.startsWith('/')) { const spaceIdx = messageText.indexOf(' '); const cmdName = spaceIdx === -1 ? messageText.slice(1) : messageText.slice(1, spaceIdx); const args = spaceIdx === -1 ? '' : messageText.slice(spaceIdx + 1).trim(); - // /poll opens the poll creation modal if (cmdName === 'poll') { setShowPollModal(true); setInput(''); setCommandQuery(null); return; } + if (cmdName === 'emoji') { + setInput(''); + setCommandQuery(null); + return; + } const cmd = findCommand(cmdName); if (cmd) { messageText = cmd.transform(args, currentUser?.username || 'user'); @@ -695,14 +657,13 @@ export function ChatArea() { } setError(msg); } - }; + }, [mentionQuery, commandQuery, activeChannelId, input, slowmodeRemaining, sendMessage, replyToMessage, currentUser]); const handleGifSelect = async (gif: Gif) => { if (!activeChannelId) return; const content = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`; try { await sendMessage(activeChannelId, content); - setShowGifPicker(false); } catch (err) { setError(err instanceof Error ? err.message : "Failed to send"); } @@ -887,11 +848,6 @@ export function ChatArea() { ))}
- {showGifPicker && ( -
- setShowGifPicker(false)} /> -
- )}
{(() => { const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : []; @@ -924,85 +880,55 @@ export function ChatArea() { SLOWMODE: wait {slowmodeRemaining}s
)} -
- {">"} -
- 0} +
+ {mentionQuery !== null && ( + + )} + {commandQuery !== null && ( + { + setInput('/' + name + ' '); + setCommandQuery(null); + setDropdownIndex(0); + inputRef.current?.focus(); + }} /> - {mentionQuery !== null && ( - - )} - {commandQuery !== null && ( - { - setInput('/' + name + ' '); - setCommandQuery(null); - setDropdownIndex(0); - inputRef.current?.focus(); - }} - /> - )} -
- - - - - {showKaomoji && ( - setInput((prev) => prev + emoji)} - onClose={() => setShowKaomoji(false)} - /> - )} - {showNativeEmoji && ( -
- { - setInput((prev) => prev + emoji.emoji); - setShowNativeEmoji(false); - }} - /> -
- )} - + /> +
{activeThread && ( setActiveThread(null)} /> diff --git a/web/src/components/DMChat.tsx b/web/src/components/DMChat.tsx index 069ab98..9a6e47b 100644 --- a/web/src/components/DMChat.tsx +++ b/web/src/components/DMChat.tsx @@ -4,12 +4,16 @@ import { useConversationStore, type ConversationMessage } from "../stores/conver import { useAuthStore } from "../stores/auth.ts"; import { useTypingStore } from "../stores/typing.ts"; import { useLayoutStore } from "../stores/layout.ts"; -import { GiphyPicker, type Gif } from "./GiphyPicker.tsx"; +import { type Gif } from "./GiphyPicker.tsx"; import { EmojiPicker as KaomojiPicker } from "./EmojiPicker.tsx"; import Picker, { Theme } from 'emoji-picker-react'; -import { FormatToolbar } from "./FormatToolbar.tsx"; +import { MessageInput } from "./MessageInput.tsx"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { useContextMenu } from "./ContextMenu.tsx"; +import { ReactionBar } from "./ReactionBar.tsx"; +import { api } from "../lib/api.ts"; +import { ExpandableImage } from "./ExpandableImage.tsx"; function formatTime(iso: string): string { const date = new Date(iso); @@ -28,19 +32,9 @@ function renderDMContent(content: string) { pre: ({ ...props }) =>
,
         blockquote: ({ ...props }) => 
, p: ({ ...props }) => , - img: ({ src, ...props }) => { - const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/") - ? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}` - : src; - return ( - - ); - }, + img: ({ src, alt, ...props }) => ( + + ), }} > {content} @@ -48,12 +42,99 @@ function renderDMContent(content: string) { ); } -const DMMessageItem = memo(({ msg }: { msg: ConversationMessage }) => { +const DMMessageItem = memo(({ + msg, + onAddReaction, + activeReactionMessageId, + setActiveReactionMessageId, + activeNativeReactionMessageId, + setActiveNativeReactionMessageId, + currentUserId, + conversationId, +}: { + msg: ConversationMessage; + onAddReaction: (messageId: string, emoji: string) => void; + activeReactionMessageId: string | null; + setActiveReactionMessageId: (id: string | null) => void; + activeNativeReactionMessageId: string | null; + setActiveNativeReactionMessageId: (id: string | null) => void; + currentUserId?: string; + conversationId: string; +}) => { + const { showMenu, MenuPortal } = useContextMenu(); + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + const menuItems = [ + { + label: "[ADD KAOMOJI REACTION]", + onClick: () => { + setActiveReactionMessageId(msg.id); + setActiveNativeReactionMessageId(null); + }, + }, + { + label: "[ADD EMOJI REACTION]", + onClick: () => { + setActiveNativeReactionMessageId(msg.id); + setActiveReactionMessageId(null); + }, + }, + { + label: "[DELETE MESSAGE]", + onClick: () => { + if (msg.author_id === currentUserId) { + api.delete(`/conversations/${conversationId}/messages/${msg.id}`).catch(console.error); + } + }, + disabled: msg.author_id !== currentUserId, + danger: true, + }, + ]; + + showMenu(e, menuItems); + }; + return ( -
+
[{formatTime(msg.created_at)}]{" "} <{msg.author_username}>{" "} {renderDMContent(msg.content)} + + {/* Message reactions */} + ({ + ...r, + reacted: r.users.includes(currentUserId), + }))} + onToggle={async (emoji, isReacted) => { + if (isReacted) { + await api.delete(`/conversations/${conversationId}/messages/${msg.id}/reactions/${encodeURIComponent(emoji)}`); + } else { + await api.post(`/conversations/${conversationId}/messages/${msg.id}/reactions`, { emoji }); + } + }} + /> + + {/* Kaomoji picker popover */} + {activeReactionMessageId === msg.id && ( + onAddReaction(msg.id, emoji)} + onClose={() => setActiveReactionMessageId(null)} + /> + )} + {activeNativeReactionMessageId === msg.id && ( +
+ { + onAddReaction(msg.id, emoji.emoji); + setActiveNativeReactionMessageId(null); + }} + /> +
+ )} + {MenuPortal}
); }); @@ -75,17 +156,27 @@ export function DMChat() { const sendTypingStart = useTypingStore((s) => s.sendTypingStart); const [input, setInput] = useState(""); const [error, setError] = useState(null); - const [showGifPicker, setShowGifPicker] = useState(false); - const [showKaomoji, setShowKaomoji] = useState(false); - const [showNativeEmoji, setShowNativeEmoji] = useState(false); + const [activeReactionMessageId, setActiveReactionMessageId] = useState(null); + const [activeNativeReactionMessageId, setActiveNativeReactionMessageId] = useState(null); const bottomRef = useRef(null); const scrollContainerRef = useRef(null); - const inputRef = useRef(null); + const inputRef = useRef(null); const lastTypingRef = useRef(0); const id = conversationId || activeId; const conversation = conversations.find((c) => c.id === id); + const handleAddReaction = useCallback(async (messageId: string, emoji: string) => { + if (!id) return; + try { + await api.post(`/conversations/${id}/messages/${messageId}/reactions`, { emoji }); + setActiveReactionMessageId(null); + setActiveNativeReactionMessageId(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add reaction"); + } + }, [id]); + const handleScroll = useCallback(() => { const el = scrollContainerRef.current; if (!el || !id || isLoadingOlder) return; @@ -116,42 +207,28 @@ export function DMChat() { bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, [messages]); - // Ctrl+E kaomoji shortcut - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.ctrlKey && e.key === 'e') { - e.preventDefault(); - setShowKaomoji((prev) => !prev); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, []); - const handleGifSelect = async (gif: Gif) => { if (!id) return; const content = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`; try { await sendMessage(id, content); - setShowGifPicker(false); } catch (err) { setError(err instanceof Error ? err.message : "Failed to send"); } }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!id || !input.trim()) return; + const handleSubmit = useCallback(() => { + const trimmed = input.trim(); + if (!id || !trimmed) return; setError(null); - try { - await sendMessage(id, input.trim()); + sendMessage(id, trimmed).then(() => { setInput(""); - } catch (err) { + }).catch((err) => { setError(err instanceof Error ? err.message : "Failed to send"); - } - }; + }); + }, [id, input, sendMessage]); - const handlePaste = async (e: React.ClipboardEvent) => { + const handlePaste = async (e: React.ClipboardEvent) => { const items = e.clipboardData.items; let imageFile: File | null = null; @@ -231,15 +308,20 @@ export function DMChat() { {!id &&

[select a conversation]

} {id && messages.length === 0 &&

[no messages]

} {messages.map((msg: ConversationMessage) => ( - + ))}
- {showGifPicker && ( -
- setShowGifPicker(false)} /> -
- )}
{(() => { const convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : []; @@ -253,15 +335,13 @@ export function DMChat() {

ERR: {error}

)} -
- {">"} - + { - setInput(e.target.value); - if (id && e.target.value.length > 0) { + onChange={(v) => { + setInput(v); + if (id && v.length > 0) { const now = Date.now(); if (now - lastTypingRef.current > 3000) { sendTypingStart(id); @@ -269,61 +349,12 @@ export function DMChat() { } } }} + onSubmit={handleSubmit} onPaste={handlePaste} - placeholder="type a message..." - className="terminal-input w-full" + onGifSelect={handleGifSelect} disabled={!id} /> - - - - - {showKaomoji && ( - setInput((prev) => prev + emoji)} - onClose={() => setShowKaomoji(false)} - /> - )} - {showNativeEmoji && ( -
- { - setInput((prev) => prev + emoji.emoji); - setShowNativeEmoji(false); - }} - /> -
- )} - +
); } diff --git a/web/src/components/ExpandableImage.tsx b/web/src/components/ExpandableImage.tsx new file mode 100644 index 0000000..5e0ea85 --- /dev/null +++ b/web/src/components/ExpandableImage.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; + +interface ExpandableImageProps extends React.ImgHTMLAttributes { + src?: string; +} + +export function ExpandableImage({ src, alt, ...props }: ExpandableImageProps) { + const [expanded, setExpanded] = useState(false); + + const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/") + ? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}` + : src; + + return ( + {alt { + e.stopPropagation(); + setExpanded(!expanded); + }} + className={ + expanded + ? "max-w-full max-h-[65vh] object-contain rounded my-1 block cursor-zoom-out" + : "max-w-[240px] max-h-[240px] object-contain rounded my-1 block cursor-zoom-in" + } + loading="lazy" + /> + ); +} diff --git a/web/src/components/FormatToolbar.tsx b/web/src/components/FormatToolbar.tsx index d6716fe..ffd06aa 100644 --- a/web/src/components/FormatToolbar.tsx +++ b/web/src/components/FormatToolbar.tsx @@ -1,11 +1,14 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBold, faItalic, faStrikethrough, faCode, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; + interface FormatToolbarProps { - inputRef: React.RefObject; + inputRef: React.RefObject; setInput: (updater: (prev: string) => string) => void; disabled?: boolean; } // ponytail: wraps selected text in input with markdown syntax -function wrap(input: HTMLInputElement, prefix: string, suffix: string, set: (updater: (prev: string) => string) => void) { +function wrap(input: HTMLTextAreaElement, prefix: string, suffix: string, set: (updater: (prev: string) => string) => void) { const start = input.selectionStart ?? 0; const end = input.selectionEnd ?? 0; const value = input.value; @@ -20,27 +23,27 @@ function wrap(input: HTMLInputElement, prefix: string, suffix: string, set: (upd }); } -const FORMATS: { label: string; prefix: string; suffix: string; title: string }[] = [ - { label: 'B', prefix: '**', suffix: '**', title: 'Bold (Ctrl+B)' }, - { label: 'I', prefix: '*', suffix: '*', title: 'Italic (Ctrl+I)' }, - { label: 'S', prefix: '~~', suffix: '~~', title: 'Strikethrough' }, - { label: '`', prefix: '`', suffix: '`', title: 'Code' }, - { label: '||', prefix: '||', suffix: '||', title: 'Spoiler' }, +const FORMATS: { icon: any; prefix: string; suffix: string; title: string }[] = [ + { icon: faBold, prefix: '**', suffix: '**', title: 'Bold (Ctrl+B)' }, + { icon: faItalic, prefix: '*', suffix: '*', title: 'Italic (Ctrl+I)' }, + { icon: faStrikethrough, prefix: '~~', suffix: '~~', title: 'Strikethrough' }, + { icon: faCode, prefix: '`', suffix: '`', title: 'Code' }, + { icon: faEyeSlash, prefix: '||', suffix: '||', title: 'Spoiler' }, ]; export function FormatToolbar({ inputRef, setInput, disabled }: FormatToolbarProps) { return ( - {FORMATS.map((f) => ( + {FORMATS.map((f, i) => ( ))} diff --git a/web/src/components/MessageInput.tsx b/web/src/components/MessageInput.tsx new file mode 100644 index 0000000..dde1b8b --- /dev/null +++ b/web/src/components/MessageInput.tsx @@ -0,0 +1,707 @@ +import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from "react"; +import { GiphyPicker, type Gif } from "./GiphyPicker"; +import { EmojiPicker as KaomojiPicker } from "./EmojiPicker"; +import Picker, { Theme } from "emoji-picker-react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faBold, + faItalic, + faStrikethrough, + faCode, + faEyeSlash, + faFilm, + faSmile, + faGrin, + faPaperPlane, + faPlus, + faParagraph, + faAlignLeft, + faListUl, + faListOl, + faQuoteRight, + faLink, + faHeading, +} from "@fortawesome/free-solid-svg-icons"; + +// ponytail: mirrors backend allowlist; real guard is server-side content-type detection +const SAFE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp3", ".wav", ".ogg", ".flac", ".mp4", ".webm", ".mov", ".pdf", ".txt", ".md", ".json", ".csv", ".zip", ".tar", ".gz"]); + +// ponytail: minimal round-trip converters for the formatting our toolbar produces +function htmlToMarkdown(html: string): string { + let md = html; + // code blocks:
...
+ md = md.replace(/]*>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => { + const inner = code.replace(//gi, "\n").replace(/<[^>]*>/g, ""); + return "\n```\n" + inner + "\n```\n"; + }); + // links + md = md.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)"); + // headings + md = md.replace(/]*>(.*?)<\/h1>/gi, "\n# $1\n"); + md = md.replace(/]*>(.*?)<\/h2>/gi, "\n## $1\n"); + md = md.replace(/]*>(.*?)<\/h3>/gi, "\n### $1\n"); + // blockquotes: handle nested
inside + md = md.replace(/]*>([\s\S]*?)<\/blockquote>/gi, (_, inner) => { + const lines = inner.replace(//gi, "\n").replace(/<[^>]*>/g, "").split("\n"); + return "\n" + lines.map((l: string) => "> " + l.trim()).join("\n") + "\n"; + }); + // unordered lists + md = md.replace(/]*>([\s\S]*?)<\/ul>/gi, (_, inner) => { + const items = inner.match(/]*>([\s\S]*?)<\/li>/gi) || []; + return "\n" + items.map((li: string) => "- " + li.replace(/<\/?li[^>]*>/gi, "").replace(//gi, " ").replace(/<[^>]*>/g, "").trim()).join("\n") + "\n"; + }); + // ordered lists + md = md.replace(/]*>([\s\S]*?)<\/ol>/gi, (_, inner) => { + const items = inner.match(/]*>([\s\S]*?)<\/li>/gi) || []; + return "\n" + items.map((li: string, i: number) => `${i + 1}. ` + li.replace(/<\/?li[^>]*>/gi, "").replace(//gi, " ").replace(/<[^>]*>/g, "").trim()).join("\n") + "\n"; + }); + // remaining block elements → newlines + md = md.replace(/]*>/gi, "\n").replace(/<\/div>/gi, ""); + md = md.replace(//gi, "\n"); + md = md.replace(/]*>/gi, "").replace(/<\/p>/gi, "\n"); + // inline formatting (innermost first) + md = md.replace(/]*>(.*?)<\/span>/gi, "||$1||"); + md = md.replace(/<(?:b|strong)>(.*?)<\/(?:b|strong)>/gi, "**$1**"); + md = md.replace(/<(?:i|em)>(.*?)<\/(?:i|em)>/gi, "*$1*"); + md = md.replace(/<(?:s|strike|del)>(.*?)<\/(?:s|strike|del)>/gi, "~~$1~~"); + md = md.replace(/(.*?)<\/code>/gi, "`$1`"); + // strip remaining tags + md = md.replace(/<[^>]*>/g, ""); + // decode entities + md = md.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + // collapse ≥3 consecutive newlines + md = md.replace(/\n{3,}/g, "\n\n"); + return md.trim(); +} + +function markdownToHtml(md: string): string { + // split into lines, process block structures + const lines = md.split("\n"); + const result: string[] = []; + let inCodeBlock = false; + let codeBuf: string[] = []; + let inList: "ul" | "ol" | null = null; + + const flushList = () => { + if (inList) { + result.push(``); + inList = null; + } + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // code block fences + if (/^```/.test(line.trim())) { + if (inCodeBlock) { + result.push(`
${escapeHtml(codeBuf.join("\n"))}
`); + codeBuf = []; + inCodeBlock = false; + } else { + flushList(); + inCodeBlock = true; + } + continue; + } + if (inCodeBlock) { codeBuf.push(line); continue; } + + // headings + const hMatch = line.match(/^(#{1,6})\s+(.+)/); + if (hMatch) { + flushList(); + const level = hMatch[1].length; + result.push(`${inlineMarkdownToHtml(hMatch[2])}`); + continue; + } + + // blockquote + if (/^>\s?/.test(line)) { + flushList(); + const content = line.replace(/^>\s?/, ""); + result.push(`
${inlineMarkdownToHtml(content)}
`); + continue; + } + + // unordered list + const ulMatch = line.match(/^[-*+]\s+(.+)/); + if (ulMatch) { + if (inList !== "ul") { flushList(); result.push("
    "); inList = "ul"; } + result.push(`
  • ${inlineMarkdownToHtml(ulMatch[1])}
  • `); + continue; + } + + // ordered list + const olMatch = line.match(/^\d+\.\s+(.+)/); + if (olMatch) { + if (inList !== "ol") { flushList(); result.push("
      "); inList = "ol"; } + result.push(`
    1. ${inlineMarkdownToHtml(olMatch[1])}
    2. `); + continue; + } + + // blank line: flush list + if (line.trim() === "") { + flushList(); + result.push("
      "); + continue; + } + + // regular paragraph + flushList(); + result.push(`
      ${inlineMarkdownToHtml(line)}
      `); + } + flushList(); + if (inCodeBlock) result.push(`
      ${escapeHtml(codeBuf.join("\n"))}
      `); + return result.join(""); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function inlineMarkdownToHtml(text: string): string { + let html = escapeHtml(text); + // links [text](url) + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
      $1'); + html = html.replace(/\|\|(.+?)\|\|/g, '$1'); + html = html.replace(/\*\*(.+?)\*\*/g, "$1"); + html = html.replace(/\*(.+?)\*/g, "$1"); + html = html.replace(/~~(.+?)~~/g, "$1"); + html = html.replace(/`(.+?)`/g, "$1"); + return html; +} + +type EditorMode = "md" | "rt"; + +interface MessageInputProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + onPaste?: (e: React.ClipboardEvent) => void; + onGifSelect?: (gif: Gif) => void; + placeholder?: string; + disabled?: boolean; +} + +export const MessageInput = forwardRef(function MessageInput({ + value, + onChange, + onSubmit, + onPaste, + onGifSelect, + placeholder = "Message...", + disabled = false, +}, ref) { + const textareaRef = useRef(null); + const richRef = useRef(null); + useImperativeHandle(ref, () => textareaRef.current!, []); + const [showGif, setShowGif] = useState(false); + const [showKaomoji, setShowKaomoji] = useState(false); + const [showEmoji, setShowEmoji] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [isDragOver, setIsDragOver] = useState(false); + const [mode, setMode] = useState("md"); + const fileInputRef = useRef(null); + const dragCounterRef = useRef(0); + const richSyncing = useRef(false); + + // sync rich text div when value changes externally + useEffect(() => { + if (mode === "rt" && richRef.current && !richSyncing.current) { + richRef.current.innerHTML = markdownToHtml(value); + } + }, [value, mode]); + + // --- textarea helper --- + const wrapTextarea = useCallback((prefix: string, suffix: string, defaultText = "text") => { + const ta = textareaRef.current; + if (!ta) return; + const start = ta.selectionStart ?? 0; + const end = ta.selectionEnd ?? 0; + const selected = ta.value.slice(start, end) || defaultText; + const result = ta.value.slice(0, start) + prefix + selected + suffix + ta.value.slice(end); + onChange(result); + requestAnimationFrame(() => { + ta.focus(); + ta.setSelectionRange(start + prefix.length, start + prefix.length + selected.length); + }); + }, [onChange]); + + // --- inline formatting --- + const execInline = useCallback((prefix: string, suffix: string) => { + if (mode === "rt") { + document.execCommand("styleWithCSS", false, "false"); + if (prefix === "**") document.execCommand("bold"); + else if (prefix === "*") document.execCommand("italic"); + else if (prefix === "~~") document.execCommand("strikeThrough"); + else if (prefix === "`") { + const sel = window.getSelection(); + if (sel && !sel.isCollapsed) { + const range = sel.getRangeAt(0); + const code = document.createElement("code"); + code.textContent = range.toString(); + range.deleteContents(); + range.insertNode(code); + } + } else if (prefix === "||") { + const sel = window.getSelection(); + if (sel && !sel.isCollapsed) { + const range = sel.getRangeAt(0); + const span = document.createElement("span"); + span.className = "spoiler"; + span.textContent = range.toString(); + range.deleteContents(); + range.insertNode(span); + } + } + richRef.current?.focus(); + } else { + wrapTextarea(prefix, suffix); + } + }, [mode, wrapTextarea]); + + // --- block-level formatting (rich text uses execCommand, markdown wraps) --- + const execBlock = useCallback((cmd: string) => { + if (mode === "rt") { + if (cmd === "ul") document.execCommand("insertUnorderedList"); + else if (cmd === "ol") document.execCommand("insertOrderedList"); + else if (cmd === "blockquote") { + // execCommand quote is finicky; wrap selected blocks manually + const sel = window.getSelection(); + if (sel && !sel.isCollapsed) { + document.execCommand("formatBlock", false, "
      "); + } else { + // insert empty blockquote + const bq = document.createElement("blockquote"); + bq.innerHTML = "
      "; + richRef.current?.appendChild(bq); + } + } + else if (cmd === "h1") document.execCommand("formatBlock", false, "

      "); + else if (cmd === "h2") document.execCommand("formatBlock", false, "

      "); + else if (cmd === "h3") document.execCommand("formatBlock", false, "

      "); + richRef.current?.focus(); + } else { + switch (cmd) { + case "ul": wrapTextarea("- ", ""); break; + case "ol": wrapTextarea("1. ", ""); break; + case "blockquote": { + const ta = textareaRef.current; + if (!ta) return; + const start = ta.selectionStart ?? 0; + const end = ta.selectionEnd ?? 0; + const text = ta.value.slice(start, end) || ta.value; + const lines = text.split("\n").map((l) => "> " + l).join("\n"); + onChange(lines); + requestAnimationFrame(() => ta.focus()); + break; + } + case "h1": wrapTextarea("# ", "", "Heading 1"); break; + case "h2": wrapTextarea("## ", "", "Heading 2"); break; + case "h3": wrapTextarea("### ", "", "Heading 3"); break; + } + } + }, [mode, onChange, wrapTextarea]); + + // --- link insertion --- + const execLink = useCallback(() => { + const url = prompt("Enter URL:", "https://"); + if (!url) return; + if (mode === "rt") { + const sel = window.getSelection(); + if (sel && !sel.isCollapsed) { + document.execCommand("createLink", false, url); + } else { + const a = document.createElement("a"); + a.href = url; + a.target = "_blank"; + a.rel = "noreferrer"; + a.textContent = url; + richRef.current?.appendChild(a); + } + richRef.current?.focus(); + } else { + const ta = textareaRef.current; + if (!ta) return; + const start = ta.selectionStart ?? 0; + const end = ta.selectionEnd ?? 0; + const selected = ta.value.slice(start, end) || url; + const md = `[${selected}](${url})`; + const result = ta.value.slice(0, start) + md + ta.value.slice(end); + onChange(result); + requestAnimationFrame(() => { + ta.focus(); + ta.setSelectionRange(start + md.length, start + md.length); + }); + } + }, [mode, onChange]); + + // --- insert text/emoji at cursor --- + const insert = useCallback((text: string) => { + if (mode === "rt" && richRef.current) { + richRef.current.focus(); + document.execCommand("insertText", false, text); + } else { + onChange(value + text); + textareaRef.current?.focus(); + } + }, [mode, value, onChange]); + + const insertMarkdown = useCallback((md: string) => { + if (mode === "rt" && richRef.current) { + richRef.current.focus(); + const html = markdownToHtml(md); + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + range.deleteContents(); + const frag = range.createContextualFragment(html); + range.insertNode(frag); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + } + } else { + onChange(value + (value && !value.endsWith(" ") ? " " : "") + md); + textareaRef.current?.focus(); + } + }, [mode, value, onChange]); + + // --- file upload --- + const validateFile = useCallback((file: File): string | null => { + const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase(); + if (!SAFE_EXTS.has(ext)) return `File type "${ext}" not allowed.`; + if (file.size > 25 * 1024 * 1024) return "File too large (max 25 MB)."; + return null; + }, []); + + const uploadFile = useCallback((file: File) => { + const err = validateFile(file); + if (err) { alert(err); return; } + setUploading(true); + setUploadProgress(0); + const formData = new FormData(); + formData.append("file", file); + const xhr = new XMLHttpRequest(); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100)); + }; + xhr.onload = () => { + setUploading(false); + if (xhr.status >= 200 && xhr.status < 300) { + const data = JSON.parse(xhr.responseText); + const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(file.name); + const md = isImage ? `![${file.name}](${data.url})` : `[${file.name}](${data.url})`; + insertMarkdown(md); + } else { + const msg = (() => { try { return JSON.parse(xhr.responseText).error; } catch { return "Upload failed"; } })(); + alert(msg); + } + }; + xhr.onerror = () => { setUploading(false); alert("Upload failed"); }; + xhr.open("POST", "/api/v1/upload"); + xhr.withCredentials = true; + xhr.send(formData); + }, [validateFile, insertMarkdown]); + + const handleFileInput = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) uploadFile(file); + e.target.value = ""; + }, [uploadFile]); + + // --- drag-and-drop --- + useEffect(() => { + const el = mode === "rt" ? richRef.current : textareaRef.current; + if (!el) return; + const onDragEnter = (e: Event) => { + e.preventDefault(); e.stopPropagation(); + dragCounterRef.current++; + if (dragCounterRef.current === 1) setIsDragOver(true); + }; + const onDragOver = (e: Event) => { e.preventDefault(); }; + const onDragLeave = (e: Event) => { + e.preventDefault(); e.stopPropagation(); + dragCounterRef.current--; + if (dragCounterRef.current === 0) setIsDragOver(false); + }; + const onDrop = (e: Event) => { + e.preventDefault(); e.stopPropagation(); + setIsDragOver(false); + dragCounterRef.current = 0; + const file = (e as DragEvent).dataTransfer?.files?.[0]; + if (file) uploadFile(file); + }; + el.addEventListener("dragenter", onDragEnter); + el.addEventListener("dragover", onDragOver); + el.addEventListener("dragleave", onDragLeave); + el.addEventListener("drop", onDrop); + return () => { + el.removeEventListener("dragenter", onDragEnter); + el.removeEventListener("dragover", onDragOver); + el.removeEventListener("dragleave", onDragLeave); + el.removeEventListener("drop", onDrop); + }; + }, [uploadFile, mode]); + + // --- Ctrl+E kaomoji --- + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === "e" && !disabled) { + e.preventDefault(); + setShowKaomoji((p) => !p); + setShowEmoji(false); + setShowGif(false); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [disabled]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + let finalValue = value; + if (mode === "rt" && richRef.current) { + finalValue = htmlToMarkdown(richRef.current.innerHTML); + } + if (!disabled && finalValue.trim()) { + if (mode === "rt") onChange(finalValue); + onSubmit(); + } + }, + [disabled, value, mode, onChange, onSubmit], + ); + + const handleRichInput = useCallback(() => { + if (!richRef.current) return; + richSyncing.current = true; + const md = htmlToMarkdown(richRef.current.innerHTML); + onChange(md); + requestAnimationFrame(() => { richSyncing.current = false; }); + }, [onChange]); + + const toggleMode = useCallback(() => { + setMode((prev) => { + const next: EditorMode = prev === "md" ? "rt" : "md"; + if (next === "rt") { + requestAnimationFrame(() => { + if (richRef.current) { + richRef.current.innerHTML = markdownToHtml(value || ""); + } + }); + } + return next; + }); + }, [value]); + + const toggleGif = () => { setShowGif((p) => !p); setShowKaomoji(false); setShowEmoji(false); }; + const toggleEmoji = () => { setShowEmoji((p) => !p); setShowKaomoji(false); setShowGif(false); }; + const toggleKaomoji = () => { setShowKaomoji((p) => !p); setShowEmoji(false); setShowGif(false); }; + + const handleGif = (gif: Gif) => { + if (onGifSelect) { + onGifSelect(gif); + } else { + const md = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`; + insertMarkdown(md); + } + setShowGif(false); + }; + + return ( +
      + {/* picker overlays */} + {showGif && ( +
      + setShowGif(false)} /> +
      + )} + {showKaomoji && ( + setShowKaomoji(false)} /> + )} + {showEmoji && ( +
      + { insert(e.emoji); setShowEmoji(false); }} + /> +
      + )} + +
      +
      + {mode === "md" ? ( +