From 305a7ad074bf2b5183cc2541e31102b77c74ea5c Mon Sep 17 00:00:00 2001 From: Maria Adriana <97130795+mariadriana-deemaze@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:02:05 +0000 Subject: [PATCH] [SOA-38] Enable Post Mentions (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat ๐ŸŽธ (be): add user.index route, controller and service action * styles ๐Ÿ’… (wip): progress on add autocomplete interface * styles ๐Ÿ’… : create highlight parsing input * refactor โœจ (ui): replace avatar comps for generic UserAvatar * feat ๐ŸŽธ : more progress * feat ๐ŸŽธ (be): serialise mentions * styles ๐Ÿ’… : improve highlighted_input * styles ๐Ÿ’… : parse mentions on post_card content * feat ๐ŸŽธ (be): notify post content mentioned users via hook * test ๐Ÿงช (unit): add tests for post mention notification event * feat ๐ŸŽธ (be): parse notification serialized template * styles ๐Ÿ’… : improve highlighted content overlay placement * refactor โœจ : small nits and renames * test ๐Ÿงช (unit): add user_service/search action test * fix โœ… : override user on mention notification --- adonisrc.ts | 1 + app/controllers/users_controller.ts | 6 + app/enums/notification.ts | 1 + app/interfaces/post.ts | 1 + .../trigger_post_mention_notification.ts | 30 ++ app/models/post.ts | 18 +- .../post_mention_notification.ts | 36 +++ app/services/posts_service.ts | 20 ++ app/services/user_notification_service.ts | 75 +++-- app/services/user_service.ts | 27 ++ app/types/events.ts | 7 + app/utils/index.ts | 11 + config/notification.ts | 6 + inertia/components/admin/generic/nav.tsx | 10 +- .../components/generic/highlighted_input.tsx | 264 ++++++++++++++++++ inertia/components/generic/user_avatar.tsx | 13 + inertia/components/posts/form.tsx | 84 +++++- inertia/components/posts/post_card.tsx | 54 ++-- .../users/_notifications_dropdown.tsx | 24 +- inertia/components/users/nav.tsx | 25 +- inertia/pages/posts/show.tsx | 5 +- inertia/pages/users/show.tsx | 7 +- package.json | 1 + start/events.ts | 5 + start/routes.ts | 1 + tests/unit/posts/mentions.spec.ts | 82 ++++++ .../trigger_post_mention_notification.spec.ts | 48 ++++ tests/unit/user_service/search.spec.ts | 68 +++++ 28 files changed, 842 insertions(+), 88 deletions(-) create mode 100644 app/listeners/trigger_post_mention_notification.ts create mode 100644 app/notifications/post_mention_notification.ts create mode 100644 app/types/events.ts create mode 100644 inertia/components/generic/highlighted_input.tsx create mode 100644 inertia/components/generic/user_avatar.tsx create mode 100644 start/events.ts create mode 100644 tests/unit/posts/mentions.spec.ts create mode 100644 tests/unit/trigger_post_mention_notification.spec.ts create mode 100644 tests/unit/user_service/search.spec.ts diff --git a/adonisrc.ts b/adonisrc.ts index a382411..b8a8171 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -68,6 +68,7 @@ export default defineConfig({ file: () => import('#start/repl'), environment: ['repl'], }, + () => import('#start/events'), ], /* diff --git a/app/controllers/users_controller.ts b/app/controllers/users_controller.ts index 8777cf8..a2585c8 100644 --- a/app/controllers/users_controller.ts +++ b/app/controllers/users_controller.ts @@ -11,6 +11,12 @@ import { PageObject } from '@adonisjs/inertia/types' export default class UsersController { constructor(private readonly service: UserService) {} + async index(ctx: HttpContext) { + const searchTerm = ctx.request.qs().search || '' + const page = ctx.request.qs().page || 1 + return this.service.search(searchTerm, { page, limit: 5 }) + } + async show(ctx: HttpContext): Promise< | string | PageObject<{ diff --git a/app/enums/notification.ts b/app/enums/notification.ts index 2fa2705..bb1fe21 100644 --- a/app/enums/notification.ts +++ b/app/enums/notification.ts @@ -2,4 +2,5 @@ export enum NotificationType { UserPostReportedNotification = 'UserPostReportedNotification', PostReportingUserStatusNotification = 'PostReportingUserStatusNotification', PostOwnerReactionNotification = 'PostOwnerReactionNotification', + PostMentionNotification = 'PostMentionNotification', } diff --git a/app/interfaces/post.ts b/app/interfaces/post.ts index c94fd2a..6f784a8 100644 --- a/app/interfaces/post.ts +++ b/app/interfaces/post.ts @@ -19,6 +19,7 @@ export interface LinkResponse { export interface PostResponse extends BaseEntity { id: UUID content: string + mentions: Record status: PostStatus attachments: { images: AttachmentResponse[] diff --git a/app/listeners/trigger_post_mention_notification.ts b/app/listeners/trigger_post_mention_notification.ts new file mode 100644 index 0000000..dbef332 --- /dev/null +++ b/app/listeners/trigger_post_mention_notification.ts @@ -0,0 +1,30 @@ +import Post from '#models/post' +import User from '#models/user' +import PostMentionNotification from '#notifications/post_mention_notification' +import { NotificationType } from '#enums/notification' + +export default class TriggerPostMentionNotification { + async handle([mentions, post]: [string[], Post]) { + const notifiables = await this.notifiables(mentions) + for (const notifiable of notifiables) { + const userNotifications = await notifiable.unreadNotifications() + const prev = userNotifications.filter( + (notification) => + notification.data.type === NotificationType.PostMentionNotification && + notification.data.postId === post.id + ) + if (prev.length > 0) return + notifiable.notify(new PostMentionNotification(post)) + } + } + + async notifiables(mentions: string[]): Promise { + const notifiables: User[] = [] + if (mentions.length === 0) return notifiables + for (const mention of mentions) { + const user = await User.findBy('username', mention) + if (user) notifiables.push(user) + } + return notifiables + } +} diff --git a/app/models/post.ts b/app/models/post.ts index 0c7be83..70aa51d 100644 --- a/app/models/post.ts +++ b/app/models/post.ts @@ -3,7 +3,6 @@ import { BaseModel, beforeDelete, beforeSave, - beforeUpdate, belongsTo, column, computed, @@ -11,10 +10,11 @@ import { scope, } from '@adonisjs/lucid/orm' import User from '#models/user' -import { extractFirstLink, sanitizePostContent } from '#utils/index' +import { extractFirstLink, REGEX, sanitizePostContent } from '#utils/index' import PostReaction from '#models/post_reaction' import PostReport from '#models/post_report' import { PostStatus } from '#enums/post' +import emitter from '@adonisjs/core/services/emitter' import type { UUID } from 'node:crypto' import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' @@ -55,10 +55,22 @@ export default class Post extends BaseModel { return extractFirstLink(this.content) } + @computed() + get matches(): Map { + const mapped = new Map() + const possibleMentions = + this.content.match(new RegExp(REGEX.MENTIONS))?.map((m) => m.replace('@', '')) || [] + mapped.set('@', possibleMentions) + return mapped + } + @beforeSave() - @beforeUpdate() static sanitizeContent(post: Post) { post.content = sanitizePostContent(post.content) + const mentions = post.matches.get('@') + // TODO: Future concept + // const tags = post.matches.get('#') + if (mentions) emitter.emit('post:mention', [mentions, post]) } @beforeDelete() diff --git a/app/notifications/post_mention_notification.ts b/app/notifications/post_mention_notification.ts new file mode 100644 index 0000000..06e79fb --- /dev/null +++ b/app/notifications/post_mention_notification.ts @@ -0,0 +1,36 @@ +import { NotificationChannelName, NotificationContract } from '@osenco/adonisjs-notifications/types' +import { PostMentionNotificationData } from '@osenco/adonisjs-notifications/types' +import { NotificationType } from '#enums/notification' +import Post from '#models/post' +import type User from '#models/user' + +export default class PostMentionNotification implements NotificationContract { + private post: Post + + protected subject = '' + protected message = '' + + constructor(post: Post) { + this.post = post + this.#templateData() + } + + via(): NotificationChannelName | Array { + return 'database' + } + + toDatabase(): PostMentionNotificationData { + return { + type: NotificationType.PostMentionNotification, + userId: this.post.userId, + postId: this.post.id, + title: this.subject, + message: this.message, + } + } + + #templateData() { + this.subject = `:authorFullName has mentioned you on their your post` + this.message = `":content"` + } +} diff --git a/app/services/posts_service.ts b/app/services/posts_service.ts index 1156f1c..36ed740 100644 --- a/app/services/posts_service.ts +++ b/app/services/posts_service.ts @@ -11,6 +11,8 @@ import { ModelObject } from '@adonisjs/lucid/types/model' import type { HttpContext } from '@adonisjs/core/http' import type { UUID } from 'node:crypto' import { UserService } from '#services/user_service' +import { UserResponse } from '#interfaces/user' +import User from '#models/user' export default class PostsService { private readonly userService: UserService @@ -134,6 +136,22 @@ export default class PostsService { return this.attachmentService.deleteMany(AttachmentModel.POST, id) } + /** + * Parse content in search of other user mentions, and returns matches. + */ + async processMentions(post: Post): Promise> { + const matches = post.matches.get('@') || [] + const result: Map = new Map() + for (const username of matches) { + const user = await User.findBy('username', username) + if (user) { + const serialized = await this.userService.serialize(user) + result.set(user.username, serialized) + } + } + return result + } + /** * Handles the process on serializing the post data, and aggregating it's many associations. */ @@ -142,6 +160,7 @@ export default class PostsService { const user = await this.userService.serialize(post.user) const attachments = await this.attachmentService.findMany(AttachmentModel.POST, post.id) const link = await this.linkService.show(post.link) + const mentions = await this.processMentions(post) let accumulator: Record = { [PostReactionType.LIKE]: 0, @@ -162,6 +181,7 @@ export default class PostsService { const resource: PostResponse = { id: data.id, content: data.content, + mentions: Object.fromEntries(mentions), status: data.status, user, link, diff --git a/app/services/user_notification_service.ts b/app/services/user_notification_service.ts index 8575165..c23e8bc 100644 --- a/app/services/user_notification_service.ts +++ b/app/services/user_notification_service.ts @@ -55,15 +55,10 @@ export default class UserNotificationService { updatedAt: json.updatedAt, } - let user = users.get(notification.notifiableId) + let user = await this.getSetMap('user', notification.notifiableId, users) if (!user) { - const serialized = await this.userService.findOne(notification.data.userId) - if (!serialized) { - logger.error(`Attempted to notify. UserId ${notification.data.userId} not found.`) - break - } - user = serialized - users.set(serialized.id, serialized) + logger.error(`Attempted to notify. UserId ${notification.data.userId} not found.`) + break } serializedNotification.data = { ...json.data, user } @@ -71,16 +66,10 @@ export default class UserNotificationService { // Replace template strings switch (notification.data.type) { case NotificationType.PostOwnerReactionNotification: { - let post = posts.get(notification.data.postId) - + let post = await this.getSetMap('post', notification.data.postId, posts) if (!post) { - const p = await Post.find(notification.data.postId) - if (!p) { - logger.error(`Attempted to notify. PostId ${notification.data.postId} not found.`) - break - } - posts.set(notification.data.postId, p) - post = p + logger.error(`Attempted to notify. PostId ${notification.data.postId} not found.`) + break } serializedNotification.data = { @@ -91,6 +80,7 @@ export default class UserNotificationService { break } + case NotificationType.UserPostReportedNotification: { serializedNotification.data = { ...serializedNotification.data, @@ -102,6 +92,31 @@ export default class UserNotificationService { } break } + + case NotificationType.PostMentionNotification: { + let postAuthor = await this.getSetMap( + 'user', + notification.data.userId, + users + ) + let post = await this.getSetMap('post', notification.data.postId, posts) + if (!postAuthor || !post) { + logger.error(`Attempted to notify. Resource not found.`) + break + } + + serializedNotification.data = { + ...serializedNotification.data, + title: serializedNotification.data.title.replace( + ':authorFullName', + postAuthor.name ?? '' + ), + message: serializedNotification.data.message.replace(':content', post.content), + user: postAuthor, + } + break + } + case NotificationType.PostReportingUserStatusNotification: serializedNotification.data = { ...serializedNotification.data, @@ -119,4 +134,30 @@ export default class UserNotificationService { return data } + + private async getSetMap( + type: 'user' | 'post', + id: UUID, + map: Map + ): Promise { + let resource: T | null = map.get(id) || null + + if (!resource) { + let item = null as T + + if (type === 'user') { + item = (await this.userService.findOne(id)) as unknown as T + } + + if (type === 'post') { + item = (await Post.find(id)) as unknown as T + } + + map.set(id, item) + } + + resource = map.get(id) || null + + return resource + } } diff --git a/app/services/user_service.ts b/app/services/user_service.ts index db24362..9c381f0 100644 --- a/app/services/user_service.ts +++ b/app/services/user_service.ts @@ -1,3 +1,4 @@ +import { PaginatedResponse } from './../interfaces/pagination' import { AttachmentResponse } from '#interfaces/attachment' import { UserResponse } from '#interfaces/user' import { AttachmentModel, AttachmentType } from '#models/attachment' @@ -13,6 +14,32 @@ export class UserService { this.attachmentService = new AttachmentService() } + async search( + searchTerm: string, + { page, limit = 10 }: { page: number; limit?: number } + ): Promise> { + const search = `%${searchTerm}%` + + const result = await User.query() + .whereILike('username', search) + .orWhereILike('name', search) + .orderBy('updated_at', 'desc') + .paginate(page, limit) + + const { meta } = result.toJSON() + + const data: UserResponse[] = [] + for (const user of result) { + const resource = await this.serialize(user) + data.push(resource) + } + + return { + data, + meta, + } + } + async findOne(id: UUID): Promise { const user = await User.find(id) if (!user) return null diff --git a/app/types/events.ts b/app/types/events.ts new file mode 100644 index 0000000..faa9fe9 --- /dev/null +++ b/app/types/events.ts @@ -0,0 +1,7 @@ +import Post from '#models/post' + +declare module '@adonisjs/core/types' { + interface EventsList { + 'post:mention': [string[], Post] + } +} diff --git a/app/utils/index.ts b/app/utils/index.ts index f8f844f..b26e83d 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -20,6 +20,16 @@ export function extractFirstLink(content: string): string | null { return matches ? matches[0] : null } +/** + * Replace last occurrence of a match from a string. + */ +export function replaceLast(text: string, searchValue: string, replaceValue: string) { + const lastOccurrenceIndex = text.lastIndexOf(searchValue) + return `${text.slice(0, lastOccurrenceIndex)}${replaceValue}${text.slice( + lastOccurrenceIndex + searchValue.length + )}` +} + /** * */ @@ -36,4 +46,5 @@ export function sanitizePostContent(content: string): string { export const REGEX = { ALPHA_STRING: /^[A-z]+$/, ALPHANUMERIC_STRING: /^[A-z0-9]+$/, + MENTIONS: /@[a-zA-Z0-9_-]+/g, } diff --git a/config/notification.ts b/config/notification.ts index 44557e9..566c946 100644 --- a/config/notification.ts +++ b/config/notification.ts @@ -38,4 +38,10 @@ declare module '@osenco/adonisjs-notifications/types' { postId: UUID postReactionType: PostReactionType } + + interface PostMentionNotificationData extends DatabaseChannelData { + type: NotificationType.PostMentionNotification + userId: UUID + postId: UUID + } } diff --git a/inertia/components/admin/generic/nav.tsx b/inertia/components/admin/generic/nav.tsx index 89007ad..2e90631 100644 --- a/inertia/components/admin/generic/nav.tsx +++ b/inertia/components/admin/generic/nav.tsx @@ -8,13 +8,13 @@ import { DropdownMenuItem, DropdownMenuShortcut, } from '@/components/ui/dropdown_menu' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import AdonisLogo from '@/components/svg/logo' import { cn } from '@/lib/utils' import { UserResponse } from '#interfaces/user' import { route } from '@izzyjs/route/client' import { PostReportStatus } from '#enums/post' +import { UserAvatar } from '@/components/generic/user_avatar' export default function NavBar({ user }: { user: UserResponse | null }) { const LINKS: Record<'title' | 'link', string>[] = [ @@ -52,13 +52,7 @@ export default function NavBar({ user }: { user: UserResponse | null }) { diff --git a/inertia/components/generic/highlighted_input.tsx b/inertia/components/generic/highlighted_input.tsx new file mode 100644 index 0000000..16b0452 --- /dev/null +++ b/inertia/components/generic/highlighted_input.tsx @@ -0,0 +1,264 @@ +import React, { Reducer, useEffect, useMemo, useReducer, useRef } from 'react' +import { Textarea, TextareaProps } from '@/components/ui/textarea' +import { Card } from '@/components/ui/card' + +interface SlottableItemProps { + item: T + searchTerm: string + select: (item: T) => void +} + +interface HighlightedInputProps extends TextareaProps { + /** + * Define the trigger that will run on the parser + */ + captureTrigger: RegExp + + /** + * Define the callback that will run as parser against the defined trigger capture group regex + * @param content - The controlled input content value. + * @param selection - The selected elements list. + */ + parser: (content: string, selection: T[]) => string + + /** + * Define a key in T, to act as a matching predicate. + */ + matcherPredicate: keyof T + + /** + * + * @param searchTerm - The query param term for the next batch of list items. + */ + fetcher: (searchTerm: string) => Promise + + /** + * Default text to highlight upon initialization. + */ + defaultHightlights: T[] + + /** + * React component to render as a slottable list option item. + * + * @property {T} item - Receives a generic . + * @property {string} searchTerm - The current search string. + * @property {function} select - The select option triger. + */ + Item: ({ item, searchTerm, select }: SlottableItemProps) => React.ReactNode +} + +export enum ReducerActionType { + OPEN_LIST = 'OPEN_LIST', + CLOSE_LIST = 'CLOSE_LIST', + UPDATE_LIST = 'UPDATE_LIST', + ADD_SELECTED = 'ADD_SELECTED', + UPDATE_SEARCH_TERM = 'UPDATE_SEARCH_TERM', + CLEAR_SEARCH_TERM = 'CLEAR_SEARCH_TERM', + RECYCLE_SELECT_MATCHES = 'RECYCLE_SELECT_MATCHES', +} + +export type ReducerContextState = { + list: T[] + open: boolean + selected: T[] + searchTerm: string +} + +export type ReducerContextActionOptions = + | { + type: ReducerActionType.UPDATE_LIST + state: { list: T[] } + } + | { + type: ReducerActionType.ADD_SELECTED + state: { selected: T } + } + | { + type: ReducerActionType.OPEN_LIST + } + | { + type: ReducerActionType.CLOSE_LIST + } + | { + type: ReducerActionType.CLEAR_SEARCH_TERM + } + | { + type: ReducerActionType.UPDATE_SEARCH_TERM + state: { + searchTerm: string + } + } + | { + type: ReducerActionType.RECYCLE_SELECT_MATCHES + state: { + value: string + } + } + +export default function HighlightedInput({ + Item, + captureTrigger, + parser, + fetcher, + matcherPredicate, + defaultHightlights, + ...rest +}: HighlightedInputProps) { + const initialState: ReducerContextState = { + list: [], + open: false, + selected: defaultHightlights ?? [], + searchTerm: '', + } + + const [state, dispatch] = useReducer< + Reducer, ReducerContextActionOptions> + >((current: ReducerContextState, action: ReducerContextActionOptions) => { + switch (action.type) { + case ReducerActionType.OPEN_LIST: { + current.open = true + return current + } + + case ReducerActionType.CLOSE_LIST: { + current.open = false + return current + } + + case ReducerActionType.UPDATE_LIST: { + current.list = [...action.state.list] + return current + } + + case ReducerActionType.UPDATE_SEARCH_TERM: { + current.searchTerm = action.state.searchTerm + return current + } + + case ReducerActionType.CLEAR_SEARCH_TERM: { + current.searchTerm = '' + return current + } + + case ReducerActionType.ADD_SELECTED: { + current.selected = [...current.selected, action.state.selected] + current.open = false + return current + } + + case ReducerActionType.RECYCLE_SELECT_MATCHES: { + const matches = action.state.value.match(captureTrigger)?.map((m) => m.replace('@', '')) + current.selected = current.selected.filter((item) => + matches?.includes(item[matcherPredicate as keyof T] as string) + ) + return current + } + + default: + return current + } + }, initialState) + + const textAreaRef = useRef(null) + const highlighterRef = useRef(null) + + function onChange(e: React.ChangeEvent) { + const matches = e.target.value.match(captureTrigger) + const isCapturing = matches ? matches?.length > state.selected.length : false + + if (isCapturing && !state.open) { + dispatch({ type: ReducerActionType.OPEN_LIST }) + } else if (!isCapturing && state.open) { + dispatch({ type: ReducerActionType.CLOSE_LIST }) + } + + if (matches?.length && matches[matches.length - 1]) { + dispatch({ + type: ReducerActionType.UPDATE_SEARCH_TERM, + state: { + searchTerm: matches[matches.length - 1].replace('@', ''), + }, + }) + } else { + dispatch({ + type: ReducerActionType.CLEAR_SEARCH_TERM, + }) + } + + dispatch({ + type: ReducerActionType.RECYCLE_SELECT_MATCHES, + state: { + value: e.target.value, + }, + }) + + if (rest?.onChange) rest?.onChange(e) + } + + async function syncScroll({ currentTarget }: React.UIEvent) { + if (highlighterRef?.current) { + highlighterRef.current.scrollTop = currentTarget.scrollTop + } + } + + async function handleSelect(item: T) { + dispatch({ type: ReducerActionType.ADD_SELECTED, state: { selected: item } }) + } + + // TODO: Move to reducer, and debounce. + async function fetchMore() { + const items = await fetcher(state.searchTerm) + dispatch({ + type: ReducerActionType.UPDATE_LIST, + state: { + list: items, + }, + }) + } + + let highlightedContent = useMemo(() => { + let content = String(rest.value) + if (parser) content = parser(content, state.selected) + return content + }, [rest.value]) + + useEffect(() => { + if (state.open) fetchMore() + }, [state.open, state.searchTerm]) + + return ( +
+
+

+

+