From 449e2acab1d41606896f3645c74fbcaeb31bbe19 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Fri, 1 Aug 2025 12:42:21 +0700 Subject: [PATCH] feat: mentions --- .../src/api/routes/integrations.controller.ts | 53 ++++++++++++++++++- .../src/components/new-launch/editor.tsx | 22 +------- .../new-launch/mention.component.tsx | 47 +++++++++------- .../src/utils/strip.html.validation.ts | 30 ++++++----- .../integrations/integration.repository.ts | 49 ++++++++++++++++- .../integrations/integration.service.ts | 17 +++++- .../database/prisma/posts/posts.repository.ts | 2 +- .../database/prisma/posts/posts.service.ts | 19 +++++-- .../src/database/prisma/schema.prisma | 12 +++++ .../integrations/social/bluesky.provider.ts | 12 +++-- .../integrations/social/linkedin.provider.ts | 6 ++- .../social/social.integrations.interface.ts | 1 + .../src/integrations/social/x.provider.ts | 4 ++ 13 files changed, 210 insertions(+), 64 deletions(-) diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index cf9a6609..d658fa6e 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -37,6 +37,7 @@ import { AuthorizationActions, Sections, } from '@gitroom/backend/services/auth/permissions/permission.exception.class'; +import { uniqBy } from 'lodash'; @ApiTags('Integrations') @Controller('/integrations') @@ -246,11 +247,59 @@ export class IntegrationsController { ) { return this._integrationService.setTimes(org.id, id, body); } + + @Post('/mentions') + async mentions( + @GetOrgFromRequest() org: Organization, + @Body() body: IntegrationFunctionDto + ) { + const getIntegration = await this._integrationService.getIntegrationById( + org.id, + body.id + ); + if (!getIntegration) { + throw new Error('Invalid integration'); + } + + const list = await this._integrationService.getMentions( + getIntegration.providerIdentifier, + body?.data?.query + ); + + let newList = []; + try { + newList = await this.functionIntegration(org, body); + } catch (err) {} + + if (newList.length) { + await this._integrationService.insertMentions( + getIntegration.providerIdentifier, + newList.map((p: any) => ({ + name: p.label, + username: p.id, + image: p.image, + })) + ); + } + + return uniqBy( + [ + ...list.map((p) => ({ + id: p.username, + image: p.image, + label: p.name, + })), + ...newList, + ], + (p) => p.id + ); + } + @Post('/function') async functionIntegration( @GetOrgFromRequest() org: Organization, @Body() body: IntegrationFunctionDto - ) { + ): Promise { const getIntegration = await this._integrationService.getIntegrationById( org.id, body.id @@ -266,8 +315,10 @@ export class IntegrationsController { throw new Error('Invalid provider'); } + // @ts-ignore if (integrationProvider[body.name]) { try { + // @ts-ignore const load = await integrationProvider[body.name]( getIntegration.token, body.data, diff --git a/apps/frontend/src/components/new-launch/editor.tsx b/apps/frontend/src/components/new-launch/editor.tsx index f71a0ae9..017d7b4c 100644 --- a/apps/frontend/src/components/new-launch/editor.tsx +++ b/apps/frontend/src/components/new-launch/editor.tsx @@ -511,23 +511,6 @@ export const Editor: FC<{ [props.value, id] ); - const addLinkedinTag = useCallback((text: string) => { - const id = text.split('(')[1].split(')')[0]; - const name = text.split('[')[1].split(']')[0]; - - editorRef?.current?.editor - .chain() - .focus() - .insertContent({ - type: 'mention', - attrs: { - linkedinId: id, - label: name, - }, - }) - .run(); - }, []); - return (
@@ -559,9 +542,6 @@ export const Editor: FC<{ > {'\uD83D\uDE00'}
- {identifier === 'linkedin' || identifier === 'linkedin-page' ? ( - - ) : null}
{ }, })); + if (props?.stop) { + return null; + } + return (
{props?.items?.none ? ( @@ -84,22 +88,26 @@ const MentionList: FC = (props: any) => { Loading...
) : props?.items ? ( - props.items.map((item: any, index: any) => ( - - )) + props.items.length === 0 ? ( +
No results found
+ ) : ( + props.items.map((item: any, index: any) => ( + + )) + ) ) : (
Loading...
)} @@ -142,11 +150,12 @@ export const suggestion = ( return { items: async ({ query }: { query: string }) => { if (!query || query.length < 2) { + component.updateProps({ loading: true, stop: true }); return []; } try { - component.updateProps({ loading: true }); + component.updateProps({ loading: true, stop: false }); const result = await debouncedLoadList(query); console.log(result); return result; @@ -169,7 +178,7 @@ export const suggestion = ( }, editor: props.editor, }); - component.updateProps({ ...props, loading: true }); + component.updateProps({ ...props, loading: true, stop: false }); updatePosition(props.editor, component.element); }, onStart: (props: any) => { @@ -212,7 +221,7 @@ export const suggestion = ( newQuery.length >= 2 && (!props.items || props.items.length === 0); - component.updateProps({ ...props, loading: false }); + component.updateProps({ ...props, loading: false, stop: false }); if (!props.clientRect) { return; diff --git a/libraries/helpers/src/utils/strip.html.validation.ts b/libraries/helpers/src/utils/strip.html.validation.ts index 4b76ab75..5ca9d725 100644 --- a/libraries/helpers/src/utils/strip.html.validation.ts +++ b/libraries/helpers/src/utils/strip.html.validation.ts @@ -135,7 +135,8 @@ export const stripHtmlValidation = ( type: 'none' | 'normal' | 'markdown' | 'html', value: string, replaceBold = false, - none = false + none = false, + convertMentionFunction?: (idOrHandle: string, name: string) => string, ): string => { if (type === 'html') { return striptags(value, [ @@ -171,18 +172,16 @@ export const stripHtmlValidation = ( } if (replaceBold) { - const processedHtml = convertLinkedinMention( + const processedHtml = convertMention( convertToAscii( html - .replace(/
    /, "\n
      ") - .replace(/<\/ul>\n/, "
    ") - .replace( - /([.\s\S]*?)<\/li.*?>/gm, - (match, p1) => { + .replace(/
      /, '\n
        ') + .replace(/<\/ul>\n/, '
      ') + .replace(/([.\s\S]*?)<\/li.*?>/gm, (match, p1) => { return `
    • - ${p1.replace(/\n/gm, '')}\n

    • `; - } - ) - ) + }) + ), + convertMentionFunction ); return striptags(processedHtml, ['h1', 'h2', 'h3']); @@ -192,11 +191,18 @@ export const stripHtmlValidation = ( return striptags(html, ['ul', 'li', 'h1', 'h2', 'h3']); }; -export const convertLinkedinMention = (value: string) => { +export const convertMention = ( + value: string, + process?: (idOrHandle: string, name: string) => string +) => { + if (!process) { + return value; + } + return value.replace( /(.*?)<\/span>/gi, (match, id, name) => { - return `@[${name.replace('@', '')}](${id})`; + return `` + process(id, name) + ``; } ); }; diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index c6541cdc..b432f246 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -15,9 +15,56 @@ export class IntegrationRepository { private _posts: PrismaRepository<'post'>, private _plugs: PrismaRepository<'plugs'>, private _exisingPlugData: PrismaRepository<'exisingPlugData'>, - private _customers: PrismaRepository<'customer'> + private _customers: PrismaRepository<'customer'>, + private _mentions: PrismaRepository<'mentions'> ) {} + getMentions(platform: string, q: string) { + return this._mentions.model.mentions.findMany({ + where: { + platform, + OR: [ + { + name: { + contains: q, + mode: 'insensitive', + }, + }, + { + username: { + contains: q, + mode: 'insensitive', + }, + }, + ], + }, + orderBy: { + name: 'asc', + }, + take: 100, + select: { + name: true, + username: true, + image: true, + }, + }); + } + + insertMentions( + platform: string, + mentions: { name: string; username: string; image: string }[] + ) { + return this._mentions.model.mentions.createMany({ + data: mentions.map((mention) => ({ + platform, + name: mention.name, + username: mention.username, + image: mention.image, + })), + skipDuplicates: true, + }); + } + updateProviderSettings(org: string, id: string, settings: string) { return this._integration.model.integration.update({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index 2d7db625..d7519354 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -46,6 +46,17 @@ export class IntegrationService { return true; } + getMentions(platform: string, q: string) { + return this._integrationRepository.getMentions(platform, q); + } + + insertMentions( + platform: string, + mentions: { name: string; username: string; image: string }[] + ) { + return this._integrationRepository.insertMentions(platform, mentions); + } + async setTimes( orgId: string, integrationId: string, @@ -163,7 +174,11 @@ export class IntegrationService { await this.informAboutRefreshError(orgId, integration); } - async informAboutRefreshError(orgId: string, integration: Integration, err = '') { + async informAboutRefreshError( + orgId: string, + integration: Integration, + err = '' + ) { await this._notificationService.inAppNotification( orgId, `Could not refresh your ${integration.providerIdentifier} channel ${err}`, diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 8d4b2287..f6db7693 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -394,7 +394,7 @@ export class PostsRepository { where: { orgId: orgId, name: { - in: tags.map((tag) => tag.label).filter(f => f), + in: tags.map((tag) => tag.label).filter((f) => f), }, }, }); diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 7bb42cb8..620d54c5 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -378,7 +378,9 @@ export class PostsService { return post; } - const ids = (extract || []).map((e) => e.replace('(post:', '').replace(')', '')); + const ids = (extract || []).map((e) => + e.replace('(post:', '').replace(')', '') + ); const urls = await this._postRepository.getPostUrls(orgId, ids); const newPlainText = ids.reduce((acc, value) => { const findUrl = urls?.find?.((u) => u.id === value)?.releaseURL || ''; @@ -467,7 +469,13 @@ export class PostsService { await Promise.all( (newPosts || []).map(async (p) => ({ id: p.id, - message: stripHtmlValidation(getIntegration.editor, p.content, true), + message: stripHtmlValidation( + getIntegration.editor, + p.content, + true, + false, + getIntegration.mentionFormat + ), settings: JSON.parse(p.settings || '{}'), media: await this.updateMedia( p.id, @@ -535,7 +543,12 @@ export class PostsService { throw err; } - throw new BadBody(integration.providerIdentifier, JSON.stringify(err), {} as any, ''); + throw new BadBody( + integration.providerIdentifier, + JSON.stringify(err), + {} as any, + '' + ); } } diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index b63c40a3..6027caa2 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -658,6 +658,18 @@ model Errors { @@index([createdAt]) } +model Mentions { + name String + username String + platform String + image String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([name, username, platform, image]) + @@index([createdAt]) +} + enum OrderStatus { PENDING ACCEPTED diff --git a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts index 18d526f0..589103c4 100644 --- a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts @@ -519,13 +519,17 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider { }); const list = await agent.searchActors({ - q: d.query + q: d.query, }); - return list.data.actors.map(p => ({ + return list.data.actors.map((p) => ({ label: p.displayName, id: p.handle, - image: p.avatar - })) + image: p.avatar, + })); + } + + mentionFormat(idOrHandle: string, name: string) { + return `@${idOrHandle}`; } } diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index cb484979..98c2aba4 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -716,7 +716,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { }); } - async mention(token: string, data: { query: string }) { + override async mention(token: string, data: { query: string }) { const { elements } = await ( await fetch( `https://api.linkedin.com/v2/organizations?q=vanityName&vanityName=${encodeURIComponent( @@ -739,4 +739,8 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { image: p.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0]?.identifier || '', })); } + + mentionFormat(idOrHandle: string, name: string) { + return `@[${name.replace('@', '')}](urn:li:organization:${idOrHandle})`; + } } diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts index cd14254f..20add654 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -135,4 +135,5 @@ export interface SocialProvider mention?: ( token: string, data: { query: string }, id: string, integration: Integration ) => Promise<{ id: string; label: string; image: string }[] | {none: true}>; + mentionFormat?(idOrHandle: string, name: string): string; } diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index 36c18669..87fe9972 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -526,4 +526,8 @@ export class XProvider extends SocialAbstract implements SocialProvider { } return []; } + + mentionFormat(idOrHandle: string, name: string) { + return `@${idOrHandle}`; + } }