diff --git a/assets/css/discord.css b/assets/css/discord.css
index f82ec99..767fa65 100644
--- a/assets/css/discord.css
+++ b/assets/css/discord.css
@@ -623,6 +623,10 @@ body {
width: 100%;
outline: none;
font-size: 1em;
+ resize: none;
+ height: 24px;
+ line-height: 24px;
+ overflow-y: hidden;
}
/* Members Sidebar */
@@ -1257,3 +1261,76 @@ body {
}
+
+/* Markdown Styles */
+.message-text code {
+ background-color: #1e1f22;
+ padding: 0.2rem 0.4rem;
+ border-radius: 4px;
+ font-family: 'Consolas', 'Monaco', 'Andale Mono', 'Ubuntu Mono', monospace;
+ font-size: 0.85em;
+ color: #dbdee1;
+}
+
+.message-text pre.code-block {
+ background-color: #1e1f22;
+ padding: 1rem;
+ border-radius: 4px;
+ margin: 0.5rem 0;
+ font-family: 'Consolas', 'Monaco', 'Andale Mono', 'Ubuntu Mono', monospace;
+ font-size: 0.85em;
+ overflow-x: auto;
+}
+
+.message-text blockquote {
+ border-left: 4px solid #4e5058;
+ padding-left: 1rem;
+ margin: 0.5rem 0;
+ color: var(--text-muted);
+}
+
+.message-text .spoiler {
+ background-color: #1e1f22;
+ color: transparent !important;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: background-color 0.2s, color 0.2s;
+}
+
+.message-text .spoiler:hover {
+ background-color: #2b2d31;
+}
+
+.message-text .spoiler.revealed {
+ background-color: rgba(255, 255, 255, 0.1);
+ color: inherit !important;
+ cursor: default;
+}
+
+.message-text h1 { font-size: 1.5em; font-weight: 700; margin: 4px 0 2px 0; color: #fff; }
+.message-text h2 { font-size: 1.25em; font-weight: 600; margin: 3px 0 1px 0; color: #fff; }
+.message-text h3 { font-size: 1.1em; font-weight: 600; margin: 2px 0 0 0; color: #fff; }
+
+[data-theme="light"] .message-text code {
+ background-color: #ebedef;
+ color: #313338;
+}
+
+[data-theme="light"] .message-text pre.code-block {
+ background-color: #ebedef;
+ color: #313338;
+}
+
+[data-theme="light"] .message-text .spoiler {
+ background-color: #dbdee1;
+}
+
+[data-theme="light"] .message-text .spoiler.revealed {
+ background-color: #f2f3f5;
+}
+
+[data-theme="light"] .message-text h1,
+[data-theme="light"] .message-text h2,
+[data-theme="light"] .message-text h3 {
+ color: #313338;
+}
diff --git a/assets/js/main.js b/assets/js/main.js
index 5020da2..04e2943 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -402,6 +402,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (chatInput) {
chatInput.value += emoji;
chatInput.focus();
+ chatInput.dispatchEvent(new Event('input'));
}
}, { keepOpen: true, width: "900px", height: "500px" });
return;
@@ -495,8 +496,12 @@ document.addEventListener('DOMContentLoaded', () => {
} else if (msg.type === 'reaction') {
updateReactionUI(msg.message_id, msg.reactions);
} else if (msg.type === 'message_edit') {
- const el = document.querySelector(`.message-item[data-id="${msg.message_id}"] .message-text`);
- if (el) el.innerHTML = msg.content.replace(/\n/g, '
');
+ const item = document.querySelector(`.message-item[data-id="${msg.message_id}"]`);
+ if (item) {
+ item.dataset.rawContent = msg.content;
+ const el = item.querySelector('.message-text');
+ if (el) el.innerHTML = parseCustomEmotes(msg.content);
+ }
} else if (msg.type === 'message_delete') {
document.querySelector(`.message-item[data-id="${msg.message_id}"]`)?.remove();
} else if (msg.type === 'presence') {
@@ -549,7 +554,22 @@ document.addEventListener('DOMContentLoaded', () => {
}, 3000);
}
+ chatInput?.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ chatForm?.dispatchEvent(new Event('submit', { cancelable: true }));
+ }
+ });
+
chatInput?.addEventListener('input', () => {
+ chatInput.style.height = 'auto';
+ chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px';
+ if (chatInput.scrollHeight > 200) {
+ chatInput.style.overflowY = 'auto';
+ } else {
+ chatInput.style.overflowY = 'hidden';
+ }
+
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'typing',
@@ -567,6 +587,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (!content && !file) return;
chatInput.value = '';
+ chatInput.style.height = '24px';
+ chatInput.style.overflowY = 'hidden';
const formData = new FormData();
formData.append('content', content);
formData.append('channel_id', currentChannel);
@@ -732,19 +754,32 @@ document.addEventListener('DOMContentLoaded', () => {
const msgId = editBtn.dataset.id;
const msgItem = editBtn.closest('.message-item');
const textEl = msgItem.querySelector('.message-text');
- const originalContent = textEl.innerText;
+ const originalContent = msgItem.dataset.rawContent || textEl.innerText;
- const input = document.createElement('input');
- input.type = 'text';
+ const input = document.createElement('textarea');
input.className = 'form-control bg-dark text-white';
+ input.style.resize = 'none';
+ input.style.overflowY = 'hidden';
+ input.rows = 1;
input.value = originalContent;
textEl.innerHTML = '';
textEl.appendChild(input);
+
+ const resizeInput = () => {
+ input.style.height = 'auto';
+ input.style.height = Math.min(input.scrollHeight, 200) + 'px';
+ input.style.overflowY = input.scrollHeight > 200 ? 'auto' : 'hidden';
+ };
+
+ input.addEventListener('input', resizeInput);
+ setTimeout(resizeInput, 0);
input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
input.onkeydown = async (ev) => {
- if (ev.key === 'Enter') {
+ if (ev.key === 'Enter' && !ev.shiftKey) {
+ ev.preventDefault();
const newContent = input.value.trim();
if (newContent && newContent !== originalContent) {
const resp = await fetch('api_v1_messages.php', {
@@ -753,14 +788,15 @@ document.addEventListener('DOMContentLoaded', () => {
body: JSON.stringify({ id: msgId, content: newContent })
});
if ((await resp.json()).success) {
- textEl.innerHTML = newContent.replace(/\n/g, '
');
+ textEl.innerHTML = parseCustomEmotes(newContent);
+ msgItem.dataset.rawContent = newContent;
ws?.send(JSON.stringify({ type: 'message_edit', message_id: msgId, content: newContent }));
}
} else {
- textEl.innerHTML = originalContent.replace(/\n/g, '
');
+ textEl.innerHTML = parseCustomEmotes(originalContent);
}
} else if (ev.key === 'Escape') {
- textEl.innerHTML = originalContent.replace(/\n/g, '
');
+ textEl.innerHTML = parseCustomEmotes(originalContent);
}
};
return;
@@ -814,6 +850,8 @@ document.addEventListener('DOMContentLoaded', () => {
data.messages.forEach(msg => {
const div = document.createElement('div');
div.className = 'message-item p-2 border-bottom border-secondary';
+ div.dataset.id = msg.id;
+ div.dataset.rawContent = msg.content;
div.style.backgroundColor = 'transparent';
const authorStyle = msg.role_color ? `color: ${msg.role_color};` : '';
div.innerHTML = `
@@ -826,7 +864,7 @@ document.addEventListener('DOMContentLoaded', () => {
${content}`);
+ return placeholder;
+ });
+
+ // Inline code: `content`
+ const inlineCodes = [];
+ html = html.replace(/`([^`\n]+)`/g, (match, content) => {
+ const placeholder = `__INLINE_CODE_${inlineCodes.length}__`;
+ inlineCodes.push(`${content}`);
+ return placeholder;
+ });
+
+ // Bold: **text**
+ html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
+
+ // Italics: *text* or _text_
+ html = html.replace(/\*([^*]+)\*/g, '$1');
+ html = html.replace(/_([^_]+)_/g, '$1');
+
+ // Underline: __text__
+ html = html.replace(/__([^_]+)__/g, '$1');
+
+ // Strikethrough: ~~text~~
+ html = html.replace(/~~([^~]+)~~/g, '$1'); + html = html.replace(/^>>> ([\s\S]*$)/g, '
$1'); + + // Hyperlinks: [text](url) + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Pure links:
)/gi, '$2'); + html = html.replace(/(<\/h1>|<\/h2>|<\/h3>|<\/blockquote>)\s*(
)/gi, '$1'); + + // Re-insert inline code + inlineCodes.forEach((code, i) => { + html = html.replace(`__INLINE_CODE_${i}__`, code); + }); + + // Re-insert code blocks + codeBlocks.forEach((block, i) => { + html = html.replace(`__CODE_BLOCK_${i}__`, block); + }); + + return html; + } + function parseCustomEmotes(text) { - let parsed = escapeHTML(text); + let parsed = parseMarkdown(text); (window.CUSTOM_EMOTES_CACHE || []).forEach(emote => { const imgHtml = ``; + // Only replace if it's not inside a tag attribute or code block (simplified) parsed = parsed.split(emote.code).join(imgHtml); }); return parsed; @@ -2409,6 +2525,7 @@ document.addEventListener('DOMContentLoaded', () => { const div = document.createElement('div'); div.className = 'message-item'; div.dataset.id = msg.id; + div.dataset.rawContent = msg.content; if (parseInt(msg.id) > lastMessageId) { lastMessageId = parseInt(msg.id); @@ -2483,7 +2600,7 @@ document.addEventListener('DOMContentLoaded', () => { ${pinnedBadge} - + ${attachmentHtml} ${embedHtml} diff --git a/index.php b/index.php index b263d37..e88629e 100644 --- a/index.php +++ b/index.php @@ -28,8 +28,88 @@ function renderRoleIcon($icon, $size = '14px') { } } +// Helper to parse markdown in content +function parse_markdown($text) { + if (empty($text)) return ""; + + // First escape HTML + $html = htmlspecialchars($text); + + // Code blocks: ```language\ncontent``` + $code_blocks = []; + $html = preg_replace_callback('/```(?:(\w+)\n)?([\s\S]*?)```/', function($matches) use (&$code_blocks) { + $lang = $matches[1] ?? 'text'; + $content = $matches[2]; + $placeholder = "__CODE_BLOCK_" . count($code_blocks) . "__"; + $code_blocks[] = '
'; + return $placeholder; + }, $html); + + // Inline code: `content` + $inline_codes = []; + $html = preg_replace_callback('/`([^`\n]+)`/', function($matches) use (&$inline_codes) { + $content = $matches[1]; + $placeholder = "__INLINE_CODE_" . count($inline_codes) . "__"; + $inline_codes[] = '' . $content . '' . $content . ''; + return $placeholder; + }, $html); + + // Bold: **text** + $html = preg_replace('/\*\*([^*]+)\*\*/', '$1', $html); + + // Italics: *text* or _text_ + $html = preg_replace('/\*([^*]+)\*/', '$1', $html); + $html = preg_replace('/_([^_]+)_/', '$1', $html); + + // Underline: __text__ + $html = preg_replace('/__([^_]+)__/', '$1', $html); + + // Strikethrough: ~~text~~ + $html = preg_replace('/~~([^~]+)~~/', '$1', $html); + + // Spoiler: ||text|| + $html = preg_replace('/\|\|([^|]+)\|\|/', '$1', $html); + + // Headers: # H1, ## H2, ### H3 (must be at start of line) + $html = preg_replace('/^# (.*$)/m', '$1
', $html); + $html = preg_replace('/^## (.*$)/m', '$1
', $html); + $html = preg_replace('/^### (.*$)/m', '$1
', $html); + + // Subtext: -# text (must be at start of line) + $html = preg_replace('/^-# (.*$)/m', '$1', $html); + + // Blockquotes: > text or >>> text + $html = preg_replace('/^> (.*$)/m', '$1', $html); + $html = preg_replace('/^>>> ([\s\S]*$)/', '$1', $html); + + // Hyperlinks: [text](url) + $html = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '$1', $html); + + // Pure links:+ $html = preg_replace('/<(https?:\/\/[^&]+)>/', '$1', $html); + + // Newlines to
(only those not inside placeholders) + $html = nl2br($html); + + // Remove extra space around headers and blockquotes added by nl2br + $html = preg_replace('/(
\s*)\s*(| )/i', '$2', $html); + $html = preg_replace('/(<\/h[1-3]>|<\/blockquote>)\s*(
\s*)/i', '$1', $html); + + // Re-insert inline code + foreach ($inline_codes as $i => $code) { + $html = str_replace("__INLINE_CODE_$i" . "__", $code, $html); + } + + // Re-insert code blocks + foreach ($code_blocks as $i => $block) { + $html = str_replace("__CODE_BLOCK_$i" . "__", $block, $html); + } + + return $html; +} + // Helper to parse emotes in content -function parse_emotes($content) { +function parse_emotes($content, $username_to_mention = null) { static $custom_emotes_cache; if ($custom_emotes_cache === null) { try { @@ -39,7 +119,14 @@ function parse_emotes($content) { } } - $result = htmlspecialchars($content); + $result = parse_markdown($content); + + // Parse mentions if username provided + if ($username_to_mention) { + $mention_pattern = '/@' . preg_quote($username_to_mention, '/') . '\b/'; + $result = preg_replace($mention_pattern, '@' . htmlspecialchars($username_to_mention) . '', $result); + } + foreach ($custom_emotes_cache as $ce) { $emote_html = ''; $result = str_replace($ce['code'], $emote_html, $result); @@ -590,7 +677,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; $is_solution = ($active_thread['solution_message_id'] == $m['id']); ?> -