diff --git a/.eslint_todo.ts b/.eslint_todo.ts index 2def3b075..d5a3c9911 100644 --- a/.eslint_todo.ts +++ b/.eslint_todo.ts @@ -6,40 +6,10 @@ import type {Linter} from "eslint"; const config: Linter.Config[] = [ - // Offense count: 7 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/comma-dangle": "off", - }, - }, - // Offense count: 5 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/key-spacing": "off", - }, - }, - // Offense count: 2 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/keyword-spacing": "off", - }, - }, // Offense count: 15 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", "vitest.config.ts", ], rules: { @@ -55,42 +25,27 @@ const config: Linter.Config[] = [ "@stylistic/multiline-comment-style": "off", }, }, - // Offense count: 8 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@stylistic/multiline-ternary": "off", - }, - }, - // Offense count: 3 + // Offense count: 30 { files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", + "spec/javascript/setup.ts", ], rules: { - "@stylistic/no-extra-parens": "off", + "@stylistic/object-curly-spacing": "off", }, }, - // Offense count: 30 + // Offense count: 1 { files: [ "app/javascript/application.ts", - "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { - "@stylistic/object-curly-spacing": "off", + "import/no-unresolved": "off", }, }, // Offense count: 3 { files: [ - "app/javascript/application.ts", "stylelint.config.mjs", ], rules: { @@ -100,47 +55,16 @@ const config: Linter.Config[] = [ // Offense count: 41 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", ], rules: { "@stylistic/quotes": "off", }, }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/semi": "off", - }, - }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/space-before-blocks": "off", - }, - }, - // Offense count: 50 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@stylistic/space-before-function-paren": "off", - }, - }, // Offense count: 4 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "@typescript-eslint/ban-ts-comment": "off", @@ -149,60 +73,17 @@ const config: Linter.Config[] = [ // Offense count: 7 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "@typescript-eslint/explicit-function-return-type": "off", }, }, - // Offense count: 3 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@typescript-eslint/init-declarations": "off", - }, - }, - // Offense count: 2 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/no-deprecated": "off", - }, - }, - // Offense count: 5 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@typescript-eslint/no-empty-function": "off", - }, - }, - // Offense count: 9 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@typescript-eslint/no-unsafe-argument": "off", - }, - }, // Offense count: 45 { files: [ - "app/javascript/application.ts", "eslint.config.mts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "@typescript-eslint/no-unsafe-assignment": "off", @@ -211,83 +92,12 @@ const config: Linter.Config[] = [ // Offense count: 181 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "@typescript-eslint/no-unsafe-call": "off", }, }, - // Offense count: 266 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "@typescript-eslint/no-unsafe-member-access": "off", - }, - }, - // Offense count: 6 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/no-unsafe-return": "off", - }, - }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/prefer-destructuring": "off", - }, - }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/prefer-nullish-coalescing": "off", - }, - }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "@typescript-eslint/prefer-optional-chain": "off", - }, - }, - // Offense count: 24 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", - ], - rules: { - "@typescript-eslint/strict-boolean-expressions": "off", - }, - }, - // Offense count: 9 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "camelcase": "off", - }, - }, // Offense count: 1 { files: [ @@ -297,223 +107,55 @@ const config: Linter.Config[] = [ "capitalized-comments": "off", }, }, - // Offense count: 13 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "curly": "off", - }, - }, - // Offense count: 2 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "eqeqeq": "off", - }, - }, // Offense count: 111 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "func-names": "off", }, }, - // Offense count: 3 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "func-style": "off", - }, - }, // Offense count: 15 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "id-length": "off", }, }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "import/no-unresolved": "off", - }, - }, // Offense count: 12 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", "vitest.config.ts", ], rules: { "max-len": "off", }, }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "max-lines": "off", - }, - }, - // Offense count: 3 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "max-lines-per-function": "off", - }, - }, - // Offense count: 1 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "max-statements": "off", - }, - }, // Offense count: 4 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "new-cap": "off", }, }, - // Offense count: 2 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - ], - rules: { - "no-implicit-coercion": "off", - }, - }, - // Offense count: 2 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - ], - rules: { - "no-multi-assign": "off", - }, - }, - // Offense count: 1 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "no-negated-condition": "off", - }, - }, - // Offense count: 1 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "no-param-reassign": "off", - }, - }, - // Offense count: 2 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "no-plusplus": "off", - }, - }, - // Offense count: 4 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "no-ternary": "off", - }, - }, - // Offense count: 47 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "object-shorthand": "off", - }, - }, - // Offense count: 1 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "one-var": "off", - }, - }, - // Offense count: 59 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "prefer-arrow-callback": "off", - }, - }, // Offense count: 4 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", ], rules: { "prefer-named-capture-group": "off", }, }, - // Offense count: 1 - { - files: [ - "app/javascript/application.ts", - ], - rules: { - "prefer-template": "off", - }, - }, // Offense count: 4 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", ], rules: { @@ -524,8 +166,6 @@ const config: Linter.Config[] = [ { files: [ "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "sort-imports": "off", @@ -534,10 +174,7 @@ const config: Linter.Config[] = [ // Offense count: 26 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "sort-keys": "off", @@ -546,49 +183,16 @@ const config: Linter.Config[] = [ // Offense count: 26 { files: [ - "app/javascript/application.ts", "spec/javascript/setup.ts", - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "sort-keys-fix/sort-keys-fix": "off", }, }, - // Offense count: 8 - { - files: [ - "app/javascript/application.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "vars-on-top": "off", - }, - }, - // Offense count: 3 - { - files: [ - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "vitest/no-hooks": "off", - }, - }, - // Offense count: 4 - { - files: [ - "spec/javascript/spec/models/story_spec.ts", - "spec/javascript/spec/views/story_view_spec.ts", - ], - rules: { - "vitest/prefer-lowercase-title": "off", - }, - }, // Offense count: 4 { files: [ "spec/javascript/setup.ts", - "spec/javascript/spec/views/story_view_spec.ts", ], rules: { "vitest/require-hook": "off", diff --git a/app/javascript/application.ts b/app/javascript/application.ts index 3ad657eee..dc666e6ab 100644 --- a/app/javascript/application.ts +++ b/app/javascript/application.ts @@ -1,329 +1,21 @@ -// @ts-nocheck import "@hotwired/turbo-rails"; import "@rails/activestorage"; import "jquery"; +// @ts-expect-error — Bootstrap 3 has no type declarations import "bootstrap"; -import "mousetrap"; -import "jquery-visible"; -import _ from "underscore"; -import Backbone from "backbone"; import "./controllers/index"; -Turbo.session.drive = false; - -/* global jQuery, Mousetrap */ -var $ = jQuery; - -window.$ = $; - -Backbone.$ = $; - -_.templateSettings = { - interpolate: /\{\{=(.+?)\}\}/g, - evaluate: /\{\{(.+?)\}\}/g -}; - -function CSRFToken() { - const tokenTag = document.getElementsByName('csrf-token')[0]; - - return (tokenTag && tokenTag.content) || ''; -} - -function requestHeaders() { - return { 'X-CSRF-Token': CSRFToken() }; -} - -var Story = Backbone.Model.extend({ - defaults: function() { - return { - "open" : false, - "selected" : false - } - }, - - toggle: function() { - if (this.get("open")) { - this.close(); - } else { - this.open(); - } - }, - - shouldSave: function() { - return this.changedAttributes() && this.get("id") > 0; - }, - - open: function() { - if (!this.get("keep_unread")) this.set("is_read", true); - if (this.shouldSave()) this.save(null, { headers: requestHeaders() }); - - if(this.collection){ - this.collection.closeOthers(this); - this.collection.unselectAll(); - this.collection.setSelection(this); - } - - this.set("open", true); - this.set("selected", true); - }, - - toggleKeepUnread: function() { - if (this.get("keep_unread")) { - this.set("keep_unread", false); - this.set("is_read", true); - } else { - this.set("keep_unread", true); - this.set("is_read", false); - } - - if (this.shouldSave()) this.save(null, { headers: requestHeaders() }); - }, - - close: function() { - this.set("open", false); - }, - - select: function() { - if(this.collection) this.collection.unselectAll(); - this.set("selected", true); - }, - - unselect: function() { - this.set("selected", false); - }, - - openInTab: function() { - window.open(this.get("permalink"), '_blank'); +declare global { + interface JQuery { + modal: (action: string) => JQuery; } -}); - -var StoryView = Backbone.View.extend({ - tagName: "li", - className: "story", - - template: "#story-template", - - events: { - "click .story-preview" : "storyClicked" - }, - - initialize: function() { - this.template = _.template($(this.template).html()); - this.listenTo(this.model, 'add', this.render); - this.listenTo(this.model, 'change:selected', this.itemSelected); - this.listenTo(this.model, 'change:open', this.itemOpened); - this.listenTo(this.model, 'change:is_read', this.itemRead); - this.el.addEventListener('keep-unread-toggle:toggled', (e) => { - var detail = e.detail; - this.model.set({keep_unread: detail.keepUnread, is_read: detail.isRead}, {silent: true}); - this.model.trigger('change:is_read'); - }); - }, - - render: function() { - var jsonModel = this.model.toJSON(); - this.$el.html(this.template(jsonModel)); - if (jsonModel.is_read) { - this.$el.addClass('read'); - } - if (jsonModel.keep_unread) { - this.$el.addClass('keepUnread'); - } - Object.assign(this.el.dataset, { - controller: "star-toggle keep-unread-toggle", - keepUnreadToggleIdValue: String(jsonModel.id), - keepUnreadToggleIsReadValue: String(jsonModel.is_read), - keepUnreadToggleKeepUnreadValue: String(jsonModel.keep_unread), - starToggleIdValue: String(jsonModel.id), - starToggleStarredValue: String(jsonModel.is_starred), - }); - return this; - }, - - itemRead: function() { - this.$el.toggleClass("read", this.model.get("is_read")); - }, - - itemOpened: function() { - if (this.model.get("open")) { - this.$el.addClass("open"); - $(".story-lead", this.$el).fadeOut(1000); - window.scrollTo(0, this.$el.offset().top); - } else { - this.$el.removeClass("open"); - $(".story-lead", this.$el).show(); - } - }, - - itemSelected: function() { - this.$el.toggleClass("cursor", this.model.get("selected")); - if (!this.$el.visible()) window.scrollTo(0, this.$el.offset().top); - }, - - storyClicked: function(e) { - if (e.metaKey || e.ctrlKey || e.which == 2) { - var backgroundTab = window.open(this.model.get("permalink")); - if (backgroundTab) backgroundTab.blur(); - window.focus(); - if (!this.model.get("keep_unread")) this.model.set("is_read", true); - if (this.model.shouldSave()) this.model.save(null, { headers: requestHeaders() }); - } else { - this.model.toggle(); - window.scrollTo(0, this.$el.offset().top); - } - }, - -}); - -var StoryList = Backbone.Collection.extend({ - model: Story, - url: "/stories", - - initialize: function() { - this.cursorPosition = -1; - }, - - max_position: function() { - return this.length - 1; - }, - - unreadCount: function() { - return this.where({is_read: false}).length; - }, - - closeOthers: function(modelToSkip) { - this.each(function(model) { - if (model.id != modelToSkip.id) { - model.close(); - } - }); - }, - - selected: function() { - return this.where({selected: true}); - }, - - unselectAll: function() { - _.invoke(this.selected(), "unselect"); - }, - - selectedStoryId: function() { - var selectedStory = this.at(this.cursorPosition); - return selectedStory ? selectedStory.id : -1; - }, - - setSelection: function(model) { - this.cursorPosition = this.indexOf(model); - }, - - moveCursorDown: function() { - if (this.cursorPosition < this.max_position()) { - this.cursorPosition++; - } else { - this.cursorPosition = 0; - } - - this.at(this.cursorPosition).select(); - }, - - moveCursorUp: function() { - if (this.cursorPosition > 0) { - this.cursorPosition--; - } else { - this.cursorPosition = this.max_position(); - } - - this.at(this.cursorPosition).select(); - }, - - openCurrentSelection: function() { - this.at(this.cursorPosition).open(); - }, - - toggleCurrent: function() { - if (this.cursorPosition < 0) this.cursorPosition = 0; - this.at(this.cursorPosition).toggle(); - }, - - viewCurrentInTab: function() { - if (this.cursorPosition < 0) this.cursorPosition = 0; - this.at(this.cursorPosition).openInTab(); - }, - - toggleCurrentKeepUnread: function() { - if (this.cursorPosition < 0) this.cursorPosition = 0; - this.at(this.cursorPosition).toggleKeepUnread(); - } -}); - -var AppView = Backbone.View.extend({ - el: "#stories", - - initialize: function(collection) { - this.stories = collection; - this.el = $(this.el); - - this.listenTo(this.stories, 'add', this.addOne); - this.listenTo(this.stories, 'reset', this.addAll); - this.listenTo(this.stories, 'all', this.render); - }, - - loadData: function(data) { - this.stories.reset(data); - }, - - render: function() { - var unreadCount = this.stories.unreadCount(); - - if (unreadCount === 0) { - document.title = window.i18n.titleName; - } else { - document.title = "(" + unreadCount + ") " + window.i18n.titleName; - } - }, - - addOne: function(story) { - var view = new StoryView({model: story}); - this.$("#story-list").append(view.render().el); - }, - - addAll: function() { - this.stories.each(this.addOne, this); - }, - - moveCursorDown: function() { - this.stories.moveCursorDown(); - }, - - moveCursorUp: function() { - this.stories.moveCursorUp(); - }, - - openCurrentSelection: function() { - this.stories.openCurrentSelection(); - }, - - toggleCurrent: function() { - this.stories.toggleCurrent(); - }, +} - viewCurrentInTab: function() { - this.stories.viewCurrentInTab(); - }, +Turbo.session.drive = false; - toggleCurrentKeepUnread: function() { - this.stories.toggleCurrentKeepUnread(); +document.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === "?") { + jQuery("#shortcuts").modal("toggle"); } }); - -$(document).ready(function() { - Mousetrap.bind("?", function() { - $("#shortcuts").modal('toggle'); - }); -}); - -window.StoryList = StoryList; -window.AppView = AppView; - -export { Story, StoryView, StoryList, AppView }; diff --git a/app/javascript/controllers/index.ts b/app/javascript/controllers/index.ts index 103dfcfdb..dd03c261f 100644 --- a/app/javascript/controllers/index.ts +++ b/app/javascript/controllers/index.ts @@ -15,5 +15,14 @@ application.register("hotkeys", HotkeysController); import KeepUnreadToggleController from "./keep_unread_toggle_controller"; application.register("keep-unread-toggle", KeepUnreadToggleController); +import MarkAllAsReadController from "./mark_all_as_read_controller"; +application.register("mark-all-as-read", MarkAllAsReadController); + import StarToggleController from "./star_toggle_controller"; application.register("star-toggle", StarToggleController); + +import StoryController from "./story_controller"; +application.register("story", StoryController); + +import StoryListController from "./story_list_controller"; +application.register("story-list", StoryListController); diff --git a/app/javascript/controllers/mark_all_as_read_controller.ts b/app/javascript/controllers/mark_all_as_read_controller.ts new file mode 100644 index 000000000..d237103fd --- /dev/null +++ b/app/javascript/controllers/mark_all_as_read_controller.ts @@ -0,0 +1,15 @@ +import {Controller} from "@hotwired/stimulus"; + +import {assert} from "helpers/assert"; + +export default class extends Controller { + static override targets = ["form"]; + + formTarget!: HTMLFormElement; + + submit(event: Event): void { + event.preventDefault(); + + assert(this.formTarget).requestSubmit(); + } +} diff --git a/app/javascript/controllers/story_controller.ts b/app/javascript/controllers/story_controller.ts new file mode 100644 index 000000000..2e5473f2b --- /dev/null +++ b/app/javascript/controllers/story_controller.ts @@ -0,0 +1,109 @@ +import {Controller} from "@hotwired/stimulus"; + +import {updateStory} from "helpers/api"; + +function closeStory(story: Element): void { + story.classList.remove("open"); + const sel = "[data-story-target='lead']"; + const lead = story.querySelector(sel); + if (lead) { lead.style.display = ""; } +} + +interface ToggleDetail { + isRead: boolean; + keepUnread: boolean; +} + +export default class extends Controller { + static override values = { + id: String, + isRead: Boolean, + keepUnread: Boolean, + permalink: String, + }; + + static override targets = ["body", "lead"]; + + declare idValue: string; + + declare isReadValue: boolean; + + declare keepUnreadValue: boolean; + + declare permalinkValue: string; + + bodyTarget!: HTMLElement; + + leadTarget!: HTMLElement; + + toggle(event: MouseEvent): void { + if (event.metaKey || event.ctrlKey || event.button === 1) { + this.openInBackgroundTab(); + return; + } + + if (this.element.classList.contains("open")) { + this.close(); + } else { + this.open(); + } + } + + open(): void { + this.closeOthers(); + this.element.classList.add("open"); + this.leadTarget.style.display = "none"; + this.element.scrollIntoView({block: "start"}); + + if (!this.keepUnreadValue) { + this.markAsRead(); + } + } + + close(): void { + this.element.classList.remove("open"); + this.leadTarget.style.display = ""; + } + + keepUnreadToggleToggled(event: CustomEvent): void { + this.keepUnreadValue = event.detail.keepUnread; + this.isReadValue = event.detail.isRead; + } + + private openInBackgroundTab(): void { + const tab = window.open(this.permalinkValue); + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (tab) { tab.blur(); } + window.focus(); + + if (!this.keepUnreadValue) { + this.markAsRead(); + } + } + + private markAsRead(): void { + if (this.isReadValue) { return; } + this.isReadValue = true; + this.element.classList.add("read"); + + /* eslint-disable camelcase */ + updateStory(this.idValue, {is_read: true}).catch(() => { + // Optimistic UI — ignore server errors + }); + /* eslint-enable camelcase */ + + this.dispatch("read", {bubbles: true}); + } + + private closeOthers(): void { + const selector = "[data-controller~='story-list']"; + const list = this.element.closest(selector); + if (!list) { return; } + + for (const story of list.querySelectorAll("li.story.open")) { + if (story !== this.element) { + closeStory(story); + } + } + } +} diff --git a/app/javascript/controllers/story_list_controller.ts b/app/javascript/controllers/story_list_controller.ts new file mode 100644 index 000000000..06df5eecf --- /dev/null +++ b/app/javascript/controllers/story_list_controller.ts @@ -0,0 +1,212 @@ +import {Controller} from "@hotwired/stimulus"; + +declare global { + interface Window { + i18n?: {titleName?: string}; + } +} + +function isVisible(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + + return rect.top >= 0 && + rect.bottom <= window.innerHeight; +} + +function isInputTarget(event: KeyboardEvent): boolean { + const {target} = event; + if (!(target instanceof HTMLElement)) { return false; } + + const tagName = target.tagName.toLowerCase(); + + return tagName === "input" || + tagName === "textarea" || + tagName === "select" || + target.isContentEditable; +} + +export default class extends Controller { + static override targets = ["story"]; + + declare storyTargets: HTMLElement[]; + + cursorPosition = -1; + + private baseTitleName = ""; + + private get maxPosition(): number { + return this.storyTargets.length - 1; + } + + override connect(): void { + this.baseTitleName = window.i18n?.titleName ?? ""; + } + + storyTargetConnected(): void { + this.updateTitle(); + } + + storyTargetDisconnected(): void { + this.updateTitle(); + } + + handleKeydown(event: KeyboardEvent): void { + if (isInputTarget(event)) { return; } + if (!this.handleKey(event.key.toLowerCase())) { return; } + + event.preventDefault(); + } + + storyRead(): void { + this.updateTitle(); + } + + private handleKey(key: string): boolean { + switch (key) { + case "j": + this.moveCursorDown(); + this.openCurrent(); + return true; + case "k": + this.moveCursorUp(); + this.openCurrent(); + return true; + case "n": + this.moveCursorDown(); + return true; + case "p": + this.moveCursorUp(); + return true; + case "o": + case "enter": + this.toggleCurrent(); + return true; + case "b": + case "v": + this.viewInTab(); + return true; + case "m": + this.clickCurrentAction(".story-keep-unread"); + return true; + case "s": + this.clickCurrentAction(".story-starred"); + return true; + default: + return false; + } + } + + private moveCursorDown(): void { + if (this.storyTargets.length === 0) { return; } + + this.clearCursor(); + + if (this.cursorPosition < this.maxPosition) { + this.cursorPosition += 1; + } else { + this.cursorPosition = 0; + } + + this.applyCursor(); + } + + private moveCursorUp(): void { + if (this.storyTargets.length === 0) { return; } + + this.clearCursor(); + + if (this.cursorPosition > 0) { + this.cursorPosition -= 1; + } else { + this.cursorPosition = this.maxPosition; + } + + this.applyCursor(); + } + + private openCurrent(): void { + const story = this.currentStory(); + if (!story) { return; } + + const preview = + story.querySelector(".story-preview"); + preview?.click(); + } + + private toggleCurrent(): void { + if (this.cursorPosition < 0) { + this.cursorPosition = 0; + } + + const story = this.currentStory(); + if (!story) { return; } + + this.clearCursor(); + this.applyCursor(); + + const preview = + story.querySelector(".story-preview"); + preview?.click(); + } + + private viewInTab(): void { + if (this.cursorPosition < 0) { + this.cursorPosition = 0; + } + + const story = this.currentStory(); + if (!story) { return; } + + const permalink = + story.dataset.storyPermalinkValue ?? ""; + if (permalink !== "") { + window.open(permalink, "_blank"); + } + } + + private clickCurrentAction(selector: string): void { + if (this.cursorPosition < 0) { + this.cursorPosition = 0; + } + + const story = this.currentStory(); + if (!story) { return; } + + story.querySelector(selector)?.click(); + } + + private currentStory(): HTMLElement | undefined { + return this.storyTargets[this.cursorPosition]; + } + + private clearCursor(): void { + for (const story of this.storyTargets) { + story.classList.remove("cursor"); + } + } + + private applyCursor(): void { + const story = this.currentStory(); + if (!story) { return; } + + story.classList.add("cursor"); + + if (!isVisible(story)) { + story.scrollIntoView({block: "start"}); + } + } + + private updateTitle(): void { + const unread = this.storyTargets.filter((story) => { + return !story.classList.contains("read"); + }); + const unreadCount = unread.length; + + if (unreadCount === 0) { + document.title = this.baseTitleName; + } else { + document.title = + `(${unreadCount}) ${this.baseTitleName}`; + } + } +} diff --git a/app/views/feeds/_single_feed_action_bar.html.erb b/app/views/feeds/_single_feed_action_bar.html.erb index 7b4e17730..ee0bd0ac1 100644 --- a/app/views/feeds/_single_feed_action_bar.html.erb +++ b/app/views/feeds/_single_feed_action_bar.html.erb @@ -1,13 +1,13 @@ - - - diff --git a/app/views/stories/_action_bar.html.erb b/app/views/stories/_action_bar.html.erb index adde91546..87ad929d2 100644 --- a/app/views/stories/_action_bar.html.erb +++ b/app/views/stories/_action_bar.html.erb @@ -1,10 +1,10 @@ -