Skip to content

Commit

Permalink
Merge branch 'autocomplete' into beta
Browse files Browse the repository at this point in the history
  • Loading branch information
davidmz committed Jun 26, 2024
2 parents 24bc43b + 59d8aa0 commit bd4b199
Show file tree
Hide file tree
Showing 22 changed files with 918 additions and 2 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.133.0] - Not released
### Added
- Add a "description" meta-tag to the index.html.
- Autocomplete for the user/group names in the post and comment inputs. When the
user types "@" and some text afterwards, the matched users/groups are shown
beneath the text input.
### Changed
- Switch to V3 server API (with _omittedCommentsOffset_ field and two comments
after the fold).
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"react-select": "~5.8.0",
"react-sortablejs": "~6.1.4",
"react-textarea-autosize": "~8.5.3",
"react-use-event-hook": "~0.9.6",
"recharts": "~2.12.7",
"redux": "~5.0.1",
"snarkdown": "~2.0.0",
Expand Down
131 changes: 131 additions & 0 deletions src/components/autocomplete/autocomplete.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useEffect, useMemo, useState } from 'react';
import { useEvent } from 'react-use-event-hook';
import { EventEmitter } from '../../services/drafts-events';
import style from './autocomplete.module.scss';
import { Selector } from './selector';

export function Autocomplete({ inputRef, context }) {
const [query, setQuery] = useState(/** @type {string|null}*/ null);

const events = useMemo(() => new EventEmitter(), []);

const keyHandler = useEvent((/** @type {KeyboardEvent}*/ e) => {
if (query !== null && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter')) {
e.preventDefault();
e.stopPropagation();
events.emit(e.key);
} else if (e.key === 'Escape') {
setQuery(null);
}
});

useEffect(() => {
const input = inputRef.current;
if (!input) {
return;
}

const inputHandler = (/** @type {Event} */ e) => {
if (e.type === 'selectionchange' && document.activeElement !== input) {
return;
}
const matchPos = getQueryPosition(input);
setQuery(matchPos ? input.value.slice(matchPos[0], matchPos[1]) : null);
};

// Clears the query after 100ms of no focus. This delay allows to click on
// the selector by mouse.
const timer = 0;
const focusHandler = () => clearTimeout(timer);
const blurHandler = () => setTimeout(() => setQuery(null), 100);

input.addEventListener('blur', blurHandler);
input.addEventListener('focus', focusHandler);
input.addEventListener('input', inputHandler);
document.addEventListener('selectionchange', inputHandler); // For the caret movements

// Use capture for early "Enter" interception
input.addEventListener('keydown', keyHandler, { capture: true });

return () => {
clearTimeout(timer);
input.removeEventListener('blur', blurHandler);
input.removeEventListener('focus', focusHandler);
input.removeEventListener('input', inputHandler);
document.removeEventListener('selectionchange', inputHandler);
input.removeEventListener('keydown', keyHandler, { capture: true });
};
}, [inputRef, keyHandler]);

const onSelectHandler = useEvent((text) => replaceQuery(inputRef.current, text));

if (query) {
return (
<div className={style.wrapper}>
<Selector query={query} events={events} onSelect={onSelectHandler} context={context} />
</div>
);
}

return null;
}

/**
* Extract the potential username from the closest "@" symbol before the caret.
* Returns null if the caret is not in the right place and the query position
* (start, end offsets) if it is.
*
* The algorithm is the following ("|" symbol means the caret position):
*
* - "|@foo bar" => null
* - "@|foo bar" => "foo" ([1,4])
* - "@f|oo bar" => "foo"
* - "@foo| bar" => "foo"
* - "@foo |bar" => null
*
* @param {HTMLInputElement|HTMLTextAreaElement} input
* @returns {[number, number]|null}
*/
function getQueryPosition({ value, selectionStart }) {
if (!selectionStart) {
return null;
}
const found = value.lastIndexOf('@', selectionStart - 1);
if (found === -1) {
return null;
}
// There should be no alphanumeric characters right before the "@" (to exclude
// email-like strings)
if (found > 0 && /[a-z\d]/i.test(value[found - 1])) {
return null;
}

const match = value.slice(found + 1).match(/^[a-z\d-]+/i)?.[0];
// Check that the caret is inside the match or is at its edge
if (!match || match.length <= selectionStart - found - 2) {
return null;
}

return [found + 1, found + 1 + match.length];
}

/**
*
* @param {HTMLInputElement|HTMLTextAreaElement} input
* @param {string} replacement
* @returns {void}
*/
function replaceQuery(input, replacement) {
const matchPos = getQueryPosition(input);
if (!matchPos) {
return;
}

const before = input.value.slice(0, matchPos[0]);
const after = input.value.slice(matchPos[1]);
input.value = before + replacement + (after || ' ');
const newCaretPos = matchPos[0] + replacement.length + 1;
input.setSelectionRange(newCaretPos, newCaretPos);

input.dispatchEvent(new Event('input', { bubbles: true }));
}
61 changes: 61 additions & 0 deletions src/components/autocomplete/autocomplete.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
.wrapper {
position: relative;
}

.selector {
position: absolute;
top: -3px;
left: 0;
width: 100%;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
z-index: 1;
transition:
opacity 0.2s,
translate 0.2s;

@starting-style {
opacity: 0;
translate: 0 -1em;
}
}

.selector mark {
padding: 0;
font-weight: bold;
background-color: transparent;
}

.list {
list-style: none;
padding: 0;
margin: 0;
}

.item {
padding: 0.5em;
cursor: pointer;
display: flex;
gap: 0.5em;
}

.itemCurrent,
.item:hover {
background-color: #eee;
}

.itemImage {
flex: none;
}

.screenName {
margin-left: 1em;
color: #999;
}

.groupIcon {
color: #aaa;
margin-right: 0.4em;
}
39 changes: 39 additions & 0 deletions src/components/autocomplete/highlight-text.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* eslint-disable prefer-destructuring */
/* eslint-disable unicorn/no-for-loop */
export function HighlightText({ text, matches }) {
if (!text || matches.length === 0) {
return <>{text}</>;
}

const mergedMatches = [];
let start = matches[0];
let end = matches[0];

for (let i = 1; i < matches.length; i++) {
if (matches[i] === end + 1) {
end = matches[i];
} else {
mergedMatches.push([start, end]);
start = matches[i];
end = matches[i];
}
}
mergedMatches.push([start, end]);

const parts = [];
let lastIndex = 0;

for (const [start, end] of mergedMatches) {
if (start > lastIndex) {
parts.push(text.slice(lastIndex, start));
}
parts.push(<mark key={`${start}:${end}`}>{text.slice(start, end + 1)}</mark>);
lastIndex = end + 1;
}

if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}

return <>{parts}</>;
}
77 changes: 77 additions & 0 deletions src/components/autocomplete/ranked-names.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export function getRankedNames(...namesSets) {
const result = new Map();
let rank = 1;
for (const names of namesSets) {
if (!names) {
continue;
}
for (const name of names) {
if (!result.has(name)) {
result.set(name, rank);
}
}
rank++;
}
return result;
}

export function getPostParticipants(post, state) {
const result = new Set();
// Author
result.add(state.users[post.createdBy].username);
// Addressees
for (const feedId of post.postedTo) {
const userId = state.subscriptions[feedId]?.user;
const user = state.subscribers[userId] || state.users[userId];
user && result.add(user.username);
}
// Comments
for (const commentId of post.comments) {
const userId = state.comments[commentId]?.createdBy;
const user = state.users[userId]?.username;
user && result.add(user);
}
return result;
}

export function getMyFriends(state) {
const result = new Set();
for (const userId of state.user.subscriptions) {
const user = state.users[userId];
user?.type === 'user' && result.add(user.username);
}
return result;
}

export function getMyGroups(state) {
const result = new Set();
for (const userId of state.user.subscriptions) {
const user = state.users[userId];
user?.type === 'group' && result.add(user.username);
}
return result;
}

export function getMySubscribers(state) {
const result = new Set();
for (const user of state.user.subscribers) {
result.add(user.username);
}
return result;
}

export function getAllUsers(state) {
const result = new Set();
for (const user of Object.values(state.users)) {
user?.type === 'user' && result.add(user.username);
}
return result;
}

export function getAllGroups(state) {
const result = new Set();
for (const user of Object.values(state.users)) {
user?.type === 'group' && result.add(user.username);
}
return result;
}
Loading

0 comments on commit bd4b199

Please sign in to comment.