diff --git a/apps/frontend/src/components/launches/add.edit.model.tsx b/apps/frontend/src/components/launches/add.edit.model.tsx index 2d577eb3..ec8ac507 100644 --- a/apps/frontend/src/components/launches/add.edit.model.tsx +++ b/apps/frontend/src/components/launches/add.edit.model.tsx @@ -39,6 +39,7 @@ import { postSelector } from '@gitroom/frontend/components/post-url-selector/pos import { UpDownArrow } from '@gitroom/frontend/components/launches/up.down.arrow'; import { DatePicker } from '@gitroom/frontend/components/launches/helpers/date.picker'; import { arrayMoveImmutable } from 'array-move'; +import { linkedinCompany } from '@gitroom/frontend/components/launches/helpers/linkedin.component'; export const AddEditModal: FC<{ date: dayjs.Dayjs; diff --git a/apps/frontend/src/components/launches/helpers/linkedin.component.tsx b/apps/frontend/src/components/launches/helpers/linkedin.component.tsx new file mode 100644 index 00000000..d1b2691c --- /dev/null +++ b/apps/frontend/src/components/launches/helpers/linkedin.component.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { EventEmitter } from 'events'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; +import { + executeCommand, + ExecuteState, + ICommand, + selectWord, + TextAreaTextApi, +} from '@uiw/react-md-editor'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { Input } from '@gitroom/react/form/input'; +import { Button } from '@gitroom/react/form/button'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import dayjs from 'dayjs'; + +const postUrlEmitter = new EventEmitter(); + +export const ShowLinkedinCompany = () => { + const [showPostSelector, setShowPostSelector] = useState(false); + const [id, setId] = useState(''); + const [callback, setCallback] = useState<{ + callback: (tag: string) => void; + // eslint-disable-next-line @typescript-eslint/no-empty-function + } | null>({ callback: (tag: string) => {} } as any); + + useEffect(() => { + postUrlEmitter.on( + 'show', + (params: { id: string; callback: (url: string) => void }) => { + setCallback(params); + setId(params.id); + setShowPostSelector(true); + } + ); + + return () => { + setShowPostSelector(false); + setCallback(null); + setId(''); + postUrlEmitter.removeAllListeners(); + }; + }, []); + + const close = useCallback(() => { + setShowPostSelector(false); + setCallback(null); + setId(''); + }, []); + + if (!showPostSelector) { + return <>; + } + + return ( + + ); +}; + +export const showPostSelector = (id: string) => { + return new Promise((resolve) => { + postUrlEmitter.emit('show', { + id, + callback: (tag: string) => { + resolve(tag); + }, + }); + }); +}; + +export const LinkedinCompany: FC<{ + onClose: () => void; + onSelect: (tag: string) => void; + id: string; +}> = (props) => { + const { onClose, onSelect, id } = props; + const fetch = useFetch(); + const [company, setCompany] = useState(null); + + const getCompany = async () => { + const {options} = await ( + await fetch('/integrations/function', { + method: 'POST', + body: JSON.stringify({ + id, + name: 'company', + data: { + url: company, + }, + }), + }) + ).json(); + + onSelect(options.value); + onClose(); + }; + + return ( +
+
+
+
+ +
+ +
+
+ setCompany(e.target.value)} + placeholder="https://www.linkedin.com/company/gitroom" + /> + +
+
+
+ ); +}; + +export const linkedinCompany = (identifier: string, id: string): ICommand[] => { + if (identifier !== 'linkedin') { + return []; + } + + return [ + { + name: 'linkedinCompany', + keyCommand: 'linkedinCompany', + shortcuts: 'ctrlcmd+p', + prefix: '', + suffix: '', + buttonProps: { + 'aria-label': 'Add Post Url', + title: 'Add Post Url', + }, + icon: ( + + + + ), + execute: async (state: ExecuteState, api: TextAreaTextApi) => { + const newSelectionRange = selectWord({ + text: state.text, + selection: state.selection, + prefix: state.command.prefix!, + suffix: state.command.suffix, + }); + + const state1 = api.setSelectionRange(newSelectionRange); + const media = await showPostSelector(id); + + console.log(media); + executeCommand({ + api, + selectedText: state1.selectedText, + selection: state.selection, + prefix: media, + suffix: '', + }); + }, + }, + ]; +}; diff --git a/apps/frontend/src/components/launches/providers/high.order.provider.tsx b/apps/frontend/src/components/launches/providers/high.order.provider.tsx index c04ba7d5..eac8480b 100644 --- a/apps/frontend/src/components/launches/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/launches/providers/high.order.provider.tsx @@ -28,6 +28,7 @@ import { newImage } from '@gitroom/frontend/components/launches/helpers/new.imag import { postSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector'; import { UpDownArrow } from '@gitroom/frontend/components/launches/up.down.arrow'; import { arrayMoveImmutable } from 'array-move'; +import { linkedinCompany } from '@gitroom/frontend/components/launches/helpers/linkedin.component'; // Simple component to change back to settings on after changing tab export const SetTab: FC<{ changeTab: () => void }> = (props) => { @@ -210,7 +211,11 @@ export const withProvider = ( setInPlaceValue( editInPlace ? [{ content: '' }] - : props.value.map((p) => ({ id: p.id, content: p.content, image: p.image})) + : props.value.map((p) => ({ + id: p.id, + content: p.content, + image: p.image, + })) ); }, [props.value, editInPlace]); @@ -280,6 +285,7 @@ export const withProvider = ( .filter((f) => f.name !== 'image'), newImage, postSelector(date), + ...linkedinCompany(integration?.identifier!, integration?.id!), ]} preview="edit" // @ts-ignore diff --git a/apps/frontend/src/components/layout/layout.settings.tsx b/apps/frontend/src/components/layout/layout.settings.tsx index ad26d731..f370d857 100644 --- a/apps/frontend/src/components/layout/layout.settings.tsx +++ b/apps/frontend/src/components/layout/layout.settings.tsx @@ -2,7 +2,6 @@ import { ReactNode, useCallback } from 'react'; import { Title } from '@gitroom/frontend/components/layout/title'; -import { headers } from 'next/headers'; import { ContextWrapper } from '@gitroom/frontend/components/layout/user.context'; import { TopMenu } from '@gitroom/frontend/components/layout/top.menu'; import { MantineWrapper } from '@gitroom/react/helpers/mantine.wrapper'; @@ -16,11 +15,12 @@ import NotificationComponent from '@gitroom/frontend/components/notifications/no import Link from 'next/link'; import useSWR from 'swr'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import weekOfYear from "dayjs/plugin/weekOfYear"; -import isoWeek from "dayjs/plugin/isoWeek"; -import isBetween from "dayjs/plugin/isBetween"; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import weekOfYear from 'dayjs/plugin/weekOfYear'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import isBetween from 'dayjs/plugin/isBetween'; +import { ShowLinkedinCompany } from '@gitroom/frontend/components/launches/helpers/linkedin.component'; dayjs.extend(utc); dayjs.extend(weekOfYear); @@ -46,6 +46,7 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => { +
diff --git a/apps/frontend/src/components/post-url-selector/post.url.selector.tsx b/apps/frontend/src/components/post-url-selector/post.url.selector.tsx index 58843ed8..0370a31f 100644 --- a/apps/frontend/src/components/post-url-selector/post.url.selector.tsx +++ b/apps/frontend/src/components/post-url-selector/post.url.selector.tsx @@ -189,11 +189,11 @@ export const postSelector = (date: dayjs.Dayjs): ICommand => ({ width="13" height="13" viewBox="0 0 32 32" - fill="none" + fill="currentColor" > ), @@ -205,8 +205,7 @@ export const postSelector = (date: dayjs.Dayjs): ICommand => ({ suffix: state.command.suffix, }); - let state1 = api.setSelectionRange(newSelectionRange); - state1 = api.setSelectionRange(newSelectionRange); + const state1 = api.setSelectionRange(newSelectionRange); const media = await showPostSelector(date); executeCommand({ api, diff --git a/libraries/helpers/src/utils/remove.markdown.ts b/libraries/helpers/src/utils/remove.markdown.ts new file mode 100644 index 00000000..7387cff0 --- /dev/null +++ b/libraries/helpers/src/utils/remove.markdown.ts @@ -0,0 +1,28 @@ +import removeMd from 'remove-markdown'; +import { makeId } from '../../../nestjs-libraries/src/services/make.is'; + +export const removeMarkdown = (params: { text: string; except?: RegExp[] }) => { + let modifiedText = params.text; + const except = params.except || []; + const placeholders: { [key: string]: string } = {}; + + // Step 2: Replace exceptions with placeholders + except.forEach((regexp, index) => { + modifiedText = modifiedText.replace(regexp, (match) => { + const placeholder = `[[EXCEPT_PLACEHOLDER_${makeId(5)}]]`; + placeholders[placeholder] = match; + return placeholder; + }); + }); + + // Step 3: Remove markdown from modified text + // Assuming removeMd is the function that removes markdown + const cleanedText = removeMd(modifiedText); + + // Step 4: Replace placeholders with original text + const finalText = Object.keys(placeholders).reduce((text, placeholder) => { + return text.replace(placeholder, placeholders[placeholder]); + }, cleanedText); + + return finalText; +}; diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index e43179bc..458f32b2 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -9,6 +9,7 @@ import sharp from 'sharp'; import { lookup } from 'mime-types'; import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch'; import removeMd from 'remove-markdown'; +import { removeMarkdown } from '@gitroom/helpers/utils/remove.markdown'; export class LinkedinProvider implements SocialProvider { identifier = 'linkedin'; @@ -114,6 +115,38 @@ export class LinkedinProvider implements SocialProvider { }; } + async company(token: string, data: { url: string }) { + const { url } = data; + const getCompanyVanity = url.match( + /^https?:\/\/?www\.?linkedin\.com\/company\/([^/]+)\/$/ + ); + if (!getCompanyVanity || !getCompanyVanity?.length) { + throw new Error('Invalid LinkedIn company URL'); + } + + const { elements } = await ( + await fetch( + `https://api.linkedin.com/rest/organizations?q=vanityName&vanityName=${getCompanyVanity[1]}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202402', + Authorization: `Bearer ${token}`, + }, + } + ) + ).json(); + + return { + options: elements.map((e: { localizedName: string; id: string }) => ({ + label: e.localizedName, + value: `@[${e.localizedName}](urn:li:organization:${e.id})`, + }))?.[0], + }; + } + private async uploadPicture( accessToken: string, personId: string, @@ -203,9 +236,10 @@ export class LinkedinProvider implements SocialProvider { }, body: JSON.stringify({ author: `urn:li:person:${id}`, - commentary: removeMd( - firstPost.message.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢') - ).replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n'), + commentary: removeMarkdown({ + text: firstPost.message.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢'), + except: [/@\[(.*?)]\(urn:li:organization:(\d+)\)/g], + }).replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n'), visibility: 'PUBLIC', distribution: { feedDistribution: 'MAIN_FEED', @@ -238,6 +272,10 @@ export class LinkedinProvider implements SocialProvider { }), }); + if (data.status !== 201 && data.status !== 200) { + throw new Error('Error posting to LinkedIn'); + } + const topPostId = data.headers.get('x-restli-id')!; const ids = [ { @@ -263,10 +301,10 @@ export class LinkedinProvider implements SocialProvider { actor: `urn:li:person:${id}`, object: topPostId, message: { - text: removeMd(post.message.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢')).replace( - '𝔫𝔢𝔴𝔩𝔦𝔫𝔢', - '\n' - ), + text: removeMarkdown({ + text: post.message.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢'), + except: [/@\[(.*?)]\(urn:li:organization:(\d+)\)/g], + }).replace('𝔫𝔢𝔴𝔩𝔦𝔫𝔢', '\n'), }, }), }