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
18 changes: 11 additions & 7 deletions .eslint_todo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// This configuration was generated by `exe/eslint_autogen`
// on 2026-07-04 16:59:21 UTC.
// on 2026-07-06 17:55:24 UTC.
// The point is for the user to remove these configuration records
// one by one as the offenses are removed from the code base.

Expand Down Expand Up @@ -97,7 +97,7 @@ const config: Linter.Config[] = [
"@stylistic/quote-props": "off",
},
},
// Offense count: 40
// Offense count: 41
{
files: [
"app/javascript/application.ts",
Expand Down Expand Up @@ -198,7 +198,7 @@ const config: Linter.Config[] = [
"@typescript-eslint/no-unsafe-assignment": "off",
},
},
// Offense count: 177
// Offense count: 181
{
files: [
"app/javascript/application.ts",
Expand All @@ -210,7 +210,7 @@ const config: Linter.Config[] = [
"@typescript-eslint/no-unsafe-call": "off",
},
},
// Offense count: 260
// Offense count: 266
{
files: [
"app/javascript/application.ts",
Expand Down Expand Up @@ -267,10 +267,12 @@ const config: Linter.Config[] = [
"@typescript-eslint/strict-boolean-expressions": "off",
},
},
// Offense count: 17
// Offense count: 21
{
files: [
"app/javascript/application.ts",
"spec/javascript/controllers/story_refresh_controller_spec.ts",
"spec/javascript/helpers/api_spec.ts",
"spec/javascript/spec/models/story_spec.ts",
"spec/javascript/spec/views/story_view_spec.ts",
],
Expand Down Expand Up @@ -326,7 +328,7 @@ const config: Linter.Config[] = [
"func-style": "off",
},
},
// Offense count: 9
// Offense count: 10
{
files: [
"app/javascript/application.ts",
Expand Down Expand Up @@ -510,9 +512,11 @@ const config: Linter.Config[] = [
"vars-on-top": "off",
},
},
// Offense count: 3
// Offense count: 6
{
files: [
"spec/javascript/controllers/story_refresh_controller_spec.ts",
"spec/javascript/helpers/api_spec.ts",
"spec/javascript/spec/views/story_view_spec.ts",
],
rules: {
Expand Down
6 changes: 4 additions & 2 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ RSpec/LeakyLocalVariable:
- 'spec/requests/stories_controller_spec.rb'
- 'spec/utils/feed_discovery_spec.rb'

# Offense count: 18
# Offense count: 19
# Configuration parameters: EnforcedStyle.
# SupportedStyles: allow, expect
RSpec/MessageExpectation:
Expand All @@ -165,6 +165,7 @@ RSpec/MessageExpectation:
- 'spec/integration/feed_importing_spec.rb'
- 'spec/models/migration_status_spec.rb'
- 'spec/repositories/story_repository_spec.rb'
- 'spec/system/stories_index_spec.rb'
- 'spec/tasks/remove_old_stories_spec.rb'
- 'spec/utils/i18n_support_spec.rb'

Expand Down Expand Up @@ -207,12 +208,13 @@ RSpec/NoBeforeHook:
- 'spec/system/authentication_spec.rb'
- 'spec/system/profile_spec.rb'

# Offense count: 5
# Offense count: 6
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
RSpec/VerifiedDoubles:
Exclude:
- 'spec/commands/feed/fetch_one_spec.rb'
- 'spec/commands/feed/find_new_stories_spec.rb'
- 'spec/commands/story/refresh_from_feed_spec.rb'
- 'spec/tasks/remove_old_stories_spec.rb'

# Offense count: 1
Expand Down
4 changes: 3 additions & 1 deletion app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ li.story.open .story-preview {
float: right;
}

.story-keep-unread, .story-starred {
.story-keep-unread, .story-starred, .story-refresh {
display: inline-block;
cursor: pointer;
-webkit-touch-callout: none;
Expand Down Expand Up @@ -379,6 +379,8 @@ p.story-details {
/* end Wordpress hacks */

.story-actions-container {
display: flex;
justify-content: space-between;
border-top: 2px solid #FAF2E5;
height: 28px;
line-height: 30px;
Expand Down
29 changes: 29 additions & 0 deletions app/commands/story/refresh_from_feed.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

module RefreshFromFeed
class << self
def call(story)
entry = find_entry(story)
return false if entry.nil?

StoryRepository.update_from_entry(story, entry)

true
rescue StandardError => e
Rails.logger.error(
"Something went wrong when refreshing story #{story.id}: #{e}"
)

false
end

private

def find_entry(story)
response = SafeFetch.body(story.feed.url)
raw_feed = Feedjira.parse(response)

raw_feed.entries.find { |entry| entry.id == story.entry_id }
end
end
end
10 changes: 10 additions & 0 deletions app/controllers/stories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ def update
head(:no_content)
end

def refresh
story = authorization.check(StoryRepository.fetch(params[:id]))

if RefreshFromFeed.call(story)
render(json: story)
else
head(:unprocessable_content)
end
end

def mark_all_as_read
stories = authorization.scope(Story.where(id: params[:story_ids]))
MarkAllAsRead.call(stories.ids)
Expand Down
8 changes: 7 additions & 1 deletion app/javascript/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ var StoryView = Backbone.NativeView.extend({
this.model.set({is_read: detail.isRead, keep_unread: detail.keepUnread}, {silent: true});
this.model.trigger('change:is_read');
});
this.el.addEventListener('story-refresh:refreshed', (e) => {
this.model.set(e.detail.story, {silent: true});
this.render();
this.itemOpened();
});
},

itemOpened: function() {
Expand Down Expand Up @@ -164,12 +169,13 @@ var StoryView = Backbone.NativeView.extend({
this.el.classList.add('keepUnread');
}
Object.assign(this.el.dataset, {
controller: "star-toggle keep-unread-toggle",
controller: "star-toggle keep-unread-toggle story-refresh",
keepUnreadToggleIdValue: String(jsonModel.id),
keepUnreadToggleIsReadValue: String(jsonModel.is_read),
keepUnreadToggleKeepUnreadValue: String(jsonModel.keep_unread),
starToggleIdValue: String(jsonModel.id),
starToggleStarredValue: String(jsonModel.is_starred),
storyRefreshIdValue: String(jsonModel.id),
unreadCountTarget: "story",
});
return this;
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 @@ -21,5 +21,8 @@ application.register("mark-all-as-read", MarkAllAsReadController);
import StarToggleController from "./star_toggle_controller";
application.register("star-toggle", StarToggleController);

import StoryRefreshController from "./story_refresh_controller";
application.register("story-refresh", StoryRefreshController);

import UnreadCountController from "./unread_count_controller";
application.register("unread-count", UnreadCountController);
19 changes: 19 additions & 0 deletions app/javascript/controllers/story_refresh_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {Controller} from "@hotwired/stimulus";

import {refreshStory} from "helpers/api";

export default class extends Controller {
static override values = {id: String};

declare idValue: string;

async refresh(): Promise<void> {
const response = await refreshStory(this.idValue);
if (!response.ok) {
throw new Error(`Failed to refresh story ${this.idValue}`);
}

const story: unknown = await response.json();
this.dispatch("refreshed", {bubbles: true, detail: {story}});
}
}
9 changes: 8 additions & 1 deletion app/javascript/helpers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,11 @@ async function updateStory(
});
}

export {updateStory};
async function refreshStory(id: string): Promise<Response> {
return fetch(`/stories/${id}/refresh`, {
headers: {"X-CSRF-Token": csrfToken()},
method: "POST",
});
}

export {refreshStory, updateStory};
11 changes: 11 additions & 0 deletions app/repositories/story_repository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ def self.add(entry, feed)
)
end

def self.update_from_entry(story, entry)
feed = story.feed

story.update!(
title: extract_title(entry),
permalink: extract_url(entry, feed),
enclosure_url: safe_normalize_url(entry.try(:enclosure_url), feed.url),
body: extract_content(entry)
)
end

def self.fetch(id)
Story.find(id)
end
Expand Down
9 changes: 6 additions & 3 deletions app/views/stories/_templates.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,22 @@
</h1>
{{= body }}
</div>
<div class="row story-actions-container">
<div class="float-start">
<div class="story-actions-container">
<div>
<span class="story-published">
{{= pretty_date }}
</span>
</div>
<div class="float-end story-actions">
<div class="story-actions">
<div class="story-keep-unread" data-action="click->keep-unread-toggle#toggle:stop">
<i class="fa {{ if(keep_unread) { }}fa-check{{ } else { }}fa-square-o{{ } }}" data-keep-unread-toggle-target="icon"></i> <%= t('stories.keep_unread') %>
</div>
<div class="story-starred" data-action="click->star-toggle#toggle:stop">
<i class="fa {{ if(is_starred) { }}fa-star{{ } else { }}fa-star-o{{ } }}" data-star-toggle-target="icon"></i>
</div>
<div class="story-refresh" title="<%= t('stories.refresh') %>" data-action="click->story-refresh#refresh:stop">
<i class="fa fa-refresh"></i>
</div>
<a class="story-permalink" target="_blank" href="{{- permalink }}">
<i class="fa fa-external-link"></i>
</a>
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ en:
sorry: Sorry, you haven't starred any stories yet!
stories:
keep_unread: Keep unread
refresh: Refresh story from feed
time:
formats:
default: '%b %d, %H:%M'
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@
get "/setup/tutorial", to: "tutorials#index"
get "/starred", to: "stories#starred"
put "/stories/:id", to: "stories#update"
post "/stories/:id/refresh", to: "stories#refresh"
post "/stories/mark_all_as_read", to: "stories#mark_all_as_read"
end
99 changes: 99 additions & 0 deletions spec/commands/story/refresh_from_feed_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

RSpec.describe RefreshFromFeed do
def create_entry(**options)
entry = {
guid: "entry-guid",
published: Time.zone.now,
title: "Updated title",
url: "https://example.com/updated",
content: "Updated content",
enclosure_url: "https://example.com/updated.mp3",
**options
}
double(entry)
end

def stub_raw_feed(feed, entries: [])
xml = GenerateXml.call(feed, entries)
stub_request(:get, feed.url).to_return(status: 200, body: xml)
end

def stub_invalid_feed(feed)
stub_request(:get, feed.url).to_return(status: 200, body: "not a feed")
end

def create_refreshable_story(**)
story = create(:story, entry_id: "entry-guid", **)
stub_raw_feed(story.feed, entries: [create_entry])
story
end

context "when the entry is still in the feed" do
it "updates the enclosure url" do
story =
create_refreshable_story(enclosure_url: "https://example.com/dead.mp3")

expect { described_class.call(story) }
.to change_record(story, :enclosure_url)
.to("https://example.com/updated.mp3")
end

it "updates the title and body" do
story = create_refreshable_story(title: "Old title")

described_class.call(story)

expect(story.reload)
.to have_attributes(title: "Updated title", body: "Updated content")
end

it "updates the permalink" do
story = create_refreshable_story

described_class.call(story)

expect(story.reload.permalink).to eq("https://example.com/updated")
end

it "returns true" do
story = create_refreshable_story

expect(described_class.call(story)).to be(true)
end
end

context "when the entry is no longer in the feed" do
it "returns false" do
story = create_refreshable_story(entry_id: "gone-guid")

expect(described_class.call(story)).to be(false)
end

it "does not change the story" do
story =
create_refreshable_story(entry_id: "gone-guid", title: "Old title")

described_class.call(story)

expect(story.reload.title).to eq("Old title")
end
end

context "when the feed cannot be fetched or parsed" do
it "returns false" do
story = create(:story)
stub_invalid_feed(story.feed)

expect(described_class.call(story)).to be(false)
end

it "logs an error" do
story = create(:story)
stub_invalid_feed(story.feed)

expect { described_class.call(story) }
.to invoke(:error).on(Rails.logger).with(/refreshing story/)
end
end
end
Loading
Loading