feat: threads

This commit is contained in:
Nevo David 2024-07-07 12:55:42 +07:00
parent be868c1ca5
commit 7bf8b4d6cc
6 changed files with 439 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -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},
];

View File

@ -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 (
<div className="rounded-[8px] flex flex-col gap-[8px] border border-black/90 w-[555px] pt-[12px] pl-[16px] pb-[12px] pr-[40px] bg-white text-black font-['helvetica']">
<div className="flex gap-[8px]">
<div className="w-[48px] h-[48px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex flex-col leading-[16px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[12px] font-[400] text-black/60">1m</div>
</div>
</div>
<div>
{!!firstPost?.images?.length && (
<div className="-ml-[16px] -mr-[40px] flex-1 h-[555px] flex overflow-hidden mt-[12px] gap-[2px]">
{firstPost.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
className="flex-1"
target="_blank"
>
<VideoOrImage autoplay={true} src={mediaDir.set(image.path)} />
</a>
))}
</div>
)}
<pre
className="font-['helvetica'] text-[14px] font-[400] text-wrap"
dangerouslySetInnerHTML={{ __html: firstPost?.text }}
/>
</div>
{morePosts.map((p, index) => (
<div className="flex gap-[8px]" key={index}>
<div className="w-[40px] h-[40px]">
<img
src={integration?.picture}
alt="x"
className="rounded-full w-full h-full relative z-[2]"
/>
</div>
<div className="flex-1 flex flex-col leading-[16px] bg-[#F2F2F2] w-full pt-[8px] pr-[64px] pl-[12px] pb-[8px] rounded-[8px]">
<div className="text-[14px] font-[600]">{integration?.name}</div>
<div className="text-[12px] font-[400] text-black/60">
CEO @ Gitroom
</div>
<div className="text-[14px] mt-[8px] font-[400] text-black/90">
{p.text}
</div>
{!!p?.images?.length && (
<div className="w-full h-[120px] flex overflow-hidden mt-[12px] gap-[2px]">
{p.images.map((image, index) => (
<a
key={`image_${index}`}
href={mediaDir.set(image.path)}
target="_blank"
>
<div className="w-[120px] h-full">
<VideoOrImage
autoplay={true}
src={mediaDir.set(image.path)}
/>
</div>
</a>
))}
</div>
)}
</div>
</div>
))}
</div>
);
};
export default withProvider(null, ThreadsPreview, undefined, async ([firstPost, ...otherPosts]) => {
if (!firstPost.length) {
return 'Instagram should have at least one media';
}
return true;
});

View File

@ -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({

View File

@ -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(),

View File

@ -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<AuthTokenDetails> {
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<PostResponse[]> {
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<AnalyticsData[]> {
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'),
})),
})) || []
);
}
}