Skip to content

feat: integrate Stories widget with backend endpoint#2172

Open
stackingsaunter wants to merge 2 commits intomasterfrom
chore/stories-widget-dummy-preview
Open

feat: integrate Stories widget with backend endpoint#2172
stackingsaunter wants to merge 2 commits intomasterfrom
chore/stories-widget-dummy-preview

Conversation

@stackingsaunter
Copy link
Contributor

@stackingsaunter stackingsaunter commented Mar 25, 2026

Summary

  • Add backend stories endpoint plumbing based on feat: stories #1650 (/api/alby/stories) for HTTP and Wails.
  • Implement Home StoriesWidget UI and fetch stories from the backend endpoint.
  • Place Stories at the top of the right Home column.
  • Keep story label tweaks: text-xs, leading-normal, and no NEW badge.
  • Story modal actions: optional CTAs beside “Watch on YouTube” depending on story type. Stories can send a kind field (update | alby-go | alby-extension); if missing, kind is inferred from the title. Actions: Update Alby Hub → getalby.com update URL with current Hub version (useInfo); Open Alby Go → in-app /appstore/alby-go; Install Alby Extension → getalby.com (new tab).
  • Preview fallback: if the stories request fails, the widget shows static preview stories so layout and modals stay testable in dev/preview.

Data & playback

  • Story cards and metadata are loaded from the Alby internal API via Hub’s /api/alby/stories.
  • videoUrl is expected to be a normal watch/embeddable URL; the dialog uses the existing YouTube iframe helper and CSP entries for YouTube.

Test plan

  • Run go test ./alby ./api ./http ./wails
  • Run cd frontend && yarn tsc:compile
  • Verify Stories appears at top of right column and opens the video dialog
  • With API error (or preview stories), open each story kind and confirm modal CTAs behave (in-app vs external, version query on update)

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Stories widget on the Home screen with a horizontally scrollable collection
    • Fetch and display story titles, avatars, and optional video content
    • Embedded YouTube playback within story dialogs and a “Watch on YouTube” external link
    • Track and persist viewed stories to visually indicate progress
  • Chores

    • Enabled YouTube embed sources so story videos can play in-app

Add stories endpoint plumbing for HTTP and Wails, wire the Home Stories card
to fetch from /api/alby/stories, and keep it first in the right column.

Made-with: Cursor
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 25, 2026

📝 Walkthrough

Walkthrough

A new Stories feature fetches story data from the Alby internal API and surfaces it via a backend API route and frontend widget. It adds service and model APIs, a React StoriesWidget with localStorage persistence and YouTube embed support, and updates CSP/frame-src for YouTube.

Changes

Cohort / File(s) Summary
Backend Models
alby/models.go, api/models.go
Added exported Story struct and GetStories(ctx context.Context) ([]Story, error) to AlbyOAuthService and API interfaces.
Backend Service Layer
alby/alby_oauth_service.go
Implemented albyOAuthService.GetStories performing HTTP GET to albyInternalAPIURL + "/stories" with 10s timeout, headers, non-2xx error handling (includes response body), and JSON decode into []Story.
Backend API Layer
api/api.go
Added api.GetStories(ctx) delegating to albyOAuthSvc.GetStories.
HTTP Handlers & Routing
http/alby_http_service.go, http/http_service.go, wails/wails_handlers.go
Registered /api/alby/stories route and handler that calls API, returns 500 on error or 200 with stories; extended CSP frame-src to allow YouTube domains.
Frontend Component
frontend/src/components/home/widgets/StoriesWidget.tsx
New StoriesWidget component: fetches /api/alby/stories, falls back to preview data on failure, persists viewed-story IDs in localStorage, renders story carousel, opens modal with story details and optional YouTube (privacy-enhanced) embed and action links.
Frontend Integration
frontend/src/screens/Home.tsx
Imported and rendered StoriesWidget in the RIGHT-column widget stack.
Frontend Dev Config
frontend/vite.config.ts
Updated dev-only CSP meta injection to include https://www.youtube.com and https://www.youtube-nocookie.com in frame-src.
Tests / Mocks
tests/mocks/AlbyOAuthService.go
Added GetStories to MockAlbyOAuthService with testify mock handling for flexible return types.

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend as Frontend:\nStoriesWidget
    participant HTTP as HTTP_Handler
    participant API as API_Layer
    participant Service as AlbyOAuthService
    participant Alby as Alby_API

    User->>Frontend: mount
    Frontend->>HTTP: GET /api/alby/stories
    HTTP->>API: GetStories(ctx)
    API->>Service: GetStories(ctx)
    Service->>Alby: HTTP GET /stories (10s timeout)
    Alby-->>Service: JSON []Story
    Service-->>API: []Story
    API-->>HTTP: []Story
    HTTP-->>Frontend: 200 OK + stories
    Frontend->>Frontend: read localStorage (viewed IDs)
    User->>Frontend: click story
    Frontend->>Frontend: mark viewed, normalize YouTube URL, open modal
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰✨ I hop to fetch the tales anew,
Tiny frames of video and view,
LocalStorage keeps the marks I've made,
Stories spin in carousel parade,
A rabbit cheers — enjoy the view!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a Stories widget and integrating it with a backend endpoint, which is reflected across the UI component, backend service, API routes, and related implementations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/stories-widget-dummy-preview

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
frontend/src/components/home/widgets/StoriesWidget.tsx (2)

229-238: Consider restricting iframe to YouTube-only URLs.

getYouTubeEmbedUrl falls back to the original URL when parsing fails or the URL isn't recognized as YouTube. If videoUrl contains a non-YouTube URL, it will be embedded directly. Since the story data comes from the backend, verify that the CSP frame-src directive appropriately restricts allowed iframe sources.

💡 Alternative: Only render iframe for valid YouTube URLs
+function isYouTubeUrl(url: string): boolean {
+  try {
+    const host = new URL(url).hostname.replace(/^www\./, "");
+    return host === "youtu.be" || host.endsWith("youtube.com");
+  } catch {
+    return false;
+  }
+}

-              {activeStory.videoUrl && (
+              {activeStory.videoUrl && isYouTubeUrl(activeStory.videoUrl) && (
                 <div className="aspect-video w-full bg-black">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/home/widgets/StoriesWidget.tsx` around lines 229 -
238, The iframe currently renders any activeStory.videoUrl because
getYouTubeEmbedUrl falls back to the original URL; update StoriesWidget to only
render the iframe when the URL is recognized as YouTube by using the output of
getYouTubeEmbedUrl (or a new isYouTubeUrl helper) and verify embedUrl is
non-empty before creating the iframe; reference activeStory.videoUrl and
getYouTubeEmbedUrl (or a new isYouTubeUrl) and ensure the component skips
rendering the iframe for non-YouTube URLs so only validated YouTube embeds are
used.

107-135: Use SWR and the typed request() helper for data fetching.

The coding guidelines specify using SWR for server state management and the typed request() helper for HTTP requests. This would provide automatic caching, revalidation, and type safety.

♻️ Suggested refactor using SWR
+import useSWR from "swr";
+import { request } from "src/utils/request";
+
+type StoryResponse = {
+  id: number;
+  title: string;
+  avatar: string;
+  videoUrl?: string;
+};

 export function StoriesWidget() {
-  const [stories, setStories] = React.useState<Story[]>([]);
-  const [isLoading, setIsLoading] = React.useState(true);
+  const { data, isLoading } = useSWR<StoryResponse[]>(
+    "/api/alby/stories",
+    request
+  );
+
+  const stories: Story[] = React.useMemo(
+    () =>
+      (data ?? []).map((story) => ({
+        id: String(story.id),
+        title: story.title,
+        avatar: story.avatar,
+        videoUrl: story.videoUrl,
+      })),
+    [data]
+  );
   const [activeStory, setActiveStory] = React.useState<Story | null>(null);
   const [viewedIds, setViewedIds] =
     React.useState<Set<string>>(loadViewedStoryIds);
-
-  React.useEffect(() => {
-    const loadStories = async () => {
-      try {
-        const response = await fetch("/api/alby/stories");
-        if (!response.ok) {
-          throw new Error(`Failed to fetch stories: ${response.status}`);
-        }
-        const payload = (await response.json()) as Array<{
-          id: number;
-          title: string;
-          avatar: string;
-          videoUrl?: string;
-        }>;
-        const mappedStories = payload.map((story) => ({
-          id: String(story.id),
-          title: story.title,
-          avatar: story.avatar,
-          videoUrl: story.videoUrl,
-        }));
-        setStories(mappedStories);
-      } catch {
-        setStories([]);
-      } finally {
-        setIsLoading(false);
-      }
-    };
-
-    void loadStories();
-  }, []);

As per coding guidelines: "Use SWR for server state management" and "Use the typed request() helper in frontend/src/utils/request.ts for HTTP requests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/home/widgets/StoriesWidget.tsx` around lines 107 -
135, Replace the manual fetch inside the useEffect/loadStories with SWR and the
typed request() helper: import useSWR and request, call
useSWR('/api/alby/stories', () =>
request<Array<{id:number;title:string;avatar:string;videoUrl?:string}>>('/api/alby/stories')),
derive isLoading from the SWR status instead of setIsLoading, map the returned
payload (convert id to string and preserve title/avatar/videoUrl) and call
setStories or initialise stories from swr.data, and remove the loadStories async
function and its try/catch/finally block; ensure types match and handle the
empty or error case using SWR's error value.
alby/alby_oauth_service.go (1)

1374-1411: Wrap returned errors with context in GetStories.

Several returns propagate raw errors, which makes upstream debugging harder. Prefer contextual wrapping in this method.

♻️ Suggested change
  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
  if err != nil {
  	logger.Logger.WithError(err).Error("Error creating request to stories endpoint")
- 	return nil, err
+ 	return nil, fmt.Errorf("create stories request: %w", err)
  }
@@
  body, err := io.ReadAll(res.Body)
  if err != nil {
  	logger.Logger.WithError(err).WithFields(logrus.Fields{
  		"url": url,
  	}).Error("Failed to read response body")
- 	return nil, errors.New("failed to read response body")
+ 	return nil, fmt.Errorf("read stories response body: %w", err)
  }
@@
  if err := json.Unmarshal(body, &stories); err != nil {
  	logger.Logger.WithFields(logrus.Fields{
  		"body":  string(body),
  		"error": err,
  	}).Error("Failed to decode stories API response")
- 	return nil, err
+ 	return nil, fmt.Errorf("decode stories response: %w", err)
  }

As per coding guidelines, **/*.go: Use error wrapping with fmt.Errorf("context: %w", err) for debugging.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@alby/alby_oauth_service.go` around lines 1374 - 1411, GetStories currently
returns raw errors from http.NewRequestWithContext, client.Do, io.ReadAll and
json.Unmarshal which loses context; update each return to wrap the underlying
error with fmt.Errorf including a short contextual prefix (e.g.
fmt.Errorf("GetStories: creating request: %w", err), fmt.Errorf("GetStories:
performing request: %w", err), fmt.Errorf("GetStories: reading response body:
%w", err), fmt.Errorf("GetStories: decoding stories response: %w", err)). Also
replace the plain errors.New("failed to read response body") with a wrapped
error that includes the original read error, and keep the non-success status
error message descriptive (include status code and body) using fmt.Errorf.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@alby/alby_oauth_service.go`:
- Around line 1374-1411: GetStories currently returns raw errors from
http.NewRequestWithContext, client.Do, io.ReadAll and json.Unmarshal which loses
context; update each return to wrap the underlying error with fmt.Errorf
including a short contextual prefix (e.g. fmt.Errorf("GetStories: creating
request: %w", err), fmt.Errorf("GetStories: performing request: %w", err),
fmt.Errorf("GetStories: reading response body: %w", err),
fmt.Errorf("GetStories: decoding stories response: %w", err)). Also replace the
plain errors.New("failed to read response body") with a wrapped error that
includes the original read error, and keep the non-success status error message
descriptive (include status code and body) using fmt.Errorf.

In `@frontend/src/components/home/widgets/StoriesWidget.tsx`:
- Around line 229-238: The iframe currently renders any activeStory.videoUrl
because getYouTubeEmbedUrl falls back to the original URL; update StoriesWidget
to only render the iframe when the URL is recognized as YouTube by using the
output of getYouTubeEmbedUrl (or a new isYouTubeUrl helper) and verify embedUrl
is non-empty before creating the iframe; reference activeStory.videoUrl and
getYouTubeEmbedUrl (or a new isYouTubeUrl) and ensure the component skips
rendering the iframe for non-YouTube URLs so only validated YouTube embeds are
used.
- Around line 107-135: Replace the manual fetch inside the useEffect/loadStories
with SWR and the typed request() helper: import useSWR and request, call
useSWR('/api/alby/stories', () =>
request<Array<{id:number;title:string;avatar:string;videoUrl?:string}>>('/api/alby/stories')),
derive isLoading from the SWR status instead of setIsLoading, map the returned
payload (convert id to string and preserve title/avatar/videoUrl) and call
setStories or initialise stories from swr.data, and remove the loadStories async
function and its try/catch/finally block; ensure types match and handle the
empty or error case using SWR's error value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3b5a8727-71ed-4ecf-a346-d0d0eee3a766

📥 Commits

Reviewing files that changed from the base of the PR and between c0e8df6 and f4e5311.

📒 Files selected for processing (11)
  • alby/alby_oauth_service.go
  • alby/models.go
  • api/api.go
  • api/models.go
  • frontend/src/components/home/widgets/StoriesWidget.tsx
  • frontend/src/screens/Home.tsx
  • frontend/vite.config.ts
  • http/alby_http_service.go
  • http/http_service.go
  • tests/mocks/AlbyOAuthService.go
  • wails/wails_handlers.go

- Add contextual actions in the story dialog (update hub with version,
  open Alby Go in-app, install extension) keyed by kind or title
- Use preview stories when the stories API request fails
- Pass hub version from useInfo into the update link

Made-with: Cursor
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
frontend/src/components/home/widgets/StoriesWidget.tsx (1)

341-348: Use proper router navigation for internal links.

The internal route /appstore/alby-go uses a raw <a> tag which causes a full page reload instead of client-side navigation. Consider using React Router's Link component for SPA navigation.

♻️ Suggested fix
+import { Link } from "react-router-dom";

                     return (
-                      <a
+                      <Link
-                        href={action.url}
+                        to={action.url}
                         className="inline-flex h-9 items-center rounded-md border border-white/15 px-4 text-sm font-medium text-white transition-colors hover:bg-white/10"
                       >
                         {action.label}
-                      </a>
+                      </Link>
                     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/home/widgets/StoriesWidget.tsx` around lines 341 -
348, In StoriesWidget replace the raw anchor used for internal navigation:
detect when action.url is an internal path (e.g. startsWith('/')) and render
React Router's Link instead of the <a> so navigation is client-side; preserve
the existing className, children (action.label) and other props, and import Link
from 'react-router-dom'; keep using a normal <a> for external URLs (and add
target="_blank" rel="noopener noreferrer" if appropriate) to avoid breaking
external behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/components/home/widgets/StoriesWidget.tsx`:
- Around line 189-218: The component currently uses manual fetch inside
loadStories and local useEffect/useState (setStories, isLoading) — replace that
with SWR and the typed request() helper: remove loadStories and effect, call
useSWR('/api/alby/stories', () =>
request<Array<{id:number;title:string;avatar:string;videoUrl?:string;kind?:string}>>('/api/alby/stories'))
in StoriesWidget, map the returned data to the existing story shape (id as
string, title, avatar, videoUrl, kind) before passing to setStories or directly
render from the mapped data, use fallbackData or onError to fall back to
previewStories, and derive loading state from SWR (e.g., !data && !error)
instead of setIsLoading.

---

Nitpick comments:
In `@frontend/src/components/home/widgets/StoriesWidget.tsx`:
- Around line 341-348: In StoriesWidget replace the raw anchor used for internal
navigation: detect when action.url is an internal path (e.g. startsWith('/'))
and render React Router's Link instead of the <a> so navigation is client-side;
preserve the existing className, children (action.label) and other props, and
import Link from 'react-router-dom'; keep using a normal <a> for external URLs
(and add target="_blank" rel="noopener noreferrer" if appropriate) to avoid
breaking external behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 87ef3fc3-53be-42b1-b0e8-e9593b94b1d2

📥 Commits

Reviewing files that changed from the base of the PR and between f4e5311 and 27160e0.

📒 Files selected for processing (1)
  • frontend/src/components/home/widgets/StoriesWidget.tsx

Comment on lines +189 to +218
React.useEffect(() => {
const loadStories = async () => {
try {
const response = await fetch("/api/alby/stories");
if (!response.ok) {
throw new Error(`Failed to fetch stories: ${response.status}`);
}
const payload = (await response.json()) as Array<{
id: number;
title: string;
avatar: string;
videoUrl?: string;
}>;
const mappedStories = payload.map((story) => ({
id: String(story.id),
title: story.title,
avatar: story.avatar,
videoUrl: story.videoUrl,
kind: (story as { kind?: Story["kind"] }).kind,
}));
setStories(mappedStories);
} catch {
setStories(previewStories);
} finally {
setIsLoading(false);
}
};

void loadStories();
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use SWR and the typed request() helper per coding guidelines.

The current implementation uses raw fetch() and manual useState/useEffect for server state. Per project coding guidelines, this should use SWR for server state management and the typed request() helper from frontend/src/utils/request.ts for HTTP requests.

Using SWR provides built-in caching, automatic revalidation, request deduplication, and consistent loading/error patterns across the codebase.

♻️ Suggested refactor using SWR
+import useSWR from "swr";
+import { swrFetcher } from "src/utils/swr";

+type StoryResponse = {
+  id: number;
+  title: string;
+  avatar: string;
+  videoUrl?: string;
+  kind?: Story["kind"];
+};

 export function StoriesWidget() {
-  const [stories, setStories] = React.useState<Story[]>([]);
-  const [isLoading, setIsLoading] = React.useState(true);
   const [activeStory, setActiveStory] = React.useState<Story | null>(null);
   const [viewedIds, setViewedIds] =
     React.useState<Set<string>>(loadViewedStoryIds);
   const { data: info } = useInfo();

-  React.useEffect(() => {
-    const loadStories = async () => {
-      try {
-        const response = await fetch("/api/alby/stories");
-        if (!response.ok) {
-          throw new Error(`Failed to fetch stories: ${response.status}`);
-        }
-        const payload = (await response.json()) as Array<{
-          id: number;
-          title: string;
-          avatar: string;
-          videoUrl?: string;
-        }>;
-        const mappedStories = payload.map((story) => ({
-          id: String(story.id),
-          title: story.title,
-          avatar: story.avatar,
-          videoUrl: story.videoUrl,
-          kind: (story as { kind?: Story["kind"] }).kind,
-        }));
-        setStories(mappedStories);
-      } catch {
-        setStories(previewStories);
-      } finally {
-        setIsLoading(false);
-      }
-    };
-
-    void loadStories();
-  }, []);
+  const { data: storiesData, isLoading } = useSWR<StoryResponse[]>(
+    "/api/alby/stories",
+    swrFetcher,
+    { onError: () => {} } // silently handle errors, we fall back below
+  );
+
+  const stories = React.useMemo(() => {
+    if (!storiesData) {
+      return isLoading ? [] : previewStories;
+    }
+    return storiesData.map((story) => ({
+      id: String(story.id),
+      title: story.title,
+      avatar: story.avatar,
+      videoUrl: story.videoUrl,
+      kind: story.kind,
+    }));
+  }, [storiesData, isLoading]);

As per coding guidelines: "Use SWR for server state management" and "Use the typed request() helper in frontend/src/utils/request.ts for HTTP requests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/home/widgets/StoriesWidget.tsx` around lines 189 -
218, The component currently uses manual fetch inside loadStories and local
useEffect/useState (setStories, isLoading) — replace that with SWR and the typed
request() helper: remove loadStories and effect, call
useSWR('/api/alby/stories', () =>
request<Array<{id:number;title:string;avatar:string;videoUrl?:string;kind?:string}>>('/api/alby/stories'))
in StoriesWidget, map the returned data to the existing story shape (id as
string, title, avatar, videoUrl, kind) before passing to setStories or directly
render from the mapped data, use fallbackData or onError to fall back to
previewStories, and derive loading state from SWR (e.g., !data && !error)
instead of setIsLoading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant