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
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via

### Added

- Document origin URLs (`source_url`) now render as clickable links across the
room document listing, document filter, and citations, replacing the internal
file path — which remains only in the document listing's metadata dialog.
- A document's source now shows as a clickable link when the backend provides a
viewer `source_url`, across the room document listing, document filter, and
citations. When `source_url` is absent, the citation link can be derived from
the document URI by a resolver a deployment injects via
`standard(documentBrowserUrl: ...)`; otherwise the document URI is shown as
non-clickable text.
- Room and lobby: the current server's name (or its address when unnamed) now
shows alongside the room name in the room view header, and as a title band at
the top of the lobby's room pane, so a user connected to several servers can
Expand Down Expand Up @@ -58,6 +61,7 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via
danger-styled text button, so the safe choice carries the emphasis.
- In an expanded citation, the cited figures now appear above the source text
rather than below it.
- The lobby sort options are shortened to "None", "Recent", and "Unread".

### Fixed

Expand Down
4 changes: 4 additions & 0 deletions lib/soliplex_frontend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export 'src/core/shell_config.dart' show ShellConfig;
export 'src/core/status_message_config.dart' show StatusMessageConfig;
export 'src/flavors/standard.dart' show standard, standardFlavor;
export 'src/flavors/standard_kit.dart' show buildStandardKit, StandardKit;
// The resolver type for `standard(documentBrowserUrl: ...)`; the provider that
// installs it stays internal.
export 'src/modules/room/document_browser_url.dart'
show DocumentBrowserUrlResolver;
export 'src/interfaces/auth_state.dart'
show AuthState, Authenticated, Unauthenticated;
export 'src/modules/auth/auth_providers.dart'
Expand Down
6 changes: 6 additions & 0 deletions lib/src/flavors/standard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import '../core/shell_config.dart';
import '../core/status_message_config.dart';
import '../modules/auth/consent_notice.dart';
import '../modules/auth/platform/callback_params.dart';
import '../modules/room/document_browser_url.dart';
import 'standard_kit.dart';

/// Builds the standard [Flavor]: the full module set on shared session
Expand All @@ -32,6 +33,7 @@ Future<Flavor> standardFlavor({
bool enableDocumentFilter = true,
String statusMessageFilePath = StatusMessageConfig.defaultFilePath,
Duration statusMessagePollInterval = StatusMessageConfig.defaultPollInterval,
DocumentBrowserUrlResolver? documentBrowserUrl,
List<AppModule> Function(StandardKit kit)? extraModules,
}) async {
final effectiveIdentity = identity ?? AppIdentity.soliplex;
Expand All @@ -52,6 +54,8 @@ Future<Flavor> standardFlavor({
theme: theme,
modules: [
...kit.modules,
if (documentBrowserUrl != null)
DocumentBrowserUrlModule(documentBrowserUrl),
...?extraModules?.call(kit),
],
initialRoute: kit.initialRoute,
Expand Down Expand Up @@ -80,6 +84,7 @@ Future<ShellConfig> standard({
Duration inactivityGraceDuration = InactivityConfig.defaultGraceDuration,
String statusMessageFilePath = StatusMessageConfig.defaultFilePath,
Duration statusMessagePollInterval = StatusMessageConfig.defaultPollInterval,
DocumentBrowserUrlResolver? documentBrowserUrl,
}) async {
final flavor = await standardFlavor(
identity: identity,
Expand All @@ -97,6 +102,7 @@ Future<ShellConfig> standard({
inactivityGraceDuration: inactivityGraceDuration,
statusMessageFilePath: statusMessageFilePath,
statusMessagePollInterval: statusMessagePollInterval,
documentBrowserUrl: documentBrowserUrl,
);
return flavor.build();
}
11 changes: 6 additions & 5 deletions lib/src/modules/lobby/ui/lobby_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -800,8 +800,8 @@ class _ViewModeToggle extends StatelessWidget {
/// labelled dropdown and the phone [_CompactSortButton] so the two can't drift.
const _sortOptions = <(LobbySortMode, String)>[
(LobbySortMode.none, 'None'),
(LobbySortMode.recentActivity, 'Recent activity'),
(LobbySortMode.unreadFirst, 'Unread first'),
(LobbySortMode.recentActivity, 'Recent'),
(LobbySortMode.unreadFirst, 'Unread'),
];

/// Compact sort control for phone layouts: an icon button that opens a menu of
Expand Down Expand Up @@ -990,11 +990,11 @@ class _ServerSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (parts.unread.isNotEmpty) ...[
const _GroupHeader(label: 'Unread'),
_GroupHeader(label: 'Unread'),
_buildBlock(context, parts.unread),
],
if (parts.read.isNotEmpty) ...[
const _GroupHeader(label: 'Read'),
_GroupHeader(label: 'Read'),
_buildBlock(context, parts.read),
],
],
Expand Down Expand Up @@ -1142,7 +1142,8 @@ class _RoomGrid extends StatelessWidget {
/// A recency-bucket section header: a muted label with a trailing rule, used
/// to separate "Today", "Yesterday", ... groups when sorting by activity.
class _GroupHeader extends StatelessWidget {
const _GroupHeader({required this.label});
_GroupHeader({required this.label})
: super(key: ValueKey('lobby-section-header-$label'));

final String label;

Expand Down
81 changes: 81 additions & 0 deletions lib/src/modules/room/document_browser_url.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:soliplex_client/soliplex_client.dart';
import 'package:soliplex_logging/soliplex_logging.dart';

import '../../core/app_module.dart';

final _logger =
LogManager.instance.getLogger('soliplex_frontend.document_browser_url');

/// Derives a document's clickable browser URL from its internal document URI.
typedef DocumentBrowserUrlResolver = Uri? Function(String documentUri);

/// A deployment's rule for turning an internal document URI into a public
/// browser URL, used as a fallback for the citation source link.
///
/// The backend supplies a viewer `source_url` only for documents whose
/// ingestion attached it. When it is absent, the internal `documentUri`
/// (typically a `file://`/`s3://` path) is never itself launchable, so a
/// deployment can supply a rule that maps it to a public URL. The concrete
/// mapping lives in the consumer, injected through `standard(documentBrowserUrl:
/// ...)` / `standardFlavor(documentBrowserUrl: ...)`. The default resolves
/// nothing, so the standard build shows the raw document URI instead of a link.
///
/// Only the citation source link consults this; the document listing and filter
/// read `RagDocument.sourceUrl` directly.
final documentBrowserUrlResolverProvider =
Provider<DocumentBrowserUrlResolver>((_) => (_) => null);

/// The launchable browser URL for a cited document: the viewer [sourceUrl] when
/// present, else a URL the [resolver] derives from [documentUri]. The resolver
/// result is validated as a web URL, so a resolver that returns a
/// non-launchable value yields null rather than a dead link.
Uri? resolveDocumentBrowserUrl(
DocumentBrowserUrlResolver resolver, {
required Uri? sourceUrl,
required String documentUri,
}) {
if (sourceUrl != null) return sourceUrl;

final Uri? derived;
try {
derived = resolver(documentUri);
} catch (error, stackTrace) {
// The resolver is consumer-supplied; a throw must degrade to no link, not
// crash the widget that renders it.
_logger.error(
'documentBrowserUrl resolver threw',
error: error,
stackTrace: stackTrace,
);
return null;
}
if (derived == null) return null;

final url = launchableWebUrl(derived.toString());
if (url == null) {
_logger.warning(
'documentBrowserUrl resolver returned a non-launchable URL '
'(scheme: ${derived.scheme})',
);
}
return url;
}

/// Installs [resolver] as the app-wide [documentBrowserUrlResolverProvider]
/// override. Added by `standardFlavor` when a `documentBrowserUrl` is supplied.
class DocumentBrowserUrlModule extends AppModule {
DocumentBrowserUrlModule(this.resolver);

final DocumentBrowserUrlResolver resolver;

@override
String get namespace => 'document_browser_url';

@override
ModuleRoutes build() => ModuleRoutes(
overrides: [
documentBrowserUrlResolverProvider.overrideWithValue(resolver),
],
);
}
27 changes: 19 additions & 8 deletions lib/src/modules/room/ui/citations_section.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:soliplex_agent/soliplex_agent.dart' hide State;
import 'package:soliplex_design/soliplex_design.dart';
import 'package:soliplex_logging/soliplex_logging.dart';

import '../../../shared/browser_url_link.dart';
import '../../../shared/copy_button.dart';
import '../../../shared/failed_image.dart';
import '../../../shared/preview_icon_button.dart';
import '../../../shared/zoomable_image.dart';
import '../../../shared/zoomable_view.dart';
import '../document_browser_url.dart';
import 'document_source.dart';
import 'markdown/flutter_markdown_plus_renderer.dart';
import 'markdown/log_source.dart';
import 'paged_zoomable_images.dart';
Expand Down Expand Up @@ -256,14 +258,23 @@ class _SourceReferenceRow extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The cited document's clickable origin link, when the backend
// shipped a usable `source_url`. The internal path is never shown
// (the row header already carries the document name).
if (sourceReference.sourceUrl case final url?)
Padding(
padding: const EdgeInsets.only(bottom: SoliplexSpacing.s1),
child: BrowserUrlLink(url: url),
// The cited document's source link: the viewer `source_url`, else a
// resolver-derived URL from the document URI, else the raw URI as
// text (it is never itself launchable). A fork supplies the resolver
// via documentBrowserUrlResolverProvider.
Padding(
padding: const EdgeInsets.only(bottom: SoliplexSpacing.s1),
child: Consumer(
builder: (context, ref, _) => DocumentSource(
url: resolveDocumentBrowserUrl(
ref.watch(documentBrowserUrlResolverProvider),
sourceUrl: sourceReference.sourceUrl,
documentUri: sourceReference.documentUri,
),
documentUri: sourceReference.documentUri,
),
),
),
if (sourceReference.figures.isNotEmpty)
_CitationFigures(sourceReference: sourceReference),
if (sourceReference.headings.isNotEmpty) ...[
Expand Down
9 changes: 3 additions & 6 deletions lib/src/modules/room/ui/document_picker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:soliplex_client/soliplex_client.dart' hide State;

import '../../../shared/browser_url_link.dart';
import '../../../shared/file_type_icons.dart';
import 'document_source.dart';
import 'package:soliplex_design/soliplex_design.dart';

class DocumentPicker extends StatefulWidget {
Expand Down Expand Up @@ -43,11 +43,8 @@ class _DocumentPickerState extends State<DocumentPicker> {
widget.onChanged(next);
}

Widget? _subtitle(RagDocument doc) {
final url = doc.sourceUrl;
if (url == null) return null;
return BrowserUrlLink(url: url);
}
Widget _subtitle(RagDocument doc) =>
DocumentSource(url: doc.sourceUrl, documentUri: doc.uri);

@override
Widget build(BuildContext context) {
Expand Down
43 changes: 43 additions & 0 deletions lib/src/modules/room/ui/document_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';

import '../../../shared/browser_url_link.dart';

/// A document's source, shown in a link slot.
///
/// Renders a clickable [BrowserUrlLink] for the launchable [url]; when there is
/// none, the [documentUri] is shown as muted, non-clickable text (the full
/// value on hover), since the URI is never itself launchable. Renders nothing
/// when neither is available.
class DocumentSource extends StatelessWidget {
const DocumentSource({
required this.url,
required this.documentUri,
super.key,
});

/// The document's launchable browser URL, or null.
final Uri? url;

/// The document's internal URI, shown as text when [url] is null.
final String documentUri;

@override
Widget build(BuildContext context) {
final linkUrl = url;
if (linkUrl != null) return BrowserUrlLink(url: linkUrl);

if (documentUri.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
return Tooltip(
message: documentUri,
child: Text(
documentUri,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
}
13 changes: 5 additions & 8 deletions lib/src/modules/room/ui/room_info/documents_card.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:soliplex_client/soliplex_client.dart' hide State;

import '../../../../shared/browser_url_link.dart';
import '../document_source.dart';
import '../../../../shared/file_type_icons.dart';
import 'room_info_widgets.dart';
import 'package:soliplex_design/soliplex_design.dart';
Expand Down Expand Up @@ -205,7 +205,6 @@ class _DocumentsCardState extends State<DocumentsCard> {
if (doc.updatedAt != null) {
dateFields.add(('updated_at', _formatDateTime(doc.updatedAt!)));
}
final sourceUrl = doc.sourceUrl;

return Padding(
padding: const EdgeInsets.only(top: SoliplexSpacing.s1),
Expand All @@ -225,12 +224,10 @@ class _DocumentsCardState extends State<DocumentsCard> {
doc.id,
style: context.monospaceOn(valueStyle),
),
if (sourceUrl != null) ...[
const SizedBox(height: SoliplexSpacing.s2),
Text('link', style: labelStyle),
const SizedBox(height: SoliplexSpacing.s1),
BrowserUrlLink(url: sourceUrl),
],
const SizedBox(height: SoliplexSpacing.s2),
Text('link', style: labelStyle),
const SizedBox(height: SoliplexSpacing.s1),
DocumentSource(url: doc.sourceUrl, documentUri: doc.uri),
if (dateFields.isNotEmpty) ...[
const SizedBox(height: SoliplexSpacing.s2),
Wrap(
Expand Down
31 changes: 22 additions & 9 deletions lib/src/shared/markdown/launch_markdown_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ final _logger =
/// Opens a tapped markdown link in the platform's default handler — a browser
/// for `http(s):`, the mail client for `mailto:`, and so on.
///
/// A missing handler (e.g. no mail client installed) is a normal failure, not
/// an error, so it is logged rather than surfaced. Only the scheme is logged;
/// the full href can carry an email address.
/// Failures are logged rather than surfaced. A missing handler for a
/// non-web scheme (e.g. no mail client for `mailto:`) is a normal, expected
/// failure logged at warning level; a failure on an `http(s)` link is a dead
/// link — every target platform has a browser — so it is logged as an error.
/// Only the scheme is logged; the full href can carry an email address.
Future<void> launchMarkdownLink(String href) async {
final uri = Uri.tryParse(href);
if (uri == null) {
Expand All @@ -19,13 +21,24 @@ Future<void> launchMarkdownLink(String href) async {
try {
final launched = await launchUrl(uri);
if (!launched) {
_logger.warning('No handler available for link (scheme: ${uri.scheme})');
_logLaunchFailure(uri, 'No handler available for link');
}
} on Exception catch (error, stackTrace) {
_logger.warning(
'Failed to launch link (scheme: ${uri.scheme})',
error: error,
stackTrace: stackTrace,
);
_logLaunchFailure(uri, 'Failed to launch link', error, stackTrace);
}
}

void _logLaunchFailure(
Uri uri,
String message, [
Object? error,
StackTrace? stackTrace,
]) {
final detail = '$message (scheme: ${uri.scheme})';
final isWeb = uri.scheme == 'http' || uri.scheme == 'https';
if (isWeb) {
_logger.error(detail, error: error, stackTrace: stackTrace);
} else {
_logger.warning(detail, error: error, stackTrace: stackTrace);
}
}
Loading
Loading