From 502bfee07214ae8818261f0436528c39cd6241d0 Mon Sep 17 00:00:00 2001 From: SatsAllDay <128755788+SatsAllDay@users.noreply.github.com> Date: Wed, 4 Oct 2023 16:10:56 -0400 Subject: [PATCH] Mention auto-complete (#532) * uber rough first pass at mention autocompletes * support custom limit on topUsers query * hot keys for selecting user suggestion in markdown input * query top stackers for mentions with no search query * refactor UserSuggestion to help with reusability textarea-caret for placing the user suggest dropdown appropriately other various code cleanup items to make it easier to use off by one errors are fun! various code cleanup and reuse the UserSuggest component in InputUserSuggest to reduce duplication * change default users to week query --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: keyan --- api/resolvers/user.js | 18 +-- api/typeDefs/user.js | 2 +- components/form.js | 265 +++++++++++++++++++++++++++++------------- fragments/users.js | 4 +- lib/cursor.js | 4 +- package-lock.json | 11 ++ package.json | 1 + 7 files changed, 211 insertions(+), 94 deletions(-) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 4e8921cd..91642e31 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -151,7 +151,7 @@ export default { users } }, - topUsers: async (parent, { cursor, when, by }, { models, me }) => { + topUsers: async (parent, { cursor, when, by, limit = LIMIT }, { models, me }) => { const decodedCursor = decodeCursor(cursor) let users @@ -179,10 +179,10 @@ export default { ) SELECT * FROM u WHERE ${column} > 0 OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) return { - cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null, users } } @@ -206,7 +206,7 @@ export default { GROUP BY users.id, users.name ORDER BY spent DESC NULLS LAST, users.created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } else if (by === 'posts') { users = await models.$queryRawUnsafe(` SELECT users.*, count(*)::INTEGER as nposts @@ -218,7 +218,7 @@ export default { GROUP BY users.id ORDER BY nposts DESC NULLS LAST, users.created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } else if (by === 'comments') { users = await models.$queryRawUnsafe(` SELECT users.*, count(*)::INTEGER as ncomments @@ -230,7 +230,7 @@ export default { GROUP BY users.id ORDER BY ncomments DESC NULLS LAST, users.created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } else if (by === 'referrals') { users = await models.$queryRawUnsafe(` SELECT users.*, count(*)::INTEGER as referrals @@ -242,7 +242,7 @@ export default { GROUP BY users.id ORDER BY referrals DESC NULLS LAST, users.created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } else { users = await models.$queryRawUnsafe(` SELECT u.id, u.name, u.streak, u."photoId", u."hideCowboyHat", floor(sum(amount)/1000) as stacked @@ -269,11 +269,11 @@ export default { GROUP BY u.id, u.name, u.created_at, u."photoId", u.streak, u."hideCowboyHat" ORDER BY stacked DESC NULLS LAST, created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } return { - cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null, users } }, diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index c2690fab..eb7037f2 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -7,7 +7,7 @@ export default gql` user(name: String!): User users: [User!] nameAvailable(name: String!): Boolean! - topUsers(cursor: String, when: String, by: String): Users + topUsers(cursor: String, when: String, by: String, limit: Int): Users topCowboys(cursor: String): Users searchUsers(q: String!, limit: Int, similarity: Float): [User!]! hasNewNotes: Boolean! diff --git a/components/form.js b/components/form.js index 0c200383..bdbbcf19 100644 --- a/components/form.js +++ b/components/form.js @@ -2,7 +2,7 @@ import Button from 'react-bootstrap/Button' import InputGroup from 'react-bootstrap/InputGroup' import BootstrapForm from 'react-bootstrap/Form' import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik' -import React, { createContext, useContext, useEffect, useRef, useState } from 'react' +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import copy from 'clipboard-copy' import Thumb from '../svgs/thumb-up-fill.svg' import Col from 'react-bootstrap/Col' @@ -16,11 +16,12 @@ import AddIcon from '../svgs/add-fill.svg' import { mdHas } from '../lib/md' import CloseIcon from '../svgs/close-line.svg' import { useLazyQuery } from '@apollo/client' -import { USER_SEARCH } from '../fragments/users' +import { TOP_USERS, USER_SEARCH } from '../fragments/users' import TextareaAutosize from 'react-textarea-autosize' import { useToast } from './toast' import { useInvoiceable } from './invoice' import { numWithUnits } from '../lib/format' +import textAreaCaret from 'textarea-caret' import ReactDatePicker from 'react-datepicker' import 'react-datepicker/dist/react-datepicker.css' @@ -119,6 +120,20 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH } }, [innerRef, selectionRange.start, selectionRange.end]) + const [mentionQuery, setMentionQuery] = useState() + const [mentionIndices, setMentionIndices] = useState({ start: -1, end: -1 }) + const [userSuggestDropdownStyle, setUserSuggestDropdownStyle] = useState({}) + const insertMention = useCallback((name) => { + const { start, end } = mentionIndices + const first = `${innerRef.current.value.substring(0, start)}@${name}` + const second = innerRef.current.value.substring(end) + const updatedValue = `${first}${second}` + innerRef.current.value = updatedValue + helpers.setValue(updatedValue) + setSelectionRange({ start: first.length, end: first.length }) + innerRef.current.focus() + }, [mentionIndices, innerRef, helpers]) + return (
@@ -136,42 +151,85 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH -
- { - if (onChange) onChange(formik, e) - if (setHasImgLink) { - setHasImgLink(mdHas(e.target.value, ['link', 'image'])) - } - }} - innerRef={innerRef} - onKeyDown={(e) => { - const metaOrCtrl = e.metaKey || e.ctrlKey - if (metaOrCtrl) { - if (e.key === 'k') { - // some browsers use CTRL+K to focus search bar so we have to prevent that behavior - e.preventDefault() - insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange) +
+ {({ onKeyDown: userSuggestOnKeyDown }) => ( + { + if (onChange) onChange(formik, e) + if (setHasImgLink) { + setHasImgLink(mdHas(e.target.value, ['link', 'image'])) } - if (e.key === 'b') { - // some browsers use CTRL+B to open bookmarks so we have to prevent that behavior - e.preventDefault() - insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange) + // check for mention editing + const { value, selectionStart } = e.target + let priorSpace = -1 + for (let i = selectionStart - 1; i >= 0; i--) { + if (/\s|\n/.test(value[i])) { + priorSpace = i + break + } } - if (e.key === 'i') { - // some browsers might use CTRL+I to do something else so prevent that behavior too - e.preventDefault() - insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange) + let nextSpace = value.length + for (let i = selectionStart; i <= value.length; i++) { + if (/\s|\n/.test(value[i])) { + nextSpace = i + break + } } - if (e.key === 'Tab' && e.altKey) { - e.preventDefault() - insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange) - } - } + const currentSegment = value.substring(priorSpace + 1, nextSpace) - if (onKeyDown) onKeyDown(e) - }} - /> + // set the query to the current character segment and note where it appears + if (/^@\w*/.test(currentSegment)) { + setMentionQuery(currentSegment) + setMentionIndices({ start: priorSpace + 1, end: nextSpace }) + } else { + setMentionQuery(undefined) + setMentionIndices({ start: -1, end: -1 }) + } + + const { top, left } = textAreaCaret(e.target, e.target.selectionStart) + setUserSuggestDropdownStyle({ + position: 'absolute', + top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`, + left: `${left}px` + }) + }} + innerRef={innerRef} + onKeyDown={(e) => { + const metaOrCtrl = e.metaKey || e.ctrlKey + if (metaOrCtrl) { + if (e.key === 'k') { + // some browsers use CTRL+K to focus search bar so we have to prevent that behavior + e.preventDefault() + insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + if (e.key === 'b') { + // some browsers use CTRL+B to open bookmarks so we have to prevent that behavior + e.preventDefault() + insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + if (e.key === 'i') { + // some browsers might use CTRL+I to do something else so prevent that behavior too + e.preventDefault() + insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + if (e.key === 'Tab' && e.altKey) { + e.preventDefault() + insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + } + + if (!metaOrCtrl) { + userSuggestOnKeyDown(e) + } + + if (onKeyDown) onKeyDown(e) + }} + />)} +
{tab !== 'write' &&
@@ -340,7 +398,12 @@ function InputInner ({ ) } -export function InputUserSuggest ({ label, groupClassName, ...props }) { +export function UserSuggest ({ query, onSelect, dropdownStyle, children }) { + const [getUsers] = useLazyQuery(TOP_USERS, { + onCompleted: data => { + setSuggestions({ array: data.topUsers.users, index: 0 }) + } + }) const [getSuggestions] = useLazyQuery(USER_SEARCH, { onCompleted: data => { setSuggestions({ array: data.searchUsers, index: 0 }) @@ -349,64 +412,106 @@ export function InputUserSuggest ({ label, groupClassName, ...props }) { const INITIAL_SUGGESTIONS = { array: [], index: 0 } const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS) - const [ovalue, setOValue] = useState() + const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), []) + + useEffect(() => { + if (query !== undefined) { + const q = query?.replace(/^[@ ]+|[ ]+$/g, '') + if (q === '') { + getUsers({ variables: { by: 'stacked', when: 'week', limit: 5 } }) + } else { + getSuggestions({ variables: { q } }) + } + } else { + resetSuggestions() + } + }, [query]) + + const onKeyDown = useCallback(e => { + switch (e.code) { + case 'ArrowUp': + if (suggestions.array.length === 0) { + break + } + e.preventDefault() + setSuggestions(suggestions => + ({ + ...suggestions, + index: Math.max(suggestions.index - 1, 0) + })) + break + case 'ArrowDown': + if (suggestions.array.length === 0) { + break + } + e.preventDefault() + setSuggestions(suggestions => + ({ + ...suggestions, + index: Math.min(suggestions.index + 1, suggestions.array.length - 1) + })) + break + case 'Tab': + case 'Enter': + if (suggestions.array?.length === 0) { + break + } + e.preventDefault() + onSelect(suggestions.array[suggestions.index].name) + resetSuggestions() + break + case 'Escape': + e.preventDefault() + resetSuggestions() + break + default: + break + } + }, [onSelect, resetSuggestions, suggestions]) return ( - - { - setOValue(e.target.value) - getSuggestions({ variables: { q: e.target.value.replace(/^[@ ]+|[ ]+$/g, '') } }) - }} - overrideValue={ovalue} - onKeyDown={(e) => { - switch (e.code) { - case 'ArrowUp': - e.preventDefault() - setSuggestions( - { - ...suggestions, - index: Math.max(suggestions.index - 1, 0) - }) - break - case 'ArrowDown': - e.preventDefault() - setSuggestions( - { - ...suggestions, - index: Math.min(suggestions.index + 1, suggestions.array.length - 1) - }) - break - case 'Enter': - e.preventDefault() - setOValue(suggestions.array[suggestions.index].name) - setSuggestions(INITIAL_SUGGESTIONS) - break - case 'Escape': - e.preventDefault() - setSuggestions(INITIAL_SUGGESTIONS) - break - default: - break - } - }} - /> - 0}> + <> + {children?.({ onKeyDown })} + 0} style={dropdownStyle}> {suggestions.array.map((v, i) => { - setOValue(v.name) - setSuggestions(INITIAL_SUGGESTIONS) + onSelect(v.name) + resetSuggestions() }} > {v.name} )} + + ) +} + +export function InputUserSuggest ({ label, groupClassName, ...props }) { + const [ovalue, setOValue] = useState() + const [query, setQuery] = useState() + return ( + + + {({ onKeyDown }) => ( + { + setOValue(e.target.value) + setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, '')) + }} + overrideValue={ovalue} + onKeyDown={onKeyDown} + /> + )} + ) } diff --git a/fragments/users.js b/fragments/users.js index 95cb3f56..9ebbefb0 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -164,8 +164,8 @@ export const USER_FIELDS = gql` }` export const TOP_USERS = gql` - query TopUsers($cursor: String, $when: String, $by: String) { - topUsers(cursor: $cursor, when: $when, by: $by) { + query TopUsers($cursor: String, $when: String, $by: String, $limit: Int) { + topUsers(cursor: $cursor, when: $when, by: $by, limit: $limit) { users { id name diff --git a/lib/cursor.js b/lib/cursor.js index d9cdfd63..98e3e638 100644 --- a/lib/cursor.js +++ b/lib/cursor.js @@ -10,7 +10,7 @@ export function decodeCursor (cursor) { } } -export function nextCursorEncoded (cursor) { - cursor.offset += LIMIT +export function nextCursorEncoded (cursor, limit = LIMIT) { + cursor.offset += limit return Buffer.from(JSON.stringify(cursor)).toString('base64') } diff --git a/package-lock.json b/package-lock.json index 28be981b..16f12a70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "remove-markdown": "^0.5.0", "sass": "^1.65.1", "serviceworker-storage": "^0.1.0", + "textarea-caret": "^3.1.0", "tldts": "^6.0.14", "tsx": "^3.13.0", "typescript": "^5.1.6", @@ -16749,6 +16750,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/textarea-caret": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/textarea-caret/-/textarea-caret-3.1.0.tgz", + "integrity": "sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -29976,6 +29982,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "textarea-caret": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/textarea-caret/-/textarea-caret-3.1.0.tgz", + "integrity": "sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==" + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/package.json b/package.json index 1ebd07b2..af34d1df 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "remove-markdown": "^0.5.0", "sass": "^1.65.1", "serviceworker-storage": "^0.1.0", + "textarea-caret": "^3.1.0", "tldts": "^6.0.14", "tsx": "^3.13.0", "typescript": "^5.1.6",