feat: proactive connection check for at-risk posts + SocialAccount name centralization (#256)

* chore: gitignore .superpowers/ scratch workspace

Holds per-plan subagent-driven-development artifacts (ledger, briefs,
review packages) — scratch state, not part of the shipped codebase.

* feat: add connection_warning_sent_at to post_platforms

* feat: add PostAtRisk notification type and translations

* fix: add user_id to NotificationPreferenceFactory definition for ->create() support

* feat: add PostAtRisk mailable and email template

* feat: add VerifyUpcomingPostConnections job

* fix: guard VerifyUpcomingPostConnections against transient errors and cross-workspace leaks

- Add a generic \Exception catch around ConnectionVerifier::verify() so a
  transient error (e.g. ConnectionException) on one account can't abort
  processing of every other at-risk account in the workspace run.
- Eager-load socialAccount.workspace so markAsTokenExpired's observer chain
  never lazy-loads it — this only ever manifested once 2+ distinct accounts
  were hydrated in a single run (Eloquent only sets preventsLazyLoading on
  batch hydration of >1 row), which is exactly the multi-account scenario
  this job exists to handle.
- Add covering tests: enabled=false posts are excluded, one workspace's
  at-risk posts never leak into another workspace's notification, and an
  unexpected exception on one account doesn't stop the rest of the run.

* feat: add social:check-upcoming-connections command and schedule it

* fix: add composite index for the 15-minute upcoming-post connection query

post_platforms(status, connection_warning_sent_at) supports the filter both
VerifyUpcomingPostConnections and social:check-upcoming-connections run every
15 minutes; without it, every run does a full table scan that only grows as
posts accumulate.

* fix: localize the PostAtRisk email's per-account line and label times as UTC

The postsLabel line was the only hardcoded-English content in an otherwise
fully-translated email, and it showed scheduled_at times with no timezone
indicator even though the app stores everything in UTC. Add
mail.post_at_risk.posts_label (pluralized, one entry per locale, mirroring
each locale's existing post_at_risk.subject plural-boundary syntax) and use
trans_choice() to build the line, with a literal " UTC" suffix left
untranslated in every locale like a unit abbreviation.

Also document why content() reassigns the public $atRiskGroups property
instead of using a local variable (Mailable::buildViewData() overwrites
with() data with same-named public properties).

* fix: time-box the warning dedup and guard against orphaned/ownerless rows

- Re-arm connection_warning_sent_at after a day instead of permanently
  suppressing it, so a post rescheduled back into the risk window after a
  stale warning is re-evaluated instead of silently skipped forever.
- Exclude post_platforms with a null social_account_id from the at-risk
  query. With tries=1, dereferencing a null socialAccount relation would
  abort the whole workspace run, including already-detected broken accounts.
- Resolve and check the workspace owner before stamping
  connection_warning_sent_at, so an ownerless workspace's posts are left
  un-warned (available to be picked up once it gets an owner) instead of
  being marked "warned" with no notification ever sent.

Applied the same dedup time-boxing and null-account guard to the
social:check-upcoming-connections dispatch query for consistency.

* fix: PostAtRisk email is always English — drop the locale translation layer

config('app.locale')/App::setLocale() is only ever set by the SetLocale
web middleware, which reads a cookie off the incoming HTTP request. Every
Mailable in this branch is built inside a queued job (SendNotification),
which runs outside the HTTP request lifecycle entirely — no middleware,
no cookie, nothing sets the locale there. So content() always resolved
'app.locale' to the static APP_LOCALE default ('en') regardless of the
recipient's actual preference: the 16-locale mail.post_at_risk.* keys
were dead weight from the start, matching an existing (pre-existing,
out of scope here) gap in the sibling WorkspaceConnectionsDisconnected/
AccountDisconnected mailables.

Replaces the trans_choice()/__() calls with plain English strings built
directly in PostAtRisk, and removes the now-unused mail.post_at_risk.*
block from all 16 locale files. Also strengthens the mailable test to
assert the full "N post(s) scheduled: ... UTC" string, not just a
fragment of it.

* refactor: consolidate the two post_platforms migrations from this branch into one

connection_warning_sent_at and its supporting index were added in two
separate migrations (the column in the original task, the index during
final review). Both are still unmerged/unshipped on this branch, so
folding the index into the same migration that adds the column is safe
and keeps the schema change to post_platforms as one unit instead of two.

Verified with a full rollback + re-migrate cycle that the consolidated
up()/down() is self-consistent.

* refactor: add PostPlatform::scopeEnabled(), replace ->where('enabled', true) everywhere

The raw where('enabled', true) clause was duplicated across 17 call sites
in 12 files (13 including the 2 this branch added), all expressing the
same rule PublishPost enforces at publish time: only enabled platforms
are eligible. Added a scopeEnabled() to PostPlatform and swapped every
query-builder call site to ->enabled().

Three call sites are intentionally left untouched: they filter an
already-loaded relation Collection (->postPlatforms->where(...), no
parens), which is Collection::where(), not a query scope — a query scope
can't apply to an in-memory collection.

No inverse (enabled = false) query pattern exists anywhere in the
codebase — 'enabled' => false only ever appears as a write when a post
is disabled/synced, never as a read filter — so no scopeDisabled() was
added; nothing would call it.

* test: cover re-armed post_platform where the account was reconnected

The re-arm dedup fix (connection_warning_sent_at older than a day is
treated as null) only had coverage for "still broken, warns again" and
"too recent, stays skipped". Missing: the row gets re-evaluated (verify()
is called, not skipped) but comes back healthy because the user
reconnected in the meantime — nothing should change (no new warning, no
notification, marker stays at its old value).

* fix: dispatch-level uniqueness, index the enabled filter, close markAsTokenExpired race

From a deep review pass on the whole branch:

- VerifyUpcomingPostConnections now implements ShouldBeUnique (keyed on
  workspaceId, 300s window). withoutOverlapping() on the schedule only
  serializes the fast-dispatching command; a queue backlog could still let
  two jobs for the same workspace run concurrently, both mailing the owner
  for the same at-risk posts.
- The composite index now covers enabled too (status, enabled,
  connection_warning_sent_at) — every query that uses it filters on all
  three, so the index previously required a heap fetch per row just to
  check enabled.
- markAsTokenExpired() silently no-ops if it loses the account's status
  lock to a concurrent process (a publish attempt, the daily check). The
  job used to push the account into the at-risk notification regardless
  of whether the update actually landed. It now re-checks the account's
  status after the call and only warns if the transition is confirmed —
  a lost race just defers the account to the next run instead of sending
  a misleading "reconnect" email for an account whose status didn't change.

Also includes an unrelated stray Pint fix (inline \Throwable -> imported)
in SendNotification.php that had been sitting uncommitted.

* refactor: centralize account handle/display name, expose to frontend, close review findings

Adds SocialAccount::handle()/accountDisplayName() plus appended
display_label/handle_label JSON fields, replacing duplicated
username/display_name fallback logic scattered across platform
previews, NetworkConnectGrid, PreviewTab, Calendar, and the post
editor pages.

Also closes the remaining findings from the final review on this
branch: escapes the workspace name in PostAtRisk's intro (and drops
the now-unnecessary raw-HTML rendering), fixes the tautological
"dispatches once per workspace" test, adds plural/subject test
coverage for PostAtRisk, raises VerifyUpcomingPostConnections'
uniqueFor to cover the full schedule cadence, and updates a stale
docblock.

* test: cover draft-post exclusion, account status after PlatformUnavailableException

Adds the two coverage gaps left open by the last review: a post still
in Draft status inside the 1-hour window must not trigger a check or
warning, and a PlatformUnavailableException must leave the account
status untouched. Also drops the dedicated PostAtRisk XSS test — the
intro is now plain Blade-escaped text, so the coverage is redundant
with the framework's own escaping.

* fix: close final review findings — i18n notification, empty-string fallback, missed refactor sites

- Localize the in-app "post at risk" notification title in all 16
  locales via trans_choice (the email stays English, unchanged)
- Use ?: instead of ?? in handle()/accountDisplayName()/handleLabel()
  so an empty-string username/display_name still falls back, matching
  the old Vue || behavior
- Migrate the 3 frontend sites the earlier sweep missed (Index.vue,
  SocialAccountsGrid.vue, ScheduleTab.vue) to display_label/handle_label
- Fix avatar-initial fallback in the platform preview components to use
  display_label instead of raw display_name
- Correct handle_label's TS type to string | null across 10 files to
  match the accessor's actual return type
- Add test coverage for the command-level "already warned" dedup path
  and the in-app Notification row created alongside PostAtRisk's email

* fix: notification storm, duplicate-email race, and queue payload bloat in upcoming-post checks

Three correctness issues found by review, fixed after discussion:

- An already-broken account could get a fresh PostAtRisk email every
  15 minutes for as long as it stayed broken, if new posts kept
  entering the 1-hour risk window. Gated with a per-account 60-minute
  renotify cooldown.
- Two concurrent jobs (RefreshExpiringTokens and this one) could each
  discover the same dead token and send their own email for it
  (AccountDisconnected + PostAtRisk) within the same tick. Gated with
  a 5-minute grace period, applied only when another process already
  transitioned the account before we got to it — not when we're the
  one making the transition.
- PostAtRisk carried full SocialAccount/PostPlatform/Post model
  graphs on the queue payload, since SerializesModels can't reduce
  models nested inside a plain array/Collection to lightweight
  identifiers. It now carries only post_platform IDs and rehydrates
  at send time, with envelope()/content() sharing one memoized query
  so their counts can't disagree.

Also replaces the account-health cache with a persisted
SocialAccount.last_verified_at column, and narrows the actual
platform API calls to only fire once a post's nearest scheduled_at
is within 30 minutes — enough lead time to reconnect, without
spending API budget checking a full hour out.

* fix: replace dead unsubscribe link with notification preferences, finish display_label sweep

The shared mail footer's unsubscribe link was permanently dead code
(unsubscribe_url was never passed by any Mailable). Replaced it with
a fixed "Manage notifications" link to the real settings page,
via route('app.notifications.preferences').

Also closes out the remaining sites still computing the
username/display_name fallback locally instead of reading the
backend-computed display_label: 8 more Vue components (platform
previews, per-platform post-editor settings, the AI post wizard, the
automation Generate node config, and the analytics account selector)
plus two PHP call sites (PostPlatform::getDisplayNameAttribute(),
already fixed on main before this branch, and the template image
generator's rendered footer text).

* fix: only show "Manage notifications" on preference-driven emails

The link doesn't make sense on transactional emails that always send
regardless of notification preferences (password reset, email
verification) or that go to recipients who may not even have an
account yet (workspace invite) — and the settings page it points to
requires login, which is actively broken for the first two.

Split the shared footer into two Maizzle components: footer.html
(plain) for the 3 transactional templates, footer-authenticated.html
(adds the link) for the 6 that go through SendNotification and
respect the recipient's notification preferences.

* fix: lock PostAtRisk's subject to the dispatch-time count, expose handle_label from analytics

PostAtRisk's subject/previewText were recomputed from a fresh DB
query at send time, while the in-app notification's title (built in
VerifyUpcomingPostConnections::notifyOwner()) used the count observed
at dispatch time. If a post_platform row disappeared in between, the
two could disagree. The count is now passed into the mailable
explicitly and reused for both — the body's account/post details
still rehydrate fresh from the DB, preserving the anti-staleness fix
from earlier in this branch.

Also adds handle_label to AnalyticsController's account payload,
matching every other endpoint that serializes a SocialAccount.

* fix: don't abort the whole workspace run if an account is deleted mid-verify

An exception thrown inside a catch block isn't routed to a sibling
catch, so $account->refresh() throwing ModelNotFoundException (the
user disconnected/deleted the account in the brief window between
this job loading it and handling the TokenExpiredException) escaped
handle() entirely. With tries = 1, that killed the run for every
other account in the same workspace, not just the deleted one.

Also fixes an inconsistent placeholder in PlatformPreview.vue
(handle_label: null instead of '', matching display_label).

* fix: guard against deleted accounts, guarantee a non-empty account name

Closes the last 4 findings from the sixth review round:
- VerifyUpcomingPostConnections now skips a group whose account
  resolved to null (deleted between the main query and its eager-loaded
  relation), instead of an unguarded property access aborting the
  whole workspace's run
- the same job's nested exception handler now covers any \Exception
  from markAsTokenExpired() (lock/DB failures), not just
  ModelNotFoundException
- PostAtRisk drops a rehydrated group whose account no longer exists
  instead of crashing the render (verified: fails without the fix,
  passes with it)
- AnalyticsController's handle_label field is now actually consumed by
  AnalyticsAccountSelector.vue instead of being unused payload

Also closes a real gap: every connector requests enough OAuth scope to
populate at least one of username/display_name (confirmed for TikTok,
whose account.py comment implied otherwise but whose connect() scopes
always include user.info.profile), so accountDisplayName()/handle()/
displayLabel/handleLabel now return a guaranteed non-empty string
(falling back to the platform label only as a last resort) instead of
being nullable. This removes the now-pointless @if guards around
accountDisplayName() in the account-disconnected and post-at-risk
email templates, and lets ~30 frontend files drop the `| null` from
display_label/handle_label and the ?? undefined fallbacks that only
existed to satisfy that type.

* fix: drop the now-pointless ?? '' fallback on display_label in TemplateImageGenerator

display_label is a guaranteed non-empty string (see 950558b4).

* fix: correct social_account's TS type to nullable in Index.vue and Calendar.vue

Both declared social_account as required while their own templates
used optional chaining (pp.social_account?.display_label) — the type
was lying. social_account_id is nullable and the account can be
deleted (FK is nullOnDelete), so the field genuinely can be null.

Swept every other social_account/socialAccount field in resources/js
for the same mismatch; all others already declared it correctly.

* Centralize avatar-initial extraction via getInitials()

Replace hand-rolled .charAt(0)/.charAt(0).toUpperCase() avatar-initial
logic across social account previews, the accounts grid, the analytics
account selector, and the mention picker with the existing
useInitials() composable already used by Avatar.vue.

* Drop pointless display_label fallbacks now that it's always populated

display_label is guaranteed non-empty (falls back to the platform
label server-side), so || 'Channel' / || 'TryPost' / ?? platform were
unreachable.

* Fix cold-review findings: dead handle_label guard, slug leak, wrong post count

- AnalyticsAccountSelector: the "@handle" line's guard/value must read the
  raw username (nullable — Facebook Pages and Telegram channels legitimately
  have none), not handle_label, which always resolves to something and made
  the guard permanently true. Drop the now-orphaned handle_label field from
  the analytics payload/type since nothing else in analytics used it.
- PlatformPreview: the no-account-selected fallback now uses
  getPlatformLabel() instead of the raw platform slug, matching the
  backend's own last-resort label fallback.
- VerifyUpcomingPostConnections: count distinct posts (post_id), not
  post_platform rows, so one post spanning multiple broken accounts doesn't
  inflate the at-risk count in the email subject and notification title.

* Fix cold-review round 2: silent Telegram/Discord false negative, flaky email ordering, dead display_name

- VerifyUpcomingPostConnections: ConnectionVerifier::verify() reports a
  dead Telegram/Discord connection by returning false rather than
  throwing. The job discarded that return value, so a bot removed from
  a channel/guild was stamped last_verified_at and silently trusted
  healthy for the next 40 minutes — no warning, post just fails at
  publish time. Route a false return through the same
  TokenExpiredException handling used by every other platform.
- PostAtRisk: atRiskGroups() had no ORDER BY, so the per-account
  "N posts scheduled: H:i, H:i UTC" line rendered in arbitrary
  (physical row) order. Sort by scheduled_at before formatting.
- Drop the orphaned display_name field from the analytics payload/type
  (superseded by display_label; nothing in resources/js/components/
  analytics or pages/analytics read it).

* Add social icons and copyright to email footers

Icons match the trypost-site footer (outline @tabler/icons style,
converted to PNG since email clients — notably Outlook desktop — don't
render inline SVG). Reordered footer content: tagline, manage-notifications
link, icons as the closing element, copyright line last.

* Standardize connection-verify error classification across all 13 platforms

Every platform now follows one contract: verify() returns true on a
healthy connection, throws TokenExpiredException only on a confirmed
dead connection, and PlatformUnavailableException on anything else
(rate limit, 5xx, unrecognized). Previously most platforms silently
returned false on anything but a 401, so callers (all of which only
react via try/catch) could never distinguish "definitely dead" from
"transient" — and Telegram/Discord never threw at all.

Each platform's "is this confirmed dead" check now lives next to its
existing publish-time error classifier (App\Exceptions\Social\*PublishException)
instead of being re-typed inline in ConnectionVerifier, closing real,
already-drifted gaps between the two paths:

- TikTok and Mastodon both had a bare "status === 401/403" check shared
  between publish and verify, but TikTok's scope_not_authorized and
  Mastodon's write-scope 403 use the same status for a non-fatal scope
  gap, not a dead token — verify's lower-privilege endpoint keeps its
  own stricter check on top instead.
- Telegram/Discord authenticate with one bot token shared across every
  connected account; a 401 means that shared token is misconfigured
  (an operator problem), never that one specific account is broken —
  excluded from both platforms' confirmed-dead checks accordingly.
- Facebook/InstagramFacebook/Mastodon/Telegram/Discord have no
  per-account refresh flow at all, so a confirmed rejection now skips
  the pointless refresh-and-retry (Platform::hasTokenRefreshFlow()).

Also fixes two bugs found while hardening VerifyUpcomingPostConnections:
a post hard-deleted mid-run could crash the whole job for every other
account in the batch (now filtered per group), and two overlapping runs
of the same job could send duplicate PostAtRisk warnings (now a
conditional claim on connection_warning_sent_at).

* Skip paused accounts in upcoming-post connection checks, close claim race

A paused (is_active=false) social account already fails at publish time
before any platform API call, so it shouldn't trigger a proactive
connection check or "reconnect" warning. Guard added at dispatch time
(CheckUpcomingPostConnections) and re-checked fresh mid-run inside
VerifyUpcomingPostConnections's per-account loop, since the job can take
real wall-clock time working through a workspace and an account can be
paused or deleted after the query-time guard already ran.

Also wraps the connection_warning_sent_at claim in a SELECT ... FOR UPDATE
transaction (ordered by id, 3 retries) to close a race between two
overlapping runs of the same job double-claiming and double-emailing about
the same post_platform.

* Clarify "commit" wording in claim-transaction comment

Reads ambiguously as a git commit on a PR diff; it means the DB
transaction commit.
This commit is contained in:
Paulo Castellano 2026-08-09 10:10:39 -04:00 committed by GitHub
parent 1adef7787c
commit a147c7414b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
132 changed files with 4229 additions and 248 deletions

1
.gitignore vendored
View file

@ -30,3 +30,4 @@ yarn-error.log
/.vscode /.vscode
/.zed /.zed
/docs/ /docs/
/.superpowers/

View file

@ -31,7 +31,7 @@ public static function execute(Post $post): void
$post->postPlatforms()->create([ $post->postPlatforms()->create([
'social_account_id' => $account->id, 'social_account_id' => $account->id,
'platform' => $account->platform->value, 'platform' => $account->platform->value,
'platform_name' => $account->display_name, 'platform_name' => $account->accountDisplayName(),
'platform_username' => $account->username, 'platform_username' => $account->username,
'platform_avatar' => $account->getRawOriginal('avatar_url'), 'platform_avatar' => $account->getRawOriginal('avatar_url'),
'content_type' => ContentType::defaultFor($account->platform), 'content_type' => ContentType::defaultFor($account->platform),

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Jobs\VerifyUpcomingPostConnections;
use App\Models\PostPlatform;
use Illuminate\Console\Command;
class CheckUpcomingPostConnections extends Command
{
protected $signature = 'social:check-upcoming-connections';
protected $description = 'Proactively verify social connections for posts scheduled within the next hour';
public function handle(): void
{
$workspaceIds = PostPlatform::query()
->where('post_platforms.status', PostPlatformStatus::Pending)
->enabled()
// Mirrors VerifyUpcomingPostConnections::atRiskPostPlatforms() —
// a paused account can't be the reason to dispatch a job for its
// workspace, since the job itself will skip it too. whereHas()
// already excludes a null social_account_id (nothing to join to).
->whereHas('socialAccount', fn ($query) => $query->where('is_active', true))
->where(function ($query) {
$query->whereNull('post_platforms.connection_warning_sent_at')
->orWhere('post_platforms.connection_warning_sent_at', '<', now()->subDay());
})
->join('posts', 'posts.id', '=', 'post_platforms.post_id')
->where('posts.status', PostStatus::Scheduled)
->whereBetween('posts.scheduled_at', [now(), now()->addHour()])
->distinct()
->pluck('posts.workspace_id');
foreach ($workspaceIds as $workspaceId) {
VerifyUpcomingPostConnections::dispatch($workspaceId);
}
$this->info("Dispatched {$workspaceIds->count()} upcoming-post connection checks.");
}
}

View file

@ -24,7 +24,7 @@ public function handle(): void
->where('updated_at', '<=', now()->subHour()) ->where('updated_at', '<=', now()->subHour())
->each(function (Post $post) use (&$count) { ->each(function (Post $post) use (&$count) {
$post->postPlatforms() $post->postPlatforms()
->where('enabled', true) ->enabled()
->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying]) ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying])
->where('updated_at', '<=', now()->subHour()) ->where('updated_at', '<=', now()->subHour())
->update([ ->update([
@ -39,7 +39,7 @@ public function handle(): void
// Delayed platform-unavailable retries keep the platform Retrying with a // Delayed platform-unavailable retries keep the platform Retrying with a
// fresh updated_at — do not finalize the post while that work is still live. // fresh updated_at — do not finalize the post while that work is still live.
$stillActive = $post->postPlatforms() $stillActive = $post->postPlatforms()
->where('enabled', true) ->enabled()
->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying]) ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying])
->exists(); ->exists();
@ -47,7 +47,7 @@ public function handle(): void
return; return;
} }
$enabledPlatforms = $post->postPlatforms()->where('enabled', true)->get(); $enabledPlatforms = $post->postPlatforms()->enabled()->get();
$total = $enabledPlatforms->count(); $total = $enabledPlatforms->count();
$publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count(); $publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count();

View file

@ -11,6 +11,7 @@ enum Type: string
case PostPartiallyPublished = 'post_partially_published'; case PostPartiallyPublished = 'post_partially_published';
case PostReady = 'post_ready'; case PostReady = 'post_ready';
case AccountDisconnected = 'account_disconnected'; case AccountDisconnected = 'account_disconnected';
case PostAtRisk = 'post_at_risk';
case InviteReceived = 'invite_received'; case InviteReceived = 'invite_received';
case MemberJoined = 'member_joined'; case MemberJoined = 'member_joined';
case MemberRemoved = 'member_removed'; case MemberRemoved = 'member_removed';

View file

@ -310,6 +310,25 @@ public function extendsAccessTokenOnRefresh(): bool
}; };
} }
/**
* Whether ConnectionVerifier has a real per-account token refresh flow
* for this platform. Facebook/InstagramFacebook use Page tokens and
* Mastodon's tokens don't expire (see defaultTokenTtlSeconds()); Telegram
* and Discord authenticate with one bot token shared across every
* connected account of that platform, with no per-account credential to
* refresh at all. For these, a rejected verify call can't be retried
* after a refresh there's nothing to refresh.
*/
public function hasTokenRefreshFlow(): bool
{
return match ($this) {
self::LinkedIn, self::LinkedInPage, self::X, self::Bluesky,
self::YouTube, self::TikTok, self::Pinterest,
self::Threads, self::Instagram => true,
default => false,
};
}
/** /**
* The `platform` column values of the platforms that refresh by extending * The `platform` column values of the platforms that refresh by extending
* their access token in place (Instagram and Threads see * their access token in place (Instagram and Threads see

View file

@ -19,7 +19,7 @@ public static function fromApiResponse(mixed $response): static
$error = data_get($body, 'error', ''); $error = data_get($body, 'error', '');
$errorMessage = data_get($body, 'message', 'An unknown Bluesky error occurred.'); $errorMessage = data_get($body, 'message', 'An unknown Bluesky error occurred.');
if (in_array($error, ['ExpiredToken', 'InvalidToken'], true)) { if (self::isConfirmedDeadToken($response)) {
throw new TokenExpiredException( throw new TokenExpiredException(
message: $errorMessage, message: $errorMessage,
platformErrorCode: $error, platformErrorCode: $error,
@ -74,4 +74,17 @@ public function platform(): string
{ {
return 'bluesky'; return 'bluesky';
} }
/**
* Whether this response confirms the account's own session token is dead
* (not merely a transient or content-specific failure). Shared with
* ConnectionVerifier so both the publish and verify paths agree on what
* a dead Bluesky session looks like.
*/
public static function isConfirmedDeadToken(Response $response): bool
{
$error = data_get($response->json(), 'error', '');
return in_array($error, ['ExpiredToken', 'InvalidToken'], true);
}
} }

View file

@ -86,4 +86,26 @@ public function platform(): string
{ {
return 'discord'; return 'discord';
} }
/**
* Whether this response confirms the bot lost access to THIS specific
* guild (kicked, missing access, or the guild is gone) used by
* ConnectionVerifier against getGuild's response.
*
* Deliberately NOT the same check fromApiResponse() uses above, and not
* called from it: fromApiResponse() classifies channel-message responses
* (channel-send scope, where a 403 there stays a Permission-category
* publish failure rather than disconnecting the account the bot could
* still be a guild member with access to other channels), while this
* classifies getGuild responses (guild-membership scope, where the same
* 403/404 unambiguously means the bot is out of this guild entirely).
* 401 is excluded from both: Discord auth is one bot token shared across
* every connected account, so a 401 means that shared token is
* misconfigured (an operator problem), never evidence that this specific
* guild connection is dead.
*/
public static function isConfirmedDeadGuild(Response $response): bool
{
return in_array($response->status(), [403, 404], true);
}
} }

View file

@ -18,7 +18,7 @@ public static function fromApiResponse(mixed $response): static
$errorMessage = data_get($body, 'message', $rawResponse); $errorMessage = data_get($body, 'message', $rawResponse);
if ($statusCode === 401) { if (self::isConfirmedDeadToken($response)) {
throw new TokenExpiredException( throw new TokenExpiredException(
message: $errorMessage ?? 'Access token has expired or been revoked', message: $errorMessage ?? 'Access token has expired or been revoked',
platformErrorCode: (string) $statusCode, platformErrorCode: (string) $statusCode,
@ -61,4 +61,15 @@ public function platform(): string
{ {
return 'linkedin'; return 'linkedin';
} }
/**
* Whether this response confirms the account's own access_token is dead
* (not merely a transient or content-specific failure). Shared with
* ConnectionVerifier so both the publish and verify paths agree on what
* a dead LinkedIn/LinkedIn Page token looks like.
*/
public static function isConfirmedDeadToken(Response $response): bool
{
return $response->status() === 401;
}
} }

View file

@ -18,7 +18,7 @@ public static function fromApiResponse(mixed $response): static
$errorMessage = data_get($body, 'error', 'An unknown Mastodon error occurred.'); $errorMessage = data_get($body, 'error', 'An unknown Mastodon error occurred.');
if ($status === 401) { if (self::isConfirmedDeadToken($response)) {
throw new TokenExpiredException( throw new TokenExpiredException(
message: $errorMessage, message: $errorMessage,
platformErrorCode: (string) $status, platformErrorCode: (string) $status,
@ -91,4 +91,23 @@ public function platform(): string
{ {
return 'mastodon'; return 'mastodon';
} }
/**
* Whether this response confirms the account's own access_token is dead
* (not merely a transient or content-specific failure). Shared with
* ConnectionVerifier so both the publish and verify paths agree on what
* a dead Mastodon token looks like.
*
* 403 is deliberately NOT included here: on the write-scoped /statuses
* endpoint a 403 can mean the app only has read scope, which doesn't
* prove the token itself is dead (see 'mastodon publisher throws
* permission exception on forbidden'). ConnectionVerifier adds its own
* 403 check on top of this one, because verify_credentials is the
* lowest-privilege read endpoint a 403 there means the token has no
* access at all, a stronger and different signal than a write-scope 403.
*/
public static function isConfirmedDeadToken(Response $response): bool
{
return $response->status() === 401;
}
} }

View file

@ -16,7 +16,7 @@ public static function fromApiResponse(mixed $response): static
$body = $response->json(); $body = $response->json();
$rawResponse = $response->body(); $rawResponse = $response->body();
if ($status === 401) { if (self::isConfirmedDeadToken($response)) {
throw new TokenExpiredException( throw new TokenExpiredException(
message: data_get($body, 'message', 'Access token has expired or been revoked'), message: data_get($body, 'message', 'Access token has expired or been revoked'),
platformErrorCode: (string) $status, platformErrorCode: (string) $status,
@ -90,4 +90,15 @@ public function platform(): string
{ {
return 'pinterest'; return 'pinterest';
} }
/**
* Whether this response confirms the account's own access_token is dead
* (not merely a transient or content-specific failure). Shared with
* ConnectionVerifier so both the publish and verify paths agree on what
* a dead Pinterest token looks like.
*/
public static function isConfirmedDeadToken(Response $response): bool
{
return $response->status() === 401;
}
} }

View file

@ -65,4 +65,25 @@ public function platform(): string
{ {
return 'telegram'; return 'telegram';
} }
/**
* Whether this response confirms the bot lost access to THIS specific
* chat (kicked, blocked, or the chat was deleted) used by
* ConnectionVerifier against getChat's response.
*
* Deliberately NOT the same check fromApiResponse() uses above, and not
* called from it: fromApiResponse() classifies sendMessage/sendPhoto
* responses (message-send scope, where a 403 there stays a
* Permission-category publish failure rather than disconnecting the
* account the bot could still reach other chats fine), while this
* classifies getChat responses (chat-read scope, where the same 400/403
* unambiguously means this one chat is gone). 401 is excluded from both:
* Telegram auth is one bot token shared across every connected account,
* so a 401 means that shared token is misconfigured (an operator
* problem), never evidence that this specific chat connection is dead.
*/
public static function isConfirmedDeadChat(Response $response): bool
{
return in_array($response->status(), [400, 403], true);
}
} }

View file

@ -18,7 +18,7 @@ public static function fromApiResponse(mixed $response): static
$errorCode = data_get($body, 'error.code'); $errorCode = data_get($body, 'error.code');
$errorMessage = data_get($body, 'error.message', 'An unknown TikTok error occurred.'); $errorMessage = data_get($body, 'error.message', 'An unknown TikTok error occurred.');
if ($errorCode === 'access_token_invalid') { if (self::isConfirmedDeadToken($response)) {
throw new TokenExpiredException( throw new TokenExpiredException(
message: $errorMessage, message: $errorMessage,
platformErrorCode: $errorCode, platformErrorCode: $errorCode,
@ -79,4 +79,29 @@ public function platform(): string
{ {
return 'tiktok'; return 'tiktok';
} }
/**
* Whether this response confirms the account's own access_token is dead
* (not merely a transient or content-specific failure). Shared with
* ConnectionVerifier so both the publish and verify paths agree on what
* a dead TikTok token looks like. 10001/10002 are the numeric forms of
* the same access_token_invalid/expired conditions TikTok also reports.
*
* A bare HTTP 401 is deliberately NOT treated as confirmed-dead here:
* TikTok also returns 401 for scope_not_authorized/scope_permission_missed
* (missing video.publish grant see
* https://developers.tiktok.com/doc/content-posting-api-reference-direct-post),
* which is a scope gap, not a dead token, and must stay a Permission-category
* publish failure (see the match arms above) rather than disconnecting the
* account. ConnectionVerifier adds its own bare-401 check on top of this
* one, because /v2/user/info/ only needs the always-granted user.info.basic
* scope a 401 there can't be a scope gap, so it's an unambiguous signal
* the token itself is dead.
*/
public static function isConfirmedDeadToken(Response $response): bool
{
$errorCode = data_get($response->json(), 'error.code');
return in_array($errorCode, ['access_token_invalid', 'access_token_expired', 10001, 10002]);
}
} }

View file

@ -22,7 +22,7 @@ public static function fromApiResponse(mixed $response): static
$typeSuffix = $type !== '' ? basename((string) $type) : ''; $typeSuffix = $type !== '' ? basename((string) $type) : '';
if ($statusCode === 401 || str_contains((string) $type, 'unsupported-authentication')) { if (self::isConfirmedDeadToken($response)) {
throw new TokenExpiredException( throw new TokenExpiredException(
message: $detail ?: 'Access token has expired or been revoked', message: $detail ?: 'Access token has expired or been revoked',
platformErrorCode: $typeSuffix ?: (string) $statusCode, platformErrorCode: $typeSuffix ?: (string) $statusCode,
@ -113,4 +113,17 @@ public function platform(): string
{ {
return 'x'; return 'x';
} }
/**
* Whether this response confirms the account's own access_token is dead
* (not merely a transient or content-specific failure). Shared with
* ConnectionVerifier so both the publish and verify paths agree on what
* a dead X token looks like.
*/
public static function isConfirmedDeadToken(Response $response): bool
{
$type = (string) data_get($response->json(), 'type', '');
return $response->status() === 401 || str_contains($type, 'unsupported-authentication');
}
} }

View file

@ -17,10 +17,18 @@ public static function fromApiResponse(mixed $response): static
$rawResponse = $response->body(); $rawResponse = $response->body();
$reason = data_get($body, 'error.errors.0.reason'); $reason = data_get($body, 'error.errors.0.reason');
$fallbackMessage = data_get($body, 'error.message', 'An unknown YouTube error occurred.');
if (self::isConfirmedDeadToken($response)) {
throw new TokenExpiredException(
message: $fallbackMessage,
platformErrorCode: $reason,
);
}
[$message, $category] = self::mapReasonToMessageAndCategory( [$message, $category] = self::mapReasonToMessageAndCategory(
reason: $reason, reason: $reason,
fallbackMessage: data_get($body, 'error.message', 'An unknown YouTube error occurred.'), fallbackMessage: $fallbackMessage,
); );
return new static( return new static(
@ -62,6 +70,17 @@ public function platform(): string
return 'youtube'; return 'youtube';
} }
/**
* Whether this response confirms the account's own access_token is dead
* (not merely a transient or content-specific failure). Shared with
* ConnectionVerifier so both the publish and verify paths agree on what
* a dead YouTube token looks like.
*/
public static function isConfirmedDeadToken(Response $response): bool
{
return $response->status() === 401;
}
/** /**
* @return array{string, ErrorCategory} * @return array{string, ErrorCategory}
*/ */

View file

@ -51,8 +51,8 @@ public function index(Request $request): Response
->map(fn (SocialAccount $account) => [ ->map(fn (SocialAccount $account) => [
'id' => $account->id, 'id' => $account->id,
'platform' => $account->platform->value, 'platform' => $account->platform->value,
'display_name' => $account->display_name,
'username' => $account->username, 'username' => $account->username,
'display_label' => $account->display_label,
'avatar_url' => $account->avatar_url, 'avatar_url' => $account->avatar_url,
]); ]);

View file

@ -46,7 +46,7 @@ public function index(Request $request, ?string $status = null): Response|Redire
$this->authorize('view', $workspace); $this->authorize('view', $workspace);
$query = $workspace->posts() $query = $workspace->posts()
->with(['postPlatforms' => fn ($query) => $query->where('enabled', true)->with('socialAccount'), 'user', 'labels']); ->with(['postPlatforms' => fn ($query) => $query->enabled()->with('socialAccount'), 'user', 'labels']);
if ($status) { if ($status) {
$query = match ($status) { $query = match ($status) {
@ -123,7 +123,7 @@ public function calendar(Request $request): Response|RedirectResponse
}; };
$posts = $workspace->posts() $posts = $workspace->posts()
->with(['postPlatforms' => fn ($query) => $query->where('enabled', true)->with('socialAccount')]) ->with(['postPlatforms' => fn ($query) => $query->enabled()->with('socialAccount')])
->whereBetween('scheduled_at', [$rangeStart->copy()->utc(), $rangeEnd->copy()->utc()]) ->whereBetween('scheduled_at', [$rangeStart->copy()->utc(), $rangeEnd->copy()->utc()])
->orderBy('scheduled_at') ->orderBy('scheduled_at')
->get() ->get()

View file

@ -21,6 +21,8 @@ public function toArray(Request $request): array
'platform_user_id' => $this->platform_user_id, 'platform_user_id' => $this->platform_user_id,
'username' => $this->username, 'username' => $this->username,
'display_name' => $this->display_name, 'display_name' => $this->display_name,
'display_label' => $this->display_label,
'handle_label' => $this->handle_label,
'avatar_url' => $this->avatar_url, 'avatar_url' => $this->avatar_url,
'profile_url' => $this->profile_url, 'profile_url' => $this->profile_url,
'status' => $this->status, 'status' => $this->status,

View file

@ -21,7 +21,7 @@ public function handle(): void
{ {
$this->post->markAsPublishing(); $this->post->markAsPublishing();
foreach ($this->post->postPlatforms()->where('enabled', true)->get() as $postPlatform) { foreach ($this->post->postPlatforms()->enabled()->get() as $postPlatform) {
PublishToSocialPlatform::dispatch($postPlatform); PublishToSocialPlatform::dispatch($postPlatform);
} }
} }

View file

@ -331,7 +331,7 @@ private function notifySuccess(Post $post): void
$publishedPlatforms = $post->postPlatforms() $publishedPlatforms = $post->postPlatforms()
->with('socialAccount') ->with('socialAccount')
->where('enabled', true) ->enabled()
->get() ->get()
->filter(fn ($pp) => $pp->status === PostPlatformStatus::Published) ->filter(fn ($pp) => $pp->status === PostPlatformStatus::Published)
->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')')
@ -384,7 +384,7 @@ private function notifyFailure(Post $post): void
$failedPlatforms = $post->postPlatforms() $failedPlatforms = $post->postPlatforms()
->with('socialAccount') ->with('socialAccount')
->where('enabled', true) ->enabled()
->get() ->get()
->filter(fn ($pp) => $pp->status === PostPlatformStatus::Failed) ->filter(fn ($pp) => $pp->status === PostPlatformStatus::Failed)
->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')')

View file

@ -14,6 +14,7 @@
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Throwable;
class SendNotification implements ShouldQueue class SendNotification implements ShouldQueue
{ {
@ -60,7 +61,7 @@ public function handle(): void
} }
} }
public function failed(\Throwable $exception): void public function failed(Throwable $exception): void
{ {
Log::error('SendNotification job failed', [ Log::error('SendNotification job failed', [
'user_id' => $this->user->id, 'user_id' => $this->user->id,

View file

@ -0,0 +1,365 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Enums\Notification\Channel;
use App\Enums\Notification\Type;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\SocialAccount\Status as SocialAccountStatus;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Mail\PostAtRisk;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use Exception;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class VerifyUpcomingPostConnections implements ShouldBeUnique, ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 120;
// Covers the timeout plus queue-wait headroom, up to the full 15-minute
// schedule cadence — a stuck/slow run can't leave a stale lock blocking
// the next legitimate dispatch for this workspace.
public int $uniqueFor = 900;
// How long to wait before re-notifying about an account that's already
// known broken, even if new posts keep entering the risk window in the
// meantime — otherwise a busy schedule with a dead token sends a fresh
// email every 15 minutes for as long as the account stays broken.
private const RENOTIFY_COOLDOWN_MINUTES = 60;
// Grace period after an account transitions to broken during which we
// skip our own notification, on the assumption another process (the
// daily sweep, RefreshExpiringTokens) just sent AccountDisconnected for
// the same event — avoids two different emails landing at once.
private const RECENTLY_DISCONNECTED_GRACE_MINUTES = 5;
// How long a successful verify() is trusted (via SocialAccount::last_verified_at)
// before re-checking the same account again. Combined with VERIFY_LEAD_MINUTES
// below, this caps a healthy account at ~1 real API call per at-risk post
// instead of one every 15-minute tick for the full 1-hour risk window —
// platform rate-limit budget is finite and shared across the whole app.
private const VERIFIED_WITHIN_MINUTES = 40;
// Don't spend an API call verifying an account until its nearest at-risk
// post is this close to publishing. A token that's already dead stays
// dead, so checking earlier buys no extra safety — only less API budget
// spent per post, while still leaving enough lead time to reconnect.
private const VERIFY_LEAD_MINUTES = 30;
public function __construct(public string $workspaceId) {}
public function uniqueId(): string
{
return $this->workspaceId;
}
public function handle(ConnectionVerifier $verifier): void
{
$workspace = Workspace::find($this->workspaceId);
if (! $workspace) {
return;
}
$postPlatforms = $this->atRiskPostPlatforms();
if ($postPlatforms->isEmpty()) {
return;
}
$atRisk = new Collection;
foreach ($postPlatforms->groupBy('social_account_id') as $group) {
$account = $group->first()->socialAccount;
if (! $account) {
// The account was hard-deleted between the main query and its
// eager-loaded relation resolving (two separate queries) —
// nothing left to verify or warn about for this group.
continue;
}
$group = $group->filter(fn (PostPlatform $pp) => $pp->post !== null);
if ($group->isEmpty()) {
// Same race as above, but for the post: every row in this
// batch was hard-deleted between the main query and its
// eager-loaded relation resolving.
continue;
}
// atRiskPostPlatforms()'s is_active guard only runs at query
// time; this job can take real wall-clock time working through
// a workspace, so re-check fresh (paused/deleted since then
// shouldn't burn an API call or warn about it). Keep workspace
// eager-loaded — SocialAccountObserver reads it when
// markAsTokenExpired() below updates the account (#255).
$account = SocialAccount::active()->with('workspace')->find($account->id);
if (! $account) {
continue;
}
if (in_array($account->status, [SocialAccountStatus::TokenExpired, SocialAccountStatus::Disconnected], true)) {
if ($this->recentlyWarnedAbout($account) || $this->recentlyDisconnected($account)) {
continue;
}
// Already known broken from an earlier run — don't re-verify,
// just warn about the posts that entered the window since then.
$atRisk->push(['account' => $account, 'postPlatforms' => $group]);
continue;
}
if ($account->last_verified_at?->isAfter(now()->subMinutes(self::VERIFIED_WITHIN_MINUTES))) {
// Confirmed healthy recently enough — trust it instead of
// hitting the platform API again on every 15-minute tick.
continue;
}
$nearestScheduledAt = $group->min(fn (PostPlatform $pp) => $pp->post->scheduled_at);
if ($nearestScheduledAt->isAfter(now()->addMinutes(self::VERIFY_LEAD_MINUTES))) {
// Not close enough to publishing yet — defer the actual API
// call to a later run instead of spending budget checking
// every 15-minute tick for the full 1-hour risk window.
continue;
}
try {
$verifier->verify($account);
$account->update(['last_verified_at' => now()]);
} catch (PlatformUnavailableException $e) {
Log::warning('Upcoming-post connection check skipped: platform unavailable', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
continue;
} catch (TokenExpiredException $e) {
try {
// Re-check right before mutating: if the account is no longer
// Connected here, a concurrent process (e.g. RefreshExpiringTokens)
// beat us to discovering and announcing this same break via its
// own AccountDisconnected email. Only skip in that case — not
// when disconnected_at is fresh purely because our own update
// below is about to set it for the first time.
if ($account->refresh()->status !== SocialAccountStatus::Connected && $this->recentlyDisconnected($account)) {
continue;
}
$account->markAsTokenExpired($e->getMessage(), notify: false);
$account->refresh();
} catch (Exception $lockOrDbError) {
// Covers the account being deleted mid-run (refresh()
// throws ModelNotFoundException) as well as infrastructure
// failures inside markAsTokenExpired() itself (its
// Cache::lock() or ->update() call). An exception thrown
// from inside a catch block isn't routed to a sibling
// catch, so this must be handled here to avoid aborting
// the run for every other account in this workspace.
Log::error('Failed to mark account token_expired for upcoming-post check', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $lockOrDbError->getMessage(),
]);
continue;
}
// markAsTokenExpired() no-ops if it couldn't acquire the
// account's status lock (another process — e.g. a concurrent
// publish attempt or the daily check — holds it). Only warn
// once the status change is confirmed; a lost race here just
// means this account is picked up again on the next run.
if ($account->status !== SocialAccountStatus::TokenExpired) {
Log::warning('Upcoming-post connection check: could not mark account token_expired (status lock contended), deferring to next run', [
'account_id' => $account->id,
'platform' => $account->platform->value,
]);
continue;
}
$atRisk->push(['account' => $account, 'postPlatforms' => $group]);
} catch (Exception $e) {
Log::error('Failed to verify social account connection for upcoming-post check', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
// Unknown error — don't mark as broken, retry next run.
continue;
}
}
if ($atRisk->isEmpty()) {
return;
}
$owner = $workspace->owner;
if (! $owner) {
// No owner to notify — leave these rows unwarned so a future run
// (once the workspace has an owner) can pick them back up.
return;
}
// Conditioned on the same "unwarned" window atRiskPostPlatforms() selected
// on, so a concurrent run that already claimed some or all of these
// exact rows (the ShouldBeUnique lock's TTL matches the schedule
// cadence, so two instances can briefly overlap if a run takes
// unusually long) never gets re-claimed here. lockForUpdate() closes
// the gap between reading which rows are still claimable and
// stamping them — without it, two overlapping runs could both read
// "unclaimed" for the same row before either writes.
$warnedIds = $atRisk->flatMap(fn (array $group) => $group['postPlatforms']->pluck('id'));
$claimedIds = DB::transaction(function () use ($warnedIds) {
$claimableIds = PostPlatform::whereIn('id', $warnedIds)
->where(function ($query) {
$query->whereNull('connection_warning_sent_at')
->orWhere('connection_warning_sent_at', '<', now()->subDay());
})
// Two overlapping runs can both claim rows here (see comment
// above the transaction) — locking in a consistent order
// (primary key) prevents them from deadlocking by acquiring
// the same two rows' locks in opposite order.
->orderBy('id')
->lockForUpdate()
->pluck('id');
if ($claimableIds->isEmpty()) {
return $claimableIds;
}
PostPlatform::whereIn('id', $claimableIds)->update(['connection_warning_sent_at' => now()]);
return $claimableIds;
}, attempts: 3);
if ($claimedIds->isEmpty()) {
return;
}
// (Pre-existing trade-off, not introduced by this transaction: a
// crash between the DB transaction above and notifyOwner() below
// loses the warning for 24h, until atRiskPostPlatforms()'s re-check window.)
// A concurrent run may have already claimed some (not all) of these
// rows between when $atRisk was built and the claim above — narrow
// the notification down to what THIS run actually claimed, so the
// email never lists an account/post pair another run is already
// notifying about. $claimedIds is a non-empty subset of $warnedIds,
// which is exactly the union of every group's post_platform ids, so
// at least one group is guaranteed to survive this filter.
$atRisk = $atRisk
->map(function (array $group) use ($claimedIds) {
$group['postPlatforms'] = $group['postPlatforms']->filter(
fn (PostPlatform $pp) => $claimedIds->containsStrict($pp->id)
);
return $group;
})
->filter(fn (array $group) => $group['postPlatforms']->isNotEmpty());
$this->notifyOwner($owner, $workspace, $atRisk);
}
/**
* Whether we've already sent a PostAtRisk notification covering this
* account within the cooldown window checked against any of its
* post_platforms, not just the ones in the current batch.
*/
private function recentlyWarnedAbout(SocialAccount $account): bool
{
return PostPlatform::query()
->where('social_account_id', $account->id)
->where('connection_warning_sent_at', '>=', now()->subMinutes(self::RENOTIFY_COOLDOWN_MINUTES))
->exists();
}
/**
* Whether the account broke recently enough that another process (the
* daily sweep, a proactive token refresh) likely just sent its own
* AccountDisconnected email for the same event.
*/
private function recentlyDisconnected(SocialAccount $account): bool
{
return $account->disconnected_at?->isAfter(now()->subMinutes(self::RECENTLY_DISCONNECTED_GRACE_MINUTES)) ?? false;
}
/**
* @return Collection<int, PostPlatform>
*/
private function atRiskPostPlatforms(): Collection
{
return PostPlatform::query()
->where('status', PostPlatformStatus::Pending)
->enabled() // PublishPost only iterates enabled platforms — an at-risk warning for a disabled one would be a false positive.
// A paused account already fails at publish time with
// posts.errors.account_inactive before any platform API call
// (PublishToSocialPlatform::handle()) — verifying it here would
// waste a real API call and, if the token also happens to be
// dead, warn the owner to "reconnect" an account they paused on
// purpose. whereHas() already excludes a null social_account_id
// (nothing to join to).
->whereHas('socialAccount', fn ($query) => $query->where('is_active', true))
->where(function ($query) {
$query->whereNull('connection_warning_sent_at')
->orWhere('connection_warning_sent_at', '<', now()->subDay());
})
->whereHas('post', function ($query) {
$query->where('workspace_id', $this->workspaceId)
->scheduled()
->whereBetween('scheduled_at', [now(), now()->addHour()]);
})
// socialAccount.workspace is eager-loaded even though this job
// never reads it directly — SocialAccountObserver::notifyOnboarding()
// (fired by the ->update() calls below via markAsTokenExpired())
// accesses $account->workspace, and lazy loading is disabled
// app-wide. Dropping this eager load throws LazyLoadingViolationException
// the moment a second account in the same run gets updated (see #255).
->with(['socialAccount.workspace', 'post'])
->get();
}
/**
* @param Collection<int, array{account: SocialAccount, postPlatforms: Collection<int, PostPlatform>}> $atRisk
*/
private function notifyOwner(User $owner, Workspace $workspace, Collection $atRisk): void
{
$postPlatforms = $atRisk->flatMap(fn (array $group) => $group['postPlatforms']);
$postCount = $postPlatforms->pluck('post_id')->unique()->count();
$postPlatformIds = $postPlatforms->pluck('id')->all();
SendNotification::dispatch(
user: $owner,
workspaceId: $workspace->id,
type: Type::PostAtRisk,
channel: Channel::Both,
title: trans_choice('notifications.post_at_risk.title', $postCount, ['count' => $postCount]),
body: $atRisk->map(fn (array $group) => $group['account']->platform->label().' ('.$group['account']->handle().')')->implode(', '),
data: ['workspace_id' => $workspace->id],
mailable: new PostAtRisk($workspace, $postPlatformIds, $postCount),
);
}
}

View file

@ -120,7 +120,7 @@ private function notifyOwner(Collection $disconnectedAccounts): void
} }
$accountNames = $disconnectedAccounts $accountNames = $disconnectedAccounts
->map(fn ($account) => $account->platform->label().' (@'.($account->username ?? $account->display_name).')') ->map(fn ($account) => $account->platform->label().' ('.$account->handle().')')
->implode(', '); ->implode(', ');
SendNotification::dispatch( SendNotification::dispatch(

View file

@ -33,7 +33,7 @@ public function envelope(): Envelope
public function content(): Content public function content(): Content
{ {
$platformName = $this->account->platform->label(); $platformName = $this->account->platform->label();
$accountName = $this->account->display_name ?? $this->account->username; $accountName = $this->account->accountDisplayName();
$workspaceName = $this->account->workspace->name; $workspaceName = $this->account->workspace->name;
return new Content( return new Content(

121
app/Mail/PostAtRisk.php Normal file
View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\PostPlatform;
use App\Models\Workspace;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class PostAtRisk extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Rehydrated, grouped-by-account rows computed once (lazily, at send
* time, never on the queue payload).
*/
private ?Collection $atRiskGroups = null;
/**
* Only the workspace (a real Eloquent model, reduced to a lightweight
* identifier by SerializesModels), the post_platform IDs, and the count
* observed at dispatch time are carried on the queue payload. The rows
* themselves are rehydrated in atRiskGroups() so the queued job's
* serialized size stays small and the account/post details reflect
* state as of send time, not as of dispatch time.
*
* $count drives the subject and preview text specifically kept as the
* dispatch-time value (rather than re-derived from the rehydrated rows)
* so it always matches the in-app notification's title, which is built
* from this same count in VerifyUpcomingPostConnections::notifyOwner().
* If a row disappears between dispatch and send, the subject may then
* differ from the number of rows actually listed in the body an
* acceptable rare edge case, in exchange for the subject never
* disagreeing with the in-app notification.
*
* @param array<int, string> $postPlatformIds
*/
public function __construct(
public Workspace $workspace,
public array $postPlatformIds,
public int $count
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->subjectFor($this->count),
);
}
public function content(): Content
{
return new Content(
view: 'mail.post-at-risk',
with: [
'title' => 'Posts May Fail to Publish',
'previewText' => $this->subjectFor($this->count),
'intro' => "The following social accounts in your {$this->workspace->name} workspace need to be reconnected before these scheduled posts can publish:",
'reconnectCta' => 'Please reconnect these accounts now to avoid missing your scheduled posts.',
'buttonText' => 'Reconnect Accounts',
'workspace' => $this->workspace,
'atRiskGroups' => $this->atRiskGroups(),
'url' => route('app.accounts'),
],
);
}
/**
* @return Collection<int, array{account: mixed, postPlatforms: Collection<int, PostPlatform>, postsLabel: string}>
*/
private function atRiskGroups(): Collection
{
if ($this->atRiskGroups !== null) {
return $this->atRiskGroups;
}
$postPlatforms = PostPlatform::query()
->with(['socialAccount', 'post'])
->whereIn('id', $this->postPlatformIds)
->get();
return $this->atRiskGroups = $postPlatforms->groupBy('social_account_id')
// The account can be null if it was hard-deleted between dispatch
// and send — nothing meaningful to render for it (no platform, no
// handle), so it's dropped rather than crashing the render.
->filter(fn (Collection $group) => $group->first()->socialAccount !== null)
->map(function (Collection $group) {
$postCount = $group->count();
$times = $group->sortBy(fn ($pp) => $pp->post->scheduled_at)
->map(fn ($pp) => $pp->post->scheduled_at->format('H:i'))
->implode(', ');
$noun = $postCount === 1 ? 'post' : 'posts';
return [
'account' => $group->first()->socialAccount,
'postPlatforms' => $group,
'postsLabel' => "{$postCount} {$noun} scheduled: {$times} UTC",
];
})->values();
}
private function subjectFor(int $count): string
{
$noun = $count === 1 ? 'post is' : 'posts are';
return "{$count} {$noun} at risk in {$this->workspace->name}";
}
public function attachments(): array
{
return [];
}
}

View file

@ -32,7 +32,7 @@ public function content(): Content
{ {
$failedPlatforms = $this->post->postPlatforms() $failedPlatforms = $this->post->postPlatforms()
->with('socialAccount') ->with('socialAccount')
->where('enabled', true) ->enabled()
->get() ->get()
->filter(fn ($pp) => $pp->status === Status::Failed) ->filter(fn ($pp) => $pp->status === Status::Failed)
->map(fn ($pp) => [ ->map(fn ($pp) => [

View file

@ -32,7 +32,7 @@ public function content(): Content
{ {
$publishedPlatforms = $this->post->postPlatforms() $publishedPlatforms = $this->post->postPlatforms()
->with('socialAccount') ->with('socialAccount')
->where('enabled', true) ->enabled()
->get() ->get()
->filter(fn ($pp) => $pp->status === Status::Published) ->filter(fn ($pp) => $pp->status === Status::Published)
->map(fn ($pp) => [ ->map(fn ($pp) => [

View file

@ -47,7 +47,7 @@ public function handle(Request $request): Response|ResponseFactory
return $denied; return $denied;
} }
if (! $post->postPlatforms()->where('enabled', true)->exists()) { if (! $post->postPlatforms()->enabled()->exists()) {
return Response::error('Post has no enabled platforms. Use update-post-tool to enable at least one platform first.'); return Response::error('Post has no enabled platforms. Use update-post-tool to enable at least one platform first.');
} }

View file

@ -4,13 +4,16 @@
namespace App\Models; namespace App\Models;
use Database\Factories\NotificationPreferenceFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationPreference extends Model class NotificationPreference extends Model
{ {
use HasUuids; /** @use HasFactory<NotificationPreferenceFactory> */
use HasFactory, HasUuids;
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',

View file

@ -148,7 +148,7 @@ public function markAsFailed(): void
public function allowedMediaTypes(): array public function allowedMediaTypes(): array
{ {
$platforms = $this->postPlatforms() $platforms = $this->postPlatforms()
->where('enabled', true) ->enabled()
->with('socialAccount') ->with('socialAccount')
->get() ->get()
->pluck('socialAccount.platform') ->pluck('socialAccount.platform')

View file

@ -8,6 +8,7 @@
use App\Enums\PostPlatform\Status; use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Platform as SocialPlatform;
use Database\Factories\PostPlatformFactory; use Database\Factories\PostPlatformFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -35,6 +36,7 @@ class PostPlatform extends Model
'error_context', 'error_context',
'published_at', 'published_at',
'meta', 'meta',
'connection_warning_sent_at',
]; ];
protected function casts(): array protected function casts(): array
@ -47,6 +49,7 @@ protected function casts(): array
'published_at' => 'datetime', 'published_at' => 'datetime',
'meta' => 'array', 'meta' => 'array',
'error_context' => 'array', 'error_context' => 'array',
'connection_warning_sent_at' => 'datetime',
]; ];
} }
@ -60,12 +63,22 @@ public function socialAccount(): BelongsTo
return $this->belongsTo(SocialAccount::class); return $this->belongsTo(SocialAccount::class);
} }
/**
* Only platforms still enabled for publishing disabled ones are
* excluded from PublishPost, so anything else that mirrors publish
* eligibility (previews, validation, proactive checks) must too.
*/
public function scopeEnabled(Builder $query): Builder
{
return $query->where('post_platforms.enabled', true);
}
/** /**
* Get display name, falling back to snapshot if account was deleted. * Get display name, falling back to snapshot if account was deleted.
*/ */
public function getDisplayNameAttribute(): string public function getDisplayNameAttribute(): string
{ {
return $this->socialAccount?->display_name ?? $this->platform_name ?? $this->platform->label(); return $this->socialAccount?->accountDisplayName() ?? $this->platform_name ?? $this->platform->label();
} }
/** /**

View file

@ -46,6 +46,7 @@ class SocialAccount extends Model
'error_message', 'error_message',
'disconnected_at', 'disconnected_at',
'last_used_at', 'last_used_at',
'last_verified_at',
]; ];
protected $hidden = [ protected $hidden = [
@ -53,6 +54,11 @@ class SocialAccount extends Model
'refresh_token', 'refresh_token',
]; ];
protected $appends = [
'display_label',
'handle_label',
];
protected function casts(): array protected function casts(): array
{ {
return [ return [
@ -64,6 +70,7 @@ protected function casts(): array
'token_expires_at' => 'datetime', 'token_expires_at' => 'datetime',
'disconnected_at' => 'datetime', 'disconnected_at' => 'datetime',
'last_used_at' => 'datetime', 'last_used_at' => 'datetime',
'last_verified_at' => 'datetime',
'scopes' => 'array', 'scopes' => 'array',
'meta' => 'array', 'meta' => 'array',
]; ];
@ -145,6 +152,49 @@ protected function profileUrl(): Attribute
); );
} }
/**
* "@handle" for notification bodies the more specific identifier
* (username) wins over the friendlier display name when both are set.
* Every connector requests enough scope to always populate at least one
* of username/display_name (e.g. TikTok always requests user.info.profile);
* the platform label is a last-resort fallback, not an expected path.
*/
public function handle(): string
{
return '@'.($this->username ?: $this->display_name ?: $this->platform->label());
}
/**
* Friendly label for email templates the display name wins over the
* username when both are set.
*/
public function accountDisplayName(): string
{
return $this->display_name ?: $this->username ?: $this->platform->label();
}
/**
* Frontend-facing mirror of accountDisplayName() appended to JSON so
* Vue components stop re-implementing this fallback.
*/
protected function displayLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->accountDisplayName(),
);
}
/**
* Frontend-facing mirror of handle() without the "@" prefix templates
* that render their own "@" (e.g. platform previews) use this instead.
*/
protected function handleLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->username ?: $this->display_name ?: $this->platform->label(),
);
}
public function markAsDisconnected(string $errorMessage): void public function markAsDisconnected(string $errorMessage): void
{ {
$lock = Cache::lock("social_account_status:{$this->id}", 10); $lock = Cache::lock("social_account_status:{$this->id}", 10);
@ -163,7 +213,7 @@ public function markAsDisconnected(string $errorMessage): void
if ($wasConnected && $this->workspace->owner) { if ($wasConnected && $this->workspace->owner) {
$placeholders = [ $placeholders = [
'platform' => $this->platform->label(), 'platform' => $this->platform->label(),
'account' => '@'.($this->username ?? $this->display_name), 'account' => $this->handle(),
]; ];
SendNotification::dispatch( SendNotification::dispatch(
@ -204,7 +254,7 @@ public function markAsTokenExpired(string $errorMessage, bool $notify = true): v
if ($notify && $wasUsable && $this->workspace->owner) { if ($notify && $wasUsable && $this->workspace->owner) {
$placeholders = [ $placeholders = [
'platform' => $this->platform->label(), 'platform' => $this->platform->label(),
'account' => '@'.($this->username ?? $this->display_name), 'account' => $this->handle(),
]; ];
SendNotification::dispatch( SendNotification::dispatch(

View file

@ -116,7 +116,7 @@ public function wantsEmailFor(NotificationType $type): bool
return match ($type) { return match ($type) {
NotificationType::PostPublished => $preference->post_published, NotificationType::PostPublished => $preference->post_published,
NotificationType::PostFailed, NotificationType::PostPartiallyPublished => $preference->post_failed, NotificationType::PostFailed, NotificationType::PostPartiallyPublished => $preference->post_failed,
NotificationType::AccountDisconnected => $preference->account_disconnected, NotificationType::AccountDisconnected, NotificationType::PostAtRisk => $preference->account_disconnected,
NotificationType::MentionedInComment => $preference->mentioned_in_comment ?? true, NotificationType::MentionedInComment => $preference->mentioned_in_comment ?? true,
default => true, default => true,
}; };

View file

@ -77,7 +77,7 @@ public static function entriesForUpdate(Post $post, ?array $requestPlatforms): a
])->all(); ])->all();
} }
return $post->postPlatforms()->where('enabled', true)->get()->values() return $post->postPlatforms()->enabled()->get()->values()
->map(fn ($postPlatform, $index): array => [ ->map(fn ($postPlatform, $index): array => [
'key' => "platforms.{$index}.content_type", 'key' => "platforms.{$index}.content_type",
'content_type' => $postPlatform->content_type?->value, 'content_type' => $postPlatform->content_type?->value,

View file

@ -362,7 +362,7 @@ private function renderFooter(ImageInterface $canvas, SocialAccount $socialAccou
$footerColor = '#9ca3af'; $footerColor = '#9ca3af';
$username = $socialAccount->username ?? ''; $username = $socialAccount->username ?? '';
$displayName = $socialAccount->display_name ?? ''; $displayName = $socialAccount->display_label;
// Footer row anchored from the bottom: avatar + handle + displayName // Footer row anchored from the bottom: avatar + handle + displayName
// share the same vertical center so they line up cleanly. // share the same vertical center so they line up cleanly.
@ -748,7 +748,7 @@ private function drawTweetCardContent(ImageInterface $canvas, mixed $core, Socia
$nameX = $avatarX + $avatarSize + 16; $nameX = $avatarX + $avatarSize + 16;
$displayNameText = $socialAccount->display_name ?? ''; $displayNameText = $socialAccount->display_label;
$handleText = '@'.($socialAccount->username ?? ''); $handleText = '@'.($socialAccount->username ?? '');
$nameBox = $fontBold ? imagettfbbox($nameSize, 0, $fontBold, $displayNameText) : [0, 0, 0, 0, 0, 0, 0, 0]; $nameBox = $fontBold ? imagettfbbox($nameSize, 0, $fontBold, $displayNameText) : [0, 0, 0, 0, 0, 0, 0, 0];
$handleBox = $fontLight ? imagettfbbox($handleSize, 0, $fontLight, $handleText) : [0, 0, 0, 0, 0, 0, 0, 0]; $handleBox = $fontLight ? imagettfbbox($handleSize, 0, $fontLight, $handleText) : [0, 0, 0, 0, 0, 0, 0, 0];

View file

@ -6,6 +6,15 @@
use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException; use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\BlueskyPublishException;
use App\Exceptions\Social\DiscordPublishException;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\Social\MastodonPublishException;
use App\Exceptions\Social\PinterestPublishException;
use App\Exceptions\Social\TelegramPublishException;
use App\Exceptions\Social\TikTokPublishException;
use App\Exceptions\Social\XPublishException;
use App\Exceptions\Social\YouTubePublishException;
use App\Exceptions\TokenExpiredException; use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount; use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordClient; use App\Services\Social\Discord\DiscordClient;
@ -38,6 +47,16 @@ public function verify(SocialAccount $account): bool
try { try {
return $this->callVerifyEndpoint($account); return $this->callVerifyEndpoint($account);
} catch (TokenExpiredException $e) { } catch (TokenExpiredException $e) {
if (! $account->platform->hasTokenRefreshFlow()) {
// Facebook/InstagramFacebook (Page tokens) and Mastodon
// tokens don't expire, and Telegram/Discord authenticate
// with one bot token shared across every connected account
// of that platform — none of them have anything to refresh,
// so retrying would just repeat this identical rejection
// while burning a call against a budget shared app-wide.
throw $e;
}
// Verify returned 401: the access_token is actually invalid. // Verify returned 401: the access_token is actually invalid.
// Refresh and retry once with the new token. // Refresh and retry once with the new token.
return $this->refreshThenVerify($account, $e); return $this->refreshThenVerify($account, $e);
@ -365,11 +384,18 @@ private function verifyLinkedIn(SocialAccount $account): bool
]) ])
->get(config('trypost.platforms.linkedin.api').'/rest/userinfo'); ->get(config('trypost.platforms.linkedin.api').'/rest/userinfo');
if ($response->status() === 401) { if (LinkedInPublishException::isConfirmedDeadToken($response)) {
throw new TokenExpiredException('LinkedIn access token is invalid or expired'); throw new TokenExpiredException('LinkedIn access token is invalid or expired');
} }
return $response->successful(); if ($response->successful()) {
return true;
}
throw new PlatformUnavailableException(
"{$account->platform->label()} verify failed ({$response->status()}).",
$response->status(),
);
} }
private function verifyLinkedInPage(SocialAccount $account): bool private function verifyLinkedInPage(SocialAccount $account): bool
@ -383,11 +409,18 @@ private function verifyLinkedInPage(SocialAccount $account): bool
'q' => 'roleAssignee', 'q' => 'roleAssignee',
]); ]);
if ($response->status() === 401) { if (LinkedInPublishException::isConfirmedDeadToken($response)) {
throw new TokenExpiredException('LinkedIn Page access token is invalid or expired'); throw new TokenExpiredException('LinkedIn Page access token is invalid or expired');
} }
return $response->successful(); if ($response->successful()) {
return true;
}
throw new PlatformUnavailableException(
"{$account->platform->label()} verify failed ({$response->status()}).",
$response->status(),
);
} }
private function verifyX(SocialAccount $account): bool private function verifyX(SocialAccount $account): bool
@ -395,11 +428,18 @@ private function verifyX(SocialAccount $account): bool
$response = Http::withToken($account->access_token) $response = Http::withToken($account->access_token)
->get(config('trypost.platforms.x.api').'/users/me'); ->get(config('trypost.platforms.x.api').'/users/me');
if ($response->status() === 401) { if (XPublishException::isConfirmedDeadToken($response)) {
throw new TokenExpiredException('X access token is invalid or expired'); throw new TokenExpiredException('X access token is invalid or expired');
} }
return $response->successful(); if ($response->successful()) {
return true;
}
throw new PlatformUnavailableException(
"{$account->platform->label()} verify failed ({$response->status()}).",
$response->status(),
);
} }
private function verifyInstagram(SocialAccount $account): bool private function verifyInstagram(SocialAccount $account): bool
@ -460,14 +500,22 @@ private function verifyTikTok(SocialAccount $account): bool
'fields' => 'open_id,display_name', 'fields' => 'open_id,display_name',
]); ]);
$body = $response->json() ?? []; // 401 here (unlike a publish-time 401, which TikTok also returns for
$errorCode = $body['error']['code'] ?? null; // scope_not_authorized/scope_permission_missed) is unambiguous: this
// endpoint only needs the always-granted user.info.basic scope, so a
if ($response->status() === 401 || in_array($errorCode, ['access_token_invalid', 'access_token_expired', 10001, 10002])) { // 401 can't be a scope gap. See TikTokPublishException::isConfirmedDeadToken().
if (TikTokPublishException::isConfirmedDeadToken($response) || $response->status() === 401) {
throw new TokenExpiredException('TikTok access token is invalid or expired'); throw new TokenExpiredException('TikTok access token is invalid or expired');
} }
return $response->successful(); if ($response->successful()) {
return true;
}
throw new PlatformUnavailableException(
"{$account->platform->label()} verify failed ({$response->status()}).",
$response->status(),
);
} }
private function verifyYouTube(SocialAccount $account): bool private function verifyYouTube(SocialAccount $account): bool
@ -478,11 +526,18 @@ private function verifyYouTube(SocialAccount $account): bool
'mine' => 'true', 'mine' => 'true',
]); ]);
if ($response->status() === 401) { if (YouTubePublishException::isConfirmedDeadToken($response)) {
throw new TokenExpiredException('YouTube access token is invalid or expired'); throw new TokenExpiredException('YouTube access token is invalid or expired');
} }
return $response->successful(); if ($response->successful()) {
return true;
}
throw new PlatformUnavailableException(
"{$account->platform->label()} verify failed ({$response->status()}).",
$response->status(),
);
} }
private function verifyPinterest(SocialAccount $account): bool private function verifyPinterest(SocialAccount $account): bool
@ -490,11 +545,18 @@ private function verifyPinterest(SocialAccount $account): bool
$response = Http::withToken($account->access_token) $response = Http::withToken($account->access_token)
->get(config('trypost.platforms.pinterest.api').'/user_account'); ->get(config('trypost.platforms.pinterest.api').'/user_account');
if ($response->status() === 401) { if (PinterestPublishException::isConfirmedDeadToken($response)) {
throw new TokenExpiredException('Pinterest access token is invalid or expired'); throw new TokenExpiredException('Pinterest access token is invalid or expired');
} }
return $response->successful(); if ($response->successful()) {
return true;
}
throw new PlatformUnavailableException(
"{$account->platform->label()} verify failed ({$response->status()}).",
$response->status(),
);
} }
private function verifyBluesky(SocialAccount $account): bool private function verifyBluesky(SocialAccount $account): bool
@ -506,14 +568,18 @@ private function verifyBluesky(SocialAccount $account): bool
'actor' => $account->platform_user_id, 'actor' => $account->platform_user_id,
]); ]);
$body = $response->json() ?? []; if (BlueskyPublishException::isConfirmedDeadToken($response)) {
$error = $body['error'] ?? null;
if ($error === 'ExpiredToken' || $error === 'InvalidToken') {
throw new TokenExpiredException('Bluesky access token is invalid or expired'); throw new TokenExpiredException('Bluesky access token is invalid or expired');
} }
return $response->successful(); if ($response->successful()) {
return true;
}
throw new PlatformUnavailableException(
"{$account->platform->label()} verify failed ({$response->status()}).",
$response->status(),
);
} }
private function verifyTelegram(SocialAccount $account): bool private function verifyTelegram(SocialAccount $account): bool
@ -523,13 +589,31 @@ private function verifyTelegram(SocialAccount $account): bool
'chat_id' => data_get($account->meta, 'chat_id'), 'chat_id' => data_get($account->meta, 'chat_id'),
]); ]);
return $response->successful() && data_get($response->json(), 'ok') === true; if ($response->successful() && data_get($response->json(), 'ok') === true) {
return true;
}
if (TelegramPublishException::isConfirmedDeadChat($response)) {
throw new TokenExpiredException('Telegram bot no longer has access to the chat');
}
throw new PlatformUnavailableException("Telegram getChat failed ({$response->status()}).", $response->status());
} }
private function verifyDiscord(SocialAccount $account): bool private function verifyDiscord(SocialAccount $account): bool
{ {
// The guild endpoint succeeds only while the bot is still a member. // The guild endpoint succeeds only while the bot is still a member.
return app(DiscordClient::class)->getGuild((string) $account->platform_user_id)->successful(); $response = app(DiscordClient::class)->getGuild((string) $account->platform_user_id);
if ($response->successful()) {
return true;
}
if (DiscordPublishException::isConfirmedDeadGuild($response)) {
throw new TokenExpiredException('Discord bot no longer has access to the guild');
}
throw new PlatformUnavailableException("Discord guild lookup failed ({$response->status()}).", $response->status());
} }
private function verifyMastodon(SocialAccount $account): bool private function verifyMastodon(SocialAccount $account): bool
@ -539,10 +623,22 @@ private function verifyMastodon(SocialAccount $account): bool
$response = Http::withToken($account->access_token) $response = Http::withToken($account->access_token)
->get("{$instance}/api/v1/accounts/verify_credentials"); ->get("{$instance}/api/v1/accounts/verify_credentials");
if ($response->status() === 401 || $response->status() === 403) { // 403 here (unlike a publish-time 403 on the write-scoped /statuses
// endpoint) means even read access is gone — verify_credentials is
// the lowest-privilege endpoint every authorized app token can
// reach, so a 403 confirms total revocation, not a scope gap. See
// MastodonPublishException::isConfirmedDeadToken().
if (MastodonPublishException::isConfirmedDeadToken($response) || $response->status() === 403) {
throw new TokenExpiredException('Mastodon access token is invalid or expired'); throw new TokenExpiredException('Mastodon access token is invalid or expired');
} }
return $response->successful(); if ($response->successful()) {
return true;
}
throw new PlatformUnavailableException(
"{$account->platform->label()} verify failed ({$response->status()}).",
$response->status(),
);
} }
} }

View file

@ -140,7 +140,7 @@ public static function assertStoredPostPublishable(Post $post): void
{ {
$errors = []; $errors = [];
foreach ($post->postPlatforms()->where('enabled', true)->get()->values() as $index => $postPlatform) { foreach ($post->postPlatforms()->enabled()->get()->values() as $index => $postPlatform) {
$violation = self::requiredMetaViolation($postPlatform->platform, $postPlatform->meta); $violation = self::requiredMetaViolation($postPlatform->platform, $postPlatform->meta);
if ($violation !== null) { if ($violation !== null) {

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\NotificationPreference;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<NotificationPreference>
*/
class NotificationPreferenceFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'post_published' => true,
'post_failed' => true,
'account_disconnected' => true,
'mentioned_in_comment' => true,
];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->timestamp('connection_warning_sent_at')->nullable()->after('error_context');
$table->index(['status', 'enabled', 'connection_warning_sent_at']);
});
}
public function down(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->dropIndex(['status', 'enabled', 'connection_warning_sent_at']);
$table->dropColumn('connection_warning_sent_at');
});
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('social_accounts', function (Blueprint $table) {
$table->timestamp('last_verified_at')->nullable()->after('last_used_at');
});
}
public function down(): void
{
Schema::table('social_accounts', function (Blueprint $table) {
$table->dropColumn('last_verified_at');
});
}
};

View file

@ -15,4 +15,7 @@
'title' => 'يحتاج حساب :platform إلى إعادة الربط', 'title' => 'يحتاج حساب :platform إلى إعادة الربط',
'body' => 'انتهت جلسة :account — يرجى إعادة الربط لمواصلة النشر', 'body' => 'انتهت جلسة :account — يرجى إعادة الربط لمواصلة النشر',
], ],
'post_at_risk' => [
'title' => '{1} منشور واحد قادم معرض للخطر|{2} منشوران قادمان معرضان للخطر|[3,10] :count منشورات قادمة معرضة للخطر|[11,*] :count منشورًا قادمًا معرضًا للخطر',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => ':platform-Konto muss erneut verbunden werden', 'title' => ':platform-Konto muss erneut verbunden werden',
'body' => 'Sitzung von :account abgelaufen bitte verbinde es erneut, um weiter zu posten', 'body' => 'Sitzung von :account abgelaufen bitte verbinde es erneut, um weiter zu posten',
], ],
'post_at_risk' => [
'title' => '{1} :count bevorstehender Beitrag ist gefährdet|[2,*] :count bevorstehende Beiträge sind gefährdet',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => 'Ο λογαριασμός :platform χρειάζεται επανασύνδεση', 'title' => 'Ο λογαριασμός :platform χρειάζεται επανασύνδεση',
'body' => 'Η συνεδρία :account έληξε — επανασυνδεθείτε για να συνεχίσετε να δημοσιεύετε', 'body' => 'Η συνεδρία :account έληξε — επανασυνδεθείτε για να συνεχίσετε να δημοσιεύετε',
], ],
'post_at_risk' => [
'title' => '{1} :count επερχόμενη ανάρτηση κινδυνεύει|[2,*] :count επερχόμενες αναρτήσεις κινδυνεύουν',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => ':platform account needs to be reconnected', 'title' => ':platform account needs to be reconnected',
'body' => ':account session expired — please reconnect to keep posting', 'body' => ':account session expired — please reconnect to keep posting',
], ],
'post_at_risk' => [
'title' => '{1} :count upcoming post is at risk|[2,*] :count upcoming posts are at risk',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => 'Cuenta de :platform necesita reconectarse', 'title' => 'Cuenta de :platform necesita reconectarse',
'body' => 'La sesión de :account expiró — reconéctala para seguir publicando', 'body' => 'La sesión de :account expiró — reconéctala para seguir publicando',
], ],
'post_at_risk' => [
'title' => '{1} :count próxima publicación está en riesgo|[2,*] :count próximas publicaciones están en riesgo',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => 'Le compte :platform doit être reconnecté', 'title' => 'Le compte :platform doit être reconnecté',
'body' => 'La session de :account a expiré — veuillez reconnecter pour continuer à publier', 'body' => 'La session de :account a expiré — veuillez reconnecter pour continuer à publier',
], ],
'post_at_risk' => [
'title' => '{1} :count publication à venir est à risque|[2,*] :count publications à venir sont à risque',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => 'L\'account :platform deve essere ricollegato', 'title' => 'L\'account :platform deve essere ricollegato',
'body' => 'Sessione di :account scaduta — ricollegalo per continuare a pubblicare', 'body' => 'Sessione di :account scaduta — ricollegalo per continuare a pubblicare',
], ],
'post_at_risk' => [
'title' => '{1} :count post imminente è a rischio|[2,*] :count post imminenti sono a rischio',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => ':platform アカウントの再接続が必要です', 'title' => ':platform アカウントの再接続が必要です',
'body' => ':account のセッションの有効期限が切れました — 投稿を続けるには再接続してください', 'body' => ':account のセッションの有効期限が切れました — 投稿を続けるには再接続してください',
], ],
'post_at_risk' => [
'title' => '{1} :count 件の予定投稿にリスクがあります|[2,*] :count 件の予定投稿にリスクがあります',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => ':platform 계정을 재연결해야 합니다', 'title' => ':platform 계정을 재연결해야 합니다',
'body' => ':account 세션이 만료되었습니다 — 계속 게시하려면 재연결하세요', 'body' => ':account 세션이 만료되었습니다 — 계속 게시하려면 재연결하세요',
], ],
'post_at_risk' => [
'title' => '{1} 예정된 게시물 :count건이 위험합니다|[2,*] 예정된 게시물 :count건이 위험합니다',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => ':platform-account moet opnieuw worden gekoppeld', 'title' => ':platform-account moet opnieuw worden gekoppeld',
'body' => 'Sessie van :account verlopen — koppel opnieuw om te blijven posten', 'body' => 'Sessie van :account verlopen — koppel opnieuw om te blijven posten',
], ],
'post_at_risk' => [
'title' => '{1} :count aankomende post loopt risico|[2,*] :count aankomende posts lopen risico',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => 'Konto :platform wymaga ponownego połączenia', 'title' => 'Konto :platform wymaga ponownego połączenia',
'body' => 'Sesja :account wygasła — połącz ponownie, aby dalej publikować', 'body' => 'Sesja :account wygasła — połącz ponownie, aby dalej publikować',
], ],
'post_at_risk' => [
'title' => ':count nadchodzący post jest zagrożony|:count nadchodzące posty są zagrożone|:count nadchodzących postów jest zagrożonych',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => 'Conta do :platform precisa ser reconectada', 'title' => 'Conta do :platform precisa ser reconectada',
'body' => 'Sessão de :account expirou — reconecte pra continuar postando', 'body' => 'Sessão de :account expirou — reconecte pra continuar postando',
], ],
'post_at_risk' => [
'title' => '{1} :count post agendado está em risco|[2,*] :count posts agendados estão em risco',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => 'Аккаунт :platform требует переподключения', 'title' => 'Аккаунт :platform требует переподключения',
'body' => 'Сессия :account истекла — переподключите, чтобы продолжить публикацию', 'body' => 'Сессия :account истекла — переподключите, чтобы продолжить публикацию',
], ],
'post_at_risk' => [
'title' => '{1} :count запланированный пост под угрозой|[2,4] :count запланированных поста под угрозой|[5,*] :count запланированных постов под угрозой',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => ':platform hesabının yeniden bağlanması gerekiyor', 'title' => ':platform hesabının yeniden bağlanması gerekiyor',
'body' => ':account oturumunun süresi doldu — paylaşıma devam etmek için lütfen yeniden bağlanın', 'body' => ':account oturumunun süresi doldu — paylaşıma devam etmek için lütfen yeniden bağlanın',
], ],
'post_at_risk' => [
'title' => '{1} :count planlanan gönderi risk altında|[2,*] :count planlanan gönderi risk altında',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => 'Акаунт :platform потрібно перепідключити', 'title' => 'Акаунт :platform потрібно перепідключити',
'body' => 'Сесію :account завершено — перепідключіть, щоб продовжити публікацію', 'body' => 'Сесію :account завершено — перепідключіть, щоб продовжити публікацію',
], ],
'post_at_risk' => [
'title' => '{1} :count запланована публікація під загрозою|[2,*] :count заплановані публікації під загрозою',
],
]; ];

View file

@ -15,4 +15,7 @@
'title' => ':platform 账号需要重新连接', 'title' => ':platform 账号需要重新连接',
'body' => ':account 会话已过期——请重新连接以继续发帖', 'body' => ':account 会话已过期——请重新连接以继续发帖',
], ],
'post_at_risk' => [
'title' => '{1} 有 :count 篇待发布的帖子存在风险|[2,*] 有 :count 篇待发布的帖子存在风险',
],
]; ];

View file

@ -0,0 +1,48 @@
<tr>
<td align="center" class="text-center text-zinc-600 text-xs p-6">
<p class="m-0 mb-2">
Open-source social media scheduling tool
</p>
<p class="m-0 mt-2">
<a href="@{{ route('app.notifications.preferences') }}" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
Manage notifications
</a>
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mx-auto mt-4">
<tr>
<td class="px-1">
<a href="https://github.com/trypostit/trypost" target="_blank">
<img src="@{{ asset('/images/emails/social/github.png') }}" width="20" height="20" alt="GitHub">
</a>
</td>
<td class="px-1">
<a href="https://x.com/trypostit" target="_blank">
<img src="@{{ asset('/images/emails/social/x.png') }}" width="20" height="20" alt="X">
</a>
</td>
<td class="px-1">
<a href="https://www.youtube.com/@trypostit" target="_blank">
<img src="@{{ asset('/images/emails/social/youtube.png') }}" width="20" height="20" alt="YouTube">
</a>
</td>
<td class="px-1">
<a href="https://trypost.it/discord" target="_blank">
<img src="@{{ asset('/images/emails/social/discord.png') }}" width="20" height="20" alt="Discord">
</a>
</td>
<td class="px-1">
<a href="https://www.instagram.com/trypost.it" target="_blank">
<img src="@{{ asset('/images/emails/social/instagram.png') }}" width="20" height="20" alt="Instagram">
</a>
</td>
</tr>
</table>
<p class="m-0 mt-3">
&copy; @{{ date('Y') }} TryPost.it
</p>
</td>
</tr>

View file

@ -1,22 +1,41 @@
<script props>
module.exports = {
unsubscribe_url: props.unsubscribe_url
}
</script>
<tr> <tr>
<td align="center" class="text-center text-zinc-600 text-xs p-6"> <td align="center" class="text-center text-zinc-600 text-xs p-6">
<p class="m-0 mb-2"> <p class="m-0 mb-2">
Open-source social media scheduling tool Open-source social media scheduling tool
</p> </p>
@if(isset($unsubscribe_url)) <table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mx-auto mt-3">
<p class="m-0 mt-2"> <tr>
<a href="{{ unsubscribe_url }}" target="_blank" <td class="px-1">
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]"> <a href="https://github.com/trypostit/trypost" target="_blank">
Unsubscribe <img src="@{{ asset('/images/emails/social/github.png') }}" width="20" height="20" alt="GitHub">
</a> </a>
</td>
<td class="px-1">
<a href="https://x.com/trypostit" target="_blank">
<img src="@{{ asset('/images/emails/social/x.png') }}" width="20" height="20" alt="X">
</a>
</td>
<td class="px-1">
<a href="https://www.youtube.com/@trypostit" target="_blank">
<img src="@{{ asset('/images/emails/social/youtube.png') }}" width="20" height="20" alt="YouTube">
</a>
</td>
<td class="px-1">
<a href="https://trypost.it/discord" target="_blank">
<img src="@{{ asset('/images/emails/social/discord.png') }}" width="20" height="20" alt="Discord">
</a>
</td>
<td class="px-1">
<a href="https://www.instagram.com/trypost.it" target="_blank">
<img src="@{{ asset('/images/emails/social/instagram.png') }}" width="20" height="20" alt="Instagram">
</a>
</td>
</tr>
</table>
<p class="m-0 mt-3">
&copy; @{{ date('Y') }} TryPost.it
</p> </p>
@endif
</td> </td>
</tr> </tr>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
maizzle/images/social/x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -40,7 +40,7 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
</td> </td>
</tr> </tr>
</table> </table>
<x-footer /> <x-footer-authenticated />
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -30,7 +30,7 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
</td> </td>
</tr> </tr>
</table> </table>
<x-footer /> <x-footer-authenticated />
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -0,0 +1,57 @@
<x-main>
<div class="bg-zinc-50 sm:px-4 font-sans">
<table align="center">
<tr>
<td class="w-[552px] max-w-full">
<x-header />
<table class="w-full">
<tr>
<td class="p-12 sm:px-6 text-base text-zinc-700 bg-white rounded shadow-sm">
<h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
@{{ $title }}
</h1>
<p class="m-0 leading-6">
@{{ $intro }}
</p>
<table class="w-full mt-4" style="border-collapse: collapse">
@foreach($atRiskGroups as $group)
<tr>
<td class="py-3" style="border-bottom: 1px solid #e4e4e7">
<div style="display: flex; align-items: center">
<div style="width: 8px; height: 8px; border-radius: 50%; background-color: @{{ $group['account']->platform->color() }}; margin-right: 12px"></div>
<div>
<strong class="text-zinc-900">@{{ $group['account']->platform->label() }}</strong>
<span class="text-zinc-500"> - @{{ $group['account']->accountDisplayName() }}</span>
<div class="text-zinc-500" style="font-size: 14px; margin-top: 4px">
@{{ $group['postsLabel'] }}
</div>
</div>
</div>
</td>
</tr>
@endforeach
</table>
<p class="m-0 mt-4 leading-6">
@{{ $reconnectCta }}
</p>
<x-spacer height="24px" />
<div class="flex items-center justify-center">
<x-button href="@{{ $url }}">
@{{ $buttonText }} &rarr;
</x-button>
</div>
</td>
</tr>
</table>
<x-footer-authenticated />
</td>
</tr>
</table>
</div>
</x-main>

View file

@ -42,7 +42,7 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
</td> </td>
</tr> </tr>
</table> </table>
<x-footer /> <x-footer-authenticated />
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -42,7 +42,7 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
</td> </td>
</tr> </tr>
</table> </table>
<x-footer /> <x-footer-authenticated />
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -24,9 +24,7 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
<div style="width: 8px; height: 8px; border-radius: 50%; background-color: @{{ $account->platform->color() }}; margin-right: 12px"></div> <div style="width: 8px; height: 8px; border-radius: 50%; background-color: @{{ $account->platform->color() }}; margin-right: 12px"></div>
<div> <div>
<strong class="text-zinc-900">@{{ $account->platform->label() }}</strong> <strong class="text-zinc-900">@{{ $account->platform->label() }}</strong>
@if($account->display_name || $account->username) <span class="text-zinc-500"> - @{{ $account->accountDisplayName() }}</span>
<span class="text-zinc-500"> - @{{ $account->display_name ?? $account->username }}</span>
@endif
</div> </div>
</div> </div>
</td> </td>
@ -58,7 +56,7 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
</td> </td>
</tr> </tr>
</table> </table>
<x-footer /> <x-footer-authenticated />
</td> </td>
</tr> </tr>
</table> </table>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -5,6 +5,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { getInitials } from '@/composables/useInitials';
import debounce from '@/debounce'; import debounce from '@/debounce';
import { search as searchMembers } from '@/routes/app/workspace/members'; import { search as searchMembers } from '@/routes/app/workspace/members';
@ -294,7 +295,7 @@ onBeforeUnmount(() => closePopover());
> >
<Avatar class="h-6 w-6 shrink-0"> <Avatar class="h-6 w-6 shrink-0">
<AvatarImage v-if="member.avatar_url" :src="member.avatar_url" :alt="member.name" /> <AvatarImage v-if="member.avatar_url" :src="member.avatar_url" :alt="member.name" />
<AvatarFallback class="text-[10px]">{{ member.name.charAt(0).toUpperCase() }}</AvatarFallback> <AvatarFallback class="text-[10px]">{{ getInitials(member.name) }}</AvatarFallback>
</Avatar> </Avatar>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{{ member.name }}</p> <p class="truncate text-sm font-medium">{{ member.name }}</p>

View file

@ -19,6 +19,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { getInitials } from '@/composables/useInitials';
import { useOAuthPopup } from '@/composables/useOAuthPopup'; import { useOAuthPopup } from '@/composables/useOAuthPopup';
import { getPlatformLogo } from '@/composables/usePlatformLogo'; import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { toggle as toggleAccount } from '@/routes/app/accounts'; import { toggle as toggleAccount } from '@/routes/app/accounts';
@ -29,6 +30,8 @@ export interface SocialAccount {
platform_user_id: string; platform_user_id: string;
username: string; username: string;
display_name: string; display_name: string;
display_label: string;
handle_label: string;
avatar_url: string; avatar_url: string;
status: 'connected' | 'disconnected' | 'token_expired' | null; status: 'connected' | 'disconnected' | 'token_expired' | null;
is_active: boolean; is_active: boolean;
@ -201,10 +204,7 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
v-if="platform.connected && platform.account" v-if="platform.connected && platform.account"
class="truncate text-sm text-muted-foreground" class="truncate text-sm text-muted-foreground"
> >
@{{ @{{ platform.account.handle_label }}
platform.account.username ||
platform.account.display_name
}}
</p> </p>
<p v-else class="text-sm text-muted-foreground"> <p v-else class="text-sm text-muted-foreground">
{{ trans('accounts.not_connected') }} {{ trans('accounts.not_connected') }}
@ -244,13 +244,13 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
:src="platform.account.avatar_url" :src="platform.account.avatar_url"
/> />
<AvatarFallback class="text-xs"> <AvatarFallback class="text-xs">
{{ platform.account.display_name?.charAt(0) }} {{ getInitials(platform.account.display_label) }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span <span
class="max-w-[120px] truncate text-sm font-medium" class="max-w-[120px] truncate text-sm font-medium"
> >
{{ platform.account.display_name }} {{ platform.account.display_label }}
</span> </span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">

View file

@ -27,6 +27,8 @@ export interface ConnectedAccount {
network: string; network: string;
username: string; username: string;
display_name: string; display_name: string;
display_label: string;
handle_label: string;
avatar_url: string | null; avatar_url: string | null;
status: 'connected' | 'disconnected' | 'token_expired' | null; status: 'connected' | 'disconnected' | 'token_expired' | null;
} }
@ -170,7 +172,7 @@ const { openOAuthPopup } = useOAuthPopup((result) => {
const disconnectAccount = (account: ConnectedAccount) => { const disconnectAccount = (account: ConnectedAccount) => {
disconnectModal.value?.open({ disconnectModal.value?.open({
url: disconnect.url(account.id), url: disconnect.url(account.id),
confirmText: account.username || account.display_name, confirmText: account.handle_label,
}); });
}; };
@ -321,10 +323,7 @@ const cardState = computed((): Record<string, CardStateValue> => {
v-else v-else
class="mt-0.5 truncate text-xs leading-tight text-foreground/70" class="mt-0.5 truncate text-xs leading-tight text-foreground/70"
> >
{{ {{ cardConnection[platform.value]?.display_label }}
cardConnection[platform.value]?.display_name ||
cardConnection[platform.value]?.username
}}
</p> </p>
</div> </div>

View file

@ -14,6 +14,7 @@ import {
CommandList, CommandList,
} from '@/components/ui/command'; } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { getInitials } from '@/composables/useInitials';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import type { AnalyticsAccount } from './types'; import type { AnalyticsAccount } from './types';
@ -53,9 +54,9 @@ const select = (account: AnalyticsAccount) => {
<template v-if="selected"> <template v-if="selected">
<div class="relative shrink-0"> <div class="relative shrink-0">
<Avatar class="size-7 rounded-full border-2 border-foreground shadow-2xs"> <Avatar class="size-7 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="selected.avatar_url" :src="selected.avatar_url" :alt="selected.display_name" /> <AvatarImage v-if="selected.avatar_url" :src="selected.avatar_url" :alt="selected.display_label" />
<AvatarFallback class="rounded-full bg-violet-100 text-xs font-bold text-foreground"> <AvatarFallback class="rounded-full bg-violet-100 text-xs font-bold text-foreground">
{{ selected.display_name?.charAt(0) }} {{ getInitials(selected.display_label) }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span class="absolute -bottom-1 -right-1 inline-flex size-4 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs"> <span class="absolute -bottom-1 -right-1 inline-flex size-4 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
@ -67,7 +68,7 @@ const select = (account: AnalyticsAccount) => {
</span> </span>
</div> </div>
<span class="min-w-0 flex-1 truncate"> <span class="min-w-0 flex-1 truncate">
<span class="font-bold text-foreground">{{ selected.display_name }}</span> <span class="font-bold text-foreground">{{ selected.display_label }}</span>
<span v-if="selected.username" class="ml-1.5 text-xs font-medium text-foreground/60"> <span v-if="selected.username" class="ml-1.5 text-xs font-medium text-foreground/60">
@{{ selected.username }} @{{ selected.username }}
</span> </span>
@ -95,9 +96,9 @@ const select = (account: AnalyticsAccount) => {
> >
<div class="relative shrink-0"> <div class="relative shrink-0">
<Avatar class="size-9 rounded-full border-2 border-foreground shadow-2xs"> <Avatar class="size-9 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="account.avatar_url" :src="account.avatar_url" :alt="account.display_name" /> <AvatarImage v-if="account.avatar_url" :src="account.avatar_url" :alt="account.display_label" />
<AvatarFallback class="rounded-full bg-violet-100 font-bold text-foreground"> <AvatarFallback class="rounded-full bg-violet-100 font-bold text-foreground">
{{ account.display_name?.charAt(0) }} {{ getInitials(account.display_label) }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span class="absolute -bottom-1 -right-1 inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs"> <span class="absolute -bottom-1 -right-1 inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
@ -109,7 +110,7 @@ const select = (account: AnalyticsAccount) => {
</span> </span>
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate font-bold text-foreground">{{ account.display_name }}</p> <p class="truncate font-bold text-foreground">{{ account.display_label }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60"> <p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">
@{{ account.username }} @{{ account.username }}
</p> </p>

View file

@ -1,7 +1,7 @@
export interface AnalyticsAccount { export interface AnalyticsAccount {
id: string; id: string;
platform: string; platform: string;
display_name: string;
username: string | null; username: string | null;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }

View file

@ -24,6 +24,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -302,7 +303,7 @@ const channels = computed<Channel[]>(() =>
return { return {
id: account.id, id: account.id,
platform: account.platform, platform: account.platform,
displayName: account.display_name, displayName: account.display_label,
username: account.username, username: account.username,
avatarUrl: account.avatar_url, avatarUrl: account.avatar_url,
socialAccount: account, socialAccount: account,

View file

@ -24,6 +24,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -306,13 +307,13 @@ const startGeneration = async () => {
<img <img
v-if="account.avatar_url" v-if="account.avatar_url"
:src="account.avatar_url" :src="account.avatar_url"
:alt="account.display_name" :alt="account.display_label"
class="size-full object-cover" class="size-full object-cover"
/> />
<img v-else :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" /> <img v-else :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-xs font-bold leading-tight text-foreground">{{ account.display_name }}</p> <p class="truncate text-xs font-bold leading-tight text-foreground">{{ account.display_label }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">@{{ account.username }}</p> <p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">@{{ account.username }}</p>
</div> </div>
<IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-foreground" stroke-width="3" /> <IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-foreground" stroke-width="3" />

View file

@ -16,6 +16,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -194,7 +195,7 @@ const updateEmbed = (index: number, patch: Partial<EmbedDraft>) =>
<img :src="getPlatformLogo('discord')" alt="Discord" class="size-full object-cover" /> <img :src="getPlatformLogo('discord')" alt="Discord" class="size-full object-cover" />
</span> </span>
<span class="truncate font-bold text-foreground">{{ $t('posts.form.discord.settings') }}</span> <span class="truncate font-bold text-foreground">{{ $t('posts.form.discord.settings') }}</span>
<span v-if="socialAccount?.display_name" class="truncate font-medium text-foreground/60">·&nbsp;{{ socialAccount.display_name }}</span> <span v-if="socialAccount?.display_label" class="truncate font-medium text-foreground/60">·&nbsp;{{ socialAccount.display_label }}</span>
</span> </span>
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" /> <IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" /> <IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
@ -204,12 +205,12 @@ const updateEmbed = (index: number, patch: Partial<EmbedDraft>) =>
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3"> <div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar <Avatar
:src="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:name="socialAccount.display_name" :name="socialAccount.display_label"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs" class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.posting_to') }}</p> <p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.posting_to') }}</p>
<p class="truncate text-sm font-bold text-foreground">{{ socialAccount.display_name }}</p> <p class="truncate text-sm font-bold text-foreground">{{ socialAccount.display_label }}</p>
</div> </div>
</div> </div>

View file

@ -14,6 +14,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -103,13 +104,13 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3"> <div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar <Avatar
:src="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:name="socialAccount.display_name" :name="socialAccount.display_label"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs" class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.facebook.posting_to') }}</p> <p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.facebook.posting_to') }}</p>
<p class="truncate text-sm"> <p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span> <span class="font-bold text-foreground">{{ socialAccount.display_label }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span> <span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p> </p>
</div> </div>

View file

@ -14,6 +14,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -103,13 +104,13 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3"> <div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar <Avatar
:src="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:name="socialAccount.display_name" :name="socialAccount.display_label"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs" class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.instagram.posting_to') }}</p> <p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.instagram.posting_to') }}</p>
<p class="truncate text-sm"> <p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span> <span class="font-bold text-foreground">{{ socialAccount.display_label }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span> <span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p> </p>
</div> </div>

View file

@ -14,6 +14,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -77,13 +78,13 @@ const documentTitle = computed({
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3"> <div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar <Avatar
:src="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:name="socialAccount.display_name" :name="socialAccount.display_label"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs" class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.posting_to') }}</p> <p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.posting_to') }}</p>
<p class="truncate text-sm"> <p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span> <span class="font-bold text-foreground">{{ socialAccount.display_label }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span> <span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p> </p>
</div> </div>

View file

@ -28,6 +28,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -153,13 +154,13 @@ const linkError = computed<string | undefined>(() => {
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3"> <div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar <Avatar
:src="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:name="socialAccount.display_name" :name="socialAccount.display_label"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs" class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.pinterest.posting_to') }}</p> <p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.pinterest.posting_to') }}</p>
<p class="truncate text-sm"> <p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span> <span class="font-bold text-foreground">{{ socialAccount.display_label }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span> <span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p> </p>
</div> </div>

View file

@ -13,6 +13,8 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
handle_label: string;
avatar_url: string | null; avatar_url: string | null;
} }

View file

@ -14,6 +14,8 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
handle_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -36,7 +38,7 @@ const props = defineProps<{
}>(); }>();
const getPlatformAvatar = (pp: PostPlatform): string | null => pp.social_account?.avatar_url ?? pp.platform_avatar ?? null; const getPlatformAvatar = (pp: PostPlatform): string | null => pp.social_account?.avatar_url ?? pp.platform_avatar ?? null;
const getPlatformDisplayName = (pp: PostPlatform): string => pp.social_account?.display_name ?? pp.platform_name ?? pp.platform; const getPlatformDisplayName = (pp: PostPlatform): string => pp.social_account?.display_label ?? pp.platform_name ?? pp.platform;
const activeId = ref<string | null>(props.platforms[0]?.id ?? null); const activeId = ref<string | null>(props.platforms[0]?.id ?? null);

View file

@ -18,6 +18,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -110,7 +111,7 @@ const videoDurationSec = computed(() => {
}); });
const getPlatformDisplayName = (pp: PostPlatform): string => const getPlatformDisplayName = (pp: PostPlatform): string =>
pp.social_account?.display_name ?? pp.platform_name ?? pp.platform; pp.social_account?.display_label ?? pp.platform_name ?? pp.platform;
const getPlatformAvatar = (pp: PostPlatform): string | null => const getPlatformAvatar = (pp: PostPlatform): string | null =>
pp.social_account?.avatar_url ?? pp.platform_avatar ?? null; pp.social_account?.avatar_url ?? pp.platform_avatar ?? null;

View file

@ -24,6 +24,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -258,13 +259,13 @@ watch(
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3"> <div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar <Avatar
:src="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:name="socialAccount.display_name" :name="socialAccount.display_label"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs" class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.tiktok.posting_to') }}</p> <p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.tiktok.posting_to') }}</p>
<p class="truncate text-sm"> <p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span> <span class="font-bold text-foreground">{{ socialAccount.display_label }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span> <span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p> </p>
</div> </div>

View file

@ -3,6 +3,7 @@ import { computed, toRef } from 'vue';
import LinkCard from "@/components/posts/previews/LinkCard.vue"; import LinkCard from "@/components/posts/previews/LinkCard.vue";
import VideoPreview from "@/components/posts/previews/VideoPreview.vue"; import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { getInitials } from '@/composables/useInitials';
import { useLinkCard } from '@/composables/useLinkCard'; import { useLinkCard } from '@/composables/useLinkCard';
import { isVideoMedia } from '@/composables/useMedia'; import { isVideoMedia } from '@/composables/useMedia';
import date from '@/date'; import date from '@/date';
@ -13,6 +14,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -67,16 +69,16 @@ const { card: linkCard, loading: linkCardLoading } = useLinkCard(
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<!-- Avatar --> <!-- Avatar -->
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" <img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:alt="socialAccount.display_name" class="h-11 w-11 rounded-full object-cover shrink-0" /> :alt="socialAccount.display_label" class="h-11 w-11 rounded-full object-cover shrink-0" />
<div v-else <div v-else
class="h-11 w-11 rounded-full bg-gradient-to-br from-[#0085ff] to-[#00d4ff] flex items-center justify-center text-white font-semibold shrink-0"> class="h-11 w-11 rounded-full bg-gradient-to-br from-[#0085ff] to-[#00d4ff] flex items-center justify-center text-white font-semibold shrink-0">
{{ socialAccount.display_name?.charAt(0) }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<!-- Name and handle --> <!-- Name and handle -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-semibold text-[15px] text-black dark:text-white"> <div class="font-semibold text-[15px] text-black dark:text-white">
{{ socialAccount.display_name }} {{ socialAccount.display_label }}
</div> </div>
<div class="text-[14px] text-neutral-500 dark:text-[#7b8d9e] truncate"> <div class="text-[14px] text-neutral-500 dark:text-[#7b8d9e] truncate">
@{{ socialAccount.username || 'handle' }}.bsky.social @{{ socialAccount.username || 'handle' }}.bsky.social
@ -188,11 +190,11 @@ const { card: linkCard, loading: linkCardLoading } = useLinkCard(
<!-- Reply input --> <!-- Reply input -->
<div class="px-4 py-3 border-t border-neutral-200 dark:border-[#1e3a5f] flex items-center gap-3"> <div class="px-4 py-3 border-t border-neutral-200 dark:border-[#1e3a5f] flex items-center gap-3">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="socialAccount.display_name" <img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="socialAccount.display_label"
class="h-8 w-8 rounded-full object-cover shrink-0" /> class="h-8 w-8 rounded-full object-cover shrink-0" />
<div v-else <div v-else
class="h-8 w-8 rounded-full bg-gradient-to-br from-[#0085ff] to-[#00d4ff] flex items-center justify-center text-white text-xs font-semibold shrink-0"> class="h-8 w-8 rounded-full bg-gradient-to-br from-[#0085ff] to-[#00d4ff] flex items-center justify-center text-white text-xs font-semibold shrink-0">
{{ socialAccount.display_name?.charAt(0) }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<div class="flex-1 text-[15px] text-neutral-400 dark:text-[#7b8d9e]"> <div class="flex-1 text-[15px] text-neutral-400 dark:text-[#7b8d9e]">
Write your reply Write your reply

View file

@ -3,6 +3,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue'; import { computed } from 'vue';
import VideoPreview from '@/components/posts/previews/VideoPreview.vue'; import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
import { getInitials } from '@/composables/useInitials';
import { isVideoMedia } from '@/composables/useMedia'; import { isVideoMedia } from '@/composables/useMedia';
import date from '@/date'; import date from '@/date';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
@ -12,6 +13,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -58,19 +60,19 @@ const postedAtLabel = computed(() =>
<img <img
v-if="socialAccount.avatar_url" v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:alt="socialAccount.display_name" :alt="socialAccount.display_label"
class="h-10 w-10 shrink-0 rounded-full object-cover" class="h-10 w-10 shrink-0 rounded-full object-cover"
/> />
<div <div
v-else v-else
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#5865F2] font-semibold text-white" class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#5865F2] font-semibold text-white"
> >
{{ socialAccount.display_name?.charAt(0) }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span class="text-[15px] font-medium text-[#060607]">{{ socialAccount.display_name || 'TryPost' }}</span> <span class="text-[15px] font-medium text-[#060607]">{{ socialAccount.display_label }}</span>
<span class="rounded bg-[#5865F2] px-1 text-[10px] font-bold uppercase tracking-wide text-white">Bot</span> <span class="rounded bg-[#5865F2] px-1 text-[10px] font-bold uppercase tracking-wide text-white">Bot</span>
<span class="text-[11px] text-[#5c5e66]">{{ postedAtLabel }}</span> <span class="text-[11px] text-[#5c5e66]">{{ postedAtLabel }}</span>
</div> </div>

View file

@ -4,6 +4,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue'; import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue'; import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import { getInitials } from '@/composables/useInitials';
import date from '@/date'; import date from '@/date';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
@ -12,6 +13,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -72,7 +74,6 @@ const truncatedContent = computed(() => {
return props.content.substring(0, 100) + '...'; return props.content.substring(0, 100) + '...';
}); });
const displayName = computed(() => props.socialAccount.display_name || props.socialAccount.username);
</script> </script>
<template> <template>
@ -114,14 +115,14 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
<!-- Post Header --> <!-- Post Header -->
<div class="flex-shrink-0 flex items-center px-3 py-2"> <div class="flex-shrink-0 flex items-center px-3 py-2">
<div class="flex items-center gap-2 flex-1"> <div class="flex items-center gap-2 flex-1">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="displayName" <img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="socialAccount.display_label"
class="w-10 h-10 rounded-full object-cover" /> class="w-10 h-10 rounded-full object-cover" />
<div v-else <div v-else
class="w-10 h-10 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold"> class="w-10 h-10 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold">
{{ displayName?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<div class="flex flex-col min-w-0"> <div class="flex flex-col min-w-0">
<span class="text-[13px] font-semibold leading-tight">{{ displayName }}</span> <span class="text-[13px] font-semibold leading-tight">{{ socialAccount.display_label }}</span>
<div class="flex items-center gap-1 text-[11px] text-[#65676b] dark:text-[#b0b3b8]"> <div class="flex items-center gap-1 text-[11px] text-[#65676b] dark:text-[#b0b3b8]">
<span>{{ postedAtLabel }}</span> <span>{{ postedAtLabel }}</span>
<span>·</span> <span>·</span>
@ -291,7 +292,7 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
class="w-full h-full object-cover" /> class="w-full h-full object-cover" />
<div v-else <div v-else
class="w-full h-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-xs"> class="w-full h-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-xs">
{{ displayName?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
</div> </div>
</div> </div>
@ -303,9 +304,9 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
class="w-8 h-8 rounded-full object-cover border border-white/30" /> class="w-8 h-8 rounded-full object-cover border border-white/30" />
<div v-else <div v-else
class="w-8 h-8 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-[12px] border border-white/30"> class="w-8 h-8 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-[12px] border border-white/30">
{{ displayName?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<span class="text-white text-[13px] font-semibold drop-shadow-lg">{{ displayName }}</span> <span class="text-white text-[13px] font-semibold drop-shadow-lg">{{ socialAccount.display_label }}</span>
<button class="px-3 py-1 bg-[#1877f2] text-white text-[11px] font-semibold rounded-md"> <button class="px-3 py-1 bg-[#1877f2] text-white text-[11px] font-semibold rounded-md">
Follow Follow
</button> </button>
@ -388,10 +389,10 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
class="w-8 h-8 rounded-full object-cover border-2 border-black" /> class="w-8 h-8 rounded-full object-cover border-2 border-black" />
<div v-else <div v-else
class="w-8 h-8 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-[11px] border-2 border-black"> class="w-8 h-8 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-[11px] border-2 border-black">
{{ displayName?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
</div> </div>
<span class="text-white text-[13px] font-semibold drop-shadow-lg">{{ displayName }}</span> <span class="text-white text-[13px] font-semibold drop-shadow-lg">{{ socialAccount.display_label }}</span>
<span class="text-white/70 text-[11px] drop-shadow">2h</span> <span class="text-white/70 text-[11px] drop-shadow">2h</span>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -410,7 +411,7 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
<!-- Bottom Reply Bar --> <!-- Bottom Reply Bar -->
<div v-if="media.length > 0" class="absolute bottom-3 left-3 right-3 flex items-center gap-2 z-10"> <div v-if="media.length > 0" class="absolute bottom-3 left-3 right-3 flex items-center gap-2 z-10">
<div class="flex-1 bg-white/20 backdrop-blur-md rounded-full px-4 py-2.5 border border-white/20"> <div class="flex-1 bg-white/20 backdrop-blur-md rounded-full px-4 py-2.5 border border-white/20">
<span class="text-white/80 text-[13px]">Reply to {{ displayName }}...</span> <span class="text-white/80 text-[13px]">Reply to {{ socialAccount.display_label }}...</span>
</div> </div>
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor"> <svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path <path

View file

@ -15,6 +15,7 @@ import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue'; import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue'; import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue';
import { getInitials } from '@/composables/useInitials';
import { ContentType } from '@/types/content-type'; import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
@ -23,6 +24,8 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
handle_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -84,8 +87,6 @@ const truncatedCaption = computed(() => {
if (props.content.length <= 80) return props.content; if (props.content.length <= 80) return props.content;
return props.content.substring(0, 80) + '...'; return props.content.substring(0, 80) + '...';
}); });
const username = computed(() => props.socialAccount.username || props.socialAccount.display_name);
</script> </script>
<template> <template>
@ -111,15 +112,15 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
style="background: conic-gradient(from 180deg, #feda75, #fa7e1e, #d62976, #962fbf, #4f5bd5, #feda75)"> style="background: conic-gradient(from 180deg, #feda75, #fa7e1e, #d62976, #962fbf, #4f5bd5, #feda75)">
<div class="p-[1.5px] bg-white dark:bg-black rounded-full"> <div class="p-[1.5px] bg-white dark:bg-black rounded-full">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" <img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:alt="socialAccount.display_name" class="w-7 h-7 rounded-full object-cover" /> :alt="socialAccount.display_label" class="w-7 h-7 rounded-full object-cover" />
<div v-else <div v-else
class="w-7 h-7 rounded-full bg-gradient-to-br from-[#833ab4] to-[#fd1d1d] flex items-center justify-center text-white font-semibold text-[10px]"> class="w-7 h-7 rounded-full bg-gradient-to-br from-[#833ab4] to-[#fd1d1d] flex items-center justify-center text-white font-semibold text-[10px]">
{{ socialAccount.display_name?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col min-w-0"> <div class="flex flex-col min-w-0">
<span class="text-[12px] font-semibold leading-tight truncate">{{ username }}</span> <span class="text-[12px] font-semibold leading-tight truncate">{{ socialAccount.handle_label }}</span>
</div> </div>
</div> </div>
<IconDots class="w-4 h-4 flex-shrink-0" /> <IconDots class="w-4 h-4 flex-shrink-0" />
@ -155,7 +156,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<!-- Caption --> <!-- Caption -->
<div v-if="content" class="flex-shrink-0 px-2.5 py-0.5"> <div v-if="content" class="flex-shrink-0 px-2.5 py-0.5">
<p class="text-[12px] line-clamp-2"> <p class="text-[12px] line-clamp-2">
<span class="font-semibold">{{ username }}</span> <span class="font-semibold">{{ socialAccount.handle_label }}</span>
<span class="ml-1">{{ content }}</span> <span class="ml-1">{{ content }}</span>
</p> </p>
</div> </div>
@ -206,9 +207,9 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
class="w-7 h-7 rounded-full object-cover border border-white/30" /> class="w-7 h-7 rounded-full object-cover border border-white/30" />
<div v-else <div v-else
class="w-7 h-7 rounded-full bg-gradient-to-br from-[#833ab4] to-[#fd1d1d] flex items-center justify-center text-white font-semibold text-[10px] border border-white/30"> class="w-7 h-7 rounded-full bg-gradient-to-br from-[#833ab4] to-[#fd1d1d] flex items-center justify-center text-white font-semibold text-[10px] border border-white/30">
{{ socialAccount.display_name?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<span class="text-white text-[12px] font-semibold drop-shadow-lg">{{ username }}</span> <span class="text-white text-[12px] font-semibold drop-shadow-lg">{{ socialAccount.handle_label }}</span>
<button class="px-2 py-0.5 border border-white/70 rounded text-white text-[10px] font-semibold"> <button class="px-2 py-0.5 border border-white/70 rounded text-white text-[10px] font-semibold">
Follow Follow
</button> </button>
@ -257,13 +258,13 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
class="w-7 h-7 rounded-full object-cover" /> class="w-7 h-7 rounded-full object-cover" />
<div v-else <div v-else
class="w-7 h-7 rounded-full bg-gradient-to-br from-[#833ab4] to-[#fd1d1d] flex items-center justify-center text-white font-semibold text-[10px]"> class="w-7 h-7 rounded-full bg-gradient-to-br from-[#833ab4] to-[#fd1d1d] flex items-center justify-center text-white font-semibold text-[10px]">
{{ socialAccount.display_name?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
</div> </div>
</div> </div>
<span class="text-[12px] font-semibold" <span class="text-[12px] font-semibold"
:class="media.length > 0 ? 'text-white drop-shadow-lg' : 'text-[#262626] dark:text-white'">{{ :class="media.length > 0 ? 'text-white drop-shadow-lg' : 'text-[#262626] dark:text-white'">{{
username socialAccount.handle_label
}}</span> }}</span>
<span class="text-[10px]" <span class="text-[10px]"
:class="media.length > 0 ? 'text-white/70 drop-shadow' : 'text-[#737373] dark:text-white/70'">2h</span> :class="media.length > 0 ? 'text-white/70 drop-shadow' : 'text-[#737373] dark:text-white/70'">2h</span>

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import VideoPreview from "@/components/posts/previews/VideoPreview.vue"; import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { getInitials } from '@/composables/useInitials';
import { isDocumentMedia, isVideoMedia } from '@/composables/useMedia'; import { isDocumentMedia, isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
@ -8,6 +9,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -30,16 +32,16 @@ defineProps<Props>();
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="relative flex-shrink-0"> <div class="relative flex-shrink-0">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" <img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:alt="socialAccount.display_name" class="h-12 w-12 rounded-full object-cover" /> :alt="socialAccount.display_label" class="h-12 w-12 rounded-full object-cover" />
<div v-else <div v-else
class="h-12 w-12 rounded-full bg-[#0a66c2] flex items-center justify-center text-white font-semibold text-lg"> class="h-12 w-12 rounded-full bg-[#0a66c2] flex items-center justify-center text-white font-semibold text-lg">
{{ socialAccount.display_name?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<p class="font-semibold text-[15px] text-[#000000e6] dark:text-[#ffffffe6]"> <p class="font-semibold text-[15px] text-[#000000e6] dark:text-[#ffffffe6]">
{{ socialAccount.display_name }} {{ socialAccount.display_label }}
</p> </p>
<span class="text-[14px] text-[#00000099] dark:text-[#ffffff99]"> 1st</span> <span class="text-[14px] text-[#00000099] dark:text-[#ffffff99]"> 1st</span>
</div> </div>
@ -123,11 +125,11 @@ defineProps<Props>();
<!-- Comment Input --> <!-- Comment Input -->
<div class="px-2 py-4 flex items-center gap-2"> <div class="px-2 py-4 flex items-center gap-2">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="socialAccount.display_name" <img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="socialAccount.display_label"
class="h-8 w-8 rounded-full object-cover shrink-0" /> class="h-8 w-8 rounded-full object-cover shrink-0" />
<div v-else <div v-else
class="h-8 w-8 rounded-full bg-[#0a66c2] flex items-center justify-center text-white font-semibold text-sm shrink-0"> class="h-8 w-8 rounded-full bg-[#0a66c2] flex items-center justify-center text-white font-semibold text-sm shrink-0">
{{ socialAccount.display_name?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<div class="flex-1 bg-[#f4f2ee] dark:bg-[#38434f] rounded-full px-4 py-2"> <div class="flex-1 bg-[#f4f2ee] dark:bg-[#38434f] rounded-full px-4 py-2">
<span class="text-[14px] text-[#00000099] dark:text-[#ffffff99]">Leave your thoughts...</span> <span class="text-[14px] text-[#00000099] dark:text-[#ffffff99]">Leave your thoughts...</span>

View file

@ -2,6 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue"; import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { getInitials } from '@/composables/useInitials';
import { isVideoMedia } from '@/composables/useMedia'; import { isVideoMedia } from '@/composables/useMedia';
import date from '@/date'; import date from '@/date';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
@ -11,6 +12,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -36,14 +38,14 @@ const postedAtLabel = computed(() => date.formatMastodonPreview(props.postedAt))
<!-- Author --> <!-- Author -->
<div class="flex items-center gap-3 mb-3"> <div class="flex items-center gap-3 mb-3">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" <img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:alt="socialAccount.display_name" class="h-10 w-10 rounded-full object-cover" /> :alt="socialAccount.display_label" class="h-10 w-10 rounded-full object-cover" />
<div v-else <div v-else
class="h-10 w-10 rounded-full bg-gradient-to-br from-[#6364ff] to-[#563acc] flex items-center justify-center text-white font-semibold"> class="h-10 w-10 rounded-full bg-gradient-to-br from-[#6364ff] to-[#563acc] flex items-center justify-center text-white font-semibold">
{{ socialAccount.display_name?.charAt(0) }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-bold text-[15px]"> <div class="font-bold text-[15px]">
{{ socialAccount.display_name }} {{ socialAccount.display_label }}
</div> </div>
<div class="text-[14px] text-[#606984] dark:text-[#9baec8] truncate"> <div class="text-[14px] text-[#606984] dark:text-[#9baec8] truncate">
@{{ socialAccount.username || 'user' }}@mastodon.social @{{ socialAccount.username || 'user' }}@mastodon.social

View file

@ -3,6 +3,7 @@ import { IconPhoto, IconStack2 } from '@tabler/icons-vue';
import { computed } from 'vue'; import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue"; import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { getInitials } from '@/composables/useInitials';
import { isVideoMedia } from '@/composables/useMedia'; import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
@ -11,6 +12,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -145,14 +147,14 @@ const pinLink = computed(() => (props.meta?.link as string | undefined) || '');
<img <img
v-if="socialAccount.avatar_url" v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:alt="socialAccount.display_name" :alt="socialAccount.display_label"
class="h-6 w-6 rounded-full object-cover" class="h-6 w-6 rounded-full object-cover"
/> />
<div v-else class="h-6 w-6 rounded-full bg-[#e60023] flex items-center justify-center text-white font-semibold text-[10px]"> <div v-else class="h-6 w-6 rounded-full bg-[#e60023] flex items-center justify-center text-white font-semibold text-[10px]">
{{ socialAccount.display_name?.charAt(0) }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<span class="text-[12px] font-medium text-[#111111] dark:text-[#e0e0e0] truncate"> <span class="text-[12px] font-medium text-[#111111] dark:text-[#e0e0e0] truncate">
{{ socialAccount.display_name }} {{ socialAccount.display_label }}
</span> </span>
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { getPlatformLabel } from '@/composables/usePlatformLogo';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
import BlueskyPreview from './BlueskyPreview.vue'; import BlueskyPreview from './BlueskyPreview.vue';
@ -21,6 +22,8 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
handle_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -42,6 +45,8 @@ const resolvedSocialAccount = computed((): SocialAccount => props.socialAccount
platform: props.platform, platform: props.platform,
display_name: '', display_name: '',
username: '', username: '',
display_label: getPlatformLabel(props.platform),
handle_label: getPlatformLabel(props.platform),
avatar_url: null, avatar_url: null,
}); });

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import VideoPreview from '@/components/posts/previews/VideoPreview.vue'; import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
import { getInitials } from '@/composables/useInitials';
import { isVideoMedia } from '@/composables/useMedia'; import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
@ -8,6 +9,7 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -38,20 +40,20 @@ const sampleReactions = [
<img <img
v-if="socialAccount.avatar_url" v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:alt="socialAccount.display_name" :alt="socialAccount.display_label"
class="h-9 w-9 rounded-full object-cover" class="h-9 w-9 rounded-full object-cover"
/> />
<div <div
v-else v-else
class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-[#2aabee] to-[#229ed9] font-semibold text-white" class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-[#2aabee] to-[#229ed9] font-semibold text-white"
> >
{{ socialAccount.display_name?.charAt(0) }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div <div
class="truncate text-[15px] font-semibold text-[#1f232b] dark:text-white" class="truncate text-[15px] font-semibold text-[#1f232b] dark:text-white"
> >
{{ socialAccount.display_name || 'Channel' }} {{ socialAccount.display_label }}
</div> </div>
<div class="text-[13px] text-[#707991] dark:text-[#708499]"> <div class="text-[13px] text-[#707991] dark:text-[#708499]">
channel channel

View file

@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, toRef } from 'vue'; import { toRef } from 'vue';
import LinkCard from "@/components/posts/previews/LinkCard.vue"; import LinkCard from "@/components/posts/previews/LinkCard.vue";
import VideoPreview from "@/components/posts/previews/VideoPreview.vue"; import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { getInitials } from '@/composables/useInitials';
import { useLinkCard } from '@/composables/useLinkCard'; import { useLinkCard } from '@/composables/useLinkCard';
import { isVideoMedia } from '@/composables/useMedia'; import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
@ -12,6 +13,8 @@ interface SocialAccount {
platform: string; platform: string;
display_name: string; display_name: string;
username: string; username: string;
display_label: string;
handle_label: string;
avatar_url: string | null; avatar_url: string | null;
} }
@ -23,8 +26,6 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const username = computed(() => props.socialAccount.username || props.socialAccount.display_name);
const { card: linkCard, loading: linkCardLoading } = useLinkCard( const { card: linkCard, loading: linkCardLoading } = useLinkCard(
toRef(props, 'content'), toRef(props, 'content'),
toRef(props, 'media'), toRef(props, 'media'),
@ -69,17 +70,17 @@ const { card: linkCard, loading: linkCardLoading } = useLinkCard(
<!-- Avatar --> <!-- Avatar -->
<div class="w-9 h-9 rounded-full overflow-hidden flex-shrink-0"> <div class="w-9 h-9 rounded-full overflow-hidden flex-shrink-0">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" <img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:alt="socialAccount.display_name" class="w-full h-full object-cover" /> :alt="socialAccount.display_label" class="w-full h-full object-cover" />
<div v-else <div v-else
class="w-full h-full bg-gradient-to-br from-[#833ab4] via-[#fd1d1d] to-[#fcb045] flex items-center justify-center text-white font-bold text-sm"> class="w-full h-full bg-gradient-to-br from-[#833ab4] via-[#fd1d1d] to-[#fcb045] flex items-center justify-center text-white font-bold text-sm">
{{ socialAccount.display_name?.charAt(0).toUpperCase() }} {{ getInitials(socialAccount.display_label) }}
</div> </div>
</div> </div>
<!-- Name + verified --> <!-- Name + verified -->
<div class="flex items-center gap-1 min-w-0"> <div class="flex items-center gap-1 min-w-0">
<span class="font-semibold text-[15px] text-[#000000] dark:text-[#f5f5f5] truncate"> <span class="font-semibold text-[15px] text-[#000000] dark:text-[#f5f5f5] truncate">
{{ username }} {{ socialAccount.handle_label }}
</span> </span>
<!-- Verified badge --> <!-- Verified badge -->
<svg class="h-3.5 w-3.5 text-[#0095f6] flex-shrink-0" viewBox="0 0 40 40" fill="currentColor"> <svg class="h-3.5 w-3.5 text-[#0095f6] flex-shrink-0" viewBox="0 0 40 40" fill="currentColor">

Some files were not shown because too many files have changed in this diff Show more