v.0.3.6 message input box update
This commit is contained in:
@@ -7,7 +7,7 @@ TZ=UTC
|
||||
# Copy this file to .env and customize
|
||||
|
||||
# Image version to run (set by build.sh, or use 'latest')
|
||||
JAMA_VERSION=0.3.5
|
||||
JAMA_VERSION=0.3.6
|
||||
|
||||
# Default admin credentials (used on FIRST RUN only)
|
||||
ADMIN_NAME=Admin User
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-backend",
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.6",
|
||||
"description": "TeamChat backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -211,11 +211,14 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Process @mentions
|
||||
// Process @mentions — format is @[display name], look up user by display_name or name
|
||||
if (content) {
|
||||
const mentions = content.match(/@\[([^\]]+)\]\((\d+)\)/g) || [];
|
||||
for (const mention of mentions) {
|
||||
const matchId = mention.match(/\((\d+)\)/)?.[1];
|
||||
const mentionNames = [...new Set((content.match(/@\[([^\]]+)\]/g) || []).map(m => m.slice(2, -1)))];
|
||||
for (const mentionName of mentionNames) {
|
||||
const mentionedUser = db.prepare(
|
||||
"SELECT id FROM users WHERE status = 'active' AND (LOWER(display_name) = LOWER(?) OR LOWER(name) = LOWER(?))"
|
||||
).get(mentionName, mentionName);
|
||||
const matchId = mentionedUser?.id?.toString();
|
||||
if (matchId && parseInt(matchId) !== userId) {
|
||||
const notifResult = db.prepare(`
|
||||
INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id)
|
||||
@@ -240,7 +243,7 @@ io.on('connection', (socket) => {
|
||||
const senderName = socket.user?.display_name || socket.user?.name || 'Someone';
|
||||
sendPushToUser(mentionedUserId, {
|
||||
title: `${senderName} mentioned you`,
|
||||
body: (content || '').replace(/@\[[^\]]+\]\(\d+\)/g, (m) => '@' + m.match(/\[([^\]]+)\]/)?.[1]).slice(0, 100),
|
||||
body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100),
|
||||
url: '/',
|
||||
badge: 1,
|
||||
}).catch(() => {});
|
||||
|
||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.3.5}"
|
||||
VERSION="${1:-0.3.6}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="jama"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -12,7 +12,7 @@ const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
||||
function formatMsgContent(content) {
|
||||
if (!content) return '';
|
||||
// First handle @mentions
|
||||
let html = content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `<span class="mention">@${name}</span>`);
|
||||
let html = content.replace(/@\[([^\]]+)\]/g, (_, name) => `<span class="mention">@${name}</span>`);
|
||||
// Then linkify bare URLs (not already inside a tag)
|
||||
html = html.replace(/(https?:\/\/[^\s<>"]+)/g, (url) => {
|
||||
// Trim trailing punctuation that's unlikely to be part of the URL
|
||||
|
||||
@@ -237,38 +237,3 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Mention display overlay — sits over the textarea to show formatted @names */
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.msg-input-mirror {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
pointer-events: none; /* clicks pass through to textarea */
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--text-primary);
|
||||
border-color: transparent; /* hide the border — textarea draws it */
|
||||
background: transparent;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.msg-input-raw {
|
||||
position: relative;
|
||||
color: transparent; /* hide raw text — mirror shows it */
|
||||
caret-color: var(--text-primary); /* but keep the cursor visible */
|
||||
background: transparent;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Bold mention chips inside the mirror */
|
||||
.mention-chip {
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Placeholder is on the raw textarea; mirror has none */
|
||||
.msg-input-mirror::placeholder { display: none; }
|
||||
|
||||
@@ -12,20 +12,6 @@ function isEmojiOnly(str) {
|
||||
return emojiRegex.test(str.trim());
|
||||
}
|
||||
|
||||
|
||||
// Convert raw mention syntax to display HTML for the input mirror overlay
|
||||
function renderInputDisplay(raw) {
|
||||
// Escape HTML special chars first
|
||||
const escaped = raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
// Replace @[name](id) with bold @name span
|
||||
return escaped.replace(/@\[([^\]]+)\]\(\d+\)/g,
|
||||
(_, name) => `<strong class="mention-chip">@${name}</strong>`
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) {
|
||||
const [text, setText] = useState('');
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
@@ -39,7 +25,6 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const mirrorRef = useRef(null);
|
||||
const typingTimer = useRef(null);
|
||||
const wasTyping = useRef(false);
|
||||
const mentionStart = useRef(-1);
|
||||
@@ -109,10 +94,8 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
el.style.height = 'auto';
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
|
||||
const maxHeight = lineHeight * 5 + 20;
|
||||
const newH = Math.min(el.scrollHeight, maxHeight) + 'px';
|
||||
el.style.height = newH;
|
||||
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||
if (mirrorRef.current) mirrorRef.current.style.height = newH;
|
||||
|
||||
const cur = e.target.selectionStart;
|
||||
const lastAt = val.lastIndexOf('@', cur - 1);
|
||||
@@ -142,8 +125,8 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
const insertMention = (user) => {
|
||||
const before = text.slice(0, mentionStart.current);
|
||||
const after = text.slice(inputRef.current.selectionStart);
|
||||
const mention = `@[${user.display_name || user.name}](${user.id}) `;
|
||||
setText(before + mention + after);
|
||||
const name = user.display_name || user.name;
|
||||
setText(before + `@[${name}] ` + after);
|
||||
setShowMention(false);
|
||||
setMentionResults([]);
|
||||
inputRef.current.focus();
|
||||
@@ -177,10 +160,8 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
inputRef.current.style.overflowY = 'hidden';
|
||||
if (mirrorRef.current) mirrorRef.current.style.height = 'auto';
|
||||
}
|
||||
|
||||
// Tag emoji-only messages so they can be rendered large
|
||||
const emojiOnly = !!trimmed && isEmojiOnly(trimmed);
|
||||
await onSend({ content: trimmed || null, imageFile, linkPreview: lp, emojiOnly });
|
||||
};
|
||||
@@ -352,17 +333,10 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
)}
|
||||
|
||||
<div className="input-wrap">
|
||||
{/* Mirror overlay renders formatted mention text over the transparent textarea */}
|
||||
<div
|
||||
ref={mirrorRef}
|
||||
className="msg-input msg-input-mirror"
|
||||
aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: renderInputDisplay(text) || '' }}
|
||||
/>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="msg-input msg-input-raw"
|
||||
placeholder={!text ? `Message ${group?.name || ''}...` : ''}
|
||||
className="msg-input"
|
||||
placeholder={`Message ${group?.name || ''}...`}
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="group-last-msg truncate">
|
||||
{(group.last_message || '').replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1') || (group.is_readonly ? '📢 Read-only' : 'No messages yet')}
|
||||
{(group.last_message || '').replace(/@\[([^\]]+)\]/g, '@$1') || (group.is_readonly ? '📢 Read-only' : 'No messages yet')}
|
||||
</span>
|
||||
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
|
||||
{hasUnread && notifs === 0 && <span className="badge badge-unread shrink-0">{unreadCount}</span>}
|
||||
|
||||
@@ -373,12 +373,6 @@ a { color: inherit; text-decoration: none; }
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .msg-input-mirror {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
[data-theme="dark"] .msg-input-raw {
|
||||
caret-color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Night mode: .in bubble (dark surface) — base mention colour is too dark, use light primary */
|
||||
[data-theme="dark"] .in .mention {
|
||||
@@ -399,3 +393,4 @@ a { color: inherit; text-decoration: none; }
|
||||
[data-theme="dark"] .reaction-btn:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user