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:
parent
e76b8a2915
commit
502bfee072
|
@ -151,7 +151,7 @@ export default {
|
||||||
users
|
users
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
topUsers: async (parent, { cursor, when, by }, { models, me }) => {
|
topUsers: async (parent, { cursor, when, by, limit = LIMIT }, { models, me }) => {
|
||||||
const decodedCursor = decodeCursor(cursor)
|
const decodedCursor = decodeCursor(cursor)
|
||||||
let users
|
let users
|
||||||
|
|
||||||
|
@ -179,10 +179,10 @@ export default {
|
||||||
)
|
)
|
||||||
SELECT * FROM u WHERE ${column} > 0
|
SELECT * FROM u WHERE ${column} > 0
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
|
||||||
users
|
users
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -206,7 +206,7 @@ export default {
|
||||||
GROUP BY users.id, users.name
|
GROUP BY users.id, users.name
|
||||||
ORDER BY spent DESC NULLS LAST, users.created_at DESC
|
ORDER BY spent DESC NULLS LAST, users.created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset)
|
||||||
} else if (by === 'posts') {
|
} else if (by === 'posts') {
|
||||||
users = await models.$queryRawUnsafe(`
|
users = await models.$queryRawUnsafe(`
|
||||||
SELECT users.*, count(*)::INTEGER as nposts
|
SELECT users.*, count(*)::INTEGER as nposts
|
||||||
|
@ -218,7 +218,7 @@ export default {
|
||||||
GROUP BY users.id
|
GROUP BY users.id
|
||||||
ORDER BY nposts DESC NULLS LAST, users.created_at DESC
|
ORDER BY nposts DESC NULLS LAST, users.created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset)
|
||||||
} else if (by === 'comments') {
|
} else if (by === 'comments') {
|
||||||
users = await models.$queryRawUnsafe(`
|
users = await models.$queryRawUnsafe(`
|
||||||
SELECT users.*, count(*)::INTEGER as ncomments
|
SELECT users.*, count(*)::INTEGER as ncomments
|
||||||
|
@ -230,7 +230,7 @@ export default {
|
||||||
GROUP BY users.id
|
GROUP BY users.id
|
||||||
ORDER BY ncomments DESC NULLS LAST, users.created_at DESC
|
ORDER BY ncomments DESC NULLS LAST, users.created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset)
|
||||||
} else if (by === 'referrals') {
|
} else if (by === 'referrals') {
|
||||||
users = await models.$queryRawUnsafe(`
|
users = await models.$queryRawUnsafe(`
|
||||||
SELECT users.*, count(*)::INTEGER as referrals
|
SELECT users.*, count(*)::INTEGER as referrals
|
||||||
|
@ -242,7 +242,7 @@ export default {
|
||||||
GROUP BY users.id
|
GROUP BY users.id
|
||||||
ORDER BY referrals DESC NULLS LAST, users.created_at DESC
|
ORDER BY referrals DESC NULLS LAST, users.created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset)
|
||||||
} else {
|
} else {
|
||||||
users = await models.$queryRawUnsafe(`
|
users = await models.$queryRawUnsafe(`
|
||||||
SELECT u.id, u.name, u.streak, u."photoId", u."hideCowboyHat", floor(sum(amount)/1000) as stacked
|
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"
|
GROUP BY u.id, u.name, u.created_at, u."photoId", u.streak, u."hideCowboyHat"
|
||||||
ORDER BY stacked DESC NULLS LAST, created_at DESC
|
ORDER BY stacked DESC NULLS LAST, created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
|
||||||
users
|
users
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -7,7 +7,7 @@ export default gql`
|
||||||
user(name: String!): User
|
user(name: String!): User
|
||||||
users: [User!]
|
users: [User!]
|
||||||
nameAvailable(name: String!): Boolean!
|
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
|
topCowboys(cursor: String): Users
|
||||||
searchUsers(q: String!, limit: Int, similarity: Float): [User!]!
|
searchUsers(q: String!, limit: Int, similarity: Float): [User!]!
|
||||||
hasNewNotes: Boolean!
|
hasNewNotes: Boolean!
|
||||||
|
|
|
@ -2,7 +2,7 @@ import Button from 'react-bootstrap/Button'
|
||||||
import InputGroup from 'react-bootstrap/InputGroup'
|
import InputGroup from 'react-bootstrap/InputGroup'
|
||||||
import BootstrapForm from 'react-bootstrap/Form'
|
import BootstrapForm from 'react-bootstrap/Form'
|
||||||
import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik'
|
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 copy from 'clipboard-copy'
|
||||||
import Thumb from '../svgs/thumb-up-fill.svg'
|
import Thumb from '../svgs/thumb-up-fill.svg'
|
||||||
import Col from 'react-bootstrap/Col'
|
import Col from 'react-bootstrap/Col'
|
||||||
|
@ -16,11 +16,12 @@ import AddIcon from '../svgs/add-fill.svg'
|
||||||
import { mdHas } from '../lib/md'
|
import { mdHas } from '../lib/md'
|
||||||
import CloseIcon from '../svgs/close-line.svg'
|
import CloseIcon from '../svgs/close-line.svg'
|
||||||
import { useLazyQuery } from '@apollo/client'
|
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 TextareaAutosize from 'react-textarea-autosize'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { useInvoiceable } from './invoice'
|
import { useInvoiceable } from './invoice'
|
||||||
import { numWithUnits } from '../lib/format'
|
import { numWithUnits } from '../lib/format'
|
||||||
|
import textAreaCaret from 'textarea-caret'
|
||||||
import ReactDatePicker from 'react-datepicker'
|
import ReactDatePicker from 'react-datepicker'
|
||||||
import 'react-datepicker/dist/react-datepicker.css'
|
import 'react-datepicker/dist/react-datepicker.css'
|
||||||
|
|
||||||
|
@ -119,6 +120,20 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||||
}
|
}
|
||||||
}, [innerRef, selectionRange.start, selectionRange.end])
|
}, [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 (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
||||||
|
@ -136,42 +151,85 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
||||||
<Markdown width={18} height={18} />
|
<Markdown width={18} height={18} />
|
||||||
</a>
|
</a>
|
||||||
</Nav>
|
</Nav>
|
||||||
<div className={tab === 'write' ? '' : 'd-none'}>
|
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
||||||
<InputInner
|
<UserSuggest
|
||||||
{...props} onChange={(formik, e) => {
|
query={mentionQuery}
|
||||||
if (onChange) onChange(formik, e)
|
onSelect={insertMention}
|
||||||
if (setHasImgLink) {
|
dropdownStyle={userSuggestDropdownStyle}
|
||||||
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
|
>{({ onKeyDown: userSuggestOnKeyDown }) => (
|
||||||
}
|
<InputInner
|
||||||
}}
|
{...props} onChange={(formik, e) => {
|
||||||
innerRef={innerRef}
|
if (onChange) onChange(formik, e)
|
||||||
onKeyDown={(e) => {
|
if (setHasImgLink) {
|
||||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
|
||||||
if (metaOrCtrl) {
|
|
||||||
if (e.key === 'k') {
|
|
||||||
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
}
|
||||||
if (e.key === 'b') {
|
// check for mention editing
|
||||||
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
const { value, selectionStart } = e.target
|
||||||
e.preventDefault()
|
let priorSpace = -1
|
||||||
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
for (let i = selectionStart - 1; i >= 0; i--) {
|
||||||
|
if (/\s|\n/.test(value[i])) {
|
||||||
|
priorSpace = i
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (e.key === 'i') {
|
let nextSpace = value.length
|
||||||
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
for (let i = selectionStart; i <= value.length; i++) {
|
||||||
e.preventDefault()
|
if (/\s|\n/.test(value[i])) {
|
||||||
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
nextSpace = i
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (e.key === 'Tab' && e.altKey) {
|
const currentSegment = value.substring(priorSpace + 1, nextSpace)
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onKeyDown) onKeyDown(e)
|
// 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) => {
|
||||||
|
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||||
|
if (metaOrCtrl) {
|
||||||
|
if (e.key === 'k') {
|
||||||
|
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
if (e.key === 'b') {
|
||||||
|
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
if (e.key === 'i') {
|
||||||
|
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab' && e.altKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metaOrCtrl) {
|
||||||
|
userSuggestOnKeyDown(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onKeyDown) onKeyDown(e)
|
||||||
|
}}
|
||||||
|
/>)}
|
||||||
|
</UserSuggest>
|
||||||
</div>
|
</div>
|
||||||
{tab !== 'write' &&
|
{tab !== 'write' &&
|
||||||
<div className='form-group'>
|
<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, {
|
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
|
||||||
onCompleted: data => {
|
onCompleted: data => {
|
||||||
setSuggestions({ array: data.searchUsers, index: 0 })
|
setSuggestions({ array: data.searchUsers, index: 0 })
|
||||||
|
@ -349,64 +412,106 @@ export function InputUserSuggest ({ label, groupClassName, ...props }) {
|
||||||
|
|
||||||
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
|
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
|
||||||
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
|
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
|
||||||
const [ovalue, setOValue] = useState()
|
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(suggestions =>
|
||||||
|
({
|
||||||
|
...suggestions,
|
||||||
|
index: Math.max(suggestions.index - 1, 0)
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
if (suggestions.array.length === 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
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()
|
||||||
|
onSelect(suggestions.array[suggestions.index].name)
|
||||||
|
resetSuggestions()
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
resetSuggestions()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}, [onSelect, resetSuggestions, suggestions])
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<>
|
||||||
<InputInner
|
{children?.({ onKeyDown })}
|
||||||
{...props}
|
<Dropdown show={suggestions.array.length > 0} style={dropdownStyle}>
|
||||||
autoComplete='off'
|
|
||||||
onChange={(_, e) => {
|
|
||||||
setOValue(e.target.value)
|
|
||||||
getSuggestions({ variables: { q: e.target.value.replace(/^[@ ]+|[ ]+$/g, '') } })
|
|
||||||
}}
|
|
||||||
overrideValue={ovalue}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
switch (e.code) {
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault()
|
|
||||||
setSuggestions(
|
|
||||||
{
|
|
||||||
...suggestions,
|
|
||||||
index: Math.max(suggestions.index - 1, 0)
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault()
|
|
||||||
setSuggestions(
|
|
||||||
{
|
|
||||||
...suggestions,
|
|
||||||
index: Math.min(suggestions.index + 1, suggestions.array.length - 1)
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault()
|
|
||||||
setOValue(suggestions.array[suggestions.index].name)
|
|
||||||
setSuggestions(INITIAL_SUGGESTIONS)
|
|
||||||
break
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault()
|
|
||||||
setSuggestions(INITIAL_SUGGESTIONS)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Dropdown show={suggestions.array.length > 0}>
|
|
||||||
<Dropdown.Menu className={styles.suggestionsMenu}>
|
<Dropdown.Menu className={styles.suggestionsMenu}>
|
||||||
{suggestions.array.map((v, i) =>
|
{suggestions.array.map((v, i) =>
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
key={v.name}
|
key={v.name}
|
||||||
active={suggestions.index === i}
|
active={suggestions.index === i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOValue(v.name)
|
onSelect(v.name)
|
||||||
setSuggestions(INITIAL_SUGGESTIONS)
|
resetSuggestions()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{v.name}
|
{v.name}
|
||||||
</Dropdown.Item>)}
|
</Dropdown.Item>)}
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</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>
|
</FormGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -164,8 +164,8 @@ export const USER_FIELDS = gql`
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export const TOP_USERS = gql`
|
export const TOP_USERS = gql`
|
||||||
query TopUsers($cursor: String, $when: String, $by: String) {
|
query TopUsers($cursor: String, $when: String, $by: String, $limit: Int) {
|
||||||
topUsers(cursor: $cursor, when: $when, by: $by) {
|
topUsers(cursor: $cursor, when: $when, by: $by, limit: $limit) {
|
||||||
users {
|
users {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
|
|
@ -10,7 +10,7 @@ export function decodeCursor (cursor) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nextCursorEncoded (cursor) {
|
export function nextCursorEncoded (cursor, limit = LIMIT) {
|
||||||
cursor.offset += LIMIT
|
cursor.offset += limit
|
||||||
return Buffer.from(JSON.stringify(cursor)).toString('base64')
|
return Buffer.from(JSON.stringify(cursor)).toString('base64')
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,6 +74,7 @@
|
||||||
"remove-markdown": "^0.5.0",
|
"remove-markdown": "^0.5.0",
|
||||||
"sass": "^1.65.1",
|
"sass": "^1.65.1",
|
||||||
"serviceworker-storage": "^0.1.0",
|
"serviceworker-storage": "^0.1.0",
|
||||||
|
"textarea-caret": "^3.1.0",
|
||||||
"tldts": "^6.0.14",
|
"tldts": "^6.0.14",
|
||||||
"tsx": "^3.13.0",
|
"tsx": "^3.13.0",
|
||||||
"typescript": "^5.1.6",
|
"typescript": "^5.1.6",
|
||||||
|
@ -16749,6 +16750,11 @@
|
||||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/through": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||||
|
@ -29976,6 +29982,11 @@
|
||||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||||
"dev": true
|
"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": {
|
"through": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||||
|
|
|
@ -77,6 +77,7 @@
|
||||||
"remove-markdown": "^0.5.0",
|
"remove-markdown": "^0.5.0",
|
||||||
"sass": "^1.65.1",
|
"sass": "^1.65.1",
|
||||||
"serviceworker-storage": "^0.1.0",
|
"serviceworker-storage": "^0.1.0",
|
||||||
|
"textarea-caret": "^3.1.0",
|
||||||
"tldts": "^6.0.14",
|
"tldts": "^6.0.14",
|
||||||
"tsx": "^3.13.0",
|
"tsx": "^3.13.0",
|
||||||
"typescript": "^5.1.6",
|
"typescript": "^5.1.6",
|
||||||
|
|
Loading…
Reference in New Issue