feat: Territory autocomplete (#2124)

* feat: Territory autocomplete

Closes #992.

* refactor: refactor UserSuggest and TerritorySuggest components

* style: lint

* refactor: unify user and territory autocomplete logic

* simplify a bit and fix unrelated onSelect re-query

* fix skipping empty string on forward draft population

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
m0wer 2025-05-13 00:59:47 +02:00 committed by GitHub
parent d4e3853f27
commit 0edf68cab9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 262 additions and 93 deletions

View File

@ -35,6 +35,27 @@ export async function getSub (parent, { name }, { models, me }) {
export default { export default {
Query: { Query: {
sub: getSub, sub: getSub,
subSuggestions: async (parent, { q, limit = 5 }, { models }) => {
let subs = []
if (q) {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
AND SIMILARITY(name, ${q}) > 0.1
ORDER BY SIMILARITY(name, ${q}) DESC
LIMIT ${limit}`
} else {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
ORDER BY name ASC
LIMIT ${limit}`
}
return subs
},
subs: async (parent, args, { models, me }) => { subs: async (parent, args, { models, me }) => {
if (me) { if (me) {
const currentUser = await models.user.findUnique({ where: { id: me.id } }) const currentUser = await models.user.findUnique({ where: { id: me.id } })

View File

@ -7,6 +7,7 @@ export default gql`
subs: [Sub!]! subs: [Sub!]!
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
subSuggestions(q: String!, limit: Limit): [Sub!]!
} }
type Subs { type Subs {

View File

@ -198,7 +198,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
for (let i = 0; i < MAX_FORWARDS; i++) { for (let i = 0; i < MAX_FORWARDS; i++) {
['nym', 'pct'].forEach(key => { ['nym', 'pct'].forEach(key => {
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`) const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
if (value) { if (value !== undefined && value !== null) {
formik?.setFieldValue(`forward[${i}].${key}`, value) formik?.setFieldValue(`forward[${i}].${key}`, value)
} }
}) })

View File

@ -16,6 +16,7 @@ import AddIcon from '@/svgs/add-fill.svg'
import CloseIcon from '@/svgs/close-line.svg' import CloseIcon from '@/svgs/close-line.svg'
import { gql, useLazyQuery } from '@apollo/client' import { gql, useLazyQuery } from '@apollo/client'
import { USER_SUGGESTIONS } from '@/fragments/users' import { USER_SUGGESTIONS } from '@/fragments/users'
import { SUB_SUGGESTIONS } from '@/fragments/subs'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast' import { useToast } from './toast'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
@ -139,6 +140,94 @@ function setNativeValue (textarea, value) {
textarea.dispatchEvent(new Event('input', { bubbles: true, value })) textarea.dispatchEvent(new Event('input', { bubbles: true, value }))
} }
function useEntityAutocomplete ({
prefix,
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent
}) {
const [entityData, setEntityData] = useState()
const handleSelect = useCallback((name) => {
if (entityData?.start === undefined || entityData?.end === undefined) return
const { start, end } = entityData
setEntityData(undefined)
const first = `${meta?.value.substring(0, start)}${prefix}${name}`
const second = meta?.value.substring(end)
const updatedValue = `${first}${second}`
helpers.setValue(updatedValue)
setSelectionRange({ start: first.length, end: first.length })
innerRef.current.focus()
}, [entityData, meta?.value, helpers, prefix, setSelectionRange, innerRef])
const handleTextChange = useCallback((e) => {
const { value, selectionStart } = e.target
if (!value || selectionStart === undefined) {
setEntityData(undefined)
return false
}
let priorSpace = -1
for (let i = selectionStart - 1; i >= 0; i--) {
if (/[^\w@~]/.test(value[i])) {
priorSpace = i
break
}
}
let nextSpace = value.length
for (let i = selectionStart; i <= value.length; i++) {
if (/[^\w]/.test(value[i])) {
nextSpace = i
break
}
}
const currentSegment = value.substring(priorSpace + 1, nextSpace)
const regexPattern = new RegExp(`^\\${prefix}\\w*$`)
if (regexPattern.test(currentSegment)) {
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
setEntityData({
query: currentSegment,
start: priorSpace + 1,
end: nextSpace,
style: {
position: 'absolute',
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
left: `${left}px`
}
})
return true
}
setEntityData(undefined)
return false
}, [prefix])
// Return a function that takes a render prop instead of directly returning the component
return {
entityData,
handleSelect,
handleTextChange,
renderSuggest: (renderProps) => {
if (!entityData) return null
return (
<SuggestComponent
query={entityData?.query}
onSelect={handleSelect}
dropdownStyle={entityData?.style}
>
{renderProps}
</SuggestComponent>
)
}
}
}
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) { export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
const [tab, setTab] = useState('write') const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props) const [, meta, helpers] = useField(props)
@ -198,18 +287,23 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
} }
}, [innerRef, selectionRange.start, selectionRange.end]) }, [innerRef, selectionRange.start, selectionRange.end])
const [mention, setMention] = useState() const userAutocomplete = useEntityAutocomplete({
const insertMention = useCallback((name) => { prefix: '@',
if (mention?.start === undefined || mention?.end === undefined) return meta,
const { start, end } = mention helpers,
setMention(undefined) innerRef,
const first = `${meta?.value.substring(0, start)}@${name}` setSelectionRange,
const second = meta?.value.substring(end) SuggestComponent: UserSuggest
const updatedValue = `${first}${second}` })
helpers.setValue(updatedValue)
setSelectionRange({ start: first.length, end: first.length }) const territoryAutocomplete = useEntityAutocomplete({
innerRef.current.focus() prefix: '~',
}, [mention, meta?.value, helpers?.setValue]) meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: TerritorySuggest
})
const uploadFeesUpdate = useDebounceCallback( const uploadFeesUpdate = useDebounceCallback(
(text) => { (text) => {
@ -219,50 +313,16 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const onChangeInner = useCallback((formik, e) => { const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e) if (onChange) onChange(formik, e)
// check for mention editing // check for mentions and territory suggestions
const { value, selectionStart } = e.target uploadFeesUpdate(e.target.value)
uploadFeesUpdate(value)
if (!value || selectionStart === undefined) { // Try to match user mentions first, then territories
setMention(undefined) if (!userAutocomplete.handleTextChange(e)) {
return territoryAutocomplete.handleTextChange(e)
} }
}, [onChange, uploadFeesUpdate, userAutocomplete, territoryAutocomplete])
let priorSpace = -1 const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
for (let i = selectionStart - 1; i >= 0; i--) {
if (/[^\w@]/.test(value[i])) {
priorSpace = i
break
}
}
let nextSpace = value.length
for (let i = selectionStart; i <= value.length; i++) {
if (/[^\w]/.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)) {
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
setMention({
query: currentSegment,
start: priorSpace + 1,
end: nextSpace,
style: {
position: 'absolute',
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
left: `${left}px`
}
})
} else {
setMention(undefined)
}
}, [onChange, setMention, uploadFeesUpdate])
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
return (e) => { return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) { if (metaOrCtrl) {
@ -293,12 +353,16 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
} }
if (!metaOrCtrl) { if (!metaOrCtrl) {
userSuggestOnKeyDown(e) if (userAutocomplete.entityData) {
userSuggestOnKeyDown(e)
} else if (territoryAutocomplete.entityData) {
territorySuggestOnKeyDown(e)
}
} }
if (onKeyDown) onKeyDown(e) if (onKeyDown) onKeyDown(e)
} }
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown]) }, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, userAutocomplete.entityData, territoryAutocomplete.entityData])
const onPaste = useCallback((event) => { const onPaste = useCallback((event) => {
const items = event.clipboardData.items const items = event.clipboardData.items
@ -409,22 +473,32 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
</Nav> </Nav>
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}> <div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
<UserSuggest <UserSuggest
query={mention?.query} query={userAutocomplete.entityData?.query}
onSelect={insertMention} onSelect={userAutocomplete.handleSelect}
dropdownStyle={mention?.style} dropdownStyle={userAutocomplete.entityData?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => ( >{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
<InputInner <TerritorySuggest
innerRef={innerRef} query={territoryAutocomplete.entityData?.query}
{...props} onSelect={territoryAutocomplete.handleSelect}
onChange={onChangeInner} dropdownStyle={territoryAutocomplete.entityData?.style}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)} >{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) => (
onBlur={() => setTimeout(resetSuggestions, 500)} <InputInner
onDragEnter={onDragEnter} innerRef={innerRef}
onDragLeave={onDragLeave} {...props}
onDrop={onDrop} onChange={onChangeInner}
onPaste={onPaste} onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
className={dragStyle === 'over' ? styles.dragOver : ''} onBlur={() => {
/>)} setTimeout(resetUserSuggestions, 500)
setTimeout(resetTerritorySuggestions, 500)
}}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>)}
</TerritorySuggest>
)}
</UserSuggest> </UserSuggest>
</div> </div>
{tab !== 'write' && {tab !== 'write' &&
@ -617,34 +691,34 @@ function InputInner ({
} }
const INITIAL_SUGGESTIONS = { array: [], index: 0 } const INITIAL_SUGGESTIONS = { array: [], index: 0 }
export function UserSuggest ({
query, onSelect, dropdownStyle, children, export function BaseSuggest ({
transformUser = user => user, selectWithTab = true, filterUsers = () => true query, onSelect, dropdownStyle,
transformItem = item => item, selectWithTab = true, filterItems = () => true,
getSuggestionsQuery, queryName, itemsField,
children
}) { }) {
const [getSuggestions] = useLazyQuery(USER_SUGGESTIONS, { const [getSuggestions] = useLazyQuery(getSuggestionsQuery, {
onCompleted: data => { onCompleted: data => {
query !== undefined && setSuggestions({ query !== undefined && setSuggestions({
array: data.userSuggestions array: data[itemsField]
.filter((...args) => filterUsers(query, ...args)) .filter((...args) => filterItems(query, ...args))
.map(transformUser), .map(transformItem),
index: 0 index: 0
}) })
} }
}) })
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS) const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), []) const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
useEffect(() => { useEffect(() => {
if (query !== undefined) { if (query !== undefined) {
// remove both the leading @ and any @domain after nym // remove the leading character and any trailing spaces
const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '') const q = query?.replace(/^[@ ~]+|[ ]+$/g, '').replace(/@[^\s]*$/, '').replace(/~[^\s]*$/, '')
getSuggestions({ variables: { q, limit: 5 } }) getSuggestions({ variables: { q, limit: 5 } })
} else { } else {
resetSuggestions() resetSuggestions()
} }
}, [query, resetSuggestions, getSuggestions]) }, [query, resetSuggestions, getSuggestions])
const onKeyDown = useCallback(e => { const onKeyDown = useCallback(e => {
switch (e.code) { switch (e.code) {
case 'ArrowUp': case 'ArrowUp':
@ -689,7 +763,6 @@ export function UserSuggest ({
break break
} }
}, [onSelect, resetSuggestions, suggestions]) }, [onSelect, resetSuggestions, suggestions])
return ( return (
<> <>
{children?.({ onKeyDown, resetSuggestions })} {children?.({ onKeyDown, resetSuggestions })}
@ -712,17 +785,17 @@ export function UserSuggest ({
) )
} }
export function InputUserSuggest ({ function BaseInputSuggest ({
label, groupClassName, transformUser, filterUsers, label, groupClassName, transformItem, filterItems,
selectWithTab, onChange, transformQuery, ...props selectWithTab, onChange, transformQuery, SuggestComponent, prefixRegex, ...props
}) { }) {
const [ovalue, setOValue] = useState() const [ovalue, setOValue] = useState()
const [query, setQuery] = useState() const [query, setQuery] = useState()
return ( return (
<FormGroup label={label} className={groupClassName}> <FormGroup label={label} className={groupClassName}>
<UserSuggest <SuggestComponent
transformUser={transformUser} transformItem={transformItem}
filterUsers={filterUsers} filterItems={filterItems}
selectWithTab={selectWithTab} selectWithTab={selectWithTab}
onSelect={(v) => { onSelect={(v) => {
// HACK ... ovalue does not trigger onChange // HACK ... ovalue does not trigger onChange
@ -737,19 +810,85 @@ export function InputUserSuggest ({
autoComplete='off' autoComplete='off'
onChange={(formik, e) => { onChange={(formik, e) => {
onChange && onChange(formik, e) onChange && onChange(formik, e)
if (e.target.value === ovalue) {
// we don't need to set the ovalue or query if the value is the same
return
}
setOValue(e.target.value) setOValue(e.target.value)
setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, '')) setQuery(e.target.value.replace(prefixRegex, ''))
}} }}
overrideValue={ovalue} overrideValue={ovalue}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onBlur={() => setTimeout(resetSuggestions, 500)} onBlur={() => setTimeout(resetSuggestions, 500)}
/> />
)} )}
</UserSuggest> </SuggestComponent>
</FormGroup> </FormGroup>
) )
} }
export function InputUserSuggest ({
transformUser, filterUsers, ...props
}) {
return (
<BaseInputSuggest
transformItem={transformUser}
filterItems={filterUsers}
SuggestComponent={UserSuggest}
prefixRegex={/^[@ ]+|[ ]+$/g}
{...props}
/>
)
}
export function InputTerritorySuggest ({
transformSub, filterSubs, ...props
}) {
return (
<BaseInputSuggest
transformItem={transformSub}
filterItems={filterSubs}
SuggestComponent={TerritorySuggest}
prefixRegex={/^[~ ]+|[ ]+$/g}
{...props}
/>
)
}
function UserSuggest ({
transformUser = user => user, filterUsers = () => true,
children, ...props
}) {
return (
<BaseSuggest
transformItem={transformUser}
filterItems={filterUsers}
getSuggestionsQuery={USER_SUGGESTIONS}
itemsField='userSuggestions'
{...props}
>
{children}
</BaseSuggest>
)
}
function TerritorySuggest ({
transformSub = sub => sub, filterSubs = () => true,
children, ...props
}) {
return (
<BaseSuggest
transformItem={transformSub}
filterItems={filterSubs}
getSuggestionsQuery={SUB_SUGGESTIONS}
itemsField='subSuggestions'
{...props}
>
{children}
</BaseSuggest>
)
}
export function Input ({ label, groupClassName, under, ...props }) { export function Input ({ label, groupClassName, under, ...props }) {
return ( return (
<FormGroup label={label} className={groupClassName}> <FormGroup label={label} className={groupClassName}>

View File

@ -122,6 +122,14 @@ export const SUB_SEARCH = gql`
} }
` `
export const SUB_SUGGESTIONS = gql`
query subSuggestions($q: String!, $limit: Limit) {
subSuggestions(q: $q, limit: $limit) {
name
}
}
`
export const TOP_SUBS = gql` export const TOP_SUBS = gql`
${SUB_FULL_FIELDS} ${SUB_FULL_FIELDS}
query TopSubs($cursor: String, $when: String, $from: String, $to: String, $by: String, ) { query TopSubs($cursor: String, $when: String, $from: String, $to: String, $by: String, ) {