diff --git a/package.json b/package.json index c8c41c8a..ec18af0d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.9.0", + "version": "0.9.1", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/src/lib/components/chat/EmojiPicker.jsx b/src/lib/components/chat/EmojiPicker.jsx index 4f2d9172..9b96566a 100644 --- a/src/lib/components/chat/EmojiPicker.jsx +++ b/src/lib/components/chat/EmojiPicker.jsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { loadEmojiIndex, searchEmoji, readRecent, pushRecent, emojiSrc, OPENICON_SPRITE } from '@/lib/emoji/openemoji.js'; +import { placePopover } from '@/lib/emoji/placement.js'; /** OpenIcon glyph per OpenEmoji group, in the set's own group order. */ const GROUP_ICONS = { @@ -25,12 +26,30 @@ export function Icon({ name, size = 18 }) { } const RECENT = 'recent'; +const EDGE = 8; + +/** The visible viewport, less a sticky navbar across the top. */ +function viewportBounds() { + let top = EDGE; + const nav = document.querySelector('.navbar'); + if (nav) { + const pos = getComputedStyle(nav).position; + const r = nav.getBoundingClientRect(); + if ((pos === 'sticky' || pos === 'fixed') && r.bottom > 0) top = Math.max(top, r.bottom + EDGE); + } + return { top, left: EDGE, right: window.innerWidth - EDGE, bottom: window.innerHeight - EDGE }; +} /** * The OpenEmoji picker. It only ever hands back a Unicode character through * onPick: what the message stores (and encrypts) is plain text. + * + * It is position: fixed next to its anchor (anchorRef, or else the element it + * is rendered in), on the `prefer` side when that fits and flipped to + * below/above/right/left when it does not, so a scroll container or the navbar + * never cuts it off. align 'end' lines it up with the anchor's right edge. */ -export default function EmojiPicker({ onPick, onClose }) { +export default function EmojiPicker({ onPick, onClose, anchorRef, prefer = 'top', align = 'start' }) { const [index, setIndex] = useState(null); const [error, setError] = useState(''); const [query, setQuery] = useState(''); @@ -38,13 +57,40 @@ export default function EmojiPicker({ onPick, onClose }) { const [tab, setTab] = useState(() => (readRecent().length ? RECENT : 0)); const rootRef = useRef(null); const searchRef = useRef(null); + const [place, setPlace] = useState(null); + + useLayoutEffect(() => { + const root = rootRef.current; + if (!root) return undefined; + function update() { + const anchor = anchorRef?.current || root.parentElement; + if (!anchor) return; + const next = placePopover({ + anchor: anchor.getBoundingClientRect(), + size: { width: root.offsetWidth, height: 380 }, + bounds: viewportBounds(), + prefer, + align, + }); + setPlace((prev) => + prev && prev.top === next.top && prev.left === next.left && prev.maxHeight === next.maxHeight ? prev : next, + ); + } + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [anchorRef, prefer, align]); useEffect(() => { let live = true; loadEmojiIndex() .then((i) => live && setIndex(i)) .catch((err) => live && setError(err.message)); - searchRef.current?.focus(); + searchRef.current?.focus({ preventScroll: true }); return () => { live = false; }; @@ -80,7 +126,18 @@ export default function EmojiPicker({ onPick, onClose }) { } return ( -
+
OpenEmoji by Profullstack, Inc. (CC BY 4.0)
diff --git a/src/lib/components/settings/ProfileSettings.jsx b/src/lib/components/settings/ProfileSettings.jsx index 62f535a3..29341ed1 100644 --- a/src/lib/components/settings/ProfileSettings.jsx +++ b/src/lib/components/settings/ProfileSettings.jsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useAuthStore } from '@/lib/stores/auth.js'; import EmojiPicker from '@/lib/components/chat/EmojiPicker.jsx'; import OpenEmoji from '@/lib/components/OpenEmoji.jsx'; @@ -14,6 +14,7 @@ export default function ProfileSettings() { const [form, setForm] = useState({ emoji: '', pronouns: '', website: '', bio: '' }); const [loaded, setLoaded] = useState(false); const [picking, setPicking] = useState(false); + const emojiBtnRef = useRef(null); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); @@ -59,7 +60,7 @@ export default function ProfileSettings() {
- {form.emoji && ( @@ -67,9 +68,12 @@ export default function ProfileSettings() { )} Shown next to your name. {picking && ( -
- { setForm((f) => ({ ...f, emoji: char })); setPicking(false); }} onClose={() => setPicking(false)} /> -
+ { setForm((f) => ({ ...f, emoji: char })); setPicking(false); }} + onClose={() => setPicking(false)} + /> )}
@@ -104,7 +108,6 @@ export default function ProfileSettings() { .ps-input { width: 100%; padding: .5rem .75rem; border: 1px solid var(--color-border-primary); border-radius: .375rem; background: var(--color-bg-primary); color: var(--color-text-primary); font-size: .875rem; font-family: inherit; } .ps-emoji { position: relative; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; } .ps-emoji-btn { width: 2.75rem; height: 2.75rem; font-size: 1.6rem; line-height: 1; border: 1px solid var(--color-border-primary); border-radius: .5rem; background: var(--color-bg-primary); cursor: pointer; } - .ps-picker { position: absolute; top: 3rem; left: 0; z-index: 20; } .ps-hint { font-size: .8rem; color: var(--color-text-secondary); } .ps-alert { padding: .5rem .75rem; border-radius: .375rem; font-size: .85rem; } .ps-ok { background: rgba(16,185,129,.1); color: #065f46; } diff --git a/src/lib/emoji/openemoji.js b/src/lib/emoji/openemoji.js index da1ce961..78e60fd9 100644 --- a/src/lib/emoji/openemoji.js +++ b/src/lib/emoji/openemoji.js @@ -14,9 +14,12 @@ const PICTOGRAPHIC = /\p{Extended_Pictographic}|\p{Regional_Indicator}|⃣/u; const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; -/** OpenEmoji key of a character sequence: its codepoints, lowercase hex, hyphen-joined. */ +/** + * OpenEmoji key of a character sequence: its codepoints, lowercase hex padded + * to four digits ("0023-fe0f-20e3" for #️⃣, "00a9-fe0f" for ©️), hyphen-joined. + */ export function emojiKey(char) { - return [...char].map((c) => c.codePointAt(0).toString(16)).join('-'); + return [...char].map((c) => c.codePointAt(0).toString(16).padStart(4, '0')).join('-'); } /** diff --git a/src/lib/emoji/openemoji.test.js b/src/lib/emoji/openemoji.test.js new file mode 100644 index 00000000..aa5e78fb --- /dev/null +++ b/src/lib/emoji/openemoji.test.js @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { emojiKey, artworkKey } from './openemoji.js'; + +describe('emojiKey', () => { + it('pads codepoints to four hex digits like the OpenEmoji files', () => { + expect(emojiKey('😀')).toBe('1f600'); + expect(emojiKey('#️⃣')).toBe('0023-fe0f-20e3'); + expect(emojiKey('©️')).toBe('00a9-fe0f'); + expect(artworkKey('©')).toBe('00a9-fe0f'); + }); + + it('has an image for every emoji the picker lists', () => { + const index = JSON.parse(readFileSync('public/openemoji/index.json', 'utf8')); + const missing = index.emoji.filter(([char]) => !existsSync(`public/openemoji/64/${emojiKey(char)}.webp`)); + expect(missing.map(([c]) => c)).toEqual([]); + }); +}); diff --git a/src/lib/emoji/placement.js b/src/lib/emoji/placement.js new file mode 100644 index 00000000..1d46b76d --- /dev/null +++ b/src/lib/emoji/placement.js @@ -0,0 +1,50 @@ +/** + * Where to put a popover (the emoji picker) next to its anchor so it stays on + * screen: try the preferred side, then the opposite one, then right and left. + * Coordinates are viewport pixels, ready for position: fixed. + * + * anchor and bounds are { top, right, bottom, left }; size is { width, height }. + * align 'start' lines the popover up with the anchor's left (or top) edge, + * 'end' with its right (or bottom) edge; either way it is then nudged back + * inside bounds. When no side has room for the full height, it goes on the + * taller of above/below and maxHeight says how much it may use. + */ +const OPPOSITE = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; + +export function placePopover({ anchor, size, bounds, prefer = 'bottom', align = 'start', gap = 8 }) { + const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, Math.max(lo, hi))); + const room = { + top: anchor.top - gap - bounds.top, + bottom: bounds.bottom - anchor.bottom - gap, + left: anchor.left - gap - bounds.left, + right: bounds.right - anchor.right - gap, + }; + const fits = (side) => + side === 'top' || side === 'bottom' + ? room[side] >= size.height && bounds.right - bounds.left >= size.width + : room[side] >= size.width && bounds.bottom - bounds.top >= size.height; + + const horizontal = (width) => + clamp(align === 'end' ? anchor.right - width : anchor.left, bounds.left, bounds.right - width); + const vertical = (height) => + clamp(align === 'end' ? anchor.bottom - height : anchor.top, bounds.top, bounds.bottom - height); + + const order = [prefer, OPPOSITE[prefer], 'right', 'left'].filter((s, i, a) => a.indexOf(s) === i); + const side = order.find(fits); + + if (side === 'top') return { side, top: anchor.top - gap - size.height, left: horizontal(size.width), maxHeight: null }; + if (side === 'bottom') return { side, top: anchor.bottom + gap, left: horizontal(size.width), maxHeight: null }; + if (side === 'right') return { side, top: vertical(size.height), left: anchor.right + gap, maxHeight: null }; + if (side === 'left') return { side, top: vertical(size.height), left: anchor.left - gap - size.width, maxHeight: null }; + + // Nothing fits whole (a short window): use the taller of above and below. + const up = room.top > room.bottom; + const maxHeight = Math.max(0, up ? room.top : room.bottom); + const height = Math.min(size.height, maxHeight); + return { + side: up ? 'top' : 'bottom', + top: up ? anchor.top - gap - height : anchor.bottom + gap, + left: horizontal(size.width), + maxHeight, + }; +} diff --git a/src/lib/emoji/placement.test.js b/src/lib/emoji/placement.test.js new file mode 100644 index 00000000..60276e22 --- /dev/null +++ b/src/lib/emoji/placement.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { placePopover } from './placement.js'; + +const bounds = { top: 64, left: 8, right: 1272, bottom: 792 }; // below a 64px navbar +const size = { width: 352, height: 380 }; +const at = (top, left, h = 44, w = 44) => ({ top, left, bottom: top + h, right: left + w }); + +describe('placePopover', () => { + it('opens below when preferred and there is room', () => { + const p = placePopover({ anchor: at(277, 81), size, bounds, prefer: 'bottom' }); + expect(p).toMatchObject({ side: 'bottom', top: 277 + 44 + 8, left: 81, maxHeight: null }); + }); + + it('flips below when above would run under the navbar (the settings bug)', () => { + const p = placePopover({ anchor: at(277, 81), size, bounds, prefer: 'top' }); + expect(p.side).toBe('bottom'); + expect(p.top).toBeGreaterThanOrEqual(bounds.top); + }); + + it('flips above near the bottom of the window (the composer)', () => { + const p = placePopover({ anchor: at(700, 300, 60, 600), size, bounds, prefer: 'bottom' }); + expect(p).toMatchObject({ side: 'top', top: 700 - 8 - 380 }); + }); + + it('goes to the side when neither above nor below has room', () => { + const p = placePopover({ anchor: at(300, 100, 300, 200), size, bounds, prefer: 'bottom' }); + expect(p.side).toBe('right'); + expect(p.left).toBe(100 + 200 + 8); + expect(p.top).toBeGreaterThanOrEqual(bounds.top); + expect(p.top + size.height).toBeLessThanOrEqual(bounds.bottom); + }); + + it('uses the left side when the anchor hugs the right edge', () => { + const p = placePopover({ anchor: at(300, 1000, 300, 260), size, bounds, prefer: 'bottom' }); + expect(p.side).toBe('left'); + expect(p.left).toBe(1000 - 8 - 352); + }); + + it('end-aligns and clamps horizontally inside the window', () => { + const p = placePopover({ anchor: at(100, 1200, 30, 60), size, bounds, prefer: 'bottom', align: 'end' }); + expect(p.left + size.width).toBeLessThanOrEqual(bounds.right); + const q = placePopover({ anchor: at(100, 0, 30, 60), size, bounds, prefer: 'bottom', align: 'end' }); + expect(q.left).toBe(bounds.left); + }); + + it('shrinks into the taller space when nothing fits whole', () => { + const short = { top: 0, left: 0, right: 360, bottom: 500 }; + const p = placePopover({ anchor: at(150, 4, 44, 352), size, bounds: short, prefer: 'top' }); + expect(p.side).toBe('bottom'); + expect(p.maxHeight).toBe(500 - 194 - 8); + expect(p.top).toBe(194 + 8); + }); +});