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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+49
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 = ``;
|
||||
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
@@ -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"><{msg.author_username}></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 = ``;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(/ /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}>`);
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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})`;
|
||||
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 = ``;
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"><{m.author_username}></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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user