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 {
Query: {
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 }) => {
if (me) {
const currentUser = await models.user.findUnique({ where: { id: me.id } })

View File

@ -7,6 +7,7 @@ export default gql`
subs: [Sub!]!
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
subSuggestions(q: String!, limit: Limit): [Sub!]!
}
type Subs {

View File

@ -198,7 +198,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
for (let i = 0; i < MAX_FORWARDS; i++) {
['nym', 'pct'].forEach(key => {
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
if (value) {
if (value !== undefined && value !== null) {
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 { gql, useLazyQuery } from '@apollo/client'
import { USER_SUGGESTIONS } from '@/fragments/users'
import { SUB_SUGGESTIONS } from '@/fragments/subs'
import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast'
import { numWithUnits } from '@/lib/format'
@ -139,6 +140,94 @@ function setNativeValue (textarea, 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 }) {
const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props)
@ -198,18 +287,23 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
}
}, [innerRef, selectionRange.start, selectionRange.end])
const [mention, setMention] = useState()
const insertMention = useCallback((name) => {
if (mention?.start === undefined || mention?.end === undefined) return
const { start, end } = mention
setMention(undefined)
const first = `${meta?.value.substring(0, start)}@${name}`
const second = meta?.value.substring(end)
const updatedValue = `${first}${second}`
helpers.setValue(updatedValue)
setSelectionRange({ start: first.length, end: first.length })
innerRef.current.focus()
}, [mention, meta?.value, helpers?.setValue])
const userAutocomplete = useEntityAutocomplete({
prefix: '@',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: UserSuggest
})
const territoryAutocomplete = useEntityAutocomplete({
prefix: '~',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: TerritorySuggest
})
const uploadFeesUpdate = useDebounceCallback(
(text) => {
@ -219,50 +313,16 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e)
// check for mention editing
const { value, selectionStart } = e.target
uploadFeesUpdate(value)
// check for mentions and territory suggestions
uploadFeesUpdate(e.target.value)
if (!value || selectionStart === undefined) {
setMention(undefined)
return
// Try to match user mentions first, then territories
if (!userAutocomplete.handleTextChange(e)) {
territoryAutocomplete.handleTextChange(e)
}
}, [onChange, uploadFeesUpdate, userAutocomplete, territoryAutocomplete])
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)
// 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) => {
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) {
@ -293,12 +353,16 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
}
if (!metaOrCtrl) {
userSuggestOnKeyDown(e)
if (userAutocomplete.entityData) {
userSuggestOnKeyDown(e)
} else if (territoryAutocomplete.entityData) {
territorySuggestOnKeyDown(e)
}
}
if (onKeyDown) onKeyDown(e)
}
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, userAutocomplete.entityData, territoryAutocomplete.entityData])
const onPaste = useCallback((event) => {
const items = event.clipboardData.items
@ -409,22 +473,32 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
</Nav>
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
<UserSuggest
query={mention?.query}
onSelect={insertMention}
dropdownStyle={mention?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => (
<InputInner
innerRef={innerRef}
{...props}
onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
onBlur={() => setTimeout(resetSuggestions, 500)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>)}
query={userAutocomplete.entityData?.query}
onSelect={userAutocomplete.handleSelect}
dropdownStyle={userAutocomplete.entityData?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
<TerritorySuggest
query={territoryAutocomplete.entityData?.query}
onSelect={territoryAutocomplete.handleSelect}
dropdownStyle={territoryAutocomplete.entityData?.style}
>{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) => (
<InputInner
innerRef={innerRef}
{...props}
onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
onBlur={() => {
setTimeout(resetUserSuggestions, 500)
setTimeout(resetTerritorySuggestions, 500)
}}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>)}
</TerritorySuggest>
)}
</UserSuggest>
</div>
{tab !== 'write' &&
@ -617,34 +691,34 @@ function InputInner ({
}
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
export function UserSuggest ({
query, onSelect, dropdownStyle, children,
transformUser = user => user, selectWithTab = true, filterUsers = () => true
export function BaseSuggest ({
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 => {
query !== undefined && setSuggestions({
array: data.userSuggestions
.filter((...args) => filterUsers(query, ...args))
.map(transformUser),
array: data[itemsField]
.filter((...args) => filterItems(query, ...args))
.map(transformItem),
index: 0
})
}
})
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
useEffect(() => {
if (query !== undefined) {
// remove both the leading @ and any @domain after nym
const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '')
// remove the leading character and any trailing spaces
const q = query?.replace(/^[@ ~]+|[ ]+$/g, '').replace(/@[^\s]*$/, '').replace(/~[^\s]*$/, '')
getSuggestions({ variables: { q, limit: 5 } })
} else {
resetSuggestions()
}
}, [query, resetSuggestions, getSuggestions])
const onKeyDown = useCallback(e => {
switch (e.code) {
case 'ArrowUp':
@ -689,7 +763,6 @@ export function UserSuggest ({
break
}
}, [onSelect, resetSuggestions, suggestions])
return (
<>
{children?.({ onKeyDown, resetSuggestions })}
@ -712,17 +785,17 @@ export function UserSuggest ({
)
}
export function InputUserSuggest ({
label, groupClassName, transformUser, filterUsers,
selectWithTab, onChange, transformQuery, ...props
function BaseInputSuggest ({
label, groupClassName, transformItem, filterItems,
selectWithTab, onChange, transformQuery, SuggestComponent, prefixRegex, ...props
}) {
const [ovalue, setOValue] = useState()
const [query, setQuery] = useState()
return (
<FormGroup label={label} className={groupClassName}>
<UserSuggest
transformUser={transformUser}
filterUsers={filterUsers}
<SuggestComponent
transformItem={transformItem}
filterItems={filterItems}
selectWithTab={selectWithTab}
onSelect={(v) => {
// HACK ... ovalue does not trigger onChange
@ -737,19 +810,85 @@ export function InputUserSuggest ({
autoComplete='off'
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)
setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, ''))
setQuery(e.target.value.replace(prefixRegex, ''))
}}
overrideValue={ovalue}
onKeyDown={onKeyDown}
onBlur={() => setTimeout(resetSuggestions, 500)}
/>
)}
</UserSuggest>
</SuggestComponent>
</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 }) {
return (
<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`
${SUB_FULL_FIELDS}
query TopSubs($cursor: String, $when: String, $from: String, $to: String, $by: String, ) {