Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 2 additions & 15 deletions app/javascript/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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;
},
Expand Down Expand Up @@ -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");
},
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
},
Expand Down
3 changes: 3 additions & 0 deletions app/javascript/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
39 changes: 39 additions & 0 deletions app/javascript/controllers/unread_count_controller.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
}
}
2 changes: 1 addition & 1 deletion app/views/feeds/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

<%= render "stories/js", { stories: @stories } %>

<div id="stories">
<div id="stories" data-controller="unread-count" data-action="story:read-changed->unread-count#update" data-unread-count-title-value="<%= t('layout.title') %>">
<ul id="story-list">
</ul>
</div>
4 changes: 0 additions & 4 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,5 @@
<%= render 'layouts/footer' %>
</div>

<script>
window.i18n = {};
window.i18n.titleName = "<%= t('layout.title') %>";
</script>
</body>
</html>
2 changes: 1 addition & 1 deletion app/views/stories/archived.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<% unless @read_stories.empty? %>
<%= render "stories/js", { stories: @read_stories } %>

<div id="stories">
<div id="stories" data-controller="unread-count" data-action="story:read-changed->unread-count#update" data-unread-count-title-value="<%= t('layout.title') %>">
<ul id="story-list">
</ul>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/views/stories/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<% else %>
<%= render "stories/js", { stories: @unread_stories } %>

<div id="stories">
<div id="stories" data-controller="unread-count" data-action="story:read-changed->unread-count#update" data-unread-count-title-value="<%= t('layout.title') %>">
<ul id="story-list">
</ul>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/views/stories/starred.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<% unless @starred_stories.empty? %>
<%= render "stories/js", { stories: @starred_stories } %>

<div id="stories">
<div id="stories" data-controller="unread-count" data-action="story:read-changed->unread-count#update" data-unread-count-title-value="<%= t('layout.title') %>">
<ul id="story-list">
</ul>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/views/tutorials/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

<%= render 'stories/js', { stories: @sample_stories } %>

<div id="stories">
<div id="stories" data-controller="unread-count" data-action="story:read-changed->unread-count#update" data-unread-count-title-value="<%= t('layout.title') %>">
<ul id="story-list">
</ul>
</div>
Expand Down
72 changes: 72 additions & 0 deletions spec/javascript/controllers/unread_count_controller_spec.ts
Original file line number Diff line number Diff line change
@@ -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 "<li class='story read' data-unread-count-target='story'></li>";
}

return "<li class='story' data-unread-count-target='story'></li>";
}

async function setupController(stories: boolean[]): Promise<void> {
// Static test fixture — safe to use innerHTML
document.body.innerHTML = [
"<div id='stories'",
" data-controller='unread-count'",
" data-action='story:read-changed->unread-count#update'",
" data-unread-count-title-value='Stringer'>",
" <ul id='story-list'>",
...stories.map(storyItem),
" </ul>",
"</div>",
].join("\n");

await bootStimulus("unread-count", UnreadCountController);
}

function storyList(): HTMLElement {
return assert(document.querySelector<HTMLElement>("#story-list"));
}

async function targetsObserved(): Promise<void> {
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");
});
});
12 changes: 12 additions & 0 deletions spec/system/stories_index_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading