feat: stories (#2172)

* feat: integrate Stories widget with backend endpoint

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

* feat(home): story modal CTAs and preview fallback

- 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

* feat(stories): polish modal, drop preview fallback

- Widen modal and put video edge-to-edge with overlay close button
- Drop verbose header and 'Watch on YouTube' button
- Remove previewStories fallback so widget hides until upstream API ships
- Tighten title line-height

* feat(stories): render cta from API instead of mapping by kind

Move CTA copy and URLs into the API response. Hub renders story.cta
directly, so adding new story types no longer requires a hub release.

* chore(csp): allow cdn.getalby-assets.com in img-src

* feat(stories): bump avatar size and add ring gap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): post-review cleanups

- Use react-router Link for in-tab CTA instead of plain <a>.
- Drop redundant www.youtube.com from frame-src (embeds always go through nocookie).
- Tighten stories endpoint status check from >= 300 to >= 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): address CodeRabbit feedback

- Switch StoriesWidget to useSWR + swrFetcher (project convention).
- Guard story iframe with isYouTubeUrl so non-YouTube urls never embed.
- Wrap GetStories errors with fmt.Errorf("...: %w", err).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): drop isYouTubeUrl guard

Stories are curated and always YouTube; the runtime check was
redundant. CSP frame-src still constrains the iframe source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): drop getYouTubeEmbedUrl, embed videoUrl as-is

The Alby API now sends canonical youtube-nocookie embed URLs with
autoplay/rel query strings (getAlby/getalby.com#2568), so the
runtime normalization is no longer needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): use w-16 instead of arbitrary w-[73px]

Match the avatar's size token; no magic numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): take videoId from API and assemble embed url locally

Pairs with getAlby/getalby.com#2568. The API now sends just the
YouTube videoId; the hub composes the canonical embed URL so the
domain/query-string format stays in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): treat 3xx as non-success, matching file convention

The other status checks in alby_oauth_service.go all use >= 300;
align GetStories so redirects don't slip through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): move viewed-storage key to constants, widen story button

Address review feedback:
- Centralize the localStorage key for viewed stories in localStorageKeys
  alongside the other keys.
- Widen the story button from w-16 to w-20 so "Alby Extension" fits on
  one line and matches the other titles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): bump story button to w-24 so titles fit one line

w-20 still wrapped "Alby Extension"; w-24 fits all current titles
without truncation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "chore(stories): bump story button to w-24 so titles fit one line"

This reverts commit 0b47438f50.

* chore(stories): split title words onto separate lines

Reserve two lines for every story title so avatars align regardless of
title length.

* chore(stories): align homeStoriesViewed key with sibling pattern

* chore(stories): fit titles on one line

* chore(stories): widen story button to w-21 for one-line titles

---------

Co-authored-by: René Aaron <rene@twentyuno.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saunter 2026-06-02 10:50:15 +02:00 committed by GitHub
parent 81b1b2f695
commit 23dccc6c6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 373 additions and 2 deletions

View file

@ -1369,6 +1369,52 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string,
}, nil
}
func (svc *albyOAuthService) GetStories(ctx context.Context) ([]Story, error) {
client := &http.Client{Timeout: 10 * time.Second}
url := fmt.Sprintf("%s/stories", albyInternalAPIURL)
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, fmt.Errorf("create stories request: %w", err)
}
setDefaultRequestHeaders(req)
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).Error("Failed to fetch stories from API")
return nil, fmt.Errorf("fetch stories: %w", err)
}
defer res.Body.Close()
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, fmt.Errorf("read stories response body: %w", err)
}
if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"status_code": res.StatusCode,
}).Error("stories endpoint returned non-success code")
return nil, fmt.Errorf("stories endpoint returned %d: %s", res.StatusCode, string(body))
}
var stories []Story
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, fmt.Errorf("decode stories response: %w", err)
}
return stories, nil
}
func setDefaultRequestHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "AlbyHub/"+version.Tag)

View file

@ -32,6 +32,7 @@ type AlbyOAuthService interface {
RemoveOAuthAccessToken() error
CreateLightningAddress(ctx context.Context, address string, appId uint) (*CreateLightningAddressResponse, error)
DeleteLightningAddress(ctx context.Context, address string) error
GetStories(ctx context.Context) ([]Story, error)
}
type CreateLightningAddressResponse struct {
@ -153,6 +154,20 @@ type ErrorResponse struct {
Message string `json:"message"`
}
type StoryCta struct {
Label string `json:"label"`
URL string `json:"url"`
OpenInNewTab bool `json:"openInNewTab"`
}
type Story struct {
ID int `json:"id"`
Title string `json:"title"`
Avatar string `json:"avatar"`
VideoID string `json:"videoId,omitempty"`
Cta *StoryCta `json:"cta,omitempty"`
}
type LSPChannelPaymentBolt11 struct {
Invoice string `json:"invoice"`
FeeTotalSat string `json:"fee_total_sat"`

View file

@ -756,6 +756,10 @@ func (api *api) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPe
return api.albySvc.GetChannelPeerSuggestions(ctx)
}
func (api *api) GetStories(ctx context.Context) ([]alby.Story, error) {
return api.albyOAuthSvc.GetStories(ctx)
}
func (api *api) GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error) {
return api.albyOAuthSvc.GetLSPChannelOffer(ctx)
}

View file

@ -22,6 +22,7 @@ type API interface {
DeleteLightningAddress(ctx context.Context, appId uint) error
ListChannels(ctx context.Context) ([]Channel, error)
GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPeerSuggestion, error)
GetStories(ctx context.Context) ([]alby.Story, error)
GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error)
ResetRouter(key string) error
ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPasswordRequest) error

View file

@ -0,0 +1,251 @@
import { XIcon } from "lucide-react";
import React from "react";
import { Link } from "react-router";
import useSWR from "swr";
import ExternalLink from "src/components/ExternalLink";
import { Button } from "src/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from "src/components/ui/dialog";
import { localStorageKeys } from "src/constants";
import { cn } from "src/lib/utils";
import { swrFetcher } from "src/utils/swr";
type StoryCta = {
label: string;
url: string;
openInNewTab: boolean;
};
type Story = {
id: string;
title: string;
avatar: string;
videoId?: string;
cta?: StoryCta;
};
type StoryApiResponse = {
id: number;
title: string;
avatar: string;
videoId?: string;
cta?: StoryCta;
};
function youTubeEmbedUrl(videoId: string) {
return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0`;
}
function loadViewedStoryIds(): Set<string> {
try {
const raw = localStorage.getItem(localStorageKeys.homeStoriesViewed);
if (!raw) {
return new Set();
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
return new Set();
}
}
function persistViewedStoryIds(ids: Set<string>) {
try {
localStorage.setItem(
localStorageKeys.homeStoriesViewed,
JSON.stringify([...ids])
);
} catch {
// ignore quota / private mode
}
}
function StoryAvatar({ story, viewed }: { story: Story; viewed: boolean }) {
return (
<div
className={cn(
"relative box-border flex size-16 shrink-0 items-center justify-center rounded-full border-2 p-0.5",
viewed ? "border-accent" : "border-primary"
)}
>
<div className="relative flex size-full items-center justify-center overflow-hidden rounded-full bg-white dark:bg-muted">
<img
src={story.avatar}
alt={`${story.title} story`}
className="size-full rounded-full object-cover"
/>
</div>
</div>
);
}
export function StoriesWidget() {
const { data, error, isLoading } = useSWR<StoryApiResponse[]>(
"/api/alby/stories",
swrFetcher
);
const [activeStory, setActiveStory] = React.useState<Story | null>(null);
const [viewedIds, setViewedIds] =
React.useState<Set<string>>(loadViewedStoryIds);
const stories = React.useMemo<Story[]>(
() =>
error || !data
? []
: data.map((story) => ({
id: String(story.id),
title: story.title,
avatar: story.avatar,
videoId: story.videoId,
cta: story.cta,
})),
[data, error]
);
const markStoryViewed = React.useCallback((storyId: string) => {
setViewedIds((prev) => {
if (prev.has(storyId)) {
return prev;
}
const next = new Set(prev);
next.add(storyId);
persistViewedStoryIds(next);
return next;
});
}, []);
if (!isLoading && stories.length === 0) {
return null;
}
return (
<>
<Card className="overflow-hidden rounded-[14px] shadow-none">
<CardHeader className="px-6 pb-0">
<CardTitle className="text-base font-semibold">Stories</CardTitle>
</CardHeader>
<CardContent className="px-0 py-0">
<div className="flex gap-3 overflow-x-auto px-6 pb-1">
{isLoading && (
<span className="text-sm text-muted-foreground">
Loading stories...
</span>
)}
{!isLoading &&
stories.map((story) => {
const viewed = viewedIds.has(story.id);
return (
<button
key={story.id}
type="button"
onClick={() => {
markStoryViewed(story.id);
setActiveStory(story);
}}
className="flex w-21 shrink-0 flex-col items-center gap-2 text-center"
>
<StoryAvatar story={story} viewed={viewed} />
<span
className={cn(
"w-full truncate text-xs leading-tight",
viewed
? "font-medium text-muted-foreground"
: "font-semibold text-foreground"
)}
>
{story.title}
</span>
</button>
);
})}
</div>
</CardContent>
</Card>
<Dialog
open={!!activeStory}
onOpenChange={(open) => !open && setActiveStory(null)}
>
<DialogContent
showCloseButton={false}
className="w-[95vw] max-w-[min(95vw,calc((90vh-80px)*16/9))] sm:max-w-[min(95vw,calc((90vh-80px)*16/9))] max-h-[90vh] overflow-hidden border-0 bg-zinc-950 p-0 text-white sm:rounded-2xl"
>
{activeStory && (
<div className="flex flex-col">
<DialogTitle className="sr-only">{activeStory.title}</DialogTitle>
<DialogDescription className="sr-only">
Watch the latest update
</DialogDescription>
{activeStory.videoId && (
<div className="relative aspect-video w-full overflow-hidden rounded-t-2xl bg-black [transform:translateZ(0)]">
<iframe
className="absolute inset-0 size-full"
src={youTubeEmbedUrl(activeStory.videoId)}
title={activeStory.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
<DialogClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-3 top-3 z-10 rounded-full bg-black/60 text-white backdrop-blur hover:bg-black/80 hover:text-white"
>
<XIcon className="size-5" />
<span className="sr-only">Close story</span>
</Button>
</DialogClose>
</div>
)}
{activeStory.videoId && (
<div className="flex items-center justify-between gap-3 px-6 py-4">
<div className="min-w-0">
<div className="truncate text-base font-semibold text-white">
{activeStory.title}
</div>
</div>
{activeStory.cta && (
<div className="flex items-center gap-2">
{activeStory.cta.openInNewTab ? (
<ExternalLink
to={activeStory.cta.url}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{activeStory.cta.label}
</ExternalLink>
) : (
<Link
to={activeStory.cta.url}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{activeStory.cta.label}
</Link>
)}
</div>
)}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View file

@ -6,6 +6,7 @@ export const localStorageKeys = {
supportAlbySidebarHintHiddenUntil: "supportAlbySidebarHintHiddenUntil",
aiHeroDismissed: "aiHeroDismissed",
cardsHeroDismissed: "cardsHeroDismissed",
homeStoriesViewed: "homeStoriesViewed",
};
export const ONCHAIN_DUST_SATS = 1000;

View file

@ -23,6 +23,7 @@ import { LightningMessageboardWidget } from "src/components/home/widgets/Lightni
import { NewArrivalsWidget } from "src/components/home/widgets/NewArrivalsWidget";
import { NodeStatusWidget } from "src/components/home/widgets/NodeStatusWidget";
import { OnchainFeesWidget } from "src/components/home/widgets/OnchainFeesWidget";
import { StoriesWidget } from "src/components/home/widgets/StoriesWidget";
import { SupportAlbyWidget } from "src/components/home/widgets/SupportAlbyWidget";
import { WhatsNewWidget } from "src/components/home/widgets/WhatsNewWidget";
import { SearchInput } from "src/components/ui/search-input";
@ -44,6 +45,7 @@ function Home() {
/>
<div className="columns-1 lg:columns-2 gap-3 *:mb-3 *:break-inside-avoid">
<OnboardingChecklist />
<StoriesWidget />
<WhatsNewWidget />
<LatestUsedAppsWidget />
<NewArrivalsWidget />

View file

@ -98,7 +98,7 @@ const insertDevCSPPlugin: Plugin = {
"<head>",
`<head>
<!-- DEV-ONLY CSP - when making changes here, also update the CSP header in http_service.go (without the nonce!) -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self' ${DEVELOPMENT_NONCE}; img-src 'self' https://uploads.getalby-assets.com https://getalby.com; connect-src 'self' https://api.getalby.com https://getalby.com https://zapplanner.albylabs.com wss://relay.getalby.com wss://relay2.getalby.com; frame-src https://embed.bitrefill.com" />`
<meta http-equiv="Content-Security-Policy" content="default-src 'self' ${DEVELOPMENT_NONCE}; img-src 'self' https://uploads.getalby-assets.com https://cdn.getalby-assets.com https://getalby.com; connect-src 'self' https://api.getalby.com https://getalby.com https://zapplanner.albylabs.com wss://relay.getalby.com wss://relay2.getalby.com; frame-src https://embed.bitrefill.com https://www.youtube-nocookie.com" />`
);
},
},

View file

@ -33,6 +33,7 @@ func (albyHttpSvc *AlbyHttpService) RegisterSharedRoutes(readOnlyApiGroup *echo.
e.GET("/api/alby/info", albyHttpSvc.albyInfoHandler)
e.GET("/api/alby/rates/:currency", albyHttpSvc.albyBitcoinRateHandler)
e.GET("/api/alby/currencies", albyHttpSvc.albyCurrenciesHandler)
e.GET("/api/alby/stories", albyHttpSvc.albyStoriesHandler)
readOnlyApiGroup.GET("/alby/me", albyHttpSvc.albyMeHandler)
fullAccessApiGroup.POST("/alby/link-account", albyHttpSvc.albyLinkAccountHandler)
fullAccessApiGroup.POST("/alby/auto-channel", albyHttpSvc.autoChannelHandler)
@ -109,6 +110,17 @@ func (albyHttpSvc *AlbyHttpService) albyCurrenciesHandler(c echo.Context) error
return c.JSON(http.StatusOK, currencies)
}
func (albyHttpSvc *AlbyHttpService) albyStoriesHandler(c echo.Context) error {
stories, err := albyHttpSvc.albyOAuthSvc.GetStories(c.Request().Context())
if err != nil {
logger.Logger.WithError(err).Error("Failed to get stories")
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to get stories: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, stories)
}
func (albyHttpSvc *AlbyHttpService) albyCallbackHandler(c echo.Context) error {
code := c.QueryParam("code")

View file

@ -66,7 +66,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
ContentTypeNosniff: "nosniff",
XFrameOptions: "DENY",
ContentSecurityPolicy: "default-src 'self'; img-src 'self' https://uploads.getalby-assets.com https://getalby.com; connect-src 'self' https://api.getalby.com https://getalby.com https://zapplanner.albylabs.com wss://relay.getalby.com wss://relay2.getalby.com; frame-src https://embed.bitrefill.com",
ContentSecurityPolicy: "default-src 'self'; img-src 'self' https://uploads.getalby-assets.com https://cdn.getalby-assets.com https://getalby.com; connect-src 'self' https://api.getalby.com https://getalby.com https://zapplanner.albylabs.com wss://relay.getalby.com wss://relay2.getalby.com; frame-src https://embed.bitrefill.com https://www.youtube-nocookie.com",
ReferrerPolicy: "no-referrer",
}))
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{

View file

@ -655,6 +655,34 @@ func (_c *MockAlbyOAuthService_GetMe_Call) RunAndReturn(run func(ctx context.Con
return _c
}
// GetStories provides a mock function for the type MockAlbyOAuthService
func (_mock *MockAlbyOAuthService) GetStories(ctx context.Context) ([]alby.Story, error) {
ret := _mock.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for GetStories")
}
var r0 []alby.Story
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context) ([]alby.Story, error)); ok {
return returnFunc(ctx)
}
if returnFunc, ok := ret.Get(0).(func(context.Context) []alby.Story); ok {
r0 = returnFunc(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]alby.Story)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = returnFunc(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUserIdentifier provides a mock function for the type MockAlbyOAuthService
func (_mock *MockAlbyOAuthService) GetUserIdentifier() (string, error) {
ret := _mock.Called()

View file

@ -465,6 +465,17 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
return WailsRequestRouterResponse{Body: currencies, Error: ""}
case "/api/alby/stories":
stories, err := app.api.GetStories(ctx)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"route": route,
"method": method,
"body": body,
}).WithError(err).Error("Failed to get stories")
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
return WailsRequestRouterResponse{Body: stories, Error: ""}
case "/api/apps":
switch method {
case "POST":