From df3f9ff22fc08da09dd9b0c24d6319f420da6c07 Mon Sep 17 00:00:00 2001 From: David Mzareulyan Date: Sat, 22 Jun 2024 16:30:54 +0300 Subject: [PATCH 01/18] Define API version as a constant in a separate file --- src/components/stats.jsx | 3 +- src/components/user.jsx | 3 +- src/services/api-version.js | 1 + src/services/api.js | 232 ++++++++++++++++++------------------ src/services/realtime.js | 3 +- 5 files changed, 123 insertions(+), 119 deletions(-) create mode 100644 src/services/api-version.js diff --git a/src/components/stats.jsx b/src/components/stats.jsx index 5a5262d0c..c45f1a605 100644 --- a/src/components/stats.jsx +++ b/src/components/stats.jsx @@ -13,6 +13,7 @@ import { import { startOfYesterday } from 'date-fns/startOfYesterday'; import { subYears } from 'date-fns/subYears'; import { format } from '../utils/date-format'; +import { apiVersion } from '../services/api-version'; function StatsChart({ type, title }) { const [data, setData] = useState(null); @@ -21,7 +22,7 @@ function StatsChart({ type, title }) { const to_date = format(startOfYesterday(), `yyyy-MM-dd`); // Yesterday const from_date = format(subYears(new Date(), 1), `yyyy-MM-dd`); // Stats for 1 year - const url = `${CONFIG.api.root}/v2/stats?data=${type}&start_date=${from_date}&end_date=${to_date}`; + const url = `${CONFIG.api.root}/v${apiVersion}/stats?data=${type}&start_date=${from_date}&end_date=${to_date}`; try { const response = await fetch(url); diff --git a/src/components/user.jsx b/src/components/user.jsx index ef3e641ff..e7536b42a 100644 --- a/src/components/user.jsx +++ b/src/components/user.jsx @@ -13,6 +13,7 @@ import { } from '../redux/action-creators'; import { getCurrentRouteName } from '../utils'; import { initialAsyncState } from '../redux/async-helpers'; +import { apiVersion } from '../services/api-version'; import { postActions, userActions } from './select-utils'; import FeedOptionsSwitch from './feed-options-switch'; import Breadcrumbs from './breadcrumbs'; @@ -79,7 +80,7 @@ const UserHandler = (props) => { ? `Posts of ${props.viewUser.username}` : `Posts in group ${props.viewUser.username}` } - href={`${CONFIG.api.root}/v2/timelines-rss/${props.viewUser.username}`} + href={`${CONFIG.api.root}/v${apiVersion}/timelines-rss/${props.viewUser.username}`} /> {nameForTitle} - {CONFIG.siteTitle} diff --git a/src/services/api-version.js b/src/services/api-version.js new file mode 100644 index 000000000..a030d1b95 --- /dev/null +++ b/src/services/api-version.js @@ -0,0 +1 @@ +export const apiVersion = 2; diff --git a/src/services/api.js b/src/services/api.js index 449ac312b..3b63452b8 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -13,10 +13,13 @@ import { } from '../utils/hide-criteria'; import { getToken } from './auth'; import { popupAsPromise } from './popup'; +import { apiVersion } from './api-version'; const apiRoot = CONFIG.api.root; const frontendPrefsId = CONFIG.frontendPreferences.clientId; +const apiPrefix = `${apiRoot}/v${apiVersion}`; + const getRequestOptions = () => ({ headers: { Accept: 'application/json', @@ -46,60 +49,57 @@ const feedQueryString = ({ offset, sortChronologically, homeFeedMode, from, tz } .join('&'); export function getWhoAmI() { - return fetch(`${apiRoot}/v2/users/whoami`, getRequestOptions()); + return fetch(`${apiPrefix}/users/whoami`, getRequestOptions()); } export function getHome({ feedId, ...params }) { return fetch( - `${apiRoot}/v2/timelines/home${feedId ? `/${feedId}/posts` : ''}?${feedQueryString(params)}`, + `${apiPrefix}/timelines/home${feedId ? `/${feedId}/posts` : ''}?${feedQueryString(params)}`, getRequestOptions(), ); } export function getMemories(params) { return fetch( - `${apiRoot}/v2/timelines/home?${feedQueryString(params)}&sort=created`, + `${apiPrefix}/timelines/home?${feedQueryString(params)}&sort=created`, getRequestOptions(), ); } export function getDiscussions(params) { return fetch( - `${apiRoot}/v2/timelines/filter/discussions?with-my-posts=yes&${feedQueryString(params)}`, + `${apiPrefix}/timelines/filter/discussions?with-my-posts=yes&${feedQueryString(params)}`, getRequestOptions(), ); } export function getSaves(params) { return fetch( - `${apiRoot}/v2/timelines/filter/saves?${feedQueryString(params)}`, + `${apiPrefix}/timelines/filter/saves?${feedQueryString(params)}`, getRequestOptions(), ); } export function getDirect(params) { return fetch( - `${apiRoot}/v2/timelines/filter/directs?${feedQueryString(params)}`, + `${apiPrefix}/timelines/filter/directs?${feedQueryString(params)}`, getRequestOptions(), ); } export function getUserFeed({ username, ...params }) { return fetch( - `${apiRoot}/v2/timelines/${username}?${feedQueryString(params)}`, + `${apiPrefix}/timelines/${username}?${feedQueryString(params)}`, getRequestOptions(), ); } export function getUserStats({ username }) { - return fetch(`${apiRoot}/v2/users/${username}/statistics`, getRequestOptions()); + return fetch(`${apiPrefix}/users/${username}/statistics`, getRequestOptions()); } export function getNotifications({ offset, filter }) { - return fetch( - `${apiRoot}/v2/notifications?offset=${offset}&filter=${filter}`, - getRequestOptions(), - ); + return fetch(`${apiPrefix}/notifications?offset=${offset}&filter=${filter}`, getRequestOptions()); } export function getLikesOnly({ postId, commentsExpanded }) { @@ -112,21 +112,21 @@ export function getLikesOnly({ postId, commentsExpanded }) { export function getPost({ postId, maxComments = '', maxLikes = '' }) { return fetch( - `${apiRoot}/v2/posts/${postId}?maxComments=${maxComments}&maxLikes=${maxLikes}`, + `${apiPrefix}/posts/${postId}?maxComments=${maxComments}&maxLikes=${maxLikes}`, getRequestOptions(), ); } export function getPostIdByOldName({ oldName }) { return fetch( - `${apiRoot}/v2/archives/post-by-old-name/${encodeURIComponent(oldName)}`, + `${apiPrefix}/archives/post-by-old-name/${encodeURIComponent(oldName)}`, getRequestOptions(), ); } export function createPost({ feeds, postText, attachmentIds, more }) { return fetch( - `${apiRoot}/v2/posts`, + `${apiPrefix}/posts`, postRequestOptions('POST', { post: { body: postText, @@ -142,7 +142,7 @@ export function createPost({ feeds, postText, attachmentIds, more }) { export function createBookmarkletPost({ feeds, postText, imageUrls, commentText }) { return fetch( - `${apiRoot}/v2/bookmarklet`, + `${apiPrefix}/bookmarklet`, postRequestOptions('POST', { title: postText, images: imageUrls, @@ -153,7 +153,7 @@ export function createBookmarkletPost({ feeds, postText, imageUrls, commentText } export function updatePost({ postId, newPost }) { - return fetch(`${apiRoot}/v2/posts/${postId}`, postRequestOptions('PUT', { post: newPost })); + return fetch(`${apiPrefix}/posts/${postId}`, postRequestOptions('PUT', { post: newPost })); } export function deletePost({ postId, fromFeeds = [] }) { @@ -161,61 +161,61 @@ export function deletePost({ postId, fromFeeds = [] }) { for (const feed of fromFeeds) { sp.append('fromFeed', feed); } - return fetch(`${apiRoot}/v2/posts/${postId}?${sp.toString()}`, postRequestOptions('DELETE')); + return fetch(`${apiPrefix}/posts/${postId}?${sp.toString()}`, postRequestOptions('DELETE')); } export function addComment({ postId, commentText }) { return fetch( - `${apiRoot}/v2/comments`, + `${apiPrefix}/comments`, postRequestOptions('POST', { comment: { body: commentText, postId } }), ); } export function updateComment({ commentId, newCommentBody }) { return fetch( - `${apiRoot}/v2/comments/${commentId}`, + `${apiPrefix}/comments/${commentId}`, postRequestOptions('PUT', { comment: { body: newCommentBody } }), ); } export function likeComment({ commentId }) { - return fetch(`${apiRoot}/v2/comments/${commentId}/like`, postRequestOptions()); + return fetch(`${apiPrefix}/comments/${commentId}/like`, postRequestOptions()); } export function unlikeComment({ commentId }) { - return fetch(`${apiRoot}/v2/comments/${commentId}/unlike`, postRequestOptions()); + return fetch(`${apiPrefix}/comments/${commentId}/unlike`, postRequestOptions()); } export function getCommentLikes({ commentId }) { - return fetch(`${apiRoot}/v2/comments/${commentId}/likes`, getRequestOptions()); + return fetch(`${apiPrefix}/comments/${commentId}/likes`, getRequestOptions()); } export function deleteComment({ commentId }) { - return fetch(`${apiRoot}/v2/comments/${commentId}`, postRequestOptions('DELETE')); + return fetch(`${apiPrefix}/comments/${commentId}`, postRequestOptions('DELETE')); } export function likePost({ postId }) { - return fetch(`${apiRoot}/v2/posts/${postId}/like`, postRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/like`, postRequestOptions()); } export function unlikePost({ postId }) { - return fetch(`${apiRoot}/v2/posts/${postId}/unlike`, postRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/unlike`, postRequestOptions()); } export function hidePost({ postId }) { - return fetch(`${apiRoot}/v2/posts/${postId}/hide`, postRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/hide`, postRequestOptions()); } export function unhidePost({ postId }) { - return fetch(`${apiRoot}/v2/posts/${postId}/unhide`, postRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/unhide`, postRequestOptions()); } export function disableComments({ postId }) { - return fetch(`${apiRoot}/v2/posts/${postId}/disableComments`, postRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/disableComments`, postRequestOptions()); } export function enableComments({ postId }) { - return fetch(`${apiRoot}/v2/posts/${postId}/enableComments`, postRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/enableComments`, postRequestOptions()); } const encodeBody = (body) => @@ -224,7 +224,7 @@ const encodeBody = (body) => export function signIn({ username, password }) { const encodedBody = encodeBody({ username, password }); - return fetch(`${apiRoot}/v2/session`, { + return fetch(`${apiPrefix}/session`, { headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', @@ -236,7 +236,7 @@ export function signIn({ username, password }) { export function restorePassword({ mail }) { const encodedBody = encodeBody({ email: mail }); - return fetch(`${apiRoot}/v2/passwords`, { + return fetch(`${apiPrefix}/passwords`, { headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', @@ -253,7 +253,7 @@ export function resetPassword({ password, token }) { }; const encodedBody = encodeBody(params); - return fetch(`${apiRoot}/v2/passwords/${token}`, { + return fetch(`${apiPrefix}/passwords/${token}`, { headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', @@ -264,15 +264,15 @@ export function resetPassword({ password, token }) { } export function signUp(postData) { - return fetch(`${apiRoot}/v2/users`, postRequestOptions('POST', postData)); + return fetch(`${apiPrefix}/users`, postRequestOptions('POST', postData)); } export function markAllDirectsAsRead() { - return fetch(`${apiRoot}/v2/users/markAllDirectsAsRead`, getRequestOptions()); + return fetch(`${apiPrefix}/users/markAllDirectsAsRead`, getRequestOptions()); } export function markAllNotificationsAsRead() { - return fetch(`${apiRoot}/v2/users/markAllNotificationsAsRead`, postRequestOptions()); + return fetch(`${apiPrefix}/users/markAllNotificationsAsRead`, postRequestOptions()); } export function updateUser({ @@ -294,7 +294,7 @@ export function updateUser({ user.preferences = backendPrefs; } return fetch( - `${apiRoot}/v2/users/${id}`, + `${apiPrefix}/users/${id}`, postRequestOptions('PUT', { user, emailVerificationCode }), ); } @@ -307,13 +307,13 @@ export function updateUserPreferences({ userId, frontendPrefs, backendPrefs }) { if (backendPrefs) { user.preferences = backendPrefs; } - return fetch(`${apiRoot}/v2/users/${userId}`, postRequestOptions('PUT', { user })); + return fetch(`${apiPrefix}/users/${userId}`, postRequestOptions('PUT', { user })); } export function updatePassword({ currentPassword, password, passwordConfirmation }) { const encodedBody = encodeBody({ currentPassword, password, passwordConfirmation }); - return fetch(`${apiRoot}/v2/users/updatePassword`, { + return fetch(`${apiPrefix}/users/updatePassword`, { method: 'PUT', headers: { Accept: 'application/json', @@ -328,7 +328,7 @@ export function updateUserPicture({ picture }) { const data = new FormData(); data.append('file', picture); - return fetch(`${apiRoot}/v2/users/updateProfilePicture`, { + return fetch(`${apiPrefix}/users/updateProfilePicture`, { method: 'POST', headers: { 'X-Authentication-Token': getToken() }, body: data, @@ -338,7 +338,7 @@ export function updateUserPicture({ picture }) { const userAction = (action) => ({ username, ...rest }) => { - return fetch(`${apiRoot}/v2/users/${username}/${action}`, postRequestOptions('POST', rest)); + return fetch(`${apiPrefix}/users/${username}/${action}`, postRequestOptions('POST', rest)); }; export const ban = userAction('ban'); @@ -350,70 +350,70 @@ export const unsubscribeFromMe = userAction('unsubscribeFromMe'); export function getUserComments({ username, ...params }) { return fetch( - `${apiRoot}/v2/timelines/${username}/comments?${feedQueryString(params)}`, + `${apiPrefix}/timelines/${username}/comments?${feedQueryString(params)}`, getRequestOptions(), ); } export function getUserLikes({ username, ...params }) { return fetch( - `${apiRoot}/v2/timelines/${username}/likes?${feedQueryString(params)}`, + `${apiPrefix}/timelines/${username}/likes?${feedQueryString(params)}`, getRequestOptions(), ); } export function getUserMemories({ username, ...params }) { return fetch( - `${apiRoot}/v2/timelines/${username}?${feedQueryString(params)}&sort=created`, + `${apiPrefix}/timelines/${username}?${feedQueryString(params)}&sort=created`, getRequestOptions(), ); } export function getCalendarYearDays({ username, year, ...params }) { return fetch( - `${apiRoot}/v2/calendar/${username}/${year}?${feedQueryString(params)}`, + `${apiPrefix}/calendar/${username}/${year}?${feedQueryString(params)}`, getRequestOptions(), ); } export function getCalendarMonthDays({ username, year, month, ...params }) { return fetch( - `${apiRoot}/v2/calendar/${username}/${year}/${month}?${feedQueryString(params)}`, + `${apiPrefix}/calendar/${username}/${year}/${month}?${feedQueryString(params)}`, getRequestOptions(), ); } export function getCalendarDatePosts({ username, year, month, day, ...params }) { return fetch( - `${apiRoot}/v2/calendar/${username}/${year}/${month}/${day}?${feedQueryString(params)}`, + `${apiPrefix}/calendar/${username}/${year}/${month}/${day}?${feedQueryString(params)}`, getRequestOptions(), ); } export function getSubscribers({ username }) { - return fetch(`${apiRoot}/v2/users/${username}/subscribers`, getRequestOptions()); + return fetch(`${apiPrefix}/users/${username}/subscribers`, getRequestOptions()); } export function getSubscriptions({ username }) { - return fetch(`${apiRoot}/v2/users/${username}/subscriptions`, getRequestOptions()); + return fetch(`${apiPrefix}/users/${username}/subscriptions`, getRequestOptions()); } export function getUserInfo({ username }) { - return fetch(`${apiRoot}/v2/users/${username}`, getRequestOptions()); + return fetch(`${apiPrefix}/users/${username}`, getRequestOptions()); } export function createGroup(groupSettings) { - return fetch(`${apiRoot}/v2/groups`, postRequestOptions('POST', { group: groupSettings })); + return fetch(`${apiPrefix}/groups`, postRequestOptions('POST', { group: groupSettings })); } export function updateGroup({ id, groupSettings }) { - return fetch(`${apiRoot}/v2/users/${id}`, postRequestOptions('PUT', { user: groupSettings })); + return fetch(`${apiPrefix}/users/${id}`, postRequestOptions('PUT', { user: groupSettings })); } export function updateGroupPicture({ groupName, file }) { const data = new FormData(); data.append('file', file); - return fetch(`${apiRoot}/v2/groups/${groupName}/updateProfilePicture`, { + return fetch(`${apiPrefix}/groups/${groupName}/updateProfilePicture`, { method: 'POST', headers: { 'X-Authentication-Token': getToken() }, body: data, @@ -421,135 +421,135 @@ export function updateGroupPicture({ groupName, file }) { } export function acceptGroupRequest({ groupName, userName }) { - return fetch(`${apiRoot}/v2/groups/${groupName}/acceptRequest/${userName}`, postRequestOptions()); + return fetch(`${apiPrefix}/groups/${groupName}/acceptRequest/${userName}`, postRequestOptions()); } export function rejectGroupRequest({ groupName, userName }) { - return fetch(`${apiRoot}/v2/groups/${groupName}/rejectRequest/${userName}`, postRequestOptions()); + return fetch(`${apiPrefix}/groups/${groupName}/rejectRequest/${userName}`, postRequestOptions()); } export function acceptUserRequest({ username }) { - return fetch(`${apiRoot}/v2/users/acceptRequest/${username}`, postRequestOptions()); + return fetch(`${apiPrefix}/users/acceptRequest/${username}`, postRequestOptions()); } export function rejectUserRequest({ username }) { - return fetch(`${apiRoot}/v2/users/rejectRequest/${username}`, postRequestOptions()); + return fetch(`${apiPrefix}/users/rejectRequest/${username}`, postRequestOptions()); } export function unsubscribeFromGroup({ groupName, userName }) { return fetch( - `${apiRoot}/v2/groups/${groupName}/unsubscribeFromGroup/${userName}`, + `${apiPrefix}/groups/${groupName}/unsubscribeFromGroup/${userName}`, postRequestOptions(), ); } export function makeGroupAdmin({ groupName, user }) { return fetch( - `${apiRoot}/v2/groups/${groupName}/subscribers/${user.username}/admin`, + `${apiPrefix}/groups/${groupName}/subscribers/${user.username}/admin`, postRequestOptions(), ); } export function unadminGroupAdmin({ groupName, user }) { return fetch( - `${apiRoot}/v2/groups/${groupName}/subscribers/${user.username}/unadmin`, + `${apiPrefix}/groups/${groupName}/subscribers/${user.username}/unadmin`, postRequestOptions(), ); } export function revokeSentRequest({ username }) { - return fetch(`${apiRoot}/v2/requests/${username}/revoke`, postRequestOptions()); + return fetch(`${apiPrefix}/requests/${username}/revoke`, postRequestOptions()); } export function getBlockedByMe() { - return fetch(`${apiRoot}/v2/users/blockedByMe`, getRequestOptions()); + return fetch(`${apiPrefix}/users/blockedByMe`, getRequestOptions()); } export function getSummary({ days }) { - return fetch(`${apiRoot}/v2/summary/${days}`, getRequestOptions()); + return fetch(`${apiPrefix}/summary/${days}`, getRequestOptions()); } export function getUserSummary({ username, days }) { - return fetch(`${apiRoot}/v2/summary/${username}/${days}`, getRequestOptions()); + return fetch(`${apiPrefix}/summary/${username}/${days}`, getRequestOptions()); } export function getSearch({ search = '', offset = 0 }) { return fetch( - `${apiRoot}/v2/search?qs=${encodeURIComponent(search)}&offset=${offset}`, + `${apiPrefix}/search?qs=${encodeURIComponent(search)}&offset=${offset}`, getRequestOptions(), ); } export function getBestOf({ offset = 0 }) { - return fetch(`${apiRoot}/v2/bestof?offset=${offset}`, getRequestOptions()); + return fetch(`${apiPrefix}/bestof?offset=${offset}`, getRequestOptions()); } export function getEverything(params) { - return fetch(`${apiRoot}/v2/everything?${feedQueryString(params)}`, getRequestOptions()); + return fetch(`${apiPrefix}/everything?${feedQueryString(params)}`, getRequestOptions()); } export function archiveRestoreActivity() { - return fetch(`${apiRoot}/v2/archives/activities`, postRequestOptions('PUT', { restore: true })); + return fetch(`${apiPrefix}/archives/activities`, postRequestOptions('PUT', { restore: true })); } export function archiveStartRestoration(params) { - return fetch(`${apiRoot}/v2/archives/restoration`, postRequestOptions('POST', params)); + return fetch(`${apiPrefix}/archives/restoration`, postRequestOptions('POST', params)); } export function getInvitationsInfo() { - return fetch(`${apiRoot}/v2/invitations/info`, getRequestOptions()); + return fetch(`${apiPrefix}/invitations/info`, getRequestOptions()); } export function createFreefeedInvitation(params) { - return fetch(`${apiRoot}/v2/invitations`, postRequestOptions('POST', params)); + return fetch(`${apiPrefix}/invitations`, postRequestOptions('POST', params)); } export function getInvitation({ invitationId }) { - return fetch(`${apiRoot}/v2/invitations/${invitationId}`, getRequestOptions()); + return fetch(`${apiPrefix}/invitations/${invitationId}`, getRequestOptions()); } export function getAppTokens() { - return fetch(`${apiRoot}/v2/app-tokens`, getRequestOptions()); + return fetch(`${apiPrefix}/app-tokens`, getRequestOptions()); } export function getAppTokensScopes() { - return fetch(`${apiRoot}/v2/app-tokens/scopes`, getRequestOptions()); + return fetch(`${apiPrefix}/app-tokens/scopes`, getRequestOptions()); } export function createAppToken(params) { - return fetch(`${apiRoot}/v2/app-tokens`, postRequestOptions('POST', params)); + return fetch(`${apiPrefix}/app-tokens`, postRequestOptions('POST', params)); } export function reissueAppToken(tokenId) { - return fetch(`${apiRoot}/v2/app-tokens/${tokenId}/reissue`, postRequestOptions()); + return fetch(`${apiPrefix}/app-tokens/${tokenId}/reissue`, postRequestOptions()); } export function deleteAppToken(tokenId) { - return fetch(`${apiRoot}/v2/app-tokens/${tokenId}`, postRequestOptions('DELETE')); + return fetch(`${apiPrefix}/app-tokens/${tokenId}`, postRequestOptions('DELETE')); } export function updateAppToken({ tokenId, ...params }) { - return fetch(`${apiRoot}/v2/app-tokens/${tokenId}`, postRequestOptions('PUT', params)); + return fetch(`${apiPrefix}/app-tokens/${tokenId}`, postRequestOptions('PUT', params)); } export function savePost({ postId }) { - return fetch(`${apiRoot}/v2/posts/${postId}/save`, postRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/save`, postRequestOptions()); } export function unsavePost({ postId }) { - return fetch(`${apiRoot}/v2/posts/${postId}/save`, postRequestOptions('DELETE')); + return fetch(`${apiPrefix}/posts/${postId}/save`, postRequestOptions('DELETE')); } export function getServerInfo() { - return fetch(`${apiRoot}/v2/server-info`, getRequestOptions()); + return fetch(`${apiPrefix}/server-info`, getRequestOptions()); } export function getExtAuthProfiles() { - return fetch(`${apiRoot}/v2/ext-auth/profiles`, getRequestOptions()); + return fetch(`${apiPrefix}/ext-auth/profiles`, getRequestOptions()); } export function unlinkExternalProfile({ id }) { - return fetch(`${apiRoot}/v2/ext-auth/profiles/${id}`, postRequestOptions('DELETE')); + return fetch(`${apiPrefix}/ext-auth/profiles/${id}`, postRequestOptions('DELETE')); } export async function performExtAuth({ provider, popup, mode }) { @@ -557,7 +557,7 @@ export async function performExtAuth({ provider, popup, mode }) { popupAsPromise(popup), (async () => { const startResp = await fetch( - `${apiRoot}/v2/ext-auth/auth-start`, + `${apiPrefix}/ext-auth/auth-start`, postRequestOptions('POST', { provider, mode, @@ -579,7 +579,7 @@ export async function performExtAuth({ provider, popup, mode }) { const query = qsParse(search.slice(1)); const finishResp = await fetch( - `${apiRoot}/v2/ext-auth/auth-finish`, + `${apiPrefix}/ext-auth/auth-finish`, postRequestOptions('POST', { provider, query }), ).then((r) => r.json()); @@ -627,7 +627,7 @@ export function unhidePostsByCriteria({ criteria: toRemove }) { } export function getAllGroups() { - return fetch(`${apiRoot}/v2/allGroups`, getRequestOptions()); + return fetch(`${apiPrefix}/allGroups`, getRequestOptions()); } export async function updateActualPreferences({ @@ -678,22 +678,22 @@ export async function togglePinnedGroup({ id: groupId }) { } export function listHomeFeeds() { - return fetch(`${apiRoot}/v2/timelines/home/list`, getRequestOptions()); + return fetch(`${apiPrefix}/timelines/home/list`, getRequestOptions()); } export function createHomeFeed({ title, subscribedTo = [] }) { - return fetch(`${apiRoot}/v2/timelines/home`, postRequestOptions('POST', { title, subscribedTo })); + return fetch(`${apiPrefix}/timelines/home`, postRequestOptions('POST', { title, subscribedTo })); } export function updateHomeFeed({ feedId, title, subscribedTo }) { return fetch( - `${apiRoot}/v2/timelines/home/${feedId}`, + `${apiPrefix}/timelines/home/${feedId}`, postRequestOptions('PATCH', { title, subscribedTo }), ); } export function deleteHomeFeed({ feedId }) { - return fetch(`${apiRoot}/v2/timelines/home/${feedId}`, postRequestOptions('DELETE')); + return fetch(`${apiPrefix}/timelines/home/${feedId}`, postRequestOptions('DELETE')); } export async function subscribeWithHomeFeeds({ @@ -709,25 +709,25 @@ export async function subscribeWithHomeFeeds({ } return fetch( - `${apiRoot}/v2/users/${username}/subscribe`, + `${apiPrefix}/users/${username}/subscribe`, postRequestOptions('PUT', { homeFeeds }), ); } export function getAllSubscriptions() { - return fetch(`${apiRoot}/v2/timelines/home/subscriptions`, getRequestOptions()); + return fetch(`${apiPrefix}/timelines/home/subscriptions`, getRequestOptions()); } export function reorderHomeFeeds({ feedIds }) { - return fetch(`${apiRoot}/v2/timelines/home`, postRequestOptions('PATCH', { reorder: feedIds })); + return fetch(`${apiPrefix}/timelines/home`, postRequestOptions('PATCH', { reorder: feedIds })); } export function suspendMe({ password }) { - return fetch(`${apiRoot}/v2/users/suspend-me`, postRequestOptions('POST', { password })); + return fetch(`${apiPrefix}/users/suspend-me`, postRequestOptions('POST', { password })); } export function resumeMe({ resumeToken }) { - return fetch(`${apiRoot}/v2/users/resume-me`, postRequestOptions('POST', { resumeToken })); + return fetch(`${apiPrefix}/users/resume-me`, postRequestOptions('POST', { resumeToken })); } export function createAttachment({ file, name }, { onProgress = () => null } = {}) { @@ -747,7 +747,7 @@ export function createAttachment({ file, name }, { onProgress = () => null } = { req.onerror = () => reject({ err: 'Network error' }); req.upload.onprogress = (e) => onProgress(e.loaded / e.total); - req.open('POST', `${apiRoot}/v2/attachments`); + req.open('POST', `${apiPrefix}/attachments`); req.responseType = 'json'; req.setRequestHeader('Accept', 'application/json'); getToken() && req.setRequestHeader('Authorization', `Bearer ${getToken()}`); @@ -757,86 +757,86 @@ export function createAttachment({ file, name }, { onProgress = () => null } = { } export function signOut() { - return fetch(`${apiRoot}/v2/session`, postRequestOptions('DELETE')); + return fetch(`${apiPrefix}/session`, postRequestOptions('DELETE')); } export function reissueAuthSession() { - return fetch(`${apiRoot}/v2/session/reissue`, postRequestOptions('POST')); + return fetch(`${apiPrefix}/session/reissue`, postRequestOptions('POST')); } export function listAuthSessions() { - return fetch(`${apiRoot}/v2/session/list`, getRequestOptions()); + return fetch(`${apiPrefix}/session/list`, getRequestOptions()); } export function closeAuthSessions(ids) { - return fetch(`${apiRoot}/v2/session/list`, postRequestOptions('PATCH', { close: ids })); + return fetch(`${apiPrefix}/session/list`, postRequestOptions('PATCH', { close: ids })); } export function leaveDirect(postId) { - return fetch(`${apiRoot}/v2/posts/${postId}/leave`, postRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/leave`, postRequestOptions()); } export function getAttachmentsStats() { - return fetch(`${apiRoot}/v2/attachments/my/stats`, getRequestOptions()); + return fetch(`${apiPrefix}/attachments/my/stats`, getRequestOptions()); } export function sanitizeMedia() { - return fetch(`${apiRoot}/v2/attachments/my/sanitize`, postRequestOptions()); + return fetch(`${apiPrefix}/attachments/my/sanitize`, postRequestOptions()); } export function getCommentByNumber({ postId, seqNumber }) { - return fetch(`${apiRoot}/v2/posts/${postId}/comments/${seqNumber}`, getRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/comments/${seqNumber}`, getRequestOptions()); } export function getGroupBlockedUsers({ groupName }) { - return fetch(`${apiRoot}/v2/groups/${groupName}/blockedUsers`, getRequestOptions()); + return fetch(`${apiPrefix}/groups/${groupName}/blockedUsers`, getRequestOptions()); } export function blockUserInGroup({ groupName, username }) { - return fetch(`${apiRoot}/v2/groups/${groupName}/block/${username}`, postRequestOptions()); + return fetch(`${apiPrefix}/groups/${groupName}/block/${username}`, postRequestOptions()); } export function unblockUserInGroup({ groupName, username }) { - return fetch(`${apiRoot}/v2/groups/${groupName}/unblock/${username}`, postRequestOptions()); + return fetch(`${apiPrefix}/groups/${groupName}/unblock/${username}`, postRequestOptions()); } export function sendVerificationCode({ email, mode }) { - return fetch(`${apiRoot}/v2/users/verifyEmail`, postRequestOptions('POST', { email, mode })); + return fetch(`${apiPrefix}/users/verifyEmail`, postRequestOptions('POST', { email, mode })); } export function disableBansInGroup({ groupName }) { - return fetch(`${apiRoot}/v2/groups/${groupName}/disableBans`, postRequestOptions()); + return fetch(`${apiPrefix}/groups/${groupName}/disableBans`, postRequestOptions()); } export function enableBansInGroup({ groupName }) { - return fetch(`${apiRoot}/v2/groups/${groupName}/enableBans`, postRequestOptions()); + return fetch(`${apiPrefix}/groups/${groupName}/enableBans`, postRequestOptions()); } export function getPostsByIds({ postIds }) { - return fetch(`${apiRoot}/v2/posts/byIds`, postRequestOptions('POST', { postIds })); + return fetch(`${apiPrefix}/posts/byIds`, postRequestOptions('POST', { postIds })); } export function getCommentsByIds({ commentIds }) { - return fetch(`${apiRoot}/v2/comments/byIds`, postRequestOptions('POST', { commentIds })); + return fetch(`${apiPrefix}/comments/byIds`, postRequestOptions('POST', { commentIds })); } export function unlockComment({ id }) { - return fetch(`${apiRoot}/v2/comments/${id}?unlock-banned`, getRequestOptions()); + return fetch(`${apiPrefix}/comments/${id}?unlock-banned`, getRequestOptions()); } export function translateText({ type, id, lang }) { const part = type === 'post' ? 'posts' : 'comments'; const qs = lang ? `?lang=${lang}` : ''; - return fetch(`${apiRoot}/v2/${part}/${id}/translated-body${qs}`, getRequestOptions()); + return fetch(`${apiPrefix}/${part}/${id}/translated-body${qs}`, getRequestOptions()); } export function getBacklinks({ postId, offset = 0 }) { - return fetch(`${apiRoot}/v2/posts/${postId}/backlinks?offset=${offset}`, getRequestOptions()); + return fetch(`${apiPrefix}/posts/${postId}/backlinks?offset=${offset}`, getRequestOptions()); } export function notifyOfAllComments({ postId, enabled }) { return fetch( - `${apiRoot}/v2/posts/${postId}/notifyOfAllComments`, + `${apiPrefix}/posts/${postId}/notifyOfAllComments`, postRequestOptions('POST', { enabled }), ); } diff --git a/src/services/realtime.js b/src/services/realtime.js index f83755a59..ea01cbf59 100644 --- a/src/services/realtime.js +++ b/src/services/realtime.js @@ -7,6 +7,7 @@ import { realtimeSubscriptionDebug as subscriptionDebug, } from '../utils/debug'; import { getToken } from './auth'; +import { apiVersion } from './api-version'; const bindSocketLog = (socket) => (eventName) => socket.on(eventName, (data) => socketDebug(`got event: ${eventName}`, data)); @@ -19,7 +20,7 @@ export class Connection { socket; constructor() { - this.socket = improveSocket(io(`${CONFIG.api.root}/`)); + this.socket = improveSocket(io(`${CONFIG.api.root}/`, { query: { apiVersion } })); bindSocketActionsLog(this.socket)(eventsToLog); } From b81af09eb9eadb1de031bb2a0930b278abf26b0d Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Sat, 22 Jun 2024 21:24:52 +0300 Subject: [PATCH 02/18] Switch to API v3 --- src/services/api-version.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/api-version.js b/src/services/api-version.js index a030d1b95..cdedc9291 100644 --- a/src/services/api-version.js +++ b/src/services/api-version.js @@ -1 +1 @@ -export const apiVersion = 2; +export const apiVersion = 3; From bec08f3ace83e03faaa0dbd669452ac788f8fdc3 Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Sat, 22 Jun 2024 21:52:03 +0300 Subject: [PATCH 03/18] Get _omittedCommentsOffset_ from the server response --- CHANGELOG.md | 3 ++ src/utils/index.js | 2 - test/unit/redux/reducers/comments.js | 76 +++++++++++++++++++++------- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d5ec5cc..ff6574cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,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. +### 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. diff --git a/src/utils/index.js b/src/utils/index.js index 645537af0..b19d517a0 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -89,8 +89,6 @@ export function postParser(post) { ...post, commentsDisabled: post.commentsDisabled === '1', savePostStatus: initialAsyncState, - // After what comment the omittedComments span is started? - omittedCommentsOffset: post.omittedComments > 0 ? 1 : 0, // All hashtags used in the post body hashtags: tokenizeHashtags()(post.body || '').map((t) => t.text), }; diff --git a/test/unit/redux/reducers/comments.js b/test/unit/redux/reducers/comments.js index 8ab974cb8..d6ade5c2c 100644 --- a/test/unit/redux/reducers/comments.js +++ b/test/unit/redux/reducers/comments.js @@ -25,33 +25,61 @@ describe('comments-related data', () => { }; it(`should parse post without comments`, () => { - expect(postParser({ id: 'post', comments: [], omittedComments: 0 }), 'to equal', { - ...emptyPostState, - id: 'post', - }); + expect( + postParser({ id: 'post', comments: [], omittedComments: 0, omittedCommentsOffset: 0 }), + 'to equal', + { + ...emptyPostState, + id: 'post', + }, + ); }); it(`should parse post with unfolded comments`, () => { - expect(postParser({ id: 'post', comments: ['c1', 'c2'], omittedComments: 0 }), 'to equal', { - ...emptyPostState, - id: 'post', - comments: ['c1', 'c2'], - }); + expect( + postParser({ + id: 'post', + comments: ['c1', 'c2'], + omittedComments: 0, + omittedCommentsOffset: 0, + }), + 'to equal', + { + ...emptyPostState, + id: 'post', + comments: ['c1', 'c2'], + }, + ); }); it(`should parse post with folded comments`, () => { - expect(postParser({ id: 'post', comments: ['c1', 'c2'], omittedComments: 3 }), 'to equal', { - ...emptyPostState, - id: 'post', - comments: ['c1', 'c2'], - omittedComments: 3, - omittedCommentsOffset: 1, - }); + expect( + postParser({ + id: 'post', + comments: ['c1', 'c2'], + omittedComments: 3, + omittedCommentsOffset: 1, + }), + 'to equal', + { + ...emptyPostState, + id: 'post', + comments: ['c1', 'c2'], + omittedComments: 3, + omittedCommentsOffset: 1, + }, + ); }); it(`should parse post with hashtags`, () => { expect( - postParser({ id: 'post', body: 'Hello #wórld!', comments: [], omittedComments: 0 }), + postParser({ + id: 'post', + body: 'Hello #wórld!', + comments: [], + omittedComments: 0, + omittedCommentsOffset: 0, + }), 'to equal', { ...emptyPostState, @@ -69,18 +97,21 @@ describe('comments-related data', () => { id: 'post0', comments: [], omittedComments: 0, + omittedCommentsOffset: 0, omittedCommentLikes: 0, }), post1: postParser({ id: 'post1', comments: ['comm11', 'comm12'], omittedComments: 0, + omittedCommentsOffset: 0, omittedCommentLikes: 0, }), post2: postParser({ id: 'post2', comments: ['comm21', 'comm22'], omittedComments: 2, + omittedCommentsOffset: 1, omittedCommentLikes: 2, }), }; @@ -352,7 +383,7 @@ describe('comments-related data', () => { describe('SHOW_MORE_COMMENTS', () => { const action = (postId, comments) => ({ type: response(SHOW_MORE_COMMENTS), - payload: { posts: { id: postId, comments, omittedComments: 0 } }, + payload: { posts: { id: postId, comments, omittedComments: 0, omittedCommentsOffset: 0 } }, }); it('should expand comments of post2', () => { @@ -390,7 +421,14 @@ describe('comments-related data', () => { describe('COMPLETE_POST_COMMENTS', () => { const action = (postId, comments, omittedComments) => ({ type: response(COMPLETE_POST_COMMENTS), - payload: { posts: { id: postId, comments, omittedComments } }, + payload: { + posts: { + id: postId, + comments, + omittedComments, + omittedCommentsOffset: omittedComments ? 1 : 0, + }, + }, }); it(`should complete a missing first comment`, () => { From 5f5f90e0a348d190f3efea5ad52e110e6782ca10 Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Tue, 11 Jun 2024 21:49:51 +0300 Subject: [PATCH 04/18] Create utility functions for the sparse matching and ranking --- src/utils/sparse-match/index.js | 58 +++++++++++ src/utils/sparse-match/rank.js | 69 +++++++++++++ src/utils/sparse-match/sparse-match.js | 52 ++++++++++ src/utils/sparse-match/top-n.js | 101 +++++++++++++++++++ test/unit/utils/sparse-match/rank.js | 53 ++++++++++ test/unit/utils/sparse-match/sparse-match.js | 68 +++++++++++++ 6 files changed, 401 insertions(+) create mode 100644 src/utils/sparse-match/index.js create mode 100644 src/utils/sparse-match/rank.js create mode 100644 src/utils/sparse-match/sparse-match.js create mode 100644 src/utils/sparse-match/top-n.js create mode 100644 test/unit/utils/sparse-match/rank.js create mode 100644 test/unit/utils/sparse-match/sparse-match.js diff --git a/src/utils/sparse-match/index.js b/src/utils/sparse-match/index.js new file mode 100644 index 000000000..8a12aef8b --- /dev/null +++ b/src/utils/sparse-match/index.js @@ -0,0 +1,58 @@ +import { compare, withRank } from './rank'; +import { sparseMatch } from './sparse-match'; +import { TopN } from './top-n'; + +export class Finder { + _topN; + /** + * + * @param {string} query + * @param {number} count + */ + constructor(query, count) { + this._topN = new TopN(count, compare); + this.query = query; + } + + /** + * + * @param {string} text + */ + add(text) { + const res = bestMatch(text, this.query); + res && this._topN.add(res); + } + + /** + * + * @returns {Ranked[]} + */ + results() { + return this._topN.data; + } +} + +/** + * Returns best match for the given query in the given text with the rank + * + * @typedef {import('./rank').Ranked} Ranked + * @param {string} text + * @param {string} query + * @returns {Ranked|null} + */ +function bestMatch(text, query) { + const variants = sparseMatch(text, query).map((m) => withRank(text, m)); + if (variants.length === 0) { + return null; + } + + // eslint-disable-next-line prefer-destructuring + let best = variants[0]; + for (let i = 1; i < variants.length; i++) { + if (compare(variants[i], best) < 0) { + best = variants[i]; + } + } + + return best; +} diff --git a/src/utils/sparse-match/rank.js b/src/utils/sparse-match/rank.js new file mode 100644 index 000000000..8f0203821 --- /dev/null +++ b/src/utils/sparse-match/rank.js @@ -0,0 +1,69 @@ +/** + * @typedef {{ text: string; matches: number[]; rank: number }} Ranked + */ + +/** + * + * @param {string} text + * @param {number[]} matches + * @returns {Ranked} + */ +export function withRank(text, matches) { + return { text, matches, rank: matchRank(matches, text.length) }; +} + +/** + * + * @param {Ranked} a + * @param {Ranked} b + * @returns {number} + */ +export function compare(a, b) { + if (a.rank === b.rank) { + return a.text.localeCompare(b.text); + } + return b.rank - a.rank; +} + +/** + * Calculates the absolute rank of the given matches + * + * @param {number[]} matches + * @param {number} textLength + * @returns {number} + */ +function matchRank(matches, textLength) { + // The maximal possible rank with this count of matches: all matches are in + // the beginning of the text. + const maxRank = calcRank([...Array(matches.length).keys()], matches.length); + return calcRank(matches, textLength) - maxRank; +} + +/** + * Calculates the raw rank of the given matches + * + * @param {number[]} matches - array of matched characters indices + * @param {number} textLength - text length + * @returns {number} + */ +function calcRank(matches, textLength) { + const positionWeight = 2; + const jointWeight = 2.5; + // Weight of a missed character + const missWeight = 0.1; + + // Total (negative) weight of all missed characters + let rank = missWeight * (-textLength + matches.length); + let prevMatch = -2; // Fake value of previous match + for (const m of matches) { + // Positional weight of the current match + rank += (2 * positionWeight) / (1 + m); + if (prevMatch === m - 1) { + // Increase weight of consecutive matches + rank += jointWeight; + } + prevMatch = m; + } + + return rank / matches.length; +} diff --git a/src/utils/sparse-match/sparse-match.js b/src/utils/sparse-match/sparse-match.js new file mode 100644 index 000000000..c65661f42 --- /dev/null +++ b/src/utils/sparse-match/sparse-match.js @@ -0,0 +1,52 @@ +/** + * Returns all possible matches for the given query in the given text. Returning + * an indices of the found characters. + * + * ``` + * { + * text: 'abba cat', + * query: 'abc', + * expected: [ + * "[ab]ba [c]at", // [0, 1, 5] + * "[a]b[b]a [c]at", // [0, 2, 5] + * ], + * } + * ``` + * + * @param {string} text + * @param {string} query + * @param {number} start + * @returns {number[][]} + */ +export function sparseMatch(text, query, start = 0) { + if (!query || text.length < query.length + start) { + return []; + } + + /** + * @type {number[][]} + */ + const results = []; + const nextQuery = query.slice(1); + let idx = start; + // eslint-disable-next-line no-constant-condition + while (true) { + const p = text.indexOf(query[0], idx); + if (p === -1) { + break; + } + idx = p + 1; + + if (nextQuery) { + const next = sparseMatch(text, nextQuery, idx); + for (const it of next) { + it.unshift(p); + results.push(it); + } + } else { + results.push([p]); + } + } + + return results; +} diff --git a/src/utils/sparse-match/top-n.js b/src/utils/sparse-match/top-n.js new file mode 100644 index 000000000..453a5f291 --- /dev/null +++ b/src/utils/sparse-match/top-n.js @@ -0,0 +1,101 @@ +/** + * TopN takes a potentially unlimited number of items and keeps only the top N + * of them. + * + * @template T + */ +export class TopN { + /** + * @type {T[]} + */ + data = []; + /** + * @type {number} + */ + size; + /** + * @type {(a: T, b: T) => number} + */ + _compare; + + /** + * + * @param {number} size + * @param {(a: T, b: T) => number} compare + */ + constructor(size, compare) { + this.size = size; + this._compare = compare; + } + + /** + * + * @param {T[]} values + * @returns {this} + */ + addMany(values) { + for (const value of values) { + this.add(value); + } + return this; + } + + /** + * + * @param {T} value + * @returns {this} + */ + add(value) { + if (this.data.length === 0) { + this._ins(0, value); + return this; + } + + if (this._compare(value, this.data[this.data.length - 1]) >= 0) { + this._ins(this.data.length, value); + return this; + } + + if (this._compare(value, this.data[0]) <= 0) { + this._ins(0, value); + return this; + } + + // Binary search + let left = 0; + let right = this.data.length - 1; + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const cmpResult = this._compare(this.data[mid], value); + + if (cmpResult === 0) { + left = mid; + break; + } else if (cmpResult < 0) { + left = mid + 1; + } else { + right = mid - 1; + } + } + + this._ins(left, value); + + return this; + } + + /** + * + * @param {number} pos + * @param {T} value + * @returns {void} + */ + _ins(pos, value) { + if (pos >= this.size) { + return; + } + this.data.splice(pos, 0, value); + if (this.data.length > this.size) { + this.data.length = this.size; + } + } +} diff --git a/test/unit/utils/sparse-match/rank.js b/test/unit/utils/sparse-match/rank.js new file mode 100644 index 000000000..9e62a3c2a --- /dev/null +++ b/test/unit/utils/sparse-match/rank.js @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { compare, withRank } from '../../../../src/utils/sparse-match/rank'; + +describe('rank', () => { + const testData = [ + { + title: '1-chars query, 3-chars text', + variants: ['*--', '-*-', '--*'], + }, + { + title: '2-chars query, 3-chars text', + variants: ['**-', '-**', '*-*'], + }, + { + title: '3-chars query, 5-chars text', + variants: ['***--', '**-*-', '-***-', '**--*', '*-**-', '--***', '-**-*', '*-*-*'], + }, + ]; + + for (const { title, variants } of testData) { + it(`should test ${title}`, () => { + const result = shuffleInPlace([...variants]) + .map((s) => withRank(s, matches(s))) + .sort(compare) + .map((x) => x.text); + expect(result).toEqual(variants); + }); + } +}); + +/** + * + * @param {string} s + * @returns {number[]} + */ +function matches(s) { + const matches = []; + // eslint-disable-next-line unicorn/no-for-loop + for (let i = 0; i < s.length; i++) { + if (s[i] === '*') { + matches.push(i); + } + } + return matches; +} + +function shuffleInPlace(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} diff --git a/test/unit/utils/sparse-match/sparse-match.js b/test/unit/utils/sparse-match/sparse-match.js new file mode 100644 index 000000000..48a56727b --- /dev/null +++ b/test/unit/utils/sparse-match/sparse-match.js @@ -0,0 +1,68 @@ +import { describe, it } from 'vitest'; +import expect from 'unexpected'; +import { sparseMatch } from '../../../../src/utils/sparse-match/sparse-match'; + +describe('sparseMatch', () => { + const testCases = [ + { + text: 'abba cat', + query: 'abc', + // prettier-ignore + expected: [ + "[ab]ba [c]at", + "[a]b[b]a [c]at", + ], + }, + { + text: 'abbabba', + query: 'babb', + // prettier-ignore + expected: [ + "a[b]b[abb]a", + "ab[babb]a", + ], + }, + { + text: 'без кокошника', + query: 'кошка', + // prettier-ignore + expected: [ + "без [ко]ко[ш]ни[ка]", + "без [к]ок[ош]ни[ка]", + "без ко[кош]ни[ка]", + ], + }, + { text: 'ab', query: 'aba', expected: [] }, + { text: 'aba', query: 'aba', expected: ['[aba]'] }, + ]; + + for (const { text, query, expected } of testCases) { + it(`matching ${JSON.stringify(text)} against ${JSON.stringify(query)}`, () => { + const actual = sparseMatch(text, query).map((p) => hlPositions(text, p)); + expect(actual, 'to equal', expected); + }); + } +}); + +/** + * + * @param {string} text + * @param {number[]} positions + * @returns {string} + */ +function hlPositions(text, positions) { + return text + .split('') + .map((c, i) => { + if (positions.includes(i)) { + if (!positions.includes(i - 1)) { + c = `[${c}`; + } + if (!positions.includes(i + 1)) { + c = `${c}]`; + } + } + return c; + }) + .join(''); +} From a8eba5403fe65ea22c8273dda9e1c3069cfc51bd Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Sun, 23 Jun 2024 12:47:37 +0300 Subject: [PATCH 05/18] Add react-use-event-hook dependency --- package.json | 1 + yarn.lock | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/package.json b/package.json index 5d1f4a618..afc1392f8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index f5d9c9a62..daeb28deb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8512,6 +8512,15 @@ __metadata: languageName: node linkType: hard +"react-use-event-hook@npm:~0.9.6": + version: 0.9.6 + resolution: "react-use-event-hook@npm:0.9.6" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/693e546060e242586a5133d29fd323d767b75c703159f29ea048a296b4ef1088c108c729dfcf9331cae8a473a7795f786ee09bf4498859f576f4bc07060cbe01 + languageName: node + linkType: hard + "react@npm:~18.3.1": version: 18.3.1 resolution: "react@npm:18.3.1" @@ -8592,6 +8601,7 @@ __metadata: react-sortablejs: "npm:~6.1.4" react-test-renderer: "npm:~18.3.1" react-textarea-autosize: "npm:~8.5.3" + react-use-event-hook: "npm:~0.9.6" recharts: "npm:~2.12.7" redux: "npm:~5.0.1" rimraf: "npm:~5.0.7" From dac8c09cbac8a4c01289fdd3d655f9fc8a571f9f Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Wed, 12 Jun 2024 17:22:46 +0300 Subject: [PATCH 06/18] Create a preliminary version of autocomplete component --- src/components/autocomplete/autocomplete.jsx | 131 ++++++++++++++++++ .../autocomplete/autocomplete.module.scss | 61 ++++++++ .../autocomplete/highlight-text.jsx | 39 ++++++ src/components/autocomplete/selector.jsx | 102 ++++++++++++++ 4 files changed, 333 insertions(+) create mode 100644 src/components/autocomplete/autocomplete.jsx create mode 100644 src/components/autocomplete/autocomplete.module.scss create mode 100644 src/components/autocomplete/highlight-text.jsx create mode 100644 src/components/autocomplete/selector.jsx diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx new file mode 100644 index 000000000..4b466729a --- /dev/null +++ b/src/components/autocomplete/autocomplete.jsx @@ -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 }) { + 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} /> + </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 })); +} diff --git a/src/components/autocomplete/autocomplete.module.scss b/src/components/autocomplete/autocomplete.module.scss new file mode 100644 index 000000000..9aa0664c9 --- /dev/null +++ b/src/components/autocomplete/autocomplete.module.scss @@ -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; +} diff --git a/src/components/autocomplete/highlight-text.jsx b/src/components/autocomplete/highlight-text.jsx new file mode 100644 index 000000000..4b32d1268 --- /dev/null +++ b/src/components/autocomplete/highlight-text.jsx @@ -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}</>; +} diff --git a/src/components/autocomplete/selector.jsx b/src/components/autocomplete/selector.jsx new file mode 100644 index 000000000..f93f495eb --- /dev/null +++ b/src/components/autocomplete/selector.jsx @@ -0,0 +1,102 @@ +import { useStore } from 'react-redux'; +import cn from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; +import { useEvent } from 'react-use-event-hook'; +import { faUserFriends } from '@fortawesome/free-solid-svg-icons'; +import { Finder } from '../../utils/sparse-match'; +import { UserPicture } from '../user-picture'; +import { Icon } from '../fontawesome-icons'; +import style from './autocomplete.module.scss'; +import { HighlightText } from './highlight-text'; + +export function Selector({ query, events, onSelect }) { + const [usernames, accountsMap] = useAccountsMap(); + + const matches = useMemo(() => { + const finder = new Finder(query, 5); + for (const username of usernames) { + finder.add(username); + } + + return finder.results(); + }, [query, usernames]); + + const [cursor, setCursor] = useState(0); + useEffect(() => setCursor(0), [matches]); + + const keyHandler = useEvent((key) => { + switch (key) { + case 'ArrowDown': + setCursor((c) => (c + 1) % matches.length); + break; + case 'ArrowUp': + setCursor((c) => (c - 1 + matches.length) % matches.length); + break; + case 'Enter': + onSelect(matches[cursor].text); + break; + } + }); + + useEffect(() => events.subscribe(keyHandler), [events, keyHandler]); + + if (matches.length === 0) { + return null; + } + + return ( + <div className={style.selector}> + <ul className={style.list}> + {matches.map((match, idx) => ( + <Item + key={match.text} + account={accountsMap.get(match.text)} + match={match} + isCurrent={idx === cursor} + onClick={onSelect} + /> + ))} + </ul> + </div> + ); +} + +function Item({ account, match, isCurrent, onClick }) { + const clk = useEvent(() => onClick(match.text)); + + return ( + <li className={cn(style.item, isCurrent && style.itemCurrent)} onClick={clk}> + <UserPicture user={account} size={20} withLink={false} className={style.itemImage} /> + <span className={style.itemText}> + {account.type === 'group' && <Icon icon={faUserFriends} className={style.groupIcon} />} + <span className={style.userName}> + <HighlightText text={account.username} matches={match.matches} /> + </span> + {account.username !== account.screenName && ( + <span className={style.screenName}>{account.screenName}</span> + )} + </span> + </li> + ); +} + +function useAccountsMap() { + const store = useStore(); + + return useMemo(() => { + const state = store.getState(); + const accountsMap = new Map(); + const allAccounts = [ + ...Object.values(state.users), + ...Object.values(state.subscriptions), + ...Object.values(state.subscribers), + ...state.managedGroups, + ]; + + for (const account of allAccounts) { + account.username && accountsMap.set(account.username, account); + } + + return [[...accountsMap.keys()], accountsMap]; + }, [store]); +} From 6a74eb857ca540dc5b3bfba4158259ce3351244c Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Wed, 12 Jun 2024 17:54:45 +0300 Subject: [PATCH 07/18] Add autocomplete to the post/comment forms --- src/components/comment-edit-form.jsx | 2 ++ src/components/create-post.jsx | 2 ++ src/components/post/post-edit-form.jsx | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/components/comment-edit-form.jsx b/src/components/comment-edit-form.jsx index 8dc5f39e1..5468084ab 100644 --- a/src/components/comment-edit-form.jsx +++ b/src/components/comment-edit-form.jsx @@ -16,6 +16,7 @@ import { useUploader } from './uploader/uploader'; import { useFileChooser } from './uploader/file-chooser'; import { UploadProgress } from './uploader/progress'; import { PreventPageLeaving } from './prevent-page-leaving'; +import { Autocomplete } from './autocomplete/autocomplete'; export function CommentEditForm({ initialText = '', @@ -127,6 +128,7 @@ export function CommentEditForm({ draftKey={draftKey} cancelEmptyDraftOnBlur={isPersistent} /> + <Autocomplete inputRef={input} /> </div> <div> <button diff --git a/src/components/create-post.jsx b/src/components/create-post.jsx index dcd80429c..5f32279eb 100644 --- a/src/components/create-post.jsx +++ b/src/components/create-post.jsx @@ -28,6 +28,7 @@ import { CREATE_DIRECT, CREATE_REGULAR } from './feeds-selector/constants'; import { CommaAndSeparated } from './separated'; import { usePrivacyCheck } from './feeds-selector/privacy-check'; import { PreventPageLeaving } from './prevent-page-leaving'; +import { Autocomplete } from './autocomplete/autocomplete'; const selectMaxFilesCount = (serverInfo) => serverInfo.attachments.maxCountPerPost; const selectMaxPostLength = (serverInfo) => serverInfo.maxTextLength.post; @@ -225,6 +226,7 @@ export default function CreatePost({ sendTo, isDirects }) { draftKey={draftKey} cancelEmptyDraftOnBlur /> + <Autocomplete inputRef={textareaRef} /> </div> <div className="post-edit-actions"> diff --git a/src/components/post/post-edit-form.jsx b/src/components/post/post-edit-form.jsx index 2962ee816..0648f7391 100644 --- a/src/components/post/post-edit-form.jsx +++ b/src/components/post/post-edit-form.jsx @@ -23,6 +23,7 @@ import { EDIT_DIRECT, EDIT_REGULAR } from '../feeds-selector/constants'; import { ButtonLink } from '../button-link'; import { usePrivacyCheck } from '../feeds-selector/privacy-check'; import { doneEditingAndDeleteDraft, existingPostURI, getDraft } from '../../services/drafts'; +import { Autocomplete } from '../autocomplete/autocomplete'; import PostAttachments from './post-attachments'; const selectMaxFilesCount = (serverInfo) => serverInfo.attachments.maxCountPerPost; @@ -185,6 +186,7 @@ export function PostEditForm({ id, isDirect, recipients, createdBy, body, attach dir="auto" draftKey={draftKey} /> + <Autocomplete inputRef={textareaRef} /> </div> <div className="post-edit-actions"> From 34e1b27dee76afbafd0e99e8bda1d107c5170017 Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Sun, 23 Jun 2024 22:15:27 +0300 Subject: [PATCH 08/18] Rank found names using autocomplete context --- src/components/autocomplete/autocomplete.jsx | 4 +- src/components/autocomplete/ranked-names.js | 77 ++++++++++++++++++++ src/components/autocomplete/selector.jsx | 59 ++++++++++++--- src/components/comment-edit-form.jsx | 2 +- src/components/post/post-comment-ctx.js | 6 +- src/utils/sparse-match/index.js | 3 +- 6 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 src/components/autocomplete/ranked-names.js diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx index 4b466729a..0219fed5e 100644 --- a/src/components/autocomplete/autocomplete.jsx +++ b/src/components/autocomplete/autocomplete.jsx @@ -4,7 +4,7 @@ import { EventEmitter } from '../../services/drafts-events'; import style from './autocomplete.module.scss'; import { Selector } from './selector'; -export function Autocomplete({ inputRef }) { +export function Autocomplete({ inputRef, context }) { const [query, setQuery] = useState(/** @type {string|null}*/ null); const events = useMemo(() => new EventEmitter(), []); @@ -62,7 +62,7 @@ export function Autocomplete({ inputRef }) { if (query) { return ( <div className={style.wrapper}> - <Selector query={query} events={events} onSelect={onSelectHandler} /> + <Selector query={query} events={events} onSelect={onSelectHandler} context={context} /> </div> ); } diff --git a/src/components/autocomplete/ranked-names.js b/src/components/autocomplete/ranked-names.js new file mode 100644 index 000000000..6c5131c5a --- /dev/null +++ b/src/components/autocomplete/ranked-names.js @@ -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; +} diff --git a/src/components/autocomplete/selector.jsx b/src/components/autocomplete/selector.jsx index f93f495eb..ffe20ffa2 100644 --- a/src/components/autocomplete/selector.jsx +++ b/src/components/autocomplete/selector.jsx @@ -6,20 +6,30 @@ import { faUserFriends } from '@fortawesome/free-solid-svg-icons'; import { Finder } from '../../utils/sparse-match'; import { UserPicture } from '../user-picture'; import { Icon } from '../fontawesome-icons'; +import { usePostId } from '../post/post-comment-ctx'; import style from './autocomplete.module.scss'; import { HighlightText } from './highlight-text'; +import { + getAllGroups, + getAllUsers, + getMyFriends, + getMyGroups, + getMySubscribers, + getPostParticipants, + getRankedNames, +} from './ranked-names'; -export function Selector({ query, events, onSelect }) { - const [usernames, accountsMap] = useAccountsMap(); +export function Selector({ query, events, onSelect, context }) { + const [usernames, accountsMap, compare] = useAccountsMap({ context }); const matches = useMemo(() => { - const finder = new Finder(query, 5); + const finder = new Finder(query, 5, compare); for (const username of usernames) { finder.add(username); } return finder.results(); - }, [query, usernames]); + }, [compare, query, usernames]); const [cursor, setCursor] = useState(0); useEffect(() => setCursor(0), [matches]); @@ -80,23 +90,54 @@ function Item({ account, match, isCurrent, onClick }) { ); } -function useAccountsMap() { +function useAccountsMap({ context }) { const store = useStore(); + const postId = usePostId(); return useMemo(() => { const state = store.getState(); const accountsMap = new Map(); + let rankedNames; + + if (context === 'comment') { + const post = state.posts[postId]; + rankedNames = getRankedNames( + post && getPostParticipants(post, state), + getMyFriends(state), + getMyGroups(state), + getMySubscribers(state), + getAllUsers(state), + getAllGroups(state), + ); + } else { + rankedNames = getRankedNames( + getMyFriends(state), + getMyGroups(state), + getMySubscribers(state), + getAllUsers(state), + getAllGroups(state), + ); + } + + function compare(a, b) { + const aRank = a.rank + 10 / (1 + (rankedNames.get(a.text) ?? 0)); + const bRank = b.rank + 10 / (1 + (rankedNames.get(b.text) ?? 0)); + if (aRank === bRank) { + return a.text.localeCompare(b.text); + } + return bRank - aRank; + } + const allAccounts = [ ...Object.values(state.users), - ...Object.values(state.subscriptions), ...Object.values(state.subscribers), - ...state.managedGroups, + ...state.user.subscribers, ]; for (const account of allAccounts) { account.username && accountsMap.set(account.username, account); } - return [[...accountsMap.keys()], accountsMap]; - }, [store]); + return [[...accountsMap.keys()], accountsMap, compare]; + }, [context, postId, store]); } diff --git a/src/components/comment-edit-form.jsx b/src/components/comment-edit-form.jsx index 5468084ab..d5fae4bcf 100644 --- a/src/components/comment-edit-form.jsx +++ b/src/components/comment-edit-form.jsx @@ -128,7 +128,7 @@ export function CommentEditForm({ draftKey={draftKey} cancelEmptyDraftOnBlur={isPersistent} /> - <Autocomplete inputRef={input} /> + <Autocomplete inputRef={input} context="comment" /> </div> <div> <button diff --git a/src/components/post/post-comment-ctx.js b/src/components/post/post-comment-ctx.js index 236ae804f..21f8f8efe 100644 --- a/src/components/post/post-comment-ctx.js +++ b/src/components/post/post-comment-ctx.js @@ -16,7 +16,11 @@ export function useComment() { */ export const postIdContext = createContext(null); +export function usePostId() { + return useContext(postIdContext); +} + export function usePost() { - const id = useContext(postIdContext); + const id = usePostId(); return useSelector((state) => state.posts[id] ?? null); } diff --git a/src/utils/sparse-match/index.js b/src/utils/sparse-match/index.js index 8a12aef8b..4d8befec9 100644 --- a/src/utils/sparse-match/index.js +++ b/src/utils/sparse-match/index.js @@ -8,8 +8,9 @@ export class Finder { * * @param {string} query * @param {number} count + * @param {(a: Ranked, b: Ranked) => number} compare */ - constructor(query, count) { + constructor(query, count, compare) { this._topN = new TopN(count, compare); this.query = query; } From 79d35a6610142352c0855b3ee7e0f9225623a9a1 Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Mon, 24 Jun 2024 11:41:29 +0300 Subject: [PATCH 09/18] Load missing post's comment in case of autocomplete in comment --- src/components/autocomplete/selector.jsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/autocomplete/selector.jsx b/src/components/autocomplete/selector.jsx index ffe20ffa2..57a3cc5ea 100644 --- a/src/components/autocomplete/selector.jsx +++ b/src/components/autocomplete/selector.jsx @@ -6,7 +6,8 @@ import { faUserFriends } from '@fortawesome/free-solid-svg-icons'; import { Finder } from '../../utils/sparse-match'; import { UserPicture } from '../user-picture'; import { Icon } from '../fontawesome-icons'; -import { usePostId } from '../post/post-comment-ctx'; +import { usePost } from '../post/post-comment-ctx'; +import { showMoreComments } from '../../redux/action-creators'; import style from './autocomplete.module.scss'; import { HighlightText } from './highlight-text'; import { @@ -92,7 +93,17 @@ function Item({ account, match, isCurrent, onClick }) { function useAccountsMap({ context }) { const store = useStore(); - const postId = usePostId(); + const post = usePost(); + + useEffect(() => { + if (context !== 'comment' || !post) { + return; + } + const postState = store.getState().postsViewState[post?.id]; + if (post?.omittedComments > 0 && !postState?.loadingComments) { + store.dispatch(showMoreComments(post.id)); + } + }, [context, post, store]); return useMemo(() => { const state = store.getState(); @@ -100,7 +111,6 @@ function useAccountsMap({ context }) { let rankedNames; if (context === 'comment') { - const post = state.posts[postId]; rankedNames = getRankedNames( post && getPostParticipants(post, state), getMyFriends(state), @@ -139,5 +149,5 @@ function useAccountsMap({ context }) { } return [[...accountsMap.keys()], accountsMap, compare]; - }, [context, postId, store]); + }, [context, post, store]); } From 7e1a791455119bfa6c638ceaac3498fa627d99ad Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Mon, 24 Jun 2024 14:06:27 +0300 Subject: [PATCH 10/18] Request all users/groups when query longer than 2 chars --- src/components/autocomplete/selector.jsx | 18 +++++++++++++++--- src/redux/action-creators.js | 8 ++++++++ src/redux/action-types.js | 1 + src/redux/reducers.js | 3 ++- src/services/api.js | 7 +++++++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/components/autocomplete/selector.jsx b/src/components/autocomplete/selector.jsx index 57a3cc5ea..eb94ece8e 100644 --- a/src/components/autocomplete/selector.jsx +++ b/src/components/autocomplete/selector.jsx @@ -1,13 +1,13 @@ -import { useStore } from 'react-redux'; +import { useDispatch, useStore } from 'react-redux'; import cn from 'classnames'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useEvent } from 'react-use-event-hook'; import { faUserFriends } from '@fortawesome/free-solid-svg-icons'; import { Finder } from '../../utils/sparse-match'; import { UserPicture } from '../user-picture'; import { Icon } from '../fontawesome-icons'; import { usePost } from '../post/post-comment-ctx'; -import { showMoreComments } from '../../redux/action-creators'; +import { getMatchedUsers, showMoreComments } from '../../redux/action-creators'; import style from './autocomplete.module.scss'; import { HighlightText } from './highlight-text'; import { @@ -21,8 +21,20 @@ import { } from './ranked-names'; export function Selector({ query, events, onSelect, context }) { + const dispatch = useDispatch(); const [usernames, accountsMap, compare] = useAccountsMap({ context }); + // Request all users/groups when query longer than 2 chars + const lastQuery = useRef(''); + useEffect(() => { + const lc = lastQuery.current; + if (query.length < 2 || (lc && query.slice(0, lc.length) === lc)) { + return; + } + lastQuery.current = query; + dispatch(getMatchedUsers(query)); + }, [dispatch, query]); + const matches = useMemo(() => { const finder = new Finder(query, 5, compare); for (const username of usernames) { diff --git a/src/redux/action-creators.js b/src/redux/action-creators.js index 165bf557e..bcd9326eb 100644 --- a/src/redux/action-creators.js +++ b/src/redux/action-creators.js @@ -1395,3 +1395,11 @@ export function unlockComment(id) { payload: { id }, }; } + +export function getMatchedUsers(query) { + return { + type: ActionTypes.GET_MATCHED_USERS, + apiRequest: Api.getMatchedUsers, + payload: { query }, + }; +} diff --git a/src/redux/action-types.js b/src/redux/action-types.js index 945763000..90d88deed 100644 --- a/src/redux/action-types.js +++ b/src/redux/action-types.js @@ -180,3 +180,4 @@ export const GET_BACKLINKS = 'GET_BACKLINKS'; export const NOTIFY_OF_ALL_COMMENTS = 'NOTIFY_OF_ALL_COMMENTS'; export const SET_ORBIT = 'SET_ORBIT'; export const UNLOCK_COMMENT = 'UNLOCK_COMMENT'; +export const GET_MATCHED_USERS = 'GET_MATCHED_USERS'; diff --git a/src/redux/reducers.js b/src/redux/reducers.js index 17c9715e3..6e451713e 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -932,7 +932,8 @@ export function users(state = {}, action) { case ActionTypes.REALTIME_USER_UPDATE: { return mergeAccounts(action.updatedGroups || [], { insert: true, update: true }); } - case response(ActionTypes.GET_ALL_GROUPS): { + case response(ActionTypes.GET_ALL_GROUPS): + case response(ActionTypes.GET_MATCHED_USERS): { return mergeAccounts(action.payload.users); } case response(ActionTypes.BLOCKED_BY_ME): { diff --git a/src/services/api.js b/src/services/api.js index 3b63452b8..d748f6884 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -840,3 +840,10 @@ export function notifyOfAllComments({ postId, enabled }) { postRequestOptions('POST', { enabled }), ); } + +export function getMatchedUsers({ query }) { + return fetch( + `${apiPrefix}/users/sparseMatches?qs=${encodeURIComponent(query)}`, + getRequestOptions(), + ); +} From 59d8aa0e415a4b77f69f689db2b88df4bc39ea9b Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Mon, 24 Jun 2024 14:56:47 +0300 Subject: [PATCH 11/18] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6574cf7..8b501d25d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,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). From e5e0044c1f6a1a5b547fa3b075936bfe66d18646 Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Wed, 26 Jun 2024 22:45:49 +0300 Subject: [PATCH 12/18] Add dark theme styles for autocomplete --- .../autocomplete/autocomplete.module.scss | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/autocomplete/autocomplete.module.scss b/src/components/autocomplete/autocomplete.module.scss index 9aa0664c9..d959e2ce8 100644 --- a/src/components/autocomplete/autocomplete.module.scss +++ b/src/components/autocomplete/autocomplete.module.scss @@ -1,3 +1,5 @@ +@import '../../../styles/helvetica/dark-vars.scss'; + .wrapper { position: relative; } @@ -20,12 +22,21 @@ 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 { @@ -44,6 +55,10 @@ .itemCurrent, .item:hover { background-color: #eee; + + :global(.dark-theme) & { + background-color: $bg-highlight-color; + } } .itemImage { From 39b26201a4000643e4987d683e5ab15ee5ee3f63 Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Thu, 27 Jun 2024 10:19:56 +0300 Subject: [PATCH 13/18] Accept autocomplete option by Tab --- src/components/autocomplete/autocomplete.jsx | 5 ++++- src/components/autocomplete/selector.jsx | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx index 0219fed5e..d633f773e 100644 --- a/src/components/autocomplete/autocomplete.jsx +++ b/src/components/autocomplete/autocomplete.jsx @@ -10,7 +10,10 @@ export function Autocomplete({ inputRef, context }) { const events = useMemo(() => new EventEmitter(), []); const keyHandler = useEvent((/** @type {KeyboardEvent}*/ e) => { - if (query !== null && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter')) { + if ( + query !== null && + (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === 'Tab') + ) { e.preventDefault(); e.stopPropagation(); events.emit(e.key); diff --git a/src/components/autocomplete/selector.jsx b/src/components/autocomplete/selector.jsx index eb94ece8e..0ea306caa 100644 --- a/src/components/autocomplete/selector.jsx +++ b/src/components/autocomplete/selector.jsx @@ -56,6 +56,7 @@ export function Selector({ query, events, onSelect, context }) { setCursor((c) => (c - 1 + matches.length) % matches.length); break; case 'Enter': + case 'Tab': onSelect(matches[cursor].text); break; } From 37f0f11e92c54daa130f29812e64e29e44591d42 Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Thu, 27 Jun 2024 22:22:52 +0300 Subject: [PATCH 14/18] Use a hacky way to set & update the React's input value --- src/components/autocomplete/autocomplete.jsx | 6 +++--- src/components/smart-textarea.jsx | 14 ++++++-------- src/utils/set-react-input-value.js | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 src/utils/set-react-input-value.js diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx index d633f773e..de16f69d1 100644 --- a/src/components/autocomplete/autocomplete.jsx +++ b/src/components/autocomplete/autocomplete.jsx @@ -1,6 +1,7 @@ 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'; @@ -126,9 +127,8 @@ function replaceQuery(input, replacement) { const before = input.value.slice(0, matchPos[0]); const after = input.value.slice(matchPos[1]); - input.value = before + replacement + (after || ' '); + const newValue = before + replacement + (after || ' '); const newCaretPos = matchPos[0] + replacement.length + 1; + setReactInputValue(input, newValue); input.setSelectionRange(newCaretPos, newCaretPos); - - input.dispatchEvent(new Event('input', { bubbles: true })); } diff --git a/src/components/smart-textarea.jsx b/src/components/smart-textarea.jsx index 55b07dd56..23714bf0e 100644 --- a/src/components/smart-textarea.jsx +++ b/src/components/smart-textarea.jsx @@ -9,6 +9,7 @@ import { submittingByEnter } from '../services/appearance'; import { makeJpegIfNeeded } from '../utils/jpeg-if-needed'; import { insertText } from '../utils/insert-text'; import { doneEditingIfEmpty, getDraft, setDraftField, subscribeToDrafts } from '../services/drafts'; +import { setReactInputValue } from '../utils/set-react-input-value'; import { useForwardedRef } from './hooks/forward-ref'; import { useEventListener } from './hooks/sub-unsub'; @@ -63,7 +64,8 @@ export const SmartTextarea = forwardRef(function SmartTextarea( }; }, [cancelEmptyDraftOnBlur, draftKey, ref]); - ref.current.insertText = useDebouncedInsert(100, ref, onText, draftKey); + // Public component method + ref.current.insertText = useDebouncedInsert(100, ref); useEffect(() => { if (!draftKey && !onText) { @@ -242,7 +244,7 @@ function containsFiles(dndEvent) { return false; } -function useDebouncedInsert(interval, inputRef, onText, draftKey) { +function useDebouncedInsert(interval, inputRef) { const queue = useRef([]); const timer = useRef(0); @@ -264,14 +266,10 @@ function useDebouncedInsert(interval, inputRef, onText, draftKey) { ); // Pre-fill the input value to keep the cursor/selection // position after React update cycle - input.value = text; + setReactInputValue(input, text); input.setSelectionRange(selStart, selEnd); input.focus(); - onText?.(input.value); - if (draftKey) { - setDraftField(draftKey, 'text', input.value); - } - }, [draftKey, inputRef, onText]); + }, [inputRef]); return useCallback( (insertion) => { diff --git a/src/utils/set-react-input-value.js b/src/utils/set-react-input-value.js new file mode 100644 index 000000000..4895879d0 --- /dev/null +++ b/src/utils/set-react-input-value.js @@ -0,0 +1,18 @@ +/** + * A hacky way to set the value of a React managed input element and trigger the + * _onChange_ handler. Replaces the naive `input.value = value`. + * + * It works with React 16-18, but may stop working in the future (React 19?). + * + * @see https://github.com/facebook/react/issues/11488#issuecomment-347775628 + * @param {HTMLTextAreaElement|HTMLInputElement} input + * @param {string} value + */ +export function setReactInputValue(input, value) { + const prevValue = input.value; + input.value = value; + // Magic is here: Set the internal value to prevValue (!= value), to force an + // actual change. + input._valueTracker?.setValue(prevValue); + input.dispatchEvent(new Event('input', { bubbles: true })); +} From 9e18a962624b20a0a9275e7834203c329a29395c Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Fri, 28 Jun 2024 14:40:22 +0300 Subject: [PATCH 15/18] Use regex instead of just '@' as autocomplete anchor --- src/components/autocomplete/autocomplete.jsx | 41 ++++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx index de16f69d1..0f80e98aa 100644 --- a/src/components/autocomplete/autocomplete.jsx +++ b/src/components/autocomplete/autocomplete.jsx @@ -5,7 +5,11 @@ import { setReactInputValue } from '../../utils/set-react-input-value'; import style from './autocomplete.module.scss'; import { Selector } from './selector'; -export function Autocomplete({ inputRef, context }) { +// 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(), []); @@ -33,7 +37,7 @@ export function Autocomplete({ inputRef, context }) { if (e.type === 'selectionchange' && document.activeElement !== input) { return; } - const matchPos = getQueryPosition(input); + const matchPos = getQueryPosition(input, anchor); setQuery(matchPos ? input.value.slice(matchPos[0], matchPos[1]) : null); }; @@ -59,9 +63,9 @@ export function Autocomplete({ inputRef, context }) { document.removeEventListener('selectionchange', inputHandler); input.removeEventListener('keydown', keyHandler, { capture: true }); }; - }, [inputRef, keyHandler]); + }, [anchor, inputRef, keyHandler]); - const onSelectHandler = useEvent((text) => replaceQuery(inputRef.current, text)); + const onSelectHandler = useEvent((text) => replaceQuery(inputRef.current, text, anchor)); if (query) { return ( @@ -90,27 +94,32 @@ export function Autocomplete({ inputRef, context }) { * @param {HTMLInputElement|HTMLTextAreaElement} input * @returns {[number, number]|null} */ -function getQueryPosition({ value, selectionStart }) { +function getQueryPosition({ value, selectionStart }, anchor) { if (!selectionStart) { return null; } - const found = value.lastIndexOf('@', selectionStart - 1); - if (found === -1) { - return null; + + anchor.lastIndex = 0; + + let found = -1; + while (anchor.exec(value) !== null) { + if (anchor.lastIndex > selectionStart) { + break; + } + found = anchor.lastIndex; } - // 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])) { + + if (found === -1) { return null; } - const match = value.slice(found + 1).match(/^[a-z\d-]+/i)?.[0]; + 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 - 2) { + if (!match || match.length <= selectionStart - found - 1) { return null; } - return [found + 1, found + 1 + match.length]; + return [found, found + match.length]; } /** @@ -119,8 +128,8 @@ function getQueryPosition({ value, selectionStart }) { * @param {string} replacement * @returns {void} */ -function replaceQuery(input, replacement) { - const matchPos = getQueryPosition(input); +function replaceQuery(input, replacement, anchor) { + const matchPos = getQueryPosition(input, anchor); if (!matchPos) { return; } From e952120ec74aa99eb85134a67b05bb454b7c426e Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Fri, 28 Jun 2024 14:51:38 +0300 Subject: [PATCH 16/18] Add Autocomplete to the search string --- src/components/autocomplete/selector.jsx | 28 ++++++++++----------- src/components/layout-header.jsx | 32 +++++++++++++++--------- src/components/layout-header.module.scss | 13 ++++++++++ 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/components/autocomplete/selector.jsx b/src/components/autocomplete/selector.jsx index 0ea306caa..773f26f61 100644 --- a/src/components/autocomplete/selector.jsx +++ b/src/components/autocomplete/selector.jsx @@ -123,23 +123,21 @@ function useAccountsMap({ context }) { const accountsMap = new Map(); let rankedNames; + const defaultRankings = [ + getMyFriends(state), + getMyGroups(state), + getMySubscribers(state), + getAllUsers(state), + getAllGroups(state), + ]; + if (context === 'comment') { - rankedNames = getRankedNames( - post && getPostParticipants(post, state), - getMyFriends(state), - getMyGroups(state), - getMySubscribers(state), - getAllUsers(state), - getAllGroups(state), - ); + rankedNames = getRankedNames(post && getPostParticipants(post, state), ...defaultRankings); + } else if (context === 'search') { + rankedNames = getRankedNames(new Set(['me']), ...defaultRankings); + accountsMap.set('me', { ...state.users[state.user.id], username: 'me' }); } else { - rankedNames = getRankedNames( - getMyFriends(state), - getMyGroups(state), - getMySubscribers(state), - getAllUsers(state), - getAllGroups(state), - ); + rankedNames = getRankedNames(...defaultRankings); } function compare(a, b) { diff --git a/src/components/layout-header.jsx b/src/components/layout-header.jsx index 32aa64f3a..9c3dfa6f5 100644 --- a/src/components/layout-header.jsx +++ b/src/components/layout-header.jsx @@ -11,6 +11,9 @@ import { Icon } from './fontawesome-icons'; import { useMediaQuery } from './hooks/media-query'; import styles from './layout-header.module.scss'; import { SignInLink } from './sign-in-link'; +import { Autocomplete } from './autocomplete/autocomplete'; + +const autocompleteAnchor = /(?<![a-z\d])@|((from|to|author|by|in|commented-?by|liked-?by):)/gi; export const LayoutHeader = withRouter(function LayoutHeader({ router }) { const dispatch = useDispatch(); @@ -73,18 +76,23 @@ export const LayoutHeader = withRouter(function LayoutHeader({ router }) { const searchForm = ( <form className={styles.searchForm} action="/search" onSubmit={onSubmit}> <span className={styles.searchInputContainer} {...focusHandlers} tabIndex={0}> - <input - className={styles.searchInput} - type="text" - name="q" - ref={input} - placeholder="Search request" - autoFocus={collapsibleSearchForm} - value={query} - onChange={onQueryChange} - onKeyDown={onKeyDown} - tabIndex={-1} - /> + <span className={styles.searchInputBox}> + <input + className={styles.searchInput} + type="text" + name="q" + ref={input} + placeholder="Search request" + autoFocus={collapsibleSearchForm} + value={query} + onChange={onQueryChange} + onKeyDown={onKeyDown} + tabIndex={-1} + /> + <div className={styles.autocompleteBox}> + <Autocomplete inputRef={input} context="search" anchor={autocompleteAnchor} /> + </div> + </span> {compactSearchForm && <Icon icon={faSearch} className={styles.searchIcon} />} <button type="button" diff --git a/src/components/layout-header.module.scss b/src/components/layout-header.module.scss index 09e394760..bbe0d6a7a 100644 --- a/src/components/layout-header.module.scss +++ b/src/components/layout-header.module.scss @@ -95,6 +95,19 @@ $without-sidebar: '(max-width: 991px)'; } } +.searchInputBox { + display: flex; + flex: 1; + position: relative; +} + +.autocompleteBox { + position: absolute; + bottom: 0; + left: 0; + width: 100%; +} + .searchInput { width: 100%; background-color: transparent; From b4a28e4855fa444ed5f9b896b5a8975a8b40841d Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Sat, 29 Jun 2024 14:35:42 +0300 Subject: [PATCH 17/18] Fix blur timer work --- src/components/autocomplete/autocomplete.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx index 0f80e98aa..ad6ccac96 100644 --- a/src/components/autocomplete/autocomplete.jsx +++ b/src/components/autocomplete/autocomplete.jsx @@ -43,9 +43,9 @@ export function Autocomplete({ inputRef, context, anchor = defaultAnchor }) { // Clears the query after 100ms of no focus. This delay allows to click on // the selector by mouse. - const timer = 0; + let timer = 0; const focusHandler = () => clearTimeout(timer); - const blurHandler = () => setTimeout(() => setQuery(null), 100); + const blurHandler = () => (timer = setTimeout(() => setQuery(null), 500)); input.addEventListener('blur', blurHandler); input.addEventListener('focus', focusHandler); From 5defec1572bded184a6c2afeb74c4145d4eb4281 Mon Sep 17 00:00:00 2001 From: David Mzareulyan <david@hiero.ru> Date: Sat, 29 Jun 2024 16:57:56 +0300 Subject: [PATCH 18/18] Don't use lookbehind assertions in regex (incompatible with old Safari) See https://caniuse.com/js-regexp-lookbehind --- src/components/autocomplete/autocomplete.jsx | 2 +- src/components/layout-header.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/autocomplete/autocomplete.jsx b/src/components/autocomplete/autocomplete.jsx index ad6ccac96..285a08e7b 100644 --- a/src/components/autocomplete/autocomplete.jsx +++ b/src/components/autocomplete/autocomplete.jsx @@ -7,7 +7,7 @@ import { Selector } from './selector'; // There should be no alphanumeric characters right before the "@" (to exclude // email-like strings) -const defaultAnchor = /(?<![a-z\d])@/gi; +const defaultAnchor = /(^|[^a-z\d])@/gi; export function Autocomplete({ inputRef, context, anchor = defaultAnchor }) { const [query, setQuery] = useState(/** @type {string|null}*/ null); diff --git a/src/components/layout-header.jsx b/src/components/layout-header.jsx index 9c3dfa6f5..22b012e50 100644 --- a/src/components/layout-header.jsx +++ b/src/components/layout-header.jsx @@ -13,7 +13,7 @@ import styles from './layout-header.module.scss'; import { SignInLink } from './sign-in-link'; import { Autocomplete } from './autocomplete/autocomplete'; -const autocompleteAnchor = /(?<![a-z\d])@|((from|to|author|by|in|commented-?by|liked-?by):)/gi; +const autocompleteAnchor = /(^|[^a-z\d])@|((from|to|author|by|in|commented-?by|liked-?by):)/gi; export const LayoutHeader = withRouter(function LayoutHeader({ router }) { const dispatch = useDispatch();