Skip to content

Commit 585b92f

Browse files
committed
Added Unsubscribe to Rooms Logic
1 parent 94f3005 commit 585b92f

4 files changed

Lines changed: 96 additions & 148 deletions

File tree

sdks/js/DittoChatCore/src/slices/useChatUser.ts

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,8 @@ export interface ChatUserSlice {
1313
addUser: (user: Omit<ChatUser, "_id"> & { _id?: string }) => Promise<void>;
1414
updateUser: (user: Partial<ChatUser> & { _id: string }) => Promise<void>;
1515
findUserById: (userId: string) => Promise<ChatUser | null>;
16-
17-
// TODO: Subcription Rooms
18-
19-
subscribeToRoom: (roomId: string) => Promise<void>;
2016
markRoomAsRead: (roomId: string) => Promise<void>;
21-
unsubscribeFromRoom: (roomId: string) => Promise<void>;
17+
toggleRoomSubscription: (roomId: string) => Promise<void>;
2218
}
2319

2420
export const createChatUserSlice: CreateSlice<ChatUserSlice> = (
@@ -49,7 +45,6 @@ export const createChatUserSlice: CreateSlice<ChatUserSlice> = (
4945
if (!ditto) return;
5046
if (!_id) return;
5147
try {
52-
// Fetch the current user
5348
const current = await _get().findUserById(_id);
5449
if (!current) return;
5550
const updated = { ...current, ...patch };
@@ -75,23 +70,6 @@ export const createChatUserSlice: CreateSlice<ChatUserSlice> = (
7570
}
7671
},
7772

78-
async subscribeToRoom(roomId: string) {
79-
if (!ditto || !userId) return;
80-
try {
81-
const user = await _get().findUserById(userId);
82-
if (!user) return;
83-
84-
// Only subscribe if not already subscribed
85-
if (!user.subscriptions || !user.subscriptions[roomId]) {
86-
const now = new Date().toISOString();
87-
const subscriptions = { ...user.subscriptions, [roomId]: now };
88-
await _get().updateUser({ _id: userId, subscriptions });
89-
}
90-
} catch (err) {
91-
console.error("Error in subscribeToRoom:", err);
92-
}
93-
},
94-
9573
async markRoomAsRead(roomId: string) {
9674
if (!ditto || !userId) return;
9775
try {
@@ -113,26 +91,37 @@ export const createChatUserSlice: CreateSlice<ChatUserSlice> = (
11391
mentions[roomId] = [];
11492
hasChanges = true;
11593
}
116-
if (hasChanges)
94+
if (hasChanges) {
11795
await _get().updateUser({ _id: userId, subscriptions, mentions });
96+
}
11897
} catch (err) {
11998
console.error("Error in markRoomAsRead:", err);
12099
}
121100
},
122101

123-
// Add an unsubscribe function as well
124-
async unsubscribeFromRoom(roomId: string) {
102+
async toggleRoomSubscription(roomId: string) {
125103
if (!ditto || !userId) return;
126104
try {
127105
const user = await _get().findUserById(userId);
128-
if (!user || !user.subscriptions) return;
106+
if (!user) return;
129107

130108
const subscriptions = { ...user.subscriptions };
131-
delete subscriptions[roomId];
132-
await _get().updateUser({ _id: userId, subscriptions });
109+
// Toggle: if subscribed (key exists AND value is not null), unsubscribe; otherwise subscribe
110+
if (roomId in subscriptions && subscriptions[roomId] !== null) {
111+
subscriptions[roomId] = null;
112+
} else {
113+
const now = new Date().toISOString();
114+
subscriptions[roomId] = now;
115+
}
116+
117+
await _get().updateUser({
118+
_id: userId,
119+
subscriptions,
120+
});
133121
} catch (err) {
134-
console.error("Error in unsubscribeFromRoom:", err);
122+
console.error("Error in toggleRoomSubscription:", err);
135123
}
124+
136125
},
137126
};
138127

sdks/js/DittoChatCore/tests/useChatUser.test.ts

Lines changed: 48 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -109,80 +109,6 @@ describe("useChatUser Slice", () => {
109109
});
110110
});
111111

112-
describe("subscribeToRoom", () => {
113-
it("updates the user subscription list", async () => {
114-
const mockUser = { _id: "test-user-id", subscriptions: {} };
115-
116-
mockDitto.store.execute.mockResolvedValue({ items: [{ value: mockUser }] });
117-
118-
await store.getState().subscribeToRoom("room-123");
119-
120-
expect(mockDitto.store.execute).toHaveBeenCalledWith(
121-
expect.stringContaining("INSERT INTO users"),
122-
expect.objectContaining({
123-
newUser: expect.objectContaining({
124-
_id: "test-user-id",
125-
subscriptions: expect.objectContaining({
126-
"room-123": expect.any(String),
127-
}),
128-
}),
129-
})
130-
);
131-
});
132-
133-
it("does not re-subscribe if already subscribed", async () => {
134-
const mockUser = {
135-
_id: "test-user-id",
136-
subscriptions: { "room-123": "2023-01-01" }
137-
};
138-
139-
mockDitto.store.execute.mockResolvedValue({ items: [{ value: mockUser }] });
140-
141-
await store.getState().subscribeToRoom("room-123");
142-
143-
// Should only call execute once (for findUserById), not for update
144-
expect(mockDitto.store.execute).toHaveBeenCalledTimes(1);
145-
});
146-
147-
it("returns early when user not found", async () => {
148-
mockDitto.store.execute.mockResolvedValue({ items: [] });
149-
150-
await store.getState().subscribeToRoom("room-123");
151-
152-
// Should only call execute once (for findUserById)
153-
expect(mockDitto.store.execute).toHaveBeenCalledTimes(1);
154-
});
155-
156-
it("handles errors gracefully", async () => {
157-
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => { });
158-
mockDitto.store.execute.mockRejectedValueOnce(new Error("DB Error"));
159-
160-
await store.getState().subscribeToRoom("room-123");
161-
162-
// Error occurs in findUserById which is called internally
163-
expect(consoleSpy).toHaveBeenCalledWith("Error in findUserById:", expect.any(Error));
164-
consoleSpy.mockRestore();
165-
});
166-
167-
it("handles user with no subscriptions object", async () => {
168-
const mockUser = { _id: "test-user-id" };
169-
170-
mockDitto.store.execute.mockResolvedValue({ items: [{ value: mockUser }] });
171-
172-
await store.getState().subscribeToRoom("room-123");
173-
174-
expect(mockDitto.store.execute).toHaveBeenCalledWith(
175-
expect.stringContaining("INSERT INTO users"),
176-
expect.objectContaining({
177-
newUser: expect.objectContaining({
178-
subscriptions: expect.objectContaining({
179-
"room-123": expect.any(String),
180-
}),
181-
}),
182-
})
183-
);
184-
});
185-
});
186112

187113
describe("markRoomAsRead", () => {
188114
it("updates timestamps and clears mentions", async () => {
@@ -295,8 +221,32 @@ describe("useChatUser Slice", () => {
295221
});
296222
});
297223

298-
describe("unsubscribeFromRoom", () => {
299-
it("removes room from subscriptions", async () => {
224+
describe("toggleRoomSubscription", () => {
225+
it("subscribes when not already subscribed", async () => {
226+
const roomId = "room-123";
227+
const mockUser = {
228+
_id: "test-user-id",
229+
subscriptions: { "other-room": "2023-01-01" },
230+
};
231+
232+
mockDitto.store.execute.mockResolvedValue({ items: [{ value: mockUser }] });
233+
234+
await store.getState().toggleRoomSubscription(roomId);
235+
236+
expect(mockDitto.store.execute).toHaveBeenCalledWith(
237+
expect.stringContaining("INSERT INTO users"),
238+
expect.objectContaining({
239+
newUser: expect.objectContaining({
240+
subscriptions: expect.objectContaining({
241+
[roomId]: expect.any(String),
242+
"other-room": "2023-01-01",
243+
}),
244+
}),
245+
})
246+
);
247+
});
248+
249+
it("unsubscribes when already subscribed", async () => {
300250
const roomId = "room-to-leave";
301251

302252
const mockUser = {
@@ -305,32 +255,40 @@ describe("useChatUser Slice", () => {
305255
};
306256
mockDitto.store.execute.mockResolvedValue({ items: [{ value: mockUser }] });
307257

308-
await store.getState().unsubscribeFromRoom(roomId);
258+
await store.getState().toggleRoomSubscription(roomId);
309259

310260
expect(mockDitto.store.execute).toHaveBeenCalledWith(
311261
expect.stringContaining("INSERT INTO users"),
312262
expect.objectContaining({
313263
newUser: expect.objectContaining({
314-
subscriptions: { "other-room": "2023-01-01" },
264+
subscriptions: { "room-to-leave": null, "other-room": "2023-01-01" },
315265
}),
316266
})
317267
);
318268
});
319269

320-
it("returns early when user not found", async () => {
321-
mockDitto.store.execute.mockResolvedValue({ items: [] });
270+
it("subscribes when user has no subscriptions object", async () => {
271+
const mockUser = { _id: "test-user-id" };
272+
mockDitto.store.execute.mockResolvedValue({ items: [{ value: mockUser }] });
322273

323-
await store.getState().unsubscribeFromRoom("room-123");
274+
await store.getState().toggleRoomSubscription("room-123");
324275

325-
// Should only call execute once (for findUserById)
326-
expect(mockDitto.store.execute).toHaveBeenCalledTimes(1);
276+
expect(mockDitto.store.execute).toHaveBeenCalledWith(
277+
expect.stringContaining("INSERT INTO users"),
278+
expect.objectContaining({
279+
newUser: expect.objectContaining({
280+
subscriptions: expect.objectContaining({
281+
"room-123": expect.any(String),
282+
}),
283+
}),
284+
})
285+
);
327286
});
328287

329-
it("returns early when user has no subscriptions", async () => {
330-
const mockUser = { _id: "test-user-id" };
331-
mockDitto.store.execute.mockResolvedValue({ items: [{ value: mockUser }] });
288+
it("returns early when user not found", async () => {
289+
mockDitto.store.execute.mockResolvedValue({ items: [] });
332290

333-
await store.getState().unsubscribeFromRoom("room-123");
291+
await store.getState().toggleRoomSubscription("room-123");
334292

335293
// Should only call execute once (for findUserById)
336294
expect(mockDitto.store.execute).toHaveBeenCalledTimes(1);
@@ -340,7 +298,7 @@ describe("useChatUser Slice", () => {
340298
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => { });
341299
mockDitto.store.execute.mockRejectedValueOnce(new Error("DB Error"));
342300

343-
await store.getState().unsubscribeFromRoom("room-123");
301+
await store.getState().toggleRoomSubscription("room-123");
344302

345303
// Error occurs in findUserById which is called internally
346304
expect(consoleSpy).toHaveBeenCalledWith("Error in findUserById:", expect.any(Error));
@@ -453,15 +411,6 @@ describe("useChatUser Slice", () => {
453411
expect(mockDitto.store.execute).not.toHaveBeenCalled();
454412
});
455413

456-
it("subscribeToRoom returns early when ditto is null", async () => {
457-
const storeWithoutDitto = createTestStore(null);
458-
459-
await storeWithoutDitto.getState().subscribeToRoom("room-123");
460-
461-
// Should not throw and should not call execute
462-
expect(mockDitto.store.execute).not.toHaveBeenCalled();
463-
});
464-
465414
it("markRoomAsRead returns early when ditto is null", async () => {
466415
const storeWithoutDitto = createTestStore(null);
467416

@@ -471,10 +420,10 @@ describe("useChatUser Slice", () => {
471420
expect(mockDitto.store.execute).not.toHaveBeenCalled();
472421
});
473422

474-
it("unsubscribeFromRoom returns early when ditto is null", async () => {
423+
it("toggleRoomSubscription returns early when ditto is null", async () => {
475424
const storeWithoutDitto = createTestStore(null);
476425

477-
await storeWithoutDitto.getState().unsubscribeFromRoom("room-123");
426+
await storeWithoutDitto.getState().toggleRoomSubscription("room-123");
478427

479428
// Should not throw and should not call execute
480429
expect(mockDitto.store.execute).not.toHaveBeenCalled();

sdks/js/DittoChatUI/src/components/ChatView.tsx

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ function ChatView({ chat, onBack }: ChatViewProps) {
5353
const createFileMessage = useDittoChatStore(
5454
(state) => state.createFileMessage,
5555
);
56-
const subscribeToRoom = useDittoChatStore(
56+
const toggleRoomSubscription = useDittoChatStore(
5757
(state) =>
58-
state.subscribeToRoom as ((roomId: string) => Promise<void>) | undefined,
58+
state.toggleRoomSubscription as ((roomId: string) => Promise<void>) | undefined,
5959
);
6060
const markRoomAsRead = useDittoChatStore((state) => state.markRoomAsRead);
6161

@@ -180,20 +180,29 @@ function ChatView({ chat, onBack }: ChatViewProps) {
180180
{room && currentUser && chat.type === "group" && (
181181
<button
182182
onClick={() => {
183-
if (subscribeToRoom)
184-
subscribeToRoom(room._id).catch(console.error);
183+
if (toggleRoomSubscription) {
184+
toggleRoomSubscription(room._id).catch(console.error);
185+
}
185186
}}
186187
className="ml-auto flex items-center space-x-2 px-3 py-1.5 rounded-full bg-(--secondary-bg) hover:bg-(--secondary-bg-hover) text-(--text-color-lighter) font-medium"
187188
>
188-
{currentUser?.subscriptions &&
189-
room._id in currentUser.subscriptions ? (
190-
"Subscribed"
191-
) : (
192-
<>
193-
<Icons.plus className="w-5 h-5" />
194-
<span>Subscribe</span>
195-
</>
196-
)}
189+
{(() => {
190+
const hasKey = currentUser?.subscriptions && room._id in currentUser.subscriptions;
191+
const subValue = currentUser?.subscriptions?.[room._id];
192+
const isSubscribed = hasKey && subValue !== null;
193+
194+
return isSubscribed ? (
195+
<>
196+
<Icons.x className="w-5 h-5" />
197+
<span>Unsubscribe</span>
198+
</>
199+
) : (
200+
<>
201+
<Icons.plus className="w-5 h-5" />
202+
<span>Subscribe</span>
203+
</>
204+
);
205+
})()}
197206
</button>
198207
)}
199208
</header>

0 commit comments

Comments
 (0)