fix: DM message ordering, consolidate input toolbar, add rich text/WYSIWYG, file upload with drag-drop

- Fix DM backend ListMessages to use DESC + reverse (match channel handler)
- Remove spurious .reverse() from frontend message/conversation stores
- Create shared MessageInput component with Slack-style single toolbar row
- Add file upload via + button with progress bar and drag-and-drop
- Add markdown/rich text toggle with full WYSIWYG block formatting
  (lists, blockquotes, links, headings, code blocks)
- Add frontend+backend security for file uploads (extension + content-type guards)
This commit is contained in:
2026-07-06 17:33:20 +00:00
parent 9512dedec1
commit b3b5ff495d
19 changed files with 1369 additions and 301 deletions
BIN
View File
Binary file not shown.
+9
View File
@@ -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(),
+202 -4
View File
@@ -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)
}
+49
View File
@@ -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",
+3
View File
@@ -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",
+9
View File
@@ -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 <div className="h-screen w-screen bg-gb-bg flex items-center justify-center text-gb-fg-s font-mono text-xs">Loading...</div>;
}
+81 -155
View File
@@ -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<string>) {
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
p: ({ ...props }) => <span {...props} className="inline" />,
img: ({ src, ...props }) => {
const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/")
? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}`
: src;
return (
<img
{...props}
src={finalSrc}
className="max-w-[240px] max-h-[240px] object-contain rounded my-1 block"
loading="lazy"
/>
);
},
img: ({ src, alt, ...props }) => (
<ExpandableImage
src={src}
alt={alt}
{...props}
/>
),
}}
>
{trimmed}
@@ -230,7 +225,6 @@ const MessageItem = memo(({
{/* Message reactions */}
<ReactionBar
messageId={message.id}
reactions={(message.reactions || []).map((r: any) => {
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<string | null>(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<string | null>(null);
const [commandQuery, setCommandQuery] = useState<string | null>(null);
@@ -307,7 +304,7 @@ export function ChatArea() {
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const lastTypingRef = useRef<number>(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<HTMLInputElement>) => {
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData.items;
let imageFile: File | null = null;
@@ -516,41 +511,6 @@ export function ChatArea() {
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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() {
))}
<div ref={bottomRef} />
</div>
{showGifPicker && (
<div className="px-3 pb-1">
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
</div>
)}
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic h-5 select-none">
{(() => {
const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : [];
@@ -924,85 +880,55 @@ export function ChatArea() {
SLOWMODE: wait {slowmodeRemaining}s
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<div className="flex-1 min-w-0 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
onPaste={handlePaste}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId || slowmodeRemaining > 0}
<div className="p-3 relative">
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
)}
{commandQuery !== null && (
<CommandDropdown
query={commandQuery}
selectedIndex={dropdownIndex}
onSelect={(name) => {
setInput('/' + name + ' ');
setCommandQuery(null);
setDropdownIndex(0);
inputRef.current?.focus();
}}
/>
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
)}
{commandQuery !== null && (
<CommandDropdown
query={commandQuery}
selectedIndex={dropdownIndex}
onSelect={(name) => {
setInput('/' + name + ' ');
setCommandQuery(null);
setDropdownIndex(0);
inputRef.current?.focus();
}}
/>
)}
</div>
<FormatToolbar inputRef={inputRef} setInput={setInput} disabled={!activeChannelId} />
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!activeChannelId}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle GIF picker"
>
[GIF]
</button>
<button
type="button"
onClick={() => {
setShowNativeEmoji((prev) => !prev);
setShowKaomoji(false);
setShowGifPicker(false);
)}
<MessageInput
ref={inputRef}
value={input}
onChange={(v) => {
setInput(v);
const cursor = inputRef.current?.selectionStart ?? v.length;
const beforeCursor = v.slice(0, cursor);
if (beforeCursor.startsWith('/') && !beforeCursor.includes(' ')) {
setCommandQuery(beforeCursor.slice(1));
setMentionQuery(null);
} else {
setCommandQuery(null);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1 || beforeCursor.slice(atIndex + 1).includes(" ") || beforeCursor.slice(atIndex + 1).includes("\n")) {
setMentionQuery(null);
} else {
setMentionQuery(beforeCursor.slice(atIndex + 1));
}
}
if (activeChannelId && v.length > 0) {
const now = Date.now();
if (now - lastTypingRef.current > 3000) {
sendTypingStart(activeChannelId);
lastTypingRef.current = now;
}
}
}}
onSubmit={handleSubmit}
onPaste={handlePaste}
onGifSelect={handleGifSelect}
disabled={!activeChannelId || slowmodeRemaining > 0}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle native emoji picker"
>
[E]
</button>
<button
type="button"
onClick={() => setShowKaomoji((prev) => !prev)}
disabled={!activeChannelId || slowmodeRemaining > 0}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle kaomoji picker (Ctrl+E)"
>
[]
</button>
{showKaomoji && (
<KaomojiPicker
onSelect={(emoji) => setInput((prev) => prev + emoji)}
onClose={() => setShowKaomoji(false)}
/>
)}
{showNativeEmoji && (
<div className="absolute bottom-full right-0 mb-2 z-50">
<Picker
theme={Theme.DARK}
onEmojiClick={(emoji) => {
setInput((prev) => prev + emoji.emoji);
setShowNativeEmoji(false);
}}
/>
</div>
)}
</form>
/>
</div>
</div>
{activeThread && (
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
+139 -108
View File
@@ -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 }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
p: ({ ...props }) => <span {...props} className="inline" />,
img: ({ src, ...props }) => {
const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/")
? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}`
: src;
return (
<img
{...props}
src={finalSrc}
className="max-w-[240px] max-h-[240px] object-contain rounded my-1 block"
loading="lazy"
/>
);
},
img: ({ src, alt, ...props }) => (
<ExpandableImage src={src} alt={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 (
<div className="break-words">
<div className="group relative break-words" onContextMenu={handleContextMenu}>
<span className="text-gb-fg-f">[{formatTime(msg.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
{/* Message reactions */}
<ReactionBar
reactions={(msg.reactions || []).map((r: any) => ({
...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 && (
<KaomojiPicker
onSelect={(emoji) => onAddReaction(msg.id, emoji)}
onClose={() => setActiveReactionMessageId(null)}
/>
)}
{activeNativeReactionMessageId === msg.id && (
<div className="absolute z-50">
<Picker
theme={Theme.DARK}
onEmojiClick={(emoji) => {
onAddReaction(msg.id, emoji.emoji);
setActiveNativeReactionMessageId(null);
}}
/>
</div>
)}
{MenuPortal}
</div>
);
});
@@ -75,17 +156,27 @@ export function DMChat() {
const sendTypingStart = useTypingStore((s) => s.sendTypingStart);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [showGifPicker, setShowGifPicker] = useState(false);
const [showKaomoji, setShowKaomoji] = useState(false);
const [showNativeEmoji, setShowNativeEmoji] = useState(false);
const [activeReactionMessageId, setActiveReactionMessageId] = useState<string | null>(null);
const [activeNativeReactionMessageId, setActiveNativeReactionMessageId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const lastTypingRef = useRef<number>(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<HTMLInputElement>) => {
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData.items;
let imageFile: File | null = null;
@@ -231,15 +308,20 @@ export function DMChat() {
{!id && <p className="text-gb-fg-f">[select a conversation]</p>}
{id && messages.length === 0 && <p className="text-gb-fg-f">[no messages]</p>}
{messages.map((msg: ConversationMessage) => (
<DMMessageItem key={msg.id} msg={msg} />
<DMMessageItem
key={msg.id}
msg={msg}
onAddReaction={handleAddReaction}
activeReactionMessageId={activeReactionMessageId}
setActiveReactionMessageId={setActiveReactionMessageId}
activeNativeReactionMessageId={activeNativeReactionMessageId}
setActiveNativeReactionMessageId={setActiveNativeReactionMessageId}
currentUserId={currentUser?.id}
conversationId={id || ""}
/>
))}
<div ref={bottomRef} />
</div>
{showGifPicker && (
<div className="px-3 pb-1">
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
</div>
)}
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic h-5 select-none">
{(() => {
const convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : [];
@@ -253,15 +335,13 @@ export function DMChat() {
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<input
<div className="p-3 relative">
<MessageInput
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
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}
/>
<FormatToolbar inputRef={inputRef} setInput={setInput} disabled={!id} />
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!id}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle GIF picker"
>
[GIF]
</button>
<button
type="button"
onClick={() => {
setShowNativeEmoji((prev) => !prev);
setShowKaomoji(false);
setShowGifPicker(false);
}}
disabled={!id}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle native emoji picker"
>
[E]
</button>
<button
type="button"
onClick={() => setShowKaomoji((prev) => !prev)}
disabled={!id}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle kaomoji picker (Ctrl+E)"
>
[]
</button>
{showKaomoji && (
<KaomojiPicker
onSelect={(emoji) => setInput((prev) => prev + emoji)}
onClose={() => setShowKaomoji(false)}
/>
)}
{showNativeEmoji && (
<div className="absolute bottom-full right-0 mb-2 z-50">
<Picker
theme={Theme.DARK}
onEmojiClick={(emoji) => {
setInput((prev) => prev + emoji.emoji);
setShowNativeEmoji(false);
}}
/>
</div>
)}
</form>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useState } from 'react';
interface ExpandableImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
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 (
<img
{...props}
src={finalSrc}
alt={alt || "image"}
onClick={(e) => {
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"
/>
);
}
+15 -12
View File
@@ -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<HTMLInputElement | null>;
inputRef: React.RefObject<HTMLTextAreaElement | null>;
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 (
<span className="flex items-center gap-0.5 shrink-0">
{FORMATS.map((f) => (
{FORMATS.map((f, i) => (
<button
key={f.label}
key={i}
type="button"
disabled={disabled}
onClick={() => inputRef.current && wrap(inputRef.current, f.prefix, f.suffix, setInput)}
title={f.title}
className="text-gb-fg-f hover:text-gb-orange font-mono text-xs px-1 disabled:opacity-50 disabled:cursor-not-allowed"
className="text-gb-fg-f hover:text-gb-orange px-1 py-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{f.label}
<FontAwesomeIcon icon={f.icon} className="w-3 h-3" />
</button>
))}
</span>
+707
View File
@@ -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: <pre><code>...</code></pre>
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => {
const inner = code.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, "");
return "\n```\n" + inner + "\n```\n";
});
// links
md = md.replace(/<a [^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)");
// headings
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, "\n# $1\n");
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, "\n## $1\n");
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, "\n### $1\n");
// blockquotes: handle nested <br> inside
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, inner) => {
const lines = inner.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, "").split("\n");
return "\n" + lines.map((l: string) => "> " + l.trim()).join("\n") + "\n";
});
// unordered lists
md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, inner) => {
const items = inner.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
return "\n" + items.map((li: string) => "- " + li.replace(/<\/?li[^>]*>/gi, "").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "").trim()).join("\n") + "\n";
});
// ordered lists
md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (_, inner) => {
const items = inner.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
return "\n" + items.map((li: string, i: number) => `${i + 1}. ` + li.replace(/<\/?li[^>]*>/gi, "").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "").trim()).join("\n") + "\n";
});
// remaining block elements → newlines
md = md.replace(/<div[^>]*>/gi, "\n").replace(/<\/div>/gi, "");
md = md.replace(/<br\s*\/?>/gi, "\n");
md = md.replace(/<p[^>]*>/gi, "").replace(/<\/p>/gi, "\n");
// inline formatting (innermost first)
md = md.replace(/<span class="spoiler"[^>]*>(.*?)<\/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>(.*?)<\/code>/gi, "`$1`");
// strip remaining tags
md = md.replace(/<[^>]*>/g, "");
// decode entities
md = md.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/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}>`);
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(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
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(`<h${level}>${inlineMarkdownToHtml(hMatch[2])}</h${level}>`);
continue;
}
// blockquote
if (/^>\s?/.test(line)) {
flushList();
const content = line.replace(/^>\s?/, "");
result.push(`<blockquote>${inlineMarkdownToHtml(content)}</blockquote>`);
continue;
}
// unordered list
const ulMatch = line.match(/^[-*+]\s+(.+)/);
if (ulMatch) {
if (inList !== "ul") { flushList(); result.push("<ul>"); inList = "ul"; }
result.push(`<li>${inlineMarkdownToHtml(ulMatch[1])}</li>`);
continue;
}
// ordered list
const olMatch = line.match(/^\d+\.\s+(.+)/);
if (olMatch) {
if (inList !== "ol") { flushList(); result.push("<ol>"); inList = "ol"; }
result.push(`<li>${inlineMarkdownToHtml(olMatch[1])}</li>`);
continue;
}
// blank line: flush list
if (line.trim() === "") {
flushList();
result.push("<br>");
continue;
}
// regular paragraph
flushList();
result.push(`<div>${inlineMarkdownToHtml(line)}</div>`);
}
flushList();
if (inCodeBlock) result.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
return result.join("");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function inlineMarkdownToHtml(text: string): string {
let html = escapeHtml(text);
// links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
html = html.replace(/\|\|(.+?)\|\|/g, '<span class="spoiler">$1</span>');
html = html.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
html = html.replace(/\*(.+?)\*/g, "<i>$1</i>");
html = html.replace(/~~(.+?)~~/g, "<s>$1</s>");
html = html.replace(/`(.+?)`/g, "<code>$1</code>");
return html;
}
type EditorMode = "md" | "rt";
interface MessageInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onPaste?: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
onGifSelect?: (gif: Gif) => void;
placeholder?: string;
disabled?: boolean;
}
export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(function MessageInput({
value,
onChange,
onSubmit,
onPaste,
onGifSelect,
placeholder = "Message...",
disabled = false,
}, ref) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const richRef = useRef<HTMLDivElement>(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<EditorMode>("md");
const fileInputRef = useRef<HTMLInputElement>(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, "<blockquote>");
} else {
// insert empty blockquote
const bq = document.createElement("blockquote");
bq.innerHTML = "<br>";
richRef.current?.appendChild(bq);
}
}
else if (cmd === "h1") document.execCommand("formatBlock", false, "<h1>");
else if (cmd === "h2") document.execCommand("formatBlock", false, "<h2>");
else if (cmd === "h3") document.execCommand("formatBlock", false, "<h3>");
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<HTMLInputElement>) => {
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 (
<div className="relative">
{/* picker overlays */}
{showGif && (
<div className="mb-1">
<GiphyPicker onSelect={handleGif} onClose={() => setShowGif(false)} />
</div>
)}
{showKaomoji && (
<KaomojiPicker onSelect={insert} onClose={() => setShowKaomoji(false)} />
)}
{showEmoji && (
<div className="absolute bottom-full right-0 mb-2 z-50">
<Picker
theme={Theme.DARK}
onEmojiClick={(e) => { insert(e.emoji); setShowEmoji(false); }}
/>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="bg-gb-bg-s terminal-border px-2 py-1">
{mode === "md" ? (
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onPaste={onPaste}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}}
placeholder={isDragOver ? "Drop file here..." : placeholder}
rows={1}
disabled={disabled}
className={`w-full bg-transparent outline-none border-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono ${isDragOver ? "bg-gb-bg-t" : ""}`}
/>
) : (
<div
ref={richRef}
contentEditable={!disabled}
suppressContentEditableWarning
onInput={handleRichInput}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}}
data-placeholder={isDragOver ? "Drop file here..." : placeholder}
className={`w-full outline-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono
before:content-[attr(data-placeholder)] before:text-gb-fg-f before:opacity-60
empty:before:block before:hidden
${isDragOver ? "bg-gb-bg-t" : ""}
[&_b]:text-gb-fg [&_b]:font-bold [&_strong]:font-bold [&_i]:text-gb-fg [&_i]:italic [&_em]:italic [&_s]:text-gb-fg [&_s]:line-through [&_del]:line-through [&_strike]:line-through
[&_code]:bg-gb-bg-t [&_code]:px-1 [&_code]:rounded
[&_.spoiler]:bg-gb-bg-t [&_.spoiler]:px-1 [&_.spoiler]:rounded
[&_a]:text-gb-aqua [&_a]:underline
[&_h1]:text-lg [&_h1]:font-bold [&_h2]:text-base [&_h2]:font-bold
[&_h3]:text-sm [&_h3]:font-bold
[&_blockquote]:border-l-2 [&_blockquote]:border-gb-orange [&_blockquote]:pl-2 [&_blockquote]:text-gb-fg-s
[&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4
[&_pre]:bg-gb-bg-t [&_pre]:p-2 [&_pre]:my-1 [&_pre]:overflow-x-auto
[&_pre_code]:bg-transparent [&_pre_code]:px-0`}
/>
)}
{/* upload progress bar */}
{uploading && (
<div className="flex items-center gap-2 px-1 pb-1">
<span className="text-gb-fg-f text-xs font-mono shrink-0">uploading</span>
<div className="flex-1 h-1.5 bg-gb-bg-t rounded-full overflow-hidden">
<div
className="h-full bg-gb-aqua transition-all duration-150 rounded-full"
style={{ width: `${uploadProgress || 5}%` }}
/>
</div>
<span className="text-gb-fg-f text-xs font-mono shrink-0 w-8 text-right">{uploadProgress}%</span>
</div>
)}
{/* toolbar row */}
<div className="flex items-center gap-0.5 pt-0.5 border-t border-gb-bg-t">
{/* + file upload */}
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileInput}
accept=".jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.ogg,.flac,.mp4,.webm,.mov,.pdf,.txt,.md,.json,.csv,.zip,.tar,.gz"
/>
<button type="button" disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()}
title="Upload file"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faPlus} className={`w-3.5 h-3.5 ${uploading ? "animate-pulse" : ""}`} />
</button>
<span className="text-gb-bg-t mx-1"></span>
{/* block formatting */}
<button type="button" disabled={disabled}
onClick={() => execBlock("ul")} title="Unordered list"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faListUl} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execBlock("ol")} title="Ordered list"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faListOl} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execBlock("blockquote")} title="Blockquote"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faQuoteRight} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={execLink} title="Insert link"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faLink} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execBlock("h2")} title="Heading"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faHeading} className="w-3 h-3" />
</button>
<span className="text-gb-bg-t mx-1"></span>
{/* inline formatting */}
<button type="button" disabled={disabled}
onClick={() => execInline("**", "**")} title="Bold"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faBold} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("*", "*")} title="Italic"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faItalic} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("~~", "~~")} title="Strikethrough"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faStrikethrough} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("`", "`")} title="Code"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faCode} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("||", "||")} title="Spoiler"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faEyeSlash} className="w-3 h-3" />
</button>
<span className="text-gb-bg-t mx-1"></span>
{/* emoji / kaomoji / gif */}
<button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji"
className={`p-1 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={faSmile} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji"
className={`p-1 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={faGrin} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled} onClick={toggleGif} title="GIF"
className={`p-1 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={faFilm} className="w-4 h-4" />
</button>
<span className="flex-1" />
{/* mode toggle */}
<button type="button" disabled={disabled} onClick={toggleMode}
title={mode === "md" ? "Switch to rich text" : "Switch to markdown"}
className={`p-1 disabled:opacity-50 ${mode === "rt" ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={mode === "md" ? faParagraph : faAlignLeft} className="w-3.5 h-3.5" />
</button>
{/* send */}
<button type="submit"
disabled={disabled || !value.trim()}
className="text-gb-aqua hover:text-gb-orange disabled:text-gb-fg-f p-1 disabled:opacity-40 transition-colors"
title="Send (Enter)">
<FontAwesomeIcon icon={faPaperPlane} className="w-4 h-4" />
</button>
</div>
</div>
</form>
</div>
);
});
+3 -10
View File
@@ -1,5 +1,4 @@
import { useState } from 'react';
import { api } from '../lib/api.ts';
interface Reaction {
emoji: string;
@@ -9,12 +8,11 @@ interface Reaction {
}
interface ReactionBarProps {
messageId: string;
reactions: Reaction[];
onRefresh: () => void;
onToggle: (emoji: string, isReacted: boolean) => Promise<void>;
}
export function ReactionBar({ messageId, reactions, onRefresh }: ReactionBarProps) {
export function ReactionBar({ reactions, onToggle }: ReactionBarProps) {
const [loading, setLoading] = useState(false);
const toggleReaction = async (emoji: string) => {
@@ -22,12 +20,7 @@ export function ReactionBar({ messageId, reactions, onRefresh }: ReactionBarProp
setLoading(true);
try {
const existing = reactions.find(r => r.emoji === emoji);
if (existing?.reacted) {
await api.delete(`/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`);
} else {
await api.post(`/messages/${messageId}/reactions`, { emoji });
}
onRefresh();
await onToggle(emoji, !!existing?.reacted);
} catch (error) {
console.error('Failed to toggle reaction:', error);
} finally {
+32 -5
View File
@@ -3,6 +3,9 @@ import { useThreadStore } from '../stores/thread.ts';
import { useMessageStore } from '../stores/message.ts';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ExpandableImage } from './ExpandableImage.tsx';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlane } from '@fortawesome/free-solid-svg-icons';
interface ThreadPanelProps {
threadId: string;
@@ -22,7 +25,7 @@ export function ThreadPanel({ threadId, threadName, onClose }: ThreadPanelProps)
const addMessage = useThreadStore((s) => s.addThreadMessage);
const removeMessage = useMessageStore((s) => s.removeMessage);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
fetchMessages(threadId);
@@ -71,7 +74,13 @@ export function ThreadPanel({ threadId, threadName, onClose }: ThreadPanelProps)
<span className="text-gb-fg-f">[{formatTime(m.created_at)}]</span>{' '}
<span className="text-gb-aqua">&lt;{m.author_username}&gt;</span>{' '}
<span className="text-gb-fg">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ p: ({ ...props }) => <span {...props} className="inline" /> }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ ...props }) => <span {...props} className="inline" />,
img: ({ src, alt, ...props }) => <ExpandableImage src={src} alt={alt} {...props} />,
}}
>
{m.content}
</ReactMarkdown>
</span>
@@ -79,9 +88,27 @@ export function ThreadPanel({ threadId, threadName, onClose }: ThreadPanelProps)
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
<span className="text-gb-fg-f select-none shrink-0">{'>'}</span>
<input ref={inputRef} type="text" placeholder="reply..." className="terminal-input w-full" />
<form onSubmit={handleSubmit} className="p-3">
<div className="flex items-end gap-1 bg-gb-bg-s terminal-border px-2 py-1.5">
<textarea
ref={inputRef}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
(e.currentTarget.form as HTMLFormElement | null)?.requestSubmit();
}
}}
placeholder="Reply..."
rows={1}
className="flex-1 bg-transparent outline-none border-none resize-none overflow-y-auto max-h-20 text-sm py-1"
/>
<button
type="submit"
className="text-gb-aqua hover:text-gb-orange p-1.5 shrink-0 disabled:opacity-40"
>
<FontAwesomeIcon icon={faPaperPlane} className="w-4 h-4" />
</button>
</div>
</form>
</div>
);
+1
View File
@@ -35,6 +35,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
{ name: 'me', description: 'Send an action message', transform: (a, u) => '*' + u + ' ' + a + '*' },
{ name: 'spoiler', description: 'Hide text as a spoiler', transform: (a) => '||' + a + '||' },
{ name: 'poll', description: 'Create a poll', transform: (a) => a },
{ name: 'emoji', description: 'Open emoji picker', transform: (a) => a },
];
export function findCommand(name: string): SlashCommand | undefined {
+68
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { api } from "../lib/api.ts";
import { type Reaction } from "./message.ts";
export interface ConversationMember {
id: string;
@@ -25,6 +26,7 @@ export interface ConversationMessage {
content: string;
created_at: string;
edited_at: string | null;
reactions?: Reaction[];
}
interface ConversationState {
@@ -44,6 +46,8 @@ interface ConversationState {
addMessage: (message: ConversationMessage) => void;
updateMessage: (message: ConversationMessage) => void;
deleteMessage: (conversationId: string, messageId: string) => void;
addReaction: (conversationId: string, messageId: string, emoji: string, userId: string) => void;
removeReaction: (conversationId: string, messageId: string, emoji: string, userId: string) => void;
}
export const useConversationStore = create<ConversationState>((set, get) => ({
@@ -106,6 +110,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return;
const existing = state.messagesByConversation[conversationId] || [];
if (existing.length === 0) return;
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
const oldestId = existing[0].id;
set({ isLoadingOlder: true });
try {
@@ -176,4 +181,67 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
};
});
},
addReaction: (conversationId, messageId, emoji, userId) => {
set((state) => {
const messages = state.messagesByConversation[conversationId];
if (!messages) return state;
const newMessages = messages.map((m) => {
if (m.id !== messageId) return m;
const reactions = [...(m.reactions || [])];
const existing = reactions.find((r) => r.emoji === emoji);
if (existing) {
if (!existing.users.includes(userId)) {
existing.users.push(userId);
existing.count++;
}
} else {
reactions.push({ emoji, count: 1, users: [userId] });
}
return { ...m, reactions };
});
return {
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: newMessages,
},
};
});
},
removeReaction: (conversationId, messageId, emoji, userId) => {
set((state) => {
const messages = state.messagesByConversation[conversationId];
if (!messages) return state;
const newMessages = messages.map((m) => {
if (m.id !== messageId) return m;
let reactions = [...(m.reactions || [])];
const existing = reactions.find((r) => r.emoji === emoji);
if (existing) {
existing.users = existing.users.filter((id) => id !== userId);
existing.count--;
if (existing.count <= 0) {
reactions = reactions.filter((r) => r.emoji !== emoji);
}
}
return { ...m, reactions };
});
return {
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: newMessages,
},
};
});
},
}));
+1
View File
@@ -126,6 +126,7 @@ export const useMessageStore = create<MessageState>((set, get) => ({
if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return;
const existing = state.messagesByChannel[channelId] || [];
if (existing.length === 0) return;
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
const oldestId = existing[0].id;
set({ isLoadingOlder: true });
try {
+4 -2
View File
@@ -103,9 +103,11 @@ export const useThreadStore = create<ThreadState>((set) => ({
set({ isLoading: true, error: null });
try {
const params = before ? `?before=${encodeURIComponent(before)}` : '';
const messages = await api.get<Message[]>(`/channels/${threadId}/messages${params}`);
const raw = await api.get<Message[]>(`/channels/${threadId}/messages${params}`);
// ponytail: API returns DESC (newest first), reverse to oldest-first
const msgs = (Array.isArray(raw) ? raw : []).reverse();
set((state) => ({
messagesByThread: { ...state.messagesByThread, [threadId]: Array.isArray(messages) ? messages : [] },
messagesByThread: { ...state.messagesByThread, [threadId]: msgs },
isLoading: false,
}));
} catch (error) {
+14 -4
View File
@@ -186,24 +186,34 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
}
case 'REACTION_ADD': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : '';
const convId = typeof payload.conversation_id === 'string' ? payload.conversation_id : '';
const messageId = typeof payload.message_id === 'string' ? payload.message_id : '';
const reaction = isRecord(payload.reaction) ? payload.reaction : null;
if (channelId && messageId && reaction) {
if (messageId && reaction) {
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
const userId = typeof reaction.user_id === 'string' ? reaction.user_id : '';
if (emoji && userId) {
addReaction(channelId, messageId, emoji, userId);
if (convId) {
useConversationStore.getState().addReaction(convId, messageId, emoji, userId);
} else if (channelId) {
addReaction(channelId, messageId, emoji, userId);
}
}
}
break;
}
case 'REACTION_REMOVE': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : '';
const convId = typeof payload.conversation_id === 'string' ? payload.conversation_id : '';
const messageId = typeof payload.message_id === 'string' ? payload.message_id : '';
const emoji = typeof payload.emoji === 'string' ? payload.emoji : '';
const userId = typeof payload.user_id === 'string' ? payload.user_id : '';
if (channelId && messageId && emoji && userId) {
removeReaction(channelId, messageId, emoji, userId);
if (messageId && emoji && userId) {
if (convId) {
useConversationStore.getState().removeReaction(convId, messageId, emoji, userId);
} else if (channelId) {
removeReaction(channelId, messageId, emoji, userId);
}
}
break;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}