Skip to content

Commit b8b6284

Browse files
feat(reddit): add support for multi-image gallery posts
Fixes limitation where Reddit posts could only be scheduled with a single media item.
1 parent 79de287 commit b8b6284

File tree

2 files changed

+205
-59
lines changed

2 files changed

+205
-59
lines changed

apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -215,21 +215,44 @@ export default withProvider({
215215
CustomPreviewComponent: undefined,
216216
dto: RedditSettingsDto,
217217
checkValidity: async (posts, settings: any) => {
218-
if (
219-
settings?.subreddit?.some(
220-
(p: any, index: number) =>
221-
p?.value?.type === 'media' && posts[0].length !== 1
222-
)
223-
) {
224-
return 'When posting a media post, you must attached exactly one media file.';
225-
}
218+
// Check if type is media in any subreddit setting
219+
const hasMediaType = settings?.subreddit?.some(
220+
(p: any) => p?.value?.type === 'media'
221+
);
226222

227-
if (
228-
posts.some((p) =>
229-
p.some((a) => !a.thumbnail && a.path.indexOf('mp4') > -1)
230-
)
231-
) {
232-
return 'You must attach a thumbnail to your video post.';
223+
if (hasMediaType && posts[0]) {
224+
const firstPost = posts[0];
225+
226+
// Detect video and image media
227+
const hasVideo = firstPost.some((m: any) => m.path.indexOf('mp4') > -1);
228+
const hasImage = firstPost.some((m: any) => m.path.indexOf('mp4') === -1);
229+
230+
// Disallow mixed media (video + images)
231+
if (hasVideo && hasImage) {
232+
return 'You cannot mix videos and images in a Reddit post. Please use either a video or images, not both.';
233+
}
234+
235+
// Video validation
236+
if (hasVideo) {
237+
if (firstPost.length !== 1) {
238+
return 'When posting a video, you must attach exactly one media file.';
239+
}
240+
241+
// Check for thumbnail
242+
const videoMedia = firstPost.find((m: any) => m.path.indexOf('mp4') > -1);
243+
if (!videoMedia?.thumbnail) {
244+
return 'You must attach a thumbnail to your video post.';
245+
}
246+
} else {
247+
// Image validation (no video)
248+
if (firstPost.length === 0) {
249+
return 'When posting a media post, you must attach at least one image.';
250+
}
251+
252+
if (firstPost.length > 20) {
253+
return 'Reddit allows a maximum of 20 images per post.';
254+
}
255+
}
233256
}
234257

235258
return true;

libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts

Lines changed: 168 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,16 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
123123
};
124124
}
125125

126-
private async uploadFileToReddit(accessToken: string, path: string) {
126+
private async uploadFileToReddit(
127+
accessToken: string,
128+
path: string
129+
): Promise<{ url: string; assetId?: string }> {
127130
const mimeType = lookup(path);
128131
const formData = new FormData();
129132
formData.append('filepath', path.split('/').pop());
130133
formData.append('mimetype', mimeType || 'application/octet-stream');
131134

132-
const {
133-
args: { action, fields },
134-
} = await (
135+
const responseData = await (
135136
await this.fetch(
136137
'https://oauth.reddit.com/api/media/asset',
137138
{
@@ -147,6 +148,13 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
147148
)
148149
).json();
149150

151+
const {
152+
args: { action, fields },
153+
asset,
154+
} = responseData;
155+
156+
const assetId = asset?.asset_id || asset?.id;
157+
150158
const { data } = await axios.get(path, {
151159
responseType: 'arraybuffer',
152160
});
@@ -169,7 +177,11 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
169177
body: upload,
170178
});
171179

172-
return [...(await d.text()).matchAll(/<Location>(.*?)<\/Location>/g)][0][1];
180+
const url = [
181+
...(await d.text()).matchAll(/<Location>(.*?)<\/Location>/g),
182+
][0][1];
183+
184+
return { url, assetId };
173185
}
174186

175187
async post(
@@ -181,53 +193,159 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
181193

182194
const valueArray: PostResponse[] = [];
183195
for (const firstPostSettings of post.settings.subreddit) {
184-
const postData = {
196+
const hasVideo =
197+
post.media?.some((m) => m.path.indexOf('mp4') > -1) === true;
198+
const images = (post.media || []).filter(
199+
(m) => m.path.indexOf('mp4') === -1
200+
);
201+
202+
// Determine post kind based on actual content
203+
let useGalleryEndpoint = false;
204+
let kind: string;
205+
206+
if (hasVideo) {
207+
kind = 'video';
208+
} else if (images.length > 1) {
209+
useGalleryEndpoint = true;
210+
kind = 'gallery';
211+
} else if (images.length === 1) {
212+
kind = 'image';
213+
} else {
214+
kind = firstPostSettings.value.type;
215+
}
216+
217+
let postData: any = {
185218
api_type: 'json',
186219
title: firstPostSettings.value.title || '',
187-
kind:
188-
firstPostSettings.value.type === 'media'
189-
? post.media[0].path.indexOf('mp4') > -1
190-
? 'video'
191-
: 'image'
192-
: firstPostSettings.value.type,
220+
kind,
221+
text: post.message,
222+
sr: firstPostSettings.value.subreddit,
193223
...(firstPostSettings.value.flair
194224
? { flair_id: firstPostSettings.value.flair.id }
195225
: {}),
196-
...(firstPostSettings.value.type === 'link'
197-
? {
198-
url: firstPostSettings.value.url,
199-
}
200-
: {}),
201-
...(firstPostSettings.value.type === 'media'
202-
? {
203-
url: await this.uploadFileToReddit(
204-
accessToken,
205-
post.media[0].path
206-
),
207-
...(post.media[0].path.indexOf('mp4') > -1
208-
? {
209-
video_poster_url: await this.uploadFileToReddit(
210-
accessToken,
211-
post.media[0].thumbnail
212-
),
213-
}
214-
: {}),
215-
}
216-
: {}),
217-
text: post.message,
218-
sr: firstPostSettings.value.subreddit,
219226
};
220227

221-
const all = await (
222-
await this.fetch('https://oauth.reddit.com/api/submit', {
223-
method: 'POST',
224-
headers: {
225-
Authorization: `Bearer ${accessToken}`,
226-
'Content-Type': 'application/x-www-form-urlencoded',
227-
},
228-
body: new URLSearchParams(postData),
229-
})
230-
).json();
228+
// Handle different post types
229+
if (firstPostSettings.value.type === 'link') {
230+
postData.url = firstPostSettings.value.url;
231+
} else if (firstPostSettings.value.type === 'media') {
232+
if (kind === 'video') {
233+
const videoUpload = await this.uploadFileToReddit(
234+
accessToken,
235+
post.media[0].path
236+
);
237+
postData.url = videoUpload.url;
238+
239+
if (post.media[0].thumbnail) {
240+
const thumbnailUpload = await this.uploadFileToReddit(
241+
accessToken,
242+
post.media[0].thumbnail
243+
);
244+
postData.video_poster_url = thumbnailUpload.url;
245+
}
246+
} else if (!useGalleryEndpoint) {
247+
const imageUpload = await this.uploadFileToReddit(
248+
accessToken,
249+
post.media[0].path
250+
);
251+
postData.url = imageUpload.url;
252+
}
253+
}
254+
255+
let all: any;
256+
257+
if (useGalleryEndpoint && images.length > 1) {
258+
// Upload all images for gallery
259+
const uploads: { url: string; assetId?: string }[] = [];
260+
for (let i = 0; i < images.length; i++) {
261+
const uploaded = await this.uploadFileToReddit(
262+
accessToken,
263+
images[i].path
264+
);
265+
uploads.push(uploaded);
266+
267+
if (i < images.length - 1) {
268+
await timer(1000);
269+
}
270+
}
271+
272+
const items = uploads
273+
.filter((u) => u.assetId)
274+
.map((u) => ({
275+
media_id: u.assetId,
276+
caption: '',
277+
outbound_url: '',
278+
}));
279+
280+
if (items.length < 2) {
281+
// Fallback to single image if insufficient asset IDs
282+
const firstImageUpload = await this.uploadFileToReddit(
283+
accessToken,
284+
images[0].path
285+
);
286+
postData = {
287+
api_type: 'json',
288+
kind: 'image',
289+
sr: firstPostSettings.value.subreddit,
290+
title: firstPostSettings.value.title || '',
291+
url: firstImageUpload.url,
292+
...(firstPostSettings.value.flair
293+
? { flair_id: firstPostSettings.value.flair.id }
294+
: {}),
295+
};
296+
297+
all = await (
298+
await this.fetch('https://oauth.reddit.com/api/submit', {
299+
method: 'POST',
300+
headers: {
301+
Authorization: `Bearer ${accessToken}`,
302+
'Content-Type': 'application/x-www-form-urlencoded',
303+
},
304+
body: new URLSearchParams(postData),
305+
})
306+
).json();
307+
} else {
308+
// Submit gallery post
309+
const galleryBody = {
310+
sr: firstPostSettings.value.subreddit,
311+
title: firstPostSettings.value.title || '',
312+
items,
313+
api_type: 'json',
314+
resubmit: true,
315+
sendreplies: true,
316+
nsfw: false,
317+
spoiler: false,
318+
...(firstPostSettings.value.flair
319+
? { flair_id: firstPostSettings.value.flair.id }
320+
: {}),
321+
};
322+
323+
all = await (
324+
await this.fetch(
325+
'https://oauth.reddit.com/api/submit_gallery_post.json?raw_json=1',
326+
{
327+
method: 'POST',
328+
headers: {
329+
Authorization: `Bearer ${accessToken}`,
330+
'Content-Type': 'application/json',
331+
},
332+
body: JSON.stringify(galleryBody),
333+
}
334+
)
335+
).json();
336+
}
337+
} else {
338+
all = await (
339+
await this.fetch('https://oauth.reddit.com/api/submit', {
340+
method: 'POST',
341+
headers: {
342+
Authorization: `Bearer ${accessToken}`,
343+
'Content-Type': 'application/x-www-form-urlencoded',
344+
},
345+
body: new URLSearchParams(postData),
346+
})
347+
).json();
348+
}
231349

232350
const { id, name, url } = await new Promise<{
233351
id: string;
@@ -238,6 +356,11 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
238356
res(all.json.data);
239357
}
240358

359+
if (!all?.json?.data?.websocket_url) {
360+
res({ id: '', name: '', url: '' });
361+
return;
362+
}
363+
241364
const ws = new WebSocket(all.json.data.websocket_url);
242365
ws.on('message', (data: any) => {
243366
setTimeout(() => {

0 commit comments

Comments
 (0)