Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.2.1",
"version": "0.2.2",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
124 changes: 124 additions & 0 deletions src/lib/components/chat/EmojiEditor.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use client';

import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { hasRawEmoji, render, selectionOffsets, serialize, setCaret, spliceValue } from '@/lib/emoji/editor-dom.js';

/**
* A one-field message editor that draws emoji with OpenEmoji images while its
* value stays plain text. Drop-in for the composer's textarea:
* value / onChange(value) the message text
* onSubmit() Enter (Shift+Enter is a new line)
* ref.insert(text) put text at the caret (the emoji picker)
* ref.focus()
*/
const EmojiEditor = forwardRef(function EmojiEditor(
{ value, onChange, onSubmit, placeholder = '', disabled = false, className = '', ariaLabel = 'Message' },
ref
) {
const el = useRef(null);
const last = useRef('');
const composing = useRef(false);
const caretRef = useRef(null);

// Keep the DOM in step when the value changes from outside (cleared after send).
useEffect(() => {
const root = el.current;
if (!root || value === last.current) return;
render(root, value);
last.current = value;
}, [value]);

function commit(next, caret) {
const root = el.current;
render(root, next);
last.current = next;
if (caret != null && root.ownerDocument.activeElement === root) setCaret(root, caret);
onChange?.(next);
}

useImperativeHandle(ref, () => ({
focus() {
el.current?.focus();
},
insert(text) {
const root = el.current;
if (!root) return;
const sel = selectionOffsets(root) ?? caretRef.current;
const { value: next, caret } = spliceValue(last.current, sel, text);
root.focus();
commit(next, caret);
},
}));

function handleInput() {
const root = el.current;
const next = serialize(root);
if (!composing.current && hasRawEmoji(root)) {
// Typed from the OS emoji keyboard or autocorrected: swap in our art, keep the caret.
const sel = selectionOffsets(root);
commit(next, sel?.end ?? next.length);
return;
}
last.current = next;
onChange?.(next);
}

function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey && !composing.current) {
e.preventDefault();
onSubmit?.();
return;
}
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
const { value: next, caret } = spliceValue(last.current, selectionOffsets(el.current), '\n');
commit(next, caret);
}
}

function handlePaste(e) {
// Plain text only: no foreign markup in an encrypted message.
e.preventDefault();
const text = e.clipboardData?.getData('text/plain') ?? '';
if (!text) return;
const { value: next, caret } = spliceValue(last.current, selectionOffsets(el.current), text.replace(/\r\n/g, '\n'));
commit(next, caret);
}

// Remember where the caret was, so the picker (which takes focus) inserts there.
function rememberCaret() {
const sel = el.current && selectionOffsets(el.current);
if (sel) caretRef.current = sel;
}

return (
<div
ref={el}
className={`emoji-editor ${className}`}
contentEditable={!disabled}
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={ariaLabel}
aria-placeholder={placeholder}
aria-disabled={disabled}
data-placeholder={placeholder}
data-empty={value ? 'false' : 'true'}
spellCheck
onInput={handleInput}
onKeyDown={handleKeyDown}
onKeyUp={rememberCaret}
onMouseUp={rememberCaret}
onBlur={rememberCaret}
onPaste={handlePaste}
onCompositionStart={() => (composing.current = true)}
onCompositionEnd={() => {
composing.current = false;
handleInput();
}}
onDrop={(e) => e.preventDefault()}
/>
);
});

export default EmojiEditor;
47 changes: 14 additions & 33 deletions src/lib/components/chat/MessageInput.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
'use client';

import { useState, useRef, useEffect } from 'react';
import { useState, useRef } from 'react';
import { useChatStore } from '@/lib/stores/chat.js';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '@/lib/stores/auth.js';
import { detectTextFormat } from '@profullstack/text-type-detection';
import { trackMessageSent } from '@/lib/utils/analytics.js';
import { multiRecipientEncryption } from '@/lib/crypto/multi-recipient-encryption.js';
import { insertAtCursor } from '@/lib/emoji/openemoji.js';
import EmojiEditor from './EmojiEditor.jsx';
import EmojiPicker, { Icon } from './EmojiPicker.jsx';

const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB
Expand Down Expand Up @@ -44,7 +44,7 @@ export default function MessageInput({ conversationId, disabled = false }) {
const [selectedFiles, setSelectedFiles] = useState([]);
const [uploadError, setUploadError] = useState('');
const [showEmoji, setShowEmoji] = useState(false);
const textareaRef = useRef(null);
const editorRef = useRef(null);
const fileInputRef = useRef(null);
const typingTimeoutRef = useRef(null);

Expand All @@ -58,37 +58,16 @@ export default function MessageInput({ conversationId, disabled = false }) {
}))
);

useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px';
}
}, [messageText]);

function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}

function handleInput(e) {
const val = e.target.value;
function handleChange(val) {
setMessageText(val);
if (conversationId && user?.id) {
setTyping(conversationId);
}
}

// The picker hands back a character; the editor draws it as our artwork.
function insertEmoji(char) {
const el = textareaRef.current;
const { value, caret } = insertAtCursor(messageText, el?.selectionStart, el?.selectionEnd, char);
setMessageText(value);
requestAnimationFrame(() => {
if (!textareaRef.current) return;
textareaRef.current.focus();
textareaRef.current.setSelectionRange(caret, caret);
});
editorRef.current?.insert(char);
}

function handleFileInput(e) {
Expand Down Expand Up @@ -292,15 +271,14 @@ export default function MessageInput({ conversationId, disabled = false }) {
<Icon name="smile" size={20} />
</button>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileInput} />
<textarea
ref={textareaRef}
<EmojiEditor
ref={editorRef}
className="message-textarea"
value={messageText}
onChange={handleInput}
onKeyDown={handleKeyDown}
onChange={handleChange}
onSubmit={handleSend}
placeholder="Type a message..."
disabled={disabled || isSending}
rows={1}
/>
<button
className="send-btn"
Expand Down Expand Up @@ -332,7 +310,10 @@ export default function MessageInput({ conversationId, disabled = false }) {
.send-spinner { border-top-color: white; }
.message-input-wrapper { display: flex; align-items: flex-end; gap: .5rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border-primary); border-radius: 1.5rem; padding: .375rem .375rem .375rem .75rem; }
.message-textarea { flex: 1; border: none; background: transparent; resize: none; font-size: .9375rem; line-height: 1.5; max-height: 120px; padding: .25rem 0; color: var(--color-text-primary); outline: none; font-family: inherit; }
.message-textarea::placeholder { color: var(--color-text-muted); }
.message-textarea { overflow-y: auto; white-space: pre-wrap; word-break: break-word; min-height: 1.5em; cursor: text; }
.message-textarea[data-empty="true"]::before { content: attr(data-placeholder); color: var(--color-text-muted); pointer-events: none; }
.message-textarea[aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
.message-textarea img.openemoji { width: 1.35em; height: 1.35em; vertical-align: -0.3em; margin: 0 .05em; }
.attach-btn, .emoji-btn, .send-btn { flex-shrink: 0; width: 36px; height: 36px; border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all .2s; }
.attach-btn, .emoji-btn { background: transparent; color: var(--color-text-secondary); }
.attach-btn:hover:not(:disabled), .emoji-btn:hover:not(:disabled), .emoji-btn.active { background: var(--color-bg-tertiary); color: var(--color-text-primary); }
Expand Down
Loading
Loading