VaultFeed - full changelog

= 0.9.4 =
Fixes found on clean installs of 0.9.3, on a live X feed and in the reviews of 0.9.4 itself: the feed wizard, the post-style and layout lists, dark mode, the classic widget, right-to-left sites, refresh intervals, account warnings and the readme. No database schema changes; the update corrects the reconnect dates stored for X, TikTok, Google reviews, Snapchat, LinkedIn, Pinterest and Threads accounts.

* Fix: Settings > Theme Colors undid dark mode on every site whose Settings page had been saved once. The form pre-fills all eight colours with the built-in light defaults, so each save stored them, and the stylesheet printed them over the dark palette: dark text (#1a1a1a) on a dark card (1.19:1) in Default, Card, Magazine, Bubble, Compact, Story and the Wall layout. A colour equal to its default is no longer stored or printed (sites that already stored them are corrected when the page is drawn, with no change to the database), and a colour the site owner really chose now applies to the light palette only: a browser not set to dark, and a feed set to Light.
* Fix: the Dark colour preset with the feed's Dark-mode setting on Light (1.02:1), and date, likes, comments and the attribution under every colour preset (the browser's meta colour on the preset's background: 2.1 to 2.97:1). A preset's meta and muted colours now come from its own text colour, and the light reset leaves a preset alone.
* Fix: on a page the theme does not turn dark, an Auto feed in a dark browser drew light text straight on the white page (the feed header, slider arrows, ticker, timeline date marker). An Auto feed is now a dark panel of its own in a dark browser, as a feed set to Dark is, and a feed set to Light a light one. The Follow and Load More buttons are now #2563eb (white on #3b82f6 was 3.68:1), the search and e-mail inputs have colours of their own in every context (the placeholder was 3.2:1, typed text could be white on white), the e-mail panel and its messages follow the feed's palette, the Facebook and LinkedIn share buttons reach 4.5:1, a feed set to Dark no longer paints the preset's header navy, and a Modern post keeps its dark card under a preset.
* Fix: the classic VaultFeed widget never appeared in Appearance > Widgets (its class asked for registration from inside the action that was already running). It is registered now, and its instances are deleted with the plugin.
* Fix: on right-to-left sites no feed stylesheet was loaded: WordPress was asked to swap feed.css for feed.min-rtl.css, which does not exist. feed-rtl.css (the overrides only) now loads after feed.css.
* Fix: a default of "Pagination: None" in Settings was shown as a blank menu in the new-feed wizard and saved as Button (a Load More button). The wizard menu offers None.
* Fix: deactivating or deleting the plugin left every per-feed background refresh event in WP-Cron (they are scheduled with the feed id as their argument, which wp_clear_scheduled_hook() does not match); wp_unschedule_hook() removes them.
* Fix: the widget's posts-per-page setting accepted any number; it is held to 1-50 as the shortcode and block are.
* Fix: a visitor's click on a feed's dark-mode toggle is stored for the whole site, and every feed applied it on load, so feeds the owner set to Dark or Light turned light or dark too. Only a feed set to Toggle reads the stored choice now. In a dark browser the toggle's first click changed "auto" to "dark" on a feed that already looked dark, and its aria-pressed said "not pressed"; the toggle now starts from what the visitor sees (the browser's setting until the first click) and follows the browser while the page is open.
* Fix: right-to-left sites. feed-rtl.css, which 0.9.4 loads for the first time, set flex-direction: row-reverse on the header, post meta, share bar, source tabs, list layout, ticker items, post window and lightbox controls; under dir="rtl" a row already starts on the right, so this put them back in left-to-right order. Only the physical left/right swaps remain. The iFrame embed page printed no dir attribute, loaded the unminified stylesheet and no overrides file; it now has dir, the .min files and feed-rtl.
* Fix: in a classic theme's widget area (Twenty Twenty-One's `.widget a:link`, 0,2,1) and against rules such as `input[type=email]` (0,1,1), a theme's link and field colours outranked the feed's own one-class rules: text-only posts and the ticker 1.34:1, the Follow button 2.39:1 and typed text 1.1:1 on the feed's dark panel. The feed's link and field colours are restated at a higher specificity.
* Fix: the dark-mode toggle sat on the right end of the Follow button; the header now keeps space for it (inset-inline-end, so it moves in right-to-left). The Overlap style placed its caption at the bottom of the whole post, over the share buttons, and on a text-only post or a review laid caption and meta badge over the post's own text; the caption now lies on the picture only, and posts without one are laid out as in the Default style. On a Wall card without a picture the source label sat on the text; the carousel arrows sat on the first and last visible post's caption (they now have gutters).
* Fix: Overlap style, posts with a picture. A caption longer than its room was cut wherever the room ended, through the middle of a line; the room is now rounded down to whole lines, and the caption ends on its last whole line with an ellipsis (line-clamp, with the -webkit-box fallback, sized by feed.js to the room left). In a narrow card with every meta field on, the meta badge wraps taller than the fixed reserve above the caption, and the caption ran under it; the badge now takes its real height in the card's grid and the caption keeps below it (a card under 180 px puts the badge under the picture). A centred call to action sat on the caption and a top-corner one on the badge; the caption keeps to the half of the picture below a centred button and the badge starts below a top one. The call to action never wrapped, so a label wider than a narrow card ran past its edge and was cut off ("Visit the shop", 102 px, in a 108 px card at five columns); it is now at most the picture's width less its margins, its side padding shrinks in a narrow card and a longer label wraps, and the caption and the badge keep clear of its real height. In the Stories layout the rule that gives the picture's link its 9:16 shape also matched the call to action, which was drawn as a 102 x 181 px box for one line of text; it now applies to the picture's link only. With several meta fields on and a long author name (30 characters), the meta badge took 4-5 rows of a 3-column card: the caption had no room left and was not shown, a centred call to action covered the badge, and on a phone a short picture left the badge hanging off it. The badge is now at most the card's width (a long name or @username ends in an ellipsis), feed.js moves a badge that leaves the caption no room, or that would meet a centred call to action, under the picture, and a picture carrying a call to action is at least as tall as the button. Until feed.js has measured a call to action (or with no script) it stays on one line, so a wrapped label cannot sit on the caption. A caption holding emoji or CJK text ended without its ellipsis (feed.js counted one of its lines twice); lines are now told apart by their vertical middle. The caption's scrim faded in over its whole height, so over a white picture its first lines were at 1.3-3:1; the fade now stays in the caption's top padding and every line sits on .72 black (9.2:1). The "Show more" button of an expandable caption was clamped out of sight on the picture and could not be clicked; it is hidden there (the post window and the lightbox show the whole caption).
* Fix: a long word or URL in a caption ran past the card's edge in every post style (a 493 px line in a 140 px card); captions now wrap anywhere (overflow-wrap: anywhere).
* Fix: a Ticker feed's no-wrap track set the width of the boxes around it: in a grid widget area (Twenty Twenty-One's footer) the column grew to about 2,900 px, and in a Row block the page to 5,942 px. The widget area no longer grows (.vaultfeed-widget min-width: 0) and the ticker contains its own width (contain: inline-size layout style; the earlier contain: inline-size was overridden by a later rule).
* Fix: the light palette's meta and muted greys (#666, #6b7280) were 4.33:1 and 3.65:1 on a tinted page such as Twenty Twenty-One's #d1e4dd; they are #595959 and #4b5563 (the old values stored by a Settings save count as defaults). A feed set to Light has its light panel in a light browser as well, so it reads on a theme that is always dark; the readme says to set a feed to Dark on such a theme.
* Fix: deleting the plugin left its widget ids in each widget area's list (sidebars_widgets); they are removed.
* Fix: a feed's Refresh interval of 1 hour, the wizard's default choice, was ignored. The page and the background refresh read 3600 seconds as "use the source's default", so a feed set to 1 hour refreshed every 15 minutes on X (four times the X API reads, which X bills per use), Bluesky, Mastodon, Reddit, Snapchat, Twitch and Vimeo, every 30 minutes on LinkedIn and Threads, every 5 minutes for this site's own posts, and every 2 hours on Yelp. A feed now refreshes at the interval it shows, wherever it is read (the page, the background refresh, the stale-feed check, wp vaultfeed cache). A feed with no interval of its own (null) uses its source's, and the wizard offers that as a choice that names it ("The source's default (15 minutes)"); before, such a feed was shown as 1 hour. An interval shorter than the source allows (for example 30 minutes for YouTube, Google reviews and Pinterest, 1 hour for Yelp) is not offered and, set elsewhere (an import, an older setting), runs at the source's minimum. An aggregated feed shows no interval of its own: each of its feeds refreshes at its own. Feeds saved at 1 hour keep 1 hour: a feed of this site's own posts saved at 1 hour now shows a new post within the hour (choose a shorter interval to see it sooner).
* Fix: a freshly connected X account showed "Expiring soon", the admin notice asked to reconnect it and the token-expiry e-mail was sent, although the account renews itself. X's access token lasts two hours, and its renewal time was stored as the date by which the account had to be reconnected. TikTok, Google reviews and Snapchat accounts were flagged the same way (YouTube was fixed in 0.9.1), and LinkedIn and Pinterest accounts that cannot renew themselves, and Threads accounts, never warned. One rule now decides on the Accounts page, in the admin notice, the REST API, WP-CLI and the e-mail. An account needs a reconnect only when it has no refresh token (by the time its access token ends), when its refresh token's reported end is near (TikTok, LinkedIn, Pinterest; Google's six-month rule), when the platform refused a renewal (revoked access, a refused refresh token), or when renewing has failed for a day after the access token ran out. A passing error while renewing (a timeout, a 5xx, a 429) only pauses, and a renewal that loses a race to another at the same moment (X replaces its refresh token on every renewal) is not taken for a refusal. A renewal that works clears the warning; for Vimeo, Yelp, Reddit, Twitch and Snapchat, which never renew, a fetch that works clears it. The dates stored for existing X, TikTok, Google reviews, Snapchat, LinkedIn, Pinterest and Threads accounts are corrected on update, without a reconnect.
* Fix: a revoked connection now asks for a reconnect on every source that signs in except Twitch (Mastodon and the site's own posts have nothing to revoke), as YouTube's has since 0.9.1. This covers a renewal the platform refuses (X, TikTok, Google reviews, LinkedIn, Pinterest, Threads, Bluesky); an access token the platform calls invalid on a fetch, which is renewed at once and asks for a reconnect when the renewal is refused or the account has no refresh token (X and X mentions, TikTok, LinkedIn, Pinterest, Bluesky; Google reviews and YouTube already renewed on a 401), so access revoked weeks before a 60-day LinkedIn or 30-day Pinterest token ends is reported at the next fetch; and an account the platform rejects on a fetch (Facebook, Instagram, Instagram tagged, Threads, Vimeo, Yelp; the app credentials of Reddit and Snapchat). A changed or revoked Twitch app secret shows Twitch's answer in the feed list but does not ask for a reconnect. A renewal that works asks for nothing, and a quota or rate-limit answer is never taken for a rejection.
* Fix: an X mentions feed never renewed the two-hour X token it uses (it looked for a token of its own), so it stopped loading two hours after the account was connected.
* Fix: Facebook's and Instagram's 60-day user token and Threads' 60-day token were never renewed (Threads only in its last minute). They are now renewed once a day before the reconnect warning would show (Threads from eight days before its end, Facebook and Instagram from about two weeks before), as long as the platform extends them. Snapchat accounts no longer run a renewal that does nothing before every fetch.
* Fix: a feed first fetched by a page view, not by the background refresh, kept "never synced" in the feed list and was skipped by the stale-feed check. Every live fetch now records its time and result.
* The readme's FAQ said a feed is paused when its token expires; it now says what happens.
* Fix: an X post with a video or GIF showed as text only. X sends a file address only for photos, and the post's media was kept only when it had one. Videos and GIFs now show their picture with a play button, and play in the lightbox from X's best MP4 (VaultFeed now asks X for its video files). An Instagram video without a file (Instagram leaves it out of a video flagged for copyright) is kept the same way, including in a carousel. An Instagram tagged video no longer tries to play its thumbnail, and a Threads video shows its thumbnail instead of drawing the video file as a picture. A video that only has its picture opens the post on the platform. A carousel whose first item is a video shows its picture.
* Fix: X posts showed raw t.co links, one of them pointing at the picture or video the card already shows. That link is now removed, and every other link shows as X shows it (for example ccgnomes.com/vaultfeed/dark…). In a caption under a picture (every style but Overlap, whose caption lies on the picture) it opens the real address (rel="nofollow noopener noreferrer"), a quoted post's link included; the card of a post without a picture links to the post. The RSS feed, the JSON Feed, outbound webhooks and the REST API carry each link's real address. Bluesky links shortened by the app are linked to the address in the post's link data, and a Mastodon link shows as Mastodon shows it, linked; Mastodon paragraphs now keep a line break between them.
* Fix: the feed wizard's live preview fetched from the platform on every preview (each option changed, each Refresh) and again on the feed's first page view; on X, which bills each read, every preview cost money. A preview now uses the feed's cached posts, or one fetch kept for the feed's refresh interval whatever the display settings, and saving a new feed keeps that fetch as its first cache. A fetch that found no posts (none yet, a failure, a rate limit) is kept too, for ten minutes or until the platform's resume time, and is not stored as the new feed's cache; once the account is reconnected the preview fetches again at once. The preview also renews an expired token first, and shows the platform's reason when it has no posts.
* Fix: a platform's "slow down" answer now pauses the feed on every source: until the platform's own resume time when it gives one, otherwise for the source's fixed pause. VaultFeed reads HTTP 429, Retry-After and the platform's reset header (X, Reddit, Twitch, Bluesky, Mastodon, Yelp, Vimeo, Pinterest). It also recognises Meta's rate-limit codes (4, 17, 32, 613, 80000-80009), Google's quota reasons (YouTube's daily quota resumes at midnight Pacific time), and TikTok's, Yelp's and Bluesky's own. Before, X's reset time was read and ignored (the pause was a fixed hour), most sources were never paused and were called again every few minutes, and any YouTube 403 (a disabled API, a forbidden playlist) paused the feed for a day. The cached posts keep showing meanwhile.
* Fix: a failed sign-in shows the platform's own reason, sanitised (no codes, secrets or tokens), for every source that signs in with OAuth, as YouTube's has since 0.9.1: X, TikTok, Google reviews, Facebook, Instagram, Snapchat, Threads, LinkedIn and Pinterest. Sources that connect with a key or an app password (Twitch, Vimeo, Yelp, Bluesky) show VaultFeed's own message.
* Fix: the feed list and Analytics show why a sync failed, for every source, using the platform's short reason instead of "The platform returned no posts."
* Fix: the feed wizard had no Dark mode setting (it could only be set through the REST API). Step 4 now offers it (Automatic, Visitor can switch, Always dark, Always light, Dark at night), an edit shows the saved choice, and the live preview follows it.
* Fix: a post without a picture (a text-only X post, for example) printed its text twice in the Card style, lost its inner padding on themes that reset padding, and showed a lighter strip under the text in a stretched grid row. Its text now shows once, in its card, in every post style and for every source (a Facebook event or a post whose picture failed included). The card is padded, fills the tile in the grid, carousel, slider, highlight and list layouts, and a long word or address breaks at the card's edge.
* New: a feed's Text-only posts setting (the wizard, the REST API, wp vaultfeed feed create --text-posts) chooses how a post without a picture shows its text: Shorten to 5 lines with Show more (the default) shows five lines and a Show more / Show less button that opens and closes the rest in place (a real button with aria-expanded, also on posts added by Load more), or Show the full text shows all of it. Before, the card showed the first 30 words with no way to read the rest. A feed saved before this setting whose captions show in full (caption length 0) shows the full text; any other shows five lines. In a box of a picture's shape (the Stories layout, the Story style, the Highlight hero) a longer text scrolls inside the box. The card's link is now named by the post's text for screen readers (it was "View post").

* Fix: text was unreadable in a dark browser and in a feed set to Dark. Card kept a white surface under light text, Clean a #555 caption on a dark card, Bubble a pale-blue surface under light text, and every colour preset (Ocean, Rose, Slate, Nature) a dark card under its own dark text; the Wall layout kept a white card under light text, the carousel arrows a light glyph on a white pill, and the post detail window a white page under light text. A feed set to Dark (also Toggle and Schedule) kept the light palette's #1a1a1a text on its navy panel (1.09:1) in every style. The surfaces each style paints are now variables that the dark contexts swap, a colour preset keeps its own palette in light and dark, a feed set to Light stays light under a dark browser (with its own light panel), and the preset link colours (Ocean, Nature) and the timeline date marker meet 4.5:1. Every text on every post style, colour preset, feed setting (Auto, Light, Dark, Toggle) and layout is measured by the verification gate in a light browser and in a dark one, on a site whose Settings were never saved, after they were saved through the form, and with every default stored as 0.9.3 stored them.
* Fix: the shortcode, the block, the widget and the Settings page each kept their own list of six layouts although the wizard saves eleven, so layout="highlight" on a shortcode, or a Highlight, Stories, Ticker, Timeline or Wall default in Settings, was dropped without a word. They now use DisplaySettings::ENUMS['layout'] (the wizard menu, the Settings and widget menus and the block-editor list come from the same eleven); the hover, pagination, visibility, image-ratio, dark-mode and e-mail-position lists the block and Settings page kept by hand are the shared ones too.
* Fix: the package no longer ships about twenty files with Windows line endings; the build converts text files to LF and fails if one is left.
* Fix: the wizard showed the display-settings and live-preview panel on steps 1 to 3, and other hidden parts (feed types, accounts, Back and Next buttons) at the same time. A display rule on the step-4 grid, on .button and on option rows outranked the browser's rule for the hidden attribute. Inside the wizard, hidden now always wins.
* Fix: the Feed saved! step showed an empty iFrame Embed box. The script that fills it waited for a step-change event that was never sent and a feed id that was never stored, and the save handler wrote to an element that does not exist. The wizard now sends the event and stores the id, and the box shows the embed code.
* Fix: the live preview could sit on Loading preview until Refresh Preview was clicked. A preview waits for the platform, and a slow or unreachable platform held the request open for as long as every call's own limit (20 s each) allowed, with no limit in the browser. The server now gives all of a preview's outbound calls one 15-second budget and answers with an error; the browser stops waiting at 25 seconds and says so; an older, slower answer can no longer overwrite the newest preview.
* Fix: the preview's Tablet and Mobile buttons only narrowed the box, which does not change the viewport the feed's column rules read. They now apply the feed's own tablet and phone column settings.
* Fix: only five of the ten post styles saved. The wizard listed Modern, Magazine, Bubble, Compact and Story, but the list the save accepts had five entries, so the choice was dropped and the feed kept its old style. There is now one list, DisplaySettings::ENUMS['template_id'], used by the wizard menu, the save, imports, the shortcode, the block, the widget and the default-style setting; the block editor, widget and Settings page menus offer all ten.
* Fix: readme.txt said all nine layouts; the wizard offers eleven (Highlight and Stories were missing from the list).
* Fix: search and the source tabs set the hidden attribute on a post, but a layout's display rule kept it on screen: every post in List, Masonry and Waterfall, a post without a picture in Grid, Carousel, Slider and Highlight ("2 posts found" with all four shown). Wall cards and ticker items were never filtered at all. A hidden item now stays hidden in every layout, Wall and Ticker filter too (a ticker post is counted once), and no timeline entry, timeline date or Highlight hero is left empty on screen.
* Fix: the same feed twice on a page (a shortcode and a widget, two blocks) repeated its ids, also from the render cache, and the second copy's Load more added its posts to the first copy. The second and later copy get ids of their own (-2, -3), and Load more, the skip link and Show more work per copy. The search box's id was the same in every feed, and two Social Proof widgets repeated one id; neither repeats now.
* Fix: the e-mail sign-up field had only a placeholder; it has a label for screen readers. The feed's messages for screen readers and its slider dots (posts loaded, posts found, slide numbers, the skip link, the post window) were English on every site; they are translated now.

= 0.9.3 =
Feeds on quiet pages now refresh on schedule; the stale-feed email is sent once per problem and says why. No database changes.

* Fix: a feed was refreshed only when a visitor opened a page whose cached posts had expired; the hourly cache sweep refreshed only rows a visitor had already marked stale. A feed on a page nobody opened was never refreshed, and the hourly health check then e-mailed "Stale feed alert" about it every hour although its account and fetches were fine. The sweep now also refreshes feeds whose cache has expired, with no page view. It refreshes a feed only when the cache row its refresh writes is due (missing, marked stale or expired), so an expired row the refresh never renews (a widget showing another number of posts, settings changed since) no longer queues a fetch every hour. A refresh waiting out a platform rate limit still makes no call.
* Fix: the stale-feed e-mail is sent only when VaultFeed's own refresh has failed: a feed found overdue gets a refresh first and is reported only if it is still overdue at the next hourly check (so a site whose WP-Cron did not run during a quiet spell gets no false alarm). Each feed is reported once per problem and again only after it has refreshed successfully and failed again. The e-mail gives each feed's last error and says visitors still see the last posts fetched. A feed counts as overdue after three times its cache lifetime, or its lifetime plus two hours if that is longer; times are read as UTC.

= 0.9.2 =
Two display fixes found on a live site. No database changes.

* Fix: a feed's "Custom CSS" setting now reaches the front end. It was attached to the feed stylesheet with wp_add_inline_style() while the feed rendered; on a block theme the page content renders before wp_head(), before that stylesheet is registered, and WordPress drops inline CSS for a handle that is not registered yet. The CSS now gets its own style handle, registered when the feed renders, so WordPress prints it in the head (block themes) or with the footer styles (classic themes and page builders), after the feed stylesheet. It is stripped of all markup on save and again before output. A feed served from the render cache queues its CSS too.
* Fix: the highlight layout's hero no longer shrinks on wide pages. Its media had a 16:9 ratio and a 480px height cap (280px on phones); together they made the browser narrow the media (853px of a 1140px column) and leave an empty strip beside it. The cap is gone: the hero spans the feed's width and its height follows the 16:9 (4:3 on phones) ratio.

= 0.9.1 =
Fixes found on a live YouTube feed. Adds a small data migration (YouTube reconnect date only).

* Fix: YouTube, Vimeo, TikTok and Twitch videos now play in the lightbox and the post window, using each platform's official embedded player. They were given to a plain video element as a page address, which a browser cannot play. Nothing is requested from these services until a visitor clicks a video; the player address is built on the server from a sanitised ID and checked again in the script against a fixed host list. Other sources are unchanged.
* Fix: Bluesky videos play where the browser supports them natively and otherwise show a link to the post. No video library is bundled.
* Fix: closing the lightbox or post window now stops the video.
* Fix: a freshly connected YouTube account no longer shows "Expiring soon". The warning follows the refresh token, the access token is renewed automatically (before, a YouTube feed stopped loading about an hour after connecting and failed on every refresh), a revoked connection raises the reconnect warning, scheduled refreshes wait out a quota-exceeded answer, and 0.9.0 accounts are corrected on update.
* Fix: every connected account whose access token expires (YouTube, X, TikTok and others) is renewed before a feed is fetched; until now only the Google Reviews and Bluesky sources did this, so those feeds failed an hour or a day after connecting.
* Fix: a failed refresh no longer replaces good cached posts with an empty list ("No posts to display"); the last good copy keeps being shown.
* Fix: the feed list and Analytics show why a sync failed (HTTP code and the platform's reason). YouTube quota-exceeded answers pause scheduled refreshes until the pause ends.
* Fix: the hashtag shortcode no longer shows an empty page when its cached posts have just expired; it keeps showing the last cached posts for up to a day while a refresh is scheduled.
* Fix: the stale-feed warning no longer reports feeds that have never been fetched.
* Fix: the feed list shows the last sync time, and "Sync Now" on a feed that is not active says so.
* Fix: a failed YouTube code exchange now shows Google's reason (sanitised).
* Fix: a refresh that returned posts was recorded as a failed fetch whenever the Zapier listener ran (it was registered on a hook with the wrong number of arguments); this was a second cause of "failed 3 consecutive times". A failing listener can no longer fail a refresh.

= 0.9.0 =
Fixes found by independent audits of 0.8.9 and 0.8.10. No database changes.

* Security (WP.org review): the feed-import upload reader no longer carries any `phpcs:ignore` for a sanitisation rule. `$_FILES` fields are read on the line that sanitises them: the client file name with `sanitize_file_name( wp_unslash() )`, PHP's own temporary path with `filter_var( …, FILTER_UNSAFE_RAW )`, which returns it byte for byte so `is_uploaded_file()` still accepts it.
* Fix: the wizard's live preview no longer stays on "Loading..." for a feed on this site's own posts (its built-in account is never ticked) or when no account is ticked; it previews, or says what is missing.
* Fix: editing an aggregated feed no longer drops members that are paused, scheduled or in error; they are listed with their status and kept unless you untick them.
* Fix: editing a feed in the wizard keeps a "Show only" post-type list it has no button for (carousel, reel, text, link or three types) instead of turning it into photos or photos and videos, and no longer clears a stored minimum of 0, a stored date that is not plain YYYY-MM-DD or a stored schedule saved without a time zone (read as UTC).
* Fix: the legacy widget and the block editor keep a stored feed that is paused, scheduled or in error and label it with its name and status (before, the widget saved "no feed" and the block said "not available to you").
* Fix: an aggregated feed's RSS ETag and Last-Modified now include the members the reader may see, so hiding a post in a member, or pausing or restricting a member, changes the validators and a revalidating client gets the new feed instead of a 304. A shared cache may still serve its copy until it expires, so the aggregate's `Cache-Control: max-age` is now the shortest limit over the feed and its visible members (60 s for a moderated member, 300 s with hidden posts, never past a scheduled end), not just the aggregate's own settings.
* Fix: a legacy widget that holds a paused, scheduled, errored or restricted feed can be cleared again by choosing "— Select feed —"; before, the save put the old feed back.
* Security: the block editor lists the names of paused, scheduled or errored feeds only to users who can edit others' posts, and to anyone else only the feeds already used in the post they are editing (Contributors no longer see unpublished feed names).
* Fix: the wizard's live preview of an aggregated feed shows a translated explanation instead of a red untranslated "No adapter for source: aggregated".
* Fix: inactive members of an aggregated feed stay hidden in the wizard when they are filtered out (a CSS rule overrode the `hidden` attribute); the same rule is applied to the account and feed-type lists.
* Fix: the wizard shows a stored schedule that has no time zone as the UTC instant the server enforces, and an untouched save keeps stored dates (including those saved with a time) exactly as they were.
* Fix: the feed-import upload reader no longer alters PHP's temporary file path (double spaces and `%` sequences survived `sanitize_text_field()` badly); the client file name still goes through `sanitize_file_name()`.
* Performance: an aggregated feed's RSS reads its members' moderation versions in one query instead of one per member.
* Fix: What's New shows the current version badge and lists this release.

= 0.8.9 =
Supersedes the unreleased 0.8.6, 0.8.7 and 0.8.8 builds; everything below is new since 0.8.5.

* Fix: editing a feed in the wizard no longer resets anything the form did not change. The edit form now loads every field it posts (auto-block terms, alert e-mail, approval mode, alert switch, call-to-action, UTM, ticker speed, link overrides, GDPR gate and the rest) and posts an explicit 0 / empty for every unchecked box or cleared field, and the server changes only the fields the form actually sent. Display settings are overlaid on the stored ones the same way, so values set through REST or an import that the wizard has no input for (dark-mode preference, e-mail capture) survive an edit. The last aggregate member and the last injected card can be removed, and a field can still be cleared on purpose.
* Fix: the wizard can create and edit feeds for sources with no token row. Its account step is now built from the same per-source rule as activation: Mastodon (account row only), this site's own posts (a built-in "This site" account, no connection needed) and the tagged / mentions sources (the Instagram / X accounts). Before, those sources offered no account, so such a feed could not be created or edited in the wizard.
* Fix: the scheduled-feed cron (scheduled to active, expired to paused) now retires the cached feed lists; they stayed stale for up to 60 seconds on a persistent object cache. The list generation is a fresh token, not a counter, so an evicted counter can never revalidate an old list.
* Fix: the wizard's schedule fields now show and accept your browser's time zone and store UTC (the schedule fields were labelled UTC but a stored UTC time was displayed unchanged and a local time was saved as UTC). An untouched schedule is saved byte for byte as stored.
* Fix: `PUT /feeds/{id}` with a `filter_settings` object now merges the keys it names over the stored settings; before, an omitted `exclude_ids` un-hid every moderated post.
* Fix: the legacy widget and the block editor keep a stored feed the current user may not view (it was dropped when that user saved the widget or block).
* Fix: the RSS feed's Last-Modified and ETag now change when a post is hidden, restored, approved or rejected or the feed's visibility, schedule or filters are saved, so a conditional GET or shared cache cannot keep serving a moderated-away post; a feed in approval mode is shared for at most 60 seconds, one with hidden posts for 5 minutes.
* Security: every `vaultfeed/v1` REST response that depends on who asks (including `GET /feeds/{id}/posts`, which answers 404 or posts per viewer) now sends `Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private`. WordPress sends those headers on REST responses only to logged-in requests. The embed script and the signed image proxy are the same for every viewer and stay cacheable.
* Hardening: the verified Meta data-deletion payload is sanitised after the signature check; the feed-import upload fields are read through one helper that unslashes the client file name and keeps PHP's own temporary path as reported.

* Security: the RSS endpoint sent `Cache-Control: public, max-age=3600` on every response, replacing the no-cache headers WordPress sends to logged-in users, so a shared cache or CDN could store an administrator's copy of an aggregated or restricted feed and serve it to anonymous visitors for an hour. Responses to logged-in users, and every feed that is not public to everyone, are now private and not stored; a public feed read anonymously may be shared for up to an hour, never past its schedule, and varies by Cookie. The JSON Feed and embed page always send no-cache headers, `admin-ajax.php` sends them for every AJAX action, and the image proxy and embed script are the same for every viewer.
* Security: the block editor, the Elementor / Divi / Beaver Builder modules and the legacy widget listed every active feed in their feed pickers, so a Contributor opening the block editor learned the names and ids of editor-only and admin-only feeds. The pickers now offer only the feeds the user may view.
* Fix: saving a feed in the wizard replaced its filter settings with the form's fields; the form never carries the moderation list, the blocked accounts or the minimum-comments filter, so every edit un-hid every moderated post and cleared the blocklist. The wizard now starts from the stored settings and changes only the fields it shows.
* Fix: hiding or restoring a post, toggling approval mode, pinning posts and saving the blocklists on an active feed whose source stores no token (Mastodon, this site's own posts) or reads another source's token (Instagram tagged, X mentions) failed with a 500, because every save re-ran the activation check against the token table. The connected-account check now runs only when a save takes a feed live or moves it to another account, follows each source's own rule, and is answered as a 400 with the reason everywhere — including `wp vaultfeed feed create`, which used to stop with an uncaught exception.
* Fix: "Editors and above" visibility now means the Editor role and above (`edit_others_posts`); earlier builds tested `edit_posts`, which Contributors and Authors also hold.
* Fix: feed lists read with a limit (the block editor's) were not invalidated on save and could be served up to 60 seconds stale under a persistent object cache.
* Fix: RSS and JSON Feed autodiscovery links in `<head>` are emitted only for feeds that are public to everyone, so a page cache warmed by a logged-in reader never advertises a restricted feed.
* Fix: a database insert failure is reported in plain language in the wizard, Duplicate, REST and WP-CLI instead of the raw `db_insert_failed` code; two repository messages are translatable.
* Uninstall: also removes the network settings option (the multisite sitemeta row included) and the migration wizard's per-user "dismissed" flags.
* Network settings: the "share feeds across network" and "share tokens across network" switches were stored but read by nothing; both are removed from the Network Admin screen (the allowed-sources list and the subsite override remain).
* Note: feed schedules are stored in UTC. A schedule saved by a build before 0.8.6 in the site's local time is left as stored (it is not rewritten); re-enter it in the wizard if it is off. The wizard converts between your browser's time zone and UTC.
* Security: on sites with a persistent object cache (Redis, Memcached — common on managed hosts) a feed's rendered HTML was cached for 60 seconds under the feed id alone, so after an administrator viewed an aggregated feed, a copy that included its admin-only member feeds was served to anonymous visitors through the shortcode, block, widget, embed page and page builders. Rendered HTML now lives in a request-scoped (non-persistent) cache group and is keyed by viewer class and by the member feeds that viewer may see.
* Security: the `[vaultfeed_hashtag]` shortcode read the cached posts of every active feed, ignoring each feed's visibility (logged-in / editor / admin), schedule, moderation list and approval mode. A post now appears only if the feed it came from would show it to the current visitor.
* Security: an aggregated feed shows a member feed's posts only to visitors who could view that member feed directly — a member restricted to logged-in users, editors or admins, paused, or outside its schedule contributes nothing to anonymous visitors. Only feeds whose source is "Aggregated" merge other feeds; a member list on a platform feed is dropped.
* Security: `GET /wp-json/vaultfeed/v1/feeds/{id}/posts` returned a restricted feed's name, source and posts to anonymous callers. It now applies the same visibility gate as every other public surface and answers 404 (indistinguishable from a nonexistent feed). An API key identifies a client for rate limiting; it does not make a restricted feed public.
* Activating a feed (bulk action, REST API, WP-CLI) or saving it active now checks that its account is really connected on this site, not just that an account id is present — an imported feed keeps the account id of the site it was exported from — and refuses with a clear message otherwise. Every imported feed is now created paused — 0.8.5's admin-post import handler and format-1 files still created active feeds; connect the account under VaultFeed → Accounts, then activate.
* Imports and stored settings: display numbers are clamped to the wizard's ranges wherever a feed is read — import files, REST updates and feeds already stored — so a stored 100 posts per page renders as 50, and an auto-refresh interval the wizard does not offer runs at the nearest offered one (120 s becomes 60 s; nothing polls faster than 30 s) instead of switching auto-refresh off. Nested values where text is expected are ignored instead of raising PHP notices. Imported schedules are stored in UTC in both the JSON copy and the indexed columns the scheduler reads (a "10:00+02:00" schedule used to be stored two hours late, and a non-ISO date used to restrict the renderer but never reach the scheduler). An aggregated feed's member list is capped at 50 everywhere it is read and the members are loaded in one query. Partial imports report which feeds were skipped and why, in plain language.
* REST: creating or cloning a feed that fails validation (for example an empty or unconnected account) returns 400 with the reason instead of a 500; the batch endpoint no longer echoes raw request values back in `failed`.
* Fix: link-in-bio profiles never showed their embedded feed (the profile passed the feed as `feed_id`, the shortcode reads `id`).
* Fix: the block's "email capture after post" attribute is clamped to 1–100 like the stored setting; feed validation messages are translatable; double-escaped characters in import error messages.

= 0.8.5 =
* Fix wizard deprecation notices, aggregated feed save, export/import round trip and import error handling.

= 0.8.4 =
* Security (WP.org review): every request value is now sanitised at the point it is read with the WordPress function for its type, with no sanitisation finding suppressed. Feed imports (file upload, the Import Feed button and WP-CLI) validate the file with `is_uploaded_file()`, a 1 MB limit and a `.json` extension / MIME check, then size- and depth-limit the JSON and sanitise every decoded field before use. Injected cards are submitted as separate position / HTML / repeat fields, limited to 50 cards and a fixed HTML tag whitelist (no scripts, event handlers or `javascript:` links), and filtered again when rendered.
* Behaviour change (intended): existing injected cards that used tags outside that whitelist — for example `table`, `video`, `iframe`, `form` or `section` — now have those tags stripped when the feed renders. Edit the card in the Feed Wizard if it relied on them. URL fields (link-in-bio links, webhook URLs, hashtag-to-URL maps) now keep percent-encoded characters intact.

= 0.8.3 =
* WP.org review: removed a third-party review-site integration (source, account connection, and all related code) that WordPress.org does not permit for plugins listed on WordPress.org. Feed source count is now 20. If you had connected an account for it, that connection and any feeds built on it are simply gone — nothing to clean up.

= 0.8.2 =
* Security: fixed a cross-site scripting hole in the optional social-proof popup, which inserted a post's text, image URL and permalink into the page without escaping them. On hashtag, tagged-post, mention and review feeds that content is authored by third parties, so it could carry markup. All three values are now escaped, and every post URL the front-end script puts in an `href` or `src` must be a plain http(s) link, so a `javascript:` or `data:` URL from a platform response can no longer be used.
* Security: fixed a stored cross-site scripting hole in the review schema (JSON-LD) output. Review text and reviewer names come from the public on Google and Yelp, and were encoded in a way that let a review containing a closing `</script>` tag end the JSON-LD block and run as markup on your page. `<`, `>` and `&` are now hex-escaped inside the block; search engines read the data exactly as before.
* Security: the Meta data-deletion callback (`/vaultfeed/v1/fb-delete`) wrote a new row to the options table for every request it received, including unsigned and forged ones, so an anonymous caller could grow the database without limit. The request's Meta signature is now verified before anything is stored, unverified requests are refused outright, the route is rate limited, and old deletion logs are pruned after your data-retention period.
* Security: the Embed SDK script — which runs on the third-party sites you embed feeds into — now only accepts plain http(s) URLs for post links and images, so a `javascript:` URL in a platform response cannot run on the host page.
* Security: the public email-capture endpoint (`/vaultfeed/v1/subscribe`) accepted submissions even on sites that had never switched email capture on, writing addresses to the database or forwarding them to a configured Mailchimp/ConvertKit account. It now only accepts a sign-up for a feed the visitor is allowed to see and that has the email-capture form enabled, and is rate limited.
* Security: fixed an XML injection in the RSS feed — a post whose text contained the literal sequence `]]>` closed the description's CDATA section early, letting the rest of the post be parsed as raw markup by feed readers.
* Security: every public surface that can expose a feed — the iframe embed page, the RSS feed, the JSON Feed endpoint, the oEmbed provider and the click/impression tracking endpoints — now goes through one shared access gate (`FeedLoader::is_viewable()`) that honours the feed's status, its configured visibility (public / logged-in / editors / admins) and its scheduled window. Previously some of these surfaces checked only that the feed was active, so a feed restricted to logged-in users or admins could still have its name and embed URL disclosed. A `vaultfeed_feed_is_viewable` filter is available for custom rules.
* Security: the oEmbed endpoint now resolves only embed URLs belonging to this site (it previously accepted any URL carrying a `vaultfeed_embed` query variable, including URLs on other hosts), returns the same 404 for every rejection so responses cannot be used to tell which feed IDs exist, and is rate limited to 30 requests per IP per minute.
* WP.org review: upgraded the bundled SortableJS from 1.15.2 to 1.15.7 (latest stable) and shipped its MIT licence alongside it at `assets/js/vendor/sortable-LICENSE.txt`.
* WP.org review: the plugin header was missing its `License URI`, and the packaged `composer.json` still declared `"license": "proprietary"` next to a GPL plugin header. The header is complete, the package now declares GPL-2.0-or-later throughout, and the full licence text ships as `LICENSE.txt`.
* Change: AI caption rewriting now uses WordPress's built-in AI Client (WordPress 7.0+) exclusively — it sends the caption to whichever AI provider you have connected in WordPress. VaultFeed no longer contacts any AI provider directly and stores no AI key. The feature is available only when you have connected an AI provider in WordPress; the "Enhance Caption" button is hidden otherwise.
* New: the Social Proof Popup can now actually be switched on — it has a section under VaultFeed → Settings (feed, corner, delay). The feature shipped before without any settings screen, and its script could not load a post; both are fixed, and it now works on pages that have no feed on them.
* Fix: clicking "Save Settings" saved your changes but then left a half-rendered screen with a "headers already sent" warning, because the form was processed after the page had started printing. It is now processed before any output.
* Fix: the Welcome / onboarding screen lacked WordPress's standard page wrapper, so admin notices from WordPress and other plugins were placed at an arbitrary point on it. They now appear above the welcome card.
* Fix: on sites using plain permalinks the Moderation screen could not load any posts (its request URL contained two `?`).
* Fix: on page-cached sites the nonce-refresh endpoint now also renews the click-tracking nonce, and its response is marked `no-store` so a cache or CDN can never serve a stale one. It is rate limited.
* Fix: the email-capture form never actually submitted — the front-end script was not given the REST URL, and sent a nonce WordPress rejects. Sign-ups now work for visitors and logged-in users, including on page-cached sites.
* Fix: the hourly clean-up job queried a column that does not exist (`logged_at` instead of `created_at`), so it logged a database error on every run and the moderation log was never pruned.
* Fix: the legacy JSON Feed address (`?vaultfeed_json_feed=1&feed_id=N`) always served feed 1 regardless of `feed_id`. Also stopped registering an unprefixed public `feed_id` query variable, which could collide with other plugins.
* Fix: uninstalling on a multisite network overwrote WordPress's `$blog_id` global while looping over subsites.
* Fix: RSS items for posts with no author handle rendered an empty `(@)` after the author name.
* Housekeeping: `wp_json_encode()` and `rawurlencode()` in place of their plain PHP counterparts, and the last two ungated `error_log()` calls now only write when `WP_DEBUG` is on.
* Docs: documented the social platform media CDNs under "External services" — feed images and videos are served from the platforms' own CDNs to the visitor's browser (or through your own server if you enable the image proxy) — and corrected the third-party libraries note, which previously read as though no images at all were loaded from external servers.

= 0.8.1 =
* WP.org review: documented every previously-undisclosed optional third-party service in the "External services" section — Mailchimp and ConvertKit/Kit (email capture), the Google Analytics 4 Measurement Protocol (click analytics), Slack and Zapier webhooks (notifications/automation), the Shopify Storefront API (shop badge), and user-defined outbound webhooks. Each is opt-in and inactive unless you enter your own credentials or URL. Also added the missing Instagram privacy-policy link and example Mastodon instance terms.
* WP.org review: corrected a review-platform integration's terms and privacy URLs, which now pointed to its current legal pages (the previous paths returned 404), and replaced three other dead links (Yelp developer portal, and two links to a not-yet-live domain).
* WP.org review: SortableJS is now bundled with the plugin (`assets/js/vendor/`) instead of being loaded from the jsDelivr CDN on the Moderation screen. The plugin no longer loads any asset from an external server.
* Docs: corrected the readme's description of token-salt storage — the salt lives in the `vaultfeed_token_salt` database option; the plugin does not write to `wp-config.php` (that behaviour was removed in 0.2.1).
* Security: the image-proxy REST endpoint now has a real permission callback — it responds only when the proxy is enabled and the requested URL carries a valid server-issued HMAC signature, so it can no longer be used as an open proxy.
* Security: the AJAX "load more" endpoint now enforces the same feed visibility (public / logged-in / editor / admin) and schedule-window rules as the initial render.
* Security: the public impression and click-tracking endpoints now reject feed IDs that do not exist or are not active, so they cannot be used to create counter rows for arbitrary IDs.
* Security: feed-import uploads are now validated with `is_uploaded_file()` and each `$_FILES` field is sanitised individually.
* Fix: per-feed API-key restrictions were never enforced on `/feeds/{id}/posts` because the permission check read the wrong request parameter.
* WP.org review: everything the plugin stores now uses the distinctive `vaultfeed_` prefix instead of the two-letter `vf_` — all 11 database tables, the impression-counter options, every transient, the notice-dismissal user meta, a nonce action, and the JavaScript globals (`vfConfig` → `vaultfeedConfig`, etc.). Existing sites are migrated automatically and losslessly (database migration 0007: tables are renamed in place, options and user meta are renamed, short-lived transients are discarded). Only VaultFeed's own explicitly-listed names are touched.
* Fix: uninstall now drops VaultFeed's tables from an explicit list. It previously dropped every table matching `{prefix}vf_%`, which could have removed another plugin's tables that happened to share the two-letter prefix.
* Fix: pending database migrations now run on the first request of any kind after an update, not only on the next wp-admin visit, so the front end never queries a not-yet-migrated schema after a background auto-update.
* Security: rate limiting and click/impression de-duplication now identify visitors by `REMOTE_ADDR` only. The `X-Forwarded-For` and `CF-Connecting-IP` headers — which any client can forge to obtain a fresh rate-limit bucket — are honoured only if you declare a trusted proxy under VaultFeed → Settings → Reverse Proxy / CDN. **If your site is behind Cloudflare or another reverse proxy, set this after updating**, otherwise all visitors share the proxy's address for rate-limiting purposes. A `vaultfeed_client_ip` filter is available for custom set-ups.
* Security: Twitch and Snapchat bearer tokens, and a newly-created API key awaiting its one-time display, are now encrypted (AES-256-GCM) before being cached in a transient, matching the Reddit fix above. Any plaintext token cached by an older build is discarded on upgrade.
* Fix: three REST routes — the Embed SDK loader (`/embed.js`), the email-capture endpoint (`/subscribe`) and the image proxy (`/image`) — were never actually registered, because each added its `rest_api_init` callback from inside that same hook. The Embed SDK and the email-capture form now work.
* Fix: the Settings screen raised "Undefined array key" PHP warnings under `WP_DEBUG` on sites that had not saved settings yet.
* Fix: the admin-bar "VaultFeed" links and the feed-error notification email pointed at a non-existent admin page and produced a "not allowed" error.
* i18n: regenerated `languages/vaultfeed.pot` (it had fallen out of date and was missing over half of the plugin's strings).
* Security: the front-end click-tracking AJAX endpoint now returns an explicit error (never a misleading "success") when the security nonce is missing or invalid.
* Security: removed the redundant unauthenticated REST route `POST /feeds/{id}/posts/{platform_id}/click`. Click tracking is handled solely by the nonce-protected `admin-ajax` endpoint, closing an endpoint that allowed arbitrary click-counter increments.
* Security: the Meta (Facebook/Instagram) data-deletion callback now purges cached content only when the `signed_request` signature is cryptographically verified against the stored app secret; unverifiable requests are acknowledged (per Meta's spec) but take no privileged action.
* Security: the image proxy now fetches through `wp_safe_remote_get()` (blocking private/reserved IPs on every redirect hop) and enforces a 5 MB response-size cap before caching, mitigating SSRF and unbounded-cache abuse.
* Security: the Reddit app-only OAuth bearer token is now encrypted (AES-256-GCM) before being cached in a transient, rather than stored in plaintext.
* Security: display, filter, and cache settings are now sanitised per-field when hydrated from imported/uploaded JSON (`from_json`) — text via `sanitize_text_field`, URLs via `esc_url_raw`, custom CSS via `wp_strip_all_tags`, CSS lengths and colours against strict patterns, emails via `sanitize_email`, injected-card HTML via `wp_kses_post`, and JSON maps decoded and re-encoded with typed values.
* WP-CLI: `wp vaultfeed feed export` now always writes into the plugin's folder inside `wp-content/uploads` (resolved at runtime via `wp_upload_dir()`); the `--file` option is treated as a filename only, never an arbitrary path.

= 0.8.0 =
* Housekeeping: versioning reset to 0.8.0 ahead of a 1.0 release (X.Y.Z, each of Y and Z rolling over to the next level after 9 — e.g. 0.8.9 → 0.9.0, 0.9.9 → 1.0.0). Prior internal builds were numbered 0.1.0–0.45.x.
* Fix: two activation-fatal bugs — a stale Composer autoloader referencing dev-only packages, and Elementor/Divi/Beaver Builder integrations that errored on sites without those page builders installed.
* Fix: WP-CLI command conflict between `wp vaultfeed` and its subcommands; renamed to `wp vaultfeed info`.
* Fix: cron scheduling deferred to `init` in 5 classes to avoid a false "translation loaded too early" notice on WordPress 6.7+.
* WP.org review: resolved remaining Plugin Check findings — output escaping, input sanitization, SQL preparation, discouraged functions, i18n, and nonce checks across ~35 files.
* Pro: removed the "no separate Meta developer account" option — Facebook/Instagram now always use your own Meta app, like every other platform. ReelVault, DHT storage, and priority support remain in Pro.
