fix: repair invite links, social features, messaging + Discord UI overhaul
Phase 1 - Feature Repair: - Fix member kick/leave: add missing db.delete() call in servers.ts - Stabilize invite codes: return existing code instead of regenerating - Fix user search: use LIKE instead of exact match in social.ts - Wire DM button on FriendsPage to create/navigate to DM channels - Add cancel outgoing friend request (DELETE endpoint + frontend) - Add accept/decline friend request actions with WS real-time events - Fix replyToId persistence in message creation - Hydrate reactions and replyTo in message queries - Add joinByCode to API client and serverStore - Add friend_request_received/accepted WebSocket events Phase 2 - Discord UI Overhaul: - Remove stray borders between layout columns - Replace shadow-sm with shadow-header on content headers - Replace all bg-gray-*/text-gray-* with Discord color tokens - Ensure flat color contrast (#1E1F22, #2B2D31, #313338) Testing: - Set up vitest + @testing-library/react + jsdom - Add 17 tests across InviteModal, JoinServer, FriendsPage (all passing) - Fix vite resolve.extensions to prefer .tsx over stale .js files
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
export function Embed({ url }) {
|
||||
const [metadata, setMetadata] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
// Simple fetch from our new API
|
||||
fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('opencord_token')}`
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (isMounted && data.title) {
|
||||
setMetadata(data);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted)
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => { isMounted = false; };
|
||||
}, [url]);
|
||||
if (isLoading || !metadata)
|
||||
return null;
|
||||
return (_jsxs("div", { className: "mt-2 max-w-[520px] bg-discord-bg-secondary rounded-[4px] border-l-4 border-discord-bg-tertiary flex overflow-hidden", children: [_jsxs("div", { className: "flex-1 p-3 min-w-0", children: [metadata.siteName && (_jsx("div", { className: "text-[12px] text-discord-text-normal font-medium mb-1 truncate", children: metadata.siteName })), metadata.title && (_jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", className: "text-[16px] text-discord-text-link font-semibold hover:underline block mb-2", children: metadata.title })), metadata.description && (_jsx("div", { className: "text-[14px] text-discord-text-normal leading-[1.125rem]", children: metadata.description }))] }), metadata.image && (_jsx("div", { className: "w-[80px] h-[80px] m-3 flex-shrink-0", children: _jsx("img", { src: metadata.image, alt: "", className: "w-full h-full object-cover rounded-[4px]" }) }))] }));
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
interface EmbedProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export function Embed({ url }: EmbedProps) {
|
||||
const [metadata, setMetadata] = useState<Metadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
// Simple fetch from our new API
|
||||
fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('opencord_token')}`
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (isMounted && data.title) {
|
||||
setMetadata(data);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => { isMounted = false; };
|
||||
}, [url]);
|
||||
|
||||
if (isLoading || !metadata) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 max-w-[520px] bg-discord-bg-secondary rounded-[4px] border-l-4 border-discord-bg-tertiary flex overflow-hidden">
|
||||
<div className="flex-1 p-3 min-w-0">
|
||||
{metadata.siteName && (
|
||||
<div className="text-[12px] text-discord-text-normal font-medium mb-1 truncate">
|
||||
{metadata.siteName}
|
||||
</div>
|
||||
)}
|
||||
{metadata.title && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[16px] text-discord-text-link font-semibold hover:underline block mb-2"
|
||||
>
|
||||
{metadata.title}
|
||||
</a>
|
||||
)}
|
||||
{metadata.description && (
|
||||
<div className="text-[14px] text-discord-text-normal leading-[1.125rem]">
|
||||
{metadata.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{metadata.image && (
|
||||
<div className="w-[80px] h-[80px] m-3 flex-shrink-0">
|
||||
<img
|
||||
src={metadata.image}
|
||||
alt=""
|
||||
className="w-full h-full object-cover rounded-[4px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
export function FriendsPage() {
|
||||
const [activeTab, setActiveTab] = useState('online');
|
||||
const [addUsername, setAddUsername] = useState('');
|
||||
const [addStatus, setAddStatus] = useState(null);
|
||||
const { friends, requests, isLoading, loadFriends, loadRequests, sendFriendRequest, updateFriendRequest, removeFriend } = useSocialStore();
|
||||
useEffect(() => {
|
||||
loadFriends();
|
||||
loadRequests();
|
||||
}, [loadFriends, loadRequests]);
|
||||
const onlineFriends = friends.filter(f => f.status !== 'offline');
|
||||
const pendingIncoming = requests.filter(r => r.status === 'pending' && r.toId !== r.fromId && r.user?.id === r.fromId);
|
||||
const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.fromId !== r.toId && r.user?.id === r.toId);
|
||||
const handleAddFriend = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!addUsername.trim())
|
||||
return;
|
||||
try {
|
||||
await sendFriendRequest(addUsername.trim());
|
||||
setAddStatus({ type: 'success', message: `Success! Your friend request to ${addUsername} has been sent.` });
|
||||
setAddUsername('');
|
||||
}
|
||||
catch (err) {
|
||||
setAddStatus({ type: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
const renderTabContent = () => {
|
||||
if (isLoading && friends.length === 0 && requests.length === 0) {
|
||||
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) }));
|
||||
}
|
||||
switch (activeTab) {
|
||||
case 'online':
|
||||
return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Online \u2014 ", onlineFriends.length] }), onlineFriends.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: [_jsx("img", { src: "/friends-empty.svg", alt: "", className: "w-64 h-64 mb-4", onError: (e) => e.target.style.display = 'none' }), _jsx("p", { className: "text-discord-text-muted", children: "No one's around to play with Wumpus." })] })) : (onlineFriends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id) }, friend.id))))] }));
|
||||
case 'all':
|
||||
return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["All Friends \u2014 ", friends.length] }), friends.length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "Wumpus is waiting on friends. You can add them!" }) })) : (friends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id) }, friend.id))))] }));
|
||||
case 'pending':
|
||||
return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Pending \u2014 ", pendingIncoming.length + pendingOutgoing.length] }), [...pendingIncoming, ...pendingOutgoing].length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "There are no pending friend requests. Here's Wumpus for now!" }) })) : (_jsxs(_Fragment, { children: [pendingIncoming.map(req => (_jsx(RequestItem, { request: req, type: "incoming", onAction: (status) => updateFriendRequest(req.id, status) }, req.id))), pendingOutgoing.map(req => (_jsx(RequestItem, { request: req, type: "outgoing", onAction: () => { } }, req.id)))] }))] }));
|
||||
case 'add':
|
||||
return (_jsxs("div", { className: "flex-1 p-8", children: [_jsx("h2", { className: "text-base font-bold text-discord-text-primary uppercase mb-2", children: "Add Friend" }), _jsx("p", { className: "text-sm text-discord-text-muted mb-4", children: "You can add friends with their Opencord username." }), _jsxs("form", { onSubmit: handleAddFriend, className: "relative mb-8", children: [_jsx("input", { type: "text", placeholder: "You can add a friend with their username", value: addUsername, onChange: (e) => setAddUsername(e.target.value), className: "w-full bg-discord-bg-tertiary text-discord-text-primary px-4 py-3 rounded-lg border border-transparent focus:border-discord-text-link outline-none transition-all placeholder:text-discord-text-muted/50" }), _jsx("button", { type: "submit", disabled: !addUsername.trim() || isLoading, className: "absolute right-2 top-1.5 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple-hover disabled:opacity-50 disabled:bg-discord-blurple text-white text-sm font-medium rounded transition-colors", children: "Send Friend Request" })] }), addStatus && (_jsx("div", { className: `text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-discord-green border-discord-green/20 bg-discord-green/5' : 'text-discord-red border-discord-red/20 bg-discord-red/5'}`, children: addStatus.message }))] }));
|
||||
}
|
||||
};
|
||||
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary h-full", children: [_jsxs("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary/50 shadow-sm flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 mr-4", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: "Friends" })] }), _jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsxs("div", { className: "flex items-center gap-4 ml-2", children: [_jsx(TabButton, { active: activeTab === 'online', onClick: () => setActiveTab('online'), children: "Online" }), _jsx(TabButton, { active: activeTab === 'all', onClick: () => setActiveTab('all'), children: "All" }), _jsxs(TabButton, { active: activeTab === 'pending', onClick: () => setActiveTab('pending'), children: ["Pending", (pendingIncoming.length > 0) && (_jsx("span", { className: "ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none", children: pendingIncoming.length }))] }), _jsx("button", { onClick: () => setActiveTab('add'), className: `px-2 py-0.5 rounded text-[14px] font-medium transition-all ${activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'}`, children: "Add Friend" })] })] }), renderTabContent()] }));
|
||||
}
|
||||
function TabButton({ children, active, onClick }) {
|
||||
return (_jsx("button", { onClick: onClick, className: `px-2 py-0.5 rounded-[4px] text-[16px] font-medium transition-colors ${active ? 'bg-discord-modifier-selected text-white' : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: children }));
|
||||
}
|
||||
function FriendItem({ friend, onRemove }) {
|
||||
return (_jsxs("div", { className: "flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-discord-modifier-hover group transition-colors border-t border-discord-bg-modifier-accent mx-2", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: friend.avatar, name: friend.displayName ?? friend.username, size: 32, status: friend.status }), _jsxs("div", { className: "flex flex-col leading-tight", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-header font-semibold text-[15px]", children: friend.displayName ?? friend.username }), _jsxs("span", { className: "text-discord-text-muted text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium", children: ["@", friend.username] })] }), _jsx("span", { className: "text-[12px] text-discord-text-muted font-medium uppercase", children: friend.status })] })] }), _jsxs("div", { className: "flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2", children: [_jsx("button", { className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" }) }) }), _jsx("button", { onClick: (e) => { e.stopPropagation(); onRemove(); }, className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-colors", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })] })] }));
|
||||
}
|
||||
function RequestItem({ request, type, onAction }) {
|
||||
const user = request.user;
|
||||
if (!user)
|
||||
return null;
|
||||
return (_jsxs("div", { className: "flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-discord-bg-hover/50 group transition-colors border-t border-transparent hover:border-discord-bg-tertiary/30", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-primary font-bold text-sm", children: user.displayName ?? user.username }), _jsxs("span", { className: "text-discord-text-muted text-xs", children: ["@", user.username] })] }), _jsx("span", { className: "text-xs text-discord-text-muted", children: type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request' })] })] }), _jsx("div", { className: "flex items-center gap-2", children: type === 'incoming' ? (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAction('accepted'), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-green hover:bg-discord-green hover:text-white transition-all shadow-sm", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) }) }), _jsx("button", { onClick: () => onAction('declined'), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-red hover:bg-discord-red hover:text-white transition-all shadow-sm", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })] })) : (_jsx("button", { className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-all shadow-sm", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })) })] }));
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { FriendsPage } from './FriendsPage';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import type { Friend, FriendRequest } from '@opencord/shared';
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
dm: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
social: {
|
||||
friends: vi.fn().mockResolvedValue([]),
|
||||
requests: vi.fn().mockResolvedValue([]),
|
||||
sendRequest: vi.fn().mockResolvedValue({ success: true }),
|
||||
updateRequest: vi.fn().mockResolvedValue({ success: true }),
|
||||
cancelRequest: vi.fn().mockResolvedValue({ success: true }),
|
||||
removeFriend: vi.fn().mockResolvedValue({ success: true }),
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
const makeFriend = (overrides: Partial<Friend> = {}): Friend => ({
|
||||
id: 'friend-1',
|
||||
username: 'testfriend',
|
||||
displayName: 'Test Friend',
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
createdAt: Date.now(),
|
||||
addedAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRequest = (overrides: Partial<FriendRequest> = {}): FriendRequest => ({
|
||||
id: 'req-1',
|
||||
fromId: 'other-user',
|
||||
toId: 'current-user',
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
user: {
|
||||
id: 'other-user',
|
||||
username: 'otheruser',
|
||||
displayName: 'Other User',
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function renderFriendsPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<FriendsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockClear();
|
||||
// Reset the social store with no-op loaders (we set state directly)
|
||||
useSocialStore.setState({
|
||||
friends: [],
|
||||
requests: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
loadFriends: vi.fn(),
|
||||
loadRequests: vi.fn(),
|
||||
});
|
||||
useServerStore.setState({
|
||||
dmChannels: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe('FriendsPage', () => {
|
||||
describe('Add Friend tab', () => {
|
||||
it('renders the Add Friend form when tab is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFriendsPage();
|
||||
|
||||
const addFriendTab = screen.getByText('Add Friend');
|
||||
await user.click(addFriendTab);
|
||||
|
||||
expect(screen.getByPlaceholderText('You can add a friend with their username')).toBeInTheDocument();
|
||||
expect(screen.getByText('Send Friend Request')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls sendFriendRequest with the username when form is submitted', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSendFriendRequest = vi.fn().mockResolvedValue(undefined);
|
||||
useSocialStore.setState({
|
||||
sendFriendRequest: mockSendFriendRequest,
|
||||
});
|
||||
|
||||
renderFriendsPage();
|
||||
|
||||
// Switch to Add Friend tab
|
||||
await user.click(screen.getByText('Add Friend'));
|
||||
|
||||
// Type username
|
||||
const input = screen.getByPlaceholderText('You can add a friend with their username');
|
||||
await user.type(input, 'newbuddy');
|
||||
|
||||
// Click send
|
||||
await user.click(screen.getByText('Send Friend Request'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendFriendRequest).toHaveBeenCalledWith('newbuddy');
|
||||
});
|
||||
|
||||
// Should show success message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Success! Your friend request to newbuddy has been sent/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when sendFriendRequest fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSendFriendRequest = vi.fn().mockRejectedValue(new Error('User not found'));
|
||||
useSocialStore.setState({
|
||||
sendFriendRequest: mockSendFriendRequest,
|
||||
});
|
||||
|
||||
renderFriendsPage();
|
||||
await user.click(screen.getByText('Add Friend'));
|
||||
|
||||
const input = screen.getByPlaceholderText('You can add a friend with their username');
|
||||
await user.type(input, 'ghost');
|
||||
await user.click(screen.getByText('Send Friend Request'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('User not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DM button on friend item', () => {
|
||||
it('calls api.dm.create and navigates when clicking the Message button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const friend = makeFriend({ id: 'friend-42', username: 'dmpal', displayName: 'DM Pal' });
|
||||
const mockAddDmChannel = vi.fn();
|
||||
|
||||
useSocialStore.setState({
|
||||
friends: [friend],
|
||||
requests: [],
|
||||
});
|
||||
useServerStore.setState({
|
||||
addDmChannel: mockAddDmChannel,
|
||||
});
|
||||
|
||||
// Mock the dm.create API
|
||||
const { api } = await import('../../api/client');
|
||||
(api.dm.create as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 'dm-channel-99',
|
||||
createdAt: Date.now(),
|
||||
members: [],
|
||||
});
|
||||
|
||||
renderFriendsPage();
|
||||
|
||||
// Switch to "All" tab to see the friend
|
||||
await user.click(screen.getByText('All'));
|
||||
|
||||
// Find the Message button by title
|
||||
const dmButton = screen.getByTitle('Message');
|
||||
await user.click(dmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.dm.create).toHaveBeenCalledWith({ userId: 'friend-42' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDmChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'dm-channel-99' }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/channels/@me/dm-channel-99');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cancel outgoing friend request', () => {
|
||||
it('calls cancelFriendRequest when clicking cancel on an outgoing request', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockCancel = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// Outgoing request: user.id === toId means current user sent it (fromId is current user, user is the recipient)
|
||||
const outgoingRequest = makeRequest({
|
||||
id: 'req-out-1',
|
||||
fromId: 'current-user',
|
||||
toId: 'other-user',
|
||||
user: {
|
||||
id: 'other-user',
|
||||
username: 'recipient',
|
||||
displayName: 'Recipient',
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
useSocialStore.setState({
|
||||
friends: [],
|
||||
requests: [outgoingRequest],
|
||||
cancelFriendRequest: mockCancel,
|
||||
});
|
||||
|
||||
renderFriendsPage();
|
||||
|
||||
// Switch to Pending tab
|
||||
await user.click(screen.getByText('Pending'));
|
||||
|
||||
// Should see the outgoing request
|
||||
expect(screen.getByText('Outgoing Friend Request')).toBeInTheDocument();
|
||||
|
||||
// Click the cancel button (the X icon button with title "Cancel Request")
|
||||
const cancelButton = screen.getByTitle('Cancel Request');
|
||||
await user.click(cancelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancel).toHaveBeenCalledWith('req-out-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accept/Decline incoming friend request', () => {
|
||||
it('calls updateFriendRequest with "accepted" when clicking accept', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const incomingRequest = makeRequest({
|
||||
id: 'req-in-1',
|
||||
fromId: 'sender-id',
|
||||
toId: 'current-user',
|
||||
user: {
|
||||
id: 'sender-id',
|
||||
username: 'sender',
|
||||
displayName: 'Sender',
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
useSocialStore.setState({
|
||||
friends: [],
|
||||
requests: [incomingRequest],
|
||||
updateFriendRequest: mockUpdate,
|
||||
});
|
||||
|
||||
renderFriendsPage();
|
||||
await user.click(screen.getByText('Pending'));
|
||||
|
||||
expect(screen.getByText('Incoming Friend Request')).toBeInTheDocument();
|
||||
|
||||
// Click accept button (title "Accept")
|
||||
const acceptButton = screen.getByTitle('Accept');
|
||||
await user.click(acceptButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith('req-in-1', 'accepted');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateFriendRequest with "declined" when clicking decline', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const incomingRequest = makeRequest({
|
||||
id: 'req-in-2',
|
||||
fromId: 'sender-id',
|
||||
toId: 'current-user',
|
||||
user: {
|
||||
id: 'sender-id',
|
||||
username: 'sender2',
|
||||
displayName: 'Sender 2',
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
useSocialStore.setState({
|
||||
friends: [],
|
||||
requests: [incomingRequest],
|
||||
updateFriendRequest: mockUpdate,
|
||||
});
|
||||
|
||||
renderFriendsPage();
|
||||
await user.click(screen.getByText('Pending'));
|
||||
|
||||
const declineButton = screen.getByTitle('Decline');
|
||||
await user.click(declineButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith('req-in-2', 'declined');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { api } from '../../api/client';
|
||||
import type { Friend, FriendRequest } from '@opencord/shared';
|
||||
|
||||
type Tab = 'online' | 'all' | 'pending' | 'add';
|
||||
|
||||
export function FriendsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('online');
|
||||
const [addUsername, setAddUsername] = useState('');
|
||||
const [addStatus, setAddStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const addDmChannel = useServerStore((s) => s.addDmChannel);
|
||||
|
||||
const {
|
||||
friends,
|
||||
requests,
|
||||
isLoading,
|
||||
loadFriends,
|
||||
loadRequests,
|
||||
sendFriendRequest,
|
||||
updateFriendRequest,
|
||||
cancelFriendRequest,
|
||||
removeFriend
|
||||
} = useSocialStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadFriends();
|
||||
loadRequests();
|
||||
}, [loadFriends, loadRequests]);
|
||||
|
||||
const onlineFriends = friends.filter(f => f.status !== 'offline');
|
||||
const pendingIncoming = requests.filter(r => r.status === 'pending' && r.user?.id === r.fromId);
|
||||
const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.user?.id === r.toId);
|
||||
|
||||
const handleAddFriend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!addUsername.trim()) return;
|
||||
|
||||
try {
|
||||
await sendFriendRequest(addUsername.trim());
|
||||
setAddStatus({ type: 'success', message: `Success! Your friend request to ${addUsername} has been sent.` });
|
||||
setAddUsername('');
|
||||
} catch (err) {
|
||||
setAddStatus({ type: 'error', message: (err as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDm = async (friendId: string) => {
|
||||
try {
|
||||
const dmChannel = await api.dm.create({ userId: friendId });
|
||||
addDmChannel(dmChannel);
|
||||
navigate(`/channels/@me/${dmChannel.id}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to open DM:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (isLoading && friends.length === 0 && requests.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (activeTab) {
|
||||
case 'online':
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 className="text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2">
|
||||
Online — {onlineFriends.length}
|
||||
</h2>
|
||||
{onlineFriends.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-60">
|
||||
<img src="/friends-empty.svg" alt="" className="w-64 h-64 mb-4" onError={(e) => (e.target as any).style.display='none'} />
|
||||
<p className="text-discord-text-muted">No one's around to play with Wumpus.</p>
|
||||
</div>
|
||||
) : (
|
||||
onlineFriends.map(friend => (
|
||||
<FriendItem key={friend.id} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'all':
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 className="text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2">
|
||||
All Friends — {friends.length}
|
||||
</h2>
|
||||
{friends.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-60">
|
||||
<p className="text-discord-text-muted">Wumpus is waiting on friends. You can add them!</p>
|
||||
</div>
|
||||
) : (
|
||||
friends.map(friend => (
|
||||
<FriendItem key={friend.id} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 className="text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2">
|
||||
Pending — {pendingIncoming.length + pendingOutgoing.length}
|
||||
</h2>
|
||||
{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-60">
|
||||
<p className="text-discord-text-muted">There are no pending friend requests. Here's Wumpus for now!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{pendingIncoming.map(req => (
|
||||
<RequestItem
|
||||
key={req.id}
|
||||
request={req}
|
||||
type="incoming"
|
||||
onAccept={() => updateFriendRequest(req.id, 'accepted')}
|
||||
onDecline={() => updateFriendRequest(req.id, 'declined')}
|
||||
/>
|
||||
))}
|
||||
{pendingOutgoing.map(req => (
|
||||
<RequestItem
|
||||
key={req.id}
|
||||
request={req}
|
||||
type="outgoing"
|
||||
onCancel={() => cancelFriendRequest(req.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'add':
|
||||
return (
|
||||
<div className="flex-1 p-8">
|
||||
<h2 className="text-base font-bold text-discord-text-primary uppercase mb-2">Add Friend</h2>
|
||||
<p className="text-sm text-discord-text-muted mb-4">You can add friends with their Opencord username.</p>
|
||||
<form onSubmit={handleAddFriend} className="relative mb-8">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="You can add a friend with their username"
|
||||
value={addUsername}
|
||||
onChange={(e) => setAddUsername(e.target.value)}
|
||||
className="w-full bg-discord-bg-tertiary text-discord-text-primary px-4 py-3 rounded-lg border border-transparent focus:border-discord-text-link outline-none transition-all placeholder:text-discord-text-muted/50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!addUsername.trim() || isLoading}
|
||||
className="absolute right-2 top-1.5 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple-hover disabled:opacity-50 disabled:bg-discord-blurple text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
Send Friend Request
|
||||
</button>
|
||||
</form>
|
||||
{addStatus && (
|
||||
<div className={`text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-discord-green border-discord-green/20 bg-discord-green/5' : 'text-discord-red border-discord-red/20 bg-discord-red/5'}`}>
|
||||
{addStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-discord-bg-primary h-full">
|
||||
{/* Header */}
|
||||
<div className="h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10 bg-discord-bg-primary">
|
||||
<div className="flex items-center gap-2 mr-4">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
<span className="font-bold text-discord-text-primary">Friends</span>
|
||||
</div>
|
||||
|
||||
<div className="w-[1px] h-6 bg-discord-bg-accent mx-2" />
|
||||
|
||||
<div className="flex items-center gap-4 ml-2">
|
||||
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>Online</TabButton>
|
||||
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>All</TabButton>
|
||||
<TabButton active={activeTab === 'pending'} onClick={() => setActiveTab('pending')}>
|
||||
Pending
|
||||
{(pendingIncoming.length > 0) && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none">
|
||||
{pendingIncoming.length}
|
||||
</span>
|
||||
)}
|
||||
</TabButton>
|
||||
<button
|
||||
onClick={() => setActiveTab('add')}
|
||||
className={`px-2 py-0.5 rounded text-[14px] font-medium transition-all ${
|
||||
activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'
|
||||
}`}
|
||||
>
|
||||
Add Friend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ children, active, onClick }: { children: React.ReactNode, active: boolean, onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2 py-0.5 rounded-[4px] text-[16px] font-medium transition-colors ${
|
||||
active ? 'bg-discord-modifier-selected text-white' : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () => void, onDm: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-discord-modifier-hover group transition-colors border-t border-discord-modifier-accent mx-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={friend.avatar} name={friend.displayName ?? friend.username} size={32} status={friend.status} />
|
||||
<div className="flex flex-col leading-tight">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-discord-text-primary font-semibold text-[15px]">{friend.displayName ?? friend.username}</span>
|
||||
<span className="text-discord-text-muted text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
|
||||
</div>
|
||||
<span className="text-[12px] text-discord-text-muted font-medium uppercase">{friend.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDm(); }}
|
||||
className="w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-text-primary transition-colors"
|
||||
title="Message"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-colors"
|
||||
title="Remove Friend"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
||||
request: FriendRequest;
|
||||
type: 'incoming' | 'outgoing';
|
||||
onAccept?: () => void;
|
||||
onDecline?: () => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const user = request.user;
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-discord-modifier-hover group transition-colors border-t border-discord-modifier-accent mx-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-discord-text-primary font-bold text-sm">{user.displayName ?? user.username}</span>
|
||||
<span className="text-discord-text-muted text-xs">@{user.username}</span>
|
||||
</div>
|
||||
<span className="text-xs text-discord-text-muted">{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{type === 'incoming' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onAccept?.()}
|
||||
className="p-2 bg-discord-bg-tertiary rounded-full text-discord-green hover:bg-discord-green hover:text-white transition-all"
|
||||
title="Accept"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDecline?.()}
|
||||
className="p-2 bg-discord-bg-tertiary rounded-full text-discord-red hover:bg-discord-red hover:text-white transition-all"
|
||||
title="Decline"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onCancel?.()}
|
||||
className="p-2 bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-all"
|
||||
title="Cancel Request"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../stores/authStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Embed } from './Embed';
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
@@ -33,10 +34,44 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
||||
const members = useServerStore((s) => s.members);
|
||||
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
const isAuthor = currentUser?.id === message.userId;
|
||||
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
|
||||
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
|
||||
const canDelete = isAuthor || isAdminUser;
|
||||
const addReaction = useChatStore((s) => s.addReaction);
|
||||
const removeReaction = useChatStore((s) => s.removeReaction);
|
||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||
const toggleReaction = (emoji) => {
|
||||
const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji);
|
||||
if (hasReacted) {
|
||||
removeReaction(message.id, emoji);
|
||||
}
|
||||
else {
|
||||
addReaction(message.id, emoji);
|
||||
}
|
||||
};
|
||||
const reactionGroups = (message.reactions || []).reduce((acc, r) => {
|
||||
const group = acc[r.emoji] || { count: 0, me: false };
|
||||
group.count++;
|
||||
if (r.userId === currentUser?.id) {
|
||||
group.me = true;
|
||||
}
|
||||
acc[r.emoji] = group;
|
||||
return acc;
|
||||
}, {});
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const firstUrl = message.content?.match(urlRegex)?.[0];
|
||||
const handleUsernameClick = (e) => {
|
||||
if (!message.user)
|
||||
return;
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(message.user, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.right + 16,
|
||||
});
|
||||
};
|
||||
const contextMenuItems = [];
|
||||
if (isAuthor) {
|
||||
contextMenuItems.push({
|
||||
@@ -70,36 +105,52 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
const displayName = message.user.displayName ?? message.user.username;
|
||||
const roleColor = (() => {
|
||||
const member = members.find(m => m.userId === message.userId);
|
||||
if (member?.roles && member.roles.length > 0) {
|
||||
return { color: member.roles[0].color };
|
||||
}
|
||||
if (member?.role === 'owner')
|
||||
return 'text-discord-red';
|
||||
return { color: '#da373c' };
|
||||
if (member?.role === 'admin')
|
||||
return 'text-discord-blurple';
|
||||
return 'text-white';
|
||||
return { color: '#5865f2' };
|
||||
return { color: '#dbdee1' };
|
||||
})();
|
||||
const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [_jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-center", children: isFirstInGroup ? (_jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, className: "mt-0.5 cursor-pointer" })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0", children: [isFirstInGroup && (_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: `font-medium cursor-pointer hover:underline ${roleColor}`, children: displayName }), _jsx("span", { className: "text-xs text-discord-text-muted", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-xs text-discord-text-muted mt-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-[#00aff4] hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => {
|
||||
const replyRoleColor = (msg) => {
|
||||
const member = members.find(m => m.userId === msg.userId);
|
||||
if (member?.roles && member.roles.length > 0) {
|
||||
return { color: member.roles[0].color };
|
||||
}
|
||||
if (member?.role === 'owner')
|
||||
return { color: '#da373c' };
|
||||
if (member?.role === 'admin')
|
||||
return { color: '#5865f2' };
|
||||
return { color: '#dbdee1' };
|
||||
};
|
||||
const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-[#2e3035]/30 transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [message.replyTo && (_jsx("div", { className: "absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-[#4e5058] rounded-tl-[6px] opacity-60" })), _jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-start pl-0.5", children: isFirstInGroup || message.replyTo ? (_jsx("div", { className: "mt-1", children: _jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, user: message.user, className: "hover:drop-shadow-md transition-all active:translate-y-[1px]" }) })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-2 select-none w-full text-center leading-[1.375rem] font-medium`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0 pr-4", children: [message.replyTo && (_jsxs("div", { className: "flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply", children: [_jsx(Avatar, { src: message.replyTo.user.avatar, name: message.replyTo.user.username, size: 16 }), _jsx("span", { className: "text-[14px] font-bold text-discord-text-header hover:underline", style: message.replyTo ? replyRoleColor(message.replyTo) : undefined, children: message.replyTo.user.displayName ?? message.replyTo.user.username }), _jsx("span", { className: "text-[14px] text-discord-text-normal truncate max-w-[400px] hover:text-white", children: message.replyTo.content })] })), (isFirstInGroup || message.replyTo) && (_jsxs("div", { className: "flex items-baseline gap-2 mb-0.5", children: [_jsx("span", { onClick: handleUsernameClick, className: "font-bold cursor-pointer hover:underline text-[16px] leading-tight", style: roleColor, children: displayName }), _jsx("span", { className: "text-[12px] text-discord-text-muted leading-tight font-medium hover:cursor-default", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1 w-full", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-3 bg-discord-bg-input rounded-lg text-discord-text-primary outline-none resize-none text-[16px] leading-[1.375rem] shadow-inner", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-[12px] text-discord-text-muted mt-1.5 ml-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-discord-text-link hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => {
|
||||
if (editContent.trim()) {
|
||||
editMessage(message.id, editContent.trim());
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, className: "text-[#00aff4] hover:underline", children: "save" })] })] })) : (_jsxs(_Fragment, { children: [message.content && (_jsxs("div", { className: "text-discord-text-primary text-sm leading-[1.375rem] break-words", children: [_jsx(ReactMarkdown, { components: {
|
||||
}, className: "text-discord-text-link hover:underline", children: "save" })] })] })) : (_jsxs("div", { className: "flex flex-col gap-1", children: [message.content && (_jsxs("div", { className: "text-discord-text-normal text-[16px] leading-[1.375rem] break-words whitespace-pre-wrap selection:bg-discord-blurple/30", children: [_jsx(ReactMarkdown, { components: {
|
||||
p: ({ children }) => _jsx("span", { children: children }),
|
||||
a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[#00aff4] hover:underline", children: children })),
|
||||
code: ({ children }) => (_jsx("code", { className: "px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono", children: children })),
|
||||
pre: ({ children }) => (_jsx("pre", { className: "mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto", children: children })),
|
||||
strong: ({ children }) => _jsx("strong", { className: "font-bold", children: children }),
|
||||
a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-discord-text-link hover:underline", children: children })),
|
||||
code: ({ children }) => (_jsx("code", { className: "px-1 py-0.5 bg-discord-bg-tertiary rounded text-[14px] font-mono", children: children })),
|
||||
pre: ({ children }) => (_jsx("pre", { className: "mt-1 p-3 bg-discord-bg-tertiary border border-discord-bg-tertiary/50 rounded-md text-[14px] font-mono overflow-x-auto", children: children })),
|
||||
strong: ({ children }) => _jsx("strong", { className: "font-bold text-discord-text-primary", children: children }),
|
||||
em: ({ children }) => _jsx("em", { className: "italic", children: children }),
|
||||
}, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1", children: "(edited)" }))] })), message.attachments.length > 0 && (_jsx("div", { className: "mt-1 space-y-1", children: message.attachments.map((att) => {
|
||||
}, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1 select-none font-medium", children: "(edited)" }))] })), Object.keys(reactionGroups).length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1 mt-1", children: Object.entries(reactionGroups).map(([emoji, { count, me }]) => (_jsxs("button", { onClick: () => toggleReaction(emoji), className: `flex items-center gap-1.5 px-1.5 py-0.5 rounded-[8px] text-[14px] font-medium border transition-colors ${me
|
||||
? 'bg-discord-blurple/15 border-discord-blurple text-discord-blurple'
|
||||
: 'bg-discord-bg-secondary border-transparent text-discord-text-muted hover:border-discord-text-muted/30'}`, children: [_jsx("span", { children: emoji }), _jsx("span", { className: me ? 'text-discord-blurple' : 'text-discord-text-normal', children: count })] }, emoji))) })), !isEditing && firstUrl && _jsx(Embed, { url: firstUrl }), message.attachments.length > 0 && (_jsx("div", { className: "mt-1 grid gap-2", children: message.attachments.map((att) => {
|
||||
const isImage = att.mimetype.startsWith('image/');
|
||||
if (isImage) {
|
||||
return (_jsx("div", { className: "max-w-[400px]", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id));
|
||||
return (_jsx("div", { className: "max-w-fit mt-1 rounded-lg overflow-hidden border border-discord-bg-tertiary/50 bg-discord-bg-tertiary/20", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[350px] object-contain cursor-pointer hover:brightness-95 transition-all", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id));
|
||||
}
|
||||
return (_jsxs("a", { href: `/api/uploads/${att.filename}`, download: att.originalName, className: "flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]", children: [_jsx("svg", { className: "w-6 h-6 text-discord-text-muted flex-shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-[#00aff4] text-sm truncate hover:underline", children: att.originalName }), _jsx("p", { className: "text-xs text-discord-text-muted", children: att.size < 1024 ? `${att.size} B` :
|
||||
return (_jsxs("a", { href: `/api/uploads/${att.filename}`, download: att.originalName, className: "flex items-center gap-3 p-4 bg-discord-bg-secondary/50 rounded-lg border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-all max-w-[400px] mt-1 group/att", children: [_jsx("div", { className: "p-2 bg-discord-bg-tertiary rounded text-discord-text-muted group-hover/att:text-discord-text-primary transition-colors", children: _jsx("svg", { className: "w-8 h-8", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 1.5, d: "M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" }) }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-discord-text-link text-[15px] font-medium truncate hover:underline", children: att.originalName }), _jsx("p", { className: "text-[12px] text-discord-text-muted font-medium", children: att.size < 1024 ? `${att.size} B` :
|
||||
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
|
||||
`${(att.size / 1048576).toFixed(1)} MB` })] })] }, att.id));
|
||||
}) }))] }))] }), isHovered && !isEditing && contextMenuItems.length > 0 && (_jsxs("div", { className: "absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md", children: [isAuthor && (_jsx("button", { onClick: () => {
|
||||
}) }))] }))] }), isHovered && !isEditing && (_jsxs("div", { className: "absolute -top-[18px] right-4 flex items-center bg-discord-bg-primary border border-discord-bg-tertiary/50 rounded-[4px] shadow-elevation-low overflow-hidden z-10 h-8", children: [_jsx("div", { className: "flex items-center px-1 border-r border-discord-bg-tertiary/50 h-full", children: ['👍', '❤️', '😂', '😮'].map(emoji => (_jsx("button", { onClick: () => toggleReaction(emoji), className: "p-1 hover:bg-discord-modifier-hover rounded transition-colors text-[16px] leading-none", children: emoji }, emoji))) }), _jsx("button", { onClick: () => setReplyTo(message), className: "px-2 h-full text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex items-center justify-center", title: "Reply", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" }) }) }), isAuthor && (_jsx("button", { onClick: () => {
|
||||
setEditContent(message.content ?? '');
|
||||
setIsEditing(true);
|
||||
}, className: "p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Edit", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" }) }) })), canDelete && (_jsx("button", { onClick: () => deleteMessage(message.id), className: "p-1.5 text-discord-text-muted hover:text-discord-red transition-colors", title: "Delete", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" }) }) }))] }))] }));
|
||||
}, className: "px-2 h-full text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex items-center justify-center", title: "Edit", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })), canDelete && (_jsx("button", { onClick: () => deleteMessage(message.id), className: "px-2 h-full text-discord-text-muted hover:text-discord-red hover:bg-discord-modifier-hover transition-all flex items-center justify-center", title: "Delete", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" }) }) }))] }))] }));
|
||||
if (contextMenuItems.length > 0) {
|
||||
return _jsx(ContextMenu, { items: contextMenuItems, children: content });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../stores/authStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Embed } from './Embed';
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageWithUser;
|
||||
@@ -42,12 +43,49 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
||||
const members = useServerStore((s) => s.members);
|
||||
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
|
||||
const isAuthor = currentUser?.id === message.userId;
|
||||
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
|
||||
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
|
||||
const canDelete = isAuthor || isAdminUser;
|
||||
|
||||
const addReaction = useChatStore((s) => s.addReaction);
|
||||
const removeReaction = useChatStore((s) => s.removeReaction);
|
||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||
|
||||
const toggleReaction = (emoji: string) => {
|
||||
const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji);
|
||||
if (hasReacted) {
|
||||
removeReaction(message.id, emoji);
|
||||
} else {
|
||||
addReaction(message.id, emoji);
|
||||
}
|
||||
};
|
||||
|
||||
const reactionGroups = (message.reactions || []).reduce((acc, r) => {
|
||||
const group = acc[r.emoji] || { count: 0, me: false };
|
||||
group.count++;
|
||||
if (r.userId === currentUser?.id) {
|
||||
group.me = true;
|
||||
}
|
||||
acc[r.emoji] = group;
|
||||
return acc;
|
||||
}, {} as Record<string, { count: number; me: boolean }>);
|
||||
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const firstUrl = message.content?.match(urlRegex)?.[0];
|
||||
|
||||
const handleUsernameClick = (e: React.MouseEvent) => {
|
||||
if (!message.user) return;
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(message.user, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.right + 16,
|
||||
});
|
||||
};
|
||||
|
||||
const contextMenuItems = [];
|
||||
if (isAuthor) {
|
||||
contextMenuItems.push({
|
||||
@@ -84,112 +122,175 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
|
||||
const roleColor = (() => {
|
||||
const member = members.find(m => m.userId === message.userId);
|
||||
if (member?.role === 'owner') return 'text-discord-red';
|
||||
if (member?.role === 'admin') return 'text-discord-blurple';
|
||||
return 'text-white';
|
||||
if (member?.roles && member.roles.length > 0) {
|
||||
return { color: member.roles[0]!.color };
|
||||
}
|
||||
if (member?.role === 'owner') return { color: '#da373c' };
|
||||
if (member?.role === 'admin') return { color: '#5865f2' };
|
||||
return { color: '#dbdee1' };
|
||||
})();
|
||||
|
||||
const replyRoleColor = (msg: any) => {
|
||||
const member = members.find(m => m.userId === msg.userId);
|
||||
if (member?.roles && member.roles.length > 0) {
|
||||
return { color: member.roles[0]!.color };
|
||||
}
|
||||
if (member?.role === 'owner') return { color: '#da373c' };
|
||||
if (member?.role === 'admin') return { color: '#5865f2' };
|
||||
return { color: '#dbdee1' };
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`}
|
||||
className={`group relative flex px-4 py-0.5 hover:bg-[#2e3035]/30 transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Reply Line */}
|
||||
{message.replyTo && (
|
||||
<div className="absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-[#4e5058] rounded-tl-[6px] opacity-60" />
|
||||
)}
|
||||
|
||||
{/* Avatar or timestamp column */}
|
||||
<div className="w-[72px] flex-shrink-0 flex items-start justify-center">
|
||||
{isFirstInGroup ? (
|
||||
<Avatar
|
||||
src={message.user.avatar}
|
||||
name={displayName}
|
||||
size={40}
|
||||
className="mt-0.5 cursor-pointer"
|
||||
/>
|
||||
<div className="w-[72px] flex-shrink-0 flex items-start justify-start pl-0.5">
|
||||
{isFirstInGroup || message.replyTo ? (
|
||||
<div className="mt-1">
|
||||
<Avatar
|
||||
src={message.user.avatar}
|
||||
name={displayName}
|
||||
size={40}
|
||||
user={message.user}
|
||||
className="hover:drop-shadow-md transition-all active:translate-y-[1px]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className={`text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`}>
|
||||
<span className={`text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-2 select-none w-full text-center leading-[1.375rem] font-medium`}>
|
||||
{formatHoverTime(message.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFirstInGroup && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`font-medium cursor-pointer hover:underline ${roleColor}`}>
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
{message.replyTo && (
|
||||
<div className="flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply">
|
||||
<Avatar src={message.replyTo.user.avatar} name={message.replyTo.user.username} size={16} />
|
||||
<span
|
||||
className="text-[14px] font-bold text-discord-text-header hover:underline"
|
||||
style={message.replyTo ? replyRoleColor(message.replyTo) : undefined}
|
||||
>
|
||||
{message.replyTo.user.displayName ?? message.replyTo.user.username}
|
||||
</span>
|
||||
<span className="text-[14px] text-discord-text-normal truncate max-w-[400px] hover:text-white">
|
||||
{message.replyTo.content}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isFirstInGroup || message.replyTo) && (
|
||||
<div className="flex items-baseline gap-2 mb-0.5">
|
||||
<span
|
||||
onClick={handleUsernameClick}
|
||||
className="font-bold cursor-pointer hover:underline text-[16px] leading-tight"
|
||||
style={roleColor}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-xs text-discord-text-muted">
|
||||
<span className="text-[12px] text-discord-text-muted leading-tight font-medium hover:cursor-default">
|
||||
{formatTime(message.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-1">
|
||||
<div className="mt-1 w-full">
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={handleEditSubmit}
|
||||
className="w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm"
|
||||
className="w-full p-3 bg-discord-bg-input rounded-lg text-discord-text-primary outline-none resize-none text-[16px] leading-[1.375rem] shadow-inner"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-discord-text-muted mt-1">
|
||||
escape to <button onClick={() => setIsEditing(false)} className="text-[#00aff4] hover:underline">cancel</button>
|
||||
<p className="text-[12px] text-discord-text-muted mt-1.5 ml-1">
|
||||
escape to <button onClick={() => setIsEditing(false)} className="text-discord-text-link hover:underline">cancel</button>
|
||||
{' '}• enter to <button onClick={() => {
|
||||
if (editContent.trim()) {
|
||||
editMessage(message.id, editContent.trim());
|
||||
setIsEditing(false);
|
||||
}
|
||||
}} className="text-[#00aff4] hover:underline">save</button>
|
||||
}} className="text-discord-text-link hover:underline">save</button>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
{message.content && (
|
||||
<div className="text-discord-text-primary text-sm leading-[1.375rem] break-words">
|
||||
<div className="text-discord-text-normal text-[16px] leading-[1.375rem] break-words whitespace-pre-wrap selection:bg-discord-blurple/30">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <span>{children}</span>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-[#00aff4] hover:underline">
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-discord-text-link hover:underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono">
|
||||
<code className="px-1 py-0.5 bg-discord-bg-tertiary rounded text-[14px] font-mono">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto">
|
||||
<pre className="mt-1 p-3 bg-discord-bg-tertiary border border-discord-bg-tertiary/50 rounded-md text-[14px] font-mono overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
|
||||
strong: ({ children }) => <strong className="font-bold text-discord-text-primary">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
{message.editedAt && (
|
||||
<span className="text-[10px] text-discord-text-muted ml-1">(edited)</span>
|
||||
<span className="text-[10px] text-discord-text-muted ml-1 select-none font-medium">(edited)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{Object.keys(reactionGroups).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{Object.entries(reactionGroups).map(([emoji, { count, me }]) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => toggleReaction(emoji)}
|
||||
className={`flex items-center gap-1.5 px-1.5 py-0.5 rounded-[8px] text-[14px] font-medium border transition-colors ${
|
||||
me
|
||||
? 'bg-discord-blurple/15 border-discord-blurple text-discord-blurple'
|
||||
: 'bg-discord-bg-secondary border-transparent text-discord-text-muted hover:border-discord-text-muted/30'
|
||||
}`}
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
<span className={me ? 'text-discord-blurple' : 'text-discord-text-normal'}>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embeds */}
|
||||
{!isEditing && firstUrl && <Embed url={firstUrl} />}
|
||||
|
||||
{/* Attachments */}
|
||||
{message.attachments.length > 0 && (
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="mt-1 grid gap-2">
|
||||
{message.attachments.map((att) => {
|
||||
const isImage = att.mimetype.startsWith('image/');
|
||||
if (isImage) {
|
||||
return (
|
||||
<div key={att.id} className="max-w-[400px]">
|
||||
<div key={att.id} className="max-w-fit mt-1 rounded-lg overflow-hidden border border-discord-bg-tertiary/50 bg-discord-bg-tertiary/20">
|
||||
<img
|
||||
src={`/api/uploads/${att.filename}`}
|
||||
alt={att.originalName}
|
||||
className="max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow"
|
||||
className="max-w-full max-h-[350px] object-contain cursor-pointer hover:brightness-95 transition-all"
|
||||
onClick={() => openImagePreview(`/api/uploads/${att.filename}`)}
|
||||
loading="lazy"
|
||||
/>
|
||||
@@ -201,14 +302,16 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
key={att.id}
|
||||
href={`/api/uploads/${att.filename}`}
|
||||
download={att.originalName}
|
||||
className="flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]"
|
||||
className="flex items-center gap-3 p-4 bg-discord-bg-secondary/50 rounded-lg border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-all max-w-[400px] mt-1 group/att"
|
||||
>
|
||||
<svg className="w-6 h-6 text-discord-text-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<div className="p-2 bg-discord-bg-tertiary rounded text-discord-text-muted group-hover/att:text-discord-text-primary transition-colors">
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[#00aff4] text-sm truncate hover:underline">{att.originalName}</p>
|
||||
<p className="text-xs text-discord-text-muted">
|
||||
<p className="text-discord-text-link text-[15px] font-medium truncate hover:underline">{att.originalName}</p>
|
||||
<p className="text-[12px] text-discord-text-muted font-medium">
|
||||
{att.size < 1024 ? `${att.size} B` :
|
||||
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
|
||||
`${(att.size / 1048576).toFixed(1)} MB`}
|
||||
@@ -219,35 +322,55 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons on hover */}
|
||||
{isHovered && !isEditing && contextMenuItems.length > 0 && (
|
||||
<div className="absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md">
|
||||
{isHovered && !isEditing && (
|
||||
<div className="absolute -top-[18px] right-4 flex items-center bg-discord-bg-primary border border-discord-bg-tertiary/50 rounded-[4px] shadow-elevation-low overflow-hidden z-10 h-8">
|
||||
<div className="flex items-center px-1 border-r border-discord-bg-tertiary/50 h-full">
|
||||
{['👍', '❤️', '😂', '😮'].map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => toggleReaction(emoji)}
|
||||
className="p-1 hover:bg-discord-modifier-hover rounded transition-colors text-[16px] leading-none"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setReplyTo(message)}
|
||||
className="px-2 h-full text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex items-center justify-center"
|
||||
title="Reply"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
|
||||
</svg>
|
||||
</button>
|
||||
{isAuthor && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditContent(message.content ?? '');
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors"
|
||||
className="px-2 h-full text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex items-center justify-center"
|
||||
title="Edit"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" />
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => deleteMessage(message.id)}
|
||||
className="p-1.5 text-discord-text-muted hover:text-discord-red transition-colors"
|
||||
className="px-2 h-full text-discord-text-muted hover:text-discord-red hover:bg-discord-modifier-hover transition-all flex items-center justify-center"
|
||||
title="Delete"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,8 @@ export function MessageInput({ channelId, channelName }) {
|
||||
const fileInputRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const replyTo = useChatStore((s) => s.replyTo);
|
||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||
const typingTimeoutRef = useRef();
|
||||
const handleTyping = useCallback(() => {
|
||||
if (typingTimeoutRef.current)
|
||||
@@ -89,11 +91,11 @@ export function MessageInput({ channelId, channelName }) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
|
||||
};
|
||||
return (_jsx("div", { className: "px-4 pb-6", children: _jsxs("div", { className: "bg-discord-bg-input rounded-lg", onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[100px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary", children: [_jsx("svg", { className: "w-5 h-5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }) }), _jsx("span", { className: "truncate", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity", children: "\u2715" })] }, i))) })), _jsxs("div", { className: "flex items-end", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Attach file", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => {
|
||||
const selected = Array.from(e.target.files ?? []);
|
||||
if (selected.length > 0) {
|
||||
setFiles((prev) => [...prev, ...selected]);
|
||||
}
|
||||
e.target.value = '';
|
||||
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message #${channelName}`, className: "flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) }))] })] }) }));
|
||||
return (_jsxs("div", { className: "px-4 pb-6 flex-shrink-0", children: [replyTo && (_jsxs("div", { className: "bg-[#2e3035] rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-discord-bg-tertiary/50", children: [_jsxs("div", { className: "flex items-center gap-1 text-[14px] text-discord-text-normal truncate", children: [_jsx("span", { className: "opacity-60", children: "Replying to" }), _jsx("span", { className: "font-bold", children: replyTo.user.displayName ?? replyTo.user.username })] }), _jsx("button", { onClick: () => setReplyTo(null), className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] })), _jsxs("div", { className: `bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`, onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded-lg p-2 max-w-[200px] shadow-sm border border-discord-bg-tertiary", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[150px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary py-4 px-2", children: [_jsx("svg", { className: "w-8 h-8 opacity-60", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }) }), _jsx("span", { className: "truncate max-w-[120px] font-medium", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-2 -right-2 w-7 h-7 bg-discord-red hover:bg-discord-red-hover shadow-lg rounded-lg flex items-center justify-center text-white transition-colors z-10", children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" }) }) })] }, i))) })), _jsxs("div", { className: "flex items-start px-1", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors sticky top-0", title: "Attach file", children: _jsx("div", { className: "bg-discord-text-muted/20 hover:bg-discord-text-muted/40 rounded-full p-0.5 transition-colors", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => {
|
||||
const selected = Array.from(e.target.files ?? []);
|
||||
if (selected.length > 0) {
|
||||
setFiles((prev) => [...prev, ...selected]);
|
||||
}
|
||||
e.target.value = '';
|
||||
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message #${channelName}`, className: "flex-1 py-[11px] px-1 bg-transparent text-discord-text-primary placeholder-discord-text-muted/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) })), _jsx("button", { className: "p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" }) }) }), _jsx("button", { onClick: handleSubmit, disabled: !content.trim() && files.length === 0, className: "p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors disabled:opacity-30 disabled:hover:text-discord-text-muted", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" }) }) })] })] })] }));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const replyTo = useChatStore((s) => s.replyTo);
|
||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleTyping = useCallback(() => {
|
||||
@@ -103,52 +105,72 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-6">
|
||||
<div className="px-4 pb-6 flex-shrink-0">
|
||||
{replyTo && (
|
||||
<div className="bg-[#2e3035] rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-discord-bg-tertiary/50">
|
||||
<div className="flex items-center gap-1 text-[14px] text-discord-text-normal truncate">
|
||||
<span className="opacity-60">Replying to</span>
|
||||
<span className="font-bold">{replyTo.user.displayName ?? replyTo.user.username}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setReplyTo(null)}
|
||||
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="bg-discord-bg-input rounded-lg"
|
||||
className={`bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* File previews */}
|
||||
{files.length > 0 && (
|
||||
<div className="p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2">
|
||||
<div className="p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30">
|
||||
{files.map((file, i) => (
|
||||
<div key={i} className="relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]">
|
||||
<div key={i} className="relative group bg-discord-bg-secondary rounded-lg p-2 max-w-[200px] shadow-sm border border-discord-bg-tertiary">
|
||||
{file.type.startsWith('image/') ? (
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
className="max-h-[100px] rounded object-cover"
|
||||
className="max-h-[150px] rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-discord-text-secondary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<div className="flex items-center gap-2 text-sm text-discord-text-secondary py-4 px-2">
|
||||
<svg className="w-8 h-8 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="truncate">{file.name}</span>
|
||||
<span className="truncate max-w-[120px] font-medium">{file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
className="absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute -top-2 -right-2 w-7 h-7 bg-discord-red hover:bg-discord-red-hover shadow-lg rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
||||
>
|
||||
✕
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="flex items-start px-1">
|
||||
{/* File attach button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors"
|
||||
className="p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors sticky top-0"
|
||||
title="Attach file"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||
</svg>
|
||||
<div className="bg-discord-text-muted/20 hover:bg-discord-text-muted/40 rounded-full p-0.5 transition-colors">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -172,7 +194,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={`Message #${channelName}`}
|
||||
className="flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]"
|
||||
className="flex-1 py-[11px] px-1 bg-transparent text-discord-text-primary placeholder-discord-text-muted/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
||||
rows={1}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
@@ -186,6 +208,24 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emoji button placeholder */}
|
||||
<button className="p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Send Button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim() && files.length === 0}
|
||||
className="p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors disabled:opacity-30 disabled:hover:text-discord-text-muted"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,10 +77,10 @@ export function MessageList({ channelId }) {
|
||||
if (isLoading && messages.length === 0) {
|
||||
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) }));
|
||||
}
|
||||
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-6 pb-4", children: [_jsx("h3", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-muted text-sm mt-1", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-4 border-b border-discord-bg-hover" })] })), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => {
|
||||
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white", children: _jsx("svg", { width: "42", height: "42", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-secondary text-[16px] mt-2", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => {
|
||||
const prevMsg = messages[i - 1];
|
||||
const showDate = shouldShowDateDivider(prevMsg, msg);
|
||||
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
|
||||
return (_jsxs(React.Fragment, { children: [showDate && (_jsxs("div", { className: "flex items-center px-4 my-4", children: [_jsx("div", { className: "flex-1 border-t border-discord-bg-hover" }), _jsx("span", { className: "px-2 text-xs font-semibold text-discord-text-muted", children: formatDateDivider(msg.createdAt) }), _jsx("div", { className: "flex-1 border-t border-discord-bg-hover" })] })), _jsx(Message, { message: msg, isCompact: !isFirstInGroup, isFirstInGroup: isFirstInGroup })] }, msg.id));
|
||||
return (_jsxs(React.Fragment, { children: [showDate && (_jsxs("div", { className: "flex items-center px-4 my-6 select-none pointer-events-none", children: [_jsx("div", { className: "flex-1 h-[1px] bg-discord-modifier-accent" }), _jsx("span", { className: "px-2 text-[12px] font-bold text-discord-text-muted leading-tight", children: formatDateDivider(msg.createdAt) }), _jsx("div", { className: "flex-1 h-[1px] bg-discord-modifier-accent" })] })), _jsx(Message, { message: msg, isCompact: !isFirstInGroup, isFirstInGroup: isFirstInGroup })] }, msg.id));
|
||||
}) }), _jsx("div", { ref: bottomRef })] }));
|
||||
}
|
||||
|
||||
@@ -108,10 +108,15 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
)}
|
||||
|
||||
{!hasMore && (
|
||||
<div className="px-4 pt-6 pb-4">
|
||||
<h3 className="text-2xl font-bold text-discord-text-primary">Welcome to the channel!</h3>
|
||||
<p className="text-discord-text-muted text-sm mt-1">This is the start of the conversation.</p>
|
||||
<div className="mt-4 border-b border-discord-bg-hover" />
|
||||
<div className="px-4 pt-8 pb-4">
|
||||
<div className="w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white">
|
||||
<svg width="42" height="42" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-[32px] leading-10 font-bold text-discord-text-primary">Welcome to the channel!</h3>
|
||||
<p className="text-discord-text-secondary text-[16px] mt-2">This is the start of the conversation.</p>
|
||||
<div className="mt-6 border-b border-discord-modifier-accent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -124,12 +129,12 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div className="flex items-center px-4 my-4">
|
||||
<div className="flex-1 border-t border-discord-bg-hover" />
|
||||
<span className="px-2 text-xs font-semibold text-discord-text-muted">
|
||||
<div className="flex items-center px-4 my-6 select-none pointer-events-none">
|
||||
<div className="flex-1 h-[1px] bg-discord-modifier-accent" />
|
||||
<span className="px-2 text-[12px] font-bold text-discord-text-muted leading-tight">
|
||||
{formatDateDivider(msg.createdAt)}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-discord-bg-hover" />
|
||||
<div className="flex-1 h-[1px] bg-discord-modifier-accent" />
|
||||
</div>
|
||||
)}
|
||||
<Message
|
||||
|
||||
@@ -25,5 +25,5 @@ export function TypingIndicator({ channelId }) {
|
||||
else {
|
||||
text = 'Several people are typing';
|
||||
}
|
||||
return (_jsx("div", { className: "h-6 px-4 flex items-center text-xs text-discord-text-muted", children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs("span", { className: "flex gap-0.5", children: [_jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '0ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '150ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '300ms' } })] }), _jsx("span", { className: "font-medium", children: text }), _jsx("span", { children: "..." })] }) }));
|
||||
return (_jsx("div", { className: "h-[24px] px-4 flex items-center text-[12px] text-discord-text-header font-medium select-none pointer-events-none", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex gap-[2px] bg-discord-bg-accent/20 rounded-full px-2 py-1", children: [_jsx("div", { className: "w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce", style: { animationDelay: '0ms', animationDuration: '0.8s' } }), _jsx("div", { className: "w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce", style: { animationDelay: '150ms', animationDuration: '0.8s' } }), _jsx("div", { className: "w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce", style: { animationDelay: '300ms', animationDuration: '0.8s' } })] }), _jsx("span", { className: "truncate max-w-[400px]", children: _jsx("span", { className: "font-bold", children: text }) })] }) }));
|
||||
}
|
||||
|
||||
@@ -30,15 +30,16 @@ export function TypingIndicator({ channelId }: TypingIndicatorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-6 px-4 flex items-center text-xs text-discord-text-muted">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="flex gap-0.5">
|
||||
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
<div className="h-[24px] px-4 flex items-center text-[12px] text-discord-text-header font-medium select-none pointer-events-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-[2px] bg-discord-bg-accent/20 rounded-full px-2 py-1">
|
||||
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '0ms', animationDuration: '0.8s' }} />
|
||||
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '150ms', animationDuration: '0.8s' }} />
|
||||
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '300ms', animationDuration: '0.8s' }} />
|
||||
</div>
|
||||
<span className="truncate max-w-[400px]">
|
||||
<span className="font-bold">{text}</span>
|
||||
</span>
|
||||
<span className="font-medium">{text}</span>
|
||||
<span>...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user