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 <keyan.kousha+huumn@gmail.com>
This commit is contained in:
SatsAllDay 2023-10-04 16:10:56 -04:00 committed by GitHub
parent e76b8a2915
commit 502bfee072
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 211 additions and 94 deletions

View File

@ -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
}
},

View File

@ -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!

View File

@ -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 (
<FormGroup label={label} className={groupClassName}>
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
@ -136,13 +151,51 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
<Markdown width={18} height={18} />
</a>
</Nav>
<div className={tab === 'write' ? '' : 'd-none'}>
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
<UserSuggest
query={mentionQuery}
onSelect={insertMention}
dropdownStyle={userSuggestDropdownStyle}
>{({ onKeyDown: userSuggestOnKeyDown }) => (
<InputInner
{...props} onChange={(formik, e) => {
if (onChange) onChange(formik, e)
if (setHasImgLink) {
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
}
// 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
}
}
let nextSpace = value.length
for (let i = selectionStart; i <= value.length; i++) {
if (/\s|\n/.test(value[i])) {
nextSpace = i
break
}
}
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 })
}
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) => {
@ -169,9 +222,14 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
}
}
if (!metaOrCtrl) {
userSuggestOnKeyDown(e)
}
if (onKeyDown) onKeyDown(e)
}}
/>
/>)}
</UserSuggest>
</div>
{tab !== 'write' &&
<div className='form-group'>
@ -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()
return (
<FormGroup label={label} className={groupClassName}>
<InputInner
{...props}
autoComplete='off'
onChange={(_, e) => {
setOValue(e.target.value)
getSuggestions({ variables: { q: e.target.value.replace(/^[@ ]+|[ ]+$/g, '') } })
}}
overrideValue={ovalue}
onKeyDown={(e) => {
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(
{
setSuggestions(suggestions =>
({
...suggestions,
index: Math.max(suggestions.index - 1, 0)
})
}))
break
case 'ArrowDown':
if (suggestions.array.length === 0) {
break
}
e.preventDefault()
setSuggestions(
{
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()
setOValue(suggestions.array[suggestions.index].name)
setSuggestions(INITIAL_SUGGESTIONS)
onSelect(suggestions.array[suggestions.index].name)
resetSuggestions()
break
case 'Escape':
e.preventDefault()
setSuggestions(INITIAL_SUGGESTIONS)
resetSuggestions()
break
default:
break
}
}}
/>
<Dropdown show={suggestions.array.length > 0}>
}, [onSelect, resetSuggestions, suggestions])
return (
<>
{children?.({ onKeyDown })}
<Dropdown show={suggestions.array.length > 0} style={dropdownStyle}>
<Dropdown.Menu className={styles.suggestionsMenu}>
{suggestions.array.map((v, i) =>
<Dropdown.Item
key={v.name}
active={suggestions.index === i}
onClick={() => {
setOValue(v.name)
setSuggestions(INITIAL_SUGGESTIONS)
onSelect(v.name)
resetSuggestions()
}}
>
{v.name}
</Dropdown.Item>)}
</Dropdown.Menu>
</Dropdown>
</>
)
}
export function InputUserSuggest ({ label, groupClassName, ...props }) {
const [ovalue, setOValue] = useState()
const [query, setQuery] = useState()
return (
<FormGroup label={label} className={groupClassName}>
<UserSuggest
onSelect={setOValue}
query={query}
>
{({ onKeyDown }) => (
<InputInner
{...props}
autoComplete='off'
onChange={(_, e) => {
setOValue(e.target.value)
setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, ''))
}}
overrideValue={ovalue}
onKeyDown={onKeyDown}
/>
)}
</UserSuggest>
</FormGroup>
)
}

View File

@ -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

View File

@ -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')
}

11
package-lock.json generated
View File

@ -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",

View File

@ -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",