From 8c3f2f8a09c91a91386554fae8c5719b44498136 Mon Sep 17 00:00:00 2001 From: IanM Date: Wed, 13 May 2026 10:42:32 +0100 Subject: [PATCH 1/2] [2.x] fix(api): surface event posts in discussion PATCH responses and route title via rename() Fixes two related 2.x regressions where discussion changes silently fail to surface in the UI: 1. Event posts created via `mergePost()` (sticky/tags/lock) were absent from PATCH responses. The 1.x `UpdateDiscussionController` refreshed the `posts` linkage and inlined modified posts on every update; the 2.x `DiscussionResource` rewrite dropped that. 2. Renaming a discussion no longer raised `Renamed`. The 2.x `DiscussionResource` title field delegated to the default attribute setter, bypassing `Discussion::rename()`. The `DiscussionRenamedLogger` listener never ran, so neither the `discussionRenamed` event post nor the notification fired. Together these broke the "rename -> event post appears -> author gets notified" flow that worked end-to-end in 1.x. Core fixes: - `DiscussionResource::posts` field now exposes linkage on update - `DiscussionResource::title` field now routes through `rename()` - `RenameDiscussionModal` chains redraw onto the `update()` promise Sticky/Tags/Lock JS: - Chain redraw onto the `stream.update()` promise (was firing synchronously, before the new post was in-store) Sticky/Tags realtime: - Register broadcasts via the Realtime extender so other users see the event posts as they happen, mirroring the existing `lock` template. Recipient permission is enforced per-user by the Realtime Generator (internal API request runs as the recipient). Closes #4620 --- .../lock/js/src/forum/addLockControl.js | 9 +- extensions/sticky/extend.php | 12 ++ .../sticky/js/src/forum/addStickyControl.js | 9 +- .../sticky/js/src/forum/extendRealtime.ts | 6 + extensions/sticky/js/src/forum/index.js | 7 ++ .../integration/api/StickyDiscussionsTest.php | 50 +++++++++ extensions/tags/extend.php | 12 ++ .../forum/components/TagDiscussionModal.tsx | 9 +- .../tags/js/src/forum/extendRealtime.ts | 6 + extensions/tags/js/src/forum/index.ts | 7 ++ .../components/RenameDiscussionModal.tsx | 8 +- .../src/Api/Resource/DiscussionResource.php | 12 ++ .../api/discussions/UpdateTest.php | 105 ++++++++++++++++++ 13 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 extensions/sticky/js/src/forum/extendRealtime.ts create mode 100644 extensions/tags/js/src/forum/extendRealtime.ts create mode 100644 framework/core/tests/integration/api/discussions/UpdateTest.php diff --git a/extensions/lock/js/src/forum/addLockControl.js b/extensions/lock/js/src/forum/addLockControl.js index ae438e8849..7513598eb6 100644 --- a/extensions/lock/js/src/forum/addLockControl.js +++ b/extensions/lock/js/src/forum/addLockControl.js @@ -19,10 +19,13 @@ export default function addLockControl() { DiscussionControls.lockAction = function () { this.save({ isLocked: !this.isLocked() }).then(() => { if (app.current.matches(DiscussionPage)) { - app.current.get('stream').update(); + app.current + .get('stream') + .update() + .then(() => m.redraw()); + } else { + m.redraw(); } - - m.redraw(); }); }; } diff --git a/extensions/sticky/extend.php b/extensions/sticky/extend.php index fac39e5018..286d9b8091 100644 --- a/extensions/sticky/extend.php +++ b/extensions/sticky/extend.php @@ -12,6 +12,7 @@ use Flarum\Discussion\Discussion; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Extend; +use Flarum\Realtime\Extend\Realtime as RealtimeExtend; use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Sticky\Api\DiscussionResourceFields; use Flarum\Sticky\Event\DiscussionWasStickied; @@ -58,4 +59,15 @@ (new Extend\Settings()) ->default('flarum-sticky.only_sticky_unread_discussions', true) ->serializeToForum('onlyStickyUnreadDiscussions', 'flarum-sticky.only_sticky_unread_discussions', 'boolval'), + + (new Extend\Conditional()) + ->whenExtensionEnabled('flarum-realtime', fn () => [ + (new RealtimeExtend()) + ->broadcastModelEvent( + [DiscussionWasStickied::class, DiscussionWasUnstickied::class], + fn ($event) => $event->discussion, + fn ($event) => $event->user, + 'stickiedEvent' + ), + ]), ]; diff --git a/extensions/sticky/js/src/forum/addStickyControl.js b/extensions/sticky/js/src/forum/addStickyControl.js index bc083bba6f..4899ccfa93 100644 --- a/extensions/sticky/js/src/forum/addStickyControl.js +++ b/extensions/sticky/js/src/forum/addStickyControl.js @@ -19,10 +19,13 @@ export default function addStickyControl() { DiscussionControls.stickyAction = function () { this.save({ isSticky: !this.isSticky() }).then(() => { if (app.current.matches(DiscussionPage)) { - app.current.get('stream').update(); + app.current + .get('stream') + .update() + .then(() => m.redraw()); + } else { + m.redraw(); } - - m.redraw(); }); }; } diff --git a/extensions/sticky/js/src/forum/extendRealtime.ts b/extensions/sticky/js/src/forum/extendRealtime.ts new file mode 100644 index 0000000000..66df0782c5 --- /dev/null +++ b/extensions/sticky/js/src/forum/extendRealtime.ts @@ -0,0 +1,6 @@ +import app from 'flarum/forum/app'; +import RealtimeExtend from 'ext:flarum/realtime/forum/extenders/Realtime'; + +export default function extendRealtime() { + new RealtimeExtend().onDiscussionStreamEvent('stickiedEvent').extend(app, { name: 'flarum-sticky', exports: {} }); +} diff --git a/extensions/sticky/js/src/forum/index.js b/extensions/sticky/js/src/forum/index.js index be902a403b..fae2aba51c 100644 --- a/extensions/sticky/js/src/forum/index.js +++ b/extensions/sticky/js/src/forum/index.js @@ -4,6 +4,7 @@ import addStickyBadge from './addStickyBadge'; import addStickyControl from './addStickyControl'; import addStickyExcerpt from './addStickyExcerpt'; import addStickyClass from './addStickyClass'; +import extendRealtime from './extendRealtime'; export { default as extend } from './extend'; @@ -12,4 +13,10 @@ app.initializers.add('flarum-sticky', () => { addStickyControl(); addStickyExcerpt(); addStickyClass(); + + // Register a discussion stream update event with flarum/realtime when enabled. + // Stickying or unstickying a discussion will trigger a DiscussionPage stream reload for other users. + if ('flarum-realtime' in flarum.extensions) { + extendRealtime(); + } }); diff --git a/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php b/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php index 2c61768d5d..58c1727330 100644 --- a/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php +++ b/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php @@ -98,4 +98,54 @@ public static function stickyDataProvider(): array [3, false, false], ]; } + + #[Test] + public function sticky_response_exposes_new_event_post_in_linkage() + { + // Discussion 2 starts un-stickied with one comment post (id 2). Stickying + // creates a `discussionStickied` event post via mergePost(). The PATCH + // response must surface that new post in the discussion's `posts` + // relationship linkage so the client can refresh its post stream without + // a full reload. See flarum/framework#TBD. + $response = $this->send( + $this->request('PATCH', '/api/discussions/2', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'isSticky' => true, + ], + ], + ], + ]) + ); + + $body = $response->getBody()->getContents(); + $json = json_decode($body, true); + + $this->assertEquals(200, $response->getStatusCode(), $body); + + $linkage = $json['data']['relationships']['posts']['data'] ?? null; + $this->assertIsArray($linkage, 'PATCH response must include the discussion posts relationship linkage'); + + $linkageIds = array_map(fn (array $entry) => $entry['id'], $linkage); + + // The original comment post is still there. + $this->assertContains('2', $linkageIds, 'Linkage should still contain the original comment post id'); + + // And the newly-created event post is too — its id is whatever the next + // available id is after the fixture posts (1–4). The discussion gained + // exactly one post; assert the linkage grew by one. + $this->assertCount(2, $linkageIds, 'Linkage should contain the comment post and the new event post'); + + // Identify the new post id (the one that isn't '2') and assert it + // corresponds to a `discussionStickied` post in the included array. + $newPostId = array_values(array_diff($linkageIds, ['2']))[0] ?? null; + $this->assertNotNull($newPostId); + + $stickiedRow = Post::query()->find($newPostId); + $this->assertNotNull($stickiedRow, 'New post in linkage should exist'); + $this->assertEquals('discussionStickied', $stickiedRow->type); + $this->assertEquals(2, $stickiedRow->discussion_id); + } } diff --git a/extensions/tags/extend.php b/extensions/tags/extend.php index f256721f3d..f69cffbfbb 100644 --- a/extensions/tags/extend.php +++ b/extensions/tags/extend.php @@ -17,6 +17,7 @@ use Flarum\Flags\Api\Resource\FlagResource; use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; +use Flarum\Realtime\Extend\Realtime as RealtimeExtend; use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Tags\Access; use Flarum\Tags\Api; @@ -178,4 +179,15 @@ function (Endpoint\Index|Endpoint\Show|Endpoint\Create $endpoint) { return $endpoint ->addDefaultInclude(['eventPostMentionsTags']); }), + + (new Extend\Conditional()) + ->whenExtensionEnabled('flarum-realtime', fn () => [ + (new RealtimeExtend()) + ->broadcastModelEvent( + [DiscussionWasTagged::class], + fn ($event) => $event->discussion, + fn ($event) => $event->actor, + 'taggedEvent' + ), + ]), ]; diff --git a/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx b/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx index e94a28c45f..2fe0615567 100644 --- a/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx +++ b/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx @@ -49,10 +49,13 @@ export default class TagDiscussionModal extends TagSelectionModal { if (app.current.matches(DiscussionPage)) { - app.current.get('stream').update(); + app.current + .get('stream') + .update() + .then(() => m.redraw()); + } else { + m.redraw(); } - - m.redraw(); }); } diff --git a/extensions/tags/js/src/forum/extendRealtime.ts b/extensions/tags/js/src/forum/extendRealtime.ts new file mode 100644 index 0000000000..12aeb119d2 --- /dev/null +++ b/extensions/tags/js/src/forum/extendRealtime.ts @@ -0,0 +1,6 @@ +import app from 'flarum/forum/app'; +import RealtimeExtend from 'ext:flarum/realtime/forum/extenders/Realtime'; + +export default function extendRealtime() { + new RealtimeExtend().onDiscussionStreamEvent('taggedEvent').extend(app, { name: 'flarum-tags', exports: {} }); +} diff --git a/extensions/tags/js/src/forum/index.ts b/extensions/tags/js/src/forum/index.ts index 72c062bb97..45be9b266b 100644 --- a/extensions/tags/js/src/forum/index.ts +++ b/extensions/tags/js/src/forum/index.ts @@ -7,6 +7,7 @@ import addTagFilter from './addTagFilter'; import addTagLabels from './addTagLabels'; import addTagControl from './addTagControl'; import addTagComposer from './addTagComposer'; +import extendRealtime from './extendRealtime'; export { default as extend } from './extend'; @@ -18,6 +19,12 @@ app.initializers.add('flarum-tags', () => { addTagLabels(); addTagControl(); addTagComposer(); + + // Register a discussion stream update event with flarum/realtime when enabled. + // Tag changes will trigger a DiscussionPage stream reload for other users. + if ('flarum-realtime' in flarum.extensions) { + extendRealtime(); + } }); import './forum'; diff --git a/framework/core/js/src/forum/components/RenameDiscussionModal.tsx b/framework/core/js/src/forum/components/RenameDiscussionModal.tsx index 7857fe41cd..5fb9c7d8d2 100644 --- a/framework/core/js/src/forum/components/RenameDiscussionModal.tsx +++ b/framework/core/js/src/forum/components/RenameDiscussionModal.tsx @@ -70,9 +70,13 @@ export default class RenameDiscussionModal< .save({ title }) .then(() => { if (app.viewingDiscussion(this.discussion)) { - app.current.get('stream').update(); + app.current + .get('stream') + .update() + .then(() => m.redraw()); + } else { + m.redraw(); } - m.redraw(); this.hide(); }) .catch(() => { diff --git a/framework/core/src/Api/Resource/DiscussionResource.php b/framework/core/src/Api/Resource/DiscussionResource.php index ec25967a19..bf51e3a6cd 100644 --- a/framework/core/src/Api/Resource/DiscussionResource.php +++ b/framework/core/src/Api/Resource/DiscussionResource.php @@ -117,6 +117,17 @@ public function fields(): array return $context->creating() || $context->getActor()->can('rename', $discussion); }) + ->set(function (Discussion $discussion, string $value, Context $context) { + // On update, route through `rename()` so that `Renamed` + // fires — the listener creates the `discussionRenamed` + // event post and dispatches notifications. The default + // attribute setter would silently skip both. + if ($context->updating()) { + $discussion->rename($value); + } else { + $discussion->title = $value; + } + }) ->minLength(3) ->maxLength(80), Schema\Str::make('content') @@ -207,6 +218,7 @@ public function fields(): array ->withLinkage(function (Context $context) { return $context->showing(self::class) || $context->creating(self::class) + || $context->updating(self::class) || $context->creating(PostResource::class); }) ->get(function (Discussion $discussion, Context $context) { diff --git a/framework/core/tests/integration/api/discussions/UpdateTest.php b/framework/core/tests/integration/api/discussions/UpdateTest.php new file mode 100644 index 0000000000..abdc8a05a2 --- /dev/null +++ b/framework/core/tests/integration/api/discussions/UpdateTest.php @@ -0,0 +1,105 @@ +prepareDatabase([ + User::class => [ + $this->normalUser(), + ], + Discussion::class => [ + ['id' => 1, 'title' => 'Original title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1, 'last_post_number' => 1], + ], + Post::class => [ + ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

first post

', 'number' => 1], + ], + ]); + } + + #[Test] + public function renaming_creates_discussion_renamed_event_post(): void + { + $response = $this->send( + $this->request('PATCH', '/api/discussions/1', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'title' => 'Renamed title', + ], + ], + ], + ]) + ); + + $body = $response->getBody()->getContents(); + $this->assertEquals(200, $response->getStatusCode(), $body); + + // Server-side: a `discussionRenamed` post row was inserted. + $renamedPost = Post::query() + ->where('discussion_id', 1) + ->where('type', 'discussionRenamed') + ->first(); + + $this->assertNotNull($renamedPost, 'A discussionRenamed event post should be created when the title changes'); + + // Client-visible: the PATCH response carries the refreshed `posts` + // linkage with the new event post's id. Without this, frontend + // `stream.update()` can't surface the renamed event post. + $json = json_decode($body, true); + + $linkage = $json['data']['relationships']['posts']['data'] ?? null; + $this->assertIsArray($linkage, 'PATCH response must include the discussion posts relationship linkage'); + + $linkageIds = array_map(fn (array $entry) => $entry['id'], $linkage); + + $this->assertContains('1', $linkageIds, 'Linkage should still contain the original comment post id'); + $this->assertContains((string) $renamedPost->id, $linkageIds, 'Linkage should contain the new event post id'); + } + + #[Test] + public function setting_title_to_same_value_creates_no_event_post(): void + { + $response = $this->send( + $this->request('PATCH', '/api/discussions/1', [ + 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'title' => 'Original title', + ], + ], + ], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertNull( + Post::query()->where('discussion_id', 1)->where('type', 'discussionRenamed')->first(), + 'No discussionRenamed event post should be created when the title is unchanged' + ); + } +} From 3c6f50882aa9c01fa1a64e19b04ae7d64737ff15 Mon Sep 17 00:00:00 2001 From: IanM Date: Wed, 13 May 2026 10:48:39 +0100 Subject: [PATCH 2/2] [2.x] chore(ts): map ext:flarum/realtime/* path in sticky and tags tsconfig build-typings was failing because the new `extendRealtime.ts` files import from `ext:flarum/realtime/forum/extenders/Realtime`, but neither extension's tsconfig.json mapped that module specifier to realtime's dist-typings. Mirror the mapping that flarum-lock already has. --- extensions/sticky/js/tsconfig.json | 5 +++-- extensions/tags/js/tsconfig.json | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/extensions/sticky/js/tsconfig.json b/extensions/sticky/js/tsconfig.json index 519ed73b2d..7882913a7d 100644 --- a/extensions/sticky/js/tsconfig.json +++ b/extensions/sticky/js/tsconfig.json @@ -4,12 +4,13 @@ // This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder // and also tells your Typescript server to read core's global typings for // access to `dayjs` and `$` in the global namespace. - "include": ["src/**/*", "../../../framework/core/js/dist-typings/@types/**/*", "@types/**/*"], + "include": ["src/**/*", "../../../framework/core/js/dist-typings/@types/**/*", "@types/**/*", "../../realtime/js/dist-typings/@types/**/*"], "compilerOptions": { // This will output typings to `dist-typings` "declarationDir": "./dist-typings", "paths": { - "flarum/*": ["../../../framework/core/js/dist-typings/*"] + "flarum/*": ["../../../framework/core/js/dist-typings/*"], + "ext:flarum/realtime/*": ["../../realtime/js/dist-typings/*"] } } } diff --git a/extensions/tags/js/tsconfig.json b/extensions/tags/js/tsconfig.json index 519ed73b2d..7882913a7d 100644 --- a/extensions/tags/js/tsconfig.json +++ b/extensions/tags/js/tsconfig.json @@ -4,12 +4,13 @@ // This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder // and also tells your Typescript server to read core's global typings for // access to `dayjs` and `$` in the global namespace. - "include": ["src/**/*", "../../../framework/core/js/dist-typings/@types/**/*", "@types/**/*"], + "include": ["src/**/*", "../../../framework/core/js/dist-typings/@types/**/*", "@types/**/*", "../../realtime/js/dist-typings/@types/**/*"], "compilerOptions": { // This will output typings to `dist-typings` "declarationDir": "./dist-typings", "paths": { - "flarum/*": ["../../../framework/core/js/dist-typings/*"] + "flarum/*": ["../../../framework/core/js/dist-typings/*"], + "ext:flarum/realtime/*": ["../../realtime/js/dist-typings/*"] } } }