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 (
-
+
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() {
-
@@ -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);
+ });
+});