Skip to content

feat(timeline): social feed with posts, replies, and moderation events#223

Open
danielhe4rt wants to merge 28 commits into4.xfrom
feat/app-timeline
Open

feat(timeline): social feed with posts, replies, and moderation events#223
danielhe4rt wants to merge 28 commits into4.xfrom
feat/app-timeline

Conversation

@danielhe4rt
Copy link
Copy Markdown
Contributor

Summary

Twitter-like social feed on the /app panel. 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.

  • Inline composer with MarkdownEditor + FileUpload (image toggle via Alpine x-show)
  • Dedicated thread page at /app/{tenant}/timeline/{id} with reply composer and paginated replies
  • Moderation integrationActionExecuted events from the moderation module automatically publish ban/kick entries to the feed
  • 1-level flat threading — reply-to-reply flattens to root, keeping the UI simple
  • Mobile responsive — all components scale down with responsive padding, hidden usernames, and icon-only buttons on small screens

Architecture

Timeline (polymorphic hub — activity_timeline)
├── postable_type: "post_entry"      → PostEntry (user content + images via Media Library)
├── postable_type: "moderation_event" → ModerationEvent (system-generated bans/kicks)
└── postable_type: "..."             → extensible to new types (see docs/timeline.md)

Core changes

Area What
Models Timeline (UUID, polymorphic postable, threading via root_id/parent_id, HasReactions, tenant-scoped), PostEntry (UUID, SoftDeletes, HasMedia)
Actions CreatePost, CreateReply (both atomic via DB::transaction), DeleteReply (ownership + tenant check), TogglePinPost (single pin per tenant, transactional), PublishModerationEntry
DTOs CreatePostDTO, CreateReplyDTO
Feed query TimelineFeed — filters by tenant, excludes replies and ignored entries, orders by created_at DESC
Cross-module PublishModerationToTimeline listener on ActionExecuted → converts ModerationAction into ModerationEvent → observer creates Timeline entry
Livewire Feed (infinite scroll), PostShow (post card), Composer (inline post form), ReplyComposer, ThreadReplies (paginated, delete-own)
Pages TimelinePage (dashboard), ThreadPage (/timeline/{record}, tenant-validated)
Security Tenant isolation on all queries, #[Locked] on IDs and $perPage, DB::transaction on writes, maxLength(5000) on content, wire:loading.attr="disabled" on submit buttons

File tree

app-modules/activity/
├── database/factories/
│   ├── PostEntryFactory.php
│   └── TimelineFactory.php
├── docs/
│   └── timeline.md                          # Guide for adding new entry types
├── src/
│   ├── ActivityServiceProvider.php          # MorphMap, observers, listeners
│   └── Timeline/
│       ├── Actions/
│       │   ├── CreatePost.php
│       │   ├── CreateReply.php
│       │   ├── DeleteReply.php
│       │   ├── PublishModerationEntry.php
│       │   └── TogglePinPost.php
│       ├── Delegated/
│       │   └── PostEntry.php
│       ├── DTOs/
│       │   ├── CreatePostDTO.php
│       │   └── CreateReplyDTO.php
│       ├── Listeners/
│       │   └── PublishModerationToTimeline.php
│       ├── Queries/
│       │   └── TimelineFeed.php
│       └── Timeline.php
└── tests/Unit/Timeline/
    ├── CreatePostTest.php
    ├── CreateReplyTest.php
    ├── DeleteReplyTest.php
    ├── PublishModerationEntryTest.php
    ├── TimelineFeedQueryTest.php
    ├── TimelineModelTest.php
    └── TogglePinPostTest.php

app-modules/panel-app/
├── resources/views/
│   ├── components/timeline/
│   │   ├── engagement.blade.php
│   │   ├── header.blade.php
│   │   ├── moderation-event.blade.php
│   │   └── post-entry.blade.php
│   ├── dashboard.blade.php
│   ├── livewire/timeline/
│   │   ├── composer.blade.php
│   │   ├── feed.blade.php
│   │   ├── post-show.blade.php
│   │   ├── reply-composer.blade.php
│   │   └── thread-replies.blade.php
│   └── pages/
│       └── thread.blade.php
├── src/
│   ├── Livewire/Timeline/
│   │   ├── Composer.php
│   │   ├── Concerns/HasLoadMore.php
│   │   ├── Feed.php
│   │   ├── PostShow.php
│   │   ├── ReplyComposer.php
│   │   └── ThreadReplies.php
│   ├── Pages/
│   │   ├── ThreadPage.php
│   │   └── TimelinePage.php
│   └── PanelAppServiceProvider.php
└── tests/Feature/Timeline/
    └── ThreadPageTest.php

app/Providers/Filament/
└── AppPanelProvider.php                     # ThreadPage + TimelinePage registration

database/migrations/
└── 2026_05_09_192029_add_composite_feed_index_to_activity_timeline.php

Test plan

Automated (32 tests passing)

  • CreatePostTest — creates post, rejects empty, handles long content, atomic rollback on failure
  • CreateReplyTest — reply to root, reply-to-reply flattens, atomic rollback
  • DeleteReplyTest — owner can delete, cannot delete others', cannot delete root posts
  • TogglePinPostTest — pin/unpin, only one pin per tenant, only owner
  • PublishModerationEntryTest — publishes ban/kick via observer, skips warn/mute, skips when no moderator
  • TimelineModelTest — relations, threading, morph map
  • TimelineFeedQueryTest — tenant scoping, excludes replies and ignored
  • ThreadPageTest — page renders, reply submission, chronological order, delete own, tenant isolation (3 cross-tenant tests)

Manual E2E checklist for reviewer

Feed

  • Navigate to /app/{tenant} — feed loads with infinite scroll
  • Write a post with text only → appears at top of feed
  • Write a post with text + images (click photo icon to toggle upload) → images render in grid
  • Write a post with 2000+ characters → saves correctly (no DB truncation)
  • Try submitting empty post → validation error
  • Pin your own post → "Fixado" badge appears, previous pin disappears
  • Scroll down → more posts load automatically (check network tab for simplePaginate)
  • Ban a user via admin panel → moderation card appears in the feed with red styling

Thread page

  • Click the comment icon on a post → navigates to /app/{tenant}/timeline/{id}
  • "Voltar para Timeline" link → returns to feed
  • Write a reply with text → appears in the replies list below
  • Write a reply with image → image renders in the reply
  • Reply to a post that already has replies → new reply appears at the bottom (chronological)
  • Delete your own reply (trash icon) → confirm dialog → reply disappears
  • Check you cannot see the trash icon on other users' replies
  • On a post with 4+ replies, check the feed card shows "Ver todas as X respostas →"

Tenant isolation (critical)

  • Copy a post UUID from Tenant A
  • Switch to Tenant B, navigate to /app/{tenant-b}/timeline/{uuid-from-tenant-a} → should 404

Mobile

  • Resize browser to 375px width — no horizontal scroll
  • Composer avatar hides, editor takes full width
  • Post header hides @username, pin shows icon only
  • Thread page replies have smaller avatars, hidden usernames
  • Moderation card inner margins shrink

danielhe4rt added 25 commits May 9, 2026 17:18
- 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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

Warning

Rate limit exceeded

@danielhe4rt has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 14 minutes and 25 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro

Run ID: f79a7998-b945-4450-8d3d-36a940068598

📥 Commits

Reviewing files that changed from the base of the PR and between 0c624a7 and a44a87c.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (57)
  • app-modules/activity/database/factories/PostEntryFactory.php
  • app-modules/activity/database/factories/TimelineFactory.php
  • app-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php
  • app-modules/activity/database/migrations/2026_05_09_155946_create_activity_post_entries_table.php
  • app-modules/activity/docs/timeline.md
  • app-modules/activity/routes/message-routes.php
  • app-modules/activity/src/ActivityServiceProvider.php
  • app-modules/activity/src/Timeline/Actions/CreatePost.php
  • app-modules/activity/src/Timeline/Actions/CreateReply.php
  • app-modules/activity/src/Timeline/Actions/DeleteReply.php
  • app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php
  • app-modules/activity/src/Timeline/Actions/TogglePinPost.php
  • app-modules/activity/src/Timeline/DTOs/CreatePostDTO.php
  • app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php
  • app-modules/activity/src/Timeline/Delegated/PostEntry.php
  • app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php
  • app-modules/activity/src/Timeline/Observers/ModerationEventObserver.php
  • app-modules/activity/src/Timeline/Queries/TimelineFeed.php
  • app-modules/activity/src/Timeline/Timeline.php
  • app-modules/activity/tests/Unit/Timeline/CreatePostTest.php
  • app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php
  • app-modules/activity/tests/Unit/Timeline/DeleteReplyTest.php
  • app-modules/activity/tests/Unit/Timeline/PublishModerationEntryTest.php
  • app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php
  • app-modules/activity/tests/Unit/Timeline/TimelineModelTest.php
  • app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php
  • app-modules/panel-app/.gitignore
  • app-modules/panel-app/composer.json
  • app-modules/panel-app/config/panel-admin.php
  • app-modules/panel-app/resources/views/components/timeline/engagement.blade.php
  • app-modules/panel-app/resources/views/components/timeline/header.blade.php
  • app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php
  • app-modules/panel-app/resources/views/components/timeline/post-entry.blade.php
  • app-modules/panel-app/resources/views/dashboard.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/composer.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/feed.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php
  • app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php
  • app-modules/panel-app/resources/views/pages/thread.blade.php
  • app-modules/panel-app/src/Livewire/Timeline/Composer.php
  • app-modules/panel-app/src/Livewire/Timeline/Concerns/HasLoadMore.php
  • app-modules/panel-app/src/Livewire/Timeline/Feed.php
  • app-modules/panel-app/src/Livewire/Timeline/PostShow.php
  • app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php
  • app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php
  • app-modules/panel-app/src/Pages/ThreadPage.php
  • app-modules/panel-app/src/Pages/TimelinePage.php
  • app-modules/panel-app/src/PanelAppServiceProvider.php
  • app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php
  • app-modules/panel-hub/resources/views/dashboard.blade.php
  • app-modules/panel-hub/src/Pages/Dashboard.php
  • app-modules/panel-hub/src/PanelHubServiceProvider.php
  • app/Enums/FilamentPanel.php
  • app/Providers/Filament/AppPanelProvider.php
  • bootstrap/providers.php
  • composer.json

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.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor Author

@danielhe4rt danielhe4rt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some nitpicks but looks good so far.

return Timeline::query()->create([
'user_id' => $dto->userId,
'tenant_id' => $dto->tenantId,
'postable_type' => 'post_entry',
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'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',
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

return Timeline::query()->create([
'user_id' => $userId,
'tenant_id' => $event->tenant_id,
'postable_type' => 'moderation_event',
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

Comment on lines +35 to +36
$subjectIdentityId = $this->resolveIdentity($case?->author_id, $tenantId);
$moderatorIdentityId = $this->resolveIdentity($action->moderator_id, $tenantId);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Identity should be the user_id (identity_users)

Comment on lines +39 to +41
ModerationEvent::created(function (ModerationEvent $event): void {
resolve(PublishModerationEntry::class)->handle($event);
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use helper "filament()->getTenant()->getKey()" instead

Comment on lines +50 to +53
public function getTitle(): string
{
return 'Thread';
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getTitle() can be removed from the visualization.

@@ -0,0 +1,40 @@
<?php
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved to the activities module.

@@ -0,0 +1,40 @@
<?php
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

@@ -0,0 +1,20 @@
<?php
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use beforeEach()

use He4rt\Identity\User\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, RefreshDatabase in the pest configuration covers the module.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants