From 0ecb0921648bbc04dc0a1265658151797d396c64 Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Sat, 26 Nov 2022 15:23:07 +0100 Subject: [PATCH 01/15] chore: add notifications of mentions --- src/models/howto.models.tsx | 6 ++ src/stores/Howto/howto.store.test.ts | 9 +++ src/stores/Howto/howto.store.tsx | 98 +++++++++++++++++++----- src/stores/Research/research.store.tsx | 6 +- src/stores/common/mentions/index.test.ts | 15 +++- src/stores/common/mentions/index.ts | 14 +++- 6 files changed, 118 insertions(+), 30 deletions(-) diff --git a/src/models/howto.models.tsx b/src/models/howto.models.tsx index 1cc1df76a9..5b74dcc713 100644 --- a/src/models/howto.models.tsx +++ b/src/models/howto.models.tsx @@ -5,6 +5,11 @@ import type { IUploadedFileMeta } from '../stores/storage' import type { ICategory } from './categories.model' import type { IComment } from './' +type UserMention = { + username: string + location: string +} + // By default all how-to form input fields come as strings // The IHowto interface can imposes the correct formats on fields // Additionally convert from local filemeta to uploaded filemeta @@ -17,6 +22,7 @@ export interface IHowto extends IHowtoFormInput, IModerable { // Comments were added in V2, old howto's may not have the property comments?: IComment[] total_downloads?: number + mentions: UserMention[] } /** diff --git a/src/stores/Howto/howto.store.test.ts b/src/stores/Howto/howto.store.test.ts index bd2c57a944..e76e0fa530 100644 --- a/src/stores/Howto/howto.store.test.ts +++ b/src/stores/Howto/howto.store.test.ts @@ -105,6 +105,9 @@ describe('howto.store', () => { const [newHowto] = setFn.mock.calls[0] expect(setFn).toHaveBeenCalledTimes(1) expect(newHowto.description).toBe('@@{userId:username}') + expect(newHowto.mentions).toEqual(expect.arrayContaining([{ + username: 'username', location: 'description' + }])); }) it('preserves @mentions in existing comments', async () => { @@ -127,6 +130,12 @@ describe('howto.store', () => { expect(newHowto.comments[0].text).toBe( 'Existing comment @@{userId:username}', ) + expect(newHowto.mentions).toEqual(expect.arrayContaining([ + { + username: 'username', + location: 'comment:' + newHowto.comments[0]._id, + } + ])) }) }) diff --git a/src/stores/Howto/howto.store.tsx b/src/stores/Howto/howto.store.tsx index 44b3a3d62b..910dbe016d 100644 --- a/src/stores/Howto/howto.store.tsx +++ b/src/stores/Howto/howto.store.tsx @@ -25,6 +25,7 @@ import { changeMentionToUserReference, changeUserReferenceToPlainText, } from '../common/mentions' +import { DocReference } from '../databaseV2/DocReference' const COLLECTION_NAME = 'howtos' const HOWTO_SEARCH_WEIGHTS = [ @@ -202,8 +203,18 @@ export class HowtoStore extends ModuleStore { return needsModeration(howto, toJS(this.activeUser)) } - private async addUserReference(text: string): Promise { - return await changeMentionToUserReference(text, this.userStore) + private async addUserReference(msg: string): Promise<{ + text: string + users: string[] + }> { + const { text, mentionedUsers: users } = await changeMentionToUserReference( + msg, + this.userStore, + ) + return { + text, + users, + } } @action @@ -213,33 +224,26 @@ export class HowtoStore extends ModuleStore { const howto = this.activeHowto const comment = text.slice(0, MAX_COMMENT_LENGTH).trim() if (user && howto && comment) { - const userCountry = getUserCountry(user) const newComment: IComment = { _id: randomID(), _created: new Date().toISOString(), _creatorId: user._id, creatorName: user.userName, - creatorCountry: userCountry, - text: await this.addUserReference(comment), + creatorCountry: getUserCountry(user), + text: (await this.addUserReference(comment)).text, } logger.debug('addComment.newComment', { newComment }) const updatedHowto: IHowto = { ...toJS(howto), - description: await this.addUserReference(howto.description), - comments: await Promise.all( - [...toJS(howto.comments || []), newComment].map(async (comment) => { - comment.text = await this.addUserReference(comment.text) - return comment - }), - ), + comments: [...toJS(howto.comments || []), newComment], } const dbRef = this.db .collection(COLLECTION_NAME) .doc(updatedHowto._id) - await dbRef.set(updatedHowto) + await this.updateHowtoItem(dbRef, updatedHowto) // Refresh the active howto this.activeHowto = await dbRef.get() @@ -250,6 +254,46 @@ export class HowtoStore extends ModuleStore { } } + private async updateHowtoItem( + dbRef: DocReference, + howToItem: IHowto, + ): Promise { + logger.debug('updateHowtoItem', { + before: this.activeHowto, + after: howToItem, + }) + + const { text: description, users } = await this.addUserReference( + howToItem.description, + ) + + const mentions = users.map((username) => ({ + username, + location: 'description', + })) + + return dbRef.set({ + ...howToItem, + description, + comments: await Promise.all( + [...toJS(howToItem.comments || [])].map(async (comment) => { + const { text, users } = await this.addUserReference(comment.text) + comment.text = text + + users.forEach((username) => { + mentions.push({ + username, + location: `comment:${comment._id}`, + }) + }) + + return comment + }), + ), + mentions, + }) + } + @action public async editComment(id: string, newText: string) { try { @@ -261,18 +305,20 @@ export class HowtoStore extends ModuleStore { (comment) => comment._creatorId === user._id && comment._id === id, ) if (commentIndex !== -1) { - comments[commentIndex].text = (await this.addUserReference(newText)) + comments[commentIndex].text = ( + await this.addUserReference(newText) + ).text .slice(0, MAX_COMMENT_LENGTH) .trim() comments[commentIndex]._edited = new Date().toISOString() const updatedHowto: IHowto = { ...toJS(howto), - description: await this.addUserReference(howto.description), + description: (await this.addUserReference(howto.description)).text, comments: await Promise.all( comments.map(async (comment) => ({ ...comment, - text: await this.addUserReference(comment.text), + text: (await this.addUserReference(comment.text)).text, })), ), } @@ -283,6 +329,16 @@ export class HowtoStore extends ModuleStore { await dbRef.set(updatedHowto) + // After successfully updating the database document queue up all the notifications + // Should a notification be issued? + // - Only if the mention did not exist in the document before. + // How do we decide whether a mention existed in the document previously? + // - Based on combination of username/location + // Location: Where in the document does the mention exist? + // - Introduction + // - Steps + // - Comments + // Refresh the active howto this.activeHowto = await dbRef.get() } @@ -307,13 +363,13 @@ export class HowtoStore extends ModuleStore { ) .map(async (comment) => ({ ...comment, - text: await this.addUserReference(comment.text), + text: (await this.addUserReference(comment.text)).text, })), ) const updatedHowto: IHowto = { ...toJS(howto), - description: await this.addUserReference(howto.description), + description: (await this.addUserReference(howto.description)).text, comments, } @@ -380,12 +436,12 @@ export class HowtoStore extends ModuleStore { const howTo: IHowto = { ...values, - description: await this.addUserReference(values.description), + description: (await this.addUserReference(values.description)).text, _createdBy: values._createdBy ? values._createdBy : user.userName, comments: await Promise.all( comments.map(async (c) => ({ ...c, - text: await this.addUserReference(c.text), + text: (await this.addUserReference(c.text)).text, })), ), cover_image: processedCover, @@ -438,7 +494,7 @@ export class HowtoStore extends ModuleStore { step.images = imgMeta stepsWithImgMeta.push({ ...step, - text: await this.addUserReference(step.text), + text: (await this.addUserReference(step.text)).text, images: imgMeta.map((f) => { if (f === undefined) { return null diff --git a/src/stores/Research/research.store.tsx b/src/stores/Research/research.store.tsx index a05be29fb1..892a7addea 100644 --- a/src/stores/Research/research.store.tsx +++ b/src/stores/Research/research.store.tsx @@ -165,7 +165,7 @@ export class ResearchStore extends ModuleStore { } private async addUserReference(text: string): Promise { - return await changeMentionToUserReference(text, this.userStore) + return (await changeMentionToUserReference(text, this.userStore)).text } public async addComment( @@ -201,7 +201,9 @@ export class ResearchStore extends ModuleStore { ) const newImg = imgMeta.map((img) => ({ ...img })) updateWithMeta.images = newImg - } else updateWithMeta.images = [] + } else { + updateWithMeta.images = [] + } updateWithMeta.comments = updateWithMeta.comments ? [...toJS(updateWithMeta.comments), newComment] diff --git a/src/stores/common/mentions/index.test.ts b/src/stores/common/mentions/index.test.ts index aa772da10e..cbb720ccf1 100644 --- a/src/stores/common/mentions/index.test.ts +++ b/src/stores/common/mentions/index.test.ts @@ -33,7 +33,10 @@ describe('changeMentionToUserReference', () => { mockUserStore as unknown as UserStore, ), ).toEqual( - 'a simple @@{fish:FISH} containing multiple usernames, @@{seconduser:seconduser}. One fake @​user', + { + text: 'a simple @@{fish:FISH} containing multiple usernames, @@{seconduser:seconduser}. One fake @​user', + mentionedUsers: ['FISH', 'seconduser'], + }, ) }) @@ -56,7 +59,10 @@ describe('changeMentionToUserReference', () => { 'a simple email@fish.com', mockUserStore as unknown as UserStore, ), - ).toEqual('a simple email@fish.com') + ).toEqual({ + text: 'a simple email@fish.com', + mentionedUsers: [] + }) }) it('handles errors when fetching user', async () => { @@ -67,7 +73,10 @@ describe('changeMentionToUserReference', () => { 'a simple email@fish.com', mockUserStore as unknown as UserStore, ), - ).toEqual('a simple email@fish.com') + ).toEqual({ + text: 'a simple email@fish.com', + mentionedUsers: [] + }) }) }) diff --git a/src/stores/common/mentions/index.ts b/src/stores/common/mentions/index.ts index 2babb675b9..aa62dc9af5 100644 --- a/src/stores/common/mentions/index.ts +++ b/src/stores/common/mentions/index.ts @@ -11,17 +11,23 @@ import type { UserStore } from '../../User/user.store' * * @param text string * @param userStore UserStore - * @returns Promise + * @returns Promise<{ + * text: string; + * mentionedUsers: string[], + * }> */ export const changeMentionToUserReference = async function ( text: string, userStore: UserStore, -): Promise { +): Promise<{ + text: string, + mentionedUsers: string[] +}> { const mentions = text.match(/\B@[​a-z0-9_-]+/g) const mentionedUsers = new Set() if (!mentions) { - return text + return { text, mentionedUsers: [] }; } for (const mention of mentions) { @@ -42,7 +48,7 @@ export const changeMentionToUserReference = async function ( logger.debug('Unable to find matching profile', { mention }) } } - return text + return { text, mentionedUsers: Array.from(mentionedUsers) } } export const changeUserReferenceToPlainText = function (text: string) { From 5dfdecd94b3bc0752912effa7f6cea3f1a2e70cb Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Sat, 26 Nov 2022 15:39:18 +0100 Subject: [PATCH 02/15] chore: refactor comment methods to use common save pathway --- src/mocks/howto.mock.tsx | 3 ++ src/stores/Howto/howto.store.tsx | 86 +++++++++----------------------- src/test/factories/Howto.ts | 1 + 3 files changed, 27 insertions(+), 63 deletions(-) diff --git a/src/mocks/howto.mock.tsx b/src/mocks/howto.mock.tsx index 63ffd7dcf7..f333d5a72b 100644 --- a/src/mocks/howto.mock.tsx +++ b/src/mocks/howto.mock.tsx @@ -34,6 +34,7 @@ export const HOWTO_MOCK: IHowto[] = [ ], tags: {}, files: [], + mentions: [], ...MOCK_DB_META('howTo1'), _createdBy: 'ExampleUser', moderation: 'draft', @@ -60,6 +61,7 @@ export const HOWTO_MOCK: IHowto[] = [ ], tags: {}, files: [], + mentions: [], ...MOCK_DB_META('howTo2'), _createdBy: 'ExampleUser', moderation: 'awaiting-moderation', @@ -86,6 +88,7 @@ export const HOWTO_MOCK: IHowto[] = [ ], tags: {}, files: [], + mentions: [], ...MOCK_DB_META('howTo3'), _createdBy: 'ExampleUser', moderation: 'accepted', diff --git a/src/stores/Howto/howto.store.tsx b/src/stores/Howto/howto.store.tsx index 910dbe016d..63dd512930 100644 --- a/src/stores/Howto/howto.store.tsx +++ b/src/stores/Howto/howto.store.tsx @@ -25,7 +25,6 @@ import { changeMentionToUserReference, changeUserReferenceToPlainText, } from '../common/mentions' -import { DocReference } from '../databaseV2/DocReference' const COLLECTION_NAME = 'howtos' const HOWTO_SEARCH_WEIGHTS = [ @@ -234,19 +233,11 @@ export class HowtoStore extends ModuleStore { } logger.debug('addComment.newComment', { newComment }) - const updatedHowto: IHowto = { + // Update and refresh the active howto + this.activeHowto = await this.updateHowtoItem({ ...toJS(howto), comments: [...toJS(howto.comments || []), newComment], - } - - const dbRef = this.db - .collection(COLLECTION_NAME) - .doc(updatedHowto._id) - - await this.updateHowtoItem(dbRef, updatedHowto) - - // Refresh the active howto - this.activeHowto = await dbRef.get() + }) } } catch (err) { console.error(err) @@ -254,10 +245,9 @@ export class HowtoStore extends ModuleStore { } } - private async updateHowtoItem( - dbRef: DocReference, - howToItem: IHowto, - ): Promise { + private async updateHowtoItem(howToItem: IHowto) { + const dbRef = this.db.collection(COLLECTION_NAME).doc(howToItem._id) + logger.debug('updateHowtoItem', { before: this.activeHowto, after: howToItem, @@ -272,7 +262,7 @@ export class HowtoStore extends ModuleStore { location: 'description', })) - return dbRef.set({ + dbRef.set({ ...howToItem, description, comments: await Promise.all( @@ -292,6 +282,8 @@ export class HowtoStore extends ModuleStore { ), mentions, }) + + return await dbRef.get() } @action @@ -305,30 +297,11 @@ export class HowtoStore extends ModuleStore { (comment) => comment._creatorId === user._id && comment._id === id, ) if (commentIndex !== -1) { - comments[commentIndex].text = ( - await this.addUserReference(newText) - ).text + comments[commentIndex].text = newText .slice(0, MAX_COMMENT_LENGTH) .trim() comments[commentIndex]._edited = new Date().toISOString() - const updatedHowto: IHowto = { - ...toJS(howto), - description: (await this.addUserReference(howto.description)).text, - comments: await Promise.all( - comments.map(async (comment) => ({ - ...comment, - text: (await this.addUserReference(comment.text)).text, - })), - ), - } - - const dbRef = this.db - .collection(COLLECTION_NAME) - .doc(updatedHowto._id) - - await dbRef.set(updatedHowto) - // After successfully updating the database document queue up all the notifications // Should a notification be issued? // - Only if the mention did not exist in the document before. @@ -340,7 +313,10 @@ export class HowtoStore extends ModuleStore { // - Comments // Refresh the active howto - this.activeHowto = await dbRef.get() + this.activeHowto = await this.updateHowtoItem({ + ...toJS(howto), + comments, + }) } } } catch (err) { @@ -355,32 +331,15 @@ export class HowtoStore extends ModuleStore { const howto = this.activeHowto const user = this.activeUser if (id && howto && user && howto.comments) { - const comments = await Promise.all( - toJS(howto.comments) - .filter( - (comment) => - !(comment._creatorId === user._id && comment._id === id), - ) - .map(async (comment) => ({ - ...comment, - text: (await this.addUserReference(comment.text)).text, - })), - ) - - const updatedHowto: IHowto = { + + // Refresh the active howto with the updated item + this.activeHowto = await this.updateHowtoItem({ ...toJS(howto), - description: (await this.addUserReference(howto.description)).text, - comments, - } - - const dbRef = this.db - .collection(COLLECTION_NAME) - .doc(updatedHowto._id) - - await dbRef.set(updatedHowto) - - // Refresh the active howto - this.activeHowto = await dbRef.get() + comments: toJS(howto.comments).filter( + (comment) => + !(comment._creatorId === user._id && comment._id === id), + ), + }) } } catch (err) { console.error(err) @@ -444,6 +403,7 @@ export class HowtoStore extends ModuleStore { text: (await this.addUserReference(c.text)).text, })), ), + mentions: [], cover_image: processedCover, steps: processedSteps, fileLink: values.fileLink ?? '', diff --git a/src/test/factories/Howto.ts b/src/test/factories/Howto.ts index 4ba513fee0..f4e6881437 100644 --- a/src/test/factories/Howto.ts +++ b/src/test/factories/Howto.ts @@ -27,6 +27,7 @@ export const FactoryHowto = ( _deleted: faker.datatype.boolean(), _createdBy: faker.internet.userName(), steps: [], + mentions: [], cover_image: { downloadUrl: '', name: '', From da3bc2c1851d4298626c2a31a14cd998d0f62612 Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Sun, 27 Nov 2022 14:49:59 +0100 Subject: [PATCH 03/15] feat: adds support for howto_mention to notification --- .../src/NotificationItem/NotificationItem.tsx | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/components/src/NotificationItem/NotificationItem.tsx b/packages/components/src/NotificationItem/NotificationItem.tsx index 5d32e0bff3..ccd4a9dfb7 100644 --- a/packages/components/src/NotificationItem/NotificationItem.tsx +++ b/packages/components/src/NotificationItem/NotificationItem.tsx @@ -27,7 +27,39 @@ export const NotificationItem = (props: NotificationItemProps) => { fontFamily: 'Inter, sans-serif', }} > - {['howto_useful', 'research_useful'].includes(type) ? ( + {type === 'howto_mention' ? ( + + + + + + You were mentioned in a{' '} + + how-to + {' '} + by + + {triggeredBy.displayName} + {' '} + + + ) : ['howto_useful', 'research_useful'].includes(type) ? ( From 5c14f3453c99879d26435b18f78aebd2f883e217 Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Sun, 27 Nov 2022 14:50:49 +0100 Subject: [PATCH 04/15] feat: user store support howto_mention --- src/models/user.models.tsx | 1 + src/stores/User/user.store.test.ts | 46 ++++++++++++++++++++++++++++++ src/stores/User/user.store.ts | 11 +++++-- 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/stores/User/user.store.test.ts diff --git a/src/models/user.models.tsx b/src/models/user.models.tsx index 6891a930a6..673901e528 100644 --- a/src/models/user.models.tsx +++ b/src/models/user.models.tsx @@ -92,5 +92,6 @@ export interface INotification { export type NotificationType = | 'new_comment' | 'howto_useful' + | 'howto_mention' | 'new_comment_research' | 'research_useful' diff --git a/src/stores/User/user.store.test.ts b/src/stores/User/user.store.test.ts new file mode 100644 index 0000000000..b3dc34921a --- /dev/null +++ b/src/stores/User/user.store.test.ts @@ -0,0 +1,46 @@ +jest.mock('../common/module.store') +import { FactoryUser } from 'src/test/factories/User' +import type { RootStore } from '..' +import { UserStore } from './user.store' + +describe('user.store', () => { + describe('triggerNotification', () => { + it('adds a new notification to user', async () => { + const store = new UserStore({} as RootStore) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + store.db.getWhere.mockReturnValue([FactoryUser()]) + + // Act + await store.triggerNotification( + 'howto_mention', + 'example', + 'https://example.com', + ) + + // Expect + const [newUser] = store.db.set.mock.calls[0] + expect(store.db.set).toBeCalledTimes(1) + expect(newUser.notifications).toHaveLength(1) + expect(newUser.notifications[0]).toEqual( + expect.objectContaining({ + type: 'howto_mention', + }), + ) + }) + + it('throws error when invalid user passed', async () => { + const store = new UserStore({} as RootStore) + + // Act + await expect( + store.triggerNotification( + 'howto_mention', + 'non-existent-user', + 'https://example.com', + ), + ).rejects.toThrow('User not found') + }) + }) +}) diff --git a/src/stores/User/user.store.ts b/src/stores/User/user.store.ts index f81f9c88e2..5c728038fa 100644 --- a/src/stores/User/user.store.ts +++ b/src/stores/User/user.store.ts @@ -352,9 +352,6 @@ export class UserStore extends ModuleStore { } } - // use firebase auth to listen to change to signed in user - // on sign in want to load user profile - // strange implementation return the unsubscribe object on subscription, so stored @action public async triggerNotification( type: NotificationType, @@ -386,6 +383,10 @@ export class UserStore extends ModuleStore { const user = lookup[0] + if (!user) { + throw new Error('User not found.') + } + const updatedUser: IUser = { ...toJS(user), notifications: user.notifications @@ -454,6 +455,10 @@ export class UserStore extends ModuleStore { throw new Error(err) } } + + // use firebase auth to listen to change to signed in user + // on sign in want to load user profile + // strange implementation return the unsubscribe object on subscription, so stored // to authUnsubscribe variable for use later private _listenToAuthStateChanges(checkEmailVerification = false) { this.authUnsubscribe = auth.onAuthStateChanged((authUser) => { From b9df1ccc35ddd49aa88e3c7a848e29feb682d8e6 Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Sun, 27 Nov 2022 14:53:10 +0100 Subject: [PATCH 05/15] feat: generate notifications for how-to mentions --- src/stores/Howto/howto.store.test.ts | 156 ++++++++++++++++++++++++--- src/stores/Howto/howto.store.tsx | 128 ++++++++++++++-------- src/test/factories/Howto.ts | 21 +++- 3 files changed, 247 insertions(+), 58 deletions(-) diff --git a/src/stores/Howto/howto.store.test.ts b/src/stores/Howto/howto.store.test.ts index e76e0fa530..743ad91e13 100644 --- a/src/stores/Howto/howto.store.test.ts +++ b/src/stores/Howto/howto.store.test.ts @@ -1,7 +1,7 @@ jest.mock('../common/module.store') import type { IHowtoDB } from 'src/models' import { FactoryComment } from 'src/test/factories/Comment' -import { FactoryHowto } from 'src/test/factories/Howto' +import { FactoryHowto, FactoryHowtoStep } from 'src/test/factories/Howto' import { FactoryUser } from 'src/test/factories/User' import type { RootStore } from '..' import { HowtoStore } from './howto.store' @@ -29,10 +29,11 @@ async function factory(howtoOverloads: Partial = {}) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore store.userStore = { - getUserProfile: jest.fn().mockResolvedValue( + triggerNotification: jest.fn(), + getUserProfile: jest.fn().mockImplementation((userName) => FactoryUser({ _authID: 'userId', - userName: 'username', + userName, }), ), } @@ -51,6 +52,114 @@ async function factory(howtoOverloads: Partial = {}) { describe('howto.store', () => { describe('uploadHowTo', () => { it.todo('updates an existing item') + + it('captures mentions within description', async () => { + const { store, howToItem, setFn } = await factory({ + description: '@username', + }) + + // Act + await store.uploadHowTo(howToItem) + + const [newHowto] = setFn.mock.calls[0] + expect(setFn).toHaveBeenCalledTimes(1) + expect(newHowto.description).toBe('@@{userId:username}') + expect(newHowto.mentions).toHaveLength(1) + }) + + it('captures mentions within a how-step', async () => { + const { store, howToItem, setFn } = await factory({ + steps: [ + FactoryHowtoStep({ text: 'Step description featuring a @username' }), + ], + }) + + // Act + await store.uploadHowTo(howToItem) + + const [newHowto] = setFn.mock.calls[0] + expect(setFn).toHaveBeenCalledTimes(1) + expect(newHowto.steps[0].text).toBe( + 'Step description featuring a @@{userId:username}', + ) + expect(newHowto.mentions).toHaveLength(1) + }) + + it('creates notifications for any new mentions in description', async () => { + const { store, howToItem, setFn } = await factory({ + description: '@username', + mentions: [ + { + username: 'username', + location: 'description', + }, + ], + comments: [ + FactoryComment({ + text: '@commentauthor', + }), + ], + }) + + await store.uploadHowTo({ + ...howToItem, + }) + + expect(setFn).toHaveBeenCalledTimes(1) + expect(store.userStore.triggerNotification).toBeCalledTimes(1) + expect(store.userStore.triggerNotification).toBeCalledWith( + 'howto_mention', + 'commentauthor', + 'http://example.com', + ) + }) + + it('creates notifications for any new mentions in a how-to step', async () => { + const { store, howToItem, setFn } = await factory({ + description: '@username', + mentions: [ + { + username: 'username', + location: 'description', + }, + ], + steps: [ + { + images: [ + { + downloadUrl: 'string', + fullPath: 'string', + name: 'string', + type: 'string', + size: 2300, + timeCreated: 'string', + updated: 'string', + }, + ], + title: 'How to step', + text: 'Step description featuring a howto', + }, + ], + comments: [ + FactoryComment({ + text: '@commentauthor', + }), + ], + }) + + await store.uploadHowTo({ + ...howToItem, + }) + + expect(setFn).toHaveBeenCalledTimes(1) + expect(store.userStore.triggerNotification).toBeCalledTimes(1) + expect(store.userStore.triggerNotification).toBeCalledWith( + 'howto_mention', + 'commentauthor', + 'http://example.com', + ) + }) + it('preserves @mention on existing comments', async () => { const comments = [ FactoryComment({ @@ -71,6 +180,14 @@ describe('howto.store', () => { const [newHowto] = setFn.mock.calls[0] expect(setFn).toHaveBeenCalledTimes(1) expect(newHowto.comments[1].text).toBe('@@{userId:username}') + expect(newHowto.mentions).toEqual( + expect.arrayContaining([ + { + username: 'username', + location: `comment:${newHowto.comments[1]._id}`, + }, + ]), + ) }) }) @@ -91,6 +208,14 @@ describe('howto.store', () => { text: 'short comment including @@{userId:username}', }), ) + expect(newHowto.mentions).toEqual( + expect.arrayContaining([ + { + username: 'username', + location: 'comment:' + newHowto.comments[0]._id, + }, + ]), + ) }) it('preserves @mentions in description', async () => { @@ -105,9 +230,14 @@ describe('howto.store', () => { const [newHowto] = setFn.mock.calls[0] expect(setFn).toHaveBeenCalledTimes(1) expect(newHowto.description).toBe('@@{userId:username}') - expect(newHowto.mentions).toEqual(expect.arrayContaining([{ - username: 'username', location: 'description' - }])); + expect(newHowto.mentions).toEqual( + expect.arrayContaining([ + { + username: 'username', + location: 'description', + }, + ]), + ) }) it('preserves @mentions in existing comments', async () => { @@ -130,12 +260,14 @@ describe('howto.store', () => { expect(newHowto.comments[0].text).toBe( 'Existing comment @@{userId:username}', ) - expect(newHowto.mentions).toEqual(expect.arrayContaining([ - { - username: 'username', - location: 'comment:' + newHowto.comments[0]._id, - } - ])) + expect(newHowto.mentions).toEqual( + expect.arrayContaining([ + { + username: 'username', + location: 'comment:' + newHowto.comments[0]._id, + }, + ]), + ) }) }) diff --git a/src/stores/Howto/howto.store.tsx b/src/stores/Howto/howto.store.tsx index 63dd512930..5ca0a205b4 100644 --- a/src/stores/Howto/howto.store.tsx +++ b/src/stores/Howto/howto.store.tsx @@ -105,7 +105,11 @@ export class HowtoStore extends ModuleStore { public async setActiveHowtoBySlug(slug: string) { // clear any cached data and then load the new howto logger.debug(`setActiveHowtoBySlug:`, { slug }) - this.activeHowto = undefined + + if (!slug) { + this.activeHowto = undefined + } + const collection = await this.db .collection(COLLECTION_NAME) .getWhere('slug', '==', slug) @@ -221,25 +225,27 @@ export class HowtoStore extends ModuleStore { try { const user = this.activeUser const howto = this.activeHowto - const comment = text.slice(0, MAX_COMMENT_LENGTH).trim() - if (user && howto && comment) { + if (user && howto && text) { const newComment: IComment = { _id: randomID(), _created: new Date().toISOString(), _creatorId: user._id, creatorName: user.userName, creatorCountry: getUserCountry(user), - text: (await this.addUserReference(comment)).text, + text: text.slice(0, MAX_COMMENT_LENGTH).trim(), } logger.debug('addComment.newComment', { newComment }) // Update and refresh the active howto - this.activeHowto = await this.updateHowtoItem({ + const updated = await this.updateHowtoItem({ ...toJS(howto), comments: [...toJS(howto.comments || []), newComment], }) + + await this.setActiveHowtoBySlug(updated?.slug || '') } } catch (err) { + console.log({ err }) console.error(err) throw new Error(err) } @@ -262,25 +268,74 @@ export class HowtoStore extends ModuleStore { location: 'description', })) - dbRef.set({ - ...howToItem, - description, - comments: await Promise.all( - [...toJS(howToItem.comments || [])].map(async (comment) => { - const { text, users } = await this.addUserReference(comment.text) - comment.text = text - - users.forEach((username) => { - mentions.push({ - username, - location: `comment:${comment._id}`, - }) + const comments = await Promise.all( + [...toJS(howToItem.comments || [])].map(async (comment) => { + const { text, users } = await this.addUserReference(comment.text) + comment.text = text + + users.forEach((username) => { + mentions.push({ + username, + location: `comment:${comment._id}`, + }) + }) + + return comment + }), + ) + + const steps = await Promise.all( + [...toJS(howToItem.steps || [])].map(async (step) => { + const { text, users } = await this.addUserReference(step.text) + + users.forEach((username) => { + mentions.push({ + username, + location: `step`, }) + }) - return comment - }), - ), + return { + ...step, + text, + } + }), + ) + + await dbRef.set({ + ...howToItem, + description, + comments, mentions, + steps, + }) + + // After successfully updating the database document queue up all the notifications + // Should a notification be issued? + // - Only if the mention did not exist in the document before. + // How do we decide whether a mention existed in the document previously? + // - Based on combination of username/location + // Location: Where in the document does the mention exist? + // - Introduction + // - Steps + // - Comments + logger.debug(`Mentions:`, { + before: howToItem.mentions, + after: mentions, + }) + + // Previous mentions + const previousMentions = howToItem.mentions.map( + (mention) => `${mention.username}.${mention.location}`, + ) + + mentions.forEach((mention) => { + if (!previousMentions.includes(`${mention.username}.${mention.location}`)) + this.userStore.triggerNotification( + 'howto_mention', + mention.username, + 'http://example.com', + ) }) return await dbRef.get() @@ -302,16 +357,6 @@ export class HowtoStore extends ModuleStore { .trim() comments[commentIndex]._edited = new Date().toISOString() - // After successfully updating the database document queue up all the notifications - // Should a notification be issued? - // - Only if the mention did not exist in the document before. - // How do we decide whether a mention existed in the document previously? - // - Based on combination of username/location - // Location: Where in the document does the mention exist? - // - Introduction - // - Steps - // - Comments - // Refresh the active howto this.activeHowto = await this.updateHowtoItem({ ...toJS(howto), @@ -331,15 +376,16 @@ export class HowtoStore extends ModuleStore { const howto = this.activeHowto const user = this.activeUser if (id && howto && user && howto.comments) { - // Refresh the active howto with the updated item - this.activeHowto = await this.updateHowtoItem({ + await this.updateHowtoItem({ ...toJS(howto), comments: toJS(howto.comments).filter( (comment) => !(comment._creatorId === user._id && comment._id === id), ), }) + + await this.setActiveHowtoBySlug(howto.slug) } } catch (err) { console.error(err) @@ -394,16 +440,10 @@ export class HowtoStore extends ModuleStore { const userCountry = getUserCountry(user) const howTo: IHowto = { + mentions: [], ...values, - description: (await this.addUserReference(values.description)).text, + comments, _createdBy: values._createdBy ? values._createdBy : user.userName, - comments: await Promise.all( - comments.map(async (c) => ({ - ...c, - text: (await this.addUserReference(c.text)).text, - })), - ), - mentions: [], cover_image: processedCover, steps: processedSteps, fileLink: values.fileLink ?? '', @@ -425,10 +465,9 @@ export class HowtoStore extends ModuleStore { logger.debug('populating database', howTo) // set the database document - await dbRef.set(howTo) + this.activeHowto = await this.updateHowtoItem(howTo) this.updateUploadStatus('Database') logger.debug('post added') - this.activeHowto = await dbRef.get() // complete this.updateUploadStatus('Complete') } catch (error) { @@ -454,8 +493,7 @@ export class HowtoStore extends ModuleStore { step.images = imgMeta stepsWithImgMeta.push({ ...step, - text: (await this.addUserReference(step.text)).text, - images: imgMeta.map((f) => { + images: (imgMeta || []).map((f) => { if (f === undefined) { return null } diff --git a/src/test/factories/Howto.ts b/src/test/factories/Howto.ts index f4e6881437..3b6a6a0f78 100644 --- a/src/test/factories/Howto.ts +++ b/src/test/factories/Howto.ts @@ -1,4 +1,4 @@ -import type { IHowtoDB } from 'src/models' +import type { IHowtoDB, IHowtoStep } from 'src/models' import { faker } from '@faker-js/faker' export const FactoryHowto = ( @@ -39,3 +39,22 @@ export const FactoryHowto = ( }, ...howtoOverloads, }) + +export const FactoryHowtoStep = ( + howtoStepOverloads: Partial = {}, +): IHowtoStep => ({ + images: [ + { + downloadUrl: faker.internet.url(), + fullPath: faker.internet.url(), + name: faker.lorem.text(), + type: 'string', + size: 2300, + timeCreated: faker.date.past().toString(), + updated: faker.date.past().toString(), + }, + ], + title: faker.lorem.text(), + text: faker.lorem.paragraphs(2), + ...howtoStepOverloads, +}) From e979cac4fd7bd6abbe0e9841d8a78730f096bb22 Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Sun, 27 Nov 2022 14:53:52 +0100 Subject: [PATCH 06/15] chore: apply formatting --- src/stores/common/mentions/index.test.ts | 14 ++++++-------- src/stores/common/mentions/index.ts | 4 ++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/stores/common/mentions/index.test.ts b/src/stores/common/mentions/index.test.ts index cbb720ccf1..3bded356f9 100644 --- a/src/stores/common/mentions/index.test.ts +++ b/src/stores/common/mentions/index.test.ts @@ -32,12 +32,10 @@ describe('changeMentionToUserReference', () => { 'a simple @​fish containing multiple usernames, @seconduser. One fake @user', mockUserStore as unknown as UserStore, ), - ).toEqual( - { - text: 'a simple @@{fish:FISH} containing multiple usernames, @@{seconduser:seconduser}. One fake @​user', - mentionedUsers: ['FISH', 'seconduser'], - }, - ) + ).toEqual({ + text: 'a simple @@{fish:FISH} containing multiple usernames, @@{seconduser:seconduser}. One fake @​user', + mentionedUsers: ['FISH', 'seconduser'], + }) }) it('extracts valid user names', async () => { @@ -61,7 +59,7 @@ describe('changeMentionToUserReference', () => { ), ).toEqual({ text: 'a simple email@fish.com', - mentionedUsers: [] + mentionedUsers: [], }) }) @@ -75,7 +73,7 @@ describe('changeMentionToUserReference', () => { ), ).toEqual({ text: 'a simple email@fish.com', - mentionedUsers: [] + mentionedUsers: [], }) }) }) diff --git a/src/stores/common/mentions/index.ts b/src/stores/common/mentions/index.ts index aa62dc9af5..afdb224926 100644 --- a/src/stores/common/mentions/index.ts +++ b/src/stores/common/mentions/index.ts @@ -20,14 +20,14 @@ export const changeMentionToUserReference = async function ( text: string, userStore: UserStore, ): Promise<{ - text: string, + text: string mentionedUsers: string[] }> { const mentions = text.match(/\B@[​a-z0-9_-]+/g) const mentionedUsers = new Set() if (!mentions) { - return { text, mentionedUsers: [] }; + return { text, mentionedUsers: [] } } for (const mention of mentions) { From 0abcc2a0e577c579caa3e71a887583144227b95c Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Sun, 27 Nov 2022 16:40:17 +0100 Subject: [PATCH 07/15] feat(notification): add link through to how-to article --- src/stores/Howto/howto.store.test.ts | 4 ++-- src/stores/Howto/howto.store.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/stores/Howto/howto.store.test.ts b/src/stores/Howto/howto.store.test.ts index 743ad91e13..77d9ecc2af 100644 --- a/src/stores/Howto/howto.store.test.ts +++ b/src/stores/Howto/howto.store.test.ts @@ -110,7 +110,7 @@ describe('howto.store', () => { expect(store.userStore.triggerNotification).toBeCalledWith( 'howto_mention', 'commentauthor', - 'http://example.com', + `/how-to/${howToItem.slug}`, ) }) @@ -156,7 +156,7 @@ describe('howto.store', () => { expect(store.userStore.triggerNotification).toBeCalledWith( 'howto_mention', 'commentauthor', - 'http://example.com', + `/how-to/${howToItem.slug}`, ) }) diff --git a/src/stores/Howto/howto.store.tsx b/src/stores/Howto/howto.store.tsx index 5ca0a205b4..482f3dc17b 100644 --- a/src/stores/Howto/howto.store.tsx +++ b/src/stores/Howto/howto.store.tsx @@ -334,7 +334,7 @@ export class HowtoStore extends ModuleStore { this.userStore.triggerNotification( 'howto_mention', mention.username, - 'http://example.com', + `/how-to/${howToItem.slug}`, ) }) From 80e3807c7b220d674863f80f6cb40cdbd186a217 Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Sun, 27 Nov 2022 16:41:55 +0100 Subject: [PATCH 08/15] chore: switch to use common update method --- src/stores/Howto/howto.store.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/stores/Howto/howto.store.tsx b/src/stores/Howto/howto.store.tsx index 482f3dc17b..4fab8dce2c 100644 --- a/src/stores/Howto/howto.store.tsx +++ b/src/stores/Howto/howto.store.tsx @@ -174,7 +174,7 @@ export class HowtoStore extends ModuleStore { total_downloads: totalDownloads! + 1, } - await dbRef.set(updatedHowto) + await this.updateHowtoItem(updatedHowto) return updatedHowto.total_downloads } } @@ -197,9 +197,7 @@ export class HowtoStore extends ModuleStore { if (!hasAdminRights(toJS(this.activeUser))) { return false } - const ref = this.db.collection(COLLECTION_NAME).doc(howto._id) - // NOTE CC - 2021-07-06 mobx updates try write to db as observable, so need to convert toJS - return ref.set(toJS(howto)) + return this.updateHowtoItem(toJS(howto)) } public needsModeration(howto: IHowto) { From a8b2a45f854520a4fc9fb6e6dd168e24e8ca0bb7 Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Sun, 27 Nov 2022 22:27:52 +0100 Subject: [PATCH 09/15] fix: pass id through to comment notification --- packages/components/src/CommentItem/CommentItem.tsx | 2 +- src/stores/Howto/howto.store.test.ts | 4 ++-- src/stores/Howto/howto.store.tsx | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/components/src/CommentItem/CommentItem.tsx b/packages/components/src/CommentItem/CommentItem.tsx index 40a6df3a4d..099cc03b15 100644 --- a/packages/components/src/CommentItem/CommentItem.tsx +++ b/packages/components/src/CommentItem/CommentItem.tsx @@ -61,7 +61,7 @@ export const CommentItem = (props: CommentItemProps) => { } return ( - + { expect(store.userStore.triggerNotification).toBeCalledWith( 'howto_mention', 'commentauthor', - `/how-to/${howToItem.slug}`, + `/how-to/${howToItem.slug}#comment:${howToItem.comments[0]._id}`, ) }) @@ -156,7 +156,7 @@ describe('howto.store', () => { expect(store.userStore.triggerNotification).toBeCalledWith( 'howto_mention', 'commentauthor', - `/how-to/${howToItem.slug}`, + `/how-to/${howToItem.slug}#comment:${howToItem.comments[0]._id}`, ) }) diff --git a/src/stores/Howto/howto.store.tsx b/src/stores/Howto/howto.store.tsx index 4fab8dce2c..6ff950f4ea 100644 --- a/src/stores/Howto/howto.store.tsx +++ b/src/stores/Howto/howto.store.tsx @@ -317,13 +317,14 @@ export class HowtoStore extends ModuleStore { // - Introduction // - Steps // - Comments + const previousMentionsList = howToItem.mentions || [] logger.debug(`Mentions:`, { - before: howToItem.mentions, + before: previousMentionsList, after: mentions, }) // Previous mentions - const previousMentions = howToItem.mentions.map( + const previousMentions = previousMentionsList.map( (mention) => `${mention.username}.${mention.location}`, ) @@ -332,7 +333,7 @@ export class HowtoStore extends ModuleStore { this.userStore.triggerNotification( 'howto_mention', mention.username, - `/how-to/${howToItem.slug}`, + `/how-to/${howToItem.slug}#${mention.location}`, ) }) From 23b931fba0fffe7cdfeed0a473cdc3559ce6dbf9 Mon Sep 17 00:00:00 2001 From: Luke Watts Date: Mon, 28 Nov 2022 23:01:47 +0100 Subject: [PATCH 10/15] feat: support highlighting an individual comment within the CommentList --- .../src/CommentList/CommentList.stories.tsx | 60 ++++++++++++------- .../src/CommentList/CommentList.tsx | 45 ++++++++++++-- 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/packages/components/src/CommentList/CommentList.stories.tsx b/packages/components/src/CommentList/CommentList.stories.tsx index 22cf05962a..0bbec04b66 100644 --- a/packages/components/src/CommentList/CommentList.stories.tsx +++ b/packages/components/src/CommentList/CommentList.stories.tsx @@ -1,35 +1,53 @@ import type { ComponentStory, ComponentMeta } from '@storybook/react' import { CommentList } from './CommentList' +import { faker } from '@faker-js/faker' export default { title: 'Components/CommentList', component: CommentList, } as ComponentMeta -const comments = [ - { - _created: '2022-06-15T09:41:09.571Z', - _creatorId: 'TestCreatorID', - _id: 'testID', - creatorName: 'TestName', - isUserVerified: false, - text: 'Test text one', - isEditable: true, - }, - { - _created: '2022-06-15T09:41:09.571Z', - _creatorId: 'TestCreatorID2', - _id: 'testID2', - creatorName: 'TestName2', - isUserVerified: false, - text: 'Test text two', - isEditable: true, - }, -] +const createComments = (numberOfComments = 2, commentOverloads = {}) => + [...Array(numberOfComments).keys()].slice(0).map(() => ({ + _created: faker.date.past().toString(), + creatorCountry: faker.address.countryCode().toLowerCase(), + _creatorId: faker.internet.userName(), + _id: faker.database.mongodbObjectId(), + creatorName: faker.internet.userName(), + isUserVerified: faker.datatype.boolean(), + text: faker.lorem.text(), + isEditable: faker.datatype.boolean(), + ...commentOverloads, + })) export const Default: ComponentStory = () => ( Promise.resolve()} + handleEditRequest={() => Promise.resolve()} + handleEdit={() => Promise.resolve()} + /> +) + +export const Expandable: ComponentStory = () => ( + Promise.resolve()} + handleEditRequest={() => Promise.resolve()} + handleEdit={() => Promise.resolve()} + /> +) + +const highlightedCommentList = createComments(20, { isEditable: false }) + +export const Highlighted: ComponentStory = () => ( + Promise.resolve()} handleEditRequest={() => Promise.resolve()} diff --git a/packages/components/src/CommentList/CommentList.tsx b/packages/components/src/CommentList/CommentList.tsx index d7dbdda523..f4086fc0ac 100644 --- a/packages/components/src/CommentList/CommentList.tsx +++ b/packages/components/src/CommentList/CommentList.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import ReactGA from 'react-ga4' import { Box } from 'theme-ui' import { Button, CommentItem } from '../' @@ -10,16 +10,40 @@ export const CommentList: React.FC<{ handleEdit: (_id: string, comment: string) => Promise handleEditRequest: () => Promise handleDelete: (_id: string) => Promise + highlightedCommentId?: string articleTitle?: string }> = ({ articleTitle, comments, handleEditRequest, handleDelete, + highlightedCommentId, handleEdit, }) => { const [moreComments, setMoreComments] = useState(1) const shownComments = moreComments * MAX_COMMENTS + const scrollIntoRelevantComment = (commentId: string) => { + setTimeout(() => { + // the delay is needed, otherwise the scroll is not happening in Firefox + document + .getElementById(`comment:${commentId}`) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 0) + } + + useEffect(() => { + if (!highlightedCommentId) return + + const i = comments.findIndex((comment) => + highlightedCommentId.includes(comment._id), + ) + console.log(`Found highlighted comment:`, i) + if (i > 0) { + setMoreComments(Math.floor(i / MAX_COMMENTS) + 1) + scrollIntoRelevantComment(highlightedCommentId) + } + }, [highlightedCommentId]) + return ( {comments && - comments - .slice(0, shownComments) - .map((comment: Comment) => ( + comments.slice(0, shownComments).map((comment: Comment) => ( + - ))} + + ))} {comments && comments.length > shownComments && (