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.
1
.gitignore
vendored
|
|
@ -30,3 +30,4 @@ yarn-error.log
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/docs/
|
/docs/
|
||||||
|
/.superpowers/
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
45
app/Console/Commands/CheckUpcomingPostConnections.php
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', '').')')
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
365
app/Jobs/VerifyUpcomingPostConnections.php
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) => [
|
||||||
|
|
|
||||||
|
|
@ -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) => [
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
31
database/factories/NotificationPreferenceFactory.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -15,4 +15,7 @@
|
||||||
'title' => 'يحتاج حساب :platform إلى إعادة الربط',
|
'title' => 'يحتاج حساب :platform إلى إعادة الربط',
|
||||||
'body' => 'انتهت جلسة :account — يرجى إعادة الربط لمواصلة النشر',
|
'body' => 'انتهت جلسة :account — يرجى إعادة الربط لمواصلة النشر',
|
||||||
],
|
],
|
||||||
|
'post_at_risk' => [
|
||||||
|
'title' => '{1} منشور واحد قادم معرض للخطر|{2} منشوران قادمان معرضان للخطر|[3,10] :count منشورات قادمة معرضة للخطر|[11,*] :count منشورًا قادمًا معرضًا للخطر',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,7 @@
|
||||||
'title' => 'Ο λογαριασμός :platform χρειάζεται επανασύνδεση',
|
'title' => 'Ο λογαριασμός :platform χρειάζεται επανασύνδεση',
|
||||||
'body' => 'Η συνεδρία :account έληξε — επανασυνδεθείτε για να συνεχίσετε να δημοσιεύετε',
|
'body' => 'Η συνεδρία :account έληξε — επανασυνδεθείτε για να συνεχίσετε να δημοσιεύετε',
|
||||||
],
|
],
|
||||||
|
'post_at_risk' => [
|
||||||
|
'title' => '{1} :count επερχόμενη ανάρτηση κινδυνεύει|[2,*] :count επερχόμενες αναρτήσεις κινδυνεύουν',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,7 @@
|
||||||
'title' => ':platform アカウントの再接続が必要です',
|
'title' => ':platform アカウントの再接続が必要です',
|
||||||
'body' => ':account のセッションの有効期限が切れました — 投稿を続けるには再接続してください',
|
'body' => ':account のセッションの有効期限が切れました — 投稿を続けるには再接続してください',
|
||||||
],
|
],
|
||||||
|
'post_at_risk' => [
|
||||||
|
'title' => '{1} :count 件の予定投稿にリスクがあります|[2,*] :count 件の予定投稿にリスクがあります',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,7 @@
|
||||||
'title' => ':platform 계정을 재연결해야 합니다',
|
'title' => ':platform 계정을 재연결해야 합니다',
|
||||||
'body' => ':account 세션이 만료되었습니다 — 계속 게시하려면 재연결하세요',
|
'body' => ':account 세션이 만료되었습니다 — 계속 게시하려면 재연결하세요',
|
||||||
],
|
],
|
||||||
|
'post_at_risk' => [
|
||||||
|
'title' => '{1} 예정된 게시물 :count건이 위험합니다|[2,*] 예정된 게시물 :count건이 위험합니다',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,7 @@
|
||||||
'title' => 'Аккаунт :platform требует переподключения',
|
'title' => 'Аккаунт :platform требует переподключения',
|
||||||
'body' => 'Сессия :account истекла — переподключите, чтобы продолжить публикацию',
|
'body' => 'Сессия :account истекла — переподключите, чтобы продолжить публикацию',
|
||||||
],
|
],
|
||||||
|
'post_at_risk' => [
|
||||||
|
'title' => '{1} :count запланированный пост под угрозой|[2,4] :count запланированных поста под угрозой|[5,*] :count запланированных постов под угрозой',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,7 @@
|
||||||
'title' => 'Акаунт :platform потрібно перепідключити',
|
'title' => 'Акаунт :platform потрібно перепідключити',
|
||||||
'body' => 'Сесію :account завершено — перепідключіть, щоб продовжити публікацію',
|
'body' => 'Сесію :account завершено — перепідключіть, щоб продовжити публікацію',
|
||||||
],
|
],
|
||||||
|
'post_at_risk' => [
|
||||||
|
'title' => '{1} :count запланована публікація під загрозою|[2,*] :count заплановані публікації під загрозою',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,7 @@
|
||||||
'title' => ':platform 账号需要重新连接',
|
'title' => ':platform 账号需要重新连接',
|
||||||
'body' => ':account 会话已过期——请重新连接以继续发帖',
|
'body' => ':account 会话已过期——请重新连接以继续发帖',
|
||||||
],
|
],
|
||||||
|
'post_at_risk' => [
|
||||||
|
'title' => '{1} 有 :count 篇待发布的帖子存在风险|[2,*] 有 :count 篇待发布的帖子存在风险',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
48
maizzle/components/footer-authenticated.html
Normal 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">
|
||||||
|
© @{{ date('Y') }} TryPost.it
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
@ -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">
|
||||||
|
© @{{ date('Y') }} TryPost.it
|
||||||
</p>
|
</p>
|
||||||
@endif
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
BIN
maizzle/images/social/discord.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
maizzle/images/social/github.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
maizzle/images/social/instagram.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
maizzle/images/social/x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
maizzle/images/social/youtube.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
57
maizzle/templates/post-at-risk.html
Normal 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 }} →
|
||||||
|
</x-button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<x-footer-authenticated />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</x-main>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
BIN
public/images/emails/social/discord.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
public/images/emails/social/github.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/images/emails/social/instagram.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/images/emails/social/x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/images/emails/social/youtube.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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">· {{ socialAccount.display_name }}</span>
|
<span v-if="socialAccount?.display_label" class="truncate font-medium text-foreground/60">· {{ 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"> @{{ socialAccount.username }}</span>
|
<span v-if="socialAccount?.username" class="font-medium text-foreground/60"> @{{ socialAccount.username }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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"> @{{ socialAccount.username }}</span>
|
<span v-if="socialAccount?.username" class="font-medium text-foreground/60"> @{{ socialAccount.username }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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"> @{{ socialAccount.username }}</span>
|
<span v-if="socialAccount?.username" class="font-medium text-foreground/60"> @{{ socialAccount.username }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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"> @{{ socialAccount.username }}</span>
|
<span v-if="socialAccount?.username" class="font-medium text-foreground/60"> @{{ socialAccount.username }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"> @{{ socialAccount.username }}</span>
|
<span v-if="socialAccount?.username" class="font-medium text-foreground/60"> @{{ socialAccount.username }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||