Repository: zulip/zulip

Issue:
Update first message ID when first message is deleted
When a message is deleted, we should update the stored ID of the first message in the stream. Because we currently do not, deleting the first message may result in an extraneous "more topics" link in the left sidebar, with no additional topics shown when you click it.

Note: The symptom may be hard to replicate; we should focus on fixing the technical issue, as described in @timabbott 's comment below.


Discussion:
Hello @zulip/server-message-view members, this issue was labeled with the "area: message-editing" label, so you may want to check it out!

<!-- areaLabelAddition -->

@zulipbot claim
This may be tricky; I'm not sure. `do_delete_messages` probably just needs to check the stream containing the deleted messages, if any, and just do a quick query to check if its `first_message_id` is stale, and if so, update it.

It is reasonably likely that doing that change will cause a test in `test_events` for deleting messages to fail -- because we're not sending an event to notify existing clients of the new value. Whether it not that happens, we ideally would send an event `op="update"` event for the `first_message_id` property to live-update clients.

The manual test case to verify this is working is that "more topics" should no long incorrectly appearing if you create a new stream, send a message, and then delete the first message (to "stream events") that had been sent to that stream.
I couldn't reproduce the issue
here's how I have tried:
1. Create new stream.
2. Send a message to "stream events".
3. Delete the first message that i had sent to "stream events" or delete the message fist messsage sent by bot in "stream events".
[Screencast from 10-02-24 10:41:32 PM IST.webm](https://github.com/zulip/zulip/assets/90370535/638e9c57-a477-42f3-918c-832cbc8c32f0)

While working on #28941 , I got to see "more topics" appears up after following steps.
1. Mute any of the topic of particular stream.
2. Click on any other topic, "more topics" appears.

I just want to confirm if it's a desired behaviour ?

[Screencast from 16-02-24 12:16:29 AM IST.webm](https://github.com/zulip/zulip/assets/90370535/0d19550f-4bfc-4d5f-bc88-9ded40bf6c7e)
@zulipbot abandon
Yes, "more topics" is expected when you have muted topics (i.e., there are more topics to show).
@zulipbot claim
@alya I want to work on this
Welcome to Zulip, @rohit-sharma-1802! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!

@zulipbot abandon
@zulipbot claim
Welcome to Zulip, @UdaySagar-Git! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!

Hey @alya , I'm currently having trouble reproducing this issue. Could you please provide detailed steps on how to replicate it? 
@UdaySagar-Git  please take a look at the [Zulip contributor guide](https://zulip.readthedocs.io/en/latest/overview/contributing.html), and keep in mind the following guideline:

> Before you claim an issue, you should be confident that you will be able to tackle it effectively.

I will go ahead and unassign this issue, and you should feel free to re-claim it once you have figured out how to approach it (or pick a different one if you prefer).
I have updated the issue description to hopefully be more clear.
@zulipbot claim

Welcome to Zulip, @Epik-Whale463! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!

@Epik-Whale463 You have been unassigned from this issue because you have not made any updates for over 14 days. Please feel free to reclaim the issue if you decide to pick up again. Thanks!


hey @alya  , if the issue is not resolved, i can fix this....
@zulipbot claim

Welcome to Zulip, @Thanush19! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!

@Thanush19 You have been unassigned from this issue because you have not made any updates for over 14 days. Please feel free to reclaim the issue if you decide to pick up again. Thanks!

@zulipbot claim


Resolving patch:
diff --git a/version.py b/version.py
--- a/version.py
+++ b/version.py
@@ -33,7 +33,7 @@
 # Changes should be accompanied by documentation explaining what the
 # new level means in api_docs/changelog.md, as well as "**Changes**"
 # entries in the endpoint's documentation in `zulip.yaml`.
-API_FEATURE_LEVEL = 255
+API_FEATURE_LEVEL = 256
 
 # Bump the minor PROVISION_VERSION to indicate that folks should provision
 # only when going from an old version of the code to a newer version. Bump
diff --git a/zerver/actions/message_delete.py b/zerver/actions/message_delete.py
--- a/zerver/actions/message_delete.py
+++ b/zerver/actions/message_delete.py
@@ -3,7 +3,7 @@
 from zerver.lib import retention
 from zerver.lib.retention import move_messages_to_archive
 from zerver.lib.stream_subscription import get_active_subscriptions_for_stream_id
-from zerver.models import Message, Realm, UserMessage, UserProfile
+from zerver.models import Message, Realm, Stream, UserMessage, UserProfile
 from zerver.tornado.django_api import send_event_on_commit
 
 
@@ -15,6 +15,34 @@ class DeleteMessagesEvent(TypedDict, total=False):
     stream_id: int
 
 
+def check_update_first_message_id(
+    realm: Realm, stream: Stream, message_ids: List[int], users_to_notify: Iterable[int]
+) -> None:
+    # This will not update the `first_message_id` of streams where the
+    # first message was deleted prior to the implementation of this function.
+    assert stream.recipient_id is not None
+    if stream.first_message_id not in message_ids:
+        return
+    current_first_message_id = (
+        Message.objects.filter(realm_id=realm.id, recipient_id=stream.recipient_id)
+        .values_list("id", flat=True)
+        .order_by("id")
+        .first()
+    )
+
+    stream.first_message_id = current_first_message_id
+    stream.save(update_fields=["first_message_id"])
+
+    stream_event = dict(
+        type="stream",
+        op="update",
+        property="first_message_id",
+        value=stream.first_message_id,
+        stream_id=stream.id,
+    )
+    send_event_on_commit(realm, stream_event, users_to_notify)
+
+
 def do_delete_messages(realm: Realm, messages: Iterable[Message]) -> None:
     # messages in delete_message event belong to the same topic
     # or is a single direct message, as any other behaviour is not possible with
@@ -52,6 +80,9 @@ def do_delete_messages(realm: Realm, messages: Iterable[Message]) -> None:
         archiving_chunk_size = retention.STREAM_MESSAGE_BATCH_SIZE
 
     move_messages_to_archive(message_ids, realm=realm, chunk_size=archiving_chunk_size)
+    if message_type == "stream":
+        stream = Stream.objects.get(id=sample_message.recipient.type_id)
+        check_update_first_message_id(realm, stream, message_ids, users_to_notify)
 
     event["message_type"] = message_type
     send_event_on_commit(realm, event, users_to_notify)


Test patch:
diff --git a/zerver/tests/test_message_delete.py b/zerver/tests/test_message_delete.py
--- a/zerver/tests/test_message_delete.py
+++ b/zerver/tests/test_message_delete.py
@@ -11,6 +11,7 @@
 from zerver.lib.test_classes import ZulipTestCase
 from zerver.models import Message, Realm, UserProfile
 from zerver.models.realms import get_realm
+from zerver.models.streams import get_stream
 
 if TYPE_CHECKING:
     from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
@@ -321,3 +322,28 @@ def test_delete_message_in_unsubscribed_private_stream(self) -> None:
         result = self.client_delete(f"/json/messages/{msg_id}")
         self.assert_json_success(result)
         self.assertFalse(Message.objects.filter(id=msg_id).exists())
+
+    def test_update_first_message_id_on_stream_message_deletion(self) -> None:
+        realm = get_realm("zulip")
+        stream_name = "test"
+        cordelia = self.example_user("cordelia")
+        self.make_stream(stream_name)
+        self.subscribe(cordelia, stream_name)
+        message_ids = [self.send_stream_message(cordelia, stream_name) for _ in range(5)]
+        first_message_id = message_ids[0]
+
+        message = Message.objects.get(id=message_ids[3])
+        do_delete_messages(realm, [message])
+        stream = get_stream(stream_name, realm)
+        self.assertEqual(stream.first_message_id, first_message_id)
+
+        first_message = Message.objects.get(id=first_message_id)
+        do_delete_messages(realm, [first_message])
+        stream = get_stream(stream_name, realm)
+        self.assertEqual(stream.first_message_id, message_ids[1])
+
+        all_messages = Message.objects.filter(id__in=message_ids)
+        with self.assert_database_query_count(23):
+            do_delete_messages(realm, all_messages)
+        stream = get_stream(stream_name, realm)
+        self.assertEqual(stream.first_message_id, None)
diff --git a/zerver/tests/test_retention.py b/zerver/tests/test_retention.py
--- a/zerver/tests/test_retention.py
+++ b/zerver/tests/test_retention.py
@@ -1138,7 +1138,7 @@ def test_do_delete_messages_multiple(self) -> None:
         message_ids = [self.send_stream_message(cordelia, "Verona", str(i)) for i in range(10)]
         messages = Message.objects.filter(id__in=message_ids)
 
-        with self.assert_database_query_count(20):
+        with self.assert_database_query_count(21):
             do_delete_messages(realm, messages)
         self.assertFalse(Message.objects.filter(id__in=message_ids).exists())