Feat/messaging ui foundation #6
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState, type FormEvent, type KeyboardEvent } from 'react';
|
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
||||||
import { useMessages } from '../hooks/use-messages';
|
import { useMessages } from '../hooks/use-messages';
|
||||||
import { useMembers } from '../hooks/use-members';
|
import { useMembers } from '../hooks/use-members';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
resolveMentions,
|
resolveMentions,
|
||||||
trailingMentionQuery,
|
trailingMentionQuery,
|
||||||
} from '../mentions';
|
} from '../mentions';
|
||||||
|
import type { Attachment } from '../types';
|
||||||
|
|
||||||
|
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
||||||
|
|
||||||
interface Suggestion {
|
interface Suggestion {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -15,15 +18,36 @@ interface Suggestion {
|
|||||||
insert: string;
|
insert: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isImage = (mime: string): boolean => mime.startsWith('image/');
|
||||||
|
|
||||||
|
function AttachmentView({ att }: { att: Attachment }) {
|
||||||
|
if (isImage(att.mime)) {
|
||||||
|
return (
|
||||||
|
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-img-link">
|
||||||
|
<img src={att.url} alt={att.name} className="miu-att-img" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-file">
|
||||||
|
📎 {att.name}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One conversation: message list + composer, driven by useMessages. Adds @mention autocomplete
|
* One conversation: message list + composer, driven by useMessages. Includes @mention autocomplete,
|
||||||
* (from the thread's members) and highlights mentions in message bodies. Transport-agnostic.
|
* emoji reactions, and attachments — each degrading if the adapter doesn't support it. Transport-agnostic.
|
||||||
*/
|
*/
|
||||||
export function Thread({ threadId }: { threadId: string | null }) {
|
export function Thread({ threadId }: { threadId: string | null }) {
|
||||||
const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId);
|
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
|
||||||
const members = useMembers(threadId);
|
const members = useMembers(threadId);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [pickerFor, setPickerFor] = useState<string | null>(null);
|
||||||
|
const [staged, setStaged] = useState<Attachment | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
||||||
const query = trailingMentionQuery(draft);
|
const query = trailingMentionQuery(draft);
|
||||||
@@ -40,17 +64,36 @@ export function Thread({ threadId }: { threadId: string | null }) {
|
|||||||
setDraft((d) => insertMention(d, insert));
|
setDraft((d) => insertMention(d, insert));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onPickFile(e: ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
setStaged(await upload(file));
|
||||||
|
} catch {
|
||||||
|
// swallow — a real host surfaces upload errors; keep the composer usable
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submit(e?: FormEvent): Promise<void> {
|
async function submit(e?: FormEvent): Promise<void> {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text || sending) return;
|
if ((!text && !staged) || sending) return;
|
||||||
const mentions = resolveMentions(text, members);
|
const mentions = resolveMentions(text, members);
|
||||||
|
const att = staged;
|
||||||
setDraft('');
|
setDraft('');
|
||||||
|
setStaged(null);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await send(text, mentions.length ? { mentions } : undefined);
|
await send(text, {
|
||||||
|
...(mentions.length ? { mentions } : {}),
|
||||||
|
...(att ? { attachment: att } : {}),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// surfaced via the hook's error
|
setStaged(att);
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -74,13 +117,42 @@ export function Thread({ threadId }: { threadId: string | null }) {
|
|||||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||||
{messages.map((m) => (
|
{messages.map((m) => (
|
||||||
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.pending ? ' is-pending' : ''}`}>
|
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.pending ? ' is-pending' : ''}`}>
|
||||||
<div className="miu-bubble">{highlightMentions(m.text, memberNames)}</div>
|
<div className="miu-bubble-row">
|
||||||
|
<div className="miu-bubble">
|
||||||
|
{m.text ? highlightMentions(m.text, memberNames) : null}
|
||||||
|
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
||||||
|
</div>
|
||||||
|
{canReact ? (
|
||||||
|
<div className="miu-react-wrap">
|
||||||
|
<button type="button" className="miu-react-btn" title="React" onClick={() => setPickerFor((p) => (p === m.id ? null : m.id))}>
|
||||||
|
🙂
|
||||||
|
</button>
|
||||||
|
{pickerFor === m.id ? (
|
||||||
|
<div className="miu-react-picker">
|
||||||
|
{REACTION_EMOJIS.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e}
|
||||||
|
type="button"
|
||||||
|
className="miu-react-emoji"
|
||||||
|
onClick={() => {
|
||||||
|
void react(m.id, e);
|
||||||
|
setPickerFor(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{m.reactions && m.reactions.length > 0 ? (
|
{m.reactions && m.reactions.length > 0 ? (
|
||||||
<div className="miu-reactions">
|
<div className="miu-reactions">
|
||||||
{m.reactions.map((r) => (
|
{m.reactions.map((r) => (
|
||||||
<span key={r.emoji} className={`miu-reaction${r.mine ? ' is-mine' : ''}`}>
|
<button key={r.emoji} type="button" className={`miu-reaction${r.mine ? ' is-mine' : ''}`} onClick={() => canReact && void react(m.id, r.emoji)}>
|
||||||
{r.emoji} {r.count}
|
{r.emoji} {r.count}
|
||||||
</span>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -104,20 +176,38 @@ export function Thread({ threadId }: { threadId: string | null }) {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
<input
|
{staged ? (
|
||||||
className="miu-input"
|
<div className="miu-staged">
|
||||||
value={draft}
|
📎 {staged.name}
|
||||||
placeholder="Type a message… @ to mention"
|
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
|
||||||
aria-label="Message"
|
✕
|
||||||
onChange={(e) => {
|
</button>
|
||||||
setDraft(e.target.value);
|
</div>
|
||||||
sendTyping();
|
) : null}
|
||||||
}}
|
<div className="miu-composer-row">
|
||||||
onKeyDown={onKeyDown}
|
{canUpload ? (
|
||||||
/>
|
<>
|
||||||
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>
|
<input ref={fileRef} type="file" className="miu-file-input" onChange={onPickFile} aria-label="Attach a file" />
|
||||||
Send
|
<button type="button" className="miu-attach-btn" title="Attach a file" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||||
</button>
|
{uploading ? '…' : '📎'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<input
|
||||||
|
className="miu-input"
|
||||||
|
value={draft}
|
||||||
|
placeholder="Type a message… @ to mention"
|
||||||
|
aria-label="Message"
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value);
|
||||||
|
sendTyping();
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useAdapter } from '../provider';
|
import { useAdapter } from '../provider';
|
||||||
import { isOwnMessage } from '../types';
|
import { isOwnMessage } from '../types';
|
||||||
import type { Message, SendOpts } from '../types';
|
import type { Attachment, Message, SendOpts } from '../types';
|
||||||
|
|
||||||
const TYPING_TTL_MS = 3500;
|
const TYPING_TTL_MS = 3500;
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ export interface MessagesState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
send: (content: string, opts?: SendOpts) => Promise<void>;
|
send: (content: string, opts?: SendOpts) => Promise<void>;
|
||||||
react: (messageId: string, emoji: string) => Promise<void>;
|
react: (messageId: string, emoji: string) => Promise<void>;
|
||||||
|
upload: (file: File) => Promise<Attachment>;
|
||||||
typingUserIds: string[];
|
typingUserIds: string[];
|
||||||
seenIds: Set<string>;
|
seenIds: Set<string>;
|
||||||
sendTyping: () => void;
|
sendTyping: () => void;
|
||||||
@@ -147,6 +148,14 @@ export function useMessages(threadId: string | null): MessagesState {
|
|||||||
[adapter, threadId],
|
[adapter, threadId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const upload = useCallback(
|
||||||
|
async (file: File): Promise<Attachment> => {
|
||||||
|
if (!adapter.upload) throw new Error('uploads are not supported by this adapter');
|
||||||
|
return adapter.upload(file);
|
||||||
|
},
|
||||||
|
[adapter],
|
||||||
|
);
|
||||||
|
|
||||||
const sendTyping = useCallback(() => {
|
const sendTyping = useCallback(() => {
|
||||||
if (threadId) adapter.sendTyping(threadId);
|
if (threadId) adapter.sendTyping(threadId);
|
||||||
}, [adapter, threadId]);
|
}, [adapter, threadId]);
|
||||||
@@ -196,6 +205,7 @@ export function useMessages(threadId: string | null): MessagesState {
|
|||||||
error,
|
error,
|
||||||
send,
|
send,
|
||||||
react,
|
react,
|
||||||
|
upload,
|
||||||
typingUserIds,
|
typingUserIds,
|
||||||
seenIds: seenMine,
|
seenIds: seenMine,
|
||||||
sendTyping,
|
sendTyping,
|
||||||
|
|||||||
@@ -149,6 +149,57 @@
|
|||||||
.miu-msg.is-pending {
|
.miu-msg.is-pending {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
.miu-bubble-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.miu-msg.is-mine .miu-bubble-row {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
.miu-react-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.miu-react-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.miu-msg:hover .miu-react-btn {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.miu-react-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.miu-react-picker {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
background: var(--miu-panel);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.miu-react-emoji {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.miu-react-emoji:hover {
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
}
|
||||||
.miu-reactions {
|
.miu-reactions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -160,10 +211,35 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--miu-border);
|
border: 1px solid var(--miu-border);
|
||||||
background: var(--miu-panel-2);
|
background: var(--miu-panel-2);
|
||||||
|
color: var(--miu-text);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.miu-reaction.is-mine {
|
.miu-reaction.is-mine {
|
||||||
border-color: var(--miu-accent);
|
border-color: var(--miu-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* attachments */
|
||||||
|
.miu-att-img-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.miu-att-img {
|
||||||
|
max-width: 260px;
|
||||||
|
max-height: 220px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
}
|
||||||
|
.miu-att-file {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
color: var(--miu-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
.miu-seen {
|
.miu-seen {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--miu-muted);
|
color: var(--miu-muted);
|
||||||
@@ -189,10 +265,47 @@
|
|||||||
.miu-composer {
|
.miu-composer {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid var(--miu-border);
|
border-top: 1px solid var(--miu-border);
|
||||||
}
|
}
|
||||||
|
.miu-composer-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.miu-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.miu-attach-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
background: var(--miu-panel);
|
||||||
|
color: var(--miu-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.miu-staged {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.miu-staged-x {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--miu-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
.miu-suggest {
|
.miu-suggest {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user