From 159d9fbe7117aa0c4ec0601e058ca1cd16f2b8a9 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Mon, 24 Aug 2026 10:49:53 -0400 Subject: [PATCH 1/2] Make the directory switch visible, and repair the link without asking Three separate reasons a user saw no toggle at all, and the same reason the directory kept showing the raw gateway URL. IT HID ITSELF ON ANY FAILURE The switch rendered nothing whenever the server state could not be read, on the reasoning that a wrong switch is worse than no switch. That was wrong. When the listing endpoint was unreachable - which it was, because the endpoint had not been deployed - the control silently ceased to exist, and the only signal was a user hunting for a feature that looked unshipped. It now always renders: disabled, saying the directory is unavailable, or Checking while it loads. A disabled control that says why is honest; an absent one is not. IT WAS IN THE WRONG PLACE It sat below the Open / Copy / Recreate actions, reading as an afterthought. Listing is a statement about the shareable link, so the switch now sits directly under it. THE LINK NEEDED A MANUAL TOGGLE FLIP TO REPAIR A site listed before this client began sending the stable share link has listing_url NULL server-side, so the directory falls back to the raw per-generation gateway URL - which points at ONE build and goes stale on the next regeneration. The server CANNOT work the real address out: the IPNS pointer lives in the user's encrypted manifest and is published to w3name from the browser, so only the client can supply it. Rather than ask a user to toggle listing off and on to fix data they did not break, opening the site now pushes the front door once, guarded by listed AND NOT hasStableUrl AND a known pointer, attempted at most once per group per session, failures swallowed. hasStableUrl is now read off the GET (it was already returned and thrown away), and a toggle records whether the server ACCEPTED the URL rather than assuming it - a rejected link must not suppress the repair. NOT VERIFIED LOCALLY: flutter analyze cannot run on this machine, PID 15560 is holding 1,002,673 handles and every fork fails. CI is the gate. --- .../screens/web_website_detail_screen.dart | 66 ++++++++++++++--- lib/web/services/web_website_service.dart | 71 ++++++++++++++++--- pubspec.yaml | 2 +- 3 files changed, 121 insertions(+), 18 deletions(-) diff --git a/lib/web/screens/web_website_detail_screen.dart b/lib/web/screens/web_website_detail_screen.dart index 9ab73b2..3bf9189 100644 --- a/lib/web/screens/web_website_detail_screen.dart +++ b/lib/web/screens/web_website_detail_screen.dart @@ -1230,6 +1230,14 @@ class _GenerationCard extends StatelessWidget { ], ), ), + // Public-directory switch, directly under the shareable + // link: listing is a statement about THIS address, so the + // control belongs beside it rather than below the actions, + // where it read as an afterthought and was easy to miss. + // Shown on the newest completed generation only — that is + // the build the directory entry represents. + if (isLatestCompleted) + _DirectoryListingSwitch(generation: g), const SizedBox(height: 8), Wrap( spacing: 8, @@ -1266,12 +1274,6 @@ class _GenerationCard extends StatelessWidget { ), ], ), - // Public-directory switch, on the newest completed - // generation only — that is the one the directory lists. - // Changeable here so a user never has to regenerate a site - // to take it out of the directory. - if (isLatestCompleted) - _DirectoryListingSwitch(generation: g), // Click-tracking stats below the link (native parity: // shown only for generations created with tracking on). if (g.trackingEnabled && @@ -1338,7 +1340,7 @@ class _DirectoryListingSwitch extends StatefulWidget { } class _DirectoryListingSwitchState extends State<_DirectoryListingSwitch> { - ({bool listed, bool delistedByAdmin})? _state; + ({bool listed, bool delistedByAdmin, bool hasStableUrl})? _state; bool _busy = false; bool _unavailable = false; @@ -1358,6 +1360,20 @@ class _DirectoryListingSwitchState extends State<_DirectoryListingSwitch> { _state = state; _unavailable = state == null; }); + + // A site listed before this client began sending the stable share + // link carries the raw per-generation gateway URL in the directory, + // which points at one build and goes stale on regeneration. Only the + // browser knows the IPNS front door, so push it here rather than + // asking the user to toggle listing off and on to repair it. + await WebWebsiteService.instance + .ensureStableLinkPublished(widget.generation); + if (!mounted) return; + final repaired = + WebWebsiteService.instance.listedOnServer(widget.generation.tagId); + if (repaired != null && repaired != _state) { + setState(() => _state = repaired); + } } Future _set(bool listed) async { @@ -1366,7 +1382,15 @@ class _DirectoryListingSwitchState extends State<_DirectoryListingSwitch> { await WebWebsiteService.instance .setDirectoryListing(widget.generation, listed: listed); if (!mounted) return; - setState(() => _state = (listed: listed, delistedByAdmin: false)); + // Take the state the service recorded rather than reconstructing + // it: it knows whether the stable link was actually accepted. + setState(() => _state = + WebWebsiteService.instance.listedOnServer(widget.generation.tagId) ?? + ( + listed: listed, + delistedByAdmin: false, + hasStableUrl: false, + )); } catch (e) { if (!mounted) return; // Surface the failure and leave the switch where it was, rather @@ -1382,7 +1406,31 @@ class _DirectoryListingSwitchState extends State<_DirectoryListingSwitch> { Widget build(BuildContext context) { final theme = Theme.of(context); final state = _state; - if (_unavailable || state == null) return const SizedBox.shrink(); + + // Never disappear. + // + // This used to render nothing whenever the server state could not be + // read, on the reasoning that a wrong switch is worse than no + // switch. That was a mistake: when the listing endpoint was + // unreachable the control silently ceased to exist, and the only + // signal was a user hunting for a feature that looked unshipped. A + // disabled switch that says why is honest; an absent one is not. + if (state == null) { + return SwitchListTile( + value: false, + onChanged: null, + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('List in public directory', + style: TextStyle(fontSize: 13)), + subtitle: Text( + _unavailable + ? 'Directory unavailable right now — try again shortly' + : 'Checking…', + style: theme.textTheme.bodySmall, + ), + ); + } if (state.delistedByAdmin) { return Padding( diff --git a/lib/web/services/web_website_service.dart b/lib/web/services/web_website_service.dart index d18eab4..30d6b15 100644 --- a/lib/web/services/web_website_service.dart +++ b/lib/web/services/web_website_service.dart @@ -567,20 +567,71 @@ class WebWebsiteService extends ChangeNotifier { if (response.statusCode != 200) { throw Exception('Could not update the listing (${response.statusCode})'); } - _listedOnServer[generation.tagId] = - (listed: listed, delistedByAdmin: false); + // `urlAccepted` tells us whether the server actually stored the link + // it was sent — a malformed one is rejected without failing the + // toggle, and recording it as stored would suppress the repair. + var stored = _listedOnServer[generation.tagId]?.hasStableUrl ?? false; + try { + final body = jsonDecode(response.body); + if (body is Map && body['urlAccepted'] == true) stored = true; + } catch (_) { + // A 200 with an unreadable body still toggled; leave `stored` as + // it was so the repair can retry later. + } + _listedOnServer[generation.tagId] = ( + listed: listed, + delistedByAdmin: false, + hasStableUrl: stored, + ); _notify(generation); } /// Last known server-side listing state, keyed by website GROUP (tag /// id), so the switch reflects reality after a toggle without /// re-fetching. - final Map _listedOnServer = - {}; - - ({bool listed, bool delistedByAdmin})? listedOnServer(String tagId) => + final Map< + String, + ({ + bool listed, + bool delistedByAdmin, + bool hasStableUrl, + })> _listedOnServer = {}; + + ({bool listed, bool delistedByAdmin, bool hasStableUrl})? listedOnServer( + String tagId) => _listedOnServer[tagId]; + /// Groups this session has already tried to repair, so a site whose + /// front door genuinely cannot be published does not re-POST on every + /// visit to the screen. + final Set _linkRepairAttempted = {}; + + /// Push the stable share link for a site that is listed without one. + /// + /// The server CANNOT work this address out. The IPNS pointer lives in + /// this user's encrypted manifest and is published to w3name from the + /// browser, so only the client can supply it — and a site listed + /// before the client started sending it shows the raw per-generation + /// gateway URL in the directory, which points at ONE build and goes + /// stale on the next regeneration. + /// + /// Repairing that silently is deliberate: the alternative is asking a + /// user to toggle listing off and on to fix data they did not break. + /// Failures are swallowed — this is a background repair, and the entry + /// keeps its old link either way. + Future ensureStableLinkPublished(WebsiteGeneration generation) async { + final tagId = generation.tagId; + final state = _listedOnServer[tagId]; + if (state == null || !state.listed || state.hasStableUrl) return; + if (_frontDoorUrlFor(tagId) == null) return; + if (!_linkRepairAttempted.add(tagId)) return; + try { + await setDirectoryListing(generation, listed: true); + } catch (e) { + debugPrint('Could not publish the stable link for $tagId: $e'); + } + } + /// Read a website's directory state from the server. /// /// Keyed on the GROUP: the client's generation id is not the server's @@ -590,8 +641,8 @@ class WebWebsiteService extends ChangeNotifier { /// /// Returns null when the state cannot be determined, and the caller /// then shows no switch rather than a wrong one. - Future<({bool listed, bool delistedByAdmin})?> fetchListingState( - String tagId) async { + Future<({bool listed, bool delistedByAdmin, bool hasStableUrl})?> + fetchListingState(String tagId) async { final cached = _listedOnServer[tagId]; if (cached != null) return cached; try { @@ -610,6 +661,10 @@ class WebWebsiteService extends ChangeNotifier { final state = ( listed: body['listed'] == true, delistedByAdmin: body['delistedByAdmin'] == true, + // Absent on a backend that predates the stable link, which is + // the same situation as "no link stored": treat it as missing + // and let the repair push one. + hasStableUrl: body['hasStableUrl'] == true, ); _listedOnServer[tagId] = state; return state; diff --git a/pubspec.yaml b/pubspec.yaml index 8dca937..0f2c6e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fula_files description: FxFiles - A minimalistic file manager with Fula decentralized storage backup support. publish_to: 'none' -version: 1.11.8+545 +version: 1.11.8+546 environment: sdk: '>=3.2.0 <4.0.0' From 2cef1a384e3942ef8fdbd3c160bf6cf9b42edab9 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Mon, 24 Aug 2026 10:52:28 -0400 Subject: [PATCH 2/2] Move the listing switch to the top, under the shareable link Placed as asked, and the placement matters more than it looks. The switch was inside a generation-history card, gated on that card being the newest completed build - so finding it meant scrolling past the assets and into the history, and only one card in that list had it. It now sits directly under the Shareable link block at the top of the screen. Deliberately BELOW that block rather than inside it: the link card collapses to a spinner while the IPNS pointer is publishing, and to nothing at all when there is no pointer yet. Nesting the switch there would have made it vanish again whenever an unrelated thing was loading - the same class of bug as hiding it when the server state could not be read. It is its own bordered section keyed only on there being a completed generation. Drops _GenerationCard.isLatestCompleted and _latestCompletedId, now unused, in favour of a _latestCompleted getter returning the generation itself. Still CI-verified only - flutter cannot run here, PID 15560 is holding over a million handles. --- .../screens/web_website_detail_screen.dart | 65 ++++++++++++------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/lib/web/screens/web_website_detail_screen.dart b/lib/web/screens/web_website_detail_screen.dart index 3bf9189..b2d9fd1 100644 --- a/lib/web/screens/web_website_detail_screen.dart +++ b/lib/web/screens/web_website_detail_screen.dart @@ -71,13 +71,12 @@ class _WebWebsiteDetailScreenState extends State { /// Opt-in: false unless the user ticked the box. bool _listInDirectory = false; - /// Id of the newest completed generation — the one the directory - /// lists, and so the only card that carries the listing switch. - /// `_generations` is sorted newest-first, so this is the first - /// completed entry. - String? get _latestCompletedId { + /// The newest completed generation — the build the directory entry + /// represents. `_generations` is sorted newest-first, so this is the + /// first completed entry. + WebsiteGeneration? get _latestCompleted { for (final g in _generations) { - if (g.status == WebsiteGenStatus.completed) return g.id; + if (g.status == WebsiteGenStatus.completed) return g; } return null; } @@ -609,6 +608,18 @@ class _WebWebsiteDetailScreenState extends State { padding: const EdgeInsets.all(16), children: [ _stableLinkSection(theme), + // Directly under the shareable link, at the top. + // + // Listing is a statement about THAT address, so + // the control belongs beside it. It lives here + // rather than inside the link card because the + // card collapses while the IPNS pointer is being + // published — and a switch that disappears while + // some unrelated thing loads is exactly how this + // ended up looking unshipped. + if (_latestCompleted != null) + _DirectoryListingCard( + generation: _latestCompleted!), _assetsSection(theme), const SizedBox(height: 16), SizedBox( @@ -642,8 +653,6 @@ class _WebWebsiteDetailScreenState extends State { for (final g in _generations) _GenerationCard( generation: g, - isLatestCompleted: - g.id == _latestCompletedId, onRecreate: g.status == WebsiteGenStatus.completed && !_isGenerating @@ -1117,20 +1126,11 @@ class _GenerationCard extends StatelessWidget { final SocialPostRecord? socialRecord; final VoidCallback? onCreateSocial; - /// True for the newest COMPLETED generation of this website. - /// - /// The directory keeps one entry per website (the newest listed - /// generation), so only this card owns the listing switch. Showing it - /// on every historical card would both mislead and cost one status - /// request per card on screen open. - final bool isLatestCompleted; - const _GenerationCard({ required this.generation, this.onRecreate, this.socialRecord, this.onCreateSocial, - this.isLatestCompleted = false, }); @override @@ -1230,14 +1230,6 @@ class _GenerationCard extends StatelessWidget { ], ), ), - // Public-directory switch, directly under the shareable - // link: listing is a statement about THIS address, so the - // control belongs beside it rather than below the actions, - // where it read as an afterthought and was easy to miss. - // Shown on the newest completed generation only — that is - // the build the directory entry represents. - if (isLatestCompleted) - _DirectoryListingSwitch(generation: g), const SizedBox(height: 8), Wrap( spacing: 8, @@ -1323,6 +1315,29 @@ class _GenerationCard extends StatelessWidget { } } +/// The listing switch as a top-of-page section, under the shareable +/// link. +/// +/// A thin wrapper so the control reads as part of the link block rather +/// than a stray row: same padding and radius, no colour of its own. +class _DirectoryListingCard extends StatelessWidget { + final WebsiteGeneration generation; + const _DirectoryListingCard({required this.generation}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Theme.of(context).dividerColor), + ), + child: _DirectoryListingSwitch(generation: generation), + ); + } +} + /// "List in public directory" switch for a completed generation. /// /// Reads the state from the SERVER rather than assuming it: a generation