Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autocomplete for the user/group names in the post and comment inputs #1686

Merged
merged 18 commits into from
Jul 3, 2024
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ 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).
### Fixed
- Update SSI patterns in index.html to support dashes in groupnames.

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
143 changes: 143 additions & 0 deletions src/components/autocomplete/autocomplete.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from 'react';
import { useEvent } from 'react-use-event-hook';
import { EventEmitter } from '../../services/drafts-events';
import { setReactInputValue } from '../../utils/set-react-input-value';
import style from './autocomplete.module.scss';
import { Selector } from './selector';

// There should be no alphanumeric characters right before the "@" (to exclude
// email-like strings)
const defaultAnchor = /(^|[^a-z\d])@/gi;

export function Autocomplete({ inputRef, context, anchor = defaultAnchor }) {
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.key === 'Tab')
) {
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, anchor);
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.
let timer = 0;
const focusHandler = () => clearTimeout(timer);
const blurHandler = () => (timer = setTimeout(() => setQuery(null), 500));

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 });
};
}, [anchor, inputRef, keyHandler]);

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

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 }, anchor) {
if (!selectionStart) {
return null;
}

anchor.lastIndex = 0;

let found = -1;
while (anchor.exec(value) !== null) {
if (anchor.lastIndex > selectionStart) {
break;
}
found = anchor.lastIndex;
}

if (found === -1) {
return null;
}

const match = value.slice(found).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 - 1) {
return null;
}

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

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

const before = input.value.slice(0, matchPos[0]);
const after = input.value.slice(matchPos[1]);
const newValue = before + replacement + (after || ' ');
const newCaretPos = matchPos[0] + replacement.length + 1;
setReactInputValue(input, newValue);
input.setSelectionRange(newCaretPos, newCaretPos);
}
76 changes: 76 additions & 0 deletions src/components/autocomplete/autocomplete.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
@import '../../../styles/helvetica/dark-vars.scss';

.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;
}

:global(.dark-theme) & {
background-color: $bg-color-lighter;
border: 1px solid $bg-color-lightest;
}
}

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

:global(.dark-theme) & {
color: $text-color-lighter;
}
}

.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;

:global(.dark-theme) & {
background-color: $bg-highlight-color;
}
}

.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