Better Chat
All checks were successful
Build and Release / build-and-release (push) Successful in 11m38s
All checks were successful
Build and Release / build-and-release (push) Successful in 11m38s
This commit is contained in:
@@ -34,7 +34,12 @@
|
|||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(git push:*)",
|
"Bash(git push:*)",
|
||||||
"Bash(npm run build:web:*)"
|
"Bash(npm run build:web:*)",
|
||||||
|
"WebFetch(domain:www.saurabhmisra.dev)",
|
||||||
|
"WebFetch(domain:getstream.io)",
|
||||||
|
"WebFetch(domain:blog.logrocket.com)",
|
||||||
|
"WebFetch(domain:virtuoso.dev)",
|
||||||
|
"WebFetch(domain:medium.com)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -5,5 +5,3 @@
|
|||||||
- On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile.
|
- On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile.
|
||||||
|
|
||||||
- Add photo / video albums like Commit https://commet.chat/
|
- Add photo / video albums like Commit https://commet.chat/
|
||||||
|
|
||||||
Lets allow custom emojis in the server. So if you go to the server settings on discord you can upload custom emojies. You put a image, a emoji name, and then you can use it in the chat like :emoji_name:. Discord resizes the image to a max of 32px thats either width or height depending on the image aspect ratio. The allow transparent background images and gif's. We dont allow duplicate names to existing emojis that we already have by default or custom emojies
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.21",
|
"version": "1.0.22",
|
||||||
"description": "Discord Clone - Electron app",
|
"description": "Discord Clone - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -113,6 +113,26 @@ export const send = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const sendBatch = mutation({
|
||||||
|
args: {
|
||||||
|
messages: v.array(v.object({
|
||||||
|
channelId: v.id("channels"),
|
||||||
|
senderId: v.id("userProfiles"),
|
||||||
|
ciphertext: v.string(),
|
||||||
|
nonce: v.string(),
|
||||||
|
signature: v.string(),
|
||||||
|
keyVersion: v.number(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
returns: v.object({ count: v.number() }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
for (const msg of args.messages) {
|
||||||
|
await ctx.db.insert("messages", { ...msg });
|
||||||
|
}
|
||||||
|
return { count: args.messages.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const edit = mutation({
|
export const edit = mutation({
|
||||||
args: {
|
args: {
|
||||||
id: v.id("messages"),
|
id: v.id("messages"),
|
||||||
|
|||||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"apps/electron": {
|
"apps/electron": {
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"version": "1.0.17",
|
"version": "1.0.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discord-clone/shared": "*",
|
"@discord-clone/shared": "*",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
@@ -8176,6 +8176,16 @@
|
|||||||
"react": ">= 0.14.0"
|
"react": ">= 0.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-virtuoso": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16 || >=17 || >= 18 || >= 19",
|
||||||
|
"react-dom": ">=16 || >=17 || >= 18 || >=19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-binary-file-arch": {
|
"node_modules/read-binary-file-arch": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||||
@@ -9870,7 +9880,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shared": {
|
"packages/shared": {
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"version": "1.0.17",
|
"version": "1.0.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/presence": "^0.3.0",
|
"@convex-dev/presence": "^0.3.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -9886,6 +9896,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
|
"react-virtuoso": "^4.18.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sql.js": "^1.12.0"
|
"sql.js": "^1.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.21",
|
"version": "1.0.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.jsx",
|
"main": "src/App.jsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
|
"react-virtuoso": "^4.18.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sql.js": "^1.12.0"
|
"sql.js": "^1.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
|
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
import {
|
import {
|
||||||
@@ -23,11 +24,13 @@ import Tooltip from './Tooltip';
|
|||||||
import UserProfilePopup from './UserProfilePopup';
|
import UserProfilePopup from './UserProfilePopup';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
import MentionMenu from './MentionMenu';
|
import MentionMenu from './MentionMenu';
|
||||||
|
import SlashCommandMenu from './SlashCommandMenu';
|
||||||
import MessageItem, { getUserColor } from './MessageItem';
|
import MessageItem, { getUserColor } from './MessageItem';
|
||||||
import ColoredIcon from './ColoredIcon';
|
import ColoredIcon from './ColoredIcon';
|
||||||
import { usePlatform } from '../platform';
|
import { usePlatform } from '../platform';
|
||||||
import { useVoice } from '../contexts/VoiceContext';
|
import { useVoice } from '../contexts/VoiceContext';
|
||||||
import { useSearch } from '../contexts/SearchContext';
|
import { useSearch } from '../contexts/SearchContext';
|
||||||
|
import { generateUniqueMessage } from '../utils/floodMessages';
|
||||||
|
|
||||||
const metadataCache = new Map();
|
const metadataCache = new Map();
|
||||||
const attachmentCache = new Map();
|
const attachmentCache = new Map();
|
||||||
@@ -133,6 +136,17 @@ const filterRolesForMention = (roles, query) => {
|
|||||||
return [...prefix, ...substring];
|
return [...prefix, ...substring];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SLASH_COMMANDS = [
|
||||||
|
{ name: 'ping', description: 'Responds with Pong!', category: 'Built-In' },
|
||||||
|
{ name: 'flood', description: 'Generate test messages (e.g. /flood 100)', category: 'Testing' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterSlashCommands = (commands, query) => {
|
||||||
|
if (!query) return commands;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return commands.filter(c => c.name.toLowerCase().startsWith(q));
|
||||||
|
};
|
||||||
|
|
||||||
const isNewDay = (current, previous) => {
|
const isNewDay = (current, previous) => {
|
||||||
if (!previous) return true;
|
if (!previous) return true;
|
||||||
return current.getDate() !== previous.getDate()
|
return current.getDate() !== previous.getDate()
|
||||||
@@ -490,7 +504,7 @@ const InputContextMenu = ({ x, y, onClose, onPaste }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned }) => {
|
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned, jumpToMessageId, onClearJumpToMessage }) => {
|
||||||
const { crypto } = usePlatform();
|
const { crypto } = usePlatform();
|
||||||
const { isReceivingScreenShareAudio } = useVoice();
|
const { isReceivingScreenShareAudio } = useVoice();
|
||||||
const searchCtx = useSearch();
|
const searchCtx = useSearch();
|
||||||
@@ -512,26 +526,40 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const [profilePopup, setProfilePopup] = useState(null);
|
const [profilePopup, setProfilePopup] = useState(null);
|
||||||
const [mentionQuery, setMentionQuery] = useState(null);
|
const [mentionQuery, setMentionQuery] = useState(null);
|
||||||
const [mentionIndex, setMentionIndex] = useState(0);
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
|
const [slashQuery, setSlashQuery] = useState(null);
|
||||||
|
const [slashIndex, setSlashIndex] = useState(0);
|
||||||
|
const [ephemeralMessages, setEphemeralMessages] = useState([]);
|
||||||
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
|
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
|
||||||
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
|
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
|
||||||
|
|
||||||
const messagesEndRef = useRef(null);
|
|
||||||
const messagesContainerRef = useRef(null);
|
|
||||||
const inputDivRef = useRef(null);
|
const inputDivRef = useRef(null);
|
||||||
const savedRangeRef = useRef(null);
|
const savedRangeRef = useRef(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const typingTimeoutRef = useRef(null);
|
const typingTimeoutRef = useRef(null);
|
||||||
const lastTypingEmitRef = useRef(0);
|
const lastTypingEmitRef = useRef(0);
|
||||||
const isInitialLoadRef = useRef(true);
|
const isInitialLoadRef = useRef(true);
|
||||||
|
const decryptionDoneRef = useRef(false);
|
||||||
|
const channelLoadIdRef = useRef(0);
|
||||||
|
const jumpToMessageIdRef = useRef(null);
|
||||||
const pingSeededRef = useRef(false);
|
const pingSeededRef = useRef(false);
|
||||||
const prevScrollHeightRef = useRef(0);
|
const statusRef = useRef(null);
|
||||||
const isLoadingMoreRef = useRef(false);
|
const loadMoreRef = useRef(null);
|
||||||
const userSentMessageRef = useRef(false);
|
const userSentMessageRef = useRef(false);
|
||||||
const topSentinelRef = useRef(null);
|
const scrollOnNextDataRef = useRef(false);
|
||||||
const notifiedMessageIdsRef = useRef(new Set());
|
const notifiedMessageIdsRef = useRef(new Set());
|
||||||
const pendingNotificationIdsRef = useRef(new Set());
|
const pendingNotificationIdsRef = useRef(new Set());
|
||||||
const lastPingTimeRef = useRef(0);
|
const lastPingTimeRef = useRef(0);
|
||||||
|
|
||||||
|
// Virtuoso refs and state
|
||||||
|
const virtuosoRef = useRef(null);
|
||||||
|
const scrollerElRef = useRef(null);
|
||||||
|
const chatInputFormRef = useRef(null);
|
||||||
|
const INITIAL_FIRST_INDEX = 100000;
|
||||||
|
const [firstItemIndex, setFirstItemIndex] = useState(INITIAL_FIRST_INDEX);
|
||||||
|
const prevMessageCountRef = useRef(0);
|
||||||
|
const prevFirstMsgIdRef = useRef(null);
|
||||||
|
const isAtBottomRef = useRef(true);
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
|
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
|
||||||
@@ -545,6 +573,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
{ initialNumItems: 50 }
|
{ initialNumItems: 50 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
statusRef.current = status;
|
||||||
|
loadMoreRef.current = loadMore;
|
||||||
|
}, [status, loadMore]);
|
||||||
|
|
||||||
const typingData = useQuery(
|
const typingData = useQuery(
|
||||||
api.typing.getTyping,
|
api.typing.getTyping,
|
||||||
channelId ? { channelId } : "skip"
|
channelId ? { channelId } : "skip"
|
||||||
@@ -559,6 +592,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const startTypingMutation = useMutation(api.typing.startTyping);
|
const startTypingMutation = useMutation(api.typing.startTyping);
|
||||||
const stopTypingMutation = useMutation(api.typing.stopTyping);
|
const stopTypingMutation = useMutation(api.typing.stopTyping);
|
||||||
const markReadMutation = useMutation(api.readState.markRead);
|
const markReadMutation = useMutation(api.readState.markRead);
|
||||||
|
const sendBatchMutation = useMutation(api.messages.sendBatch);
|
||||||
|
const floodInProgressRef = useRef(false);
|
||||||
|
const floodAbortRef = useRef(false);
|
||||||
|
|
||||||
const readState = useQuery(
|
const readState = useQuery(
|
||||||
api.readState.getReadState,
|
api.readState.getReadState,
|
||||||
@@ -621,7 +657,21 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
|
|
||||||
if (needsDecryption.length === 0) {
|
if (needsDecryption.length === 0) {
|
||||||
// Still re-render from cache in case optimistic matches were added
|
// Still re-render from cache in case optimistic matches were added
|
||||||
if (!cancelled) setDecryptedMessages(buildFromCache());
|
if (!cancelled) {
|
||||||
|
decryptionDoneRef.current = true;
|
||||||
|
setDecryptedMessages(buildFromCache());
|
||||||
|
|
||||||
|
if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||||
|
const loadId = channelLoadIdRef.current;
|
||||||
|
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
if (channelLoadIdRef.current === loadId) scrollEnd();
|
||||||
|
}));
|
||||||
|
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
|
||||||
|
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
|
||||||
|
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,7 +781,21 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
// Phase 3: Re-render with newly decrypted content
|
// Phase 3: Re-render with newly decrypted content
|
||||||
|
decryptionDoneRef.current = true;
|
||||||
setDecryptedMessages(buildFromCache());
|
setDecryptedMessages(buildFromCache());
|
||||||
|
|
||||||
|
// After decryption, items may be taller — re-scroll to bottom.
|
||||||
|
// Double-rAF waits for paint + ResizeObserver cycle; escalating timeouts are safety nets.
|
||||||
|
if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||||
|
const loadId = channelLoadIdRef.current;
|
||||||
|
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
if (channelLoadIdRef.current === loadId) scrollEnd();
|
||||||
|
}));
|
||||||
|
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
|
||||||
|
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
|
||||||
|
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
processUncached();
|
processUncached();
|
||||||
@@ -758,8 +822,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't clear messageDecryptionCache — it persists across channel switches
|
// Don't clear messageDecryptionCache — it persists across channel switches
|
||||||
|
channelLoadIdRef.current += 1;
|
||||||
setDecryptedMessages([]);
|
setDecryptedMessages([]);
|
||||||
isInitialLoadRef.current = true;
|
isInitialLoadRef.current = true;
|
||||||
|
decryptionDoneRef.current = false;
|
||||||
pingSeededRef.current = false;
|
pingSeededRef.current = false;
|
||||||
notifiedMessageIdsRef.current = new Set();
|
notifiedMessageIdsRef.current = new Set();
|
||||||
pendingNotificationIdsRef.current = new Set();
|
pendingNotificationIdsRef.current = new Set();
|
||||||
@@ -768,9 +834,45 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
setUnreadDividerTimestamp(null);
|
setUnreadDividerTimestamp(null);
|
||||||
setReactionPickerMsgId(null);
|
setReactionPickerMsgId(null);
|
||||||
|
setSlashQuery(null);
|
||||||
|
setEphemeralMessages([]);
|
||||||
|
floodAbortRef.current = true;
|
||||||
|
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||||
|
prevMessageCountRef.current = 0;
|
||||||
|
prevFirstMsgIdRef.current = null;
|
||||||
onTogglePinned();
|
onTogglePinned();
|
||||||
}, [channelId]);
|
}, [channelId]);
|
||||||
|
|
||||||
|
// Sync jumpToMessageId prop to ref
|
||||||
|
useEffect(() => {
|
||||||
|
jumpToMessageIdRef.current = jumpToMessageId || null;
|
||||||
|
}, [jumpToMessageId]);
|
||||||
|
|
||||||
|
// Jump to a specific message (from search results)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
|
||||||
|
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
|
||||||
|
if (idx !== -1 && virtuosoRef.current) {
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
virtuosoRef.current.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById(`msg-${jumpToMessageId}`);
|
||||||
|
if (el) {
|
||||||
|
el.classList.add('message-highlight');
|
||||||
|
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
onClearJumpToMessage?.();
|
||||||
|
}
|
||||||
|
}, [jumpToMessageId, decryptedMessages, onClearJumpToMessage]);
|
||||||
|
|
||||||
|
// Safety timeout: clear jumpToMessageId if message never found (too old / not loaded)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jumpToMessageId) return;
|
||||||
|
const timer = setTimeout(() => onClearJumpToMessage?.(), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [jumpToMessageId, onClearJumpToMessage]);
|
||||||
|
|
||||||
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
|
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
|
||||||
|
|
||||||
const isMentionedInContent = useCallback((content) => {
|
const isMentionedInContent = useCallback((content) => {
|
||||||
@@ -888,91 +990,93 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
...filteredMentionMembers.map(m => ({ type: 'member', ...m })),
|
...filteredMentionMembers.map(m => ({ type: 'member', ...m })),
|
||||||
];
|
];
|
||||||
const scrollToBottom = useCallback((force = false) => {
|
const scrollToBottom = useCallback((force = false) => {
|
||||||
const container = messagesContainerRef.current;
|
if (isInitialLoadRef.current) {
|
||||||
if (!container) return;
|
const el = scrollerElRef.current;
|
||||||
if (force) {
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
if (force) {
|
||||||
if (scrollHeight - scrollTop - clientHeight < 300) {
|
// Direct DOM scroll is more reliable than scrollToIndex for user-sent messages
|
||||||
container.scrollTop = container.scrollHeight;
|
const snap = () => {
|
||||||
|
const el = scrollerElRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
};
|
||||||
|
snap();
|
||||||
|
// Escalating retries for late-sizing content (images, embeds)
|
||||||
|
setTimeout(snap, 50);
|
||||||
|
setTimeout(snap, 150);
|
||||||
|
} else if (virtuosoRef.current) {
|
||||||
|
virtuosoRef.current.scrollToIndex({
|
||||||
|
index: 'LAST',
|
||||||
|
align: 'end',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
// Virtuoso: startReached replaces IntersectionObserver
|
||||||
const container = messagesContainerRef.current;
|
const handleStartReached = useCallback(() => {
|
||||||
if (!container || decryptedMessages.length === 0) return;
|
if (statusRef.current === 'CanLoadMore') {
|
||||||
|
loadMoreRef.current(50);
|
||||||
if (isLoadingMoreRef.current) {
|
|
||||||
const newScrollHeight = container.scrollHeight;
|
|
||||||
const heightDifference = newScrollHeight - prevScrollHeightRef.current;
|
|
||||||
container.scrollTop += heightDifference;
|
|
||||||
isLoadingMoreRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (userSentMessageRef.current || isInitialLoadRef.current) {
|
// Virtuoso: firstItemIndex management for prepend without jitter
|
||||||
container.scrollTop = container.scrollHeight;
|
useEffect(() => {
|
||||||
|
const prevCount = prevMessageCountRef.current;
|
||||||
|
const newCount = decryptedMessages.length;
|
||||||
|
if (newCount > prevCount && prevCount > 0) {
|
||||||
|
if (prevFirstMsgIdRef.current && decryptedMessages[0]?.id !== prevFirstMsgIdRef.current) {
|
||||||
|
const prependedCount = newCount - prevCount;
|
||||||
|
setFirstItemIndex(prev => prev - prependedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevMessageCountRef.current = newCount;
|
||||||
|
prevFirstMsgIdRef.current = decryptedMessages[0]?.id || null;
|
||||||
|
}, [decryptedMessages]);
|
||||||
|
|
||||||
|
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
|
||||||
|
const followOutput = useCallback((isAtBottom) => {
|
||||||
|
console.log('[Virtuoso] followOutput:', { isAtBottom, jumpTo: jumpToMessageIdRef.current, userSent: userSentMessageRef.current });
|
||||||
|
if (jumpToMessageIdRef.current) return false;
|
||||||
|
|
||||||
|
// If user sent a message, ALWAYS scroll to bottom aggressively
|
||||||
|
if (userSentMessageRef.current) {
|
||||||
userSentMessageRef.current = false;
|
userSentMessageRef.current = false;
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
// During initial load, disable followOutput so it doesn't conflict with manual scrollToIndex calls
|
||||||
|
if (isInitialLoadRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use 'smooth' again to see if 'auto' was causing the jump
|
||||||
|
return isAtBottom ? 'smooth' : false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Virtuoso: atBottomStateChange replaces manual scroll listener for read state
|
||||||
|
const handleAtBottomStateChange = useCallback((atBottom) => {
|
||||||
|
console.log('[Virtuoso] atBottomStateChange:', atBottom);
|
||||||
|
isAtBottomRef.current = atBottom;
|
||||||
|
if (atBottom) {
|
||||||
|
markChannelAsRead();
|
||||||
|
// Delay clearing isInitialLoadRef so self-correction has time for late-loading content
|
||||||
|
if (isInitialLoadRef.current && decryptionDoneRef.current) {
|
||||||
|
const loadId = channelLoadIdRef.current;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (channelLoadIdRef.current === loadId) {
|
||||||
isInitialLoadRef.current = false;
|
isInitialLoadRef.current = false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, 1500);
|
||||||
// Always auto-scroll if near bottom — handles decryption content changes,
|
|
||||||
// new messages, and any height shifts
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
||||||
if (scrollHeight - scrollTop - clientHeight < 300) {
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}
|
}
|
||||||
}, [decryptedMessages, rawMessages?.length]);
|
} else if (isInitialLoadRef.current && decryptionDoneRef.current) {
|
||||||
|
// Content resize pushed us off bottom during initial load — snap back
|
||||||
useEffect(() => {
|
const el = scrollerElRef.current;
|
||||||
const sentinel = topSentinelRef.current;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
const container = messagesContainerRef.current;
|
|
||||||
if (!sentinel || !container) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries[0].isIntersecting && status === 'CanLoadMore') {
|
|
||||||
prevScrollHeightRef.current = container.scrollHeight;
|
|
||||||
isLoadingMoreRef.current = true;
|
|
||||||
loadMore(50);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{ root: container, rootMargin: '200px 0px 0px 0px', threshold: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [status, loadMore]);
|
|
||||||
|
|
||||||
// Mark as read when scrolled to bottom
|
|
||||||
useEffect(() => {
|
|
||||||
const container = messagesContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
const handleScroll = () => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
||||||
if (scrollHeight - scrollTop - clientHeight < 50) {
|
|
||||||
markChannelAsRead();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
container.addEventListener('scroll', handleScroll);
|
|
||||||
return () => container.removeEventListener('scroll', handleScroll);
|
|
||||||
}, [markChannelAsRead]);
|
}, [markChannelAsRead]);
|
||||||
|
|
||||||
// Mark as read on initial load (already scrolled to bottom)
|
|
||||||
useEffect(() => {
|
|
||||||
if (decryptedMessages.length > 0) {
|
|
||||||
const container = messagesContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
||||||
if (scrollHeight - scrollTop - clientHeight < 50) {
|
|
||||||
markChannelAsRead();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [decryptedMessages.length, markChannelAsRead]);
|
|
||||||
|
|
||||||
// Mark as read when component unmounts (e.g., switching to voice channel)
|
// Mark as read when component unmounts (e.g., switching to voice channel)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -980,6 +1084,80 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Track input height for synchronous scroll adjustment
|
||||||
|
const prevInputHeightRef = useRef(0);
|
||||||
|
|
||||||
|
// Use useLayoutEffect to adjust scroll BEFORE paint when React updates (e.g. isMultiline change)
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const el = chatInputFormRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const currentHeight = el.clientHeight;
|
||||||
|
|
||||||
|
if (prevInputHeightRef.current > 0 && currentHeight !== prevInputHeightRef.current) {
|
||||||
|
const heightDiff = currentHeight - prevInputHeightRef.current;
|
||||||
|
const scroller = scrollerElRef.current;
|
||||||
|
|
||||||
|
if (scroller) {
|
||||||
|
const scrollTop = scroller.scrollTop;
|
||||||
|
const scrollHeight = scroller.scrollHeight;
|
||||||
|
const clientHeight = scroller.clientHeight;
|
||||||
|
|
||||||
|
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
|
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
|
||||||
|
|
||||||
|
// If we were at bottom (approx), force stay at bottom
|
||||||
|
if (previousDistanceFromBottom < 50) {
|
||||||
|
console.log('[LayoutEffect] Sync scroll adjustment', { heightDiff, previousDistanceFromBottom });
|
||||||
|
scroller.scrollTop = scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevInputHeightRef.current = currentHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = chatInputFormRef.current;
|
||||||
|
if (!el) {
|
||||||
|
console.error('[ResizeObserver] chatInputFormRef is null!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[ResizeObserver] Attaching to form', el);
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
const newHeight = el.clientHeight;
|
||||||
|
// We use a separate ref for ResizeObserver to avoid conflict/loop with layout effect if needed,
|
||||||
|
// but sharing prevInputHeightRef is mostly fine if we are careful.
|
||||||
|
// Actually, let's just use the ref we have.
|
||||||
|
if (newHeight !== prevInputHeightRef.current) {
|
||||||
|
const heightDiff = newHeight - prevInputHeightRef.current;
|
||||||
|
prevInputHeightRef.current = newHeight;
|
||||||
|
|
||||||
|
const scroller = scrollerElRef.current;
|
||||||
|
if (!scroller) return;
|
||||||
|
|
||||||
|
const scrollTop = scroller.scrollTop;
|
||||||
|
const scrollHeight = scroller.scrollHeight;
|
||||||
|
const clientHeight = scroller.clientHeight;
|
||||||
|
|
||||||
|
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
|
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
|
||||||
|
|
||||||
|
console.log('[ResizeObserver] Input resize:', {
|
||||||
|
newHeight,
|
||||||
|
heightDiff,
|
||||||
|
previousDistanceFromBottom
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousDistanceFromBottom < 50) {
|
||||||
|
console.log('[ResizeObserver] Forcing scroll to bottom');
|
||||||
|
scroller.scrollTop = scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const saveSelection = () => {
|
const saveSelection = () => {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
|
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
|
||||||
@@ -1048,6 +1226,134 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkSlashTrigger = () => {
|
||||||
|
if (!inputDivRef.current) return;
|
||||||
|
const text = inputDivRef.current.textContent;
|
||||||
|
if (text.startsWith('/')) {
|
||||||
|
setSlashQuery(text.slice(1));
|
||||||
|
setSlashIndex(0);
|
||||||
|
} else {
|
||||||
|
setSlashQuery(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlashSelect = (cmd) => {
|
||||||
|
if (!inputDivRef.current) return;
|
||||||
|
inputDivRef.current.textContent = `/${cmd.name}`;
|
||||||
|
setInput(`/${cmd.name}`);
|
||||||
|
setSlashQuery(null);
|
||||||
|
// Place cursor at end
|
||||||
|
const range = document.createRange();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
range.selectNodeContents(inputDivRef.current);
|
||||||
|
range.collapse(false);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
inputDivRef.current.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredSlashCommands = slashQuery !== null ? filterSlashCommands(SLASH_COMMANDS, slashQuery) : [];
|
||||||
|
|
||||||
|
const executeSlashCommand = (command, cmdArgs) => {
|
||||||
|
if (command.name === 'ping') {
|
||||||
|
setEphemeralMessages(prev => [...prev, {
|
||||||
|
id: `ephemeral-${Date.now()}`,
|
||||||
|
type: 'ephemeral',
|
||||||
|
command: '/ping',
|
||||||
|
username,
|
||||||
|
content: 'Pong!',
|
||||||
|
created_at: Date.now(),
|
||||||
|
}]);
|
||||||
|
} else if (command.name === 'flood') {
|
||||||
|
const count = Math.min(Math.max(parseInt(cmdArgs) || 100, 1), 5000);
|
||||||
|
if (floodInProgressRef.current) {
|
||||||
|
setEphemeralMessages(prev => [...prev, {
|
||||||
|
id: `ephemeral-${Date.now()}`,
|
||||||
|
type: 'ephemeral',
|
||||||
|
command: '/flood',
|
||||||
|
username,
|
||||||
|
content: 'A flood is already in progress. Please wait for it to finish.',
|
||||||
|
created_at: Date.now(),
|
||||||
|
}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!channelKey) {
|
||||||
|
setEphemeralMessages(prev => [...prev, {
|
||||||
|
id: `ephemeral-${Date.now()}`,
|
||||||
|
type: 'ephemeral',
|
||||||
|
command: '/flood',
|
||||||
|
username,
|
||||||
|
content: 'Cannot flood: Missing encryption key for this channel.',
|
||||||
|
created_at: Date.now(),
|
||||||
|
}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const senderId = localStorage.getItem('userId');
|
||||||
|
const signingKey = sessionStorage.getItem('signingKey');
|
||||||
|
if (!senderId || !signingKey) return;
|
||||||
|
|
||||||
|
floodInProgressRef.current = true;
|
||||||
|
floodAbortRef.current = false;
|
||||||
|
const progressId = `ephemeral-flood-${Date.now()}`;
|
||||||
|
setEphemeralMessages(prev => [...prev, {
|
||||||
|
id: progressId,
|
||||||
|
type: 'ephemeral',
|
||||||
|
command: '/flood',
|
||||||
|
username,
|
||||||
|
content: `Generating messages... 0/${count} (0%)`,
|
||||||
|
created_at: Date.now(),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const BATCH_SIZE = 50;
|
||||||
|
let sent = 0;
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < count; i += BATCH_SIZE) {
|
||||||
|
if (floodAbortRef.current) break;
|
||||||
|
const batchEnd = Math.min(i + BATCH_SIZE, count);
|
||||||
|
const batch = [];
|
||||||
|
for (let j = i; j < batchEnd; j++) {
|
||||||
|
const text = generateUniqueMessage(j);
|
||||||
|
const { content: encryptedContent, iv, tag } = await crypto.encryptData(text, channelKey);
|
||||||
|
const ciphertext = encryptedContent + tag;
|
||||||
|
const signature = await crypto.signMessage(signingKey, ciphertext);
|
||||||
|
batch.push({
|
||||||
|
channelId,
|
||||||
|
senderId,
|
||||||
|
ciphertext,
|
||||||
|
nonce: iv,
|
||||||
|
signature,
|
||||||
|
keyVersion: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await sendBatchMutation({ messages: batch });
|
||||||
|
sent += batch.length;
|
||||||
|
const pct = Math.round((sent / count) * 100);
|
||||||
|
setEphemeralMessages(prev => prev.map(m =>
|
||||||
|
m.id === progressId
|
||||||
|
? { ...m, content: `Generating messages... ${sent}/${count} (${pct}%)` }
|
||||||
|
: m
|
||||||
|
));
|
||||||
|
}
|
||||||
|
setEphemeralMessages(prev => prev.map(m =>
|
||||||
|
m.id === progressId
|
||||||
|
? { ...m, content: floodAbortRef.current ? `Flood stopped. Sent ${sent}/${count} messages.` : `Done! Sent ${sent} test messages.` }
|
||||||
|
: m
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Flood error:', err);
|
||||||
|
setEphemeralMessages(prev => prev.map(m =>
|
||||||
|
m.id === progressId
|
||||||
|
? { ...m, content: `Flood error after ${sent} messages: ${err.message}` }
|
||||||
|
: m
|
||||||
|
));
|
||||||
|
} finally {
|
||||||
|
floodInProgressRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const insertMention = (item) => {
|
const insertMention = (item) => {
|
||||||
if (!inputDivRef.current) return;
|
if (!inputDivRef.current) return;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
@@ -1167,8 +1473,30 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
}
|
}
|
||||||
if (!messageContent && pendingFiles.length === 0) return;
|
if (!messageContent && pendingFiles.length === 0) return;
|
||||||
|
|
||||||
|
// Intercept slash commands
|
||||||
|
if (messageContent.startsWith('/') && pendingFiles.length === 0) {
|
||||||
|
const parts = messageContent.slice(1).split(/\s+/);
|
||||||
|
const cmdName = parts[0];
|
||||||
|
const cmdArgs = parts.slice(1).join(' ');
|
||||||
|
const command = SLASH_COMMANDS.find(c => c.name === cmdName);
|
||||||
|
if (command) {
|
||||||
|
executeSlashCommand(command, cmdArgs);
|
||||||
|
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
|
||||||
|
setInput(''); setHasImages(false);
|
||||||
|
setSlashQuery(null);
|
||||||
|
clearTypingState();
|
||||||
|
userSentMessageRef.current = true;
|
||||||
|
scrollOnNextDataRef.current = true;
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
setTimeout(() => scrollToBottom(true), 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
userSentMessageRef.current = true;
|
userSentMessageRef.current = true;
|
||||||
|
scrollOnNextDataRef.current = true;
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
const replyId = replyingTo?.messageId;
|
const replyId = replyingTo?.messageId;
|
||||||
try {
|
try {
|
||||||
for (const file of pendingFiles) await uploadAndSendFile(file);
|
for (const file of pendingFiles) await uploadAndSendFile(file);
|
||||||
@@ -1182,6 +1510,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setReplyingTo(null);
|
setReplyingTo(null);
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
markChannelAsRead();
|
markChannelAsRead();
|
||||||
|
setTimeout(() => scrollToBottom(true), 100);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error sending message/files:", err);
|
console.error("Error sending message/files:", err);
|
||||||
alert("Failed to send message/files");
|
alert("Failed to send message/files");
|
||||||
@@ -1215,6 +1544,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
|
if (slashQuery !== null && filteredSlashCommands.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex(i => (i + 1) % filteredSlashCommands.length); return; }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex(i => (i - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); return; }
|
||||||
|
if (e.key === 'Tab') { e.preventDefault(); handleSlashSelect(filteredSlashCommands[slashIndex]); return; }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); setSlashQuery(null); return; }
|
||||||
|
}
|
||||||
if (mentionQuery !== null && mentionItems.length > 0) {
|
if (mentionQuery !== null && mentionItems.length > 0) {
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % mentionItems.length); return; }
|
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % mentionItems.length); return; }
|
||||||
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + mentionItems.length) % mentionItems.length); return; }
|
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + mentionItems.length) % mentionItems.length); return; }
|
||||||
@@ -1287,14 +1622,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToMessage = (messageId) => {
|
const scrollToMessage = useCallback((messageId) => {
|
||||||
|
const idx = decryptedMessages.findIndex(m => m.id === messageId);
|
||||||
|
if (idx !== -1 && virtuosoRef.current) {
|
||||||
|
virtuosoRef.current.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
|
||||||
|
setTimeout(() => {
|
||||||
const el = document.getElementById(`msg-${messageId}`);
|
const el = document.getElementById(`msg-${messageId}`);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
el.classList.add('message-highlight');
|
el.classList.add('message-highlight');
|
||||||
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
||||||
}
|
}
|
||||||
};
|
}, 300);
|
||||||
|
}
|
||||||
|
}, [decryptedMessages]);
|
||||||
|
|
||||||
// Stable callbacks for MessageItem
|
// Stable callbacks for MessageItem
|
||||||
const handleProfilePopup = useCallback((e, msg) => {
|
const handleProfilePopup = useCallback((e, msg) => {
|
||||||
@@ -1304,34 +1644,50 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const isDM = channelType === 'dm';
|
const isDM = channelType === 'dm';
|
||||||
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
|
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
|
||||||
|
|
||||||
|
// Merge messages + ephemeral for Virtuoso data
|
||||||
|
const allDisplayMessages = useMemo(() => {
|
||||||
|
return [...decryptedMessages, ...ephemeralMessages];
|
||||||
|
}, [decryptedMessages, ephemeralMessages]);
|
||||||
|
|
||||||
|
// When user sends a message, scroll to bottom once the new message arrives in data
|
||||||
|
// Virtuoso handles followOutput automatically via the prop
|
||||||
|
// We don't need manual scrolling here which might conflict
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollOnNextDataRef.current) {
|
||||||
|
scrollOnNextDataRef.current = false;
|
||||||
|
// followOutput already returned 'auto' but it's unreliable — force DOM scroll
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = scrollerElRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [allDisplayMessages]);
|
||||||
|
|
||||||
|
// Header component for Virtuoso — shows skeleton loader or channel beginning
|
||||||
|
const renderListHeader = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
|
<>
|
||||||
{isDragging && <DragOverlay />}
|
|
||||||
|
|
||||||
<PinnedMessagesPanel
|
|
||||||
channelId={channelId}
|
|
||||||
visible={showPinned}
|
|
||||||
onClose={onTogglePinned}
|
|
||||||
channelKey={channelKey}
|
|
||||||
onJumpToMessage={scrollToMessage}
|
|
||||||
userId={currentUserId}
|
|
||||||
username={username}
|
|
||||||
roles={roles}
|
|
||||||
Attachment={Attachment}
|
|
||||||
LinkPreview={LinkPreview}
|
|
||||||
DirectVideo={DirectVideo}
|
|
||||||
onReactionClick={handleReactionClick}
|
|
||||||
onProfilePopup={handleProfilePopup}
|
|
||||||
onImageClick={setZoomedImage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="messages-list" ref={messagesContainerRef}>
|
|
||||||
<div className="messages-content-wrapper">
|
|
||||||
<div ref={topSentinelRef} style={{ height: '1px', width: '100%' }} />
|
|
||||||
{status === 'LoadingMore' && (
|
{status === 'LoadingMore' && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
|
<>
|
||||||
<div className="loading-spinner" />
|
{[
|
||||||
|
{ name: 80, lines: [260, 180] },
|
||||||
|
{ name: 60, lines: [310] },
|
||||||
|
{ name: 100, lines: [240, 140] },
|
||||||
|
{ name: 70, lines: [290] },
|
||||||
|
{ name: 90, lines: [200, 260] },
|
||||||
|
{ name: 55, lines: [330] },
|
||||||
|
].map((s, i) => (
|
||||||
|
<div key={i} className="skeleton-message" style={{ animationDelay: `${i * 0.1}s` }}>
|
||||||
|
<div className="skeleton-avatar" />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="skeleton-name" style={{ width: s.name }} />
|
||||||
|
{s.lines.map((w, j) => (
|
||||||
|
<div key={j} className="skeleton-line" style={{ width: w }} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
|
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
|
||||||
<div className="channel-beginning">
|
<div className="channel-beginning">
|
||||||
@@ -1347,14 +1703,66 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'LoadingFirstPage' && (
|
</>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
|
);
|
||||||
<div className="loading-spinner" />
|
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName]);
|
||||||
|
|
||||||
|
// Render individual message item for Virtuoso
|
||||||
|
const renderMessageItem = useCallback((item, arrayIndex) => {
|
||||||
|
// Handle ephemeral messages (they come after decryptedMessages in allDisplayMessages)
|
||||||
|
if (item.type === 'ephemeral') {
|
||||||
|
const emsg = item;
|
||||||
|
return (
|
||||||
|
<div className="message-item ephemeral-message">
|
||||||
|
<div className="message-reply-context ephemeral-reply-context">
|
||||||
|
<div className="reply-spine" />
|
||||||
|
<div className="ephemeral-reply-avatar">
|
||||||
|
<svg width="16" height="12" viewBox="0 0 28 20">
|
||||||
|
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="reply-author" style={{ color: '#5865f2' }}>System</span>
|
||||||
{decryptedMessages.map((msg, idx) => {
|
<span className="reply-text">{emsg.username} used {emsg.command}</span>
|
||||||
|
</div>
|
||||||
|
<div className="message-avatar-wrapper">
|
||||||
|
<div className="ephemeral-avatar">
|
||||||
|
<svg width="28" height="20" viewBox="0 0 28 20">
|
||||||
|
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="message-body">
|
||||||
|
<div className="message-header">
|
||||||
|
<span className="username" style={{ color: '#5865f2' }}>System</span>
|
||||||
|
<span className="ephemeral-bot-badge">BOT</span>
|
||||||
|
<span className="timestamp">{new Date(emsg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="message-content">
|
||||||
|
<span>{emsg.content}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ephemeral-message-footer">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.6 }}>
|
||||||
|
<path d="M8 3C4.5 3 1.6 5.1.3 8c1.3 2.9 4.2 5 7.7 5s6.4-2.1 7.7-5c-1.3-2.9-4.2-5-7.7-5zm0 8.3c-1.8 0-3.3-1.5-3.3-3.3S6.2 4.7 8 4.7s3.3 1.5 3.3 3.3S9.8 11.3 8 11.3zM8 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="ephemeral-message-footer-text">Only you can see this</span>
|
||||||
|
<span className="ephemeral-message-footer-sep">·</span>
|
||||||
|
<span
|
||||||
|
className="ephemeral-message-dismiss"
|
||||||
|
onClick={() => setEphemeralMessages(prev => prev.filter(m => m.id !== emsg.id))}
|
||||||
|
>
|
||||||
|
Dismiss message
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular message
|
||||||
|
const msg = item;
|
||||||
|
const idx = arrayIndex;
|
||||||
const currentDate = new Date(msg.created_at);
|
const currentDate = new Date(msg.created_at);
|
||||||
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null;
|
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1]?.created_at) : null;
|
||||||
const isMentioned = isMentionedInContent(msg.content);
|
const isMentioned = isMentionedInContent(msg.content);
|
||||||
const isOwner = msg.username === username;
|
const isOwner = msg.username === username;
|
||||||
const canDelete = isOwner || !!myPermissions?.manage_messages;
|
const canDelete = isOwner || !!myPermissions?.manage_messages;
|
||||||
@@ -1369,14 +1777,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const showDateDivider = isNewDay(currentDate, previousDate);
|
const showDateDivider = isNewDay(currentDate, previousDate);
|
||||||
const dateLabel = showDateDivider ? currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : '';
|
const dateLabel = showDateDivider ? currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : '';
|
||||||
|
|
||||||
// Show unread divider before the first message after lastReadTimestamp
|
|
||||||
const showUnreadDivider = unreadDividerTimestamp != null
|
const showUnreadDivider = unreadDividerTimestamp != null
|
||||||
&& msg.created_at > unreadDividerTimestamp
|
&& msg.created_at > unreadDividerTimestamp
|
||||||
&& (idx === 0 || decryptedMessages[idx - 1].created_at <= unreadDividerTimestamp);
|
&& (idx === 0 || decryptedMessages[idx - 1]?.created_at <= unreadDividerTimestamp);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageItem
|
<MessageItem
|
||||||
key={msg.id || idx}
|
|
||||||
msg={msg}
|
msg={msg}
|
||||||
isGrouped={isGrouped}
|
isGrouped={isGrouped}
|
||||||
showDateDivider={showDateDivider}
|
showDateDivider={showDateDivider}
|
||||||
@@ -1411,9 +1817,56 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
DirectVideo={DirectVideo}
|
DirectVideo={DirectVideo}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
}, [decryptedMessages, username, myPermissions, isMentionedInContent, unreadDividerTimestamp, editingMessage, hoveredMessageId, editInput, roles, customEmojis, reactionPickerMsgId, currentUserId, addReaction, handleEditKeyDown, handleEditSave, handleReactionClick, scrollToMessage, handleProfilePopup, scrollToBottom]);
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
|
return (
|
||||||
|
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
|
||||||
|
{isDragging && <DragOverlay />}
|
||||||
|
|
||||||
|
<PinnedMessagesPanel
|
||||||
|
channelId={channelId}
|
||||||
|
visible={showPinned}
|
||||||
|
onClose={onTogglePinned}
|
||||||
|
channelKey={channelKey}
|
||||||
|
onJumpToMessage={scrollToMessage}
|
||||||
|
userId={currentUserId}
|
||||||
|
username={username}
|
||||||
|
roles={roles}
|
||||||
|
Attachment={Attachment}
|
||||||
|
LinkPreview={LinkPreview}
|
||||||
|
DirectVideo={DirectVideo}
|
||||||
|
onReactionClick={handleReactionClick}
|
||||||
|
onProfilePopup={handleProfilePopup}
|
||||||
|
onImageClick={setZoomedImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="messages-list">
|
||||||
|
{status === 'LoadingFirstPage' ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
|
||||||
|
<div className="loading-spinner" />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Virtuoso
|
||||||
|
ref={virtuosoRef}
|
||||||
|
scrollerRef={(el) => { scrollerElRef.current = el; }}
|
||||||
|
firstItemIndex={firstItemIndex}
|
||||||
|
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
||||||
|
alignToBottom={true}
|
||||||
|
atBottomThreshold={20}
|
||||||
|
data={allDisplayMessages}
|
||||||
|
startReached={handleStartReached}
|
||||||
|
followOutput={followOutput}
|
||||||
|
atBottomStateChange={handleAtBottomStateChange}
|
||||||
|
increaseViewportBy={{ top: 400, bottom: 400 }}
|
||||||
|
defaultItemHeight={60}
|
||||||
|
computeItemKey={(index, item) => item.id || `idx-${index}`}
|
||||||
|
components={{
|
||||||
|
Header: () => renderListHeader(),
|
||||||
|
Footer: () => <div style={{ height: '1px' }} />,
|
||||||
|
}}
|
||||||
|
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
|
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
|
||||||
{reactionPickerMsgId && (
|
{reactionPickerMsgId && (
|
||||||
@@ -1465,7 +1918,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
} catch {}
|
} catch {}
|
||||||
}} />}
|
}} />}
|
||||||
|
|
||||||
<form className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
|
<form ref={chatInputFormRef} className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
|
||||||
|
{slashQuery !== null && filteredSlashCommands.length > 0 && (
|
||||||
|
<SlashCommandMenu
|
||||||
|
commands={filteredSlashCommands}
|
||||||
|
selectedIndex={slashIndex}
|
||||||
|
onSelect={handleSlashSelect}
|
||||||
|
onHover={setSlashIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{mentionQuery !== null && mentionItems.length > 0 && (
|
{mentionQuery !== null && mentionItems.length > 0 && (
|
||||||
<MentionMenu
|
<MentionMenu
|
||||||
items={mentionItems}
|
items={mentionItems}
|
||||||
@@ -1542,6 +2003,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
}
|
}
|
||||||
checkTypedEmoji();
|
checkTypedEmoji();
|
||||||
checkMentionTrigger();
|
checkMentionTrigger();
|
||||||
|
checkSlashTrigger();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
|
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
|
||||||
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
|
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
|
||||||
|
|||||||
51
packages/shared/src/components/SlashCommandMenu.jsx
Normal file
51
packages/shared/src/components/SlashCommandMenu.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const SlashCommandMenu = ({ commands, selectedIndex, onSelect, onHover }) => {
|
||||||
|
const scrollerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollerRef.current) return;
|
||||||
|
const selected = scrollerRef.current.querySelector('.slash-command-row.selected');
|
||||||
|
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
if (!commands || commands.length === 0) return null;
|
||||||
|
|
||||||
|
const grouped = {};
|
||||||
|
for (const cmd of commands) {
|
||||||
|
const cat = cmd.category || 'Built-In';
|
||||||
|
if (!grouped[cat]) grouped[cat] = [];
|
||||||
|
grouped[cat].push(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
let globalIndex = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="slash-command-menu">
|
||||||
|
<div className="slash-command-scroller" ref={scrollerRef}>
|
||||||
|
{Object.entries(grouped).map(([category, cmds]) => (
|
||||||
|
<React.Fragment key={category}>
|
||||||
|
<div className="slash-command-section-header">{category}</div>
|
||||||
|
{cmds.map((cmd) => {
|
||||||
|
const idx = globalIndex++;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cmd.name}
|
||||||
|
className={`slash-command-row${idx === selectedIndex ? ' selected' : ''}`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onSelect(cmd)}
|
||||||
|
onMouseEnter={() => onHover(idx)}
|
||||||
|
>
|
||||||
|
<span className="slash-command-name">/{cmd.name}</span>
|
||||||
|
<span className="slash-command-description">{cmd.description}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SlashCommandMenu;
|
||||||
@@ -189,6 +189,16 @@ body {
|
|||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #666770;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-header {
|
.channel-header {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -251,11 +261,8 @@ body {
|
|||||||
|
|
||||||
.messages-list {
|
.messages-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
position: relative;
|
||||||
padding: 20px 0 0 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-list::-webkit-scrollbar {
|
.messages-list::-webkit-scrollbar {
|
||||||
@@ -535,10 +542,17 @@ body {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .messages-content-wrapper */
|
/* Virtuoso scroller scrollbar styles */
|
||||||
.messages-content-wrapper {
|
.messages-list [data-virtuoso-scroller] {
|
||||||
display: flex;
|
overflow-y: auto !important;
|
||||||
flex-direction: column;
|
}
|
||||||
|
.messages-list [data-virtuoso-scroller]::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.messages-list [data-virtuoso-scroller]::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #666770;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ... existing styles ... */
|
/* ... existing styles ... */
|
||||||
@@ -719,6 +733,45 @@ body {
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skeleton message loading placeholders */
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%, 100% { opacity: 0.06; }
|
||||||
|
50% { opacity: 0.12; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-message {
|
||||||
|
display: flex;
|
||||||
|
padding: 4px 48px 4px 16px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--text-normal);
|
||||||
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-name {
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--text-normal);
|
||||||
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background: var(--text-normal);
|
||||||
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Channel beginning indicator */
|
/* Channel beginning indicator */
|
||||||
.channel-beginning {
|
.channel-beginning {
|
||||||
padding: 16px 16px 8px;
|
padding: 16px 16px 8px;
|
||||||
@@ -3899,3 +3952,140 @@ img.search-dropdown-avatar {
|
|||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.4; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Slash Command Menu */
|
||||||
|
.slash-command-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--background-surface-high, var(--embed-background));
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0,0,0,.1), 0 8px 16px rgba(0,0,0,.24);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-scroller {
|
||||||
|
max-height: 490px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-scroller::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-scroller::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--scrollbar-auto-thumb, var(--bg-tertiary));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-section-header {
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--header-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-row:hover,
|
||||||
|
.slash-command-row.selected {
|
||||||
|
background-color: var(--interactive-background-hover, rgba(255,255,255,0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-normal);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ephemeral Messages */
|
||||||
|
.ephemeral-message {
|
||||||
|
background: hsla(235, 86%, 65%, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-reply-context {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-reply-avatar {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #5865f2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 20px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #5865f2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-bot-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: #5865f2;
|
||||||
|
color: white;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 14px;
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-message-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-message-footer-text {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-message-footer-sep {
|
||||||
|
opacity: 0.4;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-message-dismiss {
|
||||||
|
color: var(--text-link);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ephemeral-message-dismiss:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ const Chat = () => {
|
|||||||
const [showPinned, setShowPinned] = useState(false);
|
const [showPinned, setShowPinned] = useState(false);
|
||||||
const [mobileView, setMobileView] = useState('sidebar');
|
const [mobileView, setMobileView] = useState('sidebar');
|
||||||
|
|
||||||
|
// Jump-to-message state (for search result clicks)
|
||||||
|
const [jumpToMessageId, setJumpToMessageId] = useState(null);
|
||||||
|
const clearJumpToMessage = useCallback(() => setJumpToMessageId(null), []);
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showSearchDropdown, setShowSearchDropdown] = useState(false);
|
const [showSearchDropdown, setShowSearchDropdown] = useState(false);
|
||||||
@@ -453,15 +457,7 @@ const Chat = () => {
|
|||||||
}
|
}
|
||||||
setShowSearchResults(false);
|
setShowSearchResults(false);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
// Give time for channel to render then scroll
|
setJumpToMessageId(messageId);
|
||||||
setTimeout(() => {
|
|
||||||
const el = document.getElementById(`msg-${messageId}`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
el.classList.add('message-highlight');
|
|
||||||
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}, [dmChannels]);
|
}, [dmChannels]);
|
||||||
|
|
||||||
// Shared search props for ChatHeader
|
// Shared search props for ChatHeader
|
||||||
@@ -540,6 +536,8 @@ const Chat = () => {
|
|||||||
onOpenDM={openDM}
|
onOpenDM={openDM}
|
||||||
showPinned={showPinned}
|
showPinned={showPinned}
|
||||||
onTogglePinned={() => setShowPinned(false)}
|
onTogglePinned={() => setShowPinned(false)}
|
||||||
|
jumpToMessageId={jumpToMessageId}
|
||||||
|
onClearJumpToMessage={clearJumpToMessage}
|
||||||
/>
|
/>
|
||||||
<SearchPanel
|
<SearchPanel
|
||||||
visible={showSearchResults}
|
visible={showSearchResults}
|
||||||
@@ -623,6 +621,8 @@ const Chat = () => {
|
|||||||
onOpenDM={openDM}
|
onOpenDM={openDM}
|
||||||
showPinned={showPinned}
|
showPinned={showPinned}
|
||||||
onTogglePinned={() => setShowPinned(false)}
|
onTogglePinned={() => setShowPinned(false)}
|
||||||
|
jumpToMessageId={jumpToMessageId}
|
||||||
|
onClearJumpToMessage={clearJumpToMessage}
|
||||||
/>
|
/>
|
||||||
<MembersList
|
<MembersList
|
||||||
channelId={activeChannel}
|
channelId={activeChannel}
|
||||||
|
|||||||
203
packages/shared/src/utils/floodMessages.js
Normal file
203
packages/shared/src/utils/floodMessages.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
const casual = [
|
||||||
|
"hey what's up", "brb grabbing coffee", "morning everyone", "good night y'all",
|
||||||
|
"anyone here?", "just got back", "what did i miss", "i'm so bored",
|
||||||
|
"how's everyone doing", "yo", "sup", "what's good", "nothing much hbu",
|
||||||
|
"just vibing", "back from lunch", "heading out soon", "gotta go eat",
|
||||||
|
"anyone wanna chat", "i'm back", "sorry was afk", "ok i'm here now",
|
||||||
|
"what are we talking about", "oh i see", "makes sense", "gotcha",
|
||||||
|
"fair enough", "alright", "cool cool", "same here", "felt that",
|
||||||
|
];
|
||||||
|
|
||||||
|
const reactions = [
|
||||||
|
"lmao that's hilarious", "wait really??", "no way", "bruh moment",
|
||||||
|
"that's crazy", "i can't believe that", "omg", "lmaooo",
|
||||||
|
"that's wild", "are you serious", "no shot", "actually?",
|
||||||
|
"wait what", "hold up", "nah that's insane", "you're joking right",
|
||||||
|
"bro what", "that's so funny", "i'm dying", "💀💀💀",
|
||||||
|
"literally crying", "stop it lmao", "that's messed up", "yooo",
|
||||||
|
"i'm screaming", "LMAO", "that sent me",
|
||||||
|
];
|
||||||
|
|
||||||
|
const short = [
|
||||||
|
"lol", "nice", "gg", "rip", "true", "based", "fr", "ong",
|
||||||
|
"bet", "yep", "nah", "ye", "ok", "k", "wow", "damn",
|
||||||
|
"sheesh", "facts", "cap", "W",
|
||||||
|
];
|
||||||
|
|
||||||
|
const techQuestions = [
|
||||||
|
"what's the best way to handle state in react?",
|
||||||
|
"anyone know how to fix cors errors?",
|
||||||
|
"is typescript worth learning?",
|
||||||
|
"how do you center a div lol",
|
||||||
|
"what's the difference between let and const?",
|
||||||
|
"should i use redux or context api?",
|
||||||
|
"how do websockets work exactly?",
|
||||||
|
"anyone used rust before?",
|
||||||
|
"what ide do you guys use?",
|
||||||
|
"is python good for backend?",
|
||||||
|
"how do i deploy to vercel?",
|
||||||
|
"what's a good database for small projects?",
|
||||||
|
"anyone know regex well?",
|
||||||
|
"how do promises work?",
|
||||||
|
"what's the deal with docker?",
|
||||||
|
"is graphql better than rest?",
|
||||||
|
"how do i get started with linux?",
|
||||||
|
"what's a good git workflow?",
|
||||||
|
"anyone know how to fix npm errors?",
|
||||||
|
"what framework should i learn next?",
|
||||||
|
"how do you debug css layout issues?",
|
||||||
|
"is svelte actually better than react?",
|
||||||
|
"what's the point of kubernetes?",
|
||||||
|
"how do you handle auth properly?",
|
||||||
|
"anyone used convex before? it's pretty cool",
|
||||||
|
"what's a good way to learn algorithms?",
|
||||||
|
"how do you structure a monorepo?",
|
||||||
|
"should i use tailwind or regular css?",
|
||||||
|
"what's the best terminal emulator?",
|
||||||
|
"how do environment variables work?",
|
||||||
|
];
|
||||||
|
|
||||||
|
const techAnswers = [
|
||||||
|
"you should try using useReducer for that",
|
||||||
|
"just add the cors headers on the backend",
|
||||||
|
"typescript is 100% worth it, saves so much debugging time",
|
||||||
|
"flexbox is your friend, just use display flex and justify-content center",
|
||||||
|
"const is for things that don't change, let is for things that do",
|
||||||
|
"context api is fine for most cases honestly",
|
||||||
|
"websockets keep a persistent connection between client and server",
|
||||||
|
"rust is amazing but the learning curve is steep",
|
||||||
|
"vscode is the goat, fight me",
|
||||||
|
"python is great for backend, especially with fastapi",
|
||||||
|
"vercel makes deployment super easy, just connect your github",
|
||||||
|
"sqlite is perfect for small projects",
|
||||||
|
"regex101.com is a lifesaver for testing regex",
|
||||||
|
"promises are just a way to handle async operations",
|
||||||
|
"docker lets you containerize your apps so they run the same everywhere",
|
||||||
|
"graphql is better when you need flexible queries",
|
||||||
|
"just install ubuntu and start using the terminal",
|
||||||
|
"git flow is overkill, just use trunk-based development",
|
||||||
|
"try deleting node_modules and running npm install again",
|
||||||
|
"next.js is probably the safest bet right now",
|
||||||
|
"use the browser devtools, inspect element is your best friend",
|
||||||
|
"svelte is great for small projects but react has more ecosystem",
|
||||||
|
"kubernetes is for orchestrating containers at scale",
|
||||||
|
"use jwt tokens with refresh tokens for auth",
|
||||||
|
"convex is great for real-time apps, the reactive queries are awesome",
|
||||||
|
];
|
||||||
|
|
||||||
|
const codeSnippets = [
|
||||||
|
"just do `const x = arr.filter(Boolean)`",
|
||||||
|
"try `Object.entries(obj).map(([k,v]) => ...)`",
|
||||||
|
"use `arr.reduce((a, b) => a + b, 0)` for sum",
|
||||||
|
"`?.` optional chaining saved my life so many times",
|
||||||
|
"just use `structuredClone(obj)` for deep copies now",
|
||||||
|
"`Promise.all()` is way faster than awaiting in a loop",
|
||||||
|
"you can do `const { data } = await fetch(url).then(r => r.json())`",
|
||||||
|
"`Array.from({ length: n }, (_, i) => i)` for range",
|
||||||
|
"use `Set` for unique values: `[...new Set(arr)]`",
|
||||||
|
"template literals are so much cleaner than string concat",
|
||||||
|
"`console.table()` is underrated for debugging arrays",
|
||||||
|
"destructuring assignment makes everything cleaner",
|
||||||
|
"`??` nullish coalescing is better than `||` for defaults",
|
||||||
|
"use `at(-1)` instead of `arr[arr.length - 1]`",
|
||||||
|
"async generators are pretty neat for streaming data",
|
||||||
|
];
|
||||||
|
|
||||||
|
const gaming = [
|
||||||
|
"anyone want to play some valorant later?",
|
||||||
|
"just hit diamond finally let's gooo",
|
||||||
|
"that game was so close",
|
||||||
|
"i keep getting destroyed in ranked",
|
||||||
|
"new update is actually fire",
|
||||||
|
"the servers are lagging again",
|
||||||
|
"who's online tonight?",
|
||||||
|
"gg wp everyone",
|
||||||
|
"i need to touch grass honestly",
|
||||||
|
"my aim is so bad today",
|
||||||
|
"anyone else hyped for the new season?",
|
||||||
|
"that clutch was insane",
|
||||||
|
"i'm taking a break from competitive",
|
||||||
|
"the new character is broken lmao",
|
||||||
|
"how do you have so many hours in that game",
|
||||||
|
"just one more game... said 3 hours ago",
|
||||||
|
"the matchmaking is so bad",
|
||||||
|
"rage quit incoming",
|
||||||
|
"carried the whole team ngl",
|
||||||
|
"that was the worst game of my life",
|
||||||
|
];
|
||||||
|
|
||||||
|
const observations = [
|
||||||
|
"i can't believe it's already february",
|
||||||
|
"why does time go so fast",
|
||||||
|
"the weather is terrible today",
|
||||||
|
"i should really go to sleep",
|
||||||
|
"my cat just knocked everything off my desk",
|
||||||
|
"i've been coding for 8 hours straight",
|
||||||
|
"i forgot to eat lunch again",
|
||||||
|
"my internet is so slow today",
|
||||||
|
"i really need a second monitor",
|
||||||
|
"it's so quiet in here",
|
||||||
|
"does anyone else stay up way too late",
|
||||||
|
"i just realized i've been in the wrong channel",
|
||||||
|
"my keyboard is so loud my roommate hates me",
|
||||||
|
"i need more coffee",
|
||||||
|
"the sun is already setting what",
|
||||||
|
"i should probably go outside at some point",
|
||||||
|
"how is it midnight already",
|
||||||
|
"my phone is at 2% as usual",
|
||||||
|
"i've had this tab open for 3 days",
|
||||||
|
"just spent 2 hours debugging a typo",
|
||||||
|
];
|
||||||
|
|
||||||
|
const humor = [
|
||||||
|
"my code worked first try, something is definitely wrong",
|
||||||
|
"git commit -m 'fixed it (for real this time)'",
|
||||||
|
"99 bugs in the code, take one down patch it around, 127 bugs in the code",
|
||||||
|
"it works on my machine ¯\\_(ツ)_/¯",
|
||||||
|
"the real bug was the friends we made along the way",
|
||||||
|
"who needs sleep when you have bugs to fix",
|
||||||
|
"stackoverflow is my copilot",
|
||||||
|
"me: writes 10 lines of code. debugger: and i took that personally",
|
||||||
|
"css: where everything is made up and the points don't matter",
|
||||||
|
"there are 10 types of people: those who understand binary and those who don't",
|
||||||
|
"a sql query walks into a bar, sees two tables, and asks: can i join you?",
|
||||||
|
"!false — it's funny because it's true",
|
||||||
|
"i don't always test my code but when i do i do it in production",
|
||||||
|
"my code doesn't have bugs, it has surprise features",
|
||||||
|
"the best error message is the one that never shows up",
|
||||||
|
];
|
||||||
|
|
||||||
|
const emoji = [
|
||||||
|
"let's gooooo 🎉", "pain 😭", "this is fine 🔥", "vibes ✨",
|
||||||
|
"crying rn 😂", "big brain move 🧠", "W take 👑", "sadge 😔",
|
||||||
|
"sheeeesh 🥶", "lowkey scary 😳", "that's a vibe 💯", "mood 😴",
|
||||||
|
"nailed it 💪", "oof 😬", "poggies 🐸",
|
||||||
|
];
|
||||||
|
|
||||||
|
const allMessages = [
|
||||||
|
...casual, ...reactions, ...short, ...techQuestions, ...techAnswers,
|
||||||
|
...codeSnippets, ...gaming, ...observations, ...humor, ...emoji,
|
||||||
|
];
|
||||||
|
|
||||||
|
const prefixes = ["oh ", "honestly ", "ngl ", "lowkey ", "wait ", "yo ", "bro ", "dude ", "hmm ", "ah "];
|
||||||
|
const suffixes = [" tbh", " lol", " lmao", " fr", " honestly", " though", " ngl", " imo", " haha", " bruh"];
|
||||||
|
|
||||||
|
export function generateUniqueMessage(index) {
|
||||||
|
if (index < allMessages.length) {
|
||||||
|
return allMessages[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseIndex = index % allMessages.length;
|
||||||
|
const variation = Math.floor(index / allMessages.length);
|
||||||
|
let msg = allMessages[baseIndex];
|
||||||
|
|
||||||
|
if (variation % 3 === 1) {
|
||||||
|
msg = prefixes[variation % prefixes.length] + msg;
|
||||||
|
} else if (variation % 3 === 2) {
|
||||||
|
msg = msg + suffixes[variation % suffixes.length];
|
||||||
|
} else {
|
||||||
|
msg = prefixes[(variation + 3) % prefixes.length] + msg + suffixes[(variation + 5) % suffixes.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user