feat: threads
This commit is contained in:
parent
be868c1ca5
commit
7bf8b4d6cc
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -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},
|
||||
];
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
})),
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue