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 (
+
+
+
+

+
+
+
{integration?.name}
+
+ CEO @ Gitroom
+
+
1m
+
+
+
+ {!!firstPost?.images?.length && (
+
+ {firstPost.images.map((image, index) => (
+
+
+
+ ))}
+
+ )}
+
+
+ {morePosts.map((p, index) => (
+
+
+

+
+
+
{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'),
+ })),
+ })) || []
+ );
+ }
+}