Merge branch 'main' into duncan/byoh-generic-acp-harness

Brings the branch up to date with origin/main (2a051a404) so CI and local
gates run against the tree that will actually merge. Requested as a merge
commit rather than a rebase to keep Duncan's authored history intact.

Two ratchet entries in desktop/scripts/check-file-sizes.mjs needed
composing, since both sides raised per-file ceilings from a shared base:

- src-tauri/src/commands/agent_discovery.rs — textual conflict. BYOH took
  1826 -> 2038 (+212); main #2767 took 1826 -> 1836 (+10). Resolved to
  2048 with both comment blocks kept, preserving the slack each side
  intended rather than discarding one growth.
- src-tauri/src/managed_agents/discovery.rs — no textual conflict, but the
  BYOH ceiling of 1715 predates main's +17 and the merged file is 1729
  lines, so the check failed on a clean auto-merge. Composed to 1732.

The overrides map is a ceiling (violation only when lineCount > limit), so
composing deltas is the resolution that matches the file's convention.

Verified on the merged tree: desktop file-size check clean, frontend suite
3554/3555 (the one failure is the pre-existing styled-qr-code 'Cannot find
package qrcode' local module-resolution fault, which fails identically on
pristine heads), presetLogos drift guard 10/10 with all 8 preset ids still
parsing out of main's modified discovery.rs, tsc --noEmit clean, biome
clean over 1640 files, just test-unit 5/5 crates, and desktop-tauri-test
1691/1691.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-07-25 10:37:54 -04:00
21 changed files with 2407 additions and 603 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## v0.4.26
- Style mobile pairing QR codes ([#2775](https://github.com/block/buzz/pull/2775)) ([`50655ac09`](https://github.com/block/buzz/commit/50655ac097fbf1a7db1a5284dccc7e2a0b0f1bfc))
- Refine community management flows ([#2738](https://github.com/block/buzz/pull/2738)) ([`384c72dee`](https://github.com/block/buzz/commit/384c72dee6336234beae3c1a0fec305044815245))
- docs: replace VPN-vendor references with generic wording ([#2805](https://github.com/block/buzz/pull/2805)) ([`bcca885ba`](https://github.com/block/buzz/commit/bcca885ba92b74df77e36b8e6c45a54dafc291f9))
- fix(desktop): explain macOS local network access ([#2263](https://github.com/block/buzz/pull/2263)) ([`e527d74f0`](https://github.com/block/buzz/commit/e527d74f069de5d706714d63d99a494389e824af))
- fix(desktop): clarify CLI runtime setup ([#2680](https://github.com/block/buzz/pull/2680)) ([`b8510ede1`](https://github.com/block/buzz/commit/b8510ede1b52ebe87ed3cf18cf0b2590a86b2245))
## v0.4.25 ## v0.4.25
- fix(discovery): spawn PowerShell install commands natively on Windows ([#2750](https://github.com/block/buzz/pull/2750)) ([`f3981dbfe`](https://github.com/block/buzz/commit/f3981dbfefc09e6a888ada489badb7dafdf122cf)) - fix(discovery): spawn PowerShell install commands natively on Windows ([#2750](https://github.com/block/buzz/pull/2750)) ([`f3981dbfe`](https://github.com/block/buzz/commit/f3981dbfefc09e6a888ada489badb7dafdf122cf))
+1 -1
View File
@@ -898,7 +898,7 @@ impl Db {
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .await?;
if owned_count >= relay_members::MAX_COMMUNITIES_PER_OWNER { if owned_count >= relay_members::max_communities_per_owner() {
tx.rollback().await?; tx.rollback().await?;
return Ok(CreateCommunityWithOwnerResult::LimitReached); return Ok(CreateCommunityWithOwnerResult::LimitReached);
} }
+55 -3
View File
@@ -373,11 +373,37 @@ pub enum TransferResult {
LimitReached, LimitReached,
} }
/// Maximum number of communities a single pubkey can own. Enforced at the /// Default maximum number of communities a single pubkey can own. Enforced at
/// relay layer — the authoritative layer — so that concurrent transfers or /// the relay layer — the authoritative layer — so that concurrent transfers or
/// transfer-vs-create races cannot both pass a preflight count. /// transfer-vs-create races cannot both pass a preflight count.
pub const MAX_COMMUNITIES_PER_OWNER: i64 = 3; pub const MAX_COMMUNITIES_PER_OWNER: i64 = 3;
/// Effective per-owner community limit for this deployment.
///
/// Reads `BUZZ_MAX_COMMUNITIES_PER_OWNER` once (cached for the process
/// lifetime); a missing, unparsable, or non-positive value falls back to
/// [`MAX_COMMUNITIES_PER_OWNER`]. Lets multi-tenant operators raise the cap
/// without a source change while keeping the stock default for everyone else.
pub fn max_communities_per_owner() -> i64 {
static LIMIT: std::sync::OnceLock<i64> = std::sync::OnceLock::new();
*LIMIT.get_or_init(|| {
effective_owner_limit(
std::env::var("BUZZ_MAX_COMMUNITIES_PER_OWNER")
.ok()
.as_deref(),
)
})
}
/// Pure resolution of the owner limit from a raw env value — extracted from
/// [`max_communities_per_owner`] so the parse/fallback rules are testable
/// without process-global env state.
fn effective_owner_limit(raw: Option<&str>) -> i64 {
raw.and_then(|value| value.trim().parse::<i64>().ok())
.filter(|limit| *limit > 0)
.unwrap_or(MAX_COMMUNITIES_PER_OWNER)
}
/// Stable advisory-lock key for serializing ownership-granting operations /// Stable advisory-lock key for serializing ownership-granting operations
/// (transfer + create) per recipient pubkey. Uses FNV-1a over the hex pubkey /// (transfer + create) per recipient pubkey. Uses FNV-1a over the hex pubkey
/// so the same recipient always maps to the same lock across processes. /// so the same recipient always maps to the same lock across processes.
@@ -472,7 +498,7 @@ pub async fn transfer_ownership(
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .await?;
if owned_count >= MAX_COMMUNITIES_PER_OWNER { if owned_count >= max_communities_per_owner() {
tx.rollback().await?; tx.rollback().await?;
return Ok(TransferResult::LimitReached); return Ok(TransferResult::LimitReached);
} }
@@ -555,6 +581,32 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn owner_limit_defaults_when_unset_or_invalid() {
assert_eq!(
super::effective_owner_limit(None),
super::MAX_COMMUNITIES_PER_OWNER
);
assert_eq!(
super::effective_owner_limit(Some("not-a-number")),
super::MAX_COMMUNITIES_PER_OWNER
);
assert_eq!(
super::effective_owner_limit(Some("0")),
super::MAX_COMMUNITIES_PER_OWNER
);
assert_eq!(
super::effective_owner_limit(Some("-5")),
super::MAX_COMMUNITIES_PER_OWNER
);
}
#[test]
fn owner_limit_honors_positive_override() {
assert_eq!(super::effective_owner_limit(Some("100")), 100);
assert_eq!(super::effective_owner_limit(Some(" 12 ")), 12);
}
use super::*; use super::*;
use uuid::Uuid; use uuid::Uuid;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "buzz", "name": "buzz",
"private": true, "private": true,
"version": "0.4.25", "version": "0.4.26",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+8 -2
View File
@@ -337,7 +337,10 @@ const overrides = new Map([
// +29: rebase over main (#2680) — discover_acp_runtime_phase1 extracted // +29: rebase over main (#2680) — discover_acp_runtime_phase1 extracted
// helper + discover_acp_runtime_availability; both load-bearing for // helper + discover_acp_runtime_availability; both load-bearing for
// post-install verification. Semantic composition with BYOH changes. // post-install verification. Semantic composition with BYOH changes.
["src-tauri/src/managed_agents/discovery.rs", 1715], // +17: merge of main (#2767) — codex_adapter_is_outdated_with_path split out
// so Codex adapter planning takes an explicit PATH. Auto-merged cleanly; only
// the ceiling needed composing with the BYOH growth above.
["src-tauri/src/managed_agents/discovery.rs", 1732],
// BYOH — save_custom_harness_to_dir (backup-swap atomic write) + save_and_warm / // BYOH — save_custom_harness_to_dir (backup-swap atomic write) + save_and_warm /
// delete_and_warm (persist-mutex serialization for concurrent-safe registry // delete_and_warm (persist-mutex serialization for concurrent-safe registry
// refresh, B-6). Also: id/collision/load/registry tests (from the file base) + // refresh, B-6). Also: id/collision/load/registry tests (from the file base) +
@@ -563,7 +566,10 @@ const overrides = new Map([
// return value so the frontend immediately has the updated env. // return value so the frontend immediately has the updated env.
// +1: rebase over main (#2680) — requires_external_cli: false added to // +1: rebase over main (#2680) — requires_external_cli: false added to
// save_custom_harness catalog entry construction (new required field). // save_custom_harness catalog entry construction (new required field).
["src-tauri/src/commands/agent_discovery.rs", 2038], // +10: merge of main (#2767) — pass an explicit PATH through Codex adapter
// install planning so unit tests avoid the process-global login-shell PATH
// cache. Independent of the BYOH growth above; the two deltas compose.
["src-tauri/src/commands/agent_discovery.rs", 2048],
// draft-persistence predicate: submit-time `loadDraft` check + inline comment // draft-persistence predicate: submit-time `loadDraft` check + inline comment
// + deps-array entry in submitMessage closes the never-persisted-boundary // + deps-array entry in submitMessage closes the never-persisted-boundary
// defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to
+1 -1
View File
@@ -1008,7 +1008,7 @@ dependencies = [
[[package]] [[package]]
name = "buzz-desktop" name = "buzz-desktop"
version = "0.4.25" version = "0.4.26"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arboard", "arboard",
+1 -1
View File
@@ -2,7 +2,7 @@
[package] [package]
name = "buzz-desktop" name = "buzz-desktop"
version = "0.4.25" version = "0.4.26"
description = "Buzz desktop app" description = "Buzz desktop app"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -44,11 +44,19 @@ pub(crate) fn plan_adapter_install<'c>(
runtime_id: &str, runtime_id: &str,
adapter_path: Option<&std::path::Path>, adapter_path: Option<&std::path::Path>,
adapter_install_commands: &'c [&'c str], adapter_install_commands: &'c [&'c str],
adapter_probe_path: Option<&str>,
) -> Option<Vec<&'c str>> { ) -> Option<Vec<&'c str>> {
match adapter_path { match adapter_path {
// Adapter present and current — no install needed. // Adapter present and current — no install needed.
Some(_) if runtime_id != "codex" => None, Some(_) if runtime_id != "codex" => None,
Some(path) if !crate::managed_agents::codex_adapter_is_outdated(path) => None, Some(path)
if !crate::managed_agents::codex_adapter_is_outdated_with_path(
path,
adapter_probe_path,
) =>
{
None
}
// Codex adapter is outdated: uninstall the old package first so npm // Codex adapter is outdated: uninstall the old package first so npm
// doesn't hit EEXIST on the shared `codex-acp` bin-link, then install. // doesn't hit EEXIST on the shared `codex-acp` bin-link, then install.
Some(_) => Some(vec![ Some(_) => Some(vec![
@@ -327,10 +335,12 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
.commands .commands
.iter() .iter()
.find_map(|cmd| crate::managed_agents::resolve_command(cmd)); .find_map(|cmd| crate::managed_agents::resolve_command(cmd));
let adapter_probe_path = crate::managed_agents::readiness::cli_probe::augmented_path();
if let Some(cmds) = plan_adapter_install( if let Some(cmds) = plan_adapter_install(
runtime_id, runtime_id,
adapter_path.as_deref(), adapter_path.as_deref(),
runtime.adapter_install_commands, runtime.adapter_install_commands,
adapter_probe_path.as_deref(),
) { ) {
let use_managed_npm = let use_managed_npm =
cmds.iter().any(|cmd| is_npm_global_install(cmd)) && managed_node_runtime_supported(); cmds.iter().any(|cmd| is_npm_global_install(cmd)) && managed_node_runtime_supported();
@@ -1331,7 +1341,7 @@ mod tests {
.expect("chmod script"); .expect("chmod script");
let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"];
let plan = plan_adapter_install("codex", Some(&bin), install_cmds); let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin"));
assert!( assert!(
plan.is_some(), plan.is_some(),
@@ -1366,7 +1376,7 @@ mod tests {
.expect("chmod script"); .expect("chmod script");
let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"];
let plan = plan_adapter_install("codex", Some(&bin), install_cmds); let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin"));
assert!( assert!(
plan.is_none(), plan.is_none(),
@@ -1377,7 +1387,7 @@ mod tests {
#[test] #[test]
fn test_plan_adapter_install_returns_catalog_cmds_when_no_adapter_path() { fn test_plan_adapter_install_returns_catalog_cmds_when_no_adapter_path() {
let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"];
let plan = plan_adapter_install("codex", None, install_cmds); let plan = plan_adapter_install("codex", None, install_cmds, None);
assert!(plan.is_some(), "missing adapter must trigger install plan"); assert!(plan.is_some(), "missing adapter must trigger install plan");
// Missing arm: use the catalog's install commands directly (no prior // Missing arm: use the catalog's install commands directly (no prior
// package to uninstall — fresh install, not a reinstall). // package to uninstall — fresh install, not a reinstall).
@@ -1401,7 +1411,7 @@ mod tests {
.expect("chmod script"); .expect("chmod script");
let install_cmds = &["npm install -g @block/goose-acp"]; let install_cmds = &["npm install -g @block/goose-acp"];
let plan = plan_adapter_install("goose", Some(&bin), install_cmds); let plan = plan_adapter_install("goose", Some(&bin), install_cmds, None);
assert!( assert!(
plan.is_none(), plan.is_none(),
"non-codex runtime with resolved binary must not trigger reinstall" "non-codex runtime with resolved binary must not trigger reinstall"
@@ -1228,9 +1228,26 @@ pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus {
} }
/// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1) /// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1)
/// or cannot be probed. Thin wrapper around [`codex_adapter_availability`]. /// or cannot be probed using `augmented_path`. Thin wrapper around
/// [`codex_adapter_is_outdated_with_path`].
#[cfg(test)]
pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool {
codex_adapter_availability(path) == AcpAvailabilityStatus::AdapterOutdated codex_adapter_is_outdated_with_path(
path,
crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(),
)
}
/// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1)
/// or cannot be probed with the supplied PATH.
pub(crate) fn codex_adapter_is_outdated_with_path(
path: &Path,
augmented_path: Option<&str>,
) -> bool {
!matches!(
probe_codex_acp_major_version_with_path(path, augmented_path),
Some(major) if major >= 1
)
} }
/// Intermediate struct built before the (potentially slow) auth probe phase. /// Intermediate struct built before the (potentially slow) auth probe phase.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Buzz", "productName": "Buzz",
"version": "0.4.25", "version": "0.4.26",
"identifier": "xyz.block.buzz.app", "identifier": "xyz.block.buzz.app",
"build": { "build": {
"beforeDevCommand": { "beforeDevCommand": {
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -87,8 +88,72 @@ class DirectoryUser {
} }
return pubkey.length > 16 ? '${pubkey.substring(0, 16)}…' : pubkey; return pubkey.length > 16 ? '${pubkey.substring(0, 16)}…' : pubkey;
} }
/// First visible character used when no avatar image is available.
String get initial => label.isNotEmpty ? label[0].toUpperCase() : '?';
} }
/// Whether the mobile DM directory should show local preview identities.
const bool mockDmDirectoryEnabled =
kDebugMode && bool.fromEnvironment('BUZZ_MOCK_DM_DIRECTORY');
/// Whether the new-DM picker should use local preview identities.
///
/// Debug builds fall back automatically while disconnected. Production builds
/// always use the active relay directory.
final dmDirectoryPreviewEnabledProvider = Provider<bool>((ref) {
if (mockDmDirectoryEnabled) {
return true;
}
final relayStatus = ref.watch(
relaySessionProvider.select((session) => session.status),
);
return kDebugMode && relayStatus != SessionStatus.connected;
});
String _mockEmojiAvatar(String emoji, String color) {
return Uri.dataFromString(
'<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128">'
'<rect width="128" height="128" fill="$color"/>'
'<text x="64" y="80" text-anchor="middle" font-size="58">$emoji</text>'
'</svg>',
mimeType: 'image/svg+xml',
encoding: utf8,
).toString();
}
/// Local identities used to preview the new-DM picker without a relay.
final dmDirectoryPreviewUsers = List<DirectoryUser>.unmodifiable([
DirectoryUser(
pubkey: '1111111111111111111111111111111111111111111111111111111111111111',
displayName: 'Maya Chen',
avatarUrl: _mockEmojiAvatar('🎨', '#8AADF4'),
nip05Handle: 'maya@demo.buzz',
),
DirectoryUser(
pubkey: '2222222222222222222222222222222222222222222222222222222222222222',
displayName: 'Jordan Brooks',
avatarUrl: _mockEmojiAvatar('🌱', '#A6DA95'),
nip05Handle: 'jordan@demo.buzz',
),
const DirectoryUser(
pubkey: '3333333333333333333333333333333333333333333333333333333333333333',
displayName: 'Priya Shah',
nip05Handle: 'priya@demo.buzz',
),
DirectoryUser(
pubkey: '4444444444444444444444444444444444444444444444444444444444444444',
displayName: 'Theo Martin',
avatarUrl: _mockEmojiAvatar('💻', '#C6A0F6'),
nip05Handle: 'theo@demo.buzz',
),
const DirectoryUser(
pubkey: '5555555555555555555555555555555555555555555555555555555555555555',
displayName: 'Sam Rivera',
nip05Handle: 'sam@demo.buzz',
),
]);
final currentPubkeyProvider = Provider<String?>((ref) { final currentPubkeyProvider = Provider<String?>((ref) {
// Prefer the explicitly-derived pubkey from nsec — this is the signing // Prefer the explicitly-derived pubkey from nsec — this is the signing
// identity used for events. // identity used for events.
@@ -110,6 +175,153 @@ final currentPubkeyProvider = Provider<String?>((ref) {
return null; return null;
}); });
/// Extracts the unique member pubkeys advertised by relay membership events.
///
/// Buzz relays use `member` tags, while older NIP-29-compatible relays may
/// still expose the same directory through `p` tags.
@visibleForTesting
List<String> relayMemberPubkeysFromEvents(List<NostrEvent> events) {
final pubkeys = <String>{};
for (final event in events) {
for (final tag in event.tags) {
if (tag.length < 2 || (tag[0] != 'member' && tag[0] != 'p')) {
continue;
}
final pubkey = tag[1].trim().toLowerCase();
if (pubkey.isNotEmpty) {
pubkeys.add(pubkey);
}
}
}
return pubkeys.toList();
}
/// Converts kind:0 events into a deduplicated, alphabetized people directory.
@visibleForTesting
List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) {
continue;
}
final pubkey = event.pubkey.toLowerCase();
final current = latestByPubkey[pubkey];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pubkey] = event;
}
}
return [
for (final event in latestByPubkey.values)
if (ProfileData.fromEvent(event) case final profile)
DirectoryUser(
pubkey: profile.pubkey.toLowerCase(),
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
}
/// People and agents discoverable on the active relay.
///
/// This mirrors desktop's empty-query people directory by listing kind:0
/// profiles through the HTTP bridge. The relay membership snapshot remains a
/// fallback for older relays that do not support directory listing.
final relayDirectoryUsersProvider = FutureProvider<List<DirectoryUser>>((
ref,
) async {
if (mockDmDirectoryEnabled) {
return dmDirectoryPreviewUsers;
}
final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final directoryEvents = await session.queryRelay([
const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}),
]);
var users = directoryUsersFromProfileEvents(directoryEvents);
if (users.isNotEmpty) {
return users
.where((user) => user.pubkey.toLowerCase() != currentPubkey)
.toList();
}
final membershipEvents = await session.fetchHistory(
NostrFilters.relayMembers(),
);
final memberPubkeys = relayMemberPubkeysFromEvents(
membershipEvents,
).where((pubkey) => pubkey != currentPubkey).toList();
if (memberPubkeys.isEmpty) {
return const [];
}
final profileEvents = await session.queryRelay([
NostrFilters.profilesBatch(memberPubkeys),
]);
final profilesByPubkey = {
for (final event in profileEvents)
event.pubkey.toLowerCase(): ProfileData.fromEvent(event),
};
users =
[
for (final pubkey in memberPubkeys)
if (profilesByPubkey[pubkey] case final profile?)
DirectoryUser(
pubkey: pubkey,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
)
else
DirectoryUser(pubkey: pubkey),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
return users;
});
/// Prefix-searches the active relay's kind:0 people directory.
final relayDirectorySearchProvider =
FutureProvider.family<List<DirectoryUser>, String>((ref, query) async {
final trimmed = query.trim();
if (mockDmDirectoryEnabled) {
final normalizedQuery = trimmed.toLowerCase();
return dmDirectoryPreviewUsers
.where(
(user) =>
user.label.toLowerCase().contains(normalizedQuery) ||
user.secondaryLabel.toLowerCase().contains(normalizedQuery),
)
.toList();
}
if (trimmed.isEmpty) {
return ref.watch(relayDirectoryUsersProvider.future);
}
final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final events = await session.queryRelay([
NostrFilters.searchUsers(trimmed, limit: 50),
]);
return directoryUsersFromProfileEvents(
events,
).where((user) => user.pubkey.toLowerCase() != currentPubkey).toList();
});
/// Build [ChannelDetails] from a kind:39000 metadata event. /// Build [ChannelDetails] from a kind:39000 metadata event.
/// ///
/// Exposed as a pure function so the mapping can be unit-tested without /// Exposed as a pure function so the mapping can be unit-tested without
@@ -227,6 +439,30 @@ List<List<String>> buildDeleteMessageTags({
]; ];
} }
/// Builds the signed kind:9007 tags used by mobile channel creation.
List<List<String>> buildCreateChannelTags({
required String channelId,
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
}) {
if (ttlSeconds != null && ttlSeconds <= 0) {
throw ArgumentError.value(ttlSeconds, 'ttlSeconds', 'must be positive');
}
return [
['h', channelId],
['name', name],
['visibility', visibility],
['channel_type', channelType],
if (description case final about? when about.trim().isNotEmpty)
['about', about.trim()],
if (ttlSeconds != null) ['ttl', ttlSeconds.toString()],
];
}
class ChannelActions { class ChannelActions {
final Ref _ref; final Ref _ref;
final RelaySessionNotifier _session; final RelaySessionNotifier _session;
@@ -248,16 +484,17 @@ class ChannelActions {
required String channelType, required String channelType,
required String visibility, required String visibility,
String? description, String? description,
int? ttlSeconds,
}) async { }) async {
final channelId = _newUuidV4(); final channelId = _newUuidV4();
final tags = <List<String>>[ final tags = buildCreateChannelTags(
['h', channelId], channelId: channelId,
['name', name], name: name,
['visibility', visibility], channelType: channelType,
['channel_type', channelType], visibility: visibility,
if (description case final about? when about.trim().isNotEmpty) description: description,
['about', about.trim()], ttlSeconds: ttlSeconds,
]; );
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags); await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
return _refreshChannelsAndRead(channelId); return _refreshChannelsAndRead(channelId);
} }
@@ -349,19 +586,10 @@ class ChannelActions {
final trimmed = query.trim(); final trimmed = query.trim();
if (trimmed.isEmpty) return const []; if (trimmed.isEmpty) return const [];
final events = await _session.fetchHistory( final events = await _session.queryRelay([
NostrFilter(kinds: [0], search: trimmed, limit: limit), NostrFilters.searchUsers(trimmed, limit: limit),
); ]);
return events return directoryUsersFromProfileEvents(events)
.map((event) {
final data = ProfileData.fromEvent(event);
return DirectoryUser(
pubkey: data.pubkey,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
nip05Handle: data.nip05,
);
})
.where( .where(
(user) => (user) =>
_currentPubkey == null || _currentPubkey == null ||
+11 -64
View File
@@ -5,6 +5,7 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/physics.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -47,6 +48,7 @@ part 'channels_page/sheets.dart';
part 'channels_page/badges.dart'; part 'channels_page/badges.dart';
part 'channels_page/community.dart'; part 'channels_page/community.dart';
part 'channels_page/quick_actions.dart'; part 'channels_page/quick_actions.dart';
part 'channels_page/quick_actions_launcher.dart';
enum _QuickAction { createChannel, newDm } enum _QuickAction { createChannel, newDm }
@@ -156,8 +158,6 @@ class ChannelsPage extends HookConsumerWidget {
cachedChannels.value = data; cachedChannels.value = data;
} }
final channels = cachedChannels.value; final channels = cachedChannels.value;
final quickActionsOpen = useState(false);
Future<void> openChannel(Channel channel) async { Future<void> openChannel(Channel channel) async {
if (!context.mounted) return; if (!context.mounted) return;
await Navigator.of(context).push( await Navigator.of(context).push(
@@ -167,39 +167,6 @@ class ChannelsPage extends HookConsumerWidget {
); );
} }
Future<void> selectQuickAction(_QuickAction action) async {
final reducedMotion = MediaQuery.of(context).disableAnimations;
quickActionsOpen.value = false;
if (!reducedMotion) {
await Future<void>.delayed(_kMorphCloseDuration);
}
if (!context.mounted) return;
switch (action) {
case _QuickAction.createChannel:
final created = await showModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
);
if (created != null && context.mounted) {
await openChannel(created);
}
case _QuickAction.newDm:
final opened = await showModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
_NewDirectMessageSheet(currentPubkey: currentPubkey),
);
if (opened != null && context.mounted) {
await openChannel(opened);
}
}
}
// Only surface fetch errors while the relay is stably connected. During a // Only surface fetch errors while the relay is stably connected. During a
// reconnect the session owns recovery, so a cancelled in-flight query must // reconnect the session owns recovery, so a cancelled in-flight query must
// not turn into a manual Retry page. // not turn into a manual Retry page.
@@ -258,35 +225,15 @@ class ChannelsPage extends HookConsumerWidget {
), ),
], ],
), ),
floatingActionButton: _MorphingQuickActionsButton( body: _ChannelsBody(
open: quickActionsOpen.value, channels: channels,
onToggle: () => quickActionsOpen.value = !quickActionsOpen.value, channelsAsync: channelsAsync,
onSelected: (action) => unawaited(selectQuickAction(action)), showError: showError.value,
), sessionStatus: sessionState.status,
body: Stack( showConnectionBanner: showConnectionBanner.value,
children: [ currentPubkey: currentPubkey,
_ChannelsBody( onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
channels: channels, onSelectChannel: openChannel,
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
showConnectionBanner: showConnectionBanner.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
),
if (quickActionsOpen.value)
Positioned.fill(
child: Semantics(
button: true,
label: 'Close quick actions',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => quickActionsOpen.value = false,
),
),
),
],
), ),
); );
} }
@@ -1,24 +1,29 @@
part of '../channels_page.dart'; part of '../channels_page.dart';
const _kMorphOpenDuration = Duration(milliseconds: 350); const _kMorphOpenDuration = Duration(milliseconds: 280);
const _kMorphCloseDuration = Duration(milliseconds: 250); const _kMorphCloseDuration = Duration(milliseconds: 240);
const _kMorphFadeDuration = Duration(milliseconds: 200); const _kMorphFadeDuration = Duration(milliseconds: 200);
const _kMorphOpenCurve = Cubic(0.34, 1.25, 0.64, 1);
const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1); const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1);
const double _kMorphOpenBounce = 0.14;
const double _kMorphCloseBounce = 0.06;
const double _kMorphClosedSize = 56; const double _kMorphClosedSize = 56;
const double _kMorphOpenHeight = 160; const double _kMorphOpenHeight = 160;
const double _kMorphOpenRadius = 20; const double _kMorphOpenRadius = 20;
const double _kMorphSlide = 40; const double _kMorphSlide = 40;
const double _kMorphScale = 0.97; const double _kMorphScale = 0.97;
const double _kMorphBlur = 2; const double _kMorphBlur = 2;
const double _kQuickActionCardOverlayOpacity = 0.1;
const double _kQuickActionCardRadius = _kMorphOpenRadius - Grid.xxs;
class _MorphingQuickActionsButton extends HookWidget { class _MorphingQuickActionsButton extends HookWidget {
final bool open; final bool open;
final double openEdgeOffset;
final VoidCallback onToggle; final VoidCallback onToggle;
final ValueChanged<_QuickAction> onSelected; final ValueChanged<_QuickAction> onSelected;
const _MorphingQuickActionsButton({ const _MorphingQuickActionsButton({
required this.open, required this.open,
required this.openEdgeOffset,
required this.onToggle, required this.onToggle,
required this.onSelected, required this.onSelected,
}); });
@@ -27,28 +32,18 @@ class _MorphingQuickActionsButton extends HookWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations; final reducedMotion = MediaQuery.of(context).disableAnimations;
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final openWidth = final openWidth = (mediaQuery.size.width - (Grid.gutter * 2))
(mediaQuery.size.width - mediaQuery.padding.horizontal - (Grid.xs * 2)) .clamp(_kMorphClosedSize, double.infinity)
.clamp(_kMorphClosedSize, double.infinity) .toDouble();
.toDouble();
final surfaceController = useAnimationController( final surfaceController = useAnimationController(
duration: reducedMotion ? Duration.zero : _kMorphOpenDuration,
reverseDuration: reducedMotion ? Duration.zero : _kMorphCloseDuration,
initialValue: open ? 1 : 0, initialValue: open ? 1 : 0,
upperBound: 1.02,
); );
final fadeController = useAnimationController( final fadeController = useAnimationController(
duration: reducedMotion ? Duration.zero : _kMorphFadeDuration, duration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
reverseDuration: reducedMotion ? Duration.zero : _kMorphFadeDuration, reverseDuration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
initialValue: open ? 1 : 0, initialValue: open ? 1 : 0,
); );
final surfaceAnimation = useMemoized(
() => CurvedAnimation(
parent: surfaceController,
curve: _kMorphOpenCurve,
reverseCurve: _kMorphCloseCurve,
),
[surfaceController],
);
final fadeAnimation = useMemoized( final fadeAnimation = useMemoized(
() => CurvedAnimation( () => CurvedAnimation(
parent: fadeController, parent: fadeController,
@@ -58,20 +53,13 @@ class _MorphingQuickActionsButton extends HookWidget {
[fadeController], [fadeController],
); );
final animation = useMemoized( final animation = useMemoized(
() => Listenable.merge([surfaceAnimation, fadeAnimation]), () => Listenable.merge([surfaceController, fadeAnimation]),
[surfaceAnimation, fadeAnimation], [surfaceController, fadeAnimation],
); );
useEffect(() => surfaceAnimation.dispose, [surfaceAnimation]);
useEffect(() => fadeAnimation.dispose, [fadeAnimation]); useEffect(() => fadeAnimation.dispose, [fadeAnimation]);
useEffect(() { useEffect(() {
surfaceController.duration = reducedMotion
? Duration.zero
: _kMorphOpenDuration;
surfaceController.reverseDuration = reducedMotion
? Duration.zero
: _kMorphCloseDuration;
fadeController.duration = reducedMotion fadeController.duration = reducedMotion
? Duration.zero ? Duration.zero
: _kMorphFadeDuration; : _kMorphFadeDuration;
@@ -82,12 +70,29 @@ class _MorphingQuickActionsButton extends HookWidget {
if (reducedMotion) { if (reducedMotion) {
surfaceController.value = open ? 1 : 0; surfaceController.value = open ? 1 : 0;
fadeController.value = open ? 1 : 0; fadeController.value = open ? 1 : 0;
} else if (open) {
unawaited(surfaceController.forward());
unawaited(fadeController.forward());
} else { } else {
unawaited(surfaceController.reverse()); final target = open ? 1.0 : 0.0;
unawaited(fadeController.reverse()); if ((surfaceController.value - target).abs() > 0.001) {
unawaited(
surfaceController.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: open ? _kMorphOpenDuration : _kMorphCloseDuration,
bounce: open ? _kMorphOpenBounce : _kMorphCloseBounce,
),
surfaceController.value,
target,
surfaceController.velocity,
snapToEnd: true,
),
),
);
}
if (open) {
unawaited(fadeController.forward());
} else {
unawaited(fadeController.reverse());
}
} }
return null; return null;
}, [fadeController, open, reducedMotion, surfaceController]); }, [fadeController, open, reducedMotion, surfaceController]);
@@ -95,7 +100,8 @@ class _MorphingQuickActionsButton extends HookWidget {
return AnimatedBuilder( return AnimatedBuilder(
animation: animation, animation: animation,
builder: (context, _) { builder: (context, _) {
final surfaceValue = surfaceAnimation.value; final surfaceValue = surfaceController.value;
final surfaceProgress = surfaceValue.clamp(0.0, 1.0);
final fadeValue = fadeAnimation.value.clamp(0.0, 1.0); final fadeValue = fadeAnimation.value.clamp(0.0, 1.0);
final width = lerpDouble(_kMorphClosedSize, openWidth, surfaceValue)!; final width = lerpDouble(_kMorphClosedSize, openWidth, surfaceValue)!;
final height = lerpDouble( final height = lerpDouble(
@@ -110,58 +116,63 @@ class _MorphingQuickActionsButton extends HookWidget {
)!; )!;
final borderRadius = BorderRadius.circular(radius); final borderRadius = BorderRadius.circular(radius);
return SizedBox( return Transform.translate(
width: width, offset: Offset(openEdgeOffset * surfaceProgress, 0),
height: height, child: SizedBox(
child: DecoratedBox( key: const Key('quick-actions-surface'),
decoration: BoxDecoration( width: width,
color: context.colors.primary, height: height,
borderRadius: borderRadius, child: DecoratedBox(
boxShadow: [ decoration: BoxDecoration(
BoxShadow( color: context.colors.primary,
color: context.colors.shadow.withValues(alpha: 0.24), borderRadius: borderRadius,
blurRadius: 12, boxShadow: [
offset: const Offset(0, 4), BoxShadow(
), color: context.colors.shadow.withValues(alpha: 0.24),
], blurRadius: 12,
), offset: const Offset(0, 4),
child: ClipRRect( ),
borderRadius: borderRadius, ],
clipBehavior: Clip.antiAlias, ),
child: Material( child: ClipRRect(
type: MaterialType.transparency, borderRadius: borderRadius,
child: Stack( clipBehavior: Clip.antiAlias,
children: [ child: Material(
Positioned.fill( type: MaterialType.transparency,
child: OverflowBox( child: Stack(
alignment: Alignment.bottomRight, children: [
minWidth: openWidth, Positioned.fill(
maxWidth: openWidth, child: OverflowBox(
minHeight: _kMorphOpenHeight, alignment: Alignment.bottomRight,
maxHeight: _kMorphOpenHeight, minWidth: openWidth,
child: IgnorePointer( maxWidth: openWidth,
ignoring: !open || fadeValue < 0.9, minHeight: _kMorphOpenHeight,
child: ExcludeSemantics( maxHeight: _kMorphOpenHeight,
excluding: !open, child: IgnorePointer(
child: Opacity( ignoring: !open || fadeValue < 0.9,
opacity: fadeValue, child: ExcludeSemantics(
child: ImageFiltered( excluding: !open,
imageFilter: ImageFilter.blur( child: Opacity(
sigmaX: _kMorphBlur * (1 - fadeValue), opacity: fadeValue,
sigmaY: _kMorphBlur * (1 - fadeValue), child: ImageFiltered(
), imageFilter: ImageFilter.blur(
child: Transform.translate( sigmaX: _kMorphBlur * (1 - fadeValue),
offset: Offset( sigmaY: _kMorphBlur * (1 - fadeValue),
_kMorphSlide * (1 - surfaceValue),
0,
), ),
child: Transform.scale( child: Transform.translate(
alignment: Alignment.bottomRight, offset: Offset(
scale: _kMorphSlide * (1 - surfaceProgress),
_kMorphScale + 0,
((1 - _kMorphScale) * surfaceValue), ),
child: _QuickActionsMenu( child: Transform.scale(
onSelected: onSelected, alignment: Alignment.bottomRight,
scale:
_kMorphScale +
((1 - _kMorphScale) *
surfaceProgress),
child: _QuickActionsMenu(
onSelected: onSelected,
),
), ),
), ),
), ),
@@ -170,43 +181,48 @@ class _MorphingQuickActionsButton extends HookWidget {
), ),
), ),
), ),
), Positioned(
Positioned( right: 0,
right: 0, bottom: 0,
bottom: 0, width: _kMorphClosedSize,
width: _kMorphClosedSize, height: _kMorphClosedSize,
height: _kMorphClosedSize, child: IgnorePointer(
child: IgnorePointer( ignoring: open,
ignoring: open, child: ExcludeSemantics(
child: ExcludeSemantics( excluding: open,
excluding: open, child: Opacity(
child: Opacity( opacity: 1 - fadeValue,
opacity: 1 - fadeValue, child: ImageFiltered(
child: ImageFiltered( imageFilter: ImageFilter.blur(
imageFilter: ImageFilter.blur( sigmaX: _kMorphBlur * fadeValue,
sigmaX: _kMorphBlur * fadeValue, sigmaY: _kMorphBlur * fadeValue,
sigmaY: _kMorphBlur * fadeValue, ),
), child: Transform.translate(
child: Transform.translate( offset: Offset(
offset: Offset(-_kMorphSlide * surfaceValue, 0), -_kMorphSlide * surfaceProgress,
child: Transform.rotate( 0,
angle: (pi / 4) * surfaceValue, ),
child: Transform.scale( child: Transform.rotate(
scale: angle: (pi / 4) * surfaceProgress,
1 - ((1 - _kMorphScale) * surfaceValue), child: Transform.scale(
child: Tooltip( scale:
message: 'Create or start conversation', 1 -
child: Semantics( ((1 - _kMorphScale) *
button: true, surfaceProgress),
label: 'Create or start conversation', child: Tooltip(
expanded: open, message: 'Create or start conversation',
child: InkWell( child: Semantics(
customBorder: const CircleBorder(), button: true,
onTap: onToggle, label: 'Create or start conversation',
child: Center( expanded: open,
child: Icon( child: InkWell(
LucideIcons.plus, customBorder: const CircleBorder(),
color: context.colors.onPrimary, onTap: onToggle,
child: Center(
child: Icon(
LucideIcons.plus,
color: context.colors.onPrimary,
),
), ),
), ),
), ),
@@ -219,8 +235,8 @@ class _MorphingQuickActionsButton extends HookWidget {
), ),
), ),
), ),
), ],
], ),
), ),
), ),
), ),
@@ -239,20 +255,23 @@ class _QuickActionsMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs), key: const Key('quick-actions-menu'),
padding: const EdgeInsets.all(Grid.xxs),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_QuickActionItem( _QuickActionItem(
icon: LucideIcons.hash, icon: LucideIcons.hash,
title: 'Create channel', title: 'Create channel',
subtitle: 'Start a new stream channel', key: const Key('quick-action-create-channel-card'),
onTap: () => onSelected(_QuickAction.createChannel), onTap: () => onSelected(_QuickAction.createChannel),
), ),
const SizedBox(height: Grid.xxs),
_QuickActionItem( _QuickActionItem(
icon: LucideIcons.messagesSquare, icon: LucideIcons.messagesSquare,
title: 'New direct message', title: 'New direct message',
subtitle: 'Message one or more people', key: const Key('quick-action-new-dm-card'),
onTap: () => onSelected(_QuickAction.newDm), onTap: () => onSelected(_QuickAction.newDm),
), ),
], ],
@@ -264,56 +283,55 @@ class _QuickActionsMenu extends StatelessWidget {
class _QuickActionItem extends StatelessWidget { class _QuickActionItem extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
final String subtitle;
final VoidCallback onTap; final VoidCallback onTap;
const _QuickActionItem({ const _QuickActionItem({
super.key,
required this.icon, required this.icon,
required this.title, required this.title,
required this.subtitle,
required this.onTap, required this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final foreground = context.colors.onPrimary; final foreground = context.colors.onPrimary;
final background = Color.alphaBlend(
foreground.withValues(alpha: _kQuickActionCardOverlayOpacity),
context.colors.primary,
);
return Expanded( return Expanded(
child: InkWell( child: Material(
onTap: onTap, color: background,
child: Padding( borderRadius: BorderRadius.circular(_kQuickActionCardRadius),
padding: const EdgeInsets.symmetric(horizontal: Grid.xs), clipBehavior: Clip.antiAlias,
child: Row( child: InkWell(
children: [ onTap: onTap,
Icon(icon, size: 22, color: foreground), child: Padding(
const SizedBox(width: Grid.twelve), padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
Expanded( child: Row(
child: Column( children: [
mainAxisAlignment: MainAxisAlignment.center, Icon(icon, size: 24, color: foreground),
crossAxisAlignment: CrossAxisAlignment.start, const SizedBox(width: Grid.xs),
children: [ Expanded(
Text( child: Column(
title, mainAxisAlignment: MainAxisAlignment.center,
maxLines: 1, crossAxisAlignment: CrossAxisAlignment.start,
overflow: TextOverflow.ellipsis, children: [
style: context.textTheme.bodyLarge?.copyWith( Text(
color: foreground, title,
fontWeight: FontWeight.w600, maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
), ),
), ],
const SizedBox(height: Grid.quarter), ),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: foreground.withValues(alpha: 0.72),
),
),
],
), ),
), ],
], ),
), ),
), ),
), ),
@@ -0,0 +1,182 @@
part of '../channels_page.dart';
const _kQuickActionsTabMotionDuration = Duration(milliseconds: 220);
const _kQuickActionsTabMotionCurve = Cubic(0.77, 0, 0.175, 1);
const _kQuickActionsHiddenOverlap = Grid.half;
const _kQuickActionsHiddenScale = 0.8;
/// Places the channel quick-actions button beside mobile navigation.
///
/// The button remains available only on Home and moves behind the navigation
/// bar when another destination is selected.
class ChannelQuickActionsLauncher extends HookConsumerWidget {
/// Whether the launcher should be visible beside the navigation bar.
final bool visible;
/// Height of the navigation bar used to vertically center the closed button.
final double navigationBarHeight;
/// Space between the navigation bar and the bottom safe area.
final double navigationBarBottomGap;
/// Full width of the navigation bar, including its inner edge padding.
final double navigationBarWidth;
/// Bottom system inset used by the navigation bar's [SafeArea].
final double systemBottomInset;
/// Distance between the launcher and the right edge of the screen.
final double rightInset;
/// Creates a launcher aligned with the supplied navigation geometry.
const ChannelQuickActionsLauncher({
super.key,
required this.visible,
required this.navigationBarHeight,
required this.navigationBarBottomGap,
required this.navigationBarWidth,
required this.systemBottomInset,
required this.rightInset,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentPubkey = ref.watch(currentPubkeyProvider);
final quickActionsOpen = useState(false);
final reducedMotion = MediaQuery.of(context).disableAnimations;
final navigationBottomInset = systemBottomInset > navigationBarBottomGap
? systemBottomInset
: navigationBarBottomGap;
final closedBottomInset =
navigationBottomInset + ((navigationBarHeight - _kMorphClosedSize) / 2);
final openLift =
navigationBarHeight +
Grid.xxs -
((navigationBarHeight - _kMorphClosedSize) / 2);
final effectiveOpen = visible && quickActionsOpen.value;
final screenWidth = MediaQuery.sizeOf(context).width;
final navigationBarRight = (screenWidth + navigationBarWidth) / 2;
final launcherRight = screenWidth - rightInset;
final hiddenHorizontalOffset =
navigationBarRight - launcherRight - _kQuickActionsHiddenOverlap;
useEffect(() {
if (!visible && quickActionsOpen.value) {
quickActionsOpen.value = false;
}
return null;
}, [visible]);
Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
}
Future<void> selectQuickAction(_QuickAction action) async {
quickActionsOpen.value = false;
if (!reducedMotion) {
await Future<void>.delayed(_kMorphCloseDuration);
}
if (!context.mounted) return;
switch (action) {
case _QuickAction.createChannel:
final created = await showModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
);
if (created != null && context.mounted) {
await openChannel(created);
}
case _QuickAction.newDm:
final opened = await showModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
_NewDirectMessageSheet(currentPubkey: currentPubkey),
);
if (opened != null && context.mounted) {
await openChannel(opened);
}
}
}
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
if (quickActionsOpen.value)
Positioned.fill(
child: Semantics(
button: true,
label: 'Close quick actions',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => quickActionsOpen.value = false,
),
),
),
AnimatedPositioned(
duration: reducedMotion
? Duration.zero
: _kQuickActionsTabMotionDuration,
curve: _kQuickActionsTabMotionCurve,
right: rightInset,
bottom: closedBottomInset + (effectiveOpen ? openLift : 0),
child: IgnorePointer(
ignoring: !visible,
child: ExcludeSemantics(
excluding: !visible,
child: TweenAnimationBuilder<double>(
key: const Key('channel-quick-actions-motion'),
tween: Tween(end: visible ? 0 : 1),
duration: reducedMotion
? Duration.zero
: _kQuickActionsTabMotionDuration,
curve: _kQuickActionsTabMotionCurve,
builder: (context, hiddenProgress, child) => Opacity(
key: const Key('channel-quick-actions-opacity'),
opacity: 1 - hiddenProgress,
child: Transform.translate(
key: const Key('channel-quick-actions-transform'),
offset: Offset(hiddenHorizontalOffset * hiddenProgress, 0),
child: Transform.scale(
key: const Key('channel-quick-actions-scale'),
scale:
1 -
((1 - _kQuickActionsHiddenScale) * hiddenProgress),
child: child,
),
),
),
child: _MorphingQuickActionsButton(
open: effectiveOpen,
openEdgeOffset: rightInset - Grid.gutter,
onToggle: () =>
quickActionsOpen.value = !quickActionsOpen.value,
onSelected: (action) => unawaited(selectQuickAction(action)),
),
),
),
),
),
],
);
}
}
BoxConstraints _quickActionSheetConstraints(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return BoxConstraints(
maxHeight: mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.sm,
);
}
@@ -1,5 +1,34 @@
part of '../channels_page.dart'; part of '../channels_page.dart';
const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60;
class _CreateChannelMenuOption<T> {
final Key? key;
final String label;
final T value;
const _CreateChannelMenuOption({
this.key,
required this.label,
required this.value,
});
}
const _createChannelTtlOptions = [
_CreateChannelMenuOption(label: '30 minutes', value: 30 * 60),
_CreateChannelMenuOption(label: '1 hour', value: 60 * 60),
_CreateChannelMenuOption(label: '6 hours', value: 6 * 60 * 60),
_CreateChannelMenuOption(label: '12 hours', value: 12 * 60 * 60),
_CreateChannelMenuOption(label: '1 day', value: 24 * 60 * 60),
_CreateChannelMenuOption(label: '3 days', value: 3 * 24 * 60 * 60),
_CreateChannelMenuOption(
label: '7 days',
value: _defaultCreateChannelTtlSeconds,
),
_CreateChannelMenuOption(label: '14 days', value: 14 * 24 * 60 * 60),
_CreateChannelMenuOption(label: '30 days', value: 30 * 24 * 60 * 60),
];
class _CreateChannelSheet extends HookConsumerWidget { class _CreateChannelSheet extends HookConsumerWidget {
final String channelType; final String channelType;
@@ -10,8 +39,15 @@ class _CreateChannelSheet extends HookConsumerWidget {
final nameController = useTextEditingController(); final nameController = useTextEditingController();
final descriptionController = useTextEditingController(); final descriptionController = useTextEditingController();
final visibility = useState('open'); final visibility = useState('open');
final temporary = useState(false);
final ttlSeconds = useState(_defaultCreateChannelTtlSeconds);
final isSubmitting = useState(false); final isSubmitting = useState(false);
final errorMessage = useState<String?>(null); final errorMessage = useState<String?>(null);
useListenable(nameController);
final kindLabel = channelType == 'forum' ? 'forum' : 'channel';
final canSubmit =
nameController.text.trim().isNotEmpty && !isSubmitting.value;
Future<void> submit() async { Future<void> submit() async {
final name = nameController.text.trim(); final name = nameController.text.trim();
@@ -29,6 +65,7 @@ class _CreateChannelSheet extends HookConsumerWidget {
channelType: channelType, channelType: channelType,
visibility: visibility.value, visibility: visibility.value,
description: descriptionController.text.trim(), description: descriptionController.text.trim(),
ttlSeconds: temporary.value ? ttlSeconds.value : null,
); );
if (context.mounted) { if (context.mounted) {
Navigator.of(context).pop(created); Navigator.of(context).pop(created);
@@ -49,68 +86,355 @@ class _CreateChannelSheet extends HookConsumerWidget {
), ),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Column( child: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
TextField( children: [
controller: nameController,
enabled: !isSubmitting.value,
decoration: InputDecoration(
labelText: 'Name',
hintText: channelType == 'forum'
? 'design-discussions'
: 'release-notes',
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: Grid.xxs),
TextField(
controller: descriptionController,
enabled: !isSubmitting.value,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'What this space is for',
),
minLines: 2,
maxLines: 3,
),
const SizedBox(height: Grid.xxs),
SwitchListTile(
title: const Text('Private'),
contentPadding: EdgeInsets.zero,
value: visibility.value == 'private',
onChanged: isSubmitting.value
? null
: (on) => visibility.value = on ? 'private' : 'open',
),
if (errorMessage.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text( Text(
error, 'Create a new $kindLabel',
style: context.textTheme.bodySmall?.copyWith( style: context.textTheme.titleLarge?.copyWith(
color: context.colors.error, fontWeight: FontWeight.w600,
letterSpacing: -0.3,
), ),
), ),
], const SizedBox(height: Grid.sm),
const SizedBox(height: Grid.xs), _CreateChannelFieldLabel(label: 'Name'),
Row( const SizedBox(height: Grid.xxs),
mainAxisAlignment: MainAxisAlignment.end, _CreateChannelFieldShell(
children: [ child: TextField(
TextButton( key: const Key('create-channel-name'),
onPressed: isSubmitting.value controller: nameController,
? null enabled: !isSubmitting.value,
: () => Navigator.of(context).pop(), autofocus: true,
child: const Text('Cancel'), autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
hintText: channelType == 'forum'
? 'design-discussions'
: 'release-notes',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
), ),
const SizedBox(width: Grid.half), ),
FilledButton( const SizedBox(height: Grid.xs),
onPressed: isSubmitting.value ? null : submit, const _CreateChannelFieldLabel(
child: Text(isSubmitting.value ? 'Creating…' : 'Create'), label: 'Description',
optional: true,
),
const SizedBox(height: Grid.xxs),
_CreateChannelFieldShell(
child: TextField(
key: const Key('create-channel-description'),
controller: descriptionController,
enabled: !isSubmitting.value,
decoration: InputDecoration(
hintText: 'What this $kindLabel is for',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
),
minLines: 2,
maxLines: 3,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
),
),
const SizedBox(height: Grid.sm),
_CreateChannelRadioGroup<bool>(
enabled: !isSubmitting.value,
label: 'Channel type',
onSelected: (value) => temporary.value = value,
options: const [
_CreateChannelMenuOption(
key: Key('create-channel-type-ongoing'),
label: 'Ongoing',
value: false,
),
_CreateChannelMenuOption(
key: Key('create-channel-type-temporary'),
label: 'Temporary',
value: true,
),
],
value: temporary.value,
),
if (temporary.value) ...[
const SizedBox(height: Grid.xs),
_CreateChannelSettingMenu<int>(
controlKey: const Key('create-channel-ttl'),
enabled: !isSubmitting.value,
label: 'Expires after',
onSelected: (value) => ttlSeconds.value = value,
options: _createChannelTtlOptions,
value: ttlSeconds.value,
valueLabel: _createChannelTtlOptions
.firstWhere((option) => option.value == ttlSeconds.value)
.label,
), ),
], ],
const SizedBox(height: Grid.sm),
_CreateChannelRadioGroup<String>(
enabled: !isSubmitting.value,
label: 'Visibility',
onSelected: (value) => visibility.value = value,
options: const [
_CreateChannelMenuOption(
key: Key('create-channel-visibility-public'),
label: 'Public',
value: 'open',
),
_CreateChannelMenuOption(
key: Key('create-channel-visibility-private'),
label: 'Private',
value: 'private',
),
],
value: visibility.value,
),
if (errorMessage.value case final error?) ...[
const SizedBox(height: Grid.xs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
],
),
),
),
);
}
}
class _CreateChannelFieldLabel extends StatelessWidget {
final String label;
final bool optional;
const _CreateChannelFieldLabel({required this.label, this.optional = false});
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: [
TextSpan(text: label),
if (optional)
TextSpan(
text: ' Optional',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
fontWeight: FontWeight.w400,
),
), ),
], ],
),
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
);
}
}
class _CreateChannelFieldShell extends StatelessWidget {
final Widget child;
const _CreateChannelFieldShell({required this.child});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primaryContainer.withValues(alpha: 0.55),
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: child,
);
}
}
class _CreateChannelRadioGroup<T> extends StatelessWidget {
final bool enabled;
final String label;
final ValueChanged<T> onSelected;
final List<_CreateChannelMenuOption<T>> options;
final T value;
const _CreateChannelRadioGroup({
required this.enabled,
required this.label,
required this.onSelected,
required this.options,
required this.value,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CreateChannelFieldLabel(label: label),
const SizedBox(height: Grid.xxs),
DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: RadioGroup<T>(
groupValue: value,
onChanged: (nextValue) {
if (enabled && nextValue != null) {
onSelected(nextValue);
}
},
child: Column(
children: [
for (final (index, option) in options.indexed) ...[
RadioListTile<T>(
key: option.key,
value: option.value,
enabled: enabled,
selected: option.value == value,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.half,
),
visualDensity: VisualDensity.standard,
activeColor: context.colors.primary,
title: Text(
option.label,
style: context.textTheme.bodyMedium?.copyWith(
color: enabled
? context.colors.onSurface
: context.colors.onSurface.withValues(alpha: 0.38),
fontWeight: option.value == value
? FontWeight.w600
: FontWeight.w400,
),
),
),
if (index < options.length - 1)
Divider(
height: 1,
indent: Grid.xs,
endIndent: Grid.xs,
color: context.colors.outlineVariant,
),
],
],
),
),
),
],
);
}
}
class _CreateChannelSettingMenu<T> extends StatelessWidget {
final Key controlKey;
final bool enabled;
final String label;
final ValueChanged<T> onSelected;
final List<_CreateChannelMenuOption<T>> options;
final T value;
final IconData? valueIcon;
final String valueLabel;
const _CreateChannelSettingMenu({
required this.controlKey,
required this.enabled,
required this.label,
required this.onSelected,
required this.options,
required this.value,
this.valueIcon,
required this.valueLabel,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: PopupMenuButton<T>(
enabled: enabled,
initialValue: value,
onSelected: onSelected,
tooltip: '$label: $valueLabel',
itemBuilder: (context) => [
for (final option in options)
CheckedPopupMenuItem<T>(
checked: option.value == value,
value: option.value,
child: Text(option.label),
),
],
child: Padding(
key: controlKey,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
if (valueIcon case final icon?) ...[
Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
const SizedBox(width: Grid.half),
],
Text(
valueLabel,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: Grid.half),
Icon(
LucideIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
),
],
),
), ),
), ),
); );
@@ -125,40 +449,50 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final queryController = useTextEditingController(); final queryController = useTextEditingController();
final queryFocusNode = useFocusNode();
final query = useState(''); final query = useState('');
final debouncedQuery = useState(''); final debouncedQuery = useState('');
final selectedUsers = useState<List<DirectoryUser>>([]); final selectedUsers = useState<List<DirectoryUser>>([]);
final isSubmitting = useState(false); final isSubmitting = useState(false);
final submitError = useState<String?>(null); final submitError = useState<String?>(null);
final previewDirectoryActive = ref.watch(dmDirectoryPreviewEnabledProvider);
useEffect(() { useEffect(() {
final timer = Timer(const Duration(milliseconds: 250), () { final timer = Timer(const Duration(milliseconds: 250), () {
debouncedQuery.value = query.value.trim(); debouncedQuery.value = query.value.trim().toLowerCase();
}); });
return timer.cancel; return timer.cancel;
}, [query.value]); }, [query.value]);
final searchFuture = useMemoized(() { final normalizedQuery = previewDirectoryActive
if (debouncedQuery.value.isEmpty || selectedUsers.value.length >= 8) { ? query.value.trim().toLowerCase()
return Future.value(const <DirectoryUser>[]); : debouncedQuery.value;
} final isSearchTransitionPending =
return ref !previewDirectoryActive &&
.read(channelActionsProvider) query.value.trim().toLowerCase() != normalizedQuery;
.searchUsers(debouncedQuery.value, limit: 8); final directoryAsync = previewDirectoryActive
}, [debouncedQuery.value, selectedUsers.value.length]); ? AsyncValue.data(dmDirectoryPreviewUsers)
final searchResults = useFuture(searchFuture); : normalizedQuery.isEmpty
? ref.watch(relayDirectoryUsersProvider)
: ref.watch(relayDirectorySearchProvider(normalizedQuery));
final selectedPubkeys = selectedUsers.value final selectedPubkeys = selectedUsers.value
.map((user) => user.pubkey.toLowerCase()) .map((user) => user.pubkey.toLowerCase())
.toSet(); .toSet();
final availableResults = final availableResults =
searchResults.data directoryAsync.asData?.value.where((user) {
?.where( final normalizedPubkey = user.pubkey.toLowerCase();
(user) => if (selectedPubkeys.contains(normalizedPubkey) ||
!selectedPubkeys.contains(user.pubkey.toLowerCase()) && normalizedPubkey == currentPubkey?.toLowerCase()) {
user.pubkey.toLowerCase() != currentPubkey?.toLowerCase(), return false;
) }
.toList() ?? if (normalizedQuery.isEmpty) {
return true;
}
return user.label.toLowerCase().contains(normalizedQuery) ||
user.secondaryLabel.toLowerCase().contains(normalizedQuery) ||
normalizedPubkey.contains(normalizedQuery);
}).toList() ??
const <DirectoryUser>[]; const <DirectoryUser>[];
final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty; final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty;
@@ -166,6 +500,10 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
if (selectedUsers.value.isEmpty || isSubmitting.value) { if (selectedUsers.value.isEmpty || isSubmitting.value) {
return; return;
} }
if (previewDirectoryActive) {
submitError.value = 'Preview only — mock people cannot be messaged.';
return;
}
isSubmitting.value = true; isSubmitting.value = true;
submitError.value = null; submitError.value = null;
@@ -194,123 +532,349 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
), ),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Column( child: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
TextField( children: [
controller: queryController, Text(
decoration: const InputDecoration( 'New message',
prefixIcon: Icon(LucideIcons.search), style: context.textTheme.titleLarge?.copyWith(
hintText: 'Search by name, NIP-05, or pubkey', fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
), ),
enabled: !isSubmitting.value, const SizedBox(height: Grid.xs),
onChanged: (value) => query.value = value, GestureDetector(
), behavior: HitTestBehavior.translucent,
if (selectedUsers.value.isNotEmpty) ...[ onTap: isSubmitting.value ? null : queryFocusNode.requestFocus,
const SizedBox(height: Grid.xxs), child: SizedBox(
Wrap( width: double.infinity,
spacing: Grid.half, child: ConstrainedBox(
runSpacing: Grid.half, key: const Key('new-dm-recipient-field'),
children: [ constraints: const BoxConstraints(minHeight: Grid.xl),
for (final user in selectedUsers.value) child: DecoratedBox(
InputChip( decoration: BoxDecoration(
label: Text(user.label), color: context.colors.surface,
onDeleted: isSubmitting.value border: Border.all(
? null color: context.colors.outlineVariant,
: () { ),
selectedUsers.value = [ borderRadius: BorderRadius.circular(Radii.lg),
for (final candidate in selectedUsers.value)
if (candidate.pubkey != user.pubkey)
candidate,
];
},
),
],
),
],
const SizedBox(height: Grid.xs),
SizedBox(
height: 280,
child: Builder(
builder: (context) {
if (selectedUsers.value.length >= 8) {
return const Center(
child: Text(
'Direct messages support up to 9 people including you.',
), ),
); child: Padding(
} padding: const EdgeInsets.symmetric(
if (debouncedQuery.value.isEmpty) { horizontal: Grid.twelve,
return const Center( vertical: Grid.xxs,
child: Text( ),
'Search for someone to start a conversation.', child: LayoutBuilder(
), builder: (context, constraints) {
); final hasSelectedUsers =
} selectedUsers.value.isNotEmpty;
if (searchResults.connectionState == final searchFieldWidth = hasSelectedUsers
ConnectionState.waiting) { ? 96.0
return const Center(child: CircularProgressIndicator()); : (constraints.maxWidth - 32).clamp(
} 96.0,
if (availableResults.isEmpty) { constraints.maxWidth,
return const Center(child: Text('No matching users.')); );
}
return ListView( return Wrap(
shrinkWrap: true, key: const Key('new-dm-recipient-wrap'),
children: [ spacing: 6,
for (final user in availableResults) runSpacing: 6,
ListTile( crossAxisAlignment: WrapCrossAlignment.center,
leading: AvatarImage( children: [
imageUrl: user.avatarUrl, Padding(
radius: 20, padding: const EdgeInsets.symmetric(
fallback: Text( vertical: Grid.half,
user.label.substring(0, 1).toUpperCase(), ),
), child: Text(
), 'To:',
title: Text(user.label), style: context.textTheme.bodyLarge
subtitle: Text(user.secondaryLabel), ?.copyWith(fontWeight: FontWeight.w600),
onTap: () { ),
selectedUsers.value = [ ),
...selectedUsers.value, for (final user in selectedUsers.value)
user, _SelectedDmRecipientChip(
]; user: user,
queryController.clear(); enabled: !isSubmitting.value,
query.value = ''; onDeleted: () {
debouncedQuery.value = ''; selectedUsers.value = [
for (final candidate
in selectedUsers.value)
if (candidate.pubkey != user.pubkey)
candidate,
];
queryFocusNode.requestFocus();
},
),
SizedBox(
width: searchFieldWidth,
child: TextField(
key: const Key('new-dm-search'),
controller: queryController,
focusNode: queryFocusNode,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
enabled: !isSubmitting.value,
onChanged: (value) => query.value = value,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
decoration: InputDecoration(
hintText: hasSelectedUsers
? null
: 'Search for a person',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(
vertical: Grid.half,
),
suffixIcon: isSubmitting.value
? const Padding(
padding: EdgeInsets.all(
Grid.half,
),
child: SizedBox.square(
dimension: 16,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
),
)
: null,
suffixIconConstraints:
const BoxConstraints(
minHeight: 32,
minWidth: 32,
),
),
textInputAction: TextInputAction.done,
),
),
],
);
}, },
), ),
], ),
),
),
),
),
const SizedBox(height: Grid.xs),
Builder(
key: const Key('new-dm-results'),
builder: (context) {
if (selectedUsers.value.length >= 8) {
return const SizedBox(
height: 96,
child: Center(
child: Text(
'DMs support up to nine people, including you.',
),
),
);
}
if (directoryAsync.isLoading &&
directoryAsync.asData == null ||
isSearchTransitionPending) {
return const SizedBox(
height: 280,
child: Center(child: CircularProgressIndicator()),
);
}
if (directoryAsync.hasError) {
return SizedBox(
height: 280,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Could not load people from this relay.',
),
const SizedBox(height: Grid.half),
TextButton(
onPressed: () {
if (normalizedQuery.isEmpty) {
ref.invalidate(relayDirectoryUsersProvider);
} else {
ref.invalidate(
relayDirectorySearchProvider(
normalizedQuery,
),
);
}
},
child: const Text('Retry'),
),
],
),
),
);
}
if (availableResults.isEmpty) {
return SizedBox(
height: 280,
child: Center(
child: Text(
normalizedQuery.isEmpty
? 'No people or agents available to message.'
: 'No matching users.',
),
),
);
}
return ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: availableResults.length,
separatorBuilder: (_, _) =>
const Divider(height: 1, indent: 56),
itemBuilder: (context, index) {
final user = availableResults[index];
return ListTile(
key: Key('new-dm-person-${user.pubkey}'),
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
),
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
user.initial,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
title: Text(
user.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
user.secondaryLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(
LucideIcons.plus,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: isSubmitting.value
? null
: () {
selectedUsers.value = [
...selectedUsers.value,
user,
];
queryController.clear();
query.value = '';
debouncedQuery.value = '';
submitError.value = null;
queryFocusNode.requestFocus();
},
);
},
); );
}, },
), ),
), if (submitError.value case final error?) ...[
if (submitError.value case final error?) ...[ const SizedBox(height: Grid.xxs),
const SizedBox(height: Grid.xxs), Text(
Text( error,
error, style: context.textTheme.bodySmall?.copyWith(
style: context.textTheme.bodySmall?.copyWith( color: context.colors.error,
color: context.colors.error, ),
),
),
],
const SizedBox(height: Grid.xs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isSubmitting.value
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: canSubmit ? submit : null,
child: Text(isSubmitting.value ? 'Opening…' : 'Open DM'),
), ),
], ],
],
),
),
),
);
}
}
class _SelectedDmRecipientChip extends StatelessWidget {
final DirectoryUser user;
final bool enabled;
final VoidCallback onDeleted;
const _SelectedDmRecipientChip({
required this.user,
required this.enabled,
required this.onDeleted,
});
@override
Widget build(BuildContext context) {
final foreground = context.colors.onSurfaceVariant;
return ConstrainedBox(
key: Key('new-dm-selected-${user.pubkey}'),
constraints: const BoxConstraints(maxWidth: 224),
child: SizedBox(
height: 40,
child: Material(
color: context.colors.surfaceContainerHighest,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: Semantics(
button: true,
enabled: enabled,
excludeSemantics: true,
label: 'Remove ${user.label}',
child: InkWell(
customBorder: const StadiumBorder(),
onTap: enabled ? onDeleted : null,
child: Padding(
padding: const EdgeInsets.only(
left: Grid.half,
right: Grid.twelve,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AvatarImage(
imageUrl: user.avatarUrl,
radius: 16,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
user.initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
user.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
), ),
], ),
), ),
), ),
); );
+162 -160
View File
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -13,13 +15,15 @@ import '../search/search_page.dart';
class HomePage extends HookConsumerWidget { class HomePage extends HookConsumerWidget {
const HomePage({super.key}); const HomePage({super.key});
static const double _tabBarHeight = 60; static const double _tabBarHeight = 56;
static const double _tabBarRadius = _tabBarHeight / 2; static const double _tabBarRadius = _tabBarHeight / 2;
static const double _tabBarInnerInset = 5; static const double _tabBarInnerInset = Grid.half;
static const double _selectedTabRadius = static const double _selectedTabRadius =
(_tabBarHeight - (_tabBarInnerInset * 2)) / 2; (_tabBarHeight - (_tabBarInnerInset * 2)) / 2;
static const double _tabBarBottomGap = Grid.twelve; static const double _tabBarBottomGap = Grid.twelve;
static const double _tabBarHorizontalMargin = Grid.gutter; static const double _tabBarHorizontalMargin = Grid.gutter;
static const double _tabDestinationHorizontalPadding = Grid.sm;
static const double _tabIconSize = 22;
static const double _fabClearance = _tabBarHeight + _tabBarBottomGap; static const double _fabClearance = _tabBarHeight + _tabBarBottomGap;
static const Duration _tabIconWeightDuration = Duration(milliseconds: 120); static const Duration _tabIconWeightDuration = Duration(milliseconds: 120);
@@ -30,8 +34,8 @@ class HomePage extends HookConsumerWidget {
label: 'Home', label: 'Home',
), ),
_HomeDestination( _HomeDestination(
icon: LucideIcons.bell300, icon: LucideIcons.inbox300,
selectedIcon: LucideIcons.bell400, selectedIcon: LucideIcons.inbox500,
label: 'Activity', label: 'Activity',
), ),
_HomeDestination( _HomeDestination(
@@ -44,27 +48,74 @@ class HomePage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabIndex = useState(0); final tabIndex = useState(0);
final systemBottomInset = MediaQuery.paddingOf(context).bottom;
final navigationBarWidth = _floatingTabBarWidth(
MediaQuery.sizeOf(context).width,
_destinations.length,
);
const pages = [ChannelsPage(), ActivityPage(), SearchPage()]; const pages = [ChannelsPage(), ActivityPage(), SearchPage()];
return Scaffold( return Scaffold(
extendBody: true, extendBody: true,
body: MediaQuery( body: SizedBox.expand(
data: _mediaQueryWithFloatingTabBarClearance( child: Stack(
context, fit: StackFit.expand,
HomePage._fabClearance, children: [
Positioned.fill(
child: MediaQuery(
data: _mediaQueryWithFloatingTabBarClearance(
context,
HomePage._fabClearance,
),
child: IndexedStack(index: tabIndex.value, children: pages),
),
),
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: tabIndex.value == 0,
navigationBarHeight: HomePage._tabBarHeight,
navigationBarBottomGap: HomePage._tabBarBottomGap,
navigationBarWidth: navigationBarWidth,
systemBottomInset: systemBottomInset,
rightInset: Grid.sm,
),
),
],
), ),
child: IndexedStack(index: tabIndex.value, children: pages),
), ),
bottomNavigationBar: _FloatingTabBar( bottomNavigationBar: _FloatingTabBar(
selectedIndex: tabIndex.value, selectedIndex: tabIndex.value,
onDestinationSelected: (i) => tabIndex.value = i, onDestinationSelected: (i) {
if (i == tabIndex.value) return;
unawaited(HapticFeedback.selectionClick());
tabIndex.value = i;
},
destinations: _destinations, destinations: _destinations,
), ),
); );
} }
} }
double _floatingTabDestinationWidth(double screenWidth, int destinationCount) {
final preferredDestinationWidth =
HomePage._tabIconSize + (HomePage._tabDestinationHorizontalPadding * 2);
final availableInnerWidth =
screenWidth -
(HomePage._tabBarHorizontalMargin * 2) -
(HomePage._tabBarInnerInset * 2);
return preferredDestinationWidth
.clamp(0.0, availableInnerWidth / destinationCount)
.toDouble();
}
double _floatingTabBarWidth(double screenWidth, int destinationCount) {
if (destinationCount <= 0) return 0;
return (_floatingTabDestinationWidth(screenWidth, destinationCount) *
destinationCount) +
(HomePage._tabBarInnerInset * 2);
}
MediaQueryData _mediaQueryWithFloatingTabBarClearance( MediaQueryData _mediaQueryWithFloatingTabBarClearance(
BuildContext context, BuildContext context,
double clearance, double clearance,
@@ -119,6 +170,11 @@ class _FloatingTabBar extends StatelessWidget {
? Alignment.center ? Alignment.center
: Alignment(-1 + (2 * safeSelectedIndex / (destinationCount - 1)), 0); : Alignment(-1 + (2 * safeSelectedIndex / (destinationCount - 1)), 0);
final destinationWidth = _floatingTabDestinationWidth(
MediaQuery.sizeOf(context).width,
destinationCount,
);
return SafeArea( return SafeArea(
minimum: const EdgeInsets.fromLTRB( minimum: const EdgeInsets.fromLTRB(
HomePage._tabBarHorizontalMargin, HomePage._tabBarHorizontalMargin,
@@ -128,117 +184,80 @@ class _FloatingTabBar extends StatelessWidget {
), ),
child: Align( child: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: SizedBox( heightFactor: 1,
width: double.infinity, child: DecoratedBox(
child: DecoratedBox( decoration: BoxDecoration(
decoration: BoxDecoration( borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
borderRadius: BorderRadius.circular(HomePage._tabBarRadius), boxShadow: [
boxShadow: [ BoxShadow(
BoxShadow( color: colorScheme.shadow.withValues(alpha: 0.10),
color: colorScheme.shadow.withValues(alpha: 0.18), blurRadius: 20,
blurRadius: 28, offset: const Offset(0, 8),
offset: const Offset(0, 12), ),
), ],
], ),
), child: ClipRRect(
child: ClipRRect( borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
borderRadius: BorderRadius.circular(HomePage._tabBarRadius), child: BackdropFilter(
child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), child: DecoratedBox(
child: DecoratedBox( decoration: BoxDecoration(
decoration: BoxDecoration( borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
borderRadius: BorderRadius.circular(HomePage._tabBarRadius), color: isDark
color: isDark ? colorScheme.surfaceContainerHighest.withValues(
? colorScheme.surfaceContainerHighest.withValues( alpha: 0.72,
alpha: 0.72, )
) : colorScheme.surface,
: null, border: Border.all(
border: Border.all( color: colorScheme.outlineVariant.withValues(
color: colorScheme.outlineVariant.withValues( alpha: isDark ? 0.20 : 0.38,
alpha: isDark ? 0.20 : 0.38,
),
), ),
gradient: isDark
? null
: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.90),
colorScheme.surfaceContainerHighest.withValues(
alpha: 0.78,
),
],
),
), ),
child: Stack( ),
children: [ child: Padding(
if (!isDark) padding: const EdgeInsets.all(HomePage._tabBarInnerInset),
Positioned.fill( child: SizedBox(
child: DecoratedBox( height:
decoration: BoxDecoration( HomePage._tabBarHeight -
gradient: LinearGradient( (HomePage._tabBarInnerInset * 2),
begin: Alignment.topCenter, width: destinationWidth * destinationCount,
end: Alignment.center, child: Stack(
colors: [ children: [
Colors.white.withValues(alpha: 0.22), AnimatedAlign(
Colors.white.withValues(alpha: 0.02), alignment: selectedAlignment,
], duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: SizedBox(
width: destinationWidth,
height: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(
HomePage._selectedTabRadius,
),
), ),
), ),
), ),
), ),
Padding( Row(
padding: const EdgeInsets.all( crossAxisAlignment: CrossAxisAlignment.stretch,
HomePage._tabBarInnerInset, children: [
), for (var i = 0; i < destinations.length; i++)
child: SizedBox( SizedBox(
height: width: destinationWidth,
HomePage._tabBarHeight - child: _FloatingTabDestination(
(HomePage._tabBarInnerInset * 2), destination: destinations[i],
child: Stack( selected: i == safeSelectedIndex,
children: [ onTap: () => onDestinationSelected(i),
AnimatedAlign(
alignment: selectedAlignment,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: FractionallySizedBox(
widthFactor: 1 / destinationCount,
heightFactor: 1,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.quarter,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(
HomePage._selectedTabRadius,
),
),
),
),
), ),
), ),
Row( ],
children: [
for (var i = 0; i < destinations.length; i++)
Expanded(
child: _FloatingTabDestination(
destination: destinations[i],
selected: i == selectedIndex,
onTap: () => onDestinationSelected(i),
),
),
],
),
],
),
), ),
), ],
], ),
), ),
), ),
), ),
@@ -264,62 +283,45 @@ class _FloatingTabDestination extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = context.colors; final colorScheme = context.colors;
final textStyle = context.textTheme.labelSmall;
final reducedMotion = MediaQuery.of(context).disableAnimations; final reducedMotion = MediaQuery.of(context).disableAnimations;
final foregroundColor = selected final foregroundColor = selected
? colorScheme.onSecondaryContainer ? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant; : colorScheme.onSurfaceVariant;
final icon = selected ? destination.selectedIcon : destination.icon; final icon = selected ? destination.selectedIcon : destination.icon;
return Padding( return Semantics(
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), button: true,
child: Material( selected: selected,
color: Colors.transparent, label: destination.label,
borderRadius: BorderRadius.circular(HomePage._selectedTabRadius), child: Tooltip(
clipBehavior: Clip.antiAlias, message: destination.label,
child: InkWell( excludeFromSemantics: true,
onTap: onTap, child: Material(
splashFactory: NoSplash.splashFactory, color: Colors.transparent,
overlayColor: const WidgetStatePropertyAll<Color>(Colors.transparent), clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(HomePage._selectedTabRadius), borderRadius: BorderRadius.circular(HomePage._selectedTabRadius),
child: Padding( child: InkWell(
padding: const EdgeInsets.symmetric( onTap: onTap,
horizontal: Grid.xxs, overlayColor: const WidgetStatePropertyAll<Color>(
vertical: Grid.xxs, Colors.transparent,
), ),
child: Column( borderRadius: BorderRadius.circular(HomePage._selectedTabRadius),
mainAxisAlignment: MainAxisAlignment.center, child: Center(
mainAxisSize: MainAxisSize.min, child: AnimatedSwitcher(
children: [ duration: reducedMotion
AnimatedSwitcher( ? Duration.zero
duration: reducedMotion : HomePage._tabIconWeightDuration,
? Duration.zero switchInCurve: Curves.easeOutCubic,
: HomePage._tabIconWeightDuration, switchOutCurve: Curves.easeOutCubic,
switchInCurve: Curves.easeOutCubic, transitionBuilder: (child, animation) =>
switchOutCurve: Curves.easeOutCubic, FadeTransition(opacity: animation, child: child),
transitionBuilder: (child, animation) => child: Icon(
FadeTransition(opacity: animation, child: child), icon,
child: Icon( key: ValueKey('${destination.label}-$icon'),
icon, color: foregroundColor,
key: ValueKey('${destination.label}-$icon'), size: HomePage._tabIconSize,
color: foregroundColor,
size: 20,
),
), ),
const SizedBox(height: 1), ),
Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle?.copyWith(
color: foregroundColor,
fontSize: 10.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
height: 1.15,
letterSpacing: 0,
),
),
],
), ),
), ),
), ),
+1
View File
@@ -63,6 +63,7 @@ class AppTheme {
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: scheme, colorScheme: scheme,
splashFactory: NoSplash.splashFactory,
scaffoldBackgroundColor: scheme.surface, scaffoldBackgroundColor: scheme.surface,
extensions: [appColors], extensions: [appColors],
fontFamily: 'Inter', fontFamily: 'Inter',
@@ -10,6 +10,74 @@ import 'package:buzz/shared/relay/relay.dart';
/// also exposed on `ChannelDetails` MUST be propagated here — otherwise /// also exposed on `ChannelDetails` MUST be propagated here — otherwise
/// `mergeDetails` silently clears that state on the merged Channel. /// `mergeDetails` silently clears that state on the merged Channel.
void main() { void main() {
test('extracts unique relay members from current and legacy tags', () {
final pubkeys = relayMemberPubkeysFromEvents([
NostrEvent(
id: 'members-1',
pubkey: 'relay',
createdAt: 1700000000,
kind: 13534,
tags: const [
['member', 'ALICE', 'member'],
['member', 'bob', 'admin'],
['p', 'alice', '', 'member'],
['name', 'not-a-member'],
['member', ''],
],
content: '',
sig: 'sig',
),
]);
expect(pubkeys, ['alice', 'bob']);
});
test('builds an alphabetized directory from the latest profile events', () {
final users = directoryUsersFromProfileEvents([
NostrEvent(
id: 'alice-old',
pubkey: 'alice',
createdAt: 10,
kind: 0,
tags: const [],
content: '{"display_name":"Zoe"}',
sig: 'sig',
),
NostrEvent(
id: 'bob',
pubkey: 'bob',
createdAt: 20,
kind: 0,
tags: const [],
content: '{"display_name":"Bob"}',
sig: 'sig',
),
NostrEvent(
id: 'alice-new',
pubkey: 'ALICE',
createdAt: 30,
kind: 0,
tags: const [],
content:
'{"display_name":"Alice","picture":"https://example.com/alice.png"}',
sig: 'sig',
),
NostrEvent(
id: 'not-a-profile',
pubkey: 'charlie',
createdAt: 40,
kind: 1,
tags: const [],
content: '{}',
sig: 'sig',
),
]);
expect(users.map((user) => user.label), ['Alice', 'Bob']);
expect(users.first.pubkey, 'alice');
expect(users.first.avatarUrl, 'https://example.com/alice.png');
});
test('propagates archived state from kind:39000 archived tag', () { test('propagates archived state from kind:39000 archived tag', () {
// Regression: previously this mapping ignored the `archived` tag, so // Regression: previously this mapping ignored the `archived` tag, so
// `Channel.mergeDetails` would clear the archived flag the list provider // `Channel.mergeDetails` would clear the archived flag the list provider
@@ -85,6 +153,38 @@ void main() {
expect(details.ttlDeadline!.isUtc, isTrue); expect(details.ttlDeadline!.isUtc, isTrue);
}); });
group('buildCreateChannelTags', () {
test('builds an ongoing channel without a ttl tag', () {
final tags = buildCreateChannelTags(
channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc',
name: 'release-notes',
channelType: 'stream',
visibility: 'open',
description: ' Ship updates ',
);
expect(tags, [
['h', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'],
['name', 'release-notes'],
['visibility', 'open'],
['channel_type', 'stream'],
['about', 'Ship updates'],
]);
});
test('adds the selected ttl for a temporary channel', () {
final tags = buildCreateChannelTags(
channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc',
name: 'incident-room',
channelType: 'stream',
visibility: 'private',
ttlSeconds: 604800,
);
expect(tags, contains(equals(['ttl', '604800'])));
});
});
group('buildDeleteMessageTags', () { group('buildDeleteMessageTags', () {
test('emits both channel h tag and target e tag', () { test('emits both channel h tag and target e tag', () {
final tags = buildDeleteMessageTags( final tags = buildDeleteMessageTags(
@@ -1,8 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart'; import 'package:hooks_riverpod/misc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart';
@@ -10,17 +14,48 @@ import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
void main() { void main() {
Widget buildTestable({required List<Override> overrides}) { Widget buildTestable({
required List<Override> overrides,
bool previewDirectory = false,
double keyboardInset = 0,
}) {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
// Provide a fake profile and presence so the avatar doesn't hit the network. // Provide a fake profile and presence so the avatar doesn't hit the network.
profileProvider.overrideWith(() => _FakeProfileNotifier()), profileProvider.overrideWith(() => _FakeProfileNotifier()),
presenceProvider.overrideWith(() => _FakePresenceNotifier()), presenceProvider.overrideWith(() => _FakePresenceNotifier()),
dmDirectoryPreviewEnabledProvider.overrideWith(
(ref) => previewDirectory,
),
...overrides, ...overrides,
], ],
child: MaterialApp(theme: AppTheme.light(), home: const ChannelsPage()), child: MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(viewInsets: EdgeInsets.only(bottom: keyboardInset)),
child: child!,
),
home: const Stack(
children: [
ChannelsPage(),
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: true,
navigationBarHeight: 60,
navigationBarBottomGap: 12,
navigationBarWidth: 218,
systemBottomInset: 0,
rightInset: 16,
),
),
],
),
),
); );
} }
@@ -82,6 +117,492 @@ void main() {
expect(find.byTooltip('Create or start conversation'), findsOneWidget); expect(find.byTooltip('Create or start conversation'), findsOneWidget);
}); });
testWidgets('quick actions slide behind navigation when leaving home', (
tester,
) async {
Widget buildLauncher({required bool visible}) {
return ProviderScope(
overrides: [profileProvider.overrideWith(() => _FakeProfileNotifier())],
child: MaterialApp(
theme: AppTheme.light(),
home: Stack(
children: [
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: visible,
navigationBarHeight: 60,
navigationBarBottomGap: 12,
navigationBarWidth: 218,
systemBottomInset: 0,
rightInset: 16,
),
),
],
),
),
);
}
await tester.pumpWidget(buildLauncher(visible: true));
await tester.pumpAndSettle();
Transform motionTransform() => tester.widget<Transform>(
find.byKey(const Key('channel-quick-actions-transform')),
);
expect(motionTransform().transform.getTranslation().x, 0);
await tester.pumpWidget(buildLauncher(visible: false));
await tester.pump();
await tester.pump(const Duration(milliseconds: 110));
final midpoint = motionTransform().transform.getTranslation().x;
expect(midpoint, lessThan(0));
expect(midpoint, greaterThan(-279));
await tester.pumpAndSettle();
expect(motionTransform().transform.getTranslation().x, closeTo(-279, 0.01));
final hiddenOpacity = tester.widget<Opacity>(
find.byKey(const Key('channel-quick-actions-opacity')),
);
expect(hiddenOpacity.opacity, 0);
final hiddenPointerGate = tester.widget<IgnorePointer>(
find
.ancestor(
of: find.byKey(const Key('channel-quick-actions-transform')),
matching: find.byType(IgnorePointer),
)
.first,
);
expect(hiddenPointerGate.ignoring, isTrue);
});
testWidgets('quick actions spring into spaced muted cards', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final surface = find.byKey(const Key('quick-actions-surface'));
expect(tester.getSize(surface), const Size.square(56));
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
var largestHeight = tester.getSize(surface).height;
for (var frame = 0; frame < 20; frame++) {
await tester.pump(const Duration(milliseconds: 16));
largestHeight = max(largestHeight, tester.getSize(surface).height);
}
await tester.pumpAndSettle();
expect(largestHeight, greaterThan(160));
expect(tester.getSize(surface).height, closeTo(160, 0.01));
final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width;
final surfaceRect = tester.getRect(surface);
expect(surfaceRect.left, closeTo(20, 0.01));
expect(surfaceRect.right, closeTo(screenWidth - 20, 0.01));
final menuRect = tester.getRect(
find.byKey(const Key('quick-actions-menu')),
);
final createCard = find.byKey(
const Key('quick-action-create-channel-card'),
);
final dmCard = find.byKey(const Key('quick-action-new-dm-card'));
final createRect = tester.getRect(createCard);
final dmRect = tester.getRect(dmCard);
expect(createRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - createRect.right, closeTo(8, 0.01));
expect(dmRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - dmRect.right, closeTo(8, 0.01));
expect(dmRect.top - createRect.bottom, closeTo(8, 0.01));
expect(dmRect.width, createRect.width);
expect(dmRect.width, closeTo(menuRect.width - 16, 0.01));
final cardScheme = Theme.of(tester.element(createCard)).colorScheme;
final expectedCardColor = Color.alphaBlend(
cardScheme.onPrimary.withValues(alpha: 0.1),
cardScheme.primary,
);
final createMaterial = tester.widget<Material>(
find.descendant(of: createCard, matching: find.byType(Material)).first,
);
final dmMaterial = tester.widget<Material>(
find.descendant(of: dmCard, matching: find.byType(Material)).first,
);
expect(createMaterial.color, expectedCardColor);
expect(dmMaterial.color, expectedCardColor);
expect(
(createMaterial.borderRadius as BorderRadius).topLeft.x,
closeTo(12, 0.01),
);
expect(
(dmMaterial.borderRadius as BorderRadius).topLeft.x,
closeTo(12, 0.01),
);
expect(find.text('Start a new stream channel'), findsNothing);
expect(
tester.widget<Text>(find.text('Create channel')).style?.fontSize,
16,
);
expect(
tester.widget<Text>(find.text('New direct message')).style?.fontSize,
16,
);
expect(find.text('Message one or more people'), findsNothing);
});
testWidgets('create channel sheet lists type and visibility radio options', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('Create channel'));
await tester.pumpAndSettle();
final createSheetRect = tester.getRect(find.byType(BottomSheet).last);
expect(createSheetRect.top, greaterThanOrEqualTo(24));
tester.testTextInput.hide();
await tester.pumpAndSettle();
expect(find.text('Create a new channel'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
expect(find.text('Description Optional'), findsOneWidget);
expect(find.text('Channel type'), findsOneWidget);
expect(find.text('Ongoing'), findsOneWidget);
expect(find.text('Temporary'), findsOneWidget);
expect(find.text('Visibility'), findsOneWidget);
expect(find.text('Public'), findsOneWidget);
expect(find.text('Private'), findsOneWidget);
expect(find.byKey(const Key('create-channel-submit')), findsNothing);
final nameField = tester.widget<TextField>(
find.byKey(const Key('create-channel-name')),
);
final descriptionField = tester.widget<TextField>(
find.byKey(const Key('create-channel-description')),
);
expect(nameField.textInputAction, TextInputAction.done);
expect(nameField.onSubmitted, isNotNull);
expect(descriptionField.textInputAction, TextInputAction.done);
expect(descriptionField.onSubmitted, isNotNull);
expect(
tester.getSize(find.byKey(const Key('create-channel-name'))).height,
greaterThanOrEqualTo(52),
);
expect(
tester
.getSize(find.byKey(const Key('create-channel-type-ongoing')))
.height,
greaterThanOrEqualTo(56),
);
await tester.tap(find.byKey(const Key('create-channel-type-temporary')));
await tester.pumpAndSettle();
expect(find.text('Expires after'), findsOneWidget);
expect(find.text('7 days'), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('create-channel-ttl'))).height,
greaterThanOrEqualTo(51),
);
final privateVisibility = find.byKey(
const Key('create-channel-visibility-private'),
);
await tester.ensureVisible(privateVisibility);
await tester.pumpAndSettle();
await tester.tap(privateVisibility);
await tester.pumpAndSettle();
expect(
tester
.widget<RadioGroup<String>>(find.byType(RadioGroup<String>))
.groupValue,
'private',
);
});
testWidgets('new message sheet lists and selects relay members', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const directoryUsers = [
DirectoryUser(
pubkey: 'alice',
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
DirectoryUser(pubkey: 'bob', displayName: 'Bob'),
DirectoryUser(pubkey: 'charlie', displayName: 'Charlie'),
DirectoryUser(pubkey: 'danielle', displayName: 'Danielle'),
];
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relayDirectoryUsersProvider.overrideWith(
(ref) async => directoryUsers,
),
relayDirectorySearchProvider.overrideWith((ref, query) async {
return directoryUsers
.where(
(user) =>
user.label.toLowerCase().contains(query.toLowerCase()),
)
.toList();
}),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
final dmSheetRect = tester.getRect(find.byType(BottomSheet).last);
expect(dmSheetRect.top, greaterThanOrEqualTo(24));
expect(find.text('New message'), findsOneWidget);
expect(find.text('To:'), findsOneWidget);
expect(find.text('Search for a person'), findsOneWidget);
final recipientField = find.byKey(const Key('new-dm-recipient-field'));
final initialRecipientWidth = tester.getSize(recipientField).width;
expect(tester.getSize(recipientField).height, greaterThanOrEqualTo(48));
expect(find.byKey(const Key('new-dm-person-alice')), findsOneWidget);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const Key('new-dm-person-alice')),
matching: find.byType(AvatarImage),
),
findsOneWidget,
);
await tester.enterText(find.byKey(const Key('new-dm-search')), 'bob');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.byKey(const Key('new-dm-person-alice')), findsNothing);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
await tester.enterText(find.byKey(const Key('new-dm-search')), '');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('new-dm-person-alice')));
await tester.pump();
final aliceChip = find.byKey(const Key('new-dm-selected-alice'));
final inlineSearch = find.byKey(const Key('new-dm-search'));
final aliceRect = tester.getRect(aliceChip);
final inlineSearchRect = tester.getRect(inlineSearch);
expect(inlineSearchRect.left, greaterThan(aliceRect.right));
expect(
(inlineSearchRect.center.dy - aliceRect.center.dy).abs(),
lessThan(1),
);
expect(find.text('Search for a person'), findsNothing);
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
expect(tester.getSize(recipientField).width, initialRecipientWidth);
expect(tester.getSize(aliceChip).height, 40);
expect(tester.getSize(aliceChip).width, lessThanOrEqualTo(224));
final aliceChipMaterial = tester.widget<Material>(
find.descendant(of: aliceChip, matching: find.byType(Material)).first,
);
expect(aliceChipMaterial.shape, isA<StadiumBorder>());
expect(
aliceChipMaterial.color,
Theme.of(tester.element(aliceChip)).colorScheme.surfaceContainerHighest,
);
expect(
tester
.widget<AvatarImage>(
find.descendant(of: aliceChip, matching: find.byType(AvatarImage)),
)
.radius,
16,
);
expect(
tester
.widget<Text>(
find.descendant(of: aliceChip, matching: find.text('Alice')),
)
.style
?.fontSize,
16,
);
expect(
find.descendant(of: aliceChip, matching: find.byIcon(LucideIcons.x)),
findsNothing,
);
expect(find.bySemanticsLabel('Remove Alice'), findsOneWidget);
await tester.tap(find.byKey(const Key('new-dm-person-bob')));
await tester.pump();
await tester.tap(find.byKey(const Key('new-dm-person-charlie')));
await tester.pump();
await tester.tap(find.byKey(const Key('new-dm-person-danielle')));
await tester.pump();
expect(aliceChip, findsOneWidget);
expect(find.byKey(const Key('new-dm-selected-bob')), findsOneWidget);
expect(find.byKey(const Key('new-dm-selected-charlie')), findsOneWidget);
final danielleChip = find.byKey(const Key('new-dm-selected-danielle'));
expect(danielleChip, findsOneWidget);
expect(tester.getSize(recipientField).width, initialRecipientWidth);
expect(
tester.getRect(danielleChip).top,
greaterThan(tester.getRect(aliceChip).top),
);
final recipientScrollViews = tester.widgetList<SingleChildScrollView>(
find.ancestor(
of: aliceChip,
matching: find.byType(SingleChildScrollView),
),
);
expect(
recipientScrollViews.every(
(scrollView) => scrollView.scrollDirection == Axis.vertical,
),
isTrue,
);
expect(
find.ancestor(
of: aliceChip,
matching: find.byKey(const Key('new-dm-recipient-wrap')),
),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('new-dm-selected-bob')));
await tester.pump();
expect(find.byKey(const Key('new-dm-selected-bob')), findsNothing);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
expect(find.text('Cancel'), findsNothing);
expect(find.text('Open DM'), findsNothing);
final searchField = tester.widget<TextField>(
find.byKey(const Key('new-dm-search')),
);
expect(searchField.textInputAction, TextInputAction.done);
expect(searchField.onSubmitted, isNotNull);
});
testWidgets('wraps many recipients without overflowing above the keyboard', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final directoryUsers = [
for (var index = 0; index < 8; index++)
DirectoryUser(
pubkey: 'person-$index',
displayName: 'Long recipient name number $index',
),
];
await tester.pumpWidget(
buildTestable(
keyboardInset: 300,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relayDirectoryUsersProvider.overrideWith(
(ref) async => directoryUsers,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
for (final user in directoryUsers) {
final result = find.byKey(Key('new-dm-person-${user.pubkey}'));
await tester.ensureVisible(result);
await tester.pumpAndSettle();
await tester.tap(result);
await tester.pumpAndSettle();
}
expect(find.byKey(const Key('new-dm-recipient-wrap')), findsOneWidget);
expect(
find.bySemanticsLabel(RegExp(r'^Remove Long recipient')),
findsNWidgets(8),
);
expect(
find.text('DMs support up to nine people, including you.'),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('shows local preview people when the relay is offline', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildTestable(
previewDirectory: true,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
expect(find.text('Could not load people from this relay.'), findsNothing);
expect(find.text('Maya Chen'), findsOneWidget);
expect(find.text('Jordan Brooks'), findsOneWidget);
expect(find.text('Priya Shah'), findsOneWidget);
await tester.tap(
find.byKey(
const Key(
'new-dm-person-'
'1111111111111111111111111111111111111111111111111111111111111111',
),
),
);
await tester.pump();
expect(find.text('Maya Chen'), findsWidgets);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(
find.text('Preview only — mock people cannot be messaged.'),
findsOneWidget,
);
});
testWidgets('hides unjoined and archived channels from the main list', ( testWidgets('hides unjoined and archived channels from the main list', (
tester, tester,
) async { ) async {
@@ -0,0 +1,137 @@
import 'package:buzz/features/home/home_page.dart';
import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
testWidgets('shows icon-only navigation and an aligned quick action', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
),
);
await tester.pump();
expect(find.text('Home'), findsNothing);
expect(find.text('Activity'), findsNothing);
expect(find.text('Search'), findsNothing);
expect(find.bySemanticsLabel('Home'), findsOneWidget);
expect(find.bySemanticsLabel('Activity'), findsOneWidget);
expect(find.bySemanticsLabel('Search'), findsOneWidget);
final quickAction = find.byTooltip('Create or start conversation');
expect(quickAction, findsOneWidget);
final launcherSize = tester.getSize(
find.byType(ChannelQuickActionsLauncher),
);
expect(launcherSize.width, 800);
expect(launcherSize.height, greaterThan(0));
final motionRect = tester.getRect(
find.byKey(const Key('channel-quick-actions-motion')),
);
expect(motionRect.width, const Size.square(56).width);
expect(motionRect.left, greaterThanOrEqualTo(0));
expect(tester.getSize(quickAction), const Size.square(56));
final quickActionRect = tester.getRect(quickAction);
expect(quickActionRect.left, greaterThanOrEqualTo(0));
expect(quickActionRect.top, greaterThanOrEqualTo(0));
expect(quickActionRect.right, lessThanOrEqualTo(800));
expect(quickActionRect.bottom, lessThanOrEqualTo(600));
final homeDestinationRect = tester.getRect(find.bySemanticsLabel('Home'));
expect(
quickActionRect.center.dy,
closeTo(homeDestinationRect.center.dy, 0.01),
);
});
testWidgets('gives selection haptics only when the tab changes', (
tester,
) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') {
hapticCalls.add(call);
}
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Home'));
await tester.pump();
expect(hapticCalls, isEmpty);
await tester.tap(find.byTooltip('Activity'));
await tester.pump();
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.selectionClick');
await tester.tap(find.byTooltip('Activity'));
await tester.pump();
expect(hapticCalls, hasLength(1));
await tester.tap(find.byTooltip('Search'));
await tester.pump();
expect(hapticCalls, hasLength(2));
});
testWidgets('scales and fades the quick action as tabs change', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
),
);
await tester.pump();
double scale() => tester
.widget<Transform>(find.byKey(const Key('channel-quick-actions-scale')))
.transform
.storage
.first;
double opacity() => tester
.widget<Opacity>(find.byKey(const Key('channel-quick-actions-opacity')))
.opacity;
expect(scale(), closeTo(1, 0.001));
expect(opacity(), closeTo(1, 0.001));
await tester.tap(find.byTooltip('Activity'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 110));
expect(scale(), inExclusiveRange(0.8, 1));
expect(opacity(), inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(scale(), closeTo(0.8, 0.001));
expect(opacity(), closeTo(0, 0.001));
await tester.tap(find.byTooltip('Home'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 110));
expect(scale(), inExclusiveRange(0.8, 1));
expect(opacity(), inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(scale(), closeTo(1, 0.001));
expect(opacity(), closeTo(1, 0.001));
});
}
@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/app_theme.dart';
void main() {
test('disables Material touch ripples in every app theme', () {
expect(AppTheme.light().splashFactory, NoSplash.splashFactory);
expect(AppTheme.dark().splashFactory, NoSplash.splashFactory);
});
}