diff --git a/src/app/styles/index.scss b/src/app/styles/index.scss
index ab35098..25f2e18 100644
--- a/src/app/styles/index.scss
+++ b/src/app/styles/index.scss
@@ -40,6 +40,10 @@ body {
}
}
+*:focus-visible {
+ outline: none;
+}
+
hr {
color: white;
border-bottom: 1px solid rgba(255 255 255 / 7%);
diff --git a/src/app/styles/reset.scss b/src/app/styles/reset.scss
deleted file mode 100644
index b1aca4d..0000000
--- a/src/app/styles/reset.scss
+++ /dev/null
@@ -1,27 +0,0 @@
-* {
- padding: 0;
- margin: 0;
- border: 0;
-}
-
-*,
-*::before,
-*::after {
- box-sizing: border-box;
- outline: none;
-}
-
-html {
- scroll-behavior: smooth;
-}
-
-a {
- text-decoration: inherit;
- cursor: pointer;
-}
-
-button {
- border: 0;
- background: transparent;
- cursor: pointer;
-}
diff --git a/src/entities/comment/hook.ts b/src/entities/comment/hook.ts
new file mode 100644
index 0000000..40b0234
--- /dev/null
+++ b/src/entities/comment/hook.ts
@@ -0,0 +1,17 @@
+import {useStoreMap} from 'effector-react';
+
+import {useDiscussionContext} from '~/widgets/discussion/context';
+
+import {CommentEntity, SubjectEntity} from '~/entities/types';
+
+export const useComments = (subject: SubjectEntity): CommentEntity[] => {
+ const {$subjects} = useDiscussionContext();
+ return useStoreMap({
+ store: $subjects,
+ keys: [subject.id],
+ fn: (subjects, [id]) =>
+ subjects.filter(
+ (subject) => subject instanceof CommentEntity && subject.subjectId === id,
+ ) as CommentEntity[],
+ });
+};
diff --git a/src/entities/comment/model.ts b/src/entities/comment/model.ts
new file mode 100644
index 0000000..6498de3
--- /dev/null
+++ b/src/entities/comment/model.ts
@@ -0,0 +1,40 @@
+import {attach, merge} from 'effector';
+
+import {newCommentReceived} from '~/pages/discussion/model';
+
+import {
+ apiV1CommentsCommentIdGetFx,
+ apiV1CommentsCommentIdReactionsPostFx,
+ apiV1CommentsCommentIdRepliesGetFx,
+ apiV1DiscussionsDiscussionIdCommentsGetFx,
+} from '~/shared/api';
+import {reactionsFactory} from '~/shared/factory/reactions.factory';
+
+const commitReactionsFx = attach({
+ effect: apiV1CommentsCommentIdReactionsPostFx,
+ mapParams: ({subjectId, reactions}) => ({
+ path: {commentId: subjectId},
+ body: reactions,
+ }),
+});
+
+const committedReactionsFetched = merge([
+ apiV1DiscussionsDiscussionIdCommentsGetFx.doneData.map((data) => data.answer.items),
+ apiV1CommentsCommentIdRepliesGetFx.doneData.map((data) => data.answer.items),
+ apiV1CommentsCommentIdGetFx.doneData.map((data) => [data.answer]),
+ newCommentReceived.map((comment) => [comment]),
+]).map((comments) => ({
+ subjects: comments.map((comment) => ({
+ id: comment.id,
+ viewerReactions: comment.viewer_reactions as string[],
+ reactionCounters: comment.reaction_counters as Record
,
+ })),
+}));
+
+const reactions = reactionsFactory({
+ commitReactionsFx,
+ committedReactionsFetched,
+});
+
+export const $commentReactions = reactions.$reactions;
+export const toggleCommentReaction = reactions.toggleReaction;
diff --git a/src/entities/discussion/model.ts b/src/entities/discussion/model.ts
new file mode 100644
index 0000000..0ec08a4
--- /dev/null
+++ b/src/entities/discussion/model.ts
@@ -0,0 +1,35 @@
+import {attach, merge} from 'effector';
+
+import {
+ apiV1DiscussionsDiscussionIdGetFx,
+ apiV1DiscussionsDiscussionIdReactionsPostFx,
+ apiV1DiscussionsGetFx,
+} from '~/shared/api';
+import {reactionsFactory} from '~/shared/factory/reactions.factory';
+
+const commitReactionsFx = attach({
+ effect: apiV1DiscussionsDiscussionIdReactionsPostFx,
+ mapParams: ({subjectId, reactions}) => ({
+ path: {discussionId: subjectId},
+ body: reactions,
+ }),
+});
+
+const committedReactionsFetched = merge([
+ apiV1DiscussionsGetFx.doneData.map((data) => data.answer.items),
+ apiV1DiscussionsDiscussionIdGetFx.doneData.map((data) => [data.answer]),
+]).map((discussions) => ({
+ subjects: discussions.map((discussion) => ({
+ id: discussion.id,
+ viewerReactions: discussion.viewer_reactions as string[],
+ reactionCounters: discussion.reaction_counters as Record,
+ })),
+}));
+
+const reactions = reactionsFactory({
+ commitReactionsFx,
+ committedReactionsFetched,
+});
+
+export const $discussionReactions = reactions.$reactions;
+export const toggleDiscussionReaction = reactions.toggleReaction;
diff --git a/src/entities/reaction/hook.ts b/src/entities/reaction/hook.ts
new file mode 100644
index 0000000..7101725
--- /dev/null
+++ b/src/entities/reaction/hook.ts
@@ -0,0 +1,28 @@
+import {useStoreMap, useUnit} from 'effector-react';
+
+import {$commentReactions, toggleCommentReaction} from '~/entities/comment/model';
+import {$discussionReactions, toggleDiscussionReaction} from '~/entities/discussion/model';
+import {$availableReactions} from '~/entities/reaction/model';
+import {DiscussionEntity, SubjectEntity} from '~/entities/types';
+
+export const useReactions = (subject: SubjectEntity) => {
+ const availableReactions = useUnit($availableReactions);
+
+ const reactions = useStoreMap({
+ store: subject instanceof DiscussionEntity ? $discussionReactions : $commentReactions,
+ keys: [subject.id],
+ fn: (reactions, [id]) =>
+ reactions[id] ?? {
+ draft: [],
+ committed: [],
+ countersWithDraft: {},
+ countersWithoutDraft: {},
+ },
+ });
+
+ const toggleReaction = useUnit(
+ subject instanceof DiscussionEntity ? toggleDiscussionReaction : toggleCommentReaction,
+ );
+
+ return {availableReactions, reactions, toggleReaction};
+};
diff --git a/src/entities/reaction/index.ts b/src/entities/reaction/index.ts
new file mode 100644
index 0000000..19a9878
--- /dev/null
+++ b/src/entities/reaction/index.ts
@@ -0,0 +1 @@
+export * as reactionModel from './model';
diff --git a/src/entities/reaction/model.ts b/src/entities/reaction/model.ts
new file mode 100644
index 0000000..4dc0232
--- /dev/null
+++ b/src/entities/reaction/model.ts
@@ -0,0 +1,8 @@
+import {restore} from 'effector';
+
+import {apiV1LookupReactionsGetFx} from '~/shared/api';
+
+export const $availableReactions = restore(
+ apiV1LookupReactionsGetFx.doneData.map((payload) => payload.answer),
+ [],
+);
diff --git a/src/entities/reaction/ui/failed-message.tsx b/src/entities/reaction/ui/failed-message.tsx
new file mode 100644
index 0000000..769565e
--- /dev/null
+++ b/src/entities/reaction/ui/failed-message.tsx
@@ -0,0 +1,9 @@
+interface FailedMessageProps {
+ reaction: string;
+}
+
+export const FailedMessage = (props: FailedMessageProps) => (
+
+ Failed to toggle {props.reaction}
+
+);
diff --git a/src/entities/types.tsx b/src/entities/types.tsx
new file mode 100644
index 0000000..ee76b31
--- /dev/null
+++ b/src/entities/types.tsx
@@ -0,0 +1,98 @@
+import * as typed from 'typed-contracts';
+
+import {apiV1CommentsCommentIdGetOk, apiV1DiscussionsDiscussionIdGetOk} from '~/shared/api';
+import {UserEntity} from '~/shared/api/types';
+
+export abstract class SubjectEntity {
+ id: string;
+ author?: UserEntity;
+ content: string;
+ publishedAt: Date;
+ commentAuthors: UserEntity[];
+ recursiveCommentCount: number;
+ directCommentCount?: number;
+
+ protected constructor(params: {
+ id: string;
+ author?: UserEntity;
+ content: string;
+ publishedAt: Date;
+ commentAuthors: UserEntity[];
+ recursiveCommentCount: number;
+ directCommentCount?: number;
+ }) {
+ this.id = params.id;
+ this.author = params.author;
+ this.content = params.content;
+ this.publishedAt = params.publishedAt;
+ this.commentAuthors = params.commentAuthors;
+ this.recursiveCommentCount = params.recursiveCommentCount;
+ this.directCommentCount = params.directCommentCount;
+ }
+}
+
+export class DiscussionEntity extends SubjectEntity {
+ title: string;
+
+ constructor(params: {
+ id: string;
+ author?: UserEntity;
+ title: string;
+ content: string;
+ publishedAt: Date;
+ commentAuthors: UserEntity[];
+ recursiveCommentCount: number;
+ directCommentCount?: number;
+ }) {
+ super(params);
+ this.title = params.title;
+ }
+
+ static fromResponse(
+ response: typed.Get,
+ ): DiscussionEntity {
+ return new DiscussionEntity({
+ id: response.id,
+ author: UserEntity.fromResponse(response.author),
+ title: response.title,
+ content: response.content,
+ publishedAt: new Date(response.created_at),
+ commentAuthors: response.last_comments_authors.map(
+ (author) => UserEntity.fromResponse(author)!,
+ ),
+ recursiveCommentCount: response.comment_count,
+ });
+ }
+}
+
+export class CommentEntity extends SubjectEntity {
+ subjectId: string;
+
+ constructor(params: {
+ id: string;
+ subjectId: string;
+ author?: UserEntity;
+ content: string;
+ publishedAt: Date;
+ commentAuthors: UserEntity[];
+ recursiveCommentCount: number;
+ directCommentCount?: number;
+ }) {
+ super(params);
+ this.subjectId = params.subjectId;
+ }
+
+ static fromResponse(response: typed.Get): CommentEntity {
+ return new CommentEntity({
+ id: response.id,
+ subjectId: response.subject_id,
+ author: UserEntity.fromResponse(response.author),
+ content: response.content,
+ publishedAt: new Date(response.created_at),
+ commentAuthors: response.last_comments_authors.map(
+ (author) => UserEntity.fromResponse(author)!,
+ ),
+ recursiveCommentCount: response.comment_count,
+ });
+ }
+}
diff --git a/src/features/comment-form.tsx b/src/features/comment-form.tsx
new file mode 100644
index 0000000..e728ac5
--- /dev/null
+++ b/src/features/comment-form.tsx
@@ -0,0 +1,157 @@
+import {zodResolver} from '@hookform/resolvers/zod';
+import {Eye, Lightbulb, Send, Trash2, Unlink, X} from 'lucide-react';
+import {HTMLAttributes, useEffect, useState} from 'react';
+import {useForm} from 'react-hook-form';
+import {z} from 'zod';
+import {cn} from '~/lib/utils';
+
+import {FormattedContent} from '~/features/formatted-content';
+import {Profile} from '~/features/profile';
+import {ReplySubjectQuote} from '~/features/reply-subject-quote';
+
+import {CommentEntity, SubjectEntity} from '~/entities/types';
+
+import {UserEntity} from '~/shared/api/types';
+import {Form, FormControl, FormField, FormItem} from '~/shared/ui';
+import {Button} from '~/shared/ui/button';
+import {Separator} from '~/shared/ui/separator';
+import {Textarea} from '~/shared/ui/textarea';
+import {Toggle} from '~/shared/ui/toggle';
+
+const formSchema = z.object({
+ content: z.string().min(1).max(1000),
+});
+
+type CommentFormProps = Omit, 'onSubmit' | 'onReset'> & {
+ author: UserEntity;
+ subject?: SubjectEntity;
+ onRemoveSubject: () => void;
+ onSubmit: (content: string) => void;
+ onReset: (content: string) => void;
+ onHide?: () => void;
+ onTogglePreview?: (preview: boolean) => void;
+};
+
+export const CommentForm = (props: CommentFormProps) => {
+ const {
+ author,
+ subject,
+ onRemoveSubject,
+ onSubmit,
+ onReset,
+ onHide,
+ onTogglePreview,
+ className,
+ ...otherProps
+ } = props;
+ const [preview, setPreview] = useState(false);
+
+ useEffect(() => onTogglePreview?.(preview), [onTogglePreview, preview]);
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {content: ''},
+ mode: 'all',
+ });
+
+ const isReplyToComment = subject instanceof CommentEntity;
+
+ return (
+
+
+ );
+};
diff --git a/src/features/formatted-content/index.tsx b/src/features/formatted-content/index.tsx
new file mode 100644
index 0000000..69a372d
--- /dev/null
+++ b/src/features/formatted-content/index.tsx
@@ -0,0 +1,84 @@
+import {createElement, HTMLAttributes, ReactElement, ReactNode} from 'react';
+import Markdown, {Components} from 'react-markdown';
+import remarkDirective from 'remark-directive';
+import {visit} from 'unist-util-visit';
+import {cn} from '~/lib/utils';
+
+function audioDirectivePlugin() {
+ return function (tree: any) {
+ visit(tree, function (node) {
+ if (
+ node.type === 'containerDirective' ||
+ node.type === 'leafDirective' ||
+ node.type === 'textDirective'
+ ) {
+ if (node.name !== 'audio') return;
+
+ const data = node.data ?? (node.data = {});
+ const source = node.children.map((child: any) => child.value).join(' ');
+
+ data.hName = 'audio';
+ data.hProperties = {controls: true, src: source};
+ }
+ });
+ };
+}
+
+const classNames: Record = {
+ h1: 'text-4xl font-bold tracking-tight lg:text-5xl',
+ h2: 'not-first:mt-6 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0',
+ h3: 'text-2xl font-semibold tracking-tight',
+ h4: 'text-xl font-semibold tracking-tight',
+ p: 'not-first:mt-2',
+ blockquote: 'mt-3 border-l-2 pl-3',
+ ul: 'ml-6 list-disc [&>li]:mt-2',
+ a: 'text-blue-500 hover:opacity-90',
+};
+
+const components: Components = {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ img: ({node, className, ...otherProps}) => (
+
+
{
+ const target = e.target as HTMLImageElement;
+ target.className = 'indent-3 w-fit leading-[1]';
+ }}
+ />
+
+ ),
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ audio: ({node, className, ...otherProps}) => (
+