From 0ed4d03f540f8c2ad7d33ced4f8c97d2866bfc87 Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Sat, 30 Sep 2023 20:37:02 -0400 Subject: [PATCH] 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 --- api/resolvers/user.js | 18 +-- api/typeDefs/user.js | 2 +- components/form.js | 263 +++++++++++++++++++++++++++++------------- fragments/users.js | 4 +- lib/cursor.js | 4 +- package-lock.json | 11 ++ package.json | 1 + 7 files changed, 210 insertions(+), 93 deletions(-) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 4e8921cdc..91642e31d 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 43d31c541..14084276f 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 d135256c5..31bee17fb 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' export function SubmitButton ({ children, variant, value, onClick, disabled, cost, ...props @@ -117,6 +118,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 (
@@ -134,42 +149,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) + + // 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 }) } - } - if (onKeyDown) onKeyDown(e) - }} - /> + 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' &&
@@ -338,7 +396,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 }) @@ -347,64 +410,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: 'day', 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 0fdd5791d..de28fcd44 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -162,8 +162,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 d9cdfd636..98e3e6387 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 2327ee129..199eac0d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,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", @@ -16696,6 +16697,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", @@ -29886,6 +29892,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 71b336655..8b77197a4 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,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",