-
Notifications
You must be signed in to change notification settings - Fork 30
feat(timeline): social feed with posts, replies, and moderation events #223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
9c5104a
feat(timeline): foundation — activity module, panel-app rename, schema
danielhe4rt 0849c6c
feat(timeline): update models with relations, HasReactions, HasMedia,…
danielhe4rt fc9dac8
feat(timeline): add domain actions and feed query
danielhe4rt 05c164d
feat(timeline): add PublishModerationEntry, fix UUID schema
danielhe4rt 0e0ae61
feat(timeline): add Livewire components, Blade views, TimelinePage
danielhe4rt 50d84cb
fix(timeline): skip moderation timeline entry when no user can be res…
danielhe4rt a542e9b
feat(timeline): listen for ActionExecuted to publish moderation entries
danielhe4rt dd3ed50
refactor(timeline): convert ModerationAction → ModerationEvent via li…
danielhe4rt 2ff37d2
refactor(timeline): extract DTOs for CreatePost and CreateReply actions
danielhe4rt 9d28d04
feat(timeline): add inline composer at top of feed
danielhe4rt 13f831f
refactor(timeline): use Filament MarkdownEditor in composer
danielhe4rt 2afed94
refactor(timeline): clean composer — Textarea + FileUpload, no toolbar
danielhe4rt 20b46e1
feat(timeline): Twitter-like image upload toggle in composer
danielhe4rt a66abc5
fix(timeline): use x-show on FileUpload for image toggle
danielhe4rt 08c0b5e
fix(timeline): resolve full disk path for FileUpload images
danielhe4rt 996c2c6
docs(timeline): add timeline usage guide for adding new entry types
danielhe4rt c3f6e1a
feat(timeline): add thread page with reply system
danielhe4rt 70b1446
fix(timeline): responsive mobile layout for all components
danielhe4rt 1244882
chore(timeline): remove legacy modal composer and unused message routes
danielhe4rt aa6d9fe
chore(timeline): remove prototype files
danielhe4rt cdd9739
fix(timeline): tenant isolation, DB transactions, content validation
danielhe4rt fbb1403
fix(timeline): address important issues from code review
danielhe4rt d32c161
fix(timeline): address low-priority issues from code review
danielhe4rt da2159c
feat(timeline): disable topbar on app panel
danielhe4rt bffd5a1
fix(timeline): add PHPDoc @property annotations for PHPStan
danielhe4rt d7d91b7
refactor(timeline): address PR review comments
danielhe4rt fa5f225
refactor(timeline): use getMorphClass() in tests and factory
danielhe4rt 46ef816
fix(timeline): restore resolveIdentity for FK-constrained columns
danielhe4rt 1674123
refactor(timeline): address Clinton's PR review comments
danielhe4rt 167fae4
refactor(timeline): address CodeRabbit review feedback
danielhe4rt 14f0494
refactor(timeline): use blank() helper for content validation
danielhe4rt a713dbe
update: deps
danielhe4rt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
22 changes: 22 additions & 0 deletions
22
app-modules/activity/database/factories/PostEntryFactory.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Activity\Database\Factories; | ||
|
|
||
| use He4rt\Activity\Timeline\Delegated\PostEntry; | ||
| use Illuminate\Database\Eloquent\Factories\Factory; | ||
|
|
||
| /** @extends Factory<PostEntry> */ | ||
| final class PostEntryFactory extends Factory | ||
| { | ||
| protected $model = PostEntry::class; | ||
|
|
||
| /** @return array<string, mixed> */ | ||
| public function definition(): array | ||
| { | ||
| return [ | ||
| 'content' => fake()->sentences(3, true), | ||
| ]; | ||
| } | ||
| } |
41 changes: 41 additions & 0 deletions
41
app-modules/activity/database/factories/TimelineFactory.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace He4rt\Activity\Database\Factories; | ||
|
|
||
| use He4rt\Activity\Timeline\Delegated\PostEntry; | ||
| use He4rt\Activity\Timeline\Timeline; | ||
| use He4rt\Identity\Tenant\Models\Tenant; | ||
| use He4rt\Identity\User\Models\User; | ||
| use Illuminate\Database\Eloquent\Factories\Factory; | ||
|
|
||
| /** @extends Factory<Timeline> */ | ||
| final class TimelineFactory extends Factory | ||
| { | ||
| protected $model = Timeline::class; | ||
|
|
||
| /** @return array<string, mixed> */ | ||
| public function definition(): array | ||
| { | ||
| return [ | ||
| 'user_id' => User::factory(), | ||
| 'tenant_id' => Tenant::factory(), | ||
| 'postable_type' => (new PostEntry)->getMorphClass(), | ||
| 'postable_id' => PostEntry::factory(), | ||
| 'is_ignored' => false, | ||
| 'pinned' => false, | ||
| 'views' => 0, | ||
| ]; | ||
| } | ||
|
|
||
| public function pinned(): static | ||
| { | ||
| return $this->state(['pinned' => true]); | ||
| } | ||
|
|
||
| public function ignored(): static | ||
| { | ||
| return $this->state(['is_ignored' => true]); | ||
| } | ||
| } |
36 changes: 36 additions & 0 deletions
36
...modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use He4rt\Identity\Tenant\Models\Tenant; | ||
| use He4rt\Identity\User\Models\User; | ||
| 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::create('activity_timeline', function (Blueprint $table): void { | ||
| $table->uuid('id')->primary(); | ||
| $table->foreignIdFor(User::class, 'user_id'); | ||
| $table->foreignIdFor(Tenant::class, 'tenant_id'); | ||
| $table->uuidMorphs('postable'); | ||
| $table->foreignUuid('root_id')->nullable(); | ||
| $table->foreignUuid('parent_id')->nullable(); | ||
| $table->boolean('is_ignored')->default(false); | ||
| $table->boolean('pinned')->default(false); | ||
| $table->integer('views')->default(0); | ||
| $table->timestamps(); | ||
|
|
||
| $table->index(['tenant_id', 'created_at'], 'activity_timeline_tenant_feed_index'); | ||
| $table->index(['tenant_id', 'parent_id', 'is_ignored', 'created_at'], 'activity_timeline_feed_composite_index'); | ||
| }); | ||
| } | ||
|
|
||
| public function down(): void | ||
| { | ||
| Schema::dropIfExists('activity_timeline'); | ||
| } | ||
| }; | ||
25 changes: 25 additions & 0 deletions
25
...les/activity/database/migrations/2026_05_09_155946_create_activity_post_entries_table.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| <?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::create('activity_post_entries', function (Blueprint $table): void { | ||
| $table->uuid('id')->primary(); | ||
| $table->text('content'); | ||
| $table->timestamps(); | ||
| $table->softDeletes(); | ||
| }); | ||
| } | ||
|
|
||
| public function down(): void | ||
| { | ||
| Schema::dropIfExists('activity_post_entries'); | ||
| } | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| # Timeline | ||
|
|
||
| The timeline is a social feed on the `/app` panel. It displays two kinds of entries: user-authored posts (`post_entry`) and system-generated moderation events (`moderation_event`). New entry types can be added without modifying existing code. | ||
|
|
||
| ## Architecture | ||
|
|
||
| ``` | ||
| activity_timeline (polymorphic hub) | ||
| ├── postable_type: "post_entry" → PostEntry model (user content) | ||
| ├── postable_type: "moderation_event" → ModerationEvent model (system) | ||
| └── postable_type: "your_new_type" → YourModel (future) | ||
| ``` | ||
|
|
||
| The `Timeline` model uses a polymorphic `postable` relation (`postable_type` + `postable_id`). Each entry type is a separate model registered in the morph map. The feed query, Livewire components, and Blade views delegate rendering by `postable_type`. | ||
|
|
||
| ### Key files | ||
|
|
||
| | Layer | File | Purpose | | ||
| | -------- | -------------------------------------------------------- | --------------------------------------- | | ||
| | Model | `src/Timeline/Timeline.php` | Hub model with `postable()` morphTo | | ||
| | Model | `src/Timeline/Delegated/PostEntry.php` | User post content + media | | ||
| | Model | `src/Moderation/Models/ModerationEvent.php` | Moderation event data | | ||
| | Action | `src/Timeline/Actions/CreatePost.php` | Creates PostEntry + Timeline | | ||
| | Action | `src/Timeline/Actions/CreateReply.php` | Creates reply (1-level flat) | | ||
| | Action | `src/Timeline/Actions/TogglePinPost.php` | Pin/unpin own posts | | ||
| | Action | `src/Timeline/Actions/PublishModerationEntry.php` | ModerationEvent → Timeline | | ||
| | Listener | `src/Timeline/Listeners/PublishModerationToTimeline.php` | ActionExecuted → ModerationEvent | | ||
| | Query | `src/Timeline/Queries/TimelineFeed.php` | Feed builder (tenant, ignored, replies) | | ||
| | DTO | `src/Timeline/DTOs/CreatePostDTO.php` | Input for CreatePost | | ||
| | DTO | `src/Timeline/DTOs/CreateReplyDTO.php` | Input for CreateReply | | ||
| | Provider | `ActivityServiceProvider.php` | MorphMap, observers, listeners | | ||
|
|
||
| ### UI files (panel-app module) | ||
|
|
||
| | File | Purpose | | ||
| | ------------------------------------------------------ | -------------------------------------- | | ||
| | `Livewire/Timeline/Feed.php` | Feed with infinite scroll | | ||
| | `Livewire/Timeline/PostShow.php` | Individual post with pin | | ||
| | `Livewire/Timeline/Composer.php` | Inline post composer | | ||
| | `views/livewire/timeline/feed.blade.php` | Feed layout, routes by `postable_type` | | ||
| | `views/components/timeline/header.blade.php` | Post header (avatar, name, time) | | ||
| | `views/components/timeline/post-entry.blade.php` | Post content + images | | ||
| | `views/components/timeline/moderation-event.blade.php` | Moderation card | | ||
| | `views/components/timeline/engagement.blade.php` | Reply/reaction/view counts, pin | | ||
|
|
||
| ## Adding a new entry type | ||
|
|
||
| ### Step 1: Create the model | ||
|
|
||
| Create your model in the appropriate module. It needs a UUID primary key to match the `activity_timeline.postable_id` column. | ||
|
|
||
| ```php | ||
| // app-modules/your-module/src/Models/BadgeAward.php | ||
|
|
||
| final class BadgeAward extends Model | ||
| { | ||
| use HasUuids; | ||
|
|
||
| protected $fillable = ['user_id', 'badge_name', 'awarded_at']; | ||
| } | ||
| ``` | ||
|
|
||
| ### Step 2: Register in the morph map | ||
|
|
||
| Add the alias in `ActivityServiceProvider::boot()`: | ||
|
|
||
| ```php | ||
| Relation::morphMap([ | ||
| // existing... | ||
| 'badge_award' => BadgeAward::class, | ||
| ]); | ||
| ``` | ||
|
|
||
| ### Step 3: Create the timeline entry | ||
|
|
||
| When the event happens (badge earned, level up, etc.), create a `Timeline` row pointing to your model: | ||
|
|
||
| ```php | ||
| Timeline::query()->create([ | ||
| 'user_id' => $user->id, | ||
| 'tenant_id' => $tenantId, | ||
| 'postable_type' => 'badge_award', | ||
| 'postable_id' => $badgeAward->id, | ||
| ]); | ||
| ``` | ||
|
|
||
| > For cross-module events, use a listener (like `PublishModerationToTimeline` listens for `ActionExecuted`): | ||
|
|
||
| ```php | ||
| // In ActivityServiceProvider::boot() | ||
| Event::listen(BadgeAwarded::class, [PublishBadgeToTimeline::class, 'handle']); | ||
| ``` | ||
|
|
||
| ### Step 4: Create the Blade component | ||
|
|
||
| Create a Blade component at `panel-app/resources/views/components/timeline/badge-award.blade.php`: | ||
|
|
||
| ```blade | ||
| @props (['timeline']) | ||
|
|
||
| @php | ||
| $award = $timeline->postable; | ||
| @endphp | ||
|
|
||
| <div class="rounded-xl border ..."> | ||
| {{-- your card design --}} | ||
| </div> | ||
| ``` | ||
|
|
||
| System-generated entries (no user interaction) should be pure Blade like this. If the card needs interactivity (like, reply), wrap it in a Livewire component instead. | ||
|
|
||
| ### Step 5: Register in the feed view | ||
|
|
||
| Add your type to `feed.blade.php`: | ||
|
|
||
| ```blade | ||
| @if ($item->postable_type === 'moderation_event') | ||
| <x-panel-app::timeline.moderation-event :timeline="$item" /> | ||
| @elseif ($item->postable_type === 'badge_award') | ||
| <x-panel-app::timeline.badge-award :timeline="$item" /> | ||
| @else | ||
| <livewire:timeline-post-show ... /> | ||
| @endif | ||
| ``` | ||
|
|
||
| That's it. Five steps: model, morph map, creation logic, Blade component, feed routing. | ||
|
|
||
| ## Important constraints | ||
|
|
||
| ### Threading | ||
|
|
||
| Threading is 1-level flat. All replies point to the root post: | ||
|
|
||
| ``` | ||
| root_id = original_post.id | ||
| parent_id = original_post.id (always equals root_id) | ||
| ``` | ||
|
|
||
| Replying to a reply flattens — both `root_id` and `parent_id` point to the original root, never to the intermediate reply. The `CreateReply` action enforces this. | ||
|
|
||
| ### Tenant scoping | ||
|
|
||
| Every timeline entry has a `tenant_id`. The `TimelineFeed` query filters by tenant. When creating entries, always pass the correct tenant — entries without a tenant won't appear in any feed. | ||
|
|
||
| ### Pinning | ||
|
|
||
| Users can pin one post per tenant. Pinning a new post unpins the previous one automatically. Only the post owner can pin. `TogglePinPost` enforces both rules. | ||
|
|
||
| ### Moderation event flow | ||
|
|
||
| Two sources create `ModerationEvent` records: | ||
|
|
||
| 1. **Discord ETL** — imports bans/kicks from Discord directly as `ModerationEvent` | ||
| 2. **Web admin panel** — moderator judges a `ModerationCase`, executes action → `ActionExecuted` event → `PublishModerationToTimeline` listener creates a `ModerationEvent` | ||
|
|
||
| Both paths converge on the same model. The `ModerationEvent::created` observer then creates the timeline entry via `PublishModerationEntry`. Only `Ban` and `Kick` types are published — `Warn`, `Mute`, etc. are filtered out. | ||
|
|
||
| ### Blade vs Livewire rendering | ||
|
|
||
| - **Interactive entries** (user posts with pin, replies, reactions) → Livewire component (`PostShow`) | ||
| - **System entries** (moderation events, future badges/milestones) → pure Blade component (no Livewire overhead) | ||
|
|
||
| Each Livewire component on the page costs ~2-4KB in the HTML snapshot. System events that have no interactivity should always be pure Blade. | ||
|
|
||
| ### Feed query | ||
|
|
||
| `TimelineFeed` excludes replies (`whereNull('parent_id')`) and ignored entries (`is_ignored = false`). It orders by `created_at DESC` and uses `simplePaginate(15)` to avoid COUNT queries. The feed Livewire component uses `HasLoadMore` trait for infinite scroll, capped at 100 items. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,3 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use App\Http\Middleware\BotAuthentication; | ||
| use App\Http\Middleware\VerifyIfHasTenantProviderMiddleware; | ||
| use He4rt\Activity\Message\Http\Controllers\MessagesController; | ||
| use Illuminate\Support\Facades\Route; | ||
|
|
||
| Route::prefix('api')->middleware(['api', BotAuthentication::class, VerifyIfHasTenantProviderMiddleware::class])->group(function (): void { | ||
| Route::prefix('messages')->group(function (): void { | ||
| Route::post('/{provider}', [MessagesController::class, 'postMessage'])->name('messages.create'); | ||
| }); | ||
|
|
||
| Route::prefix('voices')->group(function (): void { | ||
| Route::post('/{provider}', [MessagesController::class, 'postVoiceMessage']) | ||
| ->name('voices.create'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.