From 7bf8b4d6ccdd75fb2ba5ff643976425385f0221f Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sun, 7 Jul 2024 12:55:42 +0700 Subject: [PATCH] feat: threads --- .../public/icons/platforms/threads.png | Bin 0 -> 1698 bytes .../launches/providers/show.all.providers.tsx | 2 + .../providers/threads/threads.provider.tsx | 118 +++++++ .../platform-analytics/platform.analytics.tsx | 3 + .../src/integrations/integration.manager.ts | 2 + .../integrations/social/threads.provider.ts | 314 ++++++++++++++++++ 6 files changed, 439 insertions(+) create mode 100644 apps/frontend/public/icons/platforms/threads.png create mode 100644 apps/frontend/src/components/launches/providers/threads/threads.provider.tsx create mode 100644 libraries/nestjs-libraries/src/integrations/social/threads.provider.ts diff --git a/apps/frontend/public/icons/platforms/threads.png b/apps/frontend/public/icons/platforms/threads.png new file mode 100644 index 0000000000000000000000000000000000000000..3294b46e36d5a3f64007f108ceac7935472004cb GIT binary patch literal 1698 zcmV;T23`4yP)+pFR!@{3 z_#n#22T24b_6LiE{y^YN%n%`1K?!A1%8EdXBrMxjD3heIEb(8>@Xu_6wRzj_ehlb|{LX5{U%)ucT7x@87?XKQ9c!_$O(aMo|>y z2N;I=^GFDSAcCVK2*SNiOGJOM;?WYtN=c+xB9Uk`8j>V68ckYS8irwWb8|B@GZPaN zLqkK8lat~f9fd+6f@a5#9oMd1>+S9J`~8fNW@l&Lzkgp-Q?q5u7XAVRh0de6r(rM{ zUcGu1jYb7&_zH)^R;x8TJ3H=?7AX`(>+9<&isB3$3WcVpr$C&a-GZ6&Q+1be+X=G%?Y&P%Tzn>(@jEoGWQYn|q34&0o)tfeLDk&+sbLY;? z%nZBa;lqbGjwg)?$MKGi4(L8HF>&(bNtH?!vrw&8U%7Gx-hVVrx3shfZa8Ap)YJr- z>~_0Gqlv3LFE8)gw{Os)y1F_EOofGo(9z*=q^GBgFj>8N^_MSSpif?2UP4TAxxA;R z2Y5xJ(ag-u1(Fwju(PwXAy#K+r&KBxkF{&pF6eda*fD-WYHDhEd3kGVtKDwz@9*#J z?S20Ic~w=F;6<&htb}gcwrvxS)z;PqTwE>}{~j+ZD;pghWrXzo`}gC=k8|=>DwW&q z1__TJKNgP#pP=j4uX93|FJBe}V(awj)10b{7cbH@4Wc5M3JMB9eK;I88jb9b!C-)E zCPh&;o2{|2v8JY`wzk%0vxUQ95Q;=1oWgbM*16s8wzjr2XU>S&_Yed@Zrr#5+yxtG zlgZ@qcqoc0C@A2B4jnoKFPPP8SDjgJ6PAr_0UF6%3V^m%}M# zWo0GA!4`_5pFe*F){Tvg@s?+1W{!-EfO7ujjH!A1ErKB6)pogD3s`L3x-~yP-(WE4 zbULY28V-j8fxy(%R7%Rf=0>BjtE($sGGVP+wQ6i^3{3an!GnT1YuB!AZEcO&+ z1o(4mYU=IVw}XR&e!m~XFoGcTdVPL=ep*@@NRUdUr%s*PzI}U9QPIzzKNkrnAhlXO zK0XfS%2|651gWmBhO464Y+ku?B_}~9lXZ1=_8z?RLTPjEs!I!9kEyQBm>oKyMMFaaysIiIDqslqs1}Pw zJl4K_`+y5YQ5!aFV25-%T_6wu(P%X4bUG~-%dK0t9z1ve??{GW1_lP$Rd?^+1?St_ z+r?uctj6ZTg$tZead9#16a+OqJk06(Efxz1J$dpZA*}ZHc3|XmI+aQ#C%Sw0?vEco z@_{Iddi3ZK|4qc{bb^GYrly3jj7B3IOt5K|NF;@Yg>`jxR;%^Jix>Cq-K(su+_-Tg zKU1MlK&zuik0yjAm&@P1dk4HCk%&&G6K%zQs}tD(vSl)vpk04|f4nb=KnD&SfRD)i z`}Y%N0%~qY7Y@U)y?giGzJ1%}a?Q=n3DWreeuu+RTU(ox zlN0ZcjyR4d^c7>-vSn-5tkLWBTCFxcJsrcaU@$m2IXN~q=61V19uG~^F--&k{^yS? s$UIm}^mqBcSn+6yVqs!F8T@(u1G9mQg^5>#O8@`>07*qoM6N<$f;7r9pa1{> literal 0 HcmV?d00001 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'), + })), + })) || [] + ); + } +}