diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 320670b6..fef5ea73 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -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 } }) diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 8401f185..6167d535 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -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 { diff --git a/components/adv-post-form.js b/components/adv-post-form.js index d1dd53cd..51ade562 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -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) } }) diff --git a/components/form.js b/components/form.js index c429ab7c..e28d64c3 100644 --- a/components/form.js +++ b/components/form.js @@ -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 ( + + {renderProps} + + ) + } + } +} + 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
{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => ( - 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 }) => ( + {({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) => ( + { + setTimeout(resetUserSuggestions, 500) + setTimeout(resetTerritorySuggestions, 500) + }} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} + onDrop={onDrop} + onPaste={onPaste} + className={dragStyle === 'over' ? styles.dragOver : ''} + />)} + + )}
{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 ( - { // 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)} /> )} - + ) } +export function InputUserSuggest ({ + transformUser, filterUsers, ...props +}) { + return ( + + ) +} + +export function InputTerritorySuggest ({ + transformSub, filterSubs, ...props +}) { + return ( + + ) +} + +function UserSuggest ({ + transformUser = user => user, filterUsers = () => true, + children, ...props +}) { + return ( + + {children} + + ) +} + +function TerritorySuggest ({ + transformSub = sub => sub, filterSubs = () => true, + children, ...props +}) { + return ( + + {children} + + ) +} + export function Input ({ label, groupClassName, under, ...props }) { return ( diff --git a/fragments/subs.js b/fragments/subs.js index 70fddd6b..6c5ab8d9 100644 --- a/fragments/subs.js +++ b/fragments/subs.js @@ -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, ) {