diff --git a/app/javascript/application.ts b/app/javascript/application.ts index 55decb5ce..dd48e3b02 100644 --- a/app/javascript/application.ts +++ b/app/javascript/application.ts @@ -146,6 +146,7 @@ var StoryView = Backbone.NativeView.extend({ itemRead: function() { this.el.classList.toggle("read", this.model.get("is_read")); + this.el.dispatchEvent(new CustomEvent("story:read-changed", { bubbles: true })); }, itemSelected: function() { @@ -169,6 +170,7 @@ var StoryView = Backbone.NativeView.extend({ keepUnreadToggleKeepUnreadValue: String(jsonModel.keep_unread), starToggleIdValue: String(jsonModel.id), starToggleStarredValue: String(jsonModel.is_starred), + unreadCountTarget: "story", }); return this; }, @@ -257,10 +259,6 @@ var StoryList = Backbone.Collection.extend({ this.at(this.cursorPosition).toggleKeepUnread(); }, - unreadCount: function() { - return this.where({is_read: false}).length; - }, - unselectAll: function() { _.invoke(this.selected(), "unselect"); }, @@ -290,7 +288,6 @@ var AppView = Backbone.NativeView.extend({ this.listenTo(this.stories, 'add', this.addOne); this.listenTo(this.stories, 'reset', this.addAll); - this.listenTo(this.stories, 'all', this.render); }, loadData: function(data) { @@ -309,16 +306,6 @@ var AppView = Backbone.NativeView.extend({ this.stories.openCurrentSelection(); }, - render: function() { - var unreadCount = this.stories.unreadCount(); - - if (unreadCount === 0) { - document.title = window.i18n.titleName; - } else { - document.title = "(" + unreadCount + ") " + window.i18n.titleName; - } - }, - toggleCurrent: function() { this.stories.toggleCurrent(); }, diff --git a/app/javascript/controllers/index.ts b/app/javascript/controllers/index.ts index 806d7496a..6a53fd8da 100644 --- a/app/javascript/controllers/index.ts +++ b/app/javascript/controllers/index.ts @@ -20,3 +20,6 @@ application.register("mark-all-as-read", MarkAllAsReadController); import StarToggleController from "./star_toggle_controller"; application.register("star-toggle", StarToggleController); + +import UnreadCountController from "./unread_count_controller"; +application.register("unread-count", UnreadCountController); diff --git a/app/javascript/controllers/unread_count_controller.ts b/app/javascript/controllers/unread_count_controller.ts new file mode 100644 index 000000000..4dae1c806 --- /dev/null +++ b/app/javascript/controllers/unread_count_controller.ts @@ -0,0 +1,39 @@ +import {Controller} from "@hotwired/stimulus"; + +/* + * Mirrors the number of unread stories into the document title, e.g. + * "(3) Stringer". The DOM is the source of truth: any story target without + * the "read" class counts as unread. Target callbacks re-count when stories + * are added or removed; read-state flips are announced by whoever owns them + * (currently the Backbone StoryView) as a bubbling "story:read-changed" + * event, routed here via data-action on the container. + */ +export default class extends Controller { + static override targets = ["story"]; + + static override values = {title: String}; + + declare titleValue: string; + + override connect(): void { + this.update(); + } + + storyTargetConnected(): void { + this.update(); + } + + storyTargetDisconnected(): void { + this.update(); + } + + update(): void { + const unread = this.element.querySelectorAll(".story:not(.read)").length; + + if (unread === 0) { + document.title = this.titleValue; + } else { + document.title = `(${unread}) ${this.titleValue}`; + } + } +} diff --git a/app/views/feeds/show.html.erb b/app/views/feeds/show.html.erb index 8a539df9a..3dcfeed74 100644 --- a/app/views/feeds/show.html.erb +++ b/app/views/feeds/show.html.erb @@ -14,7 +14,7 @@ <%= render "stories/js", { stories: @stories } %> -
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5df14d1bd..ac2ba2709 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -42,9 +42,5 @@ <%= render 'layouts/footer' %>
- diff --git a/app/views/stories/archived.html.erb b/app/views/stories/archived.html.erb index 3f9e41542..0e6750cb2 100644 --- a/app/views/stories/archived.html.erb +++ b/app/views/stories/archived.html.erb @@ -5,7 +5,7 @@ <% unless @read_stories.empty? %> <%= render "stories/js", { stories: @read_stories } %> -
+
diff --git a/app/views/stories/index.html.erb b/app/views/stories/index.html.erb index 619793cd9..1bba2ffed 100644 --- a/app/views/stories/index.html.erb +++ b/app/views/stories/index.html.erb @@ -13,7 +13,7 @@ <% else %> <%= render "stories/js", { stories: @unread_stories } %> -
+
diff --git a/app/views/stories/starred.html.erb b/app/views/stories/starred.html.erb index 5744d188f..7bc1d72e9 100644 --- a/app/views/stories/starred.html.erb +++ b/app/views/stories/starred.html.erb @@ -5,7 +5,7 @@ <% unless @starred_stories.empty? %> <%= render "stories/js", { stories: @starred_stories } %> -
+
diff --git a/app/views/tutorials/index.html.erb b/app/views/tutorials/index.html.erb index 1e8c71bf6..a7a5062b9 100644 --- a/app/views/tutorials/index.html.erb +++ b/app/views/tutorials/index.html.erb @@ -20,7 +20,7 @@ <%= render 'stories/js', { stories: @sample_stories } %> -
+
diff --git a/spec/javascript/controllers/unread_count_controller_spec.ts b/spec/javascript/controllers/unread_count_controller_spec.ts new file mode 100644 index 000000000..fb63fc757 --- /dev/null +++ b/spec/javascript/controllers/unread_count_controller_spec.ts @@ -0,0 +1,72 @@ +import {bootStimulus} from "support/stimulus"; +import UnreadCountController from "controllers/unread_count_controller"; +import {assert} from "helpers/assert"; + +function storyItem(read: boolean): string { + if (read) { + return "
  • "; + } + + return "
  • "; +} + +async function setupController(stories: boolean[]): Promise { + // Static test fixture — safe to use innerHTML + document.body.innerHTML = [ + "
    ", + "
      ", + ...stories.map(storyItem), + "
    ", + "
    ", + ].join("\n"); + + await bootStimulus("unread-count", UnreadCountController); +} + +function storyList(): HTMLElement { + return assert(document.querySelector("#story-list")); +} + +async function targetsObserved(): Promise { + await new Promise((resolve) => { setTimeout(resolve, 0); }); +} + +function markRead(story: Element): void { + story.classList.add("read"); + story.dispatchEvent(new CustomEvent("story:read-changed", {bubbles: true})); +} + +describe("unread-count", () => { + it("shows the unread count in the title on connect", async () => { + await setupController([false, false, true]); + + expect(document.title).toBe("(2) Stringer"); + }); + + it("shows the plain title when all stories are read", async () => { + await setupController([true, true]); + + expect(document.title).toBe("Stringer"); + }); + + it("clears the count when a story announces read-changed", async () => { + await setupController([false, true]); + + markRead(assert(storyList().querySelector(".story:not(.read)"))); + + expect(document.title).toBe("Stringer"); + }); + + it("counts stories added after connect", async () => { + await setupController([]); + expect(document.title).toBe("Stringer"); + + storyList().insertAdjacentHTML("beforeend", storyItem(false)); + await targetsObserved(); + + expect(document.title).toBe("(1) Stringer"); + }); +}); diff --git a/spec/system/stories_index_spec.rb b/spec/system/stories_index_spec.rb index 5b3b79183..580a7ec7e 100644 --- a/spec/system/stories_index_spec.rb +++ b/spec/system/stories_index_spec.rb @@ -207,6 +207,18 @@ def open_story_and_find_unread_icon(story_title) expect(page).to have_title("(1)") end + it "clears the unread count in the title when the last story is read", + :aggregate_failures do + create(:story, title: "My Story") + login_as(default_user) + visit news_path + expect(page).to have_title("(1)") + + find(".story-preview", text: "My Story").click + + expect(page).to have_no_title("(1)") + end + it "allows viewing a story with hot keys" do create(:story, title: "My Story", body: "My Body") login_as(default_user)