diff --git a/lib/web/screens/web_website_detail_screen.dart b/lib/web/screens/web_website_detail_screen.dart index 9ab73b2..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 @@ -1266,12 +1266,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 && @@ -1321,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 @@ -1338,7 +1355,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 +1375,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 +1397,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 +1421,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'