Skip to content

Commit

Permalink
Feat: added mention feature
Browse files Browse the repository at this point in the history
  • Loading branch information
harshithmullapudi committed Dec 8, 2024
1 parent 6a287ae commit 039c17c
Show file tree
Hide file tree
Showing 11 changed files with 281 additions and 3 deletions.
1 change: 1 addition & 0 deletions apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"socket.io-client": "^4.7.4",
"supertokens-auth-react": "^0.36.1",
"supertokens-web-js": "^0.8.0",
"tippy.js": "^6.3.7",
"ts-key-enum": "^2.0.12",
"use-debounce": "^10.0.0",
"uuid": "^10.0.0",
Expand Down
2 changes: 2 additions & 0 deletions apps/webapp/src/components/editor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './suggestion';
export * from './mention';
89 changes: 89 additions & 0 deletions apps/webapp/src/components/editor/mention-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { cn } from '@tegonhq/ui/lib/utils';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react';

import type { User } from 'common/types';

interface MentionListProps {
items: User[];
command: (args: { id: string }) => void;
}

export const MentionList = forwardRef(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(props: MentionListProps, ref: React.Ref<any>) => {
const [selectedIndex, setSelectedIndex] = useState(0);

const selectItem = (index: number) => {
const item = props.items[index];

if (item) {
props.command({ id: item.id });
}
};

const upHandler = () => {
setSelectedIndex(
(prevIndex) =>
(prevIndex + props.items.length - 1) % props.items.length,
);
};

const downHandler = () => {
setSelectedIndex((prevIndex) => (prevIndex + 1) % props.items.length);
};

const enterHandler = () => {
selectItem(selectedIndex);
};

useEffect(() => {
setSelectedIndex(0);
}, [props.items]);

useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
switch (event.key) {
case 'ArrowUp':
upHandler();
return true;
case 'ArrowDown':
downHandler();
return true;
case 'Enter':
enterHandler();
return true;
default:
return false;
}
},
}));

return (
<div className="bg-popover border border-border rounded shadow flex flex-col gap-0.5 overflow-auto p-1 relative">
{props.items.length > 0 ? (
props.items.map((item, index) => (
<button
className={cn(
'flex items-center gap-1 w-full text-left bg-transparent hover:bg-grayAlpha-100 p-1',
index === selectedIndex ? 'bg-grayAlpha-100' : '',
)}
key={index}
onClick={() => selectItem(index)}
>
{item.fullname}
</button>
))
) : (
<div className="item">No result</div>
)}
</div>
);
},
);

MentionList.displayName = 'MentionList';
47 changes: 47 additions & 0 deletions apps/webapp/src/components/editor/mention.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { cn } from '@tegonhq/ui/lib/utils';
import Mention from '@tiptap/extension-mention';
import {
mergeAttributes,
type NodeViewProps,
NodeViewWrapper,
ReactNodeViewRenderer,
} from '@tiptap/react';
import { observer } from 'mobx-react-lite';

import type { User } from 'common/types';

import { useUsersData } from 'hooks/users';

export const MentionComponent = observer((props: NodeViewProps) => {
const { users } = useUsersData(false);

const user = users.find((user: User) => user.id === props.node.attrs.id);

return (
<NodeViewWrapper className="inline w-fit">
<span
className={cn(
'mention bg-grayAlpha-100 p-0.5 px-1 rounded-sm text-primary',
)}
>
{user.fullname}
</span>
</NodeViewWrapper>
);
});

export const CustomMention = Mention.extend({
addNodeView() {
return ReactNodeViewRenderer(MentionComponent);
},
parseHTML() {
return [
{
tag: 'mention-component',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['mention-component', mergeAttributes(HTMLAttributes)];
},
});
92 changes: 92 additions & 0 deletions apps/webapp/src/components/editor/suggestion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Editor } from '@tiptap/core';
import { ReactRenderer } from '@tiptap/react';
import tippy, { type Instance as TippyInstance } from 'tippy.js';

import { useUsersData } from 'hooks/users';

import { MentionList } from './mention-list';

interface SuggestionProps {
editor: Editor;
range: { from: number; to: number };
query: string;
items: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
clientRect?: any;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useMentionSuggestions = (): any => {
const { users } = useUsersData(false);

return {
items: ({ query }: { query: string }) => {
return users
.filter((item) =>
item.fullname.toLowerCase().startsWith(query.toLowerCase()),
)
.slice(0, 5);
},

render: () => {
let reactRenderer: ReactRenderer | null = null;
let popup: TippyInstance[] | null = null;

return {
onStart: (props: SuggestionProps) => {
if (!props.clientRect) {
return;
}

reactRenderer = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});

popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},

onUpdate: (props: SuggestionProps) => {
if (reactRenderer) {
reactRenderer.updateProps(props);
}

if (props.clientRect && popup) {
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}
},

onKeyDown: (props: {
event: KeyboardEvent;
range: { from: number; to: number };
query: string;
}) => {
if (props.event.key === 'Escape') {
popup?.[0]?.hide();
return true;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (reactRenderer?.ref as any).onKeyDown(props) ?? false;
},

onExit: () => {
popup?.[0]?.destroy();
popup = null;
reactRenderer?.destroy();
reactRenderer = null;
},
};
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import React from 'react';

import type { IssueCommentType } from 'common/types';

import { CustomMention, useMentionSuggestions } from 'components/editor';

import { useUpdateIssueCommentMutation } from 'services/issues';

interface EditCommentProps {
Expand All @@ -18,7 +20,7 @@ interface EditCommentProps {

export function EditComment({ value, onCancel, comment }: EditCommentProps) {
const [commentValue, setCommentValue] = React.useState(value);

const suggestion = useMentionSuggestions();
const { mutate: updateComment } = useUpdateIssueCommentMutation({});

const onSubmit = () => {
Expand All @@ -35,6 +37,11 @@ export function EditComment({ value, onCancel, comment }: EditCommentProps) {
<Editor
placeholder="Leave a reply..."
value={commentValue}
extensions={[
CustomMention.configure({
suggestion,
}),
]}
onChange={(e) => setCommentValue(e)}
className="w-full bg-transparent p-3 pt-0 pl-0"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { NewIssue } from 'modules/issues/new-issue';
import { type IssueCommentType, type User } from 'common/types';
import { getUserIcon } from 'common/user-util';

import { CustomMention, useMentionSuggestions } from 'components/editor';
import { useIssueData } from 'hooks/issues';

import { UserContext } from 'store/user-context';
Expand Down Expand Up @@ -49,6 +50,7 @@ export function GenericCommentActivity(props: GenericCommentActivityProps) {
} = props;
const currentUser = React.useContext(UserContext);
const issue = useIssueData();
const suggestion = useMentionSuggestions();

const sourceMetadata = comment.sourceMetadata
? JSON.parse(comment.sourceMetadata)
Expand Down Expand Up @@ -158,7 +160,16 @@ export function GenericCommentActivity(props: GenericCommentActivityProps) {
comment.parentId && 'pb-2',
)}
>
<Editor value={comment.body} editable={false} className="mb-0">
<Editor
value={comment.body}
extensions={[
CustomMention.configure({
suggestion,
}),
]}
editable={false}
className="mb-0"
>
<EditorExtensions suggestionItems={suggestionItems} />
</Editor>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as React from 'react';

import { getTiptapJSON } from 'common';

import { CustomMention, useMentionSuggestions } from 'components/editor';
import { useIssueData } from 'hooks/issues';

import { useCreateIssueCommentMutation } from 'services/issues';
Expand All @@ -17,6 +18,7 @@ export function IssueComment() {
const issueData = useIssueData();
const [commentValue, setCommentValue] = React.useState('');
const { mutate: createIssueComment } = useCreateIssueCommentMutation({});
const suggestion = useMentionSuggestions();

const onSubmit = () => {
if (commentValue !== '') {
Expand All @@ -40,6 +42,11 @@ export function IssueComment() {
onChange={(e) => {
setCommentValue(e);
}}
extensions={[
CustomMention.configure({
suggestion,
}),
]}
placeholder="Leave your comment..."
onSubmit={onSubmit}
className="w-full min-h-[44px] mb-0 p-2 border-border border"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as React from 'react';

import { getTiptapJSON } from 'common';

import { CustomMention, useMentionSuggestions } from 'components/editor';
import { useIssueData } from 'hooks/issues';

import { useCreateIssueCommentMutation } from 'services/issues';
Expand All @@ -26,6 +27,7 @@ export function ReplyComment({ issueCommentId }: ReplyCommentProps) {
const [commentValue, setCommentValue] = React.useState('');
const { mutate: createIssueComment } = useCreateIssueCommentMutation({});
const [showReplyButton, setShowReplyButton] = React.useState(false);
const suggestion = useMentionSuggestions();

const onSubmit = () => {
if (commentValue !== '') {
Expand All @@ -52,6 +54,11 @@ export function ReplyComment({ issueCommentId }: ReplyCommentProps) {
onFocus={() => {
setShowReplyButton(true);
}}
extensions={[
CustomMention.configure({
suggestion,
}),
]}
onSubmit={onSubmit}
onBlur={() => {
!commentValue && setShowReplyButton(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { type IssueCommentType } from 'common/types';
import type { User } from 'common/types';
import { getUserIcon } from 'common/user-util';

import { CustomMention, useMentionSuggestions } from 'components/editor';

import { GenericCommentActivity } from './generic-comment-activity';
import { ReplyComment } from './reply-comment';

Expand All @@ -39,6 +41,7 @@ export function SyncCommentActivity({
user,
}: CommentActivityProps) {
const [isOpen, setIsOpen] = React.useState(true);
const suggestion = useMentionSuggestions();

return (
<TimelineItem
Expand All @@ -65,7 +68,16 @@ export function SyncCommentActivity({
<div className="flex gap-1">
{getUserIcon(user)}

<Editor value={comment.body} editable={false} className="mb-0">
<Editor
extensions={[
CustomMention.configure({
suggestion,
}),
]}
value={comment.body}
editable={false}
className="mb-0"
>
<EditorExtensions suggestionItems={suggestionItems} />
</Editor>
</div>
Expand Down
Loading

0 comments on commit 039c17c

Please sign in to comment.