mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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,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 ||
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user