Repository: zulip/zulip

Issue:
Add narrowing by is:unread
I think one reasonable way of reading through zulip messages could be to narrow to various topics (e.g. the topic your cursor is currently on), reading the messages of the topic or otherwise marking the whole topic as read, and then going back to a home view where you see only unread messages. This especially makes sense if a large fraction of your messages are from bots (e.g. git commit messages), or high traffic social lists.

Currently we don't have a way to narrow to unread messages; adding `is:unread` functionality to search and a keyboard shortcut (maybe `u`?) would be great.



Discussion:
Hello @zulip/server-search members, this issue was labeled with the **area: search** label, so you may want to check it out!
@zulipbot claim

Resolving patch:
diff --git a/zerver/lib/narrow.py b/zerver/lib/narrow.py
--- a/zerver/lib/narrow.py
+++ b/zerver/lib/narrow.py
@@ -43,6 +43,9 @@ def narrow_filter(event):
             elif operator == "is" and operand in ["starred"]:
                 if operand not in flags:
                     return False
+            elif operator == "is" and operand == "unread":
+                if "read" in flags:
+                    return False
             elif operator == "is" and operand in ["alerted", "mentioned"]:
                 if "mentioned" not in flags:
                     return False
diff --git a/zerver/views/messages.py b/zerver/views/messages.py
--- a/zerver/views/messages.py
+++ b/zerver/views/messages.py
@@ -133,6 +133,9 @@ def by_is(self, query, operand, maybe_negate):
         elif operand == 'starred':
             cond = column("flags").op("&")(UserMessage.flags.starred.mask) != 0
             return query.where(maybe_negate(cond))
+        elif operand == 'unread':
+            cond = column("flags").op("&")(UserMessage.flags.read.mask) == 0
+            return query.where(maybe_negate(cond))
         elif operand == 'mentioned' or operand == 'alerted':
             cond = column("flags").op("&")(UserMessage.flags.mentioned.mask) != 0
             return query.where(maybe_negate(cond))


Test patch:
diff --git a/frontend_tests/node_tests/filter.js b/frontend_tests/node_tests/filter.js
--- a/frontend_tests/node_tests/filter.js
+++ b/frontend_tests/node_tests/filter.js
@@ -2,6 +2,7 @@ add_dependencies({
     people: 'js/people.js',
     stream_data: 'js/stream_data.js',
     util: 'js/util.js',
+    unread: 'js/unread.js',
 });
 
 set_global('page_params', {});
@@ -256,6 +257,10 @@ function make_sub(name, stream_id) {
     assert(predicate({starred: true}));
     assert(!predicate({starred: false}));
 
+    predicate = get_predicate([['is', 'unread']]);
+    assert(predicate({flags: ''}));
+    assert(!predicate({flags: 'read'}));
+
     predicate = get_predicate([['is', 'alerted']]);
     assert(predicate({alerted: true}));
     assert(!predicate({alerted: false}));
@@ -512,6 +517,13 @@ function make_sub(name, stream_id) {
     string = 'stream devel, starred messages';
     assert.equal(Filter.describe(narrow), string);
 
+    narrow = [
+        {operator: 'stream', operand: 'river'},
+        {operator: 'is', operand: 'unread'},
+    ];
+    string = 'stream river, unread messages';
+    assert.equal(Filter.describe(narrow), string);
+
     narrow = [
         {operator: 'stream', operand: 'devel'},
         {operator: 'topic', operand: 'JS'},
diff --git a/frontend_tests/node_tests/narrow.js b/frontend_tests/node_tests/narrow.js
--- a/frontend_tests/node_tests/narrow.js
+++ b/frontend_tests/node_tests/narrow.js
@@ -122,6 +122,11 @@ function set_filter(operators) {
     assert.equal(hide_id,'.empty_feed_notice');
     assert.equal(show_id, '#empty_narrow_all_private_message');
 
+    set_filter([['is', 'unread']]);
+    narrow.show_empty_narrow_message();
+    assert.equal(hide_id,'.empty_feed_notice');
+    assert.equal(show_id, '#no_unread_narrow_message');
+
     set_filter([['pm-with', ['alice@example.com', 'Yo']]]);
     narrow.show_empty_narrow_message();
     assert.equal(hide_id,'.empty_feed_notice');
diff --git a/frontend_tests/node_tests/search_suggestion.js b/frontend_tests/node_tests/search_suggestion.js
--- a/frontend_tests/node_tests/search_suggestion.js
+++ b/frontend_tests/node_tests/search_suggestion.js
@@ -179,11 +179,12 @@ global.stream_data.populate_stream_topics_for_tests({});
     ];
     assert.deepEqual(suggestions.strings, expected);
 
-    query = 'from:ted';
+    query = 'is:unread from:ted';
     suggestions = search.get_suggestions(query);
     expected = [
-        "from:ted",
-        "from:ted@zulip.com",
+        "is:unread from:ted",
+        "is:unread from:ted@zulip.com",
+        "is:unread",
     ];
     assert.deepEqual(suggestions.strings, expected);
 
@@ -388,6 +389,7 @@ init();
         "is:starred",
         "is:mentioned",
         "is:alerted",
+        "is:unread",
         "sender:bob@zulip.com",
         "stream:devel",
         "stream:office",
@@ -403,6 +405,7 @@ init();
     assert.equal(describe('is:starred'), 'Starred messages');
     assert.equal(describe('is:mentioned'), '@-mentions');
     assert.equal(describe('is:alerted'), 'Alerted messages');
+    assert.equal(describe('is:unread'), 'Unread messages');
     assert.equal(describe('sender:bob@zulip.com'), 'Sent by me');
     assert.equal(describe('stream:devel'), 'Stream <strong>devel</strong>');
 }());
diff --git a/zerver/tests/test_narrow.py b/zerver/tests/test_narrow.py
--- a/zerver/tests/test_narrow.py
+++ b/zerver/tests/test_narrow.py
@@ -112,6 +112,16 @@ def test_add_term_using_is_operator_and_non_private_operand(self):
             term = dict(operator='is', operand=operand)
             self._do_add_term_test(term, 'WHERE (flags & :flags_1) != :param_1')
 
+    def test_add_term_using_is_operator_and_unread_operand(self):
+        # type: () -> None
+        term = dict(operator='is', operand='unread')
+        self._do_add_term_test(term, 'WHERE (flags & :flags_1) = :param_1')
+
+    def test_add_term_using_is_operator_and_unread_operand_and_negated(self):  # NEGATED
+        # type: () -> None
+        term = dict(operator='is', operand='unread', negated=True)
+        self._do_add_term_test(term, 'WHERE (flags & :flags_1) != :param_1')
+
     def test_add_term_using_is_operator_non_private_operand_and_negated(self):  # NEGATED
         # type: () -> None
         for operand in ['starred', 'mentioned', 'alerted']:
@@ -335,7 +345,7 @@ def test_build_narrow_filter(self):
         fixtures_path = os.path.join(os.path.dirname(__file__),
                                      '../fixtures/narrow.json')
         scenarios = ujson.loads(open(fixtures_path, 'r').read())
-        self.assertTrue(len(scenarios) == 8)
+        self.assertTrue(len(scenarios) == 9)
         for scenario in scenarios:
             narrow = scenario['narrow']
             accept_events = scenario['accept_events']
@@ -375,6 +385,12 @@ def test_ok_to_include_history(self):
         ]
         self.assertFalse(ok_to_include_history(narrow, realm))
 
+        # History doesn't apply to unread messages.
+        narrow = [
+            dict(operator='is', operand='unread'),
+        ]
+        self.assertFalse(ok_to_include_history(narrow, realm))
+
         # If we are looking for something like starred messages, there is
         # no point in searching historical messages.
         narrow = [