diff --git a/apps/frontend/public/icons/platforms/threads.png b/apps/frontend/public/icons/platforms/threads.png new file mode 100644 index 00000000..3294b46e Binary files /dev/null and b/apps/frontend/public/icons/platforms/threads.png differ diff --git a/apps/frontend/src/components/launches/providers/show.all.providers.tsx b/apps/frontend/src/components/launches/providers/show.all.providers.tsx index cfb7997e..e215d71e 100644 --- a/apps/frontend/src/components/launches/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/launches/providers/show.all.providers.tsx @@ -12,6 +12,7 @@ import YoutubeProvider from '@gitroom/frontend/components/launches/providers/you import TiktokProvider from '@gitroom/frontend/components/launches/providers/tiktok/tiktok.provider'; import PinterestProvider from '@gitroom/frontend/components/launches/providers/pinterest/pinterest.provider'; import DribbbleProvider from '@gitroom/frontend/components/launches/providers/dribbble/dribbble.provider'; +import ThreadsProvider from '@gitroom/frontend/components/launches/providers/threads/threads.provider'; export const Providers = [ {identifier: 'devto', component: DevtoProvider}, @@ -27,6 +28,7 @@ export const Providers = [ {identifier: 'tiktok', component: TiktokProvider}, {identifier: 'pinterest', component: PinterestProvider}, {identifier: 'dribbble', component: DribbbleProvider}, + {identifier: 'threads', component: ThreadsProvider}, ]; diff --git a/apps/frontend/src/components/launches/providers/threads/threads.provider.tsx b/apps/frontend/src/components/launches/providers/threads/threads.provider.tsx new file mode 100644 index 00000000..06e52a31 --- /dev/null +++ b/apps/frontend/src/components/launches/providers/threads/threads.provider.tsx @@ -0,0 +1,118 @@ +import { FC } from 'react'; +import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; +import { useIntegration } from '@gitroom/frontend/components/launches/helpers/use.integration'; +import { useFormatting } from '@gitroom/frontend/components/launches/helpers/use.formatting'; +import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; +import { + afterLinkedinCompanyPreventRemove, + linkedinCompanyPreventRemove, +} from '@gitroom/helpers/utils/linkedin.company.prevent.remove'; +import { VideoOrImage } from '@gitroom/react/helpers/video.or.image'; + +const ThreadsPreview: FC = (props) => { + const { value: topValue, integration } = useIntegration(); + const mediaDir = useMediaDirectory(); + const newValues = useFormatting(topValue, { + removeMarkdown: true, + saveBreaklines: true, + beforeSpecialFunc: (text: string) => { + return linkedinCompanyPreventRemove(text); + }, + specialFunc: (text: string) => { + return afterLinkedinCompanyPreventRemove(text.slice(0, 280)); + }, + }); + + const [firstPost, ...morePosts] = newValues; + if (!firstPost) { + return null; + } + + return ( +
+
+
+ x +
+
+
{integration?.name}
+
+ CEO @ Gitroom +
+
1m
+
+
+
+ {!!firstPost?.images?.length && ( +
+ {firstPost.images.map((image, index) => ( + + + + ))} +
+ )} +
+      
+ {morePosts.map((p, index) => ( +
+
+ x +
+
+
{integration?.name}
+
+ CEO @ Gitroom +
+
+ {p.text} +
+ + {!!p?.images?.length && ( +
+ {p.images.map((image, index) => ( + +
+ +
+
+ ))} +
+ )} +
+
+ ))} +
+ ); +}; + +export default withProvider(null, ThreadsPreview, undefined, async ([firstPost, ...otherPosts]) => { + if (!firstPost.length) { + return 'Instagram should have at least one media'; + } + + return true; +}); diff --git a/apps/frontend/src/components/platform-analytics/platform.analytics.tsx b/apps/frontend/src/components/platform-analytics/platform.analytics.tsx index 0cca599a..d5b19e43 100644 --- a/apps/frontend/src/components/platform-analytics/platform.analytics.tsx +++ b/apps/frontend/src/components/platform-analytics/platform.analytics.tsx @@ -19,6 +19,7 @@ const allowedIntegrations = [ 'tiktok', 'youtube', 'pinterest', + 'threads' ]; export const PlatformAnalytics = () => { @@ -60,6 +61,7 @@ export const PlatformAnalytics = () => { 'linkedin-page', 'pinterest', 'youtube', + 'threads', ].indexOf(currentIntegration.identifier) !== -1 ) { arr.push({ @@ -75,6 +77,7 @@ export const PlatformAnalytics = () => { 'linkedin-page', 'pinterest', 'youtube', + 'threads', ].indexOf(currentIntegration.identifier) !== -1 ) { arr.push({ diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index f001409c..dba5c101 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -14,6 +14,7 @@ import { TiktokProvider } from '@gitroom/nestjs-libraries/integrations/social/ti import { PinterestProvider } from '@gitroom/nestjs-libraries/integrations/social/pinterest.provider'; import { DribbbleProvider } from '@gitroom/nestjs-libraries/integrations/social/dribbble.provider'; import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider'; +import { ThreadsProvider } from '@gitroom/nestjs-libraries/integrations/social/threads.provider'; const socialIntegrationList = [ ...(process.env.IS_GENERAL !== 'true' ? [new XProvider()] : []), @@ -22,6 +23,7 @@ const socialIntegrationList = [ new RedditProvider(), new FacebookProvider(), new InstagramProvider(), + new ThreadsProvider(), new YoutubeProvider(), new TiktokProvider(), new PinterestProvider(), diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts new file mode 100644 index 00000000..75e5b3b4 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts @@ -0,0 +1,314 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { timer } from '@gitroom/helpers/utils/timer'; +import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { capitalize, chunk } from 'lodash'; + +export class ThreadsProvider extends SocialAbstract implements SocialProvider { + identifier = 'threads'; + name = 'Threads'; + isBetweenSteps = false; + + async refreshToken(refresh_token: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl(refresh?: string) { + const state = makeId(6); + return { + url: + 'https://threads.net/oauth/authorize' + + `?client_id=${process.env.THREADS_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + process.env.NODE_ENV === 'development' || !process.env.NODE_ENV + ? `https://integration.git.sn/integrations/social/threads` + : `${process.env.FRONTEND_URL}/integrations/social/threads${ + refresh ? `?refresh=${refresh}` : '' + }` + )}` + + `&state=${state}` + + `&scope=${encodeURIComponent( + 'threads_basic,threads_content_publish,threads_manage_replies,threads_read_replies,threads_manage_insights' + )}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const getAccessToken = await ( + await this.fetch( + 'https://graph.threads.net/oauth/access_token' + + `?client_id=${process.env.THREADS_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + process.env.NODE_ENV === 'development' || !process.env.NODE_ENV + ? `https://integration.git.sn/integrations/social/threads` + : `${process.env.FRONTEND_URL}/integrations/social/threads${ + params.refresh ? `?refresh=${params.refresh}` : '' + }` + )}` + + `&grant_type=authorization_code` + + `&client_secret=${process.env.THREADS_APP_SECRET}` + + `&code=${params.code}` + ) + ).json(); + + const { access_token } = await ( + await this.fetch( + 'https://graph.threads.net/access_token' + + '?grant_type=th_exchange_token' + + `&client_secret=${process.env.THREADS_APP_SECRET}` + + `&access_token=${getAccessToken.access_token}&fields=access_token,expires_in` + ) + ).json(); + + const { + id, + name, + picture: { + data: { url }, + }, + } = await this.fetchPageInformation(access_token); + + return { + id, + name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(), + picture: url, + username: '', + }; + } + + async fetchPageInformation(accessToken: string) { + const { id, username, threads_profile_picture_url, access_token } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/me?fields=id,username,threads_profile_picture_url&access_token=${accessToken}` + ) + ).json(); + + return { + id, + name: username, + access_token, + picture: { data: { url: threads_profile_picture_url } }, + username, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [firstPost, ...theRest] = postDetails; + + let globalThread = ''; + let link = ''; + + if (firstPost?.media?.length! <= 1) { + const type = !firstPost?.media?.[0]?.path + ? undefined + : firstPost?.media![0].path.indexOf('.mp4') > -1 + ? 'video_url' + : 'image_url'; + + const media = new URLSearchParams({ + ...(type === 'video_url' + ? { video_url: firstPost?.media![0].path } + : {}), + ...(type === 'image_url' + ? { image_url: firstPost?.media![0].path } + : {}), + media_type: + type === 'video_url' + ? 'VIDEO' + : type === 'image_url' + ? 'IMAGE' + : 'TEXT', + text: firstPost?.message, + access_token: accessToken, + }); + + const { id: containerId } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${id}/threads?${media.toString()}`, + { + method: 'POST', + } + ) + ).json(); + + const { id: threadId } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${id}/threads_publish?creation_id=${containerId}&access_token=${accessToken}`, + { + method: 'POST', + } + ) + ).json(); + + const { permalink, ...all } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${threadId}?fields=id,permalink&access_token=${accessToken}` + ) + ).json(); + + globalThread = threadId; + link = permalink; + } else { + const medias = []; + for (const mediaLoad of firstPost.media!) { + const type = + mediaLoad.path.indexOf('.mp4') > -1 ? 'video_url' : 'image_url'; + + const media = new URLSearchParams({ + ...(type === 'video_url' + ? { video_url: firstPost?.media![0].path } + : {}), + ...(type === 'image_url' + ? { image_url: firstPost?.media![0].path } + : {}), + is_carousel_item: 'true', + media_type: + type === 'video_url' + ? 'VIDEO' + : type === 'image_url' + ? 'IMAGE' + : 'TEXT', + text: firstPost?.message, + access_token: accessToken, + }); + + const { id: mediaId } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${id}/threads?${media.toString()}`, + { + method: 'POST', + } + ) + ).json(); + + medias.push(mediaId); + } + + const { id: containerId } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${id}/threads?text=${firstPost?.message}&media_type=CAROUSEL&children=${medias.join( + ',' + )}&access_token=${accessToken}`, + { + method: 'POST', + } + ) + ).json(); + + const { id: threadId } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${id}/threads_publish?creation_id=${containerId}&access_token=${accessToken}`, + { + method: 'POST', + } + ) + ).json(); + + const { permalink } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${threadId}?fields=id,permalink&access_token=${accessToken}` + ) + ).json(); + + globalThread = threadId; + link = permalink; + } + + let lastId = globalThread; + for (const post of theRest) { + const form = new FormData(); + form.append('media_type', 'TEXT'); + form.append('text', post.message); + form.append('reply_to_id', lastId); + form.append('access_token', accessToken); + + const { id: replyId } = await ( + await this.fetch('https://graph.threads.net/v1.0/me/threads', { + method: 'POST', + body: form, + }) + ).json(); + + const { id: threadMediaId } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${id}/threads_publish?creation_id=${replyId}&access_token=${accessToken}`, + { + method: 'POST', + } + ) + ).json(); + + lastId = threadMediaId; + } + + return [ + { + id: firstPost.id, + postId: String(globalThread), + status: 'success', + releaseURL: link, + }, + ...theRest.map((p) => ({ + id: p.id, + postId: String(globalThread), + status: 'success', + releaseURL: link, + })), + ]; + } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const until = dayjs().format('YYYY-MM-DD'); + const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD'); + + const { data, ...all } = await ( + await fetch( + `https://graph.threads.net/v1.0/${id}/threads_insights?metric=views,likes,replies,reposts,quotes&access_token=${accessToken}&period=day&since=${since}&until=${until}` + ) + ).json(); + + console.log(data); + return ( + data?.map((d: any) => ({ + label: capitalize(d.name), + percentageChange: 5, + data: d.total_value ? [{total: d.total_value.value, date: dayjs().format('YYYY-MM-DD')}] : d.values.map((v: any) => ({ + total: v.value, + date: dayjs(v.end_time).format('YYYY-MM-DD'), + })), + })) || [] + ); + } +}