Merge pull request #911 from gitroomhq/feat/mention

Better mentioning functionality
This commit is contained in:
Nevo David 2025-08-01 16:37:48 +07:00 committed by GitHub
commit e915ec30d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 751 additions and 129 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,
})).filter((f: any) => f.name)
);
}
return uniqBy(
[
...list.map((p) => ({
id: p.username,
image: p.image,
label: p.name,
})),
...newList,
],
(p) => p.id
).filter(f => f.label && f.image && f.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

@ -626,3 +626,44 @@ html[dir='rtl'] [dir='ltr'] {
.mantine-Overlay-root {
background: rgba(65, 64, 66, 0.3) !important;
}
.dropdown-menu {
@apply shadow-menu;
background: var(--new-bgColorInner);
border: 1px solid var(--new-bgLineColor);
border-radius: 18px;
display: flex;
flex-direction: column;
overflow: auto;
position: relative;
button {
align-items: center;
background-color: transparent;
display: flex;
text-align: left;
width: 100%;
padding: 10px;
&:hover,
&:hover.is-selected {
background-color: var(--new-bgLineColor);
}
}
}
.tiptap {
:first-child {
margin-top: 0;
}
.mention {
background-color: var(--purple-light);
border-radius: 0.4rem;
box-decoration-break: clone;
color: #ae8afc;
&::after {
content: '\200B';
}
}
}

View File

@ -50,6 +50,12 @@ import { BulletList, ListItem } from '@tiptap/extension-list';
import { Bullets } from '@gitroom/frontend/components/new-launch/bullets.component';
import Heading from '@tiptap/extension-heading';
import { HeadingComponent } from '@gitroom/frontend/components/new-launch/heading.component';
import Mention from '@tiptap/extension-mention';
import { suggestion } from '@gitroom/frontend/components/new-launch/mention.component';
import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function';
import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useDebouncedCallback } from 'use-debounce';
const InterceptBoldShortcut = Extension.create({
name: 'preventBoldWithUnderline',
@ -79,51 +85,6 @@ const InterceptUnderlineShortcut = Extension.create({
},
});
const Span = Node.create({
name: 'mention',
inline: true,
group: 'inline',
selectable: false,
atom: true,
addAttributes() {
return {
linkedinId: {
default: null,
},
label: {
default: '',
},
};
},
parseHTML() {
return [
{
tag: 'span[data-linkedin-id]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(
// Exclude linkedinId from HTMLAttributes to avoid duplication
Object.fromEntries(
Object.entries(HTMLAttributes).filter(([key]) => key !== 'linkedinId')
),
{
'data-linkedin-id': HTMLAttributes.linkedinId,
class: 'mention',
}
),
`@${HTMLAttributes.label}`,
];
},
});
export const EditorWrapper: FC<{
totalPosts: number;
value: string;
@ -550,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}>
@ -598,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
@ -717,6 +658,43 @@ export const OnlyEditor = forwardRef<
paste?: (event: ClipboardEvent | File[]) => void;
}
>(({ editorType, value, onChange, paste }, ref) => {
const fetch = useFetch();
const { internal } = useLaunchStore(
useShallow((state) => ({
internal: state.internal.find((p) => p.integration.id === state.current),
}))
);
const loadList = useCallback(
async (query: string) => {
if (query.length < 2) {
return [];
}
if (!internal?.integration.id) {
return [];
}
try {
const load = await fetch('/integrations/mentions', {
method: 'POST',
body: JSON.stringify({
name: 'mention',
id: internal.integration.id,
data: { query },
}),
});
const result = await load.json();
return result;
} catch (error) {
console.error('Error loading mentions:', error);
return [];
}
},
[internal, fetch]
);
const editor = useEditor({
extensions: [
Document,
@ -726,9 +704,28 @@ export const OnlyEditor = forwardRef<
Bold,
InterceptBoldShortcut,
InterceptUnderlineShortcut,
Span,
BulletList,
ListItem,
...(internal?.integration?.id
? [
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
renderHTML({ options, node }) {
return [
'span',
mergeAttributes(options.HTMLAttributes, {
'data-mention-id': node.attrs.id || '',
'data-mention-label': node.attrs.label || '',
}),
`@${node.attrs.label}`,
];
},
suggestion: suggestion(loadList),
}),
]
: []),
Heading.configure({
levels: [1, 2, 3],
}),

View File

@ -0,0 +1,248 @@
import React, { FC, useEffect, useImperativeHandle, useState } from 'react';
import { computePosition, flip, shift } from '@floating-ui/dom';
import { posToDOMRect, ReactRenderer } from '@tiptap/react';
import { timer } from '@gitroom/helpers/utils/timer';
// Debounce utility for TipTap
const debounce = <T extends any[]>(
func: (...args: any[]) => Promise<T>,
wait: number
) => {
let timeout: NodeJS.Timeout;
return (...args: any[]): Promise<T> => {
clearTimeout(timeout);
return new Promise((resolve) => {
timeout = setTimeout(async () => {
try {
const result = await func(...args);
resolve(result);
} catch (error) {
console.error('Debounced function error:', error);
resolve([] as T);
}
}, wait);
});
};
};
const MentionList: FC = (props: any) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index: number) => {
const item = props.items[index];
if (item) {
props.command(item);
}
};
const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length
);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(props.ref, () => ({
onKeyDown: ({ event }: { event: any }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
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 ? (
<div className="flex items-center justify-center p-2 text-gray-500">
We don't have autocomplete for this social media
</div>
) : props?.loading ? (
<div className="flex items-center justify-center p-2 text-gray-500">
Loading...
</div>
) : props?.items ? (
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={item.id || 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>
)}
</div>
);
};
const updatePosition = (editor: any, element: any) => {
const virtualElement = {
getBoundingClientRect: () =>
posToDOMRect(
editor.view,
editor.state.selection.from,
editor.state.selection.to
),
};
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()],
}).then(({ x, y, strategy }) => {
element.style.width = 'max-content';
element.style.position = strategy;
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.style.zIndex = '1000';
});
};
export const suggestion = (
loadList: (
query: string
) => Promise<{ image: string; label: string; id: string }[]>
) => {
// Create debounced version of loadList once
const debouncedLoadList = debounce(loadList, 500);
let component: any;
return {
items: async ({ query }: { query: string }) => {
if (!query || query.length < 2) {
component.updateProps({ loading: true, stop: true });
return [];
}
try {
component.updateProps({ loading: true, stop: false });
const result = await debouncedLoadList(query);
return result;
} catch (error) {
return [];
}
},
render: () => {
let currentQuery = '';
let isLoadingQuery = false;
return {
onBeforeStart: (props: any) => {
component = new ReactRenderer(MentionList, {
props: {
...props,
loading: true,
},
editor: props.editor,
});
component.updateProps({ ...props, loading: true, stop: false });
updatePosition(props.editor, component.element);
},
onStart: (props: any) => {
currentQuery = props.query || '';
isLoadingQuery = currentQuery.length >= 2;
if (!props.clientRect) {
return;
}
component.element.style.position = 'absolute';
component.element.style.zIndex = '1000';
const container =
document.querySelector('.mantine-Paper-root') || document.body;
container.appendChild(component.element);
updatePosition(props.editor, component.element);
component.updateProps({ ...props, loading: true });
},
onUpdate(props: any) {
const newQuery = props.query || '';
const queryChanged = newQuery !== currentQuery;
currentQuery = newQuery;
// If query changed and is valid, we're loading until results come in
if (queryChanged && newQuery.length >= 2) {
isLoadingQuery = true;
}
// If we have results, we're no longer loading
if (props.items && props.items.length > 0) {
isLoadingQuery = false;
}
// Show loading if we have a valid query but no results yet
const shouldShowLoading =
isLoadingQuery &&
newQuery.length >= 2 &&
(!props.items || props.items.length === 0);
component.updateProps({ ...props, loading: false, stop: false });
if (!props.clientRect) {
return;
}
updatePosition(props.editor, component.element);
},
onKeyDown(props: any) {
if (props.event.key === 'Escape') {
component.destroy();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
component.element.remove();
component.destroy();
},
};
},
};
};

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

View File

@ -15,9 +15,59 @@ 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 }[]
) {
if (mentions.length === 0) {
return [];
}
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

@ -1,5 +1,6 @@
import { timer } from '@gitroom/helpers/utils/timer';
import { concurrencyService } from '@gitroom/helpers/utils/concurrency.service';
import { Integration } from '@prisma/client';
export class RefreshToken {
constructor(
@ -31,6 +32,10 @@ export abstract class SocialAbstract {
return undefined;
}
public async mention(token: string, d: { query: string }, id: string, integration: Integration): Promise<{ id: string; label: string; image: string }[] | {none: true}> {
return {none: true};
}
async runInConcurrent<T>(func: (...args: any[]) => Promise<T>) {
const value = await concurrencyService<any>(this.identifier.split('-')[0], async () => {
try {

View File

@ -9,13 +9,13 @@ import {
RefreshToken,
SocialAbstract,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import {
BskyAgent,
RichText,
import {
BskyAgent,
RichText,
AppBskyEmbedVideo,
AppBskyVideoDefs,
AtpAgent,
BlobRef
BlobRef,
} from '@atproto/api';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
@ -59,16 +59,19 @@ async function reduceImageBySize(url: string, maxSizeKB = 976) {
}
}
async function uploadVideo(agent: AtpAgent, videoPath: string): Promise<AppBskyEmbedVideo.Main> {
const { data: serviceAuth } = await agent.com.atproto.server.getServiceAuth(
{
aud: `did:web:${agent.dispatchUrl.host}`,
lxm: "com.atproto.repo.uploadBlob",
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
},
);
async function uploadVideo(
agent: AtpAgent,
videoPath: string
): Promise<AppBskyEmbedVideo.Main> {
const { data: serviceAuth } = await agent.com.atproto.server.getServiceAuth({
aud: `did:web:${agent.dispatchUrl.host}`,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
});
async function downloadVideo(url: string): Promise<{ video: Buffer, size: number }> {
async function downloadVideo(
url: string
): Promise<{ video: Buffer; size: number }> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch video: ${response.statusText}`);
@ -81,35 +84,37 @@ async function uploadVideo(agent: AtpAgent, videoPath: string): Promise<AppBskyE
const video = await downloadVideo(videoPath);
console.log("Downloaded video", videoPath, video.size);
const uploadUrl = new URL("https://video.bsky.app/xrpc/app.bsky.video.uploadVideo");
uploadUrl.searchParams.append("did", agent.session!.did);
uploadUrl.searchParams.append("name", videoPath.split("/").pop()!);
console.log('Downloaded video', videoPath, video.size);
const uploadUrl = new URL(
'https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'
);
uploadUrl.searchParams.append('did', agent.session!.did);
uploadUrl.searchParams.append('name', videoPath.split('/').pop()!);
const uploadResponse = await fetch(uploadUrl, {
method: "POST",
method: 'POST',
headers: {
Authorization: `Bearer ${serviceAuth.token}`,
"Content-Type": "video/mp4",
"Content-Length": video.size.toString(),
'Content-Type': 'video/mp4',
'Content-Length': video.size.toString(),
},
body: video.video
body: video.video,
});
const jobStatus = (await uploadResponse.json()) as AppBskyVideoDefs.JobStatus;
console.log("JobId:", jobStatus.jobId);
console.log('JobId:', jobStatus.jobId);
let blob: BlobRef | undefined = jobStatus.blob;
const videoAgent = new AtpAgent({ service: "https://video.bsky.app" });
const videoAgent = new AtpAgent({ service: 'https://video.bsky.app' });
while (!blob) {
const { data: status } = await videoAgent.app.bsky.video.getJobStatus(
{ jobId: jobStatus.jobId },
);
const { data: status } = await videoAgent.app.bsky.video.getJobStatus({
jobId: jobStatus.jobId,
});
console.log(
"Status:",
'Status:',
status.jobStatus.state,
status.jobStatus.progress || "",
status.jobStatus.progress || ''
);
if (status.jobStatus.blob) {
blob = status.jobStatus.blob;
@ -117,11 +122,11 @@ async function uploadVideo(agent: AtpAgent, videoPath: string): Promise<AppBskyE
// wait a second
await new Promise((resolve) => setTimeout(resolve, 1000));
}
console.log("posting video...");
console.log('posting video...');
return {
$type: "app.bsky.embed.video",
$type: 'app.bsky.embed.video',
video: blob,
} satisfies AppBskyEmbedVideo.Main;
}
@ -243,8 +248,10 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
const cidUrl = [] as { cid: string; url: string; rev: string }[];
for (const post of postDetails) {
// Separate images and videos
const imageMedia = post.media?.filter((p) => p.path.indexOf('mp4') === -1) || [];
const videoMedia = post.media?.filter((p) => p.path.indexOf('mp4') !== -1) || [];
const imageMedia =
post.media?.filter((p) => p.path.indexOf('mp4') === -1) || [];
const videoMedia =
post.media?.filter((p) => p.path.indexOf('mp4') !== -1) || [];
// Upload images
const images = await Promise.all(
@ -313,7 +320,11 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
if (postDetails?.[0]?.settings?.active_thread_finisher) {
const rt = new RichText({
text: stripHtmlValidation('normal', postDetails?.[0]?.settings?.thread_finisher, true),
text: stripHtmlValidation(
'normal',
postDetails?.[0]?.settings?.thread_finisher,
true
),
});
await rt.detectFacets(agent);
@ -487,4 +498,38 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
return true;
}
override async mention(
token: string,
d: { query: string },
id: string,
integration: Integration
) {
const body = JSON.parse(
AuthService.fixedDecryption(integration.customInstanceDetails!)
);
const agent = new BskyAgent({
service: body.service,
});
await agent.login({
identifier: body.identifier,
password: body.password,
});
const list = await agent.searchActors({
q: d.query,
});
return list.data.actors.map((p) => ({
label: p.displayName,
id: p.handle,
image: p.avatar,
}));
}
mentionFormat(idOrHandle: string, name: string) {
return `@${idOrHandle}`;
}
}

View File

@ -715,4 +715,32 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
},
});
}
override async mention(token: string, data: { query: string }) {
const { elements } = await (
await this.fetch(
`https://api.linkedin.com/v2/organizations?q=vanityName&vanityName=${encodeURIComponent(
data.query
)}&projection=(elements*(id,localizedName,logoV2(original~:playableStreams)))`,
{
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202504',
Authorization: `Bearer ${token}`,
},
}
)
).json();
return elements.map((p: any) => ({
id: String(p.id),
label: p.localizedName,
image: p.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0]?.identifier || '',
}));
}
mentionFormat(idOrHandle: string, name: string) {
return `@[${name.replace('@', '')}](urn:li:organization:${idOrHandle})`;
}
}

View File

@ -132,4 +132,8 @@ export interface SocialProvider
externalUrl?: (
url: string
) => Promise<{ client_id: string; client_secret: string }>;
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

@ -13,6 +13,7 @@ import { capitalize, chunk } from 'lodash';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { Integration } from '@prisma/client';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
import { TwitterApi } from 'twitter-api-v2';
export class ThreadsProvider extends SocialAbstract implements SocialProvider {
identifier = 'threads';
@ -23,6 +24,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
'threads_content_publish',
'threads_manage_replies',
'threads_manage_insights',
// 'threads_profile_discovery',
];
editor = 'normal' as const;
@ -413,8 +415,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
{
id: makeId(10),
media: [],
message:
postDetails?.[0]?.settings?.thread_finisher,
message: postDetails?.[0]?.settings?.thread_finisher,
settings: {},
},
lastReplyId,
@ -526,4 +527,29 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
return false;
}
// override async mention(
// token: string,
// data: { query: string },
// id: string,
// integration: Integration
// ) {
// const p = await (
// await fetch(
// `https://graph.threads.net/v1.0/profile_lookup?username=${data.query}&access_token=${integration.token}`
// )
// ).json();
//
// return [
// {
// id: String(p.id),
// label: p.name,
// image: p.profile_picture_url,
// },
// ];
// }
//
// mentionFormat(idOrHandle: string, name: string) {
// return `@${idOrHandle}`;
// }
}

View File

@ -315,7 +315,10 @@ export class XProvider extends SocialAbstract implements SocialProvider {
const media_ids = (uploadAll[post.id] || []).filter((f) => f);
// @ts-ignore
const { data }: { data: { id: string } } = await this.runInConcurrent( async () => client.v2.tweet({
const { data }: { data: { id: string } } = await this.runInConcurrent(
async () =>
// @ts-ignore
client.v2.tweet({
...(!postDetails?.[0]?.settings?.who_can_reply_post ||
postDetails?.[0]?.settings?.who_can_reply_post === 'everyone'
? {}
@ -492,4 +495,39 @@ export class XProvider extends SocialAbstract implements SocialProvider {
}
return [];
}
override async mention(token: string, d: { query: string }) {
const [accessTokenSplit, accessSecretSplit] = token.split(':');
const client = new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
accessToken: accessTokenSplit,
accessSecret: accessSecretSplit,
});
try {
const data = await client.v2.userByUsername(d.query, {
'user.fields': ['username', 'name', 'profile_image_url'],
});
if (!data?.data?.username) {
return [];
}
return [
{
id: data.data.username,
image: data.data.profile_image_url,
label: data.data.name,
},
];
} catch (err) {
console.log(err);
}
return [];
}
mentionFormat(idOrHandle: string, name: string) {
return `@${idOrHandle}`;
}
}

View File

@ -86,12 +86,14 @@
"@tiptap/extension-heading": "^3.0.7",
"@tiptap/extension-history": "^3.0.7",
"@tiptap/extension-list": "^3.0.7",
"@tiptap/extension-mention": "^3.0.7",
"@tiptap/extension-paragraph": "^3.0.6",
"@tiptap/extension-text": "^3.0.6",
"@tiptap/extension-underline": "^3.0.6",
"@tiptap/pm": "^3.0.6",
"@tiptap/react": "^3.0.6",
"@tiptap/starter-kit": "^3.0.6",
"@tiptap/suggestion": "^3.0.7",
"@types/bcrypt": "^5.0.2",
"@types/concat-stream": "^2.0.3",
"@types/facebook-nodejs-business-sdk": "^20.0.2",
@ -207,11 +209,12 @@
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "3.4.17",
"tailwindcss-rtl": "^0.9.0",
"tippy.js": "^6.3.7",
"tldts": "^6.1.47",
"transloadit": "^3.0.2",
"tslib": "^2.3.0",
"tweetnacl": "^1.0.3",
"twitter-api-v2": "^1.23.2",
"twitter-api-v2": "^1.24.0",
"twitter-text": "^3.1.0",
"use-debounce": "^10.0.0",
"utf-8-validate": "^5.0.10",

View File

@ -138,6 +138,9 @@ importers:
'@tiptap/extension-list':
specifier: ^3.0.7
version: 3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6)
'@tiptap/extension-mention':
specifier: ^3.0.7
version: 3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6)(@tiptap/suggestion@3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6))
'@tiptap/extension-paragraph':
specifier: ^3.0.6
version: 3.0.6(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))
@ -156,6 +159,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^3.0.6
version: 3.0.6
'@tiptap/suggestion':
specifier: ^3.0.7
version: 3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6)
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2
@ -501,6 +507,9 @@ importers:
tailwindcss-rtl:
specifier: ^0.9.0
version: 0.9.0
tippy.js:
specifier: ^6.3.7
version: 6.3.7
tldts:
specifier: ^6.1.47
version: 6.1.86
@ -514,7 +523,7 @@ importers:
specifier: ^1.0.3
version: 1.0.3
twitter-api-v2:
specifier: ^1.23.2
specifier: ^1.24.0
version: 1.24.0
twitter-text:
specifier: ^3.1.0
@ -5921,6 +5930,13 @@ packages:
'@tiptap/core': ^3.0.7
'@tiptap/pm': ^3.0.7
'@tiptap/extension-mention@3.0.7':
resolution: {integrity: sha512-PHEx6NdmarjvPPvTd8D9AqK1JIaVYTsnQLxJUERakOLzujgUCToZ7FpMQDhPj97YLvF0t3jeyjZOPmFuj5kw4w==}
peerDependencies:
'@tiptap/core': ^3.0.7
'@tiptap/pm': ^3.0.7
'@tiptap/suggestion': ^3.0.7
'@tiptap/extension-ordered-list@3.0.6':
resolution: {integrity: sha512-9SbeGO6kGKoX8GwhaSgpFNCGxlzfGu5otK5DE+Unn5F8/gIYGBJkXTZE1tj8XzPmH6lWhmKJQPudANnW6yuKqg==}
peerDependencies:
@ -5966,6 +5982,12 @@ packages:
'@tiptap/starter-kit@3.0.6':
resolution: {integrity: sha512-7xqcx5hwa+o0J6vpqJRSQNxKHOO6/vSwwicmaHxZ4zdGtlUjJrdreeYaaUpCf0wvpBT1DAQlRnancuD6DJkkPg==}
'@tiptap/suggestion@3.0.7':
resolution: {integrity: sha512-HSMvzAejdvcnVaRZOhXJWAvQqaQs3UYDZaA0ZnzgiJ/sNSbtTyn9XVbX6MfVNYrbtBua4iKaXuJwp6CP0KdHQg==}
peerDependencies:
'@tiptap/core': ^3.0.7
'@tiptap/pm': ^3.0.7
'@tokenizer/inflate@0.2.7':
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
engines: {node: '>=18'}
@ -14277,6 +14299,9 @@ packages:
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
engines: {node: '>=14.0.0'}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
tlds@1.259.0:
resolution: {integrity: sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==}
hasBin: true
@ -22336,6 +22361,12 @@ snapshots:
'@tiptap/core': 3.0.6(@tiptap/pm@3.0.6)
'@tiptap/pm': 3.0.6
'@tiptap/extension-mention@3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6)(@tiptap/suggestion@3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6))':
dependencies:
'@tiptap/core': 3.0.6(@tiptap/pm@3.0.6)
'@tiptap/pm': 3.0.6
'@tiptap/suggestion': 3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6)
'@tiptap/extension-ordered-list@3.0.6(@tiptap/extension-list@3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6))':
dependencies:
'@tiptap/extension-list': 3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6)
@ -22424,6 +22455,11 @@ snapshots:
'@tiptap/extensions': 3.0.6(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6)
'@tiptap/pm': 3.0.6
'@tiptap/suggestion@3.0.7(@tiptap/core@3.0.6(@tiptap/pm@3.0.6))(@tiptap/pm@3.0.6)':
dependencies:
'@tiptap/core': 3.0.6(@tiptap/pm@3.0.6)
'@tiptap/pm': 3.0.6
'@tokenizer/inflate@0.2.7':
dependencies:
debug: 4.4.1(supports-color@5.5.0)
@ -33414,6 +33450,10 @@ snapshots:
tinyspy@3.0.2: {}
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
tlds@1.259.0: {}
tldts-core@6.1.86: {}