Merge pull request 'Feat/messaging ui foundation' (#8) from feat/messaging-ui-foundation into dev
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@insignia/iios-messaging-ui",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { highlightMentions } from '../mentions';
|
||||
import { PopoverPortal } from './popover-portal';
|
||||
import type { Attachment } from '../types';
|
||||
import type { UiMessage } from '../hooks/use-messages';
|
||||
|
||||
@@ -43,6 +44,7 @@ export function MessageItem({
|
||||
onOpenThread?: (messageId: string) => void;
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const reactBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const m = message;
|
||||
|
||||
return (
|
||||
@@ -55,25 +57,27 @@ export function MessageItem({
|
||||
<div className="miu-msg-actions">
|
||||
{canReact ? (
|
||||
<div className="miu-react-wrap">
|
||||
<button type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
|
||||
<button ref={reactBtnRef} type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
|
||||
🙂
|
||||
</button>
|
||||
{pickerOpen ? (
|
||||
<div className="miu-react-picker">
|
||||
{REACTION_EMOJIS.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
className="miu-react-emoji"
|
||||
onClick={() => {
|
||||
onReact(m.id, e);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PopoverPortal anchorRef={reactBtnRef} onClose={() => setPickerOpen(false)}>
|
||||
<div className="miu-react-picker">
|
||||
{REACTION_EMOJIS.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
className="miu-react-emoji"
|
||||
onClick={() => {
|
||||
onReact(m.id, e);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPortal>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -8,7 +8,7 @@ const THEME_VARS = [
|
||||
'--miu-text', '--miu-muted', '--miu-accent', '--miu-accent-text', '--miu-radius',
|
||||
] as const;
|
||||
|
||||
function copyThemeVars(): Record<string, string> {
|
||||
export function copyThemeVars(): Record<string, string> {
|
||||
if (typeof document === 'undefined') return {};
|
||||
const src = document.querySelector('.miu-messenger, .miu-inbox');
|
||||
if (!src) return {};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useLayoutEffect, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { copyThemeVars } from './modal-portal';
|
||||
|
||||
/**
|
||||
* A small popover (e.g. the reaction picker) rendered into document.body so it is never clipped by
|
||||
* a scroll container's `overflow: hidden` — the reported "emoji picker goes beneath the container"
|
||||
* bug. Positioned fixed just below the anchor, right-aligned to it, and re-placed on scroll/resize.
|
||||
* Closes on outside pointer-down, scroll of a different element, or Escape. Carries the SDK theme
|
||||
* tokens (copied from the live surface) since a body-portaled node is outside the themed subtree.
|
||||
*/
|
||||
export function PopoverPortal({
|
||||
anchorRef,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [vars] = useState(copyThemeVars);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
const place = (): void => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setPos({ top: r.bottom + 4, left: r.right });
|
||||
};
|
||||
place();
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', place);
|
||||
window.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: PointerEvent): void => {
|
||||
const target = e.target as Node;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
if ((target as Element).closest?.('.miu-popover')) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
if (typeof document === 'undefined' || !pos) return null;
|
||||
return createPortal(
|
||||
<div className="miu-portal">
|
||||
<div className="miu-popover" style={{ top: pos.top, left: pos.left, ...vars } as CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -196,13 +196,13 @@
|
||||
.miu-react-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Portaled to <body> via .miu-popover so it's never clipped by a scroll container. */
|
||||
.miu-popover {
|
||||
position: fixed;
|
||||
z-index: 2147483000;
|
||||
transform: translateX(-100%); /* right-align the picker's right edge to the anchor */
|
||||
}
|
||||
.miu-react-picker {
|
||||
position: absolute;
|
||||
/* Open downward + right-aligned so it never clips against the top of the scroll area
|
||||
(the reported bug) or spills past the right edge. */
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
@@ -210,14 +210,9 @@
|
||||
border: 1px solid var(--miu-border);
|
||||
background: var(--miu-panel);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
z-index: 5;
|
||||
width: max-content;
|
||||
}
|
||||
/* On my own (right-aligned) messages, anchor the picker to the left instead so it stays in view. */
|
||||
.miu-msg.is-mine .miu-react-picker {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
.miu-react-emoji {
|
||||
border: none;
|
||||
background: none;
|
||||
@@ -836,6 +831,7 @@ button.miu-attach-dl:hover {
|
||||
gap: 12px;
|
||||
}
|
||||
.miu-mail-msg {
|
||||
flex: 0 0 auto; /* don't let the flex column shrink cards — they'd clip their own content */
|
||||
border: 1px solid var(--miu-border);
|
||||
border-radius: var(--miu-radius);
|
||||
background: var(--miu-panel);
|
||||
|
||||
@@ -48,9 +48,13 @@ export interface Reaction {
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
/** A resolved URL for display/download. May be empty until resolved by the host. */
|
||||
url: string;
|
||||
mime: string;
|
||||
name: string;
|
||||
/** Storage reference — carried so send() can persist the message part (upload returns it). */
|
||||
contentRef?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
Reference in New Issue
Block a user