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
289 changes: 13 additions & 276 deletions mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,6 @@ class ChannelsPage extends HookConsumerWidget {
);
}

Future<void> browseChannels() async {
final channels = channelsAsync.asData?.value;
if (channels == null || channels.isEmpty) {
return;
}

final selected = await showModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _BrowseChannelsSheet(channels: channels),
);
if (selected != null && context.mounted) {
await openChannel(selected);
}
}

Future<void> openQuickActions() async {
final action = await showModalBottomSheet<_QuickAction>(
context: context,
Expand Down Expand Up @@ -110,46 +93,20 @@ class ChannelsPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
titleSpacing: Grid.xs,
title: Row(
children: [
Expanded(
child: GestureDetector(
onTap: channelsAsync.hasValue ? browseChannels : null,
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(color: context.colors.outlineVariant),
),
child: Row(
children: [
Icon(
LucideIcons.search,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xxs),
Text(
'Search',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
),
),
const SizedBox(width: Grid.twelve),
ProfileAvatar(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsPage()),
),
),
],
title: Text(
'\u{1F331} Sprout',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
actions: [
ProfileAvatar(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsPage()),
),
),
const SizedBox(width: Grid.xs),
],
),
floatingActionButton: FloatingActionButton(
onPressed: openQuickActions,
Expand Down Expand Up @@ -730,203 +687,6 @@ class _CreateChannelSheet extends HookConsumerWidget {
}
}

class _BrowseChannelsSheet extends HookConsumerWidget {
final List<Channel> channels;

const _BrowseChannelsSheet({required this.channels});

@override
Widget build(BuildContext context, WidgetRef ref) {
final query = useState('');
final busyChannelId = useState<String?>(null);

final normalizedQuery = query.value.trim().toLowerCase();
final browsableChannels = channels.where((channel) {
if (channel.isDm) return false;
final visible = channel.isArchived
? channel.isMember
: channel.visibility == 'open' || channel.isMember;
if (!visible) return false;
if (normalizedQuery.isEmpty) return true;
return channel.name.toLowerCase().contains(normalizedQuery) ||
channel.description.toLowerCase().contains(normalizedQuery);
}).toList();

final notJoined = browsableChannels
.where((channel) => !channel.isMember)
.toList();
final joined = browsableChannels
.where((channel) => channel.isMember)
.toList();

Future<void> openOrJoin(Channel channel) async {
if (busyChannelId.value != null) return;

if (channel.isMember) {
Navigator.of(context).pop(channel);
return;
}

busyChannelId.value = channel.id;
try {
await ref.read(channelActionsProvider).joinChannel(channel.id);
final refreshed = await ref.read(channelsProvider.future);
final joinedChannel = refreshed.firstWhere(
(candidate) => candidate.id == channel.id,
orElse: () => channel,
);
if (context.mounted) {
Navigator.of(context).pop(joinedChannel);
}
} catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
busyChannelId.value = null;
}
}

return DraggableScrollableSheet(
initialChildSize: 0.75,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.twelve,
Grid.xs,
Grid.xxs,
),
child: GestureDetector(
onTap: () {},
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(color: context.colors.outlineVariant),
),
child: Row(
children: [
Icon(
LucideIcons.search,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: 'Search channels, forums…',
hintStyle: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
style: context.textTheme.bodyMedium,
onChanged: (value) => query.value = value,
),
),
],
),
),
),
),
Expanded(
child: browsableChannels.isEmpty
? Center(
child: Text(
'No matching results',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
)
: ListView(
controller: scrollController,
padding: EdgeInsets.only(
top: Grid.xxs,
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
children: [
if (notJoined.isNotEmpty) ...[
_MiniHeader(
label: '${notJoined.length} available to join',
),
for (final channel in notJoined)
_BrowseTile(
channel: channel,
isBusy: busyChannelId.value == channel.id,
onTap: () => openOrJoin(channel),
),
],
if (joined.isNotEmpty) ...[
_MiniHeader(label: '${joined.length} joined'),
for (final channel in joined)
_BrowseTile(
channel: channel,
isBusy: false,
onTap: () => openOrJoin(channel),
),
],
],
),
),
],
),
);
}
}

class _BrowseTile extends StatelessWidget {
final Channel channel;
final bool isBusy;
final VoidCallback onTap;

const _BrowseTile({
required this.channel,
required this.isBusy,
required this.onTap,
});

@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(
channel.isForum ? LucideIcons.messageSquareText : LucideIcons.hash,
),
title: Text(channel.name),
subtitle: Text(
channel.description.isEmpty ? 'No description' : channel.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: FilledButton.tonal(
onPressed: isBusy ? null : onTap,
child: Text(
isBusy
? 'Joining…'
: channel.isMember
? 'Open'
: 'Join',
),
),
onTap: isBusy ? null : onTap,
);
}
}

class _NewDirectMessageSheet extends HookConsumerWidget {
final String? currentPubkey;

Expand Down Expand Up @@ -1128,29 +888,6 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
}
}

class _MiniHeader extends StatelessWidget {
final String label;

const _MiniHeader({required this.label});

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.half,
),
child: Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontWeight: FontWeight.w600,
),
),
);
}
}

class _EphemeralBadge extends StatelessWidget {
final Channel channel;

Expand Down
23 changes: 23 additions & 0 deletions mobile/lib/features/channels/date_formatters.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,26 @@ bool isSameDay(int a, int b) {
).toLocal();
return dtA.year == dtB.year && dtA.month == dtB.month && dtA.day == dtB.day;
}

/// Returns a compact relative time string like "just now", "5m ago", "3h ago",
/// "2d ago", or a short date for older timestamps.
String relativeTime(int unixSeconds) {
final now = DateTime.now();
final time = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
final diff = now.difference(time);

if (diff.inMinutes < 1) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${time.month}/${time.day}/${time.year}';
}

/// Truncates a hex pubkey to the first 8 characters with an ellipsis.
String shortPubkey(String pubkey) {
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026';
return pubkey;
}
26 changes: 20 additions & 6 deletions mobile/lib/features/channels/message_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class MessageContent extends StatelessWidget {
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
final imetaByUrl = parseImetaTags(tags);

// Convert angle-bracket autolinks to standard markdown links,
// Convert autolinks and bare URLs to standard markdown links,
// but skip content inside backticks (inline code / fenced blocks).
final buffer = StringBuffer();
final parts = content.split('`');
Expand All @@ -60,12 +60,26 @@ class MessageContent extends StatelessWidget {
// Inside backticks — preserve as-is.
buffer.write('`${parts[i]}`');
} else {
buffer.write(
parts[i].replaceAllMapped(
RegExp(r'<(https?://[^>]+)>'),
(m) => '[${m[1]}](${m[1]})',
),
// 1. Angle-bracket autolinks: <https://...>
var segment = parts[i].replaceAllMapped(
RegExp(r'<(https?://[^>]+)>'),
(m) => '[${m[1]}](${m[1]})',
);
// 2. Bare URLs not already inside markdown link/image syntax.
// Negative lookbehind avoids matching URLs preceded by ]( or =
// which are already part of markdown links or imeta tags.
segment = segment.replaceAllMapped(
RegExp(r'(?<![(\]=])https?://[^\s)>\]]+'),
(m) {
final url = m[0]!;
// Skip if this URL is already a markdown link label that equals
// the URL (produced by step 1 or authored as [url](url)).
final start = m.start;
if (start >= 1 && segment[start - 1] == '[') return url;
return '[$url]($url)';
},
);
buffer.write(segment);
}
}
final processed = buffer.toString();
Expand Down
Loading