Repository: zulip/zulip-terminal
Pull request #1177 (2022-03-23 07:06:29)

Indicate web-public streams differently
<!-- Please see https://github.com/zulip/zulip-terminal#contributor-guidelines ! -->

**What does this PR do?**  <!-- Overall description goes here -->

This is intended to fix most of #712 (#1135)

See #**zulip-terminal > Web public marker #T1135/#T712**

<!-- If fixing a filed bug or new feature, add 'Fixes #<issue>' or 'Partial fix for #<issue>' -->

<!-- Add a link to a discussion on chat.zulip.org, if relevant -->

**Tested?** <!-- Fine to leave some of these unchecked if this is a draft/work-in-progress -->
- [X] Manually
- [x] Existing tests (adapted, if necessary)
- [x] New tests added (for any new behavior)
- [x] Passed linting & tests (each commit)
<!-- Code must pass CI (GitHub Actions) before merging - look for the green tick! -->

<!-- See https://github.com/zulip/zulip-terminal#commit-style -->
**Commit flow** <!-- if more than one commit; add/delete/fill-in as appropriate -->

- Add model accessor for stream type, given stream id
- Use new accessor outside of model (in UI)
- Add fixtures for a web public stream & adjust existing tests
- Add symbol and extend existing code minimally to indicate symbol in most cases
- Add symbol rendering in stream list
- Refactor duplicated if/elif blocks to use new common dict in ui_mappings (descriptions)
- Refactor if/elif blocks to use same dict for icons too

**Notes & Questions** <!-- if any; add/delete/fill-in as appropriate -->

This needs some tests fixed and I'm planning an additional refactor, but pushing now to get feedback.

**update** Tests are now fixed and extended.

**Visual changes** <!-- if any; add/delete/fill-in with screenshot/diagram as appropriate -->

Currently, web-public streams are shown as `A`. This is not ideal, but I've not found a reliable symbol/character yet (`W` in bold was very full/blocky). If you fetch the PR you can play with variations by changing the character in `symbols.py`.

**update** After debate on the stream, we currently have `⊚` as an approximation to a narrow (1-character-wide) circular symbol. We can explore this further when we explore symbol themes #938 (maybe #1114)


Patch:
diff --git a/.mailmap b/.mailmap
new file mode 100644
index 0000000..5f2ab4e
--- /dev/null
+++ b/.mailmap
@@ -0,0 +1,15 @@
+# This file teaches `git log` and friends the canonical names
+# and email addresses to use for our contributors.
+#
+# See https://github.com/zulip/zulip/blob/main/.mailmap for Zulip's
+# documentation on this file.
+
+Aman Agrawal <f2016561@pilani.bits-pilani.ac.in>
+Hari Prashant Bhimaraju <harry.prashantb@gmail.com>
+Ezio-Sarthak <grgsrthk.dev20@gmail.com> <garg.sarthak13@gmail.com>
+Shivam Gera <gera.shivam@gmail.com>
+Sumanth V Rao <sumanthvrao@gmail.com> <sumanthrao@pesu.pes.edu>
+Sumanth V Rao <sumanthvrao@gmail.com>
+Tim Abbott <tabbott@zulipchat.com> <tabbott@zulip.com>
+Zeeshan Equbal <equbalzeeshan@gmail.com> <54993043+zee-bit@users.noreply.github.com>
+Zeeshan Equbal <equbalzeeshan@gmail.com>
diff --git a/zulipterminal/config/symbols.py b/zulipterminal/config/symbols.py
index 772bc88..d10fa89 100644
--- a/zulipterminal/config/symbols.py
+++ b/zulipterminal/config/symbols.py
@@ -1,6 +1,7 @@
 INVALID_MARKER = "✗"
 STREAM_MARKER_PRIVATE = "P"
 STREAM_MARKER_PUBLIC = "#"
+STREAM_MARKER_WEB_PUBLIC = "⊚"
 STREAM_TOPIC_SEPARATOR = "▶"
 # Used as a separator between messages and 'EDITED'
 MESSAGE_CONTENT_MARKER = "▒"  # Options are '█', '▓', '▒', '░'
diff --git a/zulipterminal/config/ui_mappings.py b/zulipterminal/config/ui_mappings.py
index 6d05256..781597a 100644
--- a/zulipterminal/config/ui_mappings.py
+++ b/zulipterminal/config/ui_mappings.py
@@ -1,11 +1,16 @@
 from typing import Dict, Optional
 
+from typing_extensions import Literal
+
 from zulipterminal.api_types import EditPropagateMode
 from zulipterminal.config.symbols import (
     STATUS_ACTIVE,
     STATUS_IDLE,
     STATUS_INACTIVE,
     STATUS_OFFLINE,
+    STREAM_MARKER_PRIVATE,
+    STREAM_MARKER_PUBLIC,
+    STREAM_MARKER_WEB_PUBLIC,
 )
 
 
@@ -25,6 +30,15 @@ STATE_ICON = {
 }
 
 
+StreamAccessType = Literal["public", "private", "web-public"]
+
+STREAM_ACCESS_TYPE = {
+    "public": {"description": "Public", "icon": STREAM_MARKER_PUBLIC},
+    "private": {"description": "Private", "icon": STREAM_MARKER_PRIVATE},
+    "web-public": {"description": "Web public", "icon": STREAM_MARKER_WEB_PUBLIC},
+}
+
+
 BOT_TYPE_BY_ID = {
     1: "Generic Bot",
     2: "Incoming Webhook Bot",
diff --git a/zulipterminal/helper.py b/zulipterminal/helper.py
index be91e31..65c61aa 100644
--- a/zulipterminal/helper.py
+++ b/zulipterminal/helper.py
@@ -31,13 +31,14 @@ from zulipterminal.config.regexes import (
     REGEX_COLOR_6_DIGIT,
     REGEX_QUOTED_FENCE_LENGTH,
 )
+from zulipterminal.config.ui_mappings import StreamAccessType
 
 
 class StreamData(TypedDict):
     name: str
     id: int
     color: str
-    invite_only: bool
+    stream_access_type: StreamAccessType
     description: str
 
 
diff --git a/zulipterminal/model.py b/zulipterminal/model.py
index d04cdf1..b0ed291 100644
--- a/zulipterminal/model.py
+++ b/zulipterminal/model.py
@@ -36,7 +36,7 @@ from zulipterminal.api_types import (
     Subscription,
 )
 from zulipterminal.config.keys import primary_key_for_command
-from zulipterminal.config.ui_mappings import ROLE_BY_ID
+from zulipterminal.config.ui_mappings import ROLE_BY_ID, StreamAccessType
 from zulipterminal.helper import (
     Message,
     NamedEmojiData,
@@ -1018,7 +1018,7 @@ class Model:
                     "name": stream["name"],
                     "id": stream["stream_id"],
                     "color": stream["color"],
-                    "invite_only": stream["invite_only"],
+                    "stream_access_type": self.stream_access_type(stream["stream_id"]),
                     "description": stream["description"],
                 }
             )
@@ -1093,6 +1093,16 @@ class Model:
                 return stream_id
         raise RuntimeError("Invalid stream name.")
 
+    def stream_access_type(self, stream_id: int) -> StreamAccessType:
+        if stream_id not in self.stream_dict:
+            raise RuntimeError("Invalid stream id.")
+        stream = self.stream_dict[stream_id]
+        if stream.get("is_web_public", False):
+            return "web-public"
+        if stream["invite_only"]:
+            return "private"
+        return "public"
+
     def is_pinned_stream(self, stream_id: int) -> bool:
         return stream_id in [stream["id"] for stream in self.pinned_streams]
 
diff --git a/zulipterminal/themes/colors_gruvbox.py b/zulipterminal/themes/colors_gruvbox.py
index 984f9a4..0bf7266 100644
--- a/zulipterminal/themes/colors_gruvbox.py
+++ b/zulipterminal/themes/colors_gruvbox.py
@@ -22,25 +22,41 @@ from zulipterminal.config.color import color_properties
 class GruvBoxColor(Enum):
     # color          =  16code          256code   24code
     DEFAULT          = 'default         default   default'
+
+    # Only or primarily dark mode - grayscales
+    # - generally background
     DARK0_HARD       = 'black           h234      #1d2021'
-    GRAY_244         = 'dark_gray       h244      #928374'
+    # - generally foreground
     LIGHT2           = 'white           h250      #d5c4a1'
+    # - grays
+    GRAY_244         = 'dark_gray       h244      #928374'
     LIGHT4           = 'light_gray      h248      #bdae93'
+
+    # Dark mode only - colors
     BRIGHT_BLUE      = 'light_blue      h109      #83a598'
     BRIGHT_GREEN     = 'light_green     h142      #b8bb26'
     BRIGHT_RED       = 'light_red       h167      #fb4934'
-    NEUTRAL_PURPLE   = 'light_magenta   h132      #b16286'
+    BRIGHT_YELLOW    = 'brown           h214      #fabd2f'
+
+    # May be relevant to both modes
     NEUTRAL_BLUE     = 'dark_cyan       h66       #458588'
+    NEUTRAL_PURPLE   = 'light_magenta   h132      #b16286'
     NEUTRAL_YELLOW   = 'brown           h172      #d79921'
+
+    # Light mode only - colors
     FADED_BLUE       = 'dark_blue       h24       #076678'
-    FADED_YELLOW     = 'brown           h136      #b57614'
+    FADED_GREEN      = 'dark_green      h100      #79740e'
     FADED_RED        = 'dark_red        h88       #9d0006'
+    FADED_YELLOW     = 'brown           h136      #b57614'
+
+    # Only or primarily light version - grayscales
+    # - generally background
     LIGHT0_HARD      = 'white           h230      #f9f5d7'
-    GRAY_245         = 'dark_gray       h245      #928374'
+    # - generally foreground
     DARK2            = 'black           h239      #504945'
+    # - grays
+    GRAY_245         = 'dark_gray       h245      #928374'
     DARK4            = 'black           h243      #7c6f64'
-    BRIGHT_YELLOW    = 'brown           h214      #fabd2f'
-    FADED_GREEN      = 'dark_green      h100      #79740e'
 
 
 # fmt: on
diff --git a/zulipterminal/themes/gruvbox_dark.py b/zulipterminal/themes/gruvbox_dark.py
index 10dd1e2..6044e8f 100644
--- a/zulipterminal/themes/gruvbox_dark.py
+++ b/zulipterminal/themes/gruvbox_dark.py
@@ -19,10 +19,10 @@ from zulipterminal.themes.colors_gruvbox import DefaultBoldColor as Color
 STYLES = {
     # style_name       :  foreground                   background
     None               : (Color.LIGHT2,                Color.DARK0_HARD),
-    'selected'         : (Color.LIGHT2,                Color.FADED_BLUE),
-    'msg_selected'     : (Color.LIGHT2,                Color.FADED_BLUE),
-    'header'           : (Color.NEUTRAL_BLUE,          Color.FADED_BLUE),
-    'general_narrow'   : (Color.LIGHT2,                Color.FADED_BLUE),
+    'selected'         : (Color.DARK0_HARD,            Color.NEUTRAL_BLUE),
+    'msg_selected'     : (Color.DARK0_HARD,            Color.NEUTRAL_BLUE),
+    'header'           : (Color.NEUTRAL_BLUE,          Color.BRIGHT_BLUE),
+    'general_narrow'   : (Color.DARK0_HARD,            Color.BRIGHT_BLUE),
     'general_bar'      : (Color.LIGHT2,                Color.DARK0_HARD),
     'name'             : (Color.NEUTRAL_YELLOW__BOLD,  Color.DARK0_HARD),
     'unread'           : (Color.NEUTRAL_PURPLE,        Color.DARK0_HARD),
@@ -42,7 +42,7 @@ STYLES = {
     'msg_mention'      : (Color.BRIGHT_RED__BOLD,      Color.DARK0_HARD),
     'msg_link'         : (Color.BRIGHT_BLUE,           Color.DARK0_HARD),
     'msg_link_index'   : (Color.BRIGHT_BLUE__BOLD,     Color.DARK0_HARD),
-    'msg_quote'        : (Color.FADED_YELLOW,          Color.DARK0_HARD),
+    'msg_quote'        : (Color.NEUTRAL_YELLOW,        Color.DARK0_HARD),
     'msg_code'         : (Color.DARK0_HARD,            Color.LIGHT2),
     'msg_bold'         : (Color.LIGHT2__BOLD,          Color.DARK0_HARD),
     'msg_time'         : (Color.DARK0_HARD,            Color.LIGHT2),
@@ -65,14 +65,14 @@ STYLES = {
     'popup_important'  : (Color.BRIGHT_RED__BOLD,      Color.DARK0_HARD),
     'widget_disabled'  : (Color.GRAY_244,              Color.DARK0_HARD),
     'area:help'        : (Color.DARK0_HARD,            Color.BRIGHT_GREEN),
-    'area:msg'         : (Color.DARK0_HARD,            Color.BRIGHT_RED),
+    'area:msg'         : (Color.DARK0_HARD,            Color.NEUTRAL_PURPLE),
     'area:stream'      : (Color.DARK0_HARD,            Color.BRIGHT_BLUE),
-    'area:error'       : (Color.LIGHT2,                Color.FADED_RED),
-    'area:user'        : (Color.LIGHT2,                Color.FADED_BLUE),
+    'area:error'       : (Color.DARK0_HARD,            Color.BRIGHT_RED),
+    'area:user'        : (Color.DARK0_HARD,            Color.BRIGHT_YELLOW),
     'search_error'     : (Color.BRIGHT_RED,            Color.DARK0_HARD),
     'task:success'     : (Color.DARK0_HARD,            Color.BRIGHT_GREEN),
-    'task:error'       : (Color.LIGHT2,                Color.FADED_RED),
-    'task:warning'     : (Color.DARK0_HARD,            Color.BRIGHT_RED),
+    'task:error'       : (Color.DARK0_HARD,            Color.BRIGHT_RED),
+    'task:warning'     : (Color.DARK0_HARD,            Color.NEUTRAL_PURPLE),
 }
 
 META = {
diff --git a/zulipterminal/themes/gruvbox_light.py b/zulipterminal/themes/gruvbox_light.py
index d2c8d0f..7660f2d 100644
--- a/zulipterminal/themes/gruvbox_light.py
+++ b/zulipterminal/themes/gruvbox_light.py
@@ -18,10 +18,10 @@ from zulipterminal.themes.colors_gruvbox import DefaultBoldColor as Color
 STYLES = {
     # style_name       :  foreground                   background
     None               : (Color.DARK2,                  Color.LIGHT0_HARD),
-    'selected'         : (Color.DARK2,                  Color.BRIGHT_BLUE),
-    'msg_selected'     : (Color.DARK2,                  Color.BRIGHT_BLUE),
-    'header'           : (Color.NEUTRAL_BLUE,           Color.BRIGHT_BLUE),
-    'general_narrow'   : (Color.DARK2,                  Color.BRIGHT_BLUE),
+    'selected'         : (Color.LIGHT0_HARD,            Color.NEUTRAL_BLUE),
+    'msg_selected'     : (Color.LIGHT0_HARD,            Color.NEUTRAL_BLUE),
+    'header'           : (Color.NEUTRAL_BLUE,           Color.FADED_BLUE),
+    'general_narrow'   : (Color.LIGHT0_HARD,            Color.FADED_BLUE),
     'general_bar'      : (Color.DARK2,                  Color.LIGHT0_HARD),
     'name'             : (Color.NEUTRAL_YELLOW,         Color.LIGHT0_HARD),
     'unread'           : (Color.NEUTRAL_PURPLE,         Color.LIGHT0_HARD),
@@ -41,7 +41,7 @@ STYLES = {
     'msg_mention'      : (Color.FADED_RED__BOLD,        Color.LIGHT0_HARD),
     'msg_link'         : (Color.FADED_BLUE,             Color.LIGHT0_HARD),
     'msg_link_index'   : (Color.FADED_BLUE__BOLD,       Color.LIGHT0_HARD),
-    'msg_quote'        : (Color.BRIGHT_YELLOW,          Color.LIGHT0_HARD),
+    'msg_quote'        : (Color.NEUTRAL_YELLOW,         Color.LIGHT0_HARD),
     'msg_code'         : (Color.LIGHT0_HARD,            Color.DARK2),
     'msg_bold'         : (Color.DARK2__BOLD,            Color.LIGHT0_HARD),
     'msg_time'         : (Color.LIGHT0_HARD,            Color.DARK2),
@@ -64,14 +64,14 @@ STYLES = {
     'popup_important'  : (Color.FADED_RED__BOLD,        Color.LIGHT0_HARD),
     'widget_disabled'  : (Color.GRAY_245,               Color.LIGHT0_HARD),
     'area:help'        : (Color.LIGHT0_HARD,            Color.FADED_GREEN),
-    'area:msg'         : (Color.LIGHT0_HARD,            Color.FADED_RED),
+    'area:msg'         : (Color.LIGHT0_HARD,            Color.NEUTRAL_PURPLE),
     'area:stream'      : (Color.LIGHT0_HARD,            Color.FADED_BLUE),
-    'area:error'       : (Color.DARK2,                  Color.BRIGHT_RED),
-    'area:user'        : (Color.DARK2,                  Color.BRIGHT_BLUE),
+    'area:error'       : (Color.LIGHT0_HARD,            Color.FADED_RED),
+    'area:user'        : (Color.LIGHT0_HARD,            Color.FADED_YELLOW),
     'search_error'     : (Color.FADED_RED,              Color.LIGHT0_HARD),
     'task:success'     : (Color.LIGHT0_HARD,            Color.FADED_GREEN),
-    'task:error'       : (Color.DARK2,                  Color.BRIGHT_RED),
-    'task:warning'     : (Color.LIGHT0_HARD,            Color.FADED_RED),
+    'task:error'       : (Color.LIGHT0_HARD,            Color.FADED_RED),
+    'task:warning'     : (Color.LIGHT0_HARD,            Color.NEUTRAL_PURPLE),
 }
 
 META = {
diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py
index b293edc..14a12e0 100644
--- a/zulipterminal/ui_tools/boxes.py
+++ b/zulipterminal/ui_tools/boxes.py
@@ -33,12 +33,10 @@ from zulipterminal.config.symbols import (
     MESSAGE_CONTENT_MARKER,
     MESSAGE_HEADER_DIVIDER,
     QUOTED_TEXT_MARKER,
-    STREAM_MARKER_PRIVATE,
-    STREAM_MARKER_PUBLIC,
     STREAM_TOPIC_SEPARATOR,
     TIME_MENTION_MARKER,
 )
-from zulipterminal.config.ui_mappings import STATE_ICON
+from zulipterminal.config.ui_mappings import STATE_ICON, STREAM_ACCESS_TYPE
 from zulipterminal.helper import (
     Message,
     asynch,
@@ -432,11 +430,10 @@ class WriteBox(urwid.Pile):
         stream_marker = INVALID_MARKER
         color = "general_bar"
         if self.model.is_valid_stream(new_text):
-            stream = self.model.stream_dict[self.model.stream_id_from_name(new_text)]
-            if stream["invite_only"]:
-                stream_marker = STREAM_MARKER_PRIVATE
-            else:
-                stream_marker = STREAM_MARKER_PUBLIC
+            stream_id = self.model.stream_id_from_name(new_text)
+            stream_access_type = self.model.stream_access_type(stream_id)
+            stream_marker = STREAM_ACCESS_TYPE[stream_access_type]["icon"]
+            stream = self.model.stream_dict[stream_id]
             color = stream["color"]
         self.header_write_box[self.FOCUS_HEADER_PREFIX_STREAM].set_text(
             (color, stream_marker)
diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py
index d215780..9611fd5 100644
--- a/zulipterminal/ui_tools/buttons.py
+++ b/zulipterminal/ui_tools/buttons.py
@@ -9,13 +9,8 @@ from typing_extensions import TypedDict
 from zulipterminal.api_types import RESOLVED_TOPIC_PREFIX, EditPropagateMode
 from zulipterminal.config.keys import is_command_key, primary_key_for_command
 from zulipterminal.config.regexes import REGEX_INTERNAL_LINK_STREAM_ID
-from zulipterminal.config.symbols import (
-    CHECK_MARK,
-    MUTE_MARKER,
-    STREAM_MARKER_PRIVATE,
-    STREAM_MARKER_PUBLIC,
-)
-from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS
+from zulipterminal.config.symbols import CHECK_MARK, MUTE_MARKER
+from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS, STREAM_ACCESS_TYPE
 from zulipterminal.helper import Message, StreamData, hash_util_decode
 from zulipterminal.urwid_types import urwid_Size
 
@@ -171,7 +166,7 @@ class StreamButton(TopButton):
         self.stream_name = properties["name"]
         self.stream_id = properties["id"]
         self.color = properties["color"]
-        is_private = properties["invite_only"]
+        stream_access_type = properties["stream_access_type"]
         self.description = properties["description"]
 
         self.model = controller.model
@@ -190,7 +185,8 @@ class StreamButton(TopButton):
             ("s" + self.color, "", "", "standout", inverse_text, self.color)
         )
 
-        stream_marker = STREAM_MARKER_PRIVATE if is_private else STREAM_MARKER_PUBLIC
+        stream_marker = STREAM_ACCESS_TYPE[stream_access_type]["icon"]
+
         narrow_function = partial(
             controller.narrow_to_stream,
             stream_name=self.stream_name,
diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py
index 1fce22a..380db3b 100644
--- a/zulipterminal/ui_tools/views.py
+++ b/zulipterminal/ui_tools/views.py
@@ -20,14 +20,13 @@ from zulipterminal.config.symbols import (
     CHECK_MARK,
     COLUMN_TITLE_BAR_LINE,
     PINNED_STREAMS_DIVIDER,
-    STREAM_MARKER_PRIVATE,
-    STREAM_MARKER_PUBLIC,
 )
 from zulipterminal.config.ui_mappings import (
     BOT_TYPE_BY_ID,
     EDIT_MODE_CAPTIONS,
     ROLE_BY_ID,
     STATE_ICON,
+    STREAM_ACCESS_TYPE,
     STREAM_POST_POLICY,
 )
 from zulipterminal.config.ui_sizes import LEFT_WIDTH
@@ -1339,7 +1338,11 @@ class StreamInfoView(PopUpView):
                 stream_policy = STREAM_POST_POLICY[1]
 
         total_members = len(stream["subscribers"])
-        type_of_stream = "Private" if stream["invite_only"] else "Public"
+
+        stream_access_type = controller.model.stream_access_type(stream_id)
+        type_of_stream = STREAM_ACCESS_TYPE[stream_access_type]["description"]
+        stream_marker = STREAM_ACCESS_TYPE[stream_access_type]["icon"]
+
         availability_of_history = (
             "Public to Users"
             if stream["history_public_to_subscribers"]
@@ -1354,9 +1357,6 @@ class StreamInfoView(PopUpView):
             "Stream created recently" if weekly_traffic is None else str(weekly_traffic)
         )
 
-        stream_marker = (
-            STREAM_MARKER_PRIVATE if stream["invite_only"] else STREAM_MARKER_PUBLIC
-        )
         title = f"{stream_marker} {stream['name']}"
         rendered_desc = stream["rendered_description"]
         self.markup_desc, message_links, _ = MessageBox.transform_content(


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index e16b101..7155a0a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -54,7 +54,7 @@ def stream_button(mocker: MockerFixture) -> StreamButton:
             "name": "PTEST",
             "id": 205,
             "color": "#bfd56f",
-            "invite_only": False,
+            "stream_access_type": "public",
             "description": "Test stream description",
         },
         controller=mocker.patch("zulipterminal.core.Controller"),
@@ -253,11 +253,39 @@ def secret_stream() -> Dict[str, Any]:
     }
 
 
+# Like public stream but with is_web_public=True
+@pytest.fixture
+def web_public_stream() -> Dict[str, Any]:
+    return {
+        "description": "Some web public stream",
+        "stream_id": 999,
+        "pin_to_top": False,
+        "invite_only": False,
+        "name": "Web public stream",
+        "date_created": 1472047124,
+        "email_address": "web_public@example.com",
+        "rendered_description": "Some web public stream",
+        "color": "#ddd",  # Color in '#xxx' format
+        "in_home_view": True,
+        "audible_notifications": False,
+        "is_old_stream": True,
+        "desktop_notifications": False,
+        "stream_weekly_traffic": 0,
+        "message_retention_days": -1,
+        "push_notifications": False,
+        "subscribers": [1001, 11],
+        "history_public_to_subscribers": False,
+        "is_web_public": True,
+    }
+
+
 @pytest.fixture
 def streams_fixture(
-    general_stream: Dict[str, Any], secret_stream: Dict[str, Any]
+    general_stream: Dict[str, Any],
+    secret_stream: Dict[str, Any],
+    web_public_stream: Dict[str, Any],
 ) -> List[Dict[str, Any]]:
-    streams = [general_stream, secret_stream]
+    streams = [general_stream, secret_stream, web_public_stream]
     for i in range(1, 3):
         streams.append(
             {
@@ -1130,6 +1158,13 @@ def streams() -> List[Dict[str, Any]]:
             "invite_only": False,
             "description": "A description of stream 2",
         },
+        {
+            "name": "Web public stream",
+            "id": 999,
+            "color": "#ddd",
+            "invite_only": False,
+            "description": "Some web public stream",
+        },
     ]
 
 
diff --git a/tests/model/test_model.py b/tests/model/test_model.py
index a500f3a..ebf7651 100644
--- a/tests/model/test_model.py
+++ b/tests/model/test_model.py
@@ -1152,6 +1152,13 @@ class TestModel:
         model.client.update_subscription_settings.assert_called_once_with(request)
         self.display_error_if_present.assert_called_once_with(response, self.controller)
 
+    def test_stream_access_type(
+        self, model, general_stream, secret_stream, web_public_stream
+    ):
+        assert model.stream_access_type(general_stream["stream_id"]) == "public"
+        assert model.stream_access_type(secret_stream["stream_id"]) == "private"
+        assert model.stream_access_type(web_public_stream["stream_id"]) == "web-public"
+
     @pytest.mark.parametrize(
         "flags_before, expected_operator",
         [
diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py
index d0075db..24a5cf8 100644
--- a/tests/ui/test_ui_tools.py
+++ b/tests/ui/test_ui_tools.py
@@ -1200,7 +1200,7 @@ class TestLeftColumnView:
             controller=left_col_view.controller, count=3
         )
 
-    @pytest.mark.parametrize("pinned", powerset([1, 2, 99, 1000]))
+    @pytest.mark.parametrize("pinned", powerset([1, 2, 99, 999, 1000]))
     def test_streams_view(self, mocker, streams, pinned):
         self.view.unpinned_streams = [s for s in streams if s["id"] not in pinned]
         self.view.pinned_streams = [s for s in streams if s["id"] in pinned]
@@ -1222,7 +1222,7 @@ class TestLeftColumnView:
                     properties=stream,
                     controller=self.view.controller,
                     view=self.view,
-                    count=1,
+                    count=mocker.ANY,
                 )
                 for stream in (self.view.pinned_streams + self.view.unpinned_streams)
             ]
diff --git a/tests/ui_tools/test_boxes.py b/tests/ui_tools/test_boxes.py
index c6a2c79..2f4783d 100644
--- a/tests/ui_tools/test_boxes.py
+++ b/tests/ui_tools/test_boxes.py
@@ -12,7 +12,9 @@ from zulipterminal.config.symbols import (
     INVALID_MARKER,
     STREAM_MARKER_PRIVATE,
     STREAM_MARKER_PUBLIC,
+    STREAM_MARKER_WEB_PUBLIC,
 )
+from zulipterminal.config.ui_mappings import StreamAccessType
 from zulipterminal.helper import Index
 from zulipterminal.ui_tools.boxes import PanelSearchBox, WriteBox, _MessageEditState
 from zulipterminal.urwid_types import urwid_Size
@@ -508,18 +510,36 @@ class TestWriteBox:
             (
                 "#Stream",
                 0,
-                ["Stream 1", "Stream 2", "Secret stream", "Some general stream"],
+                [
+                    "Stream 1",
+                    "Stream 2",
+                    "Secret stream",
+                    "Some general stream",
+                    "Web public stream",
+                ],
             ),
             ("#*Stream", None, []),  # NOTE: Optional single star fails
             (
                 "#**Stream",
                 0,
-                ["Stream 1", "Stream 2", "Secret stream", "Some general stream"],
+                [
+                    "Stream 1",
+                    "Stream 2",
+                    "Secret stream",
+                    "Some general stream",
+                    "Web public stream",
+                ],
             ),  # Optional 2-stars
             (
                 "#Stream",
                 None,
-                ["Stream 1", "Stream 2", "Secret stream", "Some general stream"],
+                [
+                    "Stream 1",
+                    "Stream 2",
+                    "Secret stream",
+                    "Some general stream",
+                    "Web public stream",
+                ],
             ),
             ("#NoMatch", None, []),
             # emojis
@@ -734,11 +754,13 @@ class TestWriteBox:
             ("#S", 1, "#**Some general stream**", []),  # 1st-word startswith.
             ("#S", 2, "#**Stream 1**", []),  # 1st-word startswith match.
             ("#S", 3, "#**Stream 2**", []),  # 1st-word startswith match.
-            ("#S", -1, "#**Stream 2**", []),
-            ("#S", -2, "#**Stream 1**", []),
-            ("#S", -3, "#**Some general stream**", []),
-            ("#S", -4, "#**Secret stream**", []),
-            ("#S", -5, None, []),
+            ("#S", 4, "#**Web public stream**", []),  # 1st-word startswith match.
+            ("#S", -1, "#**Web public stream**", []),
+            ("#S", -2, "#**Stream 2**", []),
+            ("#S", -3, "#**Stream 1**", []),
+            ("#S", -4, "#**Some general stream**", []),
+            ("#S", -5, "#**Secret stream**", []),
+            ("#S", -6, None, []),
             ("#So", 0, "#**Some general stream**", []),
             ("#So", 1, None, []),
             ("#Se", 0, "#**Secret stream**", []),
@@ -1012,25 +1034,49 @@ class TestWriteBox:
                 "",
                 1,
                 [],
-                ["Secret stream", "Some general stream", "Stream 1", "Stream 2"],
+                [
+                    "Secret stream",
+                    "Some general stream",
+                    "Stream 1",
+                    "Stream 2",
+                    "Web public stream",
+                ],
             ),
             (
                 "",
                 1,
                 ["Stream 2"],
-                ["Stream 2", "Secret stream", "Some general stream", "Stream 1"],
+                [
+                    "Stream 2",
+                    "Secret stream",
+                    "Some general stream",
+                    "Stream 1",
+                    "Web public stream",
+                ],
             ),
             (
                 "St",
                 1,
                 [],
-                ["Stream 1", "Stream 2", "Secret stream", "Some general stream"],
+                [
+                    "Stream 1",
+                    "Stream 2",
+                    "Secret stream",
+                    "Some general stream",
+                    "Web public stream",
+                ],
             ),
             (
                 "St",
                 1,
                 ["Stream 2"],
-                ["Stream 2", "Stream 1", "Secret stream", "Some general stream"],
+                [
+                    "Stream 2",
+                    "Stream 1",
+                    "Secret stream",
+                    "Some general stream",
+                    "Web public stream",
+                ],
             ),
         ],
         ids=[
@@ -1062,13 +1108,22 @@ class TestWriteBox:
         )
 
     @pytest.mark.parametrize(
-        "stream_name, stream_id, is_valid_stream, expected_marker, expected_color",
+        "stream_name, stream_id, is_valid_stream, stream_access_type, expected_marker, expected_color",
         [
-            ("Secret stream", 99, True, STREAM_MARKER_PRIVATE, "#ccc"),
-            ("Stream 1", 1, True, STREAM_MARKER_PUBLIC, "#b0a5fd"),
-            ("Stream 0", 0, False, INVALID_MARKER, "general_bar"),
+            (
+                "Web public stream",
+                999,
+                True,
+                "web-public",
+                STREAM_MARKER_WEB_PUBLIC,
+                "#ddd",
+            ),
+            ("Secret stream", 99, True, "private", STREAM_MARKER_PRIVATE, "#ccc"),
+            ("Stream 1", 1, True, "public", STREAM_MARKER_PUBLIC, "#b0a5fd"),
+            ("Stream 0", 0, False, None, INVALID_MARKER, "general_bar"),
         ],
         ids=[
+            "web_public_stream",
             "private_stream",
             "public_stream",
             "invalid_stream_name",
@@ -1080,6 +1135,7 @@ class TestWriteBox:
         stream_id: int,
         stream_name: str,
         is_valid_stream: bool,
+        stream_access_type: StreamAccessType,
         expected_marker: str,
         stream_dict: Dict[int, Any],
         expected_color: str,
@@ -1088,6 +1144,7 @@ class TestWriteBox:
         write_box.model.stream_dict = stream_dict
         write_box.model.is_valid_stream.return_value = is_valid_stream
         write_box.model.stream_id_from_name.return_value = stream_id
+        write_box.model.stream_access_type.return_value = stream_access_type
 
         write_box.stream_box_view(stream_id)
 
diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py
index 02c7687..c21c056 100644
--- a/tests/ui_tools/test_popups.py
+++ b/tests/ui_tools/test_popups.py
@@ -1155,7 +1155,10 @@ class TestStreamInfoView:
         self.stream_id = general_stream["stream_id"]
         self.controller.model.server_feature_level = 40
         self.controller.model.cached_retention_text = {self.stream_id: "10"}
+
         self.controller.model.stream_dict = {self.stream_id: general_stream}
+        self.controller.model.stream_access_type.return_value = "public"
+
         self.stream_info_view = StreamInfoView(self.controller, self.stream_id)
 
     def test_keypress_any_key(