- add Telegram and Discord to Supported Platforms (3x4 grid)
- point documentation links at docs.trypost.it (was trypost.it/docs)
- correct the GitHub org slug trypost-it -> trypostit (broken links)
- drop the "TryPost 1.0 is here" announcement and the non-existent Roadmap link
YouTube uploads through the Google API client's resumable handler, not our
HTTP client, so the leak couldn't be reproduced for it and the nextChunk loop
can't be exercised by a test without making the Google client injectable.
Keeping this PR to changes that are both reproduced and covered by a test
(X and LinkedIn); YouTube is tracked separately.
The same leak audited in the X publisher exists wherever a video is streamed
through a per-chunk request loop. Reproduced with the reported 49MB file:
LinkedIn's plain Http::withToken() chunk loop grew to ~146MB (and it allows
videos up to 5GB). Centralize the fix as freeChunkMemory() on the shared
trait and call it (after unset) in the X, LinkedIn and YouTube loops so memory
stays flat.
Audited and left alone: TikTok and Instagram pull from a video_url (no local
upload); Facebook and Bluesky stream the whole file in a single request.
Adds a LinkedIn multi-chunk video test.
The APPEND loop sent each 1MB segment without releasing it: the chunk plus
its multipart copies and the Guzzle/retry object graph were kept alive by
reference cycles the GC doesn't reclaim mid-loop, so memory grew ~4MB per
segment. A 49MB video peaked at ~242MB locally and crossed the 512MB limit
in production. Freeing the segment and forcing cycle collection each
iteration keeps memory flat (~50MB regardless of video size).
Also harden socialHttp()'s retry predicate: it read $exception->response
directly, which fatals on exceptions without a response (e.g. a
ConnectionException during a long upload), masking the real error.
Mirror the persona handling: identify the user with a single `goals` person
property instead of an array plus a boolean per goal. Drops the goalProperties
helper.
- add an end-to-end walk (account gate -> persona -> goals -> connect ->
Stripe) and a self-hosted bypass test, plus the connect no-workspace and
just-exploring-saved cases
- drop the just_exploring exclusivity: the backend now saves any valid goal
combination (the front-end still clears siblings as a UX nicety), removing
the withValidator rule, the unused Goal::isExclusive(), and the now-unused
goals_exclusive copy
After picking who they are, users now pick what they want to achieve with
TryPost. A multi-select goal step (12 options + an exclusive "just exploring"
and "something else") sits between the persona step and connect, mirroring the
persona screen's style.
The goals persist to a json column on users and are mirrored to PostHog on
identify (onboarding_goals array plus a boolean per goal), so campaigns can be
cross-tabbed against the intent they actually attracted. connect now requires
both a persona and at least one goal; persona store advances to the goal step.
Options are grounded in TryPost's real capabilities (publishing, AI content,
brand voice, automation via API/MCP, collaboration, analytics) and copy is
localized in en/es/pt-BR.
Pinterest video pins failed with "provide cover_image_url ... or
cover_image_key_frame_time" because no cover was sent. Default to
cover_image_key_frame_time 0 (first frame) when the user provides no
cover_image_url — verified against Pinterest's OpenAPI spec (integer
seconds, minimum 0, falls back to the last frame past the duration).
YouTube Shorts derive their required title from the post content, so the
publisher rejects an empty post — but the editor still let it be scheduled.
Gate it in compliance: a youtube_short with empty content is now flagged
(requires_text) so it can't be scheduled, instead of failing at publish.
Connect flow:
- surface OAuth failures: useOAuthPopup forwards {success, message}; the
accounts grid toasts the error instead of silently reloading
- detect a blocked popup and toast a "allow popups" message
- PopupCallback only promises auto-close when it has an opener, otherwise
tells the user to close it; adds role=status/aria-live and aria-hidden icons
- drop the dead LinkedInPage connect-route entry and the unsent access_token
field on the Facebook page-select type
Media/gallery:
- delete dead code: MediaDropzone.vue and useDragAndDrop.ts (no references)
- GalleryBrowser toasts per-file upload failures instead of swallowing them;
cards are always clickable now that videos open in the lightbox
- formatDurationMs rounds the sub-second branch and tolerates undefined
- advertise PDF in the gallery's accepted-formats hint
i18n: popup_blocked, manual_close (accounts) and upload.failed (assets) in all locales.
Five hand-rolled formatDuration variants (two of them byte-identical
across the automations pages) collapse into formatClock, formatDurationWords
and formatDurationMs on the date helper, all backed by dayjs.duration.
Migrates the video badge, AI loading timer, media validation params, and
the automation metrics/invocations tables onto them. Output is unchanged
except the loading timer's minutes are no longer zero-padded (5:30, not 05:30).
ImagePreviewDialog now renders documents via an <iframe> (the browser's
native PDF viewer) alongside images and videos. The post view, gallery
browser, and editor composer no longer divert PDFs to a new tab or strip
them from the collection — a PDF opens in the dialog and navigates
(prev/next) together with the other media.
Document that media-type detection lives in App\Enums\Media\Type (backend)
and resources/js/lib/mediaType.ts (frontend), the three types
(image/video/document), the type→mime→extension precedence, the MediaType
union on every media interface, and acceptAttribute() for upload accepts.
Mirror the backend App\Enums\Media\Type on the frontend so "is this an
image/video/PDF?" has a single definition instead of being hand-written
(type === 'x' || mime_type startsWith ...) across a dozen files.
- add lib/mediaType.ts: MediaType union, classify(), fromMimeType(),
fromExtension(), isImage/isVideo/isDocument/isGif, acceptAttribute()
- route every MediaItem detection and every File-upload validator through it
- fix MediaDropzone rendering a typeless image item (AI/Unsplash/Giphy)
as a broken <video> via the img-vs-video v-else
- collapse getMediaIncompatibilityReason onto the shared
getMediaValidationWarning core (canSchedule / variant selection unchanged)
- tighten MediaItem/AssetMedia/SavedMedia/PickedMedia .type to MediaType
- replace GalleryBrowser's hardcoded accept list with acceptAttribute()
- publishCarousel now caps images at the platform max so the API/MCP paths
can't bypass the 10-image limit the UI enforces
- waitForProcessing throws on timeout instead of posting against an asset
still being processed; poll attempts/interval extracted to overridable
protected methods for testability
- scope EnsureHasWorkspace to connect entry points + disconnect only;
session-driven OAuth callbacks/selects stay ungated so a missing current
workspace can't HTML-redirect the popup instead of closing it cleanly
- DeleteWorkspace now reassigns affected users to another workspace they
belong to (instead of nulling current_workspace_id), so deleting the
current workspace while owning others no longer bounces to "create".
- Remove the dead LINKEDIN_PAGE_CLIENT_REDIRECT env (the unified flow uses
a single callback; config no longer reads a page redirect).
- useOAuthPopup uses the Wayfinder connect helpers per platform instead of
hardcoding /connect/{platform}.
- Unify the Inertia\Response alias in Mastodon/Bluesky controllers to
InertiaResponse, matching the other connect controllers.
- Add tests: workspace reassignment on delete, and independent refresh of
a linkedin row vs its linkedin-page sibling.
EnsureAccountReady bundled a subscription gate (redirects to onboarding,
SaaS only) with a workspace gate (redirects to workspace creation). The
connect routes can't sit behind it because connecting/disconnecting
happens during onboarding, before a subscription exists.
Split the workspace gate into a standalone EnsureHasWorkspace middleware:
- EnsureAccountReady is now subscription-only.
- EnsureHasWorkspace redirects to workspace creation when there is no
current workspace, in both SaaS and self-hosted modes.
- The social connect group gains EnsureHasWorkspace; the main app group
gains it alongside EnsureAccountReady (listed after it, so the
subscription gate still runs first — no custom middleware priority).
- The repeated `if (! $workspace) redirect()` guard is removed from the
connect/store/authorize/disconnect/index/toggle handlers, and their
return types are tightened (no more dangling RedirectResponse).
LinkedIn connect's no-workspace path changes from a popup callback to the
same redirect as the other platforms.
Replace the per-platform native form POST + manual CSRF + JSON/Blade
popup callback with a single Inertia mechanism:
- popupCallback() renders the accounts/PopupCallback Inertia page (notifies
the opener + closes the popup) for both the GET OAuth callbacks and the
selection submits. Drops the auth.social-callback Blade view, the
expectsJson JSON branch, and useHttp/useSocialConnect on the frontend.
- Selection/credential pages (LinkedIn, Facebook, Instagram, Bluesky,
Mastodon) use Inertia useForm: automatic CSRF + native validation errors.
- Unify the three selection screens on one row + View/Choose layout; the
LinkedIn company tag now uses a building icon.
- Bluesky auth failures throw ValidationException (422 for XHR, redirect
back with errors otherwise).
Controller tests updated from assertViewIs/assertViewHas to assertInertia.
LinkedIn was the only multi-image platform allowing 20 images; align
maxMediaCount/maxImages/maxFiles to 10, consistent with Facebook,
Instagram and Threads. The AI generation cap (MAX_GENERATED_IMAGES)
was already 10 and is unaffected.
Add direct coverage for the centralized media-type logic: every legacy image/video extension arm of Type::fromExtension, and a new MediaItem DataTransferObject test (fromArray's extension->mime backfill — previously the only zero-coverage branch — plus is* delegation and the null-mime extension fallback). Locks the broad classification lists so a dropped format fails a test.
Media-type detection was duplicated across Media, MediaItem (byte-identical copies), HasMedia::getMediaType, the ContentTypeCompatibleWithMedia rule, and six publishers — each hardcoding MIME prefixes and divergent extension lists (Media's video list even had avi/webm/mkv, contradicting the Type enum's mp4/mov).
Add classify(), fromExtension(), and isGif() to Media\Type as the single source for 'what kind is this?' (broad classification), distinct from fromMime()/allowedMimeTypes() (the strict upload allow-list). Every detector now delegates to the enum; the broad extension lists and MIME prefixes live only there. Behavior-preserving (full suite green); adds direct tests for classify/fromExtension/isGif.
The member and company-page publishers were ~95% identical (~1200 lines). Extract AbstractLinkedInPublisher holding the whole pipeline (media-inferred dispatch, post/carousel/document payloads, image/video/document upload, processing polling, error handling); each concrete publisher now only declares its platform, author URN, and (for pages) the post URL. ~640 duplicated lines removed, single source of truth.
Fix: multi-image posts sent each image URN under 'media', which LinkedIn's multiImage schema rejects with a 422 (unrecognized field 'media' / required 'id' missing). Send the URN under 'id'. Add assertions locking the 'id' field on both carousel tests, a company-page multi-image test that was missing, and a fail-fast assertion that a missing org id makes no HTTP call.
Selecting media in the gallery dropped original_filename, size, and meta when building the picked item, so they never reached the post. Since the gallery is the only way to add media, this left PDF documents without a filename (empty title field, 'PDF' thumbnail label, 'Document' fallback on publish) and skipped size/duration validation for all gallery media. Carry those fields through (guarded for unsplash/giphy items that lack them).
LinkedIn settings module: render only when a PDF is attached (its only relevant config now that format is inferred from media), pre-fill the document title from the PDF filename (editable), drop the redundant media warning (already surfaced on the channel) and the unused contentType prop/binding.
LinkedInSelect: add Person/Organization tag icons. i18n: clearer document-not-alone message in all locales.
The accounts grid and onboarding both build their connect-card list from cases()->filter(isConnectable()) now, leaving the static enabled() helper without any production caller. Remove it and the two unit tests that only exercised it.
The unified LinkedIn card grouped connected accounts by looking the platform up in the rendered card list, but linkedin-page is no longer a card — so an org-only connection fell through to its own network key and the card showed 'Connect' for an already-connected page, with no way to disconnect or reconnect. Expose each account's network on SocialAccountResource and group by account.network instead.
Onboarding still built its connect grid from SocialPlatform::enabled() instead of isConnectable(), so it rendered a standalone LinkedIn Page card with a missing logo whose connect button hit the deleted connect/linkedin-page route (404). Filter by isConnectable() to match the accounts page.
Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types.
Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
- Add a PublishToSocialPlatform test that runs the real job -> publisher chain (no publisher mock) for a linkedin_document post, asserting the documents API flow and the published platform state
- resolveDocumentTitle selects the document item explicitly (first(isDocument)) to match publishDocument, instead of the first media item
Review follow-ups before QA:
- Editor: getMediaIncompatibilityReason rejects a PDF on non-document content types, so the schedule gate and variant auto-snap match the backend rule (compliance i18n in en/es/pt-BR)
- MCP UpdatePostTool: validate effective content_type vs stored media on schedule, closing the schedule-without-content_type gap; share entriesForUpdate/errorsFor with the API path
- Tests: Platform allowedMediaTypes contains Document, URL-attach of a PDF (LinkedIn ok / TikTok rejected), multi-platform PDF rejection, document init-failure/missing-URN, Page publisher PROCESSING_FAILED
Make the document (PDF) exclusivity validation — previously web-only — also apply when scheduling/publishing via the public API and MCP, so a misconfigured post can't slip through these entry points.
- ContentTypeCompatibleWithMedia: stored-media fallback for partial updates + a stored-state assertStoredPostCompatible(Post)
- MCP PublishPostTool: assert stored-state compatibility before publish (the media-side mirror of assertStoredPostPublishable)
- API UpdatePostRequest: validate each platform's effective content_type against effective media on schedule/publish (covers publishing without resubmitting content_type)
- MCP UpdatePostTool: apply the rule on schedule with stored-media fallback
- Tests: API + MCP happy + rejection paths, plus rule fallback/precedence units
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
The persona options mix individual roles (creator, developer, marketer) with
organization types (startup, agency, store), but the prompt only asked what
describes 'you'. Make it read intentionally:
- Broaden the prompt to 'you or your business'.
- Creator -> Content creator and Marketer -> a proper role label (es/pt:
Profesional/Profissional de marketing) so the role group is consistent.
Completes the persona grid to a 3x3 with two high-value segments:
Marketer (in-house marketing / social media, a scheduling tool's core user)
and Online store (e-commerce). Labels in all three locales, with
IconSpeakerphone and IconShoppingBag glyphs. Other stays last.
Adds a Developer option to the onboarding persona step (after Freelancer),
with labels in all three locales (Developer / Desarrollador / Desenvolvedor)
and an IconCode glyph in the persona grid. The option flows to the frontend
via Persona::cases() and is accepted by the existing enum validation.
A Bluesky post embed is images XOR video, so a post that carries both can't
be published there. Enforce it per selected platform:
- Add ContentType::supportsMixedMedia() (false only for BlueskyPost) and reject
image+video in ContentTypeCompatibleWithMedia when the type forbids it.
- Mirror it client-side (useMediaRules forbidsMixedMedia + usePostCompliance)
so the editor blocks scheduling with an inline reason before submit, like
every other per-platform compatibility check.
- Add the no_mixed_media message in all three locales (en/es/pt-BR).
- Cover the rule and enum, including the GIF-counts-as-image case.
Builds on the video-upload feature with house-style cleanup and fixes:
- Extract downloadToTempFile / unwrapJobStatus / videoUploadFormat helpers and
guard tempnam/fopen on the image path too (no magic numbers; named consts).
- Send the real content-type and extension for Bluesky's four accepted formats
(mp4, mpeg, webm, mov), falling back to mp4 for anything else.
- Mint the upload and status service-auth tokens once and reuse them.
- Bound the whole upload/poll/retry flow to a wall-clock budget under the queue
job timeout so a stuck transcode degrades to a text post instead of being
killed mid-flight.
- Config-drive the poll interval, max video size, and video-service hosts.
- Expand BlueskyPublisherTest to cover every branch: 409 with and without a
blob, upload/status token failures, did:plc fallback and did:web resolution,
the format mapping, retries, and timeout.
Address review feedback and a transient failure mode seen in production:
- Guard tempnam()/fopen() returning false (bail cleanly instead of a
TypeError later) and guard the finally-block unlink() with is_string().
- pollVideoJob() now bails on a non-2xx getJobStatus (e.g. expired token)
instead of sleeping to the timeout and masking the real error.
- resolvePdsEndpoint() maps did:web colon segments to host + path per the
did:web spec (did:web:example.com:user:alice -> .../user/alice/did.json)
instead of treating the whole remainder as a host.
- The video service occasionally fails a job transiently
(JOB_STATE_FAILED "Failed to process video") for valid input, so the
upload+poll is retried up to VIDEO_UPLOAD_ATTEMPTS times (split into a
new attemptVideoUpload() helper; the upload service-auth token is minted
once and reused across attempts).
Adds a test covering retry-after-transient-failure. Full suite (23) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BlueskyPublisher only attached images; posts whose media was a video
published as text-only with no embed. Bluesky videos do not use the PDS
uploadBlob path — they go through the separate video service
(video.bsky.app), which transcodes the file and writes the resulting blob
back to the account's PDS.
Add a video path that runs when no image embed was built (a post carries
either images or one video, never both):
1. Resolve the account's real PDS host from its DID document (entryway
accounts store bsky.social as `service`, but the video service-auth
audience must be the actual *.host.bsky.network PDS).
2. Mint a com.atproto.server.getServiceAuth token scoped to uploadBlob.
3. POST the bytes to app.bsky.video.uploadVideo.
4. Poll app.bsky.video.getJobStatus until the blob is ready, then embed
it as app.bsky.embed.video.
Failures (download, oversized >100MB, service-auth, transcode) log and
return null so the post still publishes as text rather than crashing the
job — mirroring uploadBlob(). New NSID constants live in BlueskyLexicon
and the video service / PLC directory hosts are configurable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- store(): members without connected accounts no longer get redirected into
the now-admin-only /accounts (403); non-managers go to the calendar with
the same flash, admins still go to /accounts
- cover the SyncPostPlatforms can('update') gate (viewer creates no platform
rows; member does) and the store redirect split, in WorkspaceRolePermissions
- cover PostPolicy::duplicate viewer-denied
- docs sidebar link uses the canonical https://docs.trypost.it
- drop orphaned sidebar.support.{discord,last_updates} keys in all locales
- remove the explanatory isLocked comment in Edit.vue