From caf99d38c5d2ff457a3e8b47aa0177ac46100f08 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sat, 9 Aug 2025 18:21:07 +0700 Subject: [PATCH] feat: images and videos to reddit --- .../billing/main.billing.component.tsx | 2 +- .../helpers/media.settings.component.tsx | 21 +-- .../providers/high.order.provider.tsx | 1 + .../providers/reddit/reddit.provider.tsx | 24 ++- .../new-launch/providers/reddit/subreddit.tsx | 18 -- .../src/components/new-launch/store.ts | 2 +- .../posts/providers-settings/reddit.dto.ts | 7 - .../integrations/social/reddit.provider.ts | 163 ++++++++++++++---- 8 files changed, 169 insertions(+), 69 deletions(-) diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 00476764..cad4e3bc 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -505,7 +505,7 @@ export const MainBillingComponent: FC<{ {t( 'your_subscription_will_be_canceled_at', 'Your subscription will be canceled at' - )} + )}{' '} {newDayjs(subscription.cancelAt).local().format('D MMM, YYYY')}
{t( diff --git a/apps/frontend/src/components/launches/helpers/media.settings.component.tsx b/apps/frontend/src/components/launches/helpers/media.settings.component.tsx index 581d2088..3cf774d1 100644 --- a/apps/frontend/src/components/launches/helpers/media.settings.component.tsx +++ b/apps/frontend/src/components/launches/helpers/media.settings.component.tsx @@ -106,16 +106,12 @@ export const CreateThumbnail: FC<{ const [isCapturing, setIsCapturing] = useState(false); const handleLoadedMetadata = useCallback(() => { - if (videoRef.current) { - setDuration(videoRef.current.duration); - setIsLoaded(true); - } + setDuration(videoRef?.current?.duration); + setIsLoaded(true); }, []); const handleTimeUpdate = useCallback(() => { - if (videoRef.current) { - setCurrentTime(videoRef.current.currentTime); - } + setCurrentTime(videoRef?.current?.currentTime); }, []); const handleSeek = useCallback((e: React.ChangeEvent) => { @@ -127,8 +123,6 @@ export const CreateThumbnail: FC<{ }, []); const captureFrame = useCallback(async () => { - if (!videoRef.current || !canvasRef.current) return; - setIsCapturing(true); try { @@ -299,7 +293,14 @@ export const MediaComponentInner: FC<{ alt: string; }) => void; media: - | { id: string; name: string; path: string; thumbnail: string; alt: string, thumbnailTimestamp?: number } + | { + id: string; + name: string; + path: string; + thumbnail: string; + alt: string; + thumbnailTimestamp?: number; + } | undefined; }> = (props) => { const { onClose, onSelect, media } = props; diff --git a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx index 1a02aa41..eb888169 100644 --- a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx @@ -54,6 +54,7 @@ export const withProvider = function (params: { value: Array< Array<{ path: string; + thumbnail?: string; }> >, settings: T, diff --git a/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx b/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx index 68e4aefe..18ce5cae 100644 --- a/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx @@ -111,7 +111,6 @@ const RedditPreview: FC = (props) => {
{value.title}
-
{ + if ( + settings?.subreddit?.some( + (p: any, index: number) => + p?.value?.type === 'media' && posts[0].length !== 1 + ) + ) { + return 'When posting a media post, you must attached exactly one media file.'; + } + + if ( + posts.some((p) => + p.some((a) => !a.thumbnail && a.path.indexOf('mp4') > -1) + ) + ) { + return 'You must attach a thumbnail to your video post.'; + } + + return true; + }, maximumCharacters: 10000, }); diff --git a/apps/frontend/src/components/new-launch/providers/reddit/subreddit.tsx b/apps/frontend/src/components/new-launch/providers/reddit/subreddit.tsx index f53db361..9e002e1f 100644 --- a/apps/frontend/src/components/new-launch/providers/reddit/subreddit.tsx +++ b/apps/frontend/src/components/new-launch/providers/reddit/subreddit.tsx @@ -233,24 +233,6 @@ export const Subreddit: FC<{ onChange={setURL} /> )} - {value.type === 'media' && ( -
-
-
- -
-
- )} ) : (
diff --git a/apps/frontend/src/components/new-launch/store.ts b/apps/frontend/src/components/new-launch/store.ts index d92d169e..754c833d 100644 --- a/apps/frontend/src/components/new-launch/store.ts +++ b/apps/frontend/src/components/new-launch/store.ts @@ -11,7 +11,7 @@ import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone'; interface Values { id: string; content: string; - media: { id: string; path: string }[]; + media: { id: string; path: string, thumbnail?: string }[]; } interface Internal { diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts index 1eeef00f..8785ddbe 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts @@ -9,7 +9,6 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; -import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; import { Type } from 'class-transformer'; export class RedditFlairDto { @@ -57,12 +56,6 @@ export class RedditSettingsDtoInner { @IsDefined() @ValidateNested() flair: RedditFlairDto; - - @ValidateIf((e) => e.type === 'media') - @ValidateNested({ each: true }) - @Type(() => MediaDto) - @ArrayMinSize(1) - media: MediaDto[]; } export class RedditSettingsValueDto { diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts index 2676706a..c29194c8 100644 --- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -9,6 +9,12 @@ import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/provider import { timer } from '@gitroom/helpers/utils/timer'; import { groupBy } from 'lodash'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { lookup } from 'mime-types'; +import axios from 'axios'; +import WebSocket from 'ws'; + +// @ts-ignore +global.WebSocket = WebSocket; export class RedditProvider extends SocialAbstract implements SocialProvider { override maxConcurrentJob = 1; // Reddit has strict rate limits (1 request per second) @@ -117,6 +123,55 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { }; } + private async uploadFileToReddit(accessToken: string, path: string) { + const mimeType = lookup(path); + const formData = new FormData(); + formData.append('filepath', path.split('/').pop()); + formData.append('mimetype', mimeType || 'application/octet-stream'); + + const { + args: { action, fields }, + } = await ( + await this.fetch( + 'https://oauth.reddit.com/api/media/asset', + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: formData, + }, + 'reddit', + 0, + true + ) + ).json(); + + const { data } = await axios.get(path, { + responseType: 'arraybuffer', + }); + + const upload = (fields as { name: string; value: string }[]).reduce( + (acc, value) => { + acc.append(value.name, value.value); + return acc; + }, + new FormData() + ); + + upload.append( + 'file', + new Blob([Buffer.from(data)], { type: mimeType as string }) + ); + + const d = await fetch('https:' + action, { + method: 'POST', + body: upload, + }); + + return [...(await d.text()).matchAll(/(.*?)<\/Location>/g)][0][1]; + } + async post( id: string, accessToken: string, @@ -131,7 +186,9 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { title: firstPostSettings.value.title || '', kind: firstPostSettings.value.type === 'media' - ? 'image' + ? post.media[0].path.indexOf('mp4') > -1 + ? 'video' + : 'image' : firstPostSettings.value.type, ...(firstPostSettings.value.flair ? { flair_id: firstPostSettings.value.flair.id } @@ -143,22 +200,25 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { : {}), ...(firstPostSettings.value.type === 'media' ? { - url: `${ - firstPostSettings.value.media[0].path.indexOf('http') === -1 - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/uploads` - : `` - }${firstPostSettings.value.media[0].path}`, + url: await this.uploadFileToReddit( + accessToken, + post.media[0].path + ), + ...(post.media[0].path.indexOf('mp4') > -1 + ? { + video_poster_url: await this.uploadFileToReddit( + accessToken, + post.media[0].thumbnail + ), + } + : {}), } : {}), text: post.message, sr: firstPostSettings.value.subreddit, }; - const { - json: { - data: { id, name, url }, - }, - } = await ( + const all = await ( await this.fetch('https://oauth.reddit.com/api/submit', { method: 'POST', headers: { @@ -169,6 +229,38 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { }) ).json(); + const { id, name, url } = await new Promise<{ + id: string; + name: string; + url: string; + }>((res) => { + if (all?.json?.data?.id) { + res(all.json.data); + } + + const ws = new WebSocket(all.json.data.websocket_url); + ws.on('message', (data: any) => { + setTimeout(() => { + res({ id: '', name: '', url: '' }); + ws.close(); + }, 30_000); + try { + const parsedData = JSON.parse(data.toString()); + if (parsedData?.payload?.redirect) { + const onlyId = parsedData?.payload?.redirect.replace( + /https:\/\/www\.reddit\.com\/r\/.*?\/comments\/(.*?)\/.*/g, + '$1' + ); + res({ + id: onlyId, + name: `t3_${onlyId}`, + url: parsedData?.payload?.redirect, + }); + } + } catch (err) {} + }); + }); + valueArray.push({ postId: id, releaseURL: url, @@ -202,8 +294,6 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { }) ).json(); - // console.log(JSON.stringify(allTop, null, 2), JSON.stringify(allJson, null, 2), JSON.stringify(allData, null, 2)); - valueArray.push({ postId: commentId, releaseURL: 'https://www.reddit.com' + permalink, @@ -233,7 +323,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { const { data: { children }, } = await ( - await fetch( + await this.fetch( `https://oauth.reddit.com/subreddits/search?show=public&q=${data.word}&sort=activity&show_users=false&limit=10`, { method: 'GET', @@ -241,7 +331,10 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/x-www-form-urlencoded', }, - } + }, + 'reddit', + 0, + false ) ).json(); @@ -267,28 +360,34 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { permissions.push('link'); } - // if (submissionType === 'any' || allow_images) { - // permissions.push('media'); - // } + if (allow_images) { + permissions.push('media'); + } return permissions; } async restrictions(accessToken: string, data: { subreddit: string }) { const { - data: { submission_type, allow_images }, + data: { submission_type, allow_images, ...all2 }, } = await ( - await fetch(`https://oauth.reddit.com/${data.subreddit}/about`, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/x-www-form-urlencoded', + await this.fetch( + `https://oauth.reddit.com/${data.subreddit}/about`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, }, - }) + 'reddit', + 0, + false + ) ).json(); const { is_flair_required, ...all } = await ( - await fetch( + await this.fetch( `https://oauth.reddit.com/api/v1/${ data.subreddit.split('/r/')[1] }/post_requirements`, @@ -298,7 +397,10 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/x-www-form-urlencoded', }, - } + }, + 'reddit', + 0, + false ) ).json(); @@ -307,7 +409,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { async (res) => { try { const flair = await ( - await fetch( + await this.fetch( `https://oauth.reddit.com/${data.subreddit}/api/link_flair_v2`, { method: 'GET', @@ -315,7 +417,10 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/x-www-form-urlencoded', }, - } + }, + 'reddit', + 0, + false ) ).json();