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.9.0",
"version": "0.9.1",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
67 changes: 62 additions & 5 deletions src/lib/components/chat/EmojiPicker.jsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -25,26 +26,71 @@ 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('');
const [recent, setRecent] = useState(() => readRecent());
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;
};
Expand Down Expand Up @@ -80,7 +126,18 @@ export default function EmojiPicker({ onPick, onClose }) {
}

return (
<div className="emoji-picker" ref={rootRef} role="dialog" aria-label="Emoji">
<div
className="emoji-picker"
ref={rootRef}
role="dialog"
aria-label="Emoji"
data-side={place?.side}
style={
place
? { top: place.top, left: place.left, height: place.maxHeight == null ? undefined : Math.min(380, place.maxHeight) }
: { visibility: 'hidden', top: 0, left: 0 }
}
>
<div className="emoji-search">
<Icon name="search" size={16} />
<input
Expand Down Expand Up @@ -122,7 +179,7 @@ export default function EmojiPicker({ onPick, onClose }) {
<div className="emoji-credit">OpenEmoji by Profullstack, Inc. (CC BY 4.0)</div>

<style>{`
.emoji-picker { position: absolute; bottom: calc(100% + .5rem); left: .75rem; width: min(352px, calc(100vw - 2rem)); height: 380px; display: flex; flex-direction: column; background: var(--color-bg-primary); border: 1px solid var(--color-border-primary); border-radius: .75rem; box-shadow: 0 10px 30px rgba(0,0,0,.25); z-index: 20; overflow: hidden; }
.emoji-picker { position: fixed; width: min(352px, calc(100vw - 2rem)); height: 380px; display: flex; flex-direction: column; background: var(--color-bg-primary); border: 1px solid var(--color-border-primary); border-radius: .75rem; box-shadow: 0 10px 30px rgba(0,0,0,.25); z-index: 45; overflow: hidden; }
.emoji-search { display: flex; align-items: center; gap: .4rem; margin: .5rem; padding: .35rem .6rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border-primary); border-radius: .5rem; color: var(--color-text-muted); }
.emoji-search input { flex: 1; border: none; background: transparent; outline: none; color: var(--color-text-primary); font: inherit; font-size: .875rem; }
.emoji-tabs { display: flex; justify-content: space-between; padding: 0 .35rem; border-bottom: 1px solid var(--color-border-primary); }
Expand Down
4 changes: 1 addition & 3 deletions src/lib/components/chat/MessageItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export default function MessageItem({ message, showAvatar = true, showTimestamp
</div>
)}

{pickerOpen && <EmojiPicker onPick={react} onClose={() => setPickerOpen(false)} />}
{pickerOpen && <EmojiPicker onPick={react} onClose={() => setPickerOpen(false)} align={isOwn ? 'end' : 'start'} />}
</div>

<div className="message-actions" role="toolbar" aria-label="Message actions">
Expand Down Expand Up @@ -234,8 +234,6 @@ export default function MessageItem({ message, showAvatar = true, showTimestamp
.quick-reaction.mine { background: var(--color-bg-tertiary); }
.quick-reaction img.openemoji { display: block; transition: transform .1s; }
.quick-reaction:hover img.openemoji { transform: scale(1.2); }
.message-content-wrapper .emoji-picker { left: 0; right: auto; }
.message-wrapper.own .message-content-wrapper .emoji-picker { left: auto; right: 0; }
@media (max-width: 640px) { .message-actions { position: absolute; bottom: 100%; z-index: 5; } .message-wrapper.own .message-actions { right: 0; } }
`}</style>
</div>
Expand Down
15 changes: 9 additions & 6 deletions src/lib/components/settings/ProfileSettings.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);

Expand Down Expand Up @@ -59,17 +60,20 @@ export default function ProfileSettings() {
<div className="ps-row">
<label htmlFor="ps-emoji">Emoji</label>
<div className="ps-emoji">
<button type="button" id="ps-emoji" className="ps-emoji-btn emoji-btn" onClick={() => setPicking((p) => !p)} aria-label="Pick your emoji">
<button type="button" id="ps-emoji" ref={emojiBtnRef} className="ps-emoji-btn emoji-btn" onClick={() => setPicking((p) => !p)} aria-label="Pick your emoji">
{form.emoji ? <OpenEmoji char={form.emoji} size="1.6rem" /> : '+'}
</button>
{form.emoji && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setForm((f) => ({ ...f, emoji: '' }))}>Clear</button>
)}
<span className="ps-hint">Shown next to your name.</span>
{picking && (
<div className="ps-picker">
<EmojiPicker onPick={(char) => { setForm((f) => ({ ...f, emoji: char })); setPicking(false); }} onClose={() => setPicking(false)} />
</div>
<EmojiPicker
anchorRef={emojiBtnRef}
prefer="bottom"
onPick={(char) => { setForm((f) => ({ ...f, emoji: char })); setPicking(false); }}
onClose={() => setPicking(false)}
/>
)}
</div>
</div>
Expand Down Expand Up @@ -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; }
Expand Down
7 changes: 5 additions & 2 deletions src/lib/emoji/openemoji.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('-');
}

/**
Expand Down
18 changes: 18 additions & 0 deletions src/lib/emoji/openemoji.test.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
50 changes: 50 additions & 0 deletions src/lib/emoji/placement.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
53 changes: 53 additions & 0 deletions src/lib/emoji/placement.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading