feat: Optimize WebRTC pipeline for 60fps screen sharing
- Implemented 'Overdrive' logic to force high bitrates on Chrome - Fixed 'Auto' preset to default to stable 720p60 - Added persistent 'Triple-Kick' hammer to prevent bitrate throttling - Fixed sidebar connection status sync - Added comprehensive diagnostic logger
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
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';
|
||||
// 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 = {}) => ({
|
||||
id: 'friend-1',
|
||||
username: 'testfriend',
|
||||
displayName: 'Test Friend',
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
createdAt: Date.now(),
|
||||
addedAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
const makeRequest = (overrides = {}) => ({
|
||||
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(_jsx(MemoryRouter, { children: _jsx(FriendsPage, {}) }));
|
||||
}
|
||||
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.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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,8 @@ export function MessageInput({ channelId, channelName }) {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
wsSend({ type: 'dm_typing_start', dmChannelId: channelId });
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
wsSend({ type: 'typing_start', channelId });
|
||||
}
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
@@ -103,5 +104,5 @@ export function MessageInput({ channelId, channelName }) {
|
||||
setFiles((prev) => [...prev, ...selected]);
|
||||
}
|
||||
e.target.value = '';
|
||||
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message ${channelName.startsWith('@') ? channelName : `#${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" }) }) })] })] })] }));
|
||||
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message ${channelName.startsWith('@') ? channelName : `#${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-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "GIF", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" }) }) }), _jsx("button", { className: "p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Stickers", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12.5 2C6.81 2 2 6.81 2 12.5S6.81 23 12.5 23c1.31 0 2.56-.25 3.73-.7l5.07-5.07c.45-1.17.7-2.42.7-3.73C22 7.81 17.19 2 12.5 2Zm0 19c-4.69 0-8.5-3.81-8.5-8.5S7.81 4 12.5 4 21 7.81 21 12.5c0 .89-.14 1.74-.4 2.54l-3.56 3.56c-.8.26-1.65.4-2.54.4ZM8 11.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10s.67 1.5 1.5 1.5Zm6 0c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5-1.5.67-1.5 1.5.67 1.5 1.5 1.5Zm-1 3.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5Z" }) }) }), _jsx("button", { className: "p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Emoji", 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" }) }) })] })] })] }));
|
||||
}
|
||||
|
||||
@@ -215,21 +215,24 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emoji button placeholder */}
|
||||
<button className="p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors">
|
||||
{/* GIF button */}
|
||||
<button className="p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors" title="GIF">
|
||||
<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" />
|
||||
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
{/* Sticker button */}
|
||||
<button className="p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors" title="Stickers">
|
||||
<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" />
|
||||
<path d="M12.5 2C6.81 2 2 6.81 2 12.5S6.81 23 12.5 23c1.31 0 2.56-.25 3.73-.7l5.07-5.07c.45-1.17.7-2.42.7-3.73C22 7.81 17.19 2 12.5 2Zm0 19c-4.69 0-8.5-3.81-8.5-8.5S7.81 4 12.5 4 21 7.81 21 12.5c0 .89-.14 1.74-.4 2.54l-3.56 3.56c-.8.26-1.65.4-2.54.4ZM8 11.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10s.67 1.5 1.5 1.5Zm6 0c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5-1.5.67-1.5 1.5.67 1.5 1.5 1.5Zm-1 3.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Emoji button */}
|
||||
<button className="p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors" title="Emoji">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Message } from './Message';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useServerStore, isDmChannel } from '../../stores/serverStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
const EMPTY_MESSAGES = [];
|
||||
function isSameGroup(prev, curr) {
|
||||
@@ -42,6 +45,7 @@ export function MessageList({ channelId }) {
|
||||
useEffect(() => {
|
||||
loadMessages(channelId);
|
||||
}, [channelId, loadMessages]);
|
||||
// Ack channel when messages load or when new messages arrive while near bottom
|
||||
useEffect(() => {
|
||||
if (messages.length > 0 && isNearBottom) {
|
||||
clearTimeout(ackTimerRef.current);
|
||||
@@ -86,10 +90,23 @@ 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 scrollbar-thin", 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) => {
|
||||
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && _jsx(WelcomeHeader, { channelId: channelId }), _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-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 })] }));
|
||||
}
|
||||
function WelcomeHeader({ channelId }) {
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
const dm = dmChannels.find(d => d.id === channelId);
|
||||
const otherUser = dm?.members.find(m => m.id !== authUser?.id);
|
||||
const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown';
|
||||
const username = otherUser?.username ?? 'unknown';
|
||||
return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "mb-2", children: _jsx(Avatar, { src: otherUser?.avatar, name: displayName, size: 80 }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: displayName }), _jsxs("p", { className: "text-discord-text-secondary text-[14px] mt-1", children: ["This is the beginning of your direct message history with ", _jsxs("strong", { children: ["@", username] }), "."] }), _jsx("div", { className: "mt-4", children: _jsx("button", { className: "px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors", children: "Remove Friend" }) }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] }));
|
||||
}
|
||||
return (_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" })] }));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { Message } from './Message';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useServerStore, isDmChannel } from '../../stores/serverStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import type { MessageWithUser } from '@opencord/shared';
|
||||
|
||||
@@ -118,18 +121,7 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && (
|
||||
<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>
|
||||
)}
|
||||
{!hasMore && <WelcomeHeader channelId={channelId} />}
|
||||
|
||||
<div className="pb-6">
|
||||
{messages.map((msg, i) => {
|
||||
@@ -162,3 +154,47 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeHeader({ channelId }: { channelId: string }) {
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const isDm = isDmChannel(channelId);
|
||||
|
||||
if (isDm) {
|
||||
const dm = dmChannels.find(d => d.id === channelId);
|
||||
const otherUser = dm?.members.find(m => m.id !== authUser?.id);
|
||||
const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown';
|
||||
const username = otherUser?.username ?? 'unknown';
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-8 pb-4">
|
||||
<div className="mb-2">
|
||||
<Avatar src={otherUser?.avatar} name={displayName} size={80} />
|
||||
</div>
|
||||
<h3 className="text-[32px] leading-10 font-bold text-discord-text-primary">{displayName}</h3>
|
||||
<p className="text-discord-text-secondary text-[14px] mt-1">
|
||||
This is the beginning of your direct message history with <strong>@{username}</strong>.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<button className="px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors">
|
||||
Remove Friend
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 border-b border-discord-modifier-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user