Skip to content

Commit e3d84bc

Browse files
authored
[2.x] fix(messages): repair dialogs left pointing at a deleted first or last message (#4851)
* Fix dialogs left pointing at a deleted first or last message Deleting the first or last message of a conversation could leave the dialog with a null first_message_id or last_message_id, after which the conversation would not render at all: MessageStream read straight through the missing relationship and threw "Cannot read properties of null (reading 'id')". Since the reply box goes down with the rest of the stream, the one thing that repairs the pointer, posting another message, was out of reach. Both columns are declared with nullOnDelete(), so the database sets the column to null as part of the delete. The repair in DialogMessage's deleted handler then compared the dialog's first_message_id against the id of the message that had just been removed, but by then that column can already be null, so the comparison missed and no repair ran. It only worked when the dialog happened to be in memory before the delete, which is why this depends on an unrelated setting: DialogMessagePolicy only loads the dialog when allow_delete_own_messages is "until reply", and PHP short-circuits past that check for the other values. The handler now treats a pointer that is missing as one that needs recomputing, and uses two conditions rather than an if/elseif chain so both ends can be repaired in the same pass. Dialogs already left without pointers are repaired the next time a message in them is deleted. Two related fixes: - DialogMessageResource set first_message_id to the newly created message whenever it was missing. For a new dialog that is the same thing, but for a dialog whose pointer had gone missing it recorded the newest message as the first one, which left the stream showing a "load previous messages" button that had nothing to load. It now starts from the oldest surviving message. - The frontend read the first and last message relationships in three places without allowing for their absence (MessageStream.content, timeGap and the mark-as-read button in DialogListItem), so any dialog in this state took the whole page down. They now go through Dialog.messageRelationshipId(), which returns undefined when the relationship is not there. With no first message there is nothing older to load, with no last message nothing newer, and marking as read is skipped rather than sending a request built from null. Added integration coverage for deleting the first, last, middle and only message of a dialog, for repairing a dialog that is already in this state, and for the dialog remaining readable afterwards. The first four fail against the current code and pass with this change; the middle-message and only-message cases pass either way and are there to pin the behaviour that should not change. * Cast the dialog's message pointers instead of comparing loosely The repair in the deleted handler compared the dialog's pointer against the deleted message's id with ==, which worked but hid the reason a strict comparison could miss: Dialog casts neither id column, so the value comes back however the driver hands it over while $message->id is an int. Casting both columns to integer, as core's Discussion model already does for first_post_id and last_post_id, makes the comparison mean what it says, so the handler is back to ===.
1 parent ae31d6a commit e3d84bc

7 files changed

Lines changed: 214 additions & 28 deletions

File tree

extensions/messages/js/src/common/models/Dialog.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Model from 'flarum/common/Model';
1+
import Model, { type ModelIdentifier } from 'flarum/common/Model';
22
import User from 'flarum/common/models/User';
33
import DialogMessage from './DialogMessage';
44
import app from 'flarum/common/app';
@@ -27,6 +27,17 @@ export default class Dialog extends Model {
2727
return Model.hasOne<DialogMessage>('lastMessage').call(this);
2828
}
2929

30+
/**
31+
* The id of this dialog's first or last message, when it has one.
32+
*
33+
* A dialog can be left without either, for instance when the message one of
34+
* them pointed at was deleted, so every caller has to cope with the
35+
* relationship being absent rather than reading straight through it.
36+
*/
37+
messageRelationshipId(name: 'firstMessage' | 'lastMessage'): string | undefined {
38+
return (this.data.relationships?.[name]?.data as ModelIdentifier | undefined)?.id;
39+
}
40+
3041
unreadCount() {
3142
return Model.attribute<number>('unreadCount').call(this);
3243
}

extensions/messages/js/src/forum/components/DialogListItem.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,19 @@ export default class DialogListItem<CustomAttrs extends IDialogListItemAttrs = I
6767
e.preventDefault();
6868
e.stopPropagation();
6969

70-
this.attrs.dialog
71-
.save({ lastReadMessageId: (this.attrs.dialog.data.relationships?.lastMessage.data as ModelIdentifier).id })
72-
.finally(() => {
73-
if (this.attrs.dialog.unreadCount() === 0) {
74-
app.session.user!.pushAttributes({
75-
messageCount: (app.session.user!.attribute<number>('messageCount') ?? 1) - 1,
76-
});
77-
}
70+
const lastMessageId = this.attrs.dialog.messageRelationshipId('lastMessage');
7871

79-
m.redraw();
80-
});
72+
if (!lastMessageId) return;
73+
74+
this.attrs.dialog.save({ lastReadMessageId: lastMessageId }).finally(() => {
75+
if (this.attrs.dialog.unreadCount() === 0) {
76+
app.session.user!.pushAttributes({
77+
messageCount: (app.session.user!.attribute<number>('messageCount') ?? 1) - 1,
78+
});
79+
}
80+
81+
m.redraw();
82+
});
8183
}}
8284
/>,
8385
100

extensions/messages/js/src/forum/components/MessageStream.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,13 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
8484
const ReplyPlaceholder = this.replyPlaceholderComponent();
8585
const LoadingPost = this.loadingPostComponent();
8686

87-
if (messages[0].id() !== (this.attrs.dialog.data.relationships?.firstMessage.data as ModelIdentifier).id) {
87+
// A dialog without a first or last message has nothing to load in that
88+
// direction, and reading straight through the missing relationship would
89+
// take the whole conversation down with it.
90+
const firstMessageId = this.attrs.dialog.messageRelationshipId('firstMessage');
91+
const lastMessageId = this.attrs.dialog.messageRelationshipId('lastMessage');
92+
93+
if (firstMessageId && messages[0]?.id() !== firstMessageId) {
8894
items.push(
8995
<div className="MessageStream-item" key="loadNext">
9096
<Button
@@ -108,7 +114,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
108114

109115
messages.forEach((message, index) => items.push(this.messageItem(message, index)));
110116

111-
if (messages[messages.length - 1].id() !== (this.attrs.dialog.data.relationships?.lastMessage.data as ModelIdentifier).id) {
117+
if (lastMessageId && messages[messages.length - 1]?.id() !== lastMessageId) {
112118
if (LoadingPost) {
113119
items.push(
114120
<div className="MessageStream-item" key="loading-prev">
@@ -167,7 +173,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
167173
}
168174

169175
timeGap(message: DialogMessage): Mithril.Children {
170-
if (message.id() === (this.attrs.dialog.data.relationships?.firstMessage.data as ModelIdentifier).id) {
176+
if (message.id() === this.attrs.dialog.messageRelationshipId('firstMessage')) {
171177
this.lastTime = message.createdAt()!;
172178

173179
return (

extensions/messages/src/Api/Resource/DialogMessageResource.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,10 @@ public function created(object $model, OriginalContext $context): ?object
271271
}
272272

273273
if (! $model->dialog->first_message_id) {
274-
$model->dialog->setFirstMessage($model);
274+
// Normally this is the first message of a new dialog. It can also be
275+
// a dialog whose first message pointer went missing, though, and
276+
// that one starts at its oldest surviving message, not at this one.
277+
$model->dialog->setFirstMessage($model->dialog->messages()->oldest('id')->first() ?? $model);
275278
}
276279

277280
$model->dialog->isDirty() && $model->dialog->save();

extensions/messages/src/Dialog.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class Dialog extends AbstractModel
4343
protected $table = 'dialogs';
4444

4545
protected $casts = [
46+
'first_message_id' => 'integer',
47+
'last_message_id' => 'integer',
4648
'last_message_at' => 'datetime'
4749
];
4850

extensions/messages/src/DialogMessage.php

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,21 +72,40 @@ public static function boot()
7272
});
7373

7474
static::deleted(function (self $message) {
75-
if ($message->dialog) {
76-
if ($message->dialog->messages()->count() === 0) {
77-
$message->dialog->delete();
78-
} elseif ($message->dialog->first_message_id === $message->id) {
79-
$message->dialog->setFirstMessage(
80-
$message->dialog->messages()->oldest('id')->first()
81-
);
82-
$message->dialog->save();
83-
} elseif ($message->dialog->last_message_id === $message->id) {
84-
$message->dialog->setLastMessage(
85-
$message->dialog->messages()->latest('id')->first()
86-
);
87-
$message->dialog->save();
75+
$dialog = $message->dialog;
76+
77+
if (! $dialog) {
78+
return;
79+
}
80+
81+
if ($dialog->messages()->count() === 0) {
82+
$dialog->delete();
83+
84+
return;
85+
}
86+
87+
// Both columns are declared with nullOnDelete(), so by the time this
88+
// runs the database may already have set whichever one pointed at
89+
// this message to null. Unless the dialog happened to be loaded
90+
// before the delete, reading it back gives null rather than the id
91+
// being looked for, so a pointer that is missing is treated as one
92+
// that needs recomputing. That also repairs dialogs left pointing at
93+
// nothing by earlier deletions.
94+
if ($dialog->first_message_id === null || $dialog->first_message_id === $message->id) {
95+
if ($first = $dialog->messages()->oldest('id')->first()) {
96+
$dialog->setFirstMessage($first);
8897
}
8998
}
99+
100+
if ($dialog->last_message_id === null || $dialog->last_message_id === $message->id) {
101+
if ($last = $dialog->messages()->latest('id')->first()) {
102+
$dialog->setLastMessage($last);
103+
}
104+
}
105+
106+
if ($dialog->isDirty()) {
107+
$dialog->save();
108+
}
90109
});
91110
}
92111

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Messages\Tests\integration\api\dialog_messages;
11+
12+
use Carbon\Carbon;
13+
use Flarum\Messages\Dialog;
14+
use Flarum\Messages\DialogMessage;
15+
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
16+
use Flarum\Testing\integration\TestCase;
17+
use Flarum\User\User;
18+
19+
class DeleteTest extends TestCase
20+
{
21+
use RetrievesAuthorizedUsers;
22+
23+
protected function setUp(): void
24+
{
25+
parent::setUp();
26+
27+
$this->extension('flarum-messages');
28+
29+
$this->prepareDatabase([
30+
User::class => [
31+
['id' => 3, 'username' => 'alice'],
32+
['id' => 4, 'username' => 'bob'],
33+
],
34+
Dialog::class => [
35+
['id' => 102, 'type' => 'direct', 'first_message_id' => 102, 'last_message_id' => 104],
36+
],
37+
DialogMessage::class => [
38+
['id' => 102, 'dialog_id' => 102, 'user_id' => 3, 'content' => 'First', 'number' => 1],
39+
['id' => 103, 'dialog_id' => 102, 'user_id' => 3, 'content' => 'Second', 'number' => 2],
40+
['id' => 104, 'dialog_id' => 102, 'user_id' => 3, 'content' => 'Third', 'number' => 3],
41+
],
42+
'dialog_user' => [
43+
['dialog_id' => 102, 'user_id' => 3, 'joined_at' => Carbon::now()],
44+
['dialog_id' => 102, 'user_id' => 4, 'joined_at' => Carbon::now()],
45+
],
46+
]);
47+
48+
// Anything other than "until reply" reaches the delete without the
49+
// policy having loaded the dialog, which is the case the first/last
50+
// pointers used to be lost in.
51+
$this->setting('flarum-messages.allow_delete_own_messages', '-1');
52+
}
53+
54+
protected function delete(int $messageId, int $actor = 3): int
55+
{
56+
return $this->send(
57+
$this->request('DELETE', '/api/dialog-messages/'.$messageId, ['authenticatedAs' => $actor])
58+
)->getStatusCode();
59+
}
60+
61+
/**
62+
* Read through the query builder rather than the model, so this works
63+
* before the application has been booted by a request.
64+
*/
65+
protected function dialogRow(): ?object
66+
{
67+
return $this->database()->table('dialogs')->where('id', 102)->first();
68+
}
69+
70+
public function test_deleting_the_first_message_moves_the_pointer_to_the_next_one(): void
71+
{
72+
$this->assertEquals(204, $this->delete(102));
73+
74+
$dialog = $this->dialogRow();
75+
76+
// The foreign key nulls this column on delete, so it has to be
77+
// recomputed rather than left pointing at nothing.
78+
$this->assertEquals(103, $dialog->first_message_id);
79+
$this->assertEquals(104, $dialog->last_message_id);
80+
}
81+
82+
public function test_deleting_the_last_message_moves_the_pointer_to_the_previous_one(): void
83+
{
84+
$this->assertEquals(204, $this->delete(104));
85+
86+
$dialog = $this->dialogRow();
87+
88+
$this->assertEquals(102, $dialog->first_message_id);
89+
$this->assertEquals(103, $dialog->last_message_id);
90+
}
91+
92+
public function test_deleting_a_message_in_the_middle_leaves_both_pointers_alone(): void
93+
{
94+
$this->assertEquals(204, $this->delete(103));
95+
96+
$dialog = $this->dialogRow();
97+
98+
$this->assertEquals(102, $dialog->first_message_id);
99+
$this->assertEquals(104, $dialog->last_message_id);
100+
}
101+
102+
public function test_deleting_the_only_message_deletes_the_dialog(): void
103+
{
104+
$this->assertEquals(204, $this->delete(102));
105+
$this->assertEquals(204, $this->delete(103));
106+
$this->assertEquals(204, $this->delete(104));
107+
108+
$this->assertNull($this->dialogRow());
109+
}
110+
111+
public function test_a_dialog_left_without_pointers_is_repaired_on_the_next_deletion(): void
112+
{
113+
// The state an affected forum is already in.
114+
$this->database()->table('dialogs')->where('id', 102)->update([
115+
'first_message_id' => null,
116+
'last_message_id' => null,
117+
]);
118+
119+
$this->assertEquals(204, $this->delete(103));
120+
121+
$dialog = $this->dialogRow();
122+
123+
$this->assertEquals(102, $dialog->first_message_id);
124+
$this->assertEquals(104, $dialog->last_message_id);
125+
}
126+
127+
public function test_the_dialog_is_still_readable_after_its_first_message_is_deleted(): void
128+
{
129+
$this->delete(102);
130+
131+
$response = $this->send(
132+
$this->request('GET', '/api/dialogs/102', ['authenticatedAs' => 3])
133+
);
134+
135+
$this->assertEquals(200, $response->getStatusCode());
136+
137+
$data = json_decode($response->getBody()->getContents(), true);
138+
139+
// A null relationship here is what the message stream chokes on.
140+
$this->assertNotNull($data['data']['relationships']['firstMessage']['data'] ?? null);
141+
$this->assertEquals('103', $data['data']['relationships']['firstMessage']['data']['id']);
142+
}
143+
}

0 commit comments

Comments
 (0)