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, ) {