Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 89 additions & 26 deletions lib/web/screens/web_website_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,12 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
/// 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;
}
Expand Down Expand Up @@ -609,6 +608,18 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
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(
Expand Down Expand Up @@ -642,8 +653,6 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
for (final g in _generations)
_GenerationCard(
generation: g,
isLatestCompleted:
g.id == _latestCompletedId,
onRecreate: g.status ==
WebsiteGenStatus.completed &&
!_isGenerating
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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
Expand All @@ -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;

Expand All @@ -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<void> _set(bool listed) async {
Expand All @@ -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
Expand All @@ -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(
Expand Down
71 changes: 63 additions & 8 deletions lib/web/services/web_website_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, ({bool listed, bool delistedByAdmin})> _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<String> _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<void> 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
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down