diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 4e8921cd..91642e31 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -151,7 +151,7 @@ export default { users } }, - topUsers: async (parent, { cursor, when, by }, { models, me }) => { + topUsers: async (parent, { cursor, when, by, limit = LIMIT }, { models, me }) => { const decodedCursor = decodeCursor(cursor) let users @@ -179,10 +179,10 @@ export default { ) SELECT * FROM u WHERE ${column} > 0 OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) return { - cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null, users } } @@ -206,7 +206,7 @@ export default { GROUP BY users.id, users.name ORDER BY spent DESC NULLS LAST, users.created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } else if (by === 'posts') { users = await models.$queryRawUnsafe(` SELECT users.*, count(*)::INTEGER as nposts @@ -218,7 +218,7 @@ export default { GROUP BY users.id ORDER BY nposts DESC NULLS LAST, users.created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } else if (by === 'comments') { users = await models.$queryRawUnsafe(` SELECT users.*, count(*)::INTEGER as ncomments @@ -230,7 +230,7 @@ export default { GROUP BY users.id ORDER BY ncomments DESC NULLS LAST, users.created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } else if (by === 'referrals') { users = await models.$queryRawUnsafe(` SELECT users.*, count(*)::INTEGER as referrals @@ -242,7 +242,7 @@ export default { GROUP BY users.id ORDER BY referrals DESC NULLS LAST, users.created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } else { users = await models.$queryRawUnsafe(` 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" ORDER BY stacked DESC NULLS LAST, created_at DESC OFFSET $2 - LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + LIMIT ${limit}`, decodedCursor.time, decodedCursor.offset) } return { - cursor: users.length === LIMIT ? nextCursorEncoded(decodedCursor) : null, + cursor: users.length === limit ? nextCursorEncoded(decodedCursor, limit) : null, users } }, diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index c2690fab..eb7037f2 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -7,7 +7,7 @@ export default gql` user(name: String!): User users: [User!] 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 searchUsers(q: String!, limit: Int, similarity: Float): [User!]! hasNewNotes: Boolean! diff --git a/components/form.js b/components/form.js index 0c200383..bdbbcf19 100644 --- a/components/form.js +++ b/components/form.js @@ -2,7 +2,7 @@ import Button from 'react-bootstrap/Button' import InputGroup from 'react-bootstrap/InputGroup' import BootstrapForm from 'react-bootstrap/Form' 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 Thumb from '../svgs/thumb-up-fill.svg' import Col from 'react-bootstrap/Col' @@ -16,11 +16,12 @@ import AddIcon from '../svgs/add-fill.svg' import { mdHas } from '../lib/md' import CloseIcon from '../svgs/close-line.svg' 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 { useToast } from './toast' import { useInvoiceable } from './invoice' import { numWithUnits } from '../lib/format' +import textAreaCaret from 'textarea-caret' import ReactDatePicker from 'react-datepicker' import 'react-datepicker/dist/react-datepicker.css' @@ -119,6 +120,20 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH } }, [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 (
@@ -136,42 +151,85 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH -
- { - if (onChange) onChange(formik, e) - if (setHasImgLink) { - setHasImgLink(mdHas(e.target.value, ['link', 'image'])) - } - }} - 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) +
+ {({ onKeyDown: userSuggestOnKeyDown }) => ( + { + if (onChange) onChange(formik, e) + if (setHasImgLink) { + setHasImgLink(mdHas(e.target.value, ['link', 'image'])) } - 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) + // check for mention editing + const { value, selectionStart } = e.target + let priorSpace = -1 + for (let i = selectionStart - 1; i >= 0; i--) { + if (/\s|\n/.test(value[i])) { + priorSpace = i + break + } } - 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) + let nextSpace = value.length + for (let i = selectionStart; i <= value.length; i++) { + if (/\s|\n/.test(value[i])) { + nextSpace = i + break + } } - if (e.key === 'Tab' && e.altKey) { - e.preventDefault() - insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange) - } - } + const currentSegment = value.substring(priorSpace + 1, nextSpace) - 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) + }} + />)} +
{tab !== 'write' &&
@@ -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, { onCompleted: data => { setSuggestions({ array: data.searchUsers, index: 0 }) @@ -349,64 +412,106 @@ export function InputUserSuggest ({ label, groupClassName, ...props }) { const INITIAL_SUGGESTIONS = { array: [], index: 0 } 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 ( - - { - 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 - } - }} - /> - 0}> + <> + {children?.({ onKeyDown })} + 0} style={dropdownStyle}> {suggestions.array.map((v, i) => { - setOValue(v.name) - setSuggestions(INITIAL_SUGGESTIONS) + onSelect(v.name) + resetSuggestions() }} > {v.name} )} + + ) +} + +export function InputUserSuggest ({ label, groupClassName, ...props }) { + const [ovalue, setOValue] = useState() + const [query, setQuery] = useState() + return ( + + + {({ onKeyDown }) => ( + { + setOValue(e.target.value) + setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, '')) + }} + overrideValue={ovalue} + onKeyDown={onKeyDown} + /> + )} + ) } diff --git a/fragments/users.js b/fragments/users.js index 95cb3f56..9ebbefb0 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -164,8 +164,8 @@ export const USER_FIELDS = gql` }` export const TOP_USERS = gql` - query TopUsers($cursor: String, $when: String, $by: String) { - topUsers(cursor: $cursor, when: $when, by: $by) { + query TopUsers($cursor: String, $when: String, $by: String, $limit: Int) { + topUsers(cursor: $cursor, when: $when, by: $by, limit: $limit) { users { id name diff --git a/lib/cursor.js b/lib/cursor.js index d9cdfd63..98e3e638 100644 --- a/lib/cursor.js +++ b/lib/cursor.js @@ -10,7 +10,7 @@ export function decodeCursor (cursor) { } } -export function nextCursorEncoded (cursor) { - cursor.offset += LIMIT +export function nextCursorEncoded (cursor, limit = LIMIT) { + cursor.offset += limit return Buffer.from(JSON.stringify(cursor)).toString('base64') } diff --git a/package-lock.json b/package-lock.json index 28be981b..16f12a70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "remove-markdown": "^0.5.0", "sass": "^1.65.1", "serviceworker-storage": "^0.1.0", + "textarea-caret": "^3.1.0", "tldts": "^6.0.14", "tsx": "^3.13.0", "typescript": "^5.1.6", @@ -16749,6 +16750,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "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": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -29976,6 +29982,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "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": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/package.json b/package.json index 1ebd07b2..af34d1df 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "remove-markdown": "^0.5.0", "sass": "^1.65.1", "serviceworker-storage": "^0.1.0", + "textarea-caret": "^3.1.0", "tldts": "^6.0.14", "tsx": "^3.13.0", "typescript": "^5.1.6",