feat(timeline): social feed with posts, replies, and moderation events#223
feat(timeline): social feed with posts, replies, and moderation events#223danielhe4rt wants to merge 28 commits into4.xfrom
Conversation
- Rename panel-hub → panel-app module - Create activity_timeline and activity_post_entries tables - Add tenant_id, drop is_reported, change content to text - Add Timeline and PostEntry models with factories - Register morphMap for post_entry and moderation_event - Register prototype command in ActivityServiceProvider - Rename HubPanelProvider → AppPanelProvider - Add UI prototype variants for timeline feed
… morphMap - Timeline: add tenant, postable, root/parent/children relations, HasReactions - PostEntry: add InteractsWithMedia with images collection - Register post_entry, moderation_event, timeline in morphMap - Update factories with proper defaults - Fix duplicate index in create migration - Add 3 model unit tests
- CreatePost: creates PostEntry + Timeline entry, validates content - CreateReply: 1-level thread flattening (reply-to-reply → root) - TogglePinPost: single pin per user per tenant, authorization check - TimelineFeed: tenant-scoped query, excludes replies and ignored posts - 12 unit tests covering all actions and query
- PublishModerationEntry: only Ban/Kick create timeline entries - Fix postable_id to uuidMorphs (ModerationEvent uses UUIDs) - Add HasUuids to PostEntry, change id to uuid primary key - Add migration to alter existing columns for UUID support - Use ExternalIdentity.model_id for user resolution - 3 unit tests (ban publishes, kick publishes, warn ignored)
- Feed component with HasLoadMore infinite scroll - PostShow component with togglePin action - Blade: header, post-entry, moderation-event (hero block), engagement - TimelinePage with composer Action (MarkdownEditor + image upload) - Register ModerationEvent::created observer in ActivityServiceProvider - Register timeline-feed and timeline-post-show Livewire components - Update dashboard view to render live feed
- Add PublishModerationToTimeline listener for cross-module integration - Register listener via Event::listen(ActionExecuted::class) in ActivityServiceProvider - Add moderation_action to morphMap - Update moderation-event Blade to handle both ModerationEvent (Discord) and ModerationAction (web panel) with unified display - Show report count and violation type from linked ModerationCase
…stener ActionExecuted listener now creates a ModerationEvent (activity module) from the ModerationAction (moderation module), instead of referencing ModerationAction directly. The timeline only ever references moderation_event as postable type. - Listener resolves ExternalIdentity from User IDs for subject/moderator - Enriches metadata with case_id, reports_count, violation_type, source - Removed moderation_action from morphMap - Blade component handles only ModerationEvent, reads enriched metadata
Twitter-like post field with avatar, auto-growing textarea, Ctrl+Enter shortcut, and Postar button. Renders above the feed items.
Replace plain textarea with Filament HasSchemas + InteractsWithSchemas pattern. Composer now uses MarkdownEditor with toolbar (bold, italic, link, bulletList, orderedList) and proper form validation via getState().
Replace MarkdownEditor with Textarea (autosize, compact) and FileUpload for images (up to 4). Matches the previous Twitter-like design with avatar left, clean input, and footer bar with Ctrl+Enter hint + button.
FileUpload hidden by default, revealed via photo icon button in footer. Alpine.js toggles visibility class on the upload field container. Icon highlights when upload area is active.
Replace broken dynamic Tailwind class with x-show + x-cloak + x-transition on the FileUpload field wrapper via extraFieldWrapperAttributes.
FileUpload stores relative paths (strings), not UploadedFile objects.
Use Storage::disk('public')->path() to resolve the full path before
passing to Spatie's addMedia().
Dedicated thread page at /app/{tenant}/timeline/{id} with reply composer,
reply listing, and delete-own-reply support. Comment icon in feed now
navigates to thread page. Replies only allowed on post_entry, not
moderation events.
Remove min-w-xl constraint, add responsive padding (px-3 sm:px-4), hide @username and pin text on small screens, scale down avatars, and reduce gaps for mobile viewports.
TimelinePage no longer needs the header action modal — replaced by inline Composer. Also removes dead message-routes and fixes docs blockquote formatting.
Prototypes served their purpose during design exploration — removing the command, state helper, and UI variant views.
- Scope all Timeline queries by tenant_id in PostShow, ThreadPage, and ThreadReplies to prevent cross-tenant data access - Wrap CreatePost and CreateReply in DB::transaction() to prevent orphaned PostEntry records on failure - Add maxLength(5000) to Composer and ReplyComposer forms - Remove dead prototype command registration from ActivityServiceProvider
- Lock $perPage with #[Locked] to prevent Livewire protocol tampering - Paginate ThreadReplies with simplePaginate + HasLoadMore (was unbounded) - Hard-delete PostEntry on reply deletion (was soft-deleted, creating orphans) - Remove limit(3) from eager-load, use ->take(3) in Blade instead - Only attribute moderation entries to moderator, never fall back to subject - Add composite index (tenant_id, parent_id, is_ignored, created_at)
- Add wire:loading.attr="disabled" to composer submit buttons - Rename delete event to timeline.reply-deleted (was misleading) - Add @continue null check on $reply->postable in views - Remove dead discoverPages call from PanelAppServiceProvider - Remove views counter from engagement (never incremented) - Wrap TogglePinPost in DB::transaction to prevent race condition - Default TimelineFactory tenant_id to Tenant::factory()
Larastan infers user_id as int from foreignIdFor migration, but it's a UUID string. Add explicit @Property types to resolve strict comparison and undefined property errors.
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Repository YAML (base), Central YAML (inherited) Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (57)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
danielhe4rt
left a comment
There was a problem hiding this comment.
Some nitpicks but looks good so far.
| return Timeline::query()->create([ | ||
| 'user_id' => $dto->userId, | ||
| 'tenant_id' => $dto->tenantId, | ||
| 'postable_type' => 'post_entry', |
There was a problem hiding this comment.
| 'postable_type' => 'post_entry', | |
| 'postable_type' => new Model()->getMorphClass(), |
| return Timeline::query()->create([ | ||
| 'user_id' => $dto->userId, | ||
| 'tenant_id' => $parentTimeline->tenant_id, | ||
| 'postable_type' => 'post_entry', |
| return Timeline::query()->create([ | ||
| 'user_id' => $userId, | ||
| 'tenant_id' => $event->tenant_id, | ||
| 'postable_type' => 'moderation_event', |
| $subjectIdentityId = $this->resolveIdentity($case?->author_id, $tenantId); | ||
| $moderatorIdentityId = $this->resolveIdentity($action->moderator_id, $tenantId); |
There was a problem hiding this comment.
Identity should be the user_id (identity_users)
| ModerationEvent::created(function (ModerationEvent $event): void { | ||
| resolve(PublishModerationEntry::class)->handle($event); | ||
| }); |
There was a problem hiding this comment.
Should dispatch some event or so.
Or you must create an Observer for it.
|
|
||
| $timeline = Timeline::query() | ||
| ->where('id', $this->timelineId) | ||
| ->where('tenant_id', Filament::getTenant()->id) |
There was a problem hiding this comment.
use helper "filament()->getTenant()->getKey()" instead
| public function getTitle(): string | ||
| { | ||
| return 'Thread'; | ||
| } |
There was a problem hiding this comment.
getTitle() can be removed from the visualization.
| @@ -0,0 +1,40 @@ | |||
| <?php | |||
There was a problem hiding this comment.
This should be moved to the activities module.
| @@ -0,0 +1,40 @@ | |||
| <?php | |||
| @@ -0,0 +1,20 @@ | |||
| <?php | |||
There was a problem hiding this comment.
since this is a wip, the migrations should be merged into create table only, no changes.
- Use getMorphClass() instead of hardcoded postable_type strings - Use user_id directly in listener instead of resolving ExternalIdentity - Extract ModerationEventObserver from inline callback - Use filament()->getTenant()->getKey() helper consistently - Remove redundant getTitle() from ThreadPage - Consolidate 6 WIP migrations into 2 clean create-table migrations inside the activity module (removed from database/migrations/)
Replace all hardcoded 'post_entry' and 'moderation_event' strings with (new Model)->getMorphClass() calls for consistency with the morph map registered in ActivityServiceProvider.
The external_identity_id and moderator_identity_id columns have FK constraints to external_identities table. Passing user_ids directly caused QueryException in CI. Restored resolveIdentity lookup with getMorphClass() for the model_type filter.
| 'timeline' => Timeline::class, | ||
| ]); | ||
|
|
||
| ModerationEvent::observe(ModerationEventObserver::class); |
There was a problem hiding this comment.
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[ObservedBy([ModerationEventObserver::class])]
class ModerationEvent extends Model
{
// ...
}
``| uses(RefreshDatabase::class); | ||
|
|
||
| test('creates a post entry and timeline record', function (): void { | ||
| $tenant = Tenant::factory()->create(); |
| use He4rt\Identity\User\Models\User; | ||
| use Illuminate\Foundation\Testing\RefreshDatabase; | ||
|
|
||
| uses(RefreshDatabase::class); |
There was a problem hiding this comment.
If I'm not mistaken, RefreshDatabase in the pest configuration covers the module.
Summary
Twitter-like social feed on the
/apppanel. Users can post (text + images), reply in threads, and see moderation events (bans/kicks) published automatically. Built on a polymorphic hub model extensible to new entry types./app/{tenant}/timeline/{id}with reply composer and paginated repliesActionExecutedevents from the moderation module automatically publish ban/kick entries to the feedArchitecture
Core changes
Timeline(UUID, polymorphicpostable, threading viaroot_id/parent_id,HasReactions, tenant-scoped),PostEntry(UUID, SoftDeletes, HasMedia)CreatePost,CreateReply(both atomic viaDB::transaction),DeleteReply(ownership + tenant check),TogglePinPost(single pin per tenant, transactional),PublishModerationEntryCreatePostDTO,CreateReplyDTOTimelineFeed— filters by tenant, excludes replies and ignored entries, orders bycreated_at DESCPublishModerationToTimelinelistener onActionExecuted→ convertsModerationActionintoModerationEvent→ observer creates Timeline entryFeed(infinite scroll),PostShow(post card),Composer(inline post form),ReplyComposer,ThreadReplies(paginated, delete-own)TimelinePage(dashboard),ThreadPage(/timeline/{record}, tenant-validated)#[Locked]on IDs and$perPage,DB::transactionon writes,maxLength(5000)on content,wire:loading.attr="disabled"on submit buttonsFile tree
Test plan
Automated (32 tests passing)
CreatePostTest— creates post, rejects empty, handles long content, atomic rollback on failureCreateReplyTest— reply to root, reply-to-reply flattens, atomic rollbackDeleteReplyTest— owner can delete, cannot delete others', cannot delete root postsTogglePinPostTest— pin/unpin, only one pin per tenant, only ownerPublishModerationEntryTest— publishes ban/kick via observer, skips warn/mute, skips when no moderatorTimelineModelTest— relations, threading, morph mapTimelineFeedQueryTest— tenant scoping, excludes replies and ignoredThreadPageTest— page renders, reply submission, chronological order, delete own, tenant isolation (3 cross-tenant tests)Manual E2E checklist for reviewer
Feed
/app/{tenant}— feed loads with infinite scrollsimplePaginate)Thread page
/app/{tenant}/timeline/{id}Tenant isolation (critical)
/app/{tenant-b}/timeline/{uuid-from-tenant-a}→ should 404Mobile