Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@ interface CommentContext {
resolveComments: () => Promise<{ error: Error | DOMException | null }>;
updateCommentVisibility: (visibility: Partial<CommentVisibility>) => void;
triggerError: (error: KnownError) => void;

// Events - subscribe to comment lifecycle changes
events: CommentEvents;
}
```

Expand Down Expand Up @@ -550,6 +553,131 @@ function CustomCommentContent() {
}
```

#### `events` (via `useComments()`)

The `events` object provides a subscription-based system for reacting to comment lifecycle changes. Each event method accepts a callback and returns an `unsubscribe` function.

**Available Events:**

| Event | Trigger | Callback Signature |
|-------|---------|-------------------|
| `onFocusChange` | Focus moves to a different comment | `(commentId: string \| null, comment: CommentType \| undefined) => void` |
| `onCommentsConfirmed` | Draft comments are successfully saved | `(comments: CommentType[]) => void` |
| `onCommentsResolved` | Comments are successfully resolved | `(comments: CommentType[]) => void` |
| `onCommentDeleted` | A comment is deleted | `(commentId: string) => void` |
| `onCommentRegistered` | A new draft comment is created | `(comment: CommentType) => void` |

**Basic Usage:**

```tsx
import { useComments } from '@kendew-agency/react-feedback-layer';
import { useEffect } from 'react';

function CommentTracker() {
const { events } = useComments();

useEffect(() => {
const unsubscribe = events.onFocusChange((commentId, comment) => {
console.log('Focus changed to:', commentId);
if (comment) {
highlightElement(comment.position);
}
});

return unsubscribe;
}, [events]);

useEffect(() => {
const unsubscribe = events.onCommentsConfirmed((comments) => {
console.log(`${comments.length} comments saved`);
toast.success('Comments saved successfully!');
});

return unsubscribe;
}, [events]);

return null;
}
```

**Multiple Subscriptions:**

```tsx
function AnalyticsTracker() {
const { events } = useComments();

useEffect(() => {
const unsubs = [
events.onCommentRegistered((comment) => {
analytics.track('comment_created', {
position: comment.position,
user: comment.user?.name,
});
}),
events.onCommentsConfirmed((comments) => {
analytics.track('comments_confirmed', { count: comments.length });
}),
events.onCommentsResolved((comments) => {
analytics.track('comments_resolved', { count: comments.length });
}),
events.onCommentDeleted((id) => {
analytics.track('comment_deleted', { commentId: id });
}),
];

return () => unsubs.forEach((unsub) => unsub());
}, [events]);

return null;
}
```

**Syncing with External State:**

```tsx
function CommentSidebar() {
const { events, allComments } = useComments();
const [activeId, setActiveId] = useState<string | null>(null);

useEffect(() => {
return events.onFocusChange((commentId) => {
setActiveId(commentId);
// Scroll sidebar to the active comment
if (commentId) {
document.getElementById(`sidebar-${commentId}`)?.scrollIntoView({
behavior: 'smooth',
});
}
});
}, [events]);

return (
<aside className="comment-sidebar">
{allComments.map((comment) => (
<div
key={comment.id}
id={`sidebar-${comment.id}`}
className={comment.id === activeId ? 'active' : ''}
>
<p>{comment.content}</p>
<small>{comment.user?.name}</small>
</div>
))}
</aside>
);
}
```

**Type Definitions:**

```tsx
import type {
CommentEvents,
CommentEventMap,
Unsubscribe,
} from '@kendew-agency/react-feedback-layer/types';
```

### Types

Import types from the `/types` subpath:
Expand Down Expand Up @@ -1211,6 +1339,10 @@ MIT © [Kendew Agency](https://github.com/Kendew-Agency)

A list if breaking changes that could impact the way you configured the package

### 0.4.0
- Added `events` to the `useComments()` hook. You can now subscribe to comment lifecycle events such as `onFocusChange`, `onCommentsConfirmed`, `onCommentsResolved`, `onCommentDeleted`, and `onCommentRegistered`. Each event method returns an unsubscribe function for easy cleanup.
- New types exported: `CommentEvents`, `CommentEventMap`, `Unsubscribe`.

### 0.3.0
- Added `projectId` prop to `CommentContextProvider`. When set, the project ID is attached to every new comment and passed as a second argument to the `onConfirm` callback. Update your `onConfirm` signature from `(comments) => ...` to `(comments, projectId?) => ...`.
- `onError` is now a required prop on `CommentContextProvider`. If you previously omitted it, you must provide an error handler.
Expand Down
16 changes: 16 additions & 0 deletions playground/components/event-listener.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useComments } from "../../src";

export const EventListener = () => {
const { events } = useComments();

useEffect(() => {
const unsubscribe = events.onFocusChange((commentId) => {
console.info("Focus shifted to: ", commentId);
});

return unsubscribe; // cleanup on unmount
}, [events]);

return null;
};
2 changes: 2 additions & 0 deletions playground/features/comments/comment-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "./_components/comment";
import { CommentToolbar } from "./_components/toolbar";
import { CommentList } from "./_components/comment-list";
import { EventListener } from "../../components/event-listener";

export const CommentWrapper = ({ children }: { children: ReactNode }) => {
return (
Expand Down Expand Up @@ -93,6 +94,7 @@ export const CommentWrapper = ({ children }: { children: ReactNode }) => {
indicatorVisibility: "active",
}}
>
<EventListener />
<div style={{ display: "flex", gap: 10 }}>
<CommentOverlay>
<CommentRenderer
Expand Down
45 changes: 44 additions & 1 deletion src/contexts/comment-context.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { createContext, useContext, useEffect, useReducer } from "react";
import {
createContext,
useContext,
useEffect,
useReducer,
useRef,
} from "react";
import type {
CommentType,
CommentAction,
Expand All @@ -14,6 +20,7 @@ import type {
import { tx } from "../lib/tx";
import { hasStatus } from "../utils/hasStatus";
import { ConfirmError, ResolveError } from "../errors";
import { useCommentEvents } from "../lib/use-comment-events";

// Create the context for the comments
const CommentContext = createContext<CommentContextType | null>(null);
Expand Down Expand Up @@ -174,6 +181,20 @@ export const CommentContextProvider = ({
projectId,
} satisfies CommentState);

const { events, emit } = useCommentEvents();

// Track focus changes and emit event
const prevFocusRef = useRef<string | null>(null);
useEffect(() => {
if (state.focussedComment !== prevFocusRef.current) {
const comment = state.comments.find(
(c) => c.id === state.focussedComment,
);
emit("onFocusChange", state.focussedComment, comment);
prevFocusRef.current = state.focussedComment;
}
}, [state.focussedComment, state.comments, emit]);

// Replace the initial comments with the comments from the listen query
useEffect(() => {
if (!subscription) return;
Expand All @@ -198,9 +219,22 @@ export const CommentContextProvider = ({
});
};

// Emit onCommentRegistered after state updates with new draft
const prevCommentsLengthRef = useRef(state.comments.length);
useEffect(() => {
if (state.comments.length > prevCommentsLengthRef.current) {
const lastComment = state.comments[state.comments.length - 1];
if (lastComment && lastComment.status === "draft") {
emit("onCommentRegistered", lastComment);
}
}
prevCommentsLengthRef.current = state.comments.length;
}, [state.comments, emit]);

// Delete a comment from the context
const deleteComment = (id: string) => {
dispatch({ type: "DELETE", id });
emit("onCommentDeleted", id);
};

// Update a comment in the context
Expand Down Expand Up @@ -295,6 +329,10 @@ export const CommentContextProvider = ({

dispatch({ type: "CHANGE_OVERLAYSTATE", to: "idle" });
dispatch({ type: "RESET_DRAFT_COMMENTS" });
emit(
"onCommentsConfirmed",
draftComments.map((c) => ({ ...c, status: "published" as const })),
);
return { error: null };
};

Expand All @@ -320,6 +358,10 @@ export const CommentContextProvider = ({

dispatch({ type: "CHANGE_OVERLAYSTATE", to: "idle" });
dispatch({ type: "RESET_RESOLVING_COMMENTS" });
emit(
"onCommentsResolved",
resolvingComments.map((c) => ({ ...c, status: "resolved" as const })),
);
return { error: null };
};

Expand Down Expand Up @@ -362,6 +404,7 @@ export const CommentContextProvider = ({
updateCommentVisibility,

triggerError,
events,
}}
>
{children}
Expand Down
62 changes: 62 additions & 0 deletions src/lib/use-comment-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useCallback, useRef } from "react";
import type {
CommentEventMap,
CommentEvents,
Unsubscribe,
} from "../types/event.types";

type Listeners = {
[K in keyof CommentEventMap]: Set<CommentEventMap[K]>;
};

/**
* Internal hook that creates the event subscription system.
* Returns both the public `events` object (for consumers) and
* an `emit` function (for the provider to trigger events).
*/
export const useCommentEvents = () => {
const listenersRef = useRef<Listeners>({
onFocusChange: new Set(),
onCommentsConfirmed: new Set(),
onCommentsResolved: new Set(),
onCommentDeleted: new Set(),
onCommentRegistered: new Set(),
});

const subscribe = useCallback(
<K extends keyof CommentEventMap>(
event: K,
callback: CommentEventMap[K],
): Unsubscribe => {
listenersRef.current[event].add(callback);
return () => {
listenersRef.current[event].delete(callback);
};
},
[],
);

const emit = useCallback(
<K extends keyof CommentEventMap>(
event: K,
...args: Parameters<CommentEventMap[K]>
) => {
for (const listener of listenersRef.current[event]) {
(listener as (...a: Parameters<CommentEventMap[K]>) => void)(...args);
}
},
[],
);

const events: CommentEvents = {
onFocusChange: (callback) => subscribe("onFocusChange", callback),
onCommentsConfirmed: (callback) =>
subscribe("onCommentsConfirmed", callback),
onCommentsResolved: (callback) => subscribe("onCommentsResolved", callback),
onCommentDeleted: (callback) => subscribe("onCommentDeleted", callback),
onCommentRegistered: (callback) =>
subscribe("onCommentRegistered", callback),
};

return { events, emit };
};
2 changes: 2 additions & 0 deletions src/types/comment.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CommentEvents } from "./event.types";
import type { KnownError } from "./error.types";
import type {
CommentOverlayState,
Expand Down Expand Up @@ -120,6 +121,7 @@ export type CommentContext = {
}>;
updateCommentVisibility: (visibility: Partial<CommentVisibility>) => void;
triggerError: (error: KnownError) => void;
events: CommentEvents;
};

export type CommentWithStatus<T extends Status> = Omit<
Expand Down
Loading
Loading