feat: mentions

This commit is contained in:
Nevo David 2025-08-01 12:42:21 +07:00
parent 2756f28d72
commit 449e2acab1
13 changed files with 210 additions and 64 deletions

View File

@ -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<any> {
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,

View File

@ -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 (
<div>
<div className="relative bg-bigStrip" id={id}>
@ -559,9 +542,6 @@ export const Editor: FC<{
>
{'\uD83D\uDE00'}
</div>
{identifier === 'linkedin' || identifier === 'linkedin-page' ? (
<LinkedinCompanyPop addText={addLinkedinTag} />
) : null}
<div className="relative">
<div className="absolute z-[200] top-[35px] -start-[50px]">
<EmojiPicker
@ -696,7 +676,7 @@ export const OnlyEditor = forwardRef<
}
try {
const load = await fetch('/integrations/function', {
const load = await fetch('/integrations/mentions', {
method: 'POST',
body: JSON.stringify({
name: 'mention',

View File

@ -73,6 +73,10 @@ const MentionList: FC = (props: any) => {
},
}));
if (props?.stop) {
return null;
}
return (
<div className="dropdown-menu bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto p-2">
{props?.items?.none ? (
@ -84,22 +88,26 @@ const MentionList: FC = (props: any) => {
Loading...
</div>
) : props?.items ? (
props.items.map((item: any, index: any) => (
<button
className={`flex gap-[10px] w-full p-2 text-left rounded hover:bg-gray-100 ${
index === selectedIndex ? 'bg-blue-100' : ''
}`}
key={index}
onClick={() => selectItem(index)}
>
<img
src={item.image}
alt={item.label}
className="w-[30px] h-[30px] rounded-full object-cover"
/>
<div className="flex-1 text-gray-800">{item.label}</div>
</button>
))
props.items.length === 0 ? (
<div className="p-2 text-gray-500 text-center">No results found</div>
) : (
props.items.map((item: any, index: any) => (
<button
className={`flex gap-[10px] w-full p-2 text-left rounded hover:bg-gray-100 ${
index === selectedIndex ? 'bg-blue-100' : ''
}`}
key={index}
onClick={() => selectItem(index)}
>
<img
src={item.image}
alt={item.label}
className="w-[30px] h-[30px] rounded-full object-cover"
/>
<div className="flex-1 text-gray-800">{item.label}</div>
</button>
))
)
) : (
<div className="p-2 text-gray-500 text-center">Loading...</div>
)}
@ -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;

View File

@ -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(/<ul>/, "\n<ul>")
.replace(/<\/ul>\n/, "</ul>")
.replace(
/<li.*?>([.\s\S]*?)<\/li.*?>/gm,
(match, p1) => {
.replace(/<ul>/, '\n<ul>')
.replace(/<\/ul>\n/, '</ul>')
.replace(/<li.*?>([.\s\S]*?)<\/li.*?>/gm, (match, p1) => {
return `<li><p>- ${p1.replace(/\n/gm, '')}\n</p></li>`;
}
)
)
})
),
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.*?data-mention-id="(.*?)".*?>(.*?)<\/span>/gi,
(match, id, name) => {
return `@[${name.replace('@', '')}](${id})`;
return `<span>` + process(id, name) + `</span>`;
}
);
};

View File

@ -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: {

View File

@ -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}`,

View File

@ -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),
},
},
});

View File

@ -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,
''
);
}
}

View File

@ -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

View File

@ -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}`;
}
}

View File

@ -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})`;
}
}

View File

@ -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;
}

View File

@ -526,4 +526,8 @@ export class XProvider extends SocialAbstract implements SocialProvider {
}
return [];
}
mentionFormat(idOrHandle: string, name: string) {
return `@${idOrHandle}`;
}
}