Skip to content

Commit f9fc951

Browse files
paulocastellanoPaulo Castellano
andauthored
fix: allow original Instagram feed images in composer (#372)
* fix: respect Instagram feed crop in media validation * fix: allow original Instagram feed images in composer * refactor: simplify Instagram feed aspect handling * refactor: share Instagram aspect ratio values in frontend --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
1 parent 2842101 commit f9fc951

7 files changed

Lines changed: 165 additions & 24 deletions

File tree

‎resources/js/components/posts/editor/InstagramSettings.vue‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { computed, ref } from 'vue';
55
import MediaRulesWarning from '@/components/posts/editor/MediaRulesWarning.vue';
66
import { Avatar } from '@/components/ui/avatar';
77
import { getPlatformLogo } from '@/composables/usePlatformLogo';
8+
import { AspectRatio, type AspectRatioValue } from '@/types/aspect-ratio';
89
import { ContentType } from '@/types/content-type';
910
import type { MediaItem } from '@/types/media';
1011
import { Platform } from '@/types/platform';
@@ -46,21 +47,21 @@ const variants = [
4647
4748
4849
const aspectRatios = [
49-
{ value: '1:1', labelKey: 'posts.form.instagram.aspect.square' },
50-
{ value: '4:5', labelKey: 'posts.form.instagram.aspect.portrait' },
51-
{ value: '16:9', labelKey: 'posts.form.instagram.aspect.landscape' },
52-
{ value: 'original', labelKey: 'posts.form.instagram.aspect.original' },
53-
];
50+
{ value: AspectRatio.Square, labelKey: 'posts.form.instagram.aspect.square' },
51+
{ value: AspectRatio.Portrait, labelKey: 'posts.form.instagram.aspect.portrait' },
52+
{ value: AspectRatio.Landscape, labelKey: 'posts.form.instagram.aspect.landscape' },
53+
{ value: AspectRatio.Original, labelKey: 'posts.form.instagram.aspect.original' },
54+
] as const;
5455
5556
const isFeed = computed(() => props.contentType === ContentType.InstagramFeed);
56-
const selectedAspectRatio = computed(() => props.meta.aspect_ratio ?? '1:1');
57+
const selectedAspectRatio = computed(() => props.meta.aspect_ratio ?? AspectRatio.Original);
5758
5859
const pickVariant = (value: string) => {
5960
if (props.disabled) return;
6061
emit('update:contentType', value);
6162
};
6263
63-
const pickAspectRatio = (value: string) => {
64+
const pickAspectRatio = (value: AspectRatioValue) => {
6465
if (props.disabled) return;
6566
emit('update:meta', { ...props.meta, aspect_ratio: value });
6667
};
@@ -71,6 +72,7 @@ const pickAspectRatio = (value: string) => {
7172
<button
7273
type="button"
7374
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
75+
data-testid="instagram-settings-toggle"
7476
@click="open = !open"
7577
>
7678
<span class="flex min-w-0 items-center gap-2">
@@ -126,6 +128,7 @@ const pickAspectRatio = (value: string) => {
126128
v-for="ratio in aspectRatios"
127129
:key="ratio.value"
128130
type="button"
131+
:data-testid="`instagram-aspect-${ratio.value.replace(':', '-')}`"
129132
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
130133
:class="selectedAspectRatio === ratio.value
131134
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'

‎resources/js/components/posts/editor/PostEditorActions.vue‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ const scheduledAtError = computed(() => errors.value.scheduled_at);
9999
<Button
100100
type="button"
101101
class="w-full lg:w-auto"
102+
data-testid="post-submit"
102103
:disabled="isPostActionDisabled"
103104
@click="emit('submit', hasPickedTime ? PostStatus.Scheduled : PostStatus.Publishing)"
104105
>

‎resources/js/components/posts/editor/PostEditorTabs.vue‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ defineExpose({
114114
<TabsTrigger value="preview" data-testid="editor-tab-preview">{{
115115
$t('posts.edit.tabs.preview')
116116
}}</TabsTrigger>
117-
<TabsTrigger value="schedule">{{
117+
<TabsTrigger value="schedule" data-testid="editor-tab-channels">{{
118118
$t('posts.edit.tabs.channels')
119119
}}</TabsTrigger>
120120
<TabsTrigger value="comments">{{

‎resources/js/components/posts/previews/InstagramPreview.vue‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { computed } from 'vue';
1616
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
1717
import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue';
1818
import { getInitials } from '@/composables/useInitials';
19+
import { AspectRatio } from '@/types/aspect-ratio';
1920
import { ContentType } from '@/types/content-type';
2021
import type { MediaItem } from '@/types/media';
2122
@@ -57,17 +58,23 @@ const isFeed = computed(() => !isReel.value && !isStory.value);
5758
5859
// Padding-bottom percentage = height/width. Used instead of CSS `aspect-ratio`
5960
// because inside this flex column some rendering paths ignored `aspect-ratio`
60-
// and the frame stuck to a stale height. `null` = use original media height.
61-
const ASPECT_PADDING: Record<string, number | null> = {
62-
'1:1': 100,
63-
'4:5': 125,
64-
'16:9': 56.25,
65-
'original': null,
61+
// and the frame stuck to a stale height.
62+
const ASPECT_PADDING: Record<string, number> = {
63+
[AspectRatio.Square]: 100,
64+
[AspectRatio.Portrait]: 125,
65+
[AspectRatio.Landscape]: 56.25,
6666
};
6767
68-
const feedAspectStyle = computed(() => {
69-
const fraction = ASPECT_PADDING[props.meta?.aspect_ratio ?? '1:1'] ?? 100;
70-
return fraction === null ? { aspectRatio: 'auto' } : { paddingBottom: `${fraction}%` };
68+
const feedAspectPadding = computed(() => {
69+
const selectedRatio = props.meta?.aspect_ratio ?? AspectRatio.Original;
70+
71+
if (selectedRatio !== AspectRatio.Original) {
72+
return ASPECT_PADDING[selectedRatio] ?? 100;
73+
}
74+
75+
const { width = 0, height = 0 } = props.media[0]?.meta ?? {};
76+
77+
return width > 0 && height > 0 ? (height / width) * 100 : 100;
7178
});
7279
7380
// Format numbers like Instagram
@@ -127,7 +134,7 @@ const truncatedCaption = computed(() => {
127134
</div>
128135

129136
<!-- Post Media - Aspect ratio matches user's chosen crop -->
130-
<div class="relative w-full shrink-0 bg-black" :style="feedAspectStyle">
137+
<div class="relative w-full shrink-0 bg-black" :style="{ paddingBottom: `${feedAspectPadding}%` }" data-testid="instagram-feed-media">
131138
<div class="absolute inset-0">
132139
<PostMediaPreview
133140
:media="media"
@@ -297,4 +304,4 @@ const truncatedCaption = computed(() => {
297304
</div>
298305
</template>
299306
</div>
300-
</template>
307+
</template>

‎resources/js/composables/useMedia.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getMediaRulesForContentType } from '@/composables/useMediaRules';
22
import date from '@/date';
33
import type { MediaRules } from '@/lib/contentTypeMediaRules';
44
import { isDocument, isGif, isImage, isMov, isVideo } from '@/lib/mediaType';
5+
import { ContentType } from '@/types/content-type';
56
import type { MediaItem } from '@/types/media';
67

78
export type { MediaItem } from '@/types/media';
@@ -40,7 +41,7 @@ const warning = (key: string, params: Record<string, string> = {}): MediaValidat
4041
const firstWarning = (...candidates: Array<MediaValidationWarning | false | null | undefined>): MediaValidationWarning | null =>
4142
candidates.find((candidate): candidate is MediaValidationWarning => Boolean(candidate)) ?? null;
4243

43-
const itemConstraintWarning = (item: MediaItem, rules: MediaRules): MediaValidationWarning | null => {
44+
const itemConstraintWarning = (item: MediaItem, rules: MediaRules, contentType: string): MediaValidationWarning | null => {
4445
const size = item.size ?? 0;
4546
const duration = item.meta?.duration ?? 0;
4647
const width = item.meta?.width ?? 0;
@@ -68,7 +69,12 @@ const itemConstraintWarning = (item: MediaItem, rules: MediaRules): MediaValidat
6869
return warning('image_too_large', sizeParams(rules.maxImageBytes, size));
6970
}
7071

71-
if (width > 0 && height > 0 && ! (rules.autoFitsImage && isImage(item))) {
72+
const skipSourceAspectRatioCheck = isImage(item) && (
73+
rules.autoFitsImage
74+
|| contentType === ContentType.InstagramFeed
75+
);
76+
77+
if (width > 0 && height > 0 && ! skipSourceAspectRatioCheck) {
7278
const ratio = width / height;
7379

7480
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) {
@@ -111,7 +117,7 @@ export const getMediaValidationWarning = (
111117
rules.acceptDocuments && documents.length > 0 && total > 1 && warning('document_not_alone'),
112118
! rules.acceptsGif && media.some(isGif) && warning('gif_not_allowed'),
113119
! rules.acceptsMov && media.some(isMov) && warning('mov_not_allowed'),
114-
...media.map((item) => itemConstraintWarning(item, rules)),
120+
...media.map((item) => itemConstraintWarning(item, rules, contentType)),
115121
);
116122
};
117123

@@ -129,7 +135,7 @@ export const getMediaItemIssue = (item: MediaItem, contentType: string): string
129135
if (isDocument(item)) {
130136
return firstWarning(
131137
! rules.acceptDocuments && warning('no_document_allowed'),
132-
itemConstraintWarning(item, rules),
138+
itemConstraintWarning(item, rules, contentType),
133139
)?.key ?? null;
134140
}
135141

@@ -138,6 +144,6 @@ export const getMediaItemIssue = (item: MediaItem, contentType: string): string
138144
! isVideo(item) && ! rules.acceptImages && warning('no_image_allowed'),
139145
isGif(item) && ! rules.acceptsGif && warning('gif_not_allowed'),
140146
isMov(item) && ! rules.acceptsMov && warning('mov_not_allowed'),
141-
itemConstraintWarning(item, rules),
147+
itemConstraintWarning(item, rules, contentType),
142148
)?.key ?? null;
143149
};

‎resources/js/types/aspect-ratio.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const AspectRatio = {
2+
Square: '1:1',
3+
Portrait: '4:5',
4+
Landscape: '16:9',
5+
Original: 'original',
6+
} as const;
7+
8+
export type AspectRatioValue = (typeof AspectRatio)[keyof typeof AspectRatio];

‎tests/Browser/ChannelMediaIssueTest.php‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,119 @@ function waitForChannelIssueCondition(mixed $page, string $testId, string $condi
111111
'https://docs.trypost.it/knowledge-base/media#x-twitter',
112112
)->assertNoJavaScriptErrors();
113113
});
114+
115+
/**
116+
* @param array<string, string> $platformMeta
117+
* @param array<string, int> $imageMeta
118+
* @return array{Post, PostPlatform}
119+
*/
120+
function seedInstagramFeedImagePost(array $platformMeta = ['aspect_ratio' => 'original'], array $imageMeta = ['width' => 1080, 'height' => 1440], int $size = 1024): array
121+
{
122+
$user = User::factory()->create();
123+
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
124+
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
125+
$user->update(['current_workspace_id' => $workspace->id]);
126+
127+
$account = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]);
128+
$post = Post::factory()->create([
129+
'workspace_id' => $workspace->id,
130+
'user_id' => $user->id,
131+
'content' => 'A five-image carousel',
132+
'media' => array_map(fn (int $index): array => [
133+
'id' => "image-{$index}",
134+
'type' => 'image',
135+
'mime_type' => 'image/jpeg',
136+
'path' => "uploads/image-{$index}.jpg",
137+
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
138+
'size' => $size,
139+
'meta' => $imageMeta,
140+
], range(1, 5)),
141+
]);
142+
$postPlatform = PostPlatform::factory()->create([
143+
'post_id' => $post->id,
144+
'social_account_id' => $account->id,
145+
'platform' => Platform::Instagram,
146+
'content_type' => ContentType::InstagramFeed,
147+
'meta' => $platformMeta,
148+
]);
149+
150+
test()->actingAs($user);
151+
152+
return [$post, $postPlatform];
153+
}
154+
155+
test('Instagram feed aspect choices do not reject the source image in the editor', function () {
156+
[$post, $postPlatform] = seedInstagramFeedImagePost();
157+
158+
$page = visit(route('app.posts.edit', $post));
159+
waitForChannelIssueTestId($page, "channel-{$postPlatform->id}");
160+
161+
$page->click('@instagram-settings-toggle');
162+
163+
foreach (['1-1' => 1.0, '4-5' => 5 / 4, '16-9' => 9 / 16, 'original' => 4 / 3] as $option => $expectedHeightToWidth) {
164+
$page->click("@instagram-aspect-{$option}")
165+
->assertMissing('@media-rules-warning')
166+
->assertMissing("@channel-issue-{$postPlatform->id}")
167+
->assertEnabled('@post-submit')
168+
->click('@editor-tab-preview');
169+
170+
waitForChannelIssueTestId($page, 'instagram-feed-media');
171+
172+
$heightToWidth = $page->script('(() => { const frame = document.querySelector("[data-testid=instagram-feed-media]"); const rect = frame.getBoundingClientRect(); return rect.height / rect.width; })()');
173+
174+
expect(abs($heightToWidth - $expectedHeightToWidth))->toBeLessThan(0.01);
175+
176+
$page->click('@editor-tab-channels');
177+
}
178+
179+
$page->assertNoJavaScriptErrors();
180+
});
181+
182+
test('Instagram feed without a saved aspect ratio uses the original image', function () {
183+
[$post, $postPlatform] = seedInstagramFeedImagePost([]);
184+
185+
$page = visit(route('app.posts.edit', $post));
186+
waitForChannelIssueTestId($page, "channel-{$postPlatform->id}");
187+
188+
$page->click('@instagram-settings-toggle');
189+
190+
$originalSelected = $page->script('document.querySelector("[data-testid=instagram-aspect-original]").classList.contains("bg-violet-100")');
191+
192+
expect($originalSelected)->toBeTrue();
193+
194+
$page->assertMissing('@media-rules-warning')
195+
->assertMissing("@channel-issue-{$postPlatform->id}")
196+
->assertEnabled('@post-submit')
197+
->click('@editor-tab-preview')
198+
->assertNoJavaScriptErrors();
199+
200+
waitForChannelIssueTestId($page, 'instagram-feed-media');
201+
202+
$heightToWidth = $page->script('(() => { const frame = document.querySelector("[data-testid=instagram-feed-media]"); const rect = frame.getBoundingClientRect(); return rect.height / rect.width; })()');
203+
204+
expect(abs($heightToWidth - 4 / 3))->toBeLessThan(0.01);
205+
});
206+
207+
test('Instagram original preview falls back to square when image dimensions are unavailable', function () {
208+
[$post] = seedInstagramFeedImagePost(imageMeta: []);
209+
210+
$page = visit(route('app.posts.edit', $post));
211+
$page->click('@editor-tab-preview');
212+
waitForChannelIssueTestId($page, 'instagram-feed-media');
213+
214+
$heightToWidth = $page->script('(() => { const frame = document.querySelector("[data-testid=instagram-feed-media]"); const rect = frame.getBoundingClientRect(); return rect.height / rect.width; })()');
215+
216+
expect(abs($heightToWidth - 1.0))->toBeLessThan(0.01);
217+
});
218+
219+
test('Instagram aspect selection still enforces image size limits', function () {
220+
[$post] = seedInstagramFeedImagePost(size: 9 * 1024 * 1024);
221+
222+
$page = visit(route('app.posts.edit', $post));
223+
$page->click('@instagram-settings-toggle');
224+
waitForChannelIssueTestId($page, 'media-rules-warning');
225+
226+
$page->assertPresent('@media-rules-warning')
227+
->assertDisabled('@post-submit')
228+
->assertNoJavaScriptErrors();
229+
});

0 commit comments

Comments
 (0)