diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index 445412a6..bba77dae 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -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) diff --git a/alby/models.go b/alby/models.go index 2fe5e6b3..9c216846 100644 --- a/alby/models.go +++ b/alby/models.go @@ -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"` diff --git a/api/api.go b/api/api.go index 688061ed..c43a5fbe 100644 --- a/api/api.go +++ b/api/api.go @@ -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) } diff --git a/api/models.go b/api/models.go index 1761b40d..0f0cf279 100644 --- a/api/models.go +++ b/api/models.go @@ -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 diff --git a/frontend/src/components/home/widgets/StoriesWidget.tsx b/frontend/src/components/home/widgets/StoriesWidget.tsx new file mode 100644 index 00000000..8c585c2e --- /dev/null +++ b/frontend/src/components/home/widgets/StoriesWidget.tsx @@ -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 { + 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) { + try { + localStorage.setItem( + localStorageKeys.homeStoriesViewed, + JSON.stringify([...ids]) + ); + } catch { + // ignore quota / private mode + } +} + +function StoryAvatar({ story, viewed }: { story: Story; viewed: boolean }) { + return ( +
+
+ {`${story.title} +
+
+ ); +} + +export function StoriesWidget() { + const { data, error, isLoading } = useSWR( + "/api/alby/stories", + swrFetcher + ); + const [activeStory, setActiveStory] = React.useState(null); + const [viewedIds, setViewedIds] = + React.useState>(loadViewedStoryIds); + + const stories = React.useMemo( + () => + 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 ( + <> + + + Stories + + +
+ {isLoading && ( + + Loading stories... + + )} + {!isLoading && + stories.map((story) => { + const viewed = viewedIds.has(story.id); + return ( + + ); + })} +
+
+
+ + !open && setActiveStory(null)} + > + + {activeStory && ( +
+ {activeStory.title} + + Watch the latest update + + + {activeStory.videoId && ( +
+