mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(mobile): image upload + media viewer parity with desktop (#384)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -1084,7 +1084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3126,7 +3126,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3185,7 +3185,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3196,9 +3196,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.12"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -4294,7 +4294,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
4
desktop/src-tauri/Cargo.lock
generated
4
desktop/src-tauri/Cargo.lock
generated
@@ -4590,9 +4590,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.12"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
//! NIP-AB device pairing — Tauri commands for the source (desktop) side.
|
||||
//!
|
||||
//! Architecture: a background tokio task owns the WebSocket connection.
|
||||
//! Tauri commands communicate with it via an mpsc channel (JSON strings).
|
||||
//! State changes are pushed to the React frontend via Tauri events.
|
||||
//!
|
||||
//! sprout-core depends on nostr 0.36 while the desktop uses nostr 0.37.
|
||||
//! We import `nostr_compat` (aliased to nostr 0.36) for types that cross
|
||||
//! the sprout-core boundary, and serialize events to JSON strings for the
|
||||
//! mpsc channel to avoid mixing the two versions.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -28,9 +17,7 @@ use zeroize::Zeroizing;
|
||||
use crate::app_state::AppState;
|
||||
use crate::relay::{relay_api_base_url, relay_ws_url};
|
||||
|
||||
use super::tokens::mint_token_internal;
|
||||
|
||||
// ── Tauri event payloads ────────────────────────────────────────────────────
|
||||
use super::tokens::{mint_token_internal_with_auth_mode, MintTokenAuthMode};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct PairingSasPayload {
|
||||
@@ -47,8 +34,6 @@ struct PairingErrorPayload {
|
||||
message: String,
|
||||
}
|
||||
|
||||
// ── Managed state ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Managed Tauri state for an active pairing session.
|
||||
pub struct PairingHandle {
|
||||
session: Arc<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
@@ -74,14 +59,9 @@ impl PairingHandle {
|
||||
*self.cancel.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*self.outbound_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*self.payload.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
// Note: session is in an Arc<tokio::sync::Mutex> and is cleared
|
||||
// by the background task on exit. For immediate cleanup, callers
|
||||
// should also lock and clear the session explicitly.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mobile scopes ───────────────────────────────────────────────────────────
|
||||
|
||||
const MOBILE_SCOPES: &[&str] = &[
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
@@ -89,10 +69,17 @@ const MOBILE_SCOPES: &[&str] = &[
|
||||
"channels:write",
|
||||
"users:read",
|
||||
"files:read",
|
||||
"files:write",
|
||||
];
|
||||
const EXPIRES_IN_DAYS: u32 = 90;
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────────────
|
||||
fn mobile_pairing_mint_auth_mode(state: &AppState) -> MintTokenAuthMode {
|
||||
if state.configured_api_token.is_some() {
|
||||
MintTokenAuthMode::BootstrapNip98
|
||||
} else {
|
||||
MintTokenAuthMode::Auto
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a NIP-AB pairing session as the source device.
|
||||
///
|
||||
@@ -104,19 +91,23 @@ pub async fn start_pairing(
|
||||
state: State<'_, AppState>,
|
||||
pairing: State<'_, PairingHandle>,
|
||||
) -> Result<String, String> {
|
||||
// Cancel any existing session.
|
||||
if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() {
|
||||
token.cancel();
|
||||
}
|
||||
pairing.clear();
|
||||
|
||||
// 1. Mint a mobile token.
|
||||
let scopes: Vec<String> = MOBILE_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
let token_name = format!("mobile-pairing-{}", chrono::Utc::now().timestamp());
|
||||
let mint_result =
|
||||
mint_token_internal(&state, &token_name, &scopes, None, Some(EXPIRES_IN_DAYS)).await?;
|
||||
let mint_result = mint_token_internal_with_auth_mode(
|
||||
&state,
|
||||
&token_name,
|
||||
&scopes,
|
||||
None,
|
||||
Some(EXPIRES_IN_DAYS),
|
||||
mobile_pairing_mint_auth_mode(&state),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. Read user identity.
|
||||
let (nsec, pubkey_hex) = {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
let nsec = keys
|
||||
@@ -127,15 +118,12 @@ pub async fn start_pairing(
|
||||
(nsec, pubkey)
|
||||
};
|
||||
|
||||
// 3. Get relay URLs.
|
||||
let ws_url = relay_ws_url();
|
||||
let http_url = relay_api_base_url();
|
||||
|
||||
// 4. Create the pairing session.
|
||||
let (session, qr_payload) = PairingSession::new_source(ws_url.clone());
|
||||
let qr_uri = encode_qr(&qr_payload);
|
||||
|
||||
// 5. Build the custom payload to send after SAS confirmation.
|
||||
let payload_json = serde_json::json!({
|
||||
"relayUrl": http_url,
|
||||
"token": mint_result.token,
|
||||
@@ -143,7 +131,6 @@ pub async fn start_pairing(
|
||||
"nsec": nsec,
|
||||
});
|
||||
|
||||
// 6. Store session + payload.
|
||||
{
|
||||
let mut s = pairing.session.lock().await;
|
||||
*s = Some(session);
|
||||
@@ -151,14 +138,12 @@ pub async fn start_pairing(
|
||||
*pairing.payload.lock().map_err(|e| e.to_string())? =
|
||||
Some(Zeroizing::new(payload_json.to_string()));
|
||||
|
||||
// 7. Create channel + cancellation token.
|
||||
let (outbound_tx, outbound_rx) = mpsc::channel::<String>(16);
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
*pairing.outbound_tx.lock().map_err(|e| e.to_string())? = Some(outbound_tx);
|
||||
*pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone());
|
||||
|
||||
// 8. Spawn background WS task.
|
||||
let session_arc = Arc::clone(&pairing.session);
|
||||
tauri::async_runtime::spawn(pairing_ws_task(
|
||||
ws_url,
|
||||
@@ -181,7 +166,6 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<()
|
||||
.clone()
|
||||
.ok_or("no active pairing session")?;
|
||||
|
||||
// 1. Confirm SAS → get sas-confirm event, serialize to JSON.
|
||||
let sas_confirm_json = {
|
||||
let mut guard = pairing.session.lock().await;
|
||||
let session = guard.as_mut().ok_or("no active pairing session")?;
|
||||
@@ -193,7 +177,6 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<()
|
||||
.await
|
||||
.map_err(|_| "failed to send sas-confirm")?;
|
||||
|
||||
// 2. Send payload (contains nsec — Zeroizing ensures cleanup).
|
||||
let payload = pairing
|
||||
.payload
|
||||
.lock()
|
||||
@@ -220,7 +203,6 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<()
|
||||
/// Cancel the active pairing session.
|
||||
#[tauri::command]
|
||||
pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), String> {
|
||||
// Try to send an abort event if we have a session and a channel.
|
||||
let abort_json = {
|
||||
let mut guard = pairing.session.lock().await;
|
||||
if let Some(session) = guard.as_mut() {
|
||||
@@ -245,13 +227,11 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel background task.
|
||||
if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() {
|
||||
token.cancel();
|
||||
}
|
||||
pairing.clear();
|
||||
|
||||
// Clear session.
|
||||
{
|
||||
let mut s = pairing.session.lock().await;
|
||||
*s = None;
|
||||
@@ -260,8 +240,6 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Background WebSocket task ───────────────────────────────────────────────
|
||||
|
||||
async fn pairing_ws_task(
|
||||
relay_url: String,
|
||||
session: Arc<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
@@ -274,7 +252,6 @@ async fn pairing_ws_task(
|
||||
{
|
||||
let _ = app.emit("pairing-error", PairingErrorPayload { message: e });
|
||||
}
|
||||
// Clean up session on exit.
|
||||
let mut s = session.lock().await;
|
||||
*s = None;
|
||||
}
|
||||
@@ -286,16 +263,13 @@ async fn pairing_ws_task_inner(
|
||||
outbound_rx: &mut mpsc::Receiver<String>,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
// Connect to relay.
|
||||
let (ws, _) = connect_async(relay_url)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connection failed: {e}"))?;
|
||||
let (mut write, mut read) = ws.split();
|
||||
|
||||
// Handle NIP-42 auth if required.
|
||||
handle_nip42_auth(&mut read, &mut write, session, relay_url).await?;
|
||||
|
||||
// Subscribe for kind:24134 events tagged to our ephemeral pubkey.
|
||||
let our_pk = {
|
||||
let guard = session.lock().await;
|
||||
guard.as_ref().ok_or("session gone")?.pubkey().to_hex()
|
||||
@@ -309,10 +283,8 @@ async fn pairing_ws_task_inner(
|
||||
.await
|
||||
.map_err(|e| format!("subscribe failed: {e}"))?;
|
||||
|
||||
// Wait for EOSE to confirm subscription is registered.
|
||||
wait_for_eose(&mut read, "pair", Duration::from_secs(10)).await?;
|
||||
|
||||
// Event loop.
|
||||
let hard_timeout = tokio::time::sleep(Duration::from_secs(130));
|
||||
tokio::pin!(hard_timeout);
|
||||
|
||||
@@ -326,7 +298,6 @@ async fn pairing_ws_task_inner(
|
||||
break;
|
||||
}
|
||||
Some(json_msg) = outbound_rx.recv() => {
|
||||
// json_msg is already a ["EVENT", ...] JSON string.
|
||||
if let Err(e) = write.send(Message::Text(json_msg.into())).await {
|
||||
return Err(format!("publish failed: {e}"));
|
||||
}
|
||||
@@ -338,12 +309,10 @@ async fn pairing_ws_task_inner(
|
||||
let msg = msg.map_err(|e| format!("WS read error: {e}"))?;
|
||||
let Message::Text(text) = msg else { continue };
|
||||
|
||||
// Parse relay EVENT message into sprout-core's nostr::Event.
|
||||
if let Some(event) = parse_relay_event(text.as_str(), "pair") {
|
||||
let mut guard = session.lock().await;
|
||||
let Some(s) = guard.as_mut() else { break };
|
||||
|
||||
// Check for abort from peer.
|
||||
match s.handle_abort(&event) {
|
||||
Ok(reason) => {
|
||||
let _ = app.emit("pairing-aborted", PairingAbortedPayload {
|
||||
@@ -351,16 +320,14 @@ async fn pairing_ws_task_inner(
|
||||
});
|
||||
break;
|
||||
}
|
||||
Err(_) => {} // Not an abort — continue.
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
// Try handle_offer (source waits for this first).
|
||||
if let Ok(sas) = s.handle_offer(&event) {
|
||||
let _ = app.emit("pairing-sas-received", PairingSasPayload { sas });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try handle_complete (source waits for this after sending payload).
|
||||
match s.handle_complete(&event) {
|
||||
Ok(()) => {
|
||||
let _ = app.emit("pairing-complete", serde_json::json!({}));
|
||||
@@ -372,7 +339,7 @@ async fn pairing_ws_task_inner(
|
||||
});
|
||||
break;
|
||||
}
|
||||
Err(_) => {} // Wrong state or wrong message — discard per NIP-AB.
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,8 +349,6 @@ async fn pairing_ws_task_inner(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── NIP-42 auth helper ──────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_nip42_auth<R, W>(
|
||||
read: &mut R,
|
||||
write: &mut W,
|
||||
@@ -394,7 +359,6 @@ where
|
||||
R: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
W: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
|
||||
{
|
||||
// Wait up to 3 seconds for an AUTH challenge. Timeout is normal.
|
||||
let auth_result = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
let msg = read
|
||||
@@ -414,11 +378,9 @@ where
|
||||
let challenge: String = match auth_result {
|
||||
Ok(Ok(c)) => c,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()), // No AUTH challenge.
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
// Sign auth event with the session's ephemeral keys.
|
||||
// Use sprout-core's nostr EventBuilder (0.36) via PairingSession::sign_event.
|
||||
let relay_url_parsed: url::Url = relay_url
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid relay URL: {e}"))?;
|
||||
@@ -442,7 +404,6 @@ where
|
||||
.await
|
||||
.map_err(|e| format!("send auth: {e}"))?;
|
||||
|
||||
// Wait for OK response (up to 5 seconds).
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let msg = read
|
||||
@@ -462,8 +423,6 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Helper functions ────────────────────────────────────────────────────────
|
||||
|
||||
/// Serialize a nostr 0.36 Event to `["EVENT", <event>]` JSON string.
|
||||
fn event_to_relay_json(event: &nostr_compat::Event) -> String {
|
||||
format!("[\"EVENT\",{}]", nostr_compat::JsonUtil::as_json(event))
|
||||
@@ -522,3 +481,37 @@ where
|
||||
.await
|
||||
.map_err(|_| "timeout waiting for EOSE".to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MOBILE_SCOPES;
|
||||
use super::*;
|
||||
use crate::app_state::build_app_state;
|
||||
|
||||
#[test]
|
||||
fn mobile_pairing_token_includes_file_write_scope() {
|
||||
assert!(MOBILE_SCOPES.contains(&"files:write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_pairing_uses_bootstrap_mint_when_configured_token_is_present() {
|
||||
let mut state = build_app_state();
|
||||
state.configured_api_token = Some("desktop-token".to_string());
|
||||
|
||||
assert_eq!(
|
||||
mobile_pairing_mint_auth_mode(&state),
|
||||
MintTokenAuthMode::BootstrapNip98
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_pairing_keeps_auto_mint_without_configured_token() {
|
||||
let mut state = build_app_state();
|
||||
state.configured_api_token = None;
|
||||
|
||||
assert_eq!(
|
||||
mobile_pairing_mint_auth_mode(&state),
|
||||
MintTokenAuthMode::Auto
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,22 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum MintTokenAuthMode {
|
||||
Auto,
|
||||
BootstrapNip98,
|
||||
}
|
||||
|
||||
impl MintTokenAuthMode {
|
||||
fn uses_configured_bearer_token(self, state: &AppState) -> bool {
|
||||
matches!(self, Self::Auto) && state.configured_api_token.is_some()
|
||||
}
|
||||
|
||||
fn should_store_minted_session_token(self, state: &AppState) -> bool {
|
||||
matches!(self, Self::Auto) && state.configured_api_token.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_tokens(state: State<'_, AppState>) -> Result<ListTokensResponse, String> {
|
||||
let request =
|
||||
@@ -17,6 +33,31 @@ pub async fn list_tokens(state: State<'_, AppState>) -> Result<ListTokensRespons
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
fn build_mint_token_request(
|
||||
state: &AppState,
|
||||
body: &MintTokenBody<'_>,
|
||||
auth_mode: MintTokenAuthMode,
|
||||
) -> Result<reqwest::RequestBuilder, String> {
|
||||
if auth_mode.uses_configured_bearer_token(state) {
|
||||
return Ok(
|
||||
build_authed_request(&state.http_client, Method::POST, "/api/tokens", state)?
|
||||
.json(body),
|
||||
);
|
||||
}
|
||||
|
||||
let url = format!("{}{}", relay_api_base_url(), "/api/tokens");
|
||||
let body_bytes =
|
||||
serde_json::to_vec(body).map_err(|error| format!("serialize failed: {error}"))?;
|
||||
let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?;
|
||||
|
||||
Ok(state
|
||||
.http_client
|
||||
.request(Method::POST, url)
|
||||
.header("Authorization", auth_header)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_bytes))
|
||||
}
|
||||
|
||||
/// Internal token minting logic, callable from other modules (e.g. pairing).
|
||||
pub async fn mint_token_internal(
|
||||
state: &AppState,
|
||||
@@ -24,6 +65,25 @@ pub async fn mint_token_internal(
|
||||
scopes: &[String],
|
||||
channel_ids: Option<&[String]>,
|
||||
expires_in_days: Option<u32>,
|
||||
) -> Result<MintTokenResponse, String> {
|
||||
mint_token_internal_with_auth_mode(
|
||||
state,
|
||||
name,
|
||||
scopes,
|
||||
channel_ids,
|
||||
expires_in_days,
|
||||
MintTokenAuthMode::Auto,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mint_token_internal_with_auth_mode(
|
||||
state: &AppState,
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
channel_ids: Option<&[String]>,
|
||||
expires_in_days: Option<u32>,
|
||||
auth_mode: MintTokenAuthMode,
|
||||
) -> Result<MintTokenResponse, String> {
|
||||
let body = MintTokenBody {
|
||||
name,
|
||||
@@ -32,24 +92,10 @@ pub async fn mint_token_internal(
|
||||
expires_in_days,
|
||||
owner_pubkey: None,
|
||||
};
|
||||
let request = if state.configured_api_token.is_some() {
|
||||
build_authed_request(&state.http_client, Method::POST, "/api/tokens", state)?.json(&body)
|
||||
} else {
|
||||
let url = format!("{}{}", relay_api_base_url(), "/api/tokens");
|
||||
let body_bytes =
|
||||
serde_json::to_vec(&body).map_err(|error| format!("serialize failed: {error}"))?;
|
||||
let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?;
|
||||
|
||||
state
|
||||
.http_client
|
||||
.request(Method::POST, url)
|
||||
.header("Authorization", auth_header)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_bytes)
|
||||
};
|
||||
let request = build_mint_token_request(state, &body, auth_mode)?;
|
||||
let response: MintTokenResponse = send_json_request(request).await?;
|
||||
|
||||
if state.configured_api_token.is_none() {
|
||||
if auth_mode.should_store_minted_session_token(state) {
|
||||
let mut token = state
|
||||
.session_token
|
||||
.lock()
|
||||
@@ -94,3 +140,68 @@ pub async fn revoke_all_tokens(
|
||||
build_token_management_request(&state.http_client, Method::DELETE, "/api/tokens", &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_state::build_app_state;
|
||||
|
||||
fn test_body<'a>(scopes: &'a [String]) -> MintTokenBody<'a> {
|
||||
MintTokenBody {
|
||||
name: "test-token",
|
||||
scopes,
|
||||
channel_ids: None,
|
||||
expires_in_days: Some(7),
|
||||
owner_pubkey: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_mint_uses_configured_bearer_when_present() {
|
||||
let mut state = build_app_state();
|
||||
state.configured_api_token = Some("desktop-token".to_string());
|
||||
let scopes = vec!["messages:read".to_string()];
|
||||
|
||||
let request =
|
||||
build_mint_token_request(&state, &test_body(&scopes), MintTokenAuthMode::Auto)
|
||||
.expect("request should build")
|
||||
.build()
|
||||
.expect("request should finalize");
|
||||
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.expect("auth header")
|
||||
.to_str()
|
||||
.expect("auth header should be valid utf-8"),
|
||||
"Bearer desktop-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_mint_ignores_configured_bearer_token() {
|
||||
let mut state = build_app_state();
|
||||
state.configured_api_token = Some("desktop-token".to_string());
|
||||
let scopes = vec!["messages:read".to_string(), "files:write".to_string()];
|
||||
|
||||
let request = build_mint_token_request(
|
||||
&state,
|
||||
&test_body(&scopes),
|
||||
MintTokenAuthMode::BootstrapNip98,
|
||||
)
|
||||
.expect("request should build")
|
||||
.build()
|
||||
.expect("request should finalize");
|
||||
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.expect("auth header")
|
||||
.to_str()
|
||||
.expect("auth header should be valid utf-8");
|
||||
|
||||
assert!(auth_header.starts_with("Nostr "));
|
||||
assert_ne!(auth_header, "Bearer desktop-token");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,165 @@
|
||||
package com.sprout.sprout_mobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var mediaUploadChannel: MethodChannel? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
mediaUploadChannel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
MEDIA_UPLOAD_CHANNEL,
|
||||
).also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
SANITIZE_IMAGE_FOR_UPLOAD_METHOD -> {
|
||||
handleSanitizeImageForUpload(call.arguments, result)
|
||||
}
|
||||
TRANSCODE_IMAGE_TO_JPEG_METHOD -> {
|
||||
handleTranscodeImageToJpeg(call.arguments, result)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSanitizeImageForUpload(
|
||||
arguments: Any?,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val payload = arguments as? Map<*, *> ?: run {
|
||||
invalidArguments(result, "Expected image bytes and mime type.")
|
||||
return
|
||||
}
|
||||
val bytes = payload["bytes"] as? ByteArray ?: run {
|
||||
invalidArguments(result, "Expected raw image bytes.")
|
||||
return
|
||||
}
|
||||
val mimeType = payload["mimeType"] as? String ?: run {
|
||||
invalidArguments(result, "Expected image mime type.")
|
||||
return
|
||||
}
|
||||
|
||||
val format = sanitizeCompressFormatFor(mimeType)
|
||||
if (format == null) {
|
||||
result.error(
|
||||
"sanitize_failed",
|
||||
"Unable to sanitize picked image.",
|
||||
mimeType,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
transformImageBytes(
|
||||
bytes = bytes,
|
||||
result = result,
|
||||
format = format,
|
||||
errorCode = "sanitize_failed",
|
||||
encodeFailureMessage = "Unable to sanitize picked image.",
|
||||
errorDetails = mimeType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleTranscodeImageToJpeg(
|
||||
arguments: Any?,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val bytes = arguments as? ByteArray ?: run {
|
||||
invalidArguments(result, "Expected raw image bytes.")
|
||||
return
|
||||
}
|
||||
|
||||
transformImageBytes(
|
||||
bytes = bytes,
|
||||
result = result,
|
||||
format = Bitmap.CompressFormat.JPEG,
|
||||
errorCode = "transcode_failed",
|
||||
encodeFailureMessage = "Unable to convert picked image to JPEG.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeBitmap(bytes: ByteArray): Bitmap? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
runCatching {
|
||||
val source = ImageDecoder.createSource(ByteBuffer.wrap(bytes))
|
||||
ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
}
|
||||
}.getOrNull()?.let { return it }
|
||||
}
|
||||
|
||||
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
private fun encodeBitmap(
|
||||
bitmap: Bitmap,
|
||||
format: Bitmap.CompressFormat,
|
||||
): ByteArray? {
|
||||
val output = ByteArrayOutputStream()
|
||||
val encoded = bitmap.compress(format, 100, output)
|
||||
return if (encoded) output.toByteArray() else null
|
||||
}
|
||||
|
||||
private fun sanitizeCompressFormatFor(
|
||||
mimeType: String,
|
||||
): Bitmap.CompressFormat? {
|
||||
return when (mimeType) {
|
||||
"image/jpeg" -> Bitmap.CompressFormat.JPEG
|
||||
"image/png" -> Bitmap.CompressFormat.PNG
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun transformImageBytes(
|
||||
bytes: ByteArray,
|
||||
result: MethodChannel.Result,
|
||||
format: Bitmap.CompressFormat,
|
||||
errorCode: String,
|
||||
encodeFailureMessage: String,
|
||||
errorDetails: Any? = null,
|
||||
) {
|
||||
val bitmap = decodeBitmap(bytes) ?: run {
|
||||
result.error(
|
||||
errorCode,
|
||||
"Unable to decode picked image.",
|
||||
null,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val transformedBytes = encodeBitmap(bitmap, format) ?: run {
|
||||
result.error(
|
||||
errorCode,
|
||||
encodeFailureMessage,
|
||||
errorDetails,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
result.success(transformedBytes)
|
||||
}
|
||||
|
||||
private fun invalidArguments(
|
||||
result: MethodChannel.Result,
|
||||
message: String,
|
||||
) {
|
||||
result.error("invalid_arguments", message, null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MEDIA_UPLOAD_CHANNEL = "sprout/media_upload"
|
||||
private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload"
|
||||
private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- mobile_scanner (7.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -12,14 +14,19 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
@@ -28,20 +35,26 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
||||
|
||||
PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var mediaUploadChannel: FlutterMethodChannel?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
@@ -12,5 +14,97 @@ import UIKit
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
mediaUploadChannel = FlutterMethodChannel(
|
||||
name: "sprout/media_upload",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handleMediaUploadMethodCall(call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMediaUploadMethodCall(
|
||||
_ call: FlutterMethodCall,
|
||||
result: @escaping FlutterResult
|
||||
) {
|
||||
switch call.method {
|
||||
case "sanitizeImageForUpload":
|
||||
guard
|
||||
let arguments = call.arguments as? [String: Any],
|
||||
let typedData = arguments["bytes"] as? FlutterStandardTypedData,
|
||||
let mimeType = arguments["mimeType"] as? String
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "invalid_arguments",
|
||||
message: "Expected image bytes and mime type.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = UIImage(data: typedData.data) else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "sanitize_failed",
|
||||
message: "Unable to decode picked image.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let sanitizedData: Data?
|
||||
switch mimeType {
|
||||
case "image/png":
|
||||
sanitizedData = image.pngData()
|
||||
case "image/jpeg":
|
||||
sanitizedData = image.jpegData(compressionQuality: 1.0)
|
||||
default:
|
||||
sanitizedData = nil
|
||||
}
|
||||
|
||||
guard let sanitizedData else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "sanitize_failed",
|
||||
message: "Unable to sanitize picked image.",
|
||||
details: mimeType
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
result(FlutterStandardTypedData(bytes: sanitizedData))
|
||||
case "transcodeImageToJpeg":
|
||||
guard let typedData = call.arguments as? FlutterStandardTypedData else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "invalid_arguments",
|
||||
message: "Expected raw image bytes.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = UIImage(data: typedData.data),
|
||||
let jpegData = image.jpegData(compressionQuality: 1.0)
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "transcode_failed",
|
||||
message: "Unable to convert picked image to JPEG.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
result(FlutterStandardTypedData(bytes: jpegData))
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Sprout needs photo library access so you can attach images to messages.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -186,13 +186,19 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
ComposeBar(
|
||||
channelId: channel.id,
|
||||
channelName: resolvedChannel.isDm ? '' : resolvedChannel.name,
|
||||
onSend: (content, mentionPubkeys) => ref
|
||||
.read(sendMessageProvider)
|
||||
.call(
|
||||
channelId: channel.id,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) => ref
|
||||
.read(sendMessageProvider)
|
||||
.call(
|
||||
channelId: channel.id,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
mediaTags: mediaTags,
|
||||
),
|
||||
)
|
||||
else if (!resolvedChannel.isDm &&
|
||||
(!resolvedChannel.isMember || resolvedChannel.isArchived))
|
||||
@@ -654,6 +660,7 @@ class _MessageBubble extends ConsumerWidget {
|
||||
content: message.content,
|
||||
mentionNames: mentionNames,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
onChannelTap: (channelId) {
|
||||
if (channelId == currentChannelId) return;
|
||||
final channelsAsync = ref.read(channelsProvider);
|
||||
|
||||
@@ -14,12 +14,18 @@ import 'channel_management_provider.dart';
|
||||
/// Rich compose bar with @mention autocomplete, emoji picker, and a markdown
|
||||
/// formatting toolbar. Used in both channel and thread views — the caller
|
||||
/// provides an [onSend] callback that handles actual message submission.
|
||||
typedef ComposeBarOnSend =
|
||||
Future<void> Function(
|
||||
String content,
|
||||
List<String> mentionPubkeys, {
|
||||
List<List<String>> mediaTags,
|
||||
});
|
||||
|
||||
class ComposeBar extends HookConsumerWidget {
|
||||
final String channelId;
|
||||
final String channelName;
|
||||
final String? hintText;
|
||||
final Future<void> Function(String content, List<String> mentionPubkeys)
|
||||
onSend;
|
||||
final ComposeBarOnSend onSend;
|
||||
|
||||
/// Optional thread IDs for thread-scoped typing indicators.
|
||||
final String? threadHeadId;
|
||||
@@ -41,6 +47,11 @@ class ComposeBar extends HookConsumerWidget {
|
||||
final focusNode = useFocusNode();
|
||||
final isSending = useState(false);
|
||||
final showFormatting = useState(false);
|
||||
final attachments = useState<List<BlobDescriptor>>([]);
|
||||
final uploadError = useState<String?>(null);
|
||||
final uploadingCount = useState(0);
|
||||
final hasAttachments = attachments.value.isNotEmpty;
|
||||
final hasPendingUploads = uploadingCount.value > 0;
|
||||
|
||||
final resolvedHint =
|
||||
hintText ??
|
||||
@@ -170,10 +181,28 @@ class ComposeBar extends HookConsumerWidget {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void clearComposer() {
|
||||
controller.clear();
|
||||
attachments.value = [];
|
||||
mentionMap.value.clear();
|
||||
mentionQuery.value = null;
|
||||
showFormatting.value = false;
|
||||
uploadError.value = null;
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void removeAttachment(String url) {
|
||||
attachments.value = _withoutAttachment(attachments.value, url);
|
||||
}
|
||||
|
||||
// Send the message.
|
||||
Future<void> send() async {
|
||||
final text = controller.text.trim();
|
||||
if (text.isEmpty || isSending.value) return;
|
||||
if ((text.isEmpty && !hasAttachments) ||
|
||||
isSending.value ||
|
||||
hasPendingUploads) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract pubkeys for mentions present in the final text.
|
||||
final pubkeys = <String>[
|
||||
@@ -181,21 +210,43 @@ class ComposeBar extends HookConsumerWidget {
|
||||
if (text.contains('@${entry.key}')) entry.value,
|
||||
];
|
||||
|
||||
final payload = _ComposeDraftPayload.fromDraft(
|
||||
text: text,
|
||||
attachments: attachments.value,
|
||||
);
|
||||
|
||||
isSending.value = true;
|
||||
try {
|
||||
await onSend(text, pubkeys);
|
||||
await onSend(payload.content, pubkeys, mediaTags: payload.mediaTags);
|
||||
if (context.mounted) {
|
||||
controller.clear();
|
||||
mentionMap.value.clear();
|
||||
mentionQuery.value = null;
|
||||
showFormatting.value = false;
|
||||
focusNode.requestFocus();
|
||||
clearComposer();
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) isSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pickAndUploadAttachment() async {
|
||||
uploadError.value = null;
|
||||
uploadingCount.value += 1;
|
||||
try {
|
||||
final uploaded = await ref
|
||||
.read(mediaUploadServiceProvider)
|
||||
.pickAndUploadImage();
|
||||
if (uploaded != null && context.mounted) {
|
||||
attachments.value = [...attachments.value, uploaded];
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
uploadError.value = _formatUploadError(error);
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
uploadingCount.value -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert an emoji at the cursor.
|
||||
void insertEmoji(String emoji) {
|
||||
final text = controller.text;
|
||||
@@ -284,6 +335,28 @@ class ComposeBar extends HookConsumerWidget {
|
||||
if (showFormatting.value)
|
||||
_FormattingToolbar(onFormat: applyFormat),
|
||||
|
||||
if (hasAttachments || hasPendingUploads) ...[
|
||||
_AttachmentStrip(
|
||||
attachments: attachments.value,
|
||||
uploadingCount: uploadingCount.value,
|
||||
onRemove: removeAttachment,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
|
||||
if (uploadError.value case final error?) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
error,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
|
||||
// Row 1 — text input (full width, grows).
|
||||
TextField(
|
||||
controller: controller,
|
||||
@@ -316,7 +389,7 @@ class ComposeBar extends HookConsumerWidget {
|
||||
children: [
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.paperclip,
|
||||
onTap: () {}, // attachment placeholder
|
||||
onTap: pickAndUploadAttachment,
|
||||
),
|
||||
_ComposeAction(
|
||||
icon: LucideIcons.smilePlus,
|
||||
@@ -335,7 +408,11 @@ class ComposeBar extends HookConsumerWidget {
|
||||
onTap: () => showFormatting.value = !showFormatting.value,
|
||||
),
|
||||
const Spacer(),
|
||||
_SendButton(isSending: isSending.value, onTap: send),
|
||||
_SendButton(
|
||||
isDisabled: hasPendingUploads,
|
||||
isSending: isSending.value,
|
||||
onTap: send,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -902,11 +979,146 @@ class _ComposeAction extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class _ComposeDraftPayload {
|
||||
final String content;
|
||||
final List<List<String>> mediaTags;
|
||||
|
||||
const _ComposeDraftPayload({required this.content, required this.mediaTags});
|
||||
|
||||
factory _ComposeDraftPayload.fromDraft({
|
||||
required String text,
|
||||
required List<BlobDescriptor> attachments,
|
||||
}) {
|
||||
var content = text;
|
||||
final mediaTags = <List<String>>[];
|
||||
for (final attachment in attachments) {
|
||||
mediaTags.add(attachment.toImetaTag());
|
||||
content += '\n${attachment.toMarkdownImage()}';
|
||||
}
|
||||
return _ComposeDraftPayload(content: content, mediaTags: mediaTags);
|
||||
}
|
||||
}
|
||||
|
||||
List<BlobDescriptor> _withoutAttachment(
|
||||
List<BlobDescriptor> attachments,
|
||||
String url,
|
||||
) {
|
||||
return [
|
||||
for (final attachment in attachments)
|
||||
if (attachment.url != url) attachment,
|
||||
];
|
||||
}
|
||||
|
||||
class _AttachmentStrip extends StatelessWidget {
|
||||
final List<BlobDescriptor> attachments;
|
||||
final int uploadingCount;
|
||||
final void Function(String url) onRemove;
|
||||
|
||||
const _AttachmentStrip({
|
||||
required this.attachments,
|
||||
required this.uploadingCount,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbWidth = 72.0;
|
||||
final thumbHeight = 72.0;
|
||||
|
||||
return SizedBox(
|
||||
height: thumbHeight,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: attachments.length + uploadingCount,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: Grid.half),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= attachments.length) {
|
||||
return Container(
|
||||
width: thumbWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final attachment = attachments[index];
|
||||
final previewUrl = attachment.thumb ?? attachment.url;
|
||||
return Container(
|
||||
key: ValueKey('compose-attachment:${attachment.url}'),
|
||||
width: thumbWidth,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
child: Image.network(
|
||||
previewUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => ColoredBox(
|
||||
color: context.colors.surface,
|
||||
child: Icon(
|
||||
LucideIcons.image,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: Grid.quarter,
|
||||
right: Grid.quarter,
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: IconButton(
|
||||
onPressed: () => onRemove(attachment.url),
|
||||
tooltip: 'Remove attachment',
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: context.colors.surface.withValues(
|
||||
alpha: 0.92,
|
||||
),
|
||||
minimumSize: const Size(24, 24),
|
||||
maximumSize: const Size(24, 24),
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: Icon(
|
||||
LucideIcons.x,
|
||||
size: 14,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SendButton extends StatelessWidget {
|
||||
final bool isSending;
|
||||
final bool isDisabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SendButton({required this.isSending, required this.onTap});
|
||||
const _SendButton({
|
||||
required this.isSending,
|
||||
required this.onTap,
|
||||
this.isDisabled = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -914,7 +1126,7 @@ class _SendButton extends StatelessWidget {
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
onPressed: isSending ? null : onTap,
|
||||
onPressed: (isSending || isDisabled) ? null : onTap,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: context.colors.primary,
|
||||
disabledBackgroundColor: context.colors.primary.withValues(
|
||||
@@ -943,3 +1155,7 @@ class _SendButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatUploadError(Object error) {
|
||||
return error.toString().replaceFirst('Exception: ', '');
|
||||
}
|
||||
|
||||
506
mobile/lib/features/channels/media_viewer_page.dart
Normal file
506
mobile/lib/features/channels/media_viewer_page.dart
Normal file
@@ -0,0 +1,506 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
const _imageViewerPushDuration = Duration(milliseconds: 280);
|
||||
const _imageViewerPopDuration = Duration(milliseconds: 220);
|
||||
const _imageViewerTransitionOffset = Offset(0, 0.08);
|
||||
const _identityTransformEpsilon = 0.0001;
|
||||
final List<double> _identityTransformStorage = List<double>.unmodifiable(
|
||||
Matrix4.identity().storage,
|
||||
);
|
||||
|
||||
PageRoute<void> buildImageViewerRoute({
|
||||
required String imageUrl,
|
||||
required Object heroTag,
|
||||
String? semanticLabel,
|
||||
}) {
|
||||
return PageRouteBuilder<void>(
|
||||
transitionDuration: _imageViewerPushDuration,
|
||||
reverseTransitionDuration: _imageViewerPopDuration,
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
MediaImageViewerPage(
|
||||
imageUrl: imageUrl,
|
||||
heroTag: heroTag,
|
||||
semanticLabel: semanticLabel,
|
||||
),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
_ImageViewerRouteTransition(animation: animation, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
void openImageViewer(
|
||||
BuildContext context, {
|
||||
required String imageUrl,
|
||||
required Object heroTag,
|
||||
String? semanticLabel,
|
||||
}) {
|
||||
Navigator.of(context).push(
|
||||
buildImageViewerRoute(
|
||||
imageUrl: imageUrl,
|
||||
heroTag: heroTag,
|
||||
semanticLabel: semanticLabel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void openVideoViewer(
|
||||
BuildContext context, {
|
||||
required String videoUrl,
|
||||
String? posterUrl,
|
||||
}) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
MediaVideoViewerPage(videoUrl: videoUrl, posterUrl: posterUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ImageViewerRouteTransition extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final Widget child;
|
||||
|
||||
const _ImageViewerRouteTransition({
|
||||
required this.animation,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fade = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
reverseCurve: Curves.easeIn,
|
||||
);
|
||||
final slide = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: fade,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: _imageViewerTransitionOffset,
|
||||
end: Offset.zero,
|
||||
).animate(slide),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaImageViewerPage extends StatefulWidget {
|
||||
final String imageUrl;
|
||||
final Object heroTag;
|
||||
final String? semanticLabel;
|
||||
|
||||
const MediaImageViewerPage({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
required this.heroTag,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MediaImageViewerPage> createState() => _MediaImageViewerPageState();
|
||||
}
|
||||
|
||||
class _MediaImageViewerPageState extends State<MediaImageViewerPage> {
|
||||
late final TransformationController _transformationController;
|
||||
bool _isTransformed = false;
|
||||
bool _disableHeroOnDismiss = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_transformationController = TransformationController();
|
||||
_transformationController.addListener(_handleTransformChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_transformationController.removeListener(_handleTransformChanged);
|
||||
_transformationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTransformChanged() {
|
||||
final isTransformed = _hasImageTransform(_transformationController.value);
|
||||
if (isTransformed == _isTransformed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isTransformed = isTransformed;
|
||||
});
|
||||
}
|
||||
|
||||
bool get _canDismissWithHero => !_isTransformed || _disableHeroOnDismiss;
|
||||
|
||||
Future<void> _prepareHeroFallbackDismiss() async {
|
||||
if (_canDismissWithHero) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_disableHeroOnDismiss = true;
|
||||
});
|
||||
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
}
|
||||
|
||||
Future<void> _dismiss() async {
|
||||
await _prepareHeroFallbackDismiss();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope<void>(
|
||||
canPop: _canDismissWithHero,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) {
|
||||
return;
|
||||
}
|
||||
unawaited(_dismiss());
|
||||
},
|
||||
child: Scaffold(
|
||||
key: const ValueKey('message-media-image-viewer'),
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
minScale: 1,
|
||||
maxScale: 4,
|
||||
child: Center(
|
||||
child: HeroMode(
|
||||
key: const ValueKey('message-media-image-viewer-hero-mode'),
|
||||
enabled: !_disableHeroOnDismiss,
|
||||
child: Hero(
|
||||
tag: widget.heroTag,
|
||||
child: Image.network(
|
||||
widget.imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
errorBuilder: (_, _, _) => const _MediaLoadFailure(
|
||||
message: 'Failed to load image',
|
||||
icon: LucideIcons.imageOff,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
top: Grid.sm,
|
||||
end: Grid.sm,
|
||||
child: SafeArea(
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color.fromRGBO(0, 0, 0, 0.56),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
key: const ValueKey('message-media-image-viewer-close'),
|
||||
onPressed: _dismiss,
|
||||
tooltip: 'Close image viewer',
|
||||
icon: const Icon(LucideIcons.x, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasImageTransform(Matrix4 transform) {
|
||||
final storage = transform.storage;
|
||||
for (var index = 0; index < storage.length; index++) {
|
||||
if ((storage[index] - _identityTransformStorage[index]).abs() >
|
||||
_identityTransformEpsilon) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class MediaVideoViewerPage extends StatefulWidget {
|
||||
final String videoUrl;
|
||||
final String? posterUrl;
|
||||
|
||||
const MediaVideoViewerPage({
|
||||
super.key,
|
||||
required this.videoUrl,
|
||||
this.posterUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MediaVideoViewerPage> createState() => _MediaVideoViewerPageState();
|
||||
}
|
||||
|
||||
class _MediaVideoViewerPageState extends State<MediaVideoViewerPage> {
|
||||
late final VideoPlayerController _controller;
|
||||
late final Future<void> _initializeFuture;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl));
|
||||
_initializeFuture = _controller
|
||||
.initialize()
|
||||
.then((_) async {
|
||||
await _controller.play();
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
})
|
||||
.catchError((Object error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = error.toString();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_controller.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _MediaViewerScaffold(
|
||||
scaffoldKey: const ValueKey('message-media-video-viewer'),
|
||||
title: 'Video',
|
||||
child: Center(
|
||||
child: FutureBuilder<void>(
|
||||
future: _initializeFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (_error != null || snapshot.hasError) {
|
||||
return const _MediaLoadFailure(
|
||||
message: 'Failed to load video',
|
||||
icon: LucideIcons.videoOff,
|
||||
);
|
||||
}
|
||||
|
||||
if (!_controller.value.isInitialized) {
|
||||
return _VideoLoadingPoster(posterUrl: widget.posterUrl);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: _controller.value.aspectRatio,
|
||||
child: VideoPlayer(_controller),
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
_VideoTransportBar(controller: _controller),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MediaViewerScaffold extends StatelessWidget {
|
||||
final Key scaffoldKey;
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const _MediaViewerScaffold({
|
||||
required this.scaffoldKey,
|
||||
required this.title,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
key: scaffoldKey,
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
scrolledUnderElevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
title: Text(title),
|
||||
),
|
||||
body: SafeArea(child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoLoadingPoster extends StatelessWidget {
|
||||
final String? posterUrl;
|
||||
|
||||
const _VideoLoadingPoster({required this.posterUrl});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (posterUrl != null)
|
||||
Image.network(
|
||||
posterUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _videoPlaceholder(context),
|
||||
)
|
||||
else
|
||||
_videoPlaceholder(context),
|
||||
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.24)),
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _videoPlaceholder(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
LucideIcons.video,
|
||||
size: 40,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoTransportBar extends StatefulWidget {
|
||||
final VideoPlayerController controller;
|
||||
|
||||
const _VideoTransportBar({required this.controller});
|
||||
|
||||
@override
|
||||
State<_VideoTransportBar> createState() => _VideoTransportBarState();
|
||||
}
|
||||
|
||||
class _VideoTransportBarState extends State<_VideoTransportBar> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_handleTick);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _VideoTransportBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller == widget.controller) return;
|
||||
oldWidget.controller.removeListener(_handleTick);
|
||||
widget.controller.addListener(_handleTick);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_handleTick);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTick() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final value = widget.controller.value;
|
||||
final durationMs = value.duration.inMilliseconds;
|
||||
final positionMs = value.position.inMilliseconds.clamp(0, durationMs);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (value.isPlaying) {
|
||||
widget.controller.pause();
|
||||
} else {
|
||||
widget.controller.play();
|
||||
}
|
||||
},
|
||||
tooltip: value.isPlaying ? 'Pause video' : 'Play video',
|
||||
icon: Icon(
|
||||
value.isPlaying ? LucideIcons.pause : LucideIcons.play,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: Slider(
|
||||
value: durationMs == 0 ? 0 : positionMs.toDouble(),
|
||||
min: 0,
|
||||
max: durationMs == 0 ? 1 : durationMs.toDouble(),
|
||||
onChanged: durationMs == 0
|
||||
? null
|
||||
: (next) => widget.controller.seekTo(
|
||||
Duration(milliseconds: next.round()),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MediaLoadFailure extends StatelessWidget {
|
||||
final String message;
|
||||
final IconData icon;
|
||||
|
||||
const _MediaLoadFailure({required this.message, required this.icon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final showMessage = constraints.maxWidth >= 120;
|
||||
final iconSize = constraints.biggest.shortestSide
|
||||
.clamp(0.0, 36.0)
|
||||
.toDouble();
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: Colors.white70, size: iconSize),
|
||||
if (showMessage) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
message,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white70,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import 'media_viewer_page.dart';
|
||||
import 'message_media.dart';
|
||||
|
||||
/// Renders message content with markdown formatting, @mentions, and
|
||||
/// #channel links using [GptMarkdown] plus custom inline components for
|
||||
/// Sprout-specific tokens.
|
||||
const _messageMediaMaxInlineWidth = 320.0;
|
||||
const _messageMediaMaxImageHeight = 240.0;
|
||||
|
||||
/// Renders message content with markdown formatting, @mentions, #channel links,
|
||||
/// and media-aware markdown images/videos.
|
||||
class MessageContent extends StatelessWidget {
|
||||
final String content;
|
||||
|
||||
@@ -19,6 +26,9 @@ class MessageContent extends StatelessWidget {
|
||||
/// names, values are channel IDs.
|
||||
final Map<String, String> channelNames;
|
||||
|
||||
/// Raw event tags, used for `imeta` media metadata lookups.
|
||||
final List<List<String>> tags;
|
||||
|
||||
/// Called when a #channel link is tapped.
|
||||
final void Function(String channelId)? onChannelTap;
|
||||
|
||||
@@ -29,6 +39,7 @@ class MessageContent extends StatelessWidget {
|
||||
required this.content,
|
||||
this.mentionNames = const {},
|
||||
this.channelNames = const {},
|
||||
this.tags = const [],
|
||||
this.onChannelTap,
|
||||
this.baseStyle,
|
||||
});
|
||||
@@ -38,6 +49,7 @@ class MessageContent extends StatelessWidget {
|
||||
final style =
|
||||
baseStyle ??
|
||||
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
|
||||
final imetaByUrl = parseImetaTags(tags);
|
||||
|
||||
return GptMarkdown(
|
||||
content,
|
||||
@@ -45,6 +57,8 @@ class MessageContent extends StatelessWidget {
|
||||
followLinkColor: false,
|
||||
linkBuilder: (context, linkText, url, linkStyle) =>
|
||||
_buildLink(context, linkText, url, linkStyle, style),
|
||||
imageBuilder: (context, imageUrl) =>
|
||||
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
|
||||
inlineComponents: [
|
||||
_MentionMd(mentionNames: mentionNames),
|
||||
_ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap),
|
||||
@@ -53,6 +67,18 @@ class MessageContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) {
|
||||
final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta);
|
||||
if (mediaKind == MessageMediaKind.video) {
|
||||
return _MessageVideoPreview(url: imageUrl, imeta: imeta);
|
||||
}
|
||||
return _MessageImagePreview(
|
||||
url: imageUrl,
|
||||
imeta: imeta,
|
||||
semanticLabel: imeta?.alt ?? 'Message image',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Link builder (URL scheme validation)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -93,6 +119,265 @@ class MessageContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageImagePreview extends StatefulWidget {
|
||||
final String url;
|
||||
final ImetaEntry? imeta;
|
||||
final String semanticLabel;
|
||||
|
||||
const _MessageImagePreview({
|
||||
required this.url,
|
||||
required this.imeta,
|
||||
required this.semanticLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_MessageImagePreview> createState() => _MessageImagePreviewState();
|
||||
}
|
||||
|
||||
class _MessageImagePreviewState extends State<_MessageImagePreview> {
|
||||
late final Object _heroTag = Object();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final layout = _resolveImagePreviewLayout(
|
||||
context,
|
||||
widget.imeta?.aspectRatio,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Grid.half),
|
||||
child: GestureDetector(
|
||||
onTap: () => openImageViewer(
|
||||
context,
|
||||
imageUrl: widget.url,
|
||||
heroTag: _heroTag,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
),
|
||||
child: _MessageMediaPreviewFrame(
|
||||
previewKey: ValueKey('message-media-image-preview:${widget.url}'),
|
||||
backgroundColor: context.colors.surfaceContainerHighest,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
constraints: layout.constraints,
|
||||
child: Hero(
|
||||
tag: _heroTag,
|
||||
child: Image.network(
|
||||
widget.url,
|
||||
fit: layout.fit,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
errorBuilder: (_, _, _) => _MediaPreviewFallback(
|
||||
icon: LucideIcons.imageOff,
|
||||
label: 'Image unavailable',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageVideoPreview extends StatelessWidget {
|
||||
final String url;
|
||||
final ImetaEntry? imeta;
|
||||
|
||||
const _MessageVideoPreview({required this.url, required this.imeta});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rawAspectRatio = imeta?.aspectRatio ?? (16 / 9);
|
||||
final aspectRatio = rawAspectRatio.clamp(0.75, 1.91);
|
||||
final posterUrl = imeta?.posterUrl;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Grid.half),
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
openVideoViewer(context, videoUrl: url, posterUrl: posterUrl),
|
||||
child: _MessageMediaPreviewFrame(
|
||||
previewKey: ValueKey('message-media-video-preview:$url'),
|
||||
backgroundColor: Colors.black,
|
||||
child: AspectRatio(
|
||||
aspectRatio: aspectRatio.toDouble(),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (posterUrl != null)
|
||||
Image.network(
|
||||
posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const _MediaPreviewFallback(
|
||||
icon: LucideIcons.video,
|
||||
label: 'Video preview unavailable',
|
||||
),
|
||||
)
|
||||
else
|
||||
const _MediaPreviewFallback(
|
||||
icon: LucideIcons.video,
|
||||
label: 'Video attachment',
|
||||
),
|
||||
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.28)),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color.fromRGBO(0, 0, 0, 0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.play,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: Grid.xxs,
|
||||
right: Grid.xxs,
|
||||
bottom: Grid.xxs,
|
||||
child: Text(
|
||||
'Video',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageMediaPreviewFrame extends StatelessWidget {
|
||||
final Key previewKey;
|
||||
final Color backgroundColor;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxConstraints? constraints;
|
||||
final Widget child;
|
||||
|
||||
const _MessageMediaPreviewFrame({
|
||||
required this.previewKey,
|
||||
required this.backgroundColor,
|
||||
this.width,
|
||||
this.height,
|
||||
this.constraints,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final resolvedWidth = constraints == null
|
||||
? (width ?? _messageMediaMaxWidth(context))
|
||||
: width;
|
||||
|
||||
return Container(
|
||||
key: previewKey,
|
||||
width: resolvedWidth,
|
||||
height: height,
|
||||
constraints: constraints,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double _messageMediaMaxWidth(BuildContext context) {
|
||||
return math
|
||||
.min(MediaQuery.sizeOf(context).width * 0.72, _messageMediaMaxInlineWidth)
|
||||
.toDouble();
|
||||
}
|
||||
|
||||
_ImagePreviewLayout _resolveImagePreviewLayout(
|
||||
BuildContext context,
|
||||
double? aspectRatio,
|
||||
) {
|
||||
if (aspectRatio == null) {
|
||||
return _ImagePreviewLayout(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: _messageMediaMaxWidth(context),
|
||||
maxHeight: _messageMediaMaxImageHeight,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
|
||||
final previewSize = _imagePreviewSize(context, aspectRatio);
|
||||
return _ImagePreviewLayout(
|
||||
width: previewSize.width,
|
||||
height: previewSize.height,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
Size _imagePreviewSize(BuildContext context, double? aspectRatio) {
|
||||
final maxWidth = _messageMediaMaxWidth(context);
|
||||
final safeAspectRatio = (aspectRatio ?? 1.0).clamp(0.2, 4.0).toDouble();
|
||||
|
||||
var width = maxWidth;
|
||||
var height = width / safeAspectRatio;
|
||||
if (height > _messageMediaMaxImageHeight) {
|
||||
height = _messageMediaMaxImageHeight;
|
||||
width = height * safeAspectRatio;
|
||||
}
|
||||
|
||||
return Size(width, height);
|
||||
}
|
||||
|
||||
class _ImagePreviewLayout {
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxConstraints? constraints;
|
||||
final BoxFit fit;
|
||||
|
||||
const _ImagePreviewLayout({
|
||||
this.width,
|
||||
this.height,
|
||||
this.constraints,
|
||||
required this.fit,
|
||||
});
|
||||
}
|
||||
|
||||
class _MediaPreviewFallback extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
const _MediaPreviewFallback({required this.icon, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: context.colors.onSurfaceVariant),
|
||||
const SizedBox(height: Grid.quarter),
|
||||
Text(
|
||||
label,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MentionMd extends InlineMd {
|
||||
final Map<String, String> mentionNames;
|
||||
late final RegExp _exp = _buildPrefixPattern(
|
||||
|
||||
114
mobile/lib/features/channels/message_media.dart
Normal file
114
mobile/lib/features/channels/message_media.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum MessageMediaKind { image, video }
|
||||
|
||||
@immutable
|
||||
class ImetaEntry {
|
||||
final String url;
|
||||
final String? mimeType;
|
||||
final String? dimensions;
|
||||
final String? thumb;
|
||||
final String? image;
|
||||
final String? alt;
|
||||
|
||||
const ImetaEntry({
|
||||
required this.url,
|
||||
this.mimeType,
|
||||
this.dimensions,
|
||||
this.thumb,
|
||||
this.image,
|
||||
this.alt,
|
||||
});
|
||||
|
||||
bool get isVideo => mimeType?.startsWith('video/') == true;
|
||||
|
||||
String? get posterUrl => image ?? thumb;
|
||||
|
||||
double? get aspectRatio {
|
||||
final parts = dimensions?.split('x');
|
||||
if (parts == null || parts.length != 2) return null;
|
||||
final width = double.tryParse(parts[0]);
|
||||
final height = double.tryParse(parts[1]);
|
||||
if (width == null || height == null || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return width / height;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, ImetaEntry> parseImetaTags(List<List<String>> tags) {
|
||||
final byUrl = <String, ImetaEntry>{};
|
||||
for (final tag in tags) {
|
||||
if (tag.isEmpty || tag.first != 'imeta') continue;
|
||||
|
||||
String? url;
|
||||
String? mimeType;
|
||||
String? dimensions;
|
||||
String? thumb;
|
||||
String? image;
|
||||
String? alt;
|
||||
|
||||
for (final part in tag.skip(1)) {
|
||||
final separator = part.indexOf(' ');
|
||||
if (separator <= 0) continue;
|
||||
final key = part.substring(0, separator);
|
||||
final value = part.substring(separator + 1);
|
||||
switch (key) {
|
||||
case 'url':
|
||||
url = value;
|
||||
case 'm':
|
||||
mimeType = value;
|
||||
case 'dim':
|
||||
dimensions = value;
|
||||
case 'thumb':
|
||||
thumb = value;
|
||||
case 'image':
|
||||
image = value;
|
||||
case 'alt':
|
||||
alt = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (url == null || url.isEmpty) continue;
|
||||
byUrl[url] = ImetaEntry(
|
||||
url: url,
|
||||
mimeType: mimeType,
|
||||
dimensions: dimensions,
|
||||
thumb: thumb,
|
||||
image: image,
|
||||
alt: alt,
|
||||
);
|
||||
}
|
||||
return byUrl;
|
||||
}
|
||||
|
||||
MessageMediaKind? classifyMediaUrl(String url, {ImetaEntry? imeta}) {
|
||||
final mimeType = imeta?.mimeType;
|
||||
if (mimeType != null) {
|
||||
if (mimeType == 'video/mp4') return MessageMediaKind.video;
|
||||
if (mimeType.startsWith('image/')) return MessageMediaKind.image;
|
||||
if (mimeType.startsWith('video/')) return null;
|
||||
}
|
||||
|
||||
final path = (Uri.tryParse(url)?.path ?? url).toLowerCase();
|
||||
if (path.endsWith(_mp4Extension)) {
|
||||
return MessageMediaKind.video;
|
||||
}
|
||||
if (_imageExtensions.any(path.endsWith)) {
|
||||
return MessageMediaKind.image;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const _imageExtensions = {
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.avif',
|
||||
};
|
||||
|
||||
const _mp4Extension = '.mp4';
|
||||
@@ -25,13 +25,15 @@ class SendMessage {
|
||||
/// For thread replies, pass [parentEventId] and optionally [rootEventId].
|
||||
/// If [rootEventId] is null it defaults to [parentEventId] (direct reply to
|
||||
/// thread head). Tags are built to match the desktop's `buildReplyTags`
|
||||
/// convention with `root` / `reply` markers.
|
||||
/// convention with `root` / `reply` markers. Pass [mediaTags] to append
|
||||
/// relay-validated `imeta` tags for uploaded media.
|
||||
Future<void> call({
|
||||
required String channelId,
|
||||
required String content,
|
||||
String? parentEventId,
|
||||
String? rootEventId,
|
||||
List<String>? mentionPubkeys,
|
||||
List<List<String>> mediaTags = const [],
|
||||
}) async {
|
||||
// Use explicitly passed pubkeys, or resolve @mentions against
|
||||
// channel members to avoid matching the wrong user.
|
||||
@@ -52,6 +54,7 @@ class SendMessage {
|
||||
['h', channelId],
|
||||
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
|
||||
for (final pk in normalizedMentions) ['p', pk],
|
||||
...mediaTags,
|
||||
];
|
||||
|
||||
await _signedEventRelay.submit(
|
||||
|
||||
@@ -183,15 +183,21 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
hintText: 'Reply in thread\u2026',
|
||||
threadHeadId: threadHead.id,
|
||||
rootId: effectiveRootId,
|
||||
onSend: (content, mentionPubkeys) => ref
|
||||
.read(sendMessageProvider)
|
||||
.call(
|
||||
channelId: channelId,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
parentEventId: threadHead.id,
|
||||
rootEventId: effectiveRootId,
|
||||
),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) => ref
|
||||
.read(sendMessageProvider)
|
||||
.call(
|
||||
channelId: channelId,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
parentEventId: threadHead.id,
|
||||
rootEventId: effectiveRootId,
|
||||
mediaTags: mediaTags,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -457,6 +463,7 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
content: message.content,
|
||||
mentionNames: mentionNames,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
onChannelTap: (_) {},
|
||||
),
|
||||
if (message.reactions.isNotEmpty)
|
||||
|
||||
@@ -133,6 +133,7 @@ class TimelineMessage {
|
||||
final String pubkey;
|
||||
final int createdAt;
|
||||
final String content;
|
||||
final List<List<String>> tags;
|
||||
final bool isSystem;
|
||||
final bool edited;
|
||||
final SystemEvent? systemEvent;
|
||||
@@ -154,6 +155,7 @@ class TimelineMessage {
|
||||
required this.pubkey,
|
||||
required this.createdAt,
|
||||
required this.content,
|
||||
this.tags = const [],
|
||||
this.isSystem = false,
|
||||
this.edited = false,
|
||||
this.systemEvent,
|
||||
@@ -269,6 +271,7 @@ List<TimelineMessage> formatTimeline(
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.createdAt,
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
isSystem: true,
|
||||
systemEvent: systemEvent,
|
||||
),
|
||||
@@ -311,6 +314,7 @@ List<TimelineMessage> formatTimeline(
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.createdAt,
|
||||
content: edit?.content ?? event.content,
|
||||
tags: event.tags,
|
||||
edited: edit != null,
|
||||
mentionPubkeys: mentions,
|
||||
reactions: reactions,
|
||||
|
||||
@@ -114,6 +114,7 @@ class ForumPostCard extends ConsumerWidget {
|
||||
child: MessageContent(
|
||||
content: preview,
|
||||
mentionNames: mentionNames,
|
||||
tags: post.tags,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -121,15 +121,21 @@ class ForumPostsView extends HookConsumerWidget {
|
||||
ComposeBar(
|
||||
channelId: channel.id,
|
||||
hintText: 'Write your post\u2026',
|
||||
onSend: (content, mentionPubkeys) async {
|
||||
await createForumPost(
|
||||
ref,
|
||||
channelId: channel.id,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
);
|
||||
if (context.mounted) isComposing.value = false;
|
||||
},
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {
|
||||
await createForumPost(
|
||||
ref,
|
||||
channelId: channel.id,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
mediaTags: mediaTags,
|
||||
);
|
||||
if (context.mounted) isComposing.value = false;
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -44,6 +44,7 @@ Future<void> createForumPost(
|
||||
required String channelId,
|
||||
required String content,
|
||||
List<String> mentionPubkeys = const [],
|
||||
List<List<String>> mediaTags = const [],
|
||||
}) async {
|
||||
final config = ref.read(relayConfigProvider);
|
||||
final client = ref.read(relayClientProvider);
|
||||
@@ -62,6 +63,7 @@ Future<void> createForumPost(
|
||||
tags: [
|
||||
['h', channelId],
|
||||
for (final pk in normalizedMentions) ['p', pk],
|
||||
...mediaTags,
|
||||
],
|
||||
);
|
||||
ref.invalidate(forumPostsProvider(channelId));
|
||||
@@ -74,6 +76,7 @@ Future<void> createForumReply(
|
||||
required String parentEventId,
|
||||
required String content,
|
||||
List<String> mentionPubkeys = const [],
|
||||
List<List<String>> mediaTags = const [],
|
||||
}) async {
|
||||
final config = ref.read(relayConfigProvider);
|
||||
final client = ref.read(relayClientProvider);
|
||||
@@ -93,6 +96,7 @@ Future<void> createForumReply(
|
||||
['h', channelId],
|
||||
['e', parentEventId, '', 'reply'],
|
||||
for (final pk in normalizedMentions) ['p', pk],
|
||||
...mediaTags,
|
||||
],
|
||||
);
|
||||
ref.invalidate(forumPostsProvider(channelId));
|
||||
|
||||
@@ -267,13 +267,19 @@ class _ThreadContent extends HookConsumerWidget {
|
||||
ComposeBar(
|
||||
channelId: channelId,
|
||||
hintText: 'Reply to this post\u2026',
|
||||
onSend: (content, mentionPubkeys) => createForumReply(
|
||||
ref,
|
||||
channelId: channelId,
|
||||
parentEventId: post.eventId,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
),
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) => createForumReply(
|
||||
ref,
|
||||
channelId: channelId,
|
||||
parentEventId: post.eventId,
|
||||
content: content,
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
mediaTags: mediaTags,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -327,7 +333,11 @@ class _OriginalPost extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
MessageContent(content: post.content, mentionNames: mentionNames),
|
||||
MessageContent(
|
||||
content: post.content,
|
||||
mentionNames: mentionNames,
|
||||
tags: post.tags,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -410,6 +420,7 @@ class _ReplyRow extends ConsumerWidget {
|
||||
child: MessageContent(
|
||||
content: reply.content,
|
||||
mentionNames: mentionNames,
|
||||
tags: reply.tags,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
553
mobile/lib/shared/relay/media_upload.dart
Normal file
553
mobile/lib/shared/relay/media_upload.dart
Normal file
@@ -0,0 +1,553 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
|
||||
import 'relay_provider.dart';
|
||||
|
||||
const _mediaUploadPath = '/media/upload';
|
||||
const _mediaUploadPlatformChannelName = 'sprout/media_upload';
|
||||
const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload';
|
||||
const _transcodeImageToJpegMethod = 'transcodeImageToJpeg';
|
||||
const _uploadAuthKind = 24242;
|
||||
const _uploadAuthLifetimeSeconds = 300;
|
||||
const _heicBrands = {
|
||||
'heic',
|
||||
'heix',
|
||||
'hevc',
|
||||
'hevx',
|
||||
'heim',
|
||||
'heis',
|
||||
'mif1',
|
||||
'msf1',
|
||||
};
|
||||
final _mediaUploadPlatformChannel = MethodChannel(
|
||||
_mediaUploadPlatformChannelName,
|
||||
);
|
||||
|
||||
const _allowedImageMimeTypes = {'image/jpeg', 'image/png', 'image/webp'};
|
||||
const _unsupportedAnimatedImageMimeTypes = {'image/gif'};
|
||||
const _unsupportedGifUploadMessage =
|
||||
'GIF uploads are not supported on mobile yet';
|
||||
const _unsupportedAnimatedPngUploadMessage =
|
||||
'Animated PNG uploads are not supported on mobile yet';
|
||||
const _unsupportedAnimatedWebpUploadMessage =
|
||||
'Animated WebP uploads are not supported on mobile yet';
|
||||
|
||||
typedef PickGalleryImage = Future<XFile?> Function();
|
||||
typedef SanitizeImageBytes =
|
||||
Future<Uint8List> Function(Uint8List bytes, String mimeType);
|
||||
typedef TranscodeImageToJpeg = Future<Uint8List> Function(Uint8List bytes);
|
||||
|
||||
@immutable
|
||||
class _PreparedUploadImage {
|
||||
final Uint8List bytes;
|
||||
final String mimeType;
|
||||
|
||||
const _PreparedUploadImage({required this.bytes, required this.mimeType});
|
||||
}
|
||||
|
||||
@immutable
|
||||
class BlobDescriptor {
|
||||
final String url;
|
||||
final String sha256;
|
||||
final int size;
|
||||
final String type;
|
||||
final int uploaded;
|
||||
final String? dim;
|
||||
final String? blurhash;
|
||||
final String? thumb;
|
||||
final double? duration;
|
||||
final String? image;
|
||||
|
||||
const BlobDescriptor({
|
||||
required this.url,
|
||||
required this.sha256,
|
||||
required this.size,
|
||||
required this.type,
|
||||
required this.uploaded,
|
||||
this.dim,
|
||||
this.blurhash,
|
||||
this.thumb,
|
||||
this.duration,
|
||||
this.image,
|
||||
});
|
||||
|
||||
factory BlobDescriptor.fromJson(Map<String, dynamic> json) => BlobDescriptor(
|
||||
url: json['url'] as String,
|
||||
sha256: json['sha256'] as String,
|
||||
size: (json['size'] as num).toInt(),
|
||||
type: json['type'] as String,
|
||||
uploaded: (json['uploaded'] as num).toInt(),
|
||||
dim: json['dim'] as String?,
|
||||
blurhash: json['blurhash'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
duration: (json['duration'] as num?)?.toDouble(),
|
||||
image: json['image'] as String?,
|
||||
);
|
||||
|
||||
List<String> toImetaTag() => [
|
||||
'imeta',
|
||||
'url $url',
|
||||
'm $type',
|
||||
'x $sha256',
|
||||
'size $size',
|
||||
if (dim != null) 'dim $dim',
|
||||
if (blurhash != null) 'blurhash $blurhash',
|
||||
if (thumb != null) 'thumb $thumb',
|
||||
if (duration != null) 'duration $duration',
|
||||
if (image != null) 'image $image',
|
||||
];
|
||||
|
||||
String toMarkdownImage() =>
|
||||
type.startsWith('video/') ? '' : '';
|
||||
}
|
||||
|
||||
class MediaUploadService {
|
||||
final String _baseUrl;
|
||||
final String? _apiToken;
|
||||
final String? _nsec;
|
||||
final PickGalleryImage _pickGalleryImage;
|
||||
final SanitizeImageBytes _sanitizeImageBytes;
|
||||
final TranscodeImageToJpeg _transcodeImageToJpeg;
|
||||
final DateTime Function() _now;
|
||||
final http.Client _http;
|
||||
final bool _ownsHttpClient;
|
||||
|
||||
MediaUploadService({
|
||||
required String baseUrl,
|
||||
required String? apiToken,
|
||||
required String? nsec,
|
||||
required PickGalleryImage pickGalleryImage,
|
||||
SanitizeImageBytes? sanitizeImageBytes,
|
||||
TranscodeImageToJpeg? transcodeImageToJpeg,
|
||||
DateTime Function()? now,
|
||||
http.Client? httpClient,
|
||||
}) : _baseUrl = baseUrl,
|
||||
_apiToken = apiToken,
|
||||
_nsec = nsec,
|
||||
_pickGalleryImage = pickGalleryImage,
|
||||
_sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes,
|
||||
_transcodeImageToJpeg =
|
||||
transcodeImageToJpeg ?? _transcodePickedImageToJpeg,
|
||||
_now = now ?? DateTime.now,
|
||||
_http = httpClient ?? http.Client(),
|
||||
_ownsHttpClient = httpClient == null;
|
||||
|
||||
void dispose() {
|
||||
if (_ownsHttpClient) {
|
||||
_http.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<BlobDescriptor?> pickAndUploadImage() async {
|
||||
final pickedImage = await _pickGalleryImage();
|
||||
if (pickedImage == null) return null;
|
||||
final preparedImage = await _prepareUploadImage(pickedImage);
|
||||
return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType);
|
||||
}
|
||||
|
||||
Future<BlobDescriptor> uploadBytes(
|
||||
Uint8List bytes, {
|
||||
required String mimeType,
|
||||
}) async {
|
||||
_validateUpload(bytes, mimeType);
|
||||
if (!_allowedImageMimeTypes.contains(mimeType)) {
|
||||
throw Exception('unsupported file type: $mimeType');
|
||||
}
|
||||
|
||||
final sha256 = _sha256Hex(bytes);
|
||||
final request = _buildUploadRequest(
|
||||
bytes: bytes,
|
||||
mimeType: mimeType,
|
||||
sha256: sha256,
|
||||
);
|
||||
|
||||
final streamed = await _http.send(request);
|
||||
final response = await http.Response.fromStream(streamed);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception(
|
||||
'upload failed (${response.statusCode}): ${response.body}',
|
||||
);
|
||||
}
|
||||
|
||||
return BlobDescriptor.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
http.Request _buildUploadRequest({
|
||||
required Uint8List bytes,
|
||||
required String mimeType,
|
||||
required String sha256,
|
||||
}) {
|
||||
final request = http.Request(
|
||||
'PUT',
|
||||
Uri.parse(_baseUrl).resolve(_mediaUploadPath),
|
||||
);
|
||||
request.bodyBytes = bytes;
|
||||
request.headers.addAll(
|
||||
_buildUploadHeaders(mimeType: mimeType, sha256: sha256),
|
||||
);
|
||||
return request;
|
||||
}
|
||||
|
||||
Map<String, String> _buildUploadHeaders({
|
||||
required String mimeType,
|
||||
required String sha256,
|
||||
}) {
|
||||
final headers = <String, String>{
|
||||
'Authorization': _buildUploadAuthHeader(sha256),
|
||||
'Content-Type': mimeType,
|
||||
'X-SHA-256': sha256,
|
||||
};
|
||||
if (_apiToken case final token? when token.isNotEmpty) {
|
||||
headers['X-Auth-Token'] = token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
String _buildUploadAuthHeader(String sha256) {
|
||||
final authEvent = _buildUploadAuthEvent(sha256);
|
||||
final authJson = jsonEncode(authEvent.toJson());
|
||||
final encoded = base64Url.encode(utf8.encode(authJson)).replaceAll('=', '');
|
||||
return 'Nostr $encoded';
|
||||
}
|
||||
|
||||
nostr.Event _buildUploadAuthEvent(String sha256) {
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
throw Exception('Cannot upload media: no signing key available');
|
||||
}
|
||||
|
||||
final privkeyHex = nostr.Nip19.decodePrivkey(nsec);
|
||||
if (privkeyHex.isEmpty) {
|
||||
throw Exception('Invalid nsec');
|
||||
}
|
||||
|
||||
final expiration =
|
||||
(_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds;
|
||||
final tags = <List<String>>[
|
||||
['t', 'upload'],
|
||||
['x', sha256],
|
||||
['expiration', '$expiration'],
|
||||
if (_extractServerAuthority(_baseUrl) case final authority?)
|
||||
['server', authority],
|
||||
];
|
||||
|
||||
return nostr.Event.from(
|
||||
kind: _uploadAuthKind,
|
||||
content: 'Upload sprout-media',
|
||||
tags: tags,
|
||||
privkey: privkeyHex,
|
||||
verify: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_PreparedUploadImage> _prepareUploadImage(XFile pickedImage) async {
|
||||
final bytes = await pickedImage.readAsBytes();
|
||||
final detectedMimeType = _tryDetectImageMimeType(bytes);
|
||||
if (detectedMimeType case final mimeType?) {
|
||||
return _prepareDetectedUploadImage(bytes, mimeType);
|
||||
}
|
||||
|
||||
if (_shouldTranscodePickedImage(pickedImage, bytes)) {
|
||||
return _prepareTranscodedUploadImage(bytes);
|
||||
}
|
||||
|
||||
throw Exception('unsupported file type');
|
||||
}
|
||||
|
||||
Future<_PreparedUploadImage> _prepareDetectedUploadImage(
|
||||
Uint8List bytes,
|
||||
String mimeType,
|
||||
) async {
|
||||
_validateUpload(bytes, mimeType);
|
||||
final preparedBytes = await _sanitizeImageBytesIfNeeded(bytes, mimeType);
|
||||
return _buildPreparedUploadImage(preparedBytes);
|
||||
}
|
||||
|
||||
Future<_PreparedUploadImage> _prepareTranscodedUploadImage(
|
||||
Uint8List bytes,
|
||||
) async {
|
||||
final transcodedBytes = await _transcodeImageToJpeg(bytes);
|
||||
return _buildPreparedUploadImage(transcodedBytes);
|
||||
}
|
||||
|
||||
_PreparedUploadImage _buildPreparedUploadImage(Uint8List bytes) {
|
||||
return _PreparedUploadImage(
|
||||
bytes: bytes,
|
||||
mimeType: _detectImageMimeType(bytes),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _sanitizeImageBytesIfNeeded(
|
||||
Uint8List bytes,
|
||||
String mimeType,
|
||||
) async {
|
||||
if (!_shouldSanitizePickedImage(mimeType)) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
final sanitizedBytes = await _sanitizeImageBytes(bytes, mimeType);
|
||||
if (sanitizedBytes.isEmpty) {
|
||||
throw Exception('failed to sanitize image for upload');
|
||||
}
|
||||
return sanitizedBytes;
|
||||
}
|
||||
}
|
||||
|
||||
String _sha256Hex(Uint8List bytes) {
|
||||
final digest = SHA256Digest().process(bytes);
|
||||
return digest.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
String? _tryDetectImageMimeType(Uint8List bytes) {
|
||||
try {
|
||||
return _detectImageMimeType(bytes);
|
||||
} on Exception {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _validateUpload(Uint8List bytes, String mimeType) {
|
||||
if (_unsupportedAnimatedImageMimeTypes.contains(mimeType)) {
|
||||
throw Exception(_unsupportedGifUploadMessage);
|
||||
}
|
||||
if (mimeType == 'image/png' && _isAnimatedPng(bytes)) {
|
||||
throw Exception(_unsupportedAnimatedPngUploadMessage);
|
||||
}
|
||||
if (mimeType == 'image/webp' && _isAnimatedWebp(bytes)) {
|
||||
throw Exception(_unsupportedAnimatedWebpUploadMessage);
|
||||
}
|
||||
}
|
||||
|
||||
String _detectImageMimeType(Uint8List bytes) {
|
||||
if (_startsWith(bytes, const [0xff, 0xd8, 0xff])) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (_startsWith(bytes, const [
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
])) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (_startsWith(bytes, ascii.encode('GIF87a')) ||
|
||||
_startsWith(bytes, ascii.encode('GIF89a'))) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (_startsWith(bytes, ascii.encode('RIFF')) &&
|
||||
bytes.length >= 12 &&
|
||||
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) == 'WEBP') {
|
||||
return 'image/webp';
|
||||
}
|
||||
throw Exception('unsupported file type');
|
||||
}
|
||||
|
||||
bool _shouldTranscodePickedImage(XFile pickedImage, Uint8List bytes) {
|
||||
return _supportsNativeUploadImageProcessing() &&
|
||||
(_hasHeicFileExtension(pickedImage) || _looksLikeHeicOrHeif(bytes));
|
||||
}
|
||||
|
||||
bool _isAnimatedPng(Uint8List bytes) {
|
||||
if (!_startsWith(bytes, const [
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var offset = 8;
|
||||
while (offset + 12 <= bytes.length) {
|
||||
final chunkSize = _readUint32BigEndian(bytes, offset);
|
||||
if (offset + 12 + chunkSize > bytes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_matchesAscii(bytes, offset + 4, 'acTL')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
offset += 12 + chunkSize;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isAnimatedWebp(Uint8List bytes) {
|
||||
if (!_startsWith(bytes, ascii.encode('RIFF')) ||
|
||||
bytes.length < 12 ||
|
||||
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) != 'WEBP') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var offset = 12;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
final chunkSize = _readUint32LittleEndian(bytes, offset + 4);
|
||||
final payloadOffset = offset + 8;
|
||||
if (payloadOffset + chunkSize > bytes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_matchesAscii(bytes, offset, 'ANIM') ||
|
||||
_matchesAscii(bytes, offset, 'ANMF')) {
|
||||
return true;
|
||||
}
|
||||
if (_matchesAscii(bytes, offset, 'VP8X') &&
|
||||
chunkSize >= 1 &&
|
||||
(bytes[payloadOffset] & 0x02) != 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
offset = payloadOffset + chunkSize + (chunkSize.isOdd ? 1 : 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _shouldSanitizePickedImage(String mimeType) {
|
||||
return _supportsNativeUploadImageProcessing() &&
|
||||
(mimeType == 'image/jpeg' || mimeType == 'image/png');
|
||||
}
|
||||
|
||||
bool _supportsNativeUploadImageProcessing() {
|
||||
return switch (defaultTargetPlatform) {
|
||||
TargetPlatform.android || TargetPlatform.iOS => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
bool _hasHeicFileExtension(XFile pickedImage) {
|
||||
for (final candidate in [pickedImage.name, pickedImage.path]) {
|
||||
final normalizedCandidate = candidate.toLowerCase();
|
||||
if (normalizedCandidate.endsWith('.heic') ||
|
||||
normalizedCandidate.endsWith('.heif')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _looksLikeHeicOrHeif(Uint8List bytes) {
|
||||
if (bytes.length < 12 || !_matchesAscii(bytes, 4, 'ftyp')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final upperBound = bytes.length < 32 ? bytes.length : 32;
|
||||
for (var offset = 8; offset + 4 <= upperBound; offset += 4) {
|
||||
final brand = ascii.decode(
|
||||
bytes.sublist(offset, offset + 4),
|
||||
allowInvalid: true,
|
||||
);
|
||||
if (_heicBrands.contains(brand.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _startsWith(Uint8List bytes, List<int> prefix) {
|
||||
if (bytes.length < prefix.length) return false;
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
if (bytes[i] != prefix[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _matchesAscii(Uint8List bytes, int offset, String value) {
|
||||
final codeUnits = ascii.encode(value);
|
||||
if (bytes.length < offset + codeUnits.length) return false;
|
||||
for (var i = 0; i < codeUnits.length; i++) {
|
||||
if (bytes[offset + i] != codeUnits[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int _readUint32BigEndian(Uint8List bytes, int offset) {
|
||||
return (bytes[offset] << 24) |
|
||||
(bytes[offset + 1] << 16) |
|
||||
(bytes[offset + 2] << 8) |
|
||||
bytes[offset + 3];
|
||||
}
|
||||
|
||||
int _readUint32LittleEndian(Uint8List bytes, int offset) {
|
||||
return bytes[offset] |
|
||||
(bytes[offset + 1] << 8) |
|
||||
(bytes[offset + 2] << 16) |
|
||||
(bytes[offset + 3] << 24);
|
||||
}
|
||||
|
||||
String? _extractServerAuthority(String baseUrl) {
|
||||
final uri = Uri.parse(baseUrl);
|
||||
if (uri.host.isEmpty) return null;
|
||||
final host = uri.host.contains(':') ? '[${uri.host}]' : uri.host;
|
||||
return uri.hasPort ? '$host:${uri.port}' : host;
|
||||
}
|
||||
|
||||
Future<Uint8List> _transcodePickedImageToJpeg(Uint8List bytes) async {
|
||||
return _invokeRequiredPlatformBytesMethod(
|
||||
_transcodeImageToJpegMethod,
|
||||
arguments: bytes,
|
||||
errorMessage: 'failed to convert image for upload',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _sanitizePickedImageBytes(
|
||||
Uint8List bytes,
|
||||
String mimeType,
|
||||
) async {
|
||||
return _invokeRequiredPlatformBytesMethod(
|
||||
_sanitizeImageForUploadMethod,
|
||||
arguments: {'bytes': bytes, 'mimeType': mimeType},
|
||||
errorMessage: 'failed to sanitize image for upload',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _invokeRequiredPlatformBytesMethod(
|
||||
String method, {
|
||||
Object? arguments,
|
||||
required String errorMessage,
|
||||
}) async {
|
||||
final result = await _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
|
||||
method,
|
||||
arguments,
|
||||
);
|
||||
if (result == null || result.isEmpty) {
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
final mediaUploadServiceProvider = Provider<MediaUploadService>((ref) {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final picker = ImagePicker();
|
||||
final service = MediaUploadService(
|
||||
baseUrl: config.baseUrl,
|
||||
apiToken: config.apiToken,
|
||||
nsec: config.nsec,
|
||||
pickGalleryImage: () => picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
requestFullMetadata: false,
|
||||
),
|
||||
);
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export 'app_lifecycle_provider.dart';
|
||||
export 'media_upload.dart';
|
||||
export 'nostr_models.dart';
|
||||
export 'relay_client.dart';
|
||||
export 'relay_provider.dart';
|
||||
|
||||
@@ -185,6 +185,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -193,6 +201,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
custom_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -257,6 +273,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -294,6 +342,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.34"
|
||||
flutter_riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -416,6 +472,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -440,6 +504,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+16"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
image_picker_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+6"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
image_picker_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
image_picker_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1101,6 +1229,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player
|
||||
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
video_player_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_android
|
||||
sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.5"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_avfoundation
|
||||
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_platform_interface
|
||||
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.0"
|
||||
video_player_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_web
|
||||
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -22,6 +22,8 @@ dependencies:
|
||||
pointycastle: ^3.7.3
|
||||
url_launcher: ^6.3.2
|
||||
gpt_markdown: ^1.1.6
|
||||
image_picker: ^1.1.2
|
||||
video_player: ^2.10.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -40,6 +40,7 @@ NostrEvent _textMsg({
|
||||
required String pubkey,
|
||||
required String content,
|
||||
int createdAt = 1000,
|
||||
List<List<String>> extraTags = const [],
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: pubkey,
|
||||
@@ -47,6 +48,7 @@ NostrEvent _textMsg({
|
||||
kind: EventKind.streamMessage,
|
||||
tags: [
|
||||
['h', _channelId],
|
||||
...extraTags,
|
||||
],
|
||||
content: content,
|
||||
sig: '',
|
||||
@@ -205,6 +207,45 @@ void main() {
|
||||
expect(find.text('Message…'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders video attachments from imeta tags in the timeline', (
|
||||
tester,
|
||||
) async {
|
||||
const videoUrl = 'https://example.com/media/clip.mp4';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_textMsg(
|
||||
id: 'video-1',
|
||||
pubkey: 'alice',
|
||||
content: '',
|
||||
extraTags: const [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/clip.mp4',
|
||||
'm video/mp4',
|
||||
'image https://example.com/media/poster.jpg',
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-video-preview:https://example.com/media/clip.mp4',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('members sheet shows roles and manage controls for owners', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
387
mobile/test/features/channels/compose_bar_test.dart
Normal file
387
mobile/test/features/channels/compose_bar_test.dart
Normal file
@@ -0,0 +1,387 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:sprout_mobile/features/channels/channel_management_provider.dart';
|
||||
import 'package:sprout_mobile/features/channels/compose_bar.dart';
|
||||
import 'package:sprout_mobile/shared/relay/relay.dart';
|
||||
import 'package:sprout_mobile/shared/theme/theme.dart';
|
||||
|
||||
final _pngBytes = Uint8List.fromList([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
]);
|
||||
|
||||
final _gifBytes = Uint8List.fromList([
|
||||
0x47,
|
||||
0x49,
|
||||
0x46,
|
||||
0x38,
|
||||
0x39,
|
||||
0x61,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
final _apngBytes = Uint8List.fromList([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08,
|
||||
0x61,
|
||||
0x63,
|
||||
0x54,
|
||||
0x4c,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4e,
|
||||
0x44,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
const _mediaUploadPlatformChannel = MethodChannel('sprout/media_upload');
|
||||
|
||||
void _setMockMediaUploadPlatformHandler(
|
||||
Future<Object?> Function(MethodCall call)? handler,
|
||||
) {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_mediaUploadPlatformChannel, handler);
|
||||
}
|
||||
|
||||
Widget _buildComposeBar({
|
||||
required MediaUploadService uploadService,
|
||||
required ComposeBarOnSend onSend,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
mediaUploadServiceProvider.overrideWithValue(uploadService),
|
||||
currentPubkeyProvider.overrideWith((ref) => null),
|
||||
channelMembersProvider(
|
||||
'channel-1',
|
||||
).overrideWith((ref) async => const <ChannelMember>[]),
|
||||
relayClientProvider.overrideWithValue(
|
||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.lightTheme,
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: ComposeBar(channelId: 'channel-1', onSend: onSend),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
_setMockMediaUploadPlatformHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'sanitizeImageForUpload':
|
||||
final arguments = call.arguments as Map<Object?, Object?>;
|
||||
return arguments['bytes'] as Uint8List;
|
||||
case 'transcodeImageToJpeg':
|
||||
return _pngBytes;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
_setMockMediaUploadPlatformHandler(null);
|
||||
});
|
||||
|
||||
group('ComposeBar', () {
|
||||
testWidgets('uploads an image and sends markdown plus imeta tags', (
|
||||
tester,
|
||||
) async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: 'sprout_test_token',
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'https://relay.example/media/test.png',
|
||||
'sha256':
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'size': 16,
|
||||
'type': 'image/png',
|
||||
'uploaded': 1,
|
||||
'thumb': 'https://relay.example/media/test.thumb.jpg',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
||||
);
|
||||
|
||||
String? sentContent;
|
||||
List<List<String>> sentMediaTags = const [];
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: uploadService,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {
|
||||
sentContent = content;
|
||||
sentMediaTags = mediaTags;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.paperclip));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byTooltip('Remove attachment'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.sendHorizontal));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sentContent, '\n');
|
||||
expect(sentMediaTags, hasLength(1));
|
||||
expect(sentMediaTags.first.first, 'imeta');
|
||||
expect(
|
||||
sentMediaTags.first,
|
||||
contains('url https://relay.example/media/test.png'),
|
||||
);
|
||||
expect(find.byTooltip('Remove attachment'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('keeps the remove button pinned to the attachment corner', (
|
||||
tester,
|
||||
) async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: 'sprout_test_token',
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'https://relay.example/media/test.png',
|
||||
'sha256':
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'size': 16,
|
||||
'type': 'image/png',
|
||||
'uploaded': 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: uploadService,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.paperclip));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final attachmentFinder = find.byKey(
|
||||
const ValueKey(
|
||||
'compose-attachment:https://relay.example/media/test.png',
|
||||
),
|
||||
);
|
||||
final removeButtonFinder = find.byTooltip('Remove attachment');
|
||||
|
||||
expect(attachmentFinder, findsOneWidget);
|
||||
expect(removeButtonFinder, findsOneWidget);
|
||||
|
||||
final attachmentTopRight = tester.getTopRight(attachmentFinder);
|
||||
final attachmentTopLeft = tester.getTopLeft(attachmentFinder);
|
||||
final removeButtonCenter = tester.getCenter(removeButtonFinder);
|
||||
|
||||
expect(
|
||||
attachmentTopRight.dx - removeButtonCenter.dx,
|
||||
lessThanOrEqualTo(16),
|
||||
);
|
||||
expect(
|
||||
removeButtonCenter.dy - attachmentTopLeft.dy,
|
||||
lessThanOrEqualTo(16),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows an upload error when gallery upload fails', (
|
||||
tester,
|
||||
) async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: 'sprout_test_token',
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
return http.Response('bad upload', 401);
|
||||
}),
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: uploadService,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.paperclip));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('upload failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows a clean error when a GIF is picked', (tester) async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: 'sprout_test_token',
|
||||
nsec: nsec,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_gifBytes, name: 'animated.gif'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: uploadService,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.paperclip));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.textContaining('GIF uploads are not supported on mobile yet'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows a clean error when an animated PNG is picked', (
|
||||
tester,
|
||||
) async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: 'sprout_test_token',
|
||||
nsec: nsec,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_apngBytes, name: 'animated.png'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: uploadService,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.paperclip));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.textContaining('Animated PNG uploads are not supported on mobile'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:sprout_mobile/features/channels/message_content.dart';
|
||||
import 'package:sprout_mobile/features/channels/media_viewer_page.dart';
|
||||
import 'package:sprout_mobile/shared/theme/theme.dart';
|
||||
|
||||
Widget _testable(Widget child) {
|
||||
@@ -10,6 +12,50 @@ Widget _testable(Widget child) {
|
||||
);
|
||||
}
|
||||
|
||||
void _setSurfaceSize(WidgetTester tester, Size size) {
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
tester.view.physicalSize = size;
|
||||
}
|
||||
|
||||
Finder _imagePreview(String imageUrl) {
|
||||
return find.byKey(ValueKey('message-media-image-preview:$imageUrl'));
|
||||
}
|
||||
|
||||
Finder _imageViewerHeroMode() {
|
||||
return find.byKey(const ValueKey('message-media-image-viewer-hero-mode'));
|
||||
}
|
||||
|
||||
Future<TransformationController> _openImageViewer(
|
||||
WidgetTester tester,
|
||||
String imageUrl,
|
||||
) async {
|
||||
await tester.tap(_imagePreview(imageUrl));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final interactiveViewer = tester.widget<InteractiveViewer>(
|
||||
find.byType(InteractiveViewer),
|
||||
);
|
||||
final transformationController = interactiveViewer.transformationController;
|
||||
|
||||
expect(transformationController, isNotNull);
|
||||
return transformationController!;
|
||||
}
|
||||
|
||||
void _applyImageViewerTransform(
|
||||
TransformationController controller, {
|
||||
required double dx,
|
||||
required double dy,
|
||||
required double scale,
|
||||
}) {
|
||||
controller.value = Matrix4.identity()
|
||||
..translateByDouble(dx, dy, 0, 1)
|
||||
..scaleByDouble(scale, scale, scale, 1);
|
||||
}
|
||||
|
||||
bool _isImageViewerHeroEnabled(WidgetTester tester) {
|
||||
return tester.widget<HeroMode>(_imageViewerHeroMode()).enabled;
|
||||
}
|
||||
|
||||
/// Extracts all plain text from all RichText widgets in the tree.
|
||||
String _allRichText(WidgetTester tester) {
|
||||
final richTexts = tester.widgetList<RichText>(find.byType(RichText));
|
||||
@@ -84,6 +130,20 @@ bool _spanHasStyle(
|
||||
|
||||
void main() {
|
||||
group('MessageContent', () {
|
||||
test('buildImageViewerRoute uses modal-style page route builder', () {
|
||||
final route = buildImageViewerRoute(
|
||||
imageUrl: 'https://example.com/media/image.png',
|
||||
heroTag: Object(),
|
||||
);
|
||||
|
||||
expect(route, isA<PageRouteBuilder<void>>());
|
||||
expect(route.transitionDuration, const Duration(milliseconds: 280));
|
||||
expect(
|
||||
route.reverseTransitionDuration,
|
||||
const Duration(milliseconds: 220),
|
||||
);
|
||||
});
|
||||
|
||||
group('plain text', () {
|
||||
testWidgets('renders simple text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -195,6 +255,356 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('media attachments', () {
|
||||
testWidgets(
|
||||
'renders image markdown as a media preview and opens viewer',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: 'Look\n',
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/image.png',
|
||||
'm image/png',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final preview = find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/image.png',
|
||||
),
|
||||
);
|
||||
expect(preview, findsOneWidget);
|
||||
|
||||
await tester.tap(preview);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final viewer = tester.widget<Scaffold>(
|
||||
find.byKey(const ValueKey('message-media-image-viewer')),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-viewer')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(viewer.backgroundColor, Colors.black);
|
||||
expect(find.byType(AppBar), findsNothing);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-viewer-close')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('message-media-image-viewer-close')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-viewer')),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('uses unique hero tags for repeated identical image urls', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: '''
|
||||

|
||||

|
||||
''',
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/repeated.png',
|
||||
'm image/png',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final heroes = tester.widgetList<Hero>(find.byType(Hero)).toList();
|
||||
final heroTags = heroes.map((hero) => hero.tag).toSet();
|
||||
|
||||
expect(heroes, hasLength(2));
|
||||
expect(heroTags, hasLength(2));
|
||||
|
||||
await tester.tap(find.byType(Image).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-viewer')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'disables hero on close after the fullscreen image is transformed',
|
||||
(tester) async {
|
||||
const imageUrl = 'https://example.com/media/transformed.png';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content:
|
||||
'Look\n',
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/transformed.png',
|
||||
'm image/png',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final transformationController = await _openImageViewer(
|
||||
tester,
|
||||
imageUrl,
|
||||
);
|
||||
|
||||
expect(_isImageViewerHeroEnabled(tester), isTrue);
|
||||
|
||||
_applyImageViewerTransform(
|
||||
transformationController,
|
||||
dx: 24.0,
|
||||
dy: 18.0,
|
||||
scale: 1.5,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('message-media-image-viewer-close')),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(_isImageViewerHeroEnabled(tester), isFalse);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-viewer')),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'disables hero on back navigation after the fullscreen image is transformed',
|
||||
(tester) async {
|
||||
const imageUrl = 'https://example.com/media/transformed-back.png';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content:
|
||||
'Look\n',
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/transformed-back.png',
|
||||
'm image/png',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final transformationController = await _openImageViewer(
|
||||
tester,
|
||||
imageUrl,
|
||||
);
|
||||
|
||||
_applyImageViewerTransform(
|
||||
transformationController,
|
||||
dx: 32.0,
|
||||
dy: 20.0,
|
||||
scale: 1.4,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final popRouteFuture = tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
|
||||
await popRouteFuture;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-viewer')),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('caps tall image previews to a bounded inline size', (
|
||||
tester,
|
||||
) async {
|
||||
_setSurfaceSize(tester, const Size(400, 800));
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: '',
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/tall.png',
|
||||
'm image/png',
|
||||
'dim 1200x2400',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final preview = find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/tall.png',
|
||||
),
|
||||
);
|
||||
final size = tester.getSize(preview);
|
||||
|
||||
expect(size.height, closeTo(240, 0.1));
|
||||
expect(size.width, closeTo(120, 0.1));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'keeps no-dim image previews max-bounded without fixed crop',
|
||||
(tester) async {
|
||||
_setSurfaceSize(tester, const Size(400, 800));
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
const previewKey = ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/no-dim.png',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: '',
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/no-dim.png',
|
||||
'm image/png',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final preview = tester.widget<Container>(find.byKey(previewKey));
|
||||
final image = tester.widget<Image>(
|
||||
find.descendant(
|
||||
of: find.byKey(previewKey),
|
||||
matching: find.byType(Image),
|
||||
),
|
||||
);
|
||||
|
||||
expect(preview.constraints, isNotNull);
|
||||
expect(preview.constraints!.minWidth, 0);
|
||||
expect(preview.constraints!.minHeight, 0);
|
||||
expect(preview.constraints!.maxWidth, closeTo(288, 0.1));
|
||||
expect(preview.constraints!.maxHeight, closeTo(240, 0.1));
|
||||
expect(image.fit, BoxFit.contain);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('renders video markdown as a video preview', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: '',
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/clip.mp4',
|
||||
'm video/mp4',
|
||||
'image https://example.com/media/poster.jpg',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-video-preview:https://example.com/media/clip.mp4',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byIcon(LucideIcons.play), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('treats only mp4 fallback URLs as videos', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
const MessageContent(
|
||||
content: '''
|
||||

|
||||

|
||||
''',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-video-preview:https://example.com/media/clip.mp4',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-video-preview:https://example.com/media/clip.mov',
|
||||
),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/clip.mov',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('blockquotes', () {
|
||||
testWidgets('renders blockquote with left border', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
|
||||
38
mobile/test/features/channels/message_media_test.dart
Normal file
38
mobile/test/features/channels/message_media_test.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sprout_mobile/features/channels/message_media.dart';
|
||||
|
||||
void main() {
|
||||
group('classifyMediaUrl', () {
|
||||
test('treats only mp4 URLs as video fallback', () {
|
||||
expect(
|
||||
classifyMediaUrl('https://example.com/media/clip.mp4'),
|
||||
MessageMediaKind.video,
|
||||
);
|
||||
expect(classifyMediaUrl('https://example.com/media/clip.mov'), isNull);
|
||||
expect(classifyMediaUrl('https://example.com/media/clip.webm'), isNull);
|
||||
});
|
||||
|
||||
test('does not treat non-mp4 video mimetypes as video UI', () {
|
||||
expect(
|
||||
classifyMediaUrl(
|
||||
'https://example.com/media/clip.mov',
|
||||
imeta: const ImetaEntry(
|
||||
url: 'https://example.com/media/clip.mov',
|
||||
mimeType: 'video/quicktime',
|
||||
),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
classifyMediaUrl(
|
||||
'https://example.com/media/clip.mp4',
|
||||
imeta: const ImetaEntry(
|
||||
url: 'https://example.com/media/clip.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
),
|
||||
),
|
||||
MessageMediaKind.video,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,9 @@ ForumPost _makePost({
|
||||
String pubkey = 'alice',
|
||||
String content = 'Hello forum',
|
||||
int createdAt = 1000,
|
||||
List<List<String>> tags = const [
|
||||
['h', 'forum-channel'],
|
||||
],
|
||||
ForumThreadSummary? threadSummary,
|
||||
}) => ForumPost(
|
||||
eventId: eventId,
|
||||
@@ -44,14 +47,17 @@ ForumPost _makePost({
|
||||
kind: 45001,
|
||||
createdAt: createdAt,
|
||||
channelId: _channelId,
|
||||
tags: const [
|
||||
['h', 'forum-channel'],
|
||||
],
|
||||
tags: tags,
|
||||
threadSummary: threadSummary,
|
||||
);
|
||||
|
||||
const _aliceProfile = UserProfile(pubkey: 'alice', displayName: 'Alice');
|
||||
|
||||
void _setSurfaceSize(WidgetTester tester, Size size) {
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
tester.view.physicalSize = size;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -234,6 +240,46 @@ void main() {
|
||||
expect(tapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('keeps media previews non-interactive in the post list', (
|
||||
tester,
|
||||
) async {
|
||||
var tapped = false;
|
||||
const imageUrl = 'https://example.com/media/card.png';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(
|
||||
content: '',
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/card.png',
|
||||
'm image/png',
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: () => tapped = true,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final preview = find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/card.png',
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tapAt(tester.getCenter(preview));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tapped, isTrue);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-viewer')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('long press opens action sheet with Copy text', (tester) async {
|
||||
await tester.pumpWidget(_buildPostCard(post: _makePost()));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -466,6 +512,86 @@ void main() {
|
||||
expect(find.text('Reply to this post\u2026'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders media previews for forum posts', (tester) async {
|
||||
const imageUrl = 'https://example.com/media/forum.png';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(
|
||||
content: '',
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/forum.png',
|
||||
'm image/png',
|
||||
],
|
||||
],
|
||||
),
|
||||
replies: const [],
|
||||
totalReplies: 0,
|
||||
),
|
||||
users: const {'alice': _aliceProfile},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/forum.png',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps tall forum image previews bounded inline', (
|
||||
tester,
|
||||
) async {
|
||||
_setSurfaceSize(tester, const Size(400, 800));
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
const imageUrl = 'https://example.com/media/forum-tall.png';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(
|
||||
content: '',
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/forum-tall.png',
|
||||
'm image/png',
|
||||
'dim 1200x2400',
|
||||
],
|
||||
],
|
||||
),
|
||||
replies: const [],
|
||||
totalReplies: 0,
|
||||
),
|
||||
users: const {'alice': _aliceProfile},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final preview = find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/forum-tall.png',
|
||||
),
|
||||
);
|
||||
final size = tester.getSize(preview);
|
||||
|
||||
expect(size.height, closeTo(240, 0.1));
|
||||
expect(size.width, closeTo(120, 0.1));
|
||||
});
|
||||
|
||||
testWidgets('hides compose bar for non-members', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
|
||||
764
mobile/test/shared/relay/media_upload_test.dart
Normal file
764
mobile/test/shared/relay/media_upload_test.dart
Normal file
@@ -0,0 +1,764 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:sprout_mobile/shared/relay/media_upload.dart';
|
||||
|
||||
final _pngBytes = Uint8List.fromList([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
]);
|
||||
|
||||
final _jpegBytes = Uint8List.fromList([
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff,
|
||||
0xdb,
|
||||
0x00,
|
||||
0x43,
|
||||
0x00,
|
||||
0x01,
|
||||
]);
|
||||
|
||||
final _heicBytes = Uint8List.fromList([
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x18,
|
||||
0x66,
|
||||
0x74,
|
||||
0x79,
|
||||
0x70,
|
||||
0x68,
|
||||
0x65,
|
||||
0x69,
|
||||
0x63,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x6d,
|
||||
0x69,
|
||||
0x66,
|
||||
0x31,
|
||||
0x68,
|
||||
0x65,
|
||||
0x69,
|
||||
0x63,
|
||||
]);
|
||||
|
||||
final _gifBytes = Uint8List.fromList([
|
||||
0x47,
|
||||
0x49,
|
||||
0x46,
|
||||
0x38,
|
||||
0x39,
|
||||
0x61,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
final _apngBytes = Uint8List.fromList([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08,
|
||||
0x61,
|
||||
0x63,
|
||||
0x54,
|
||||
0x4c,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4e,
|
||||
0x44,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
final _staticPngWithActlPayloadBytes = Uint8List.fromList([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x04,
|
||||
0x49,
|
||||
0x44,
|
||||
0x41,
|
||||
0x54,
|
||||
0x61,
|
||||
0x63,
|
||||
0x54,
|
||||
0x4c,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4e,
|
||||
0x44,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
final _animatedWebpBytes = Uint8List.fromList([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46,
|
||||
0x16,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x57,
|
||||
0x45,
|
||||
0x42,
|
||||
0x50,
|
||||
0x56,
|
||||
0x50,
|
||||
0x38,
|
||||
0x58,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
const _mediaUploadPlatformChannel = MethodChannel('sprout/media_upload');
|
||||
|
||||
void _setMockMediaUploadPlatformHandler(
|
||||
Future<Object?> Function(MethodCall call)? handler,
|
||||
) {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_mediaUploadPlatformChannel, handler);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
_setMockMediaUploadPlatformHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'sanitizeImageForUpload':
|
||||
final arguments = call.arguments as Map<Object?, Object?>;
|
||||
return arguments['bytes'] as Uint8List;
|
||||
case 'transcodeImageToJpeg':
|
||||
return _jpegBytes;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
_setMockMediaUploadPlatformHandler(null);
|
||||
});
|
||||
|
||||
group('MediaUploadService', () {
|
||||
test('signs Blossom auth and uploads gallery image bytes', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
|
||||
http.Request? capturedRequest;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
capturedRequest = request;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'https://relay.example/media/test.png',
|
||||
'sha256':
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
'size': 16,
|
||||
'type': 'image/png',
|
||||
'uploaded': 1,
|
||||
'thumb': 'https://relay.example/media/test.thumb.jpg',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example:8443',
|
||||
apiToken: 'sprout_test_token',
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
||||
now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000),
|
||||
);
|
||||
|
||||
final descriptor = await service.pickAndUploadImage();
|
||||
|
||||
expect(descriptor, isNotNull);
|
||||
expect(descriptor!.type, 'image/png');
|
||||
expect(capturedRequest, isNotNull);
|
||||
expect(
|
||||
capturedRequest!.url.toString(),
|
||||
'https://relay.example:8443/media/upload',
|
||||
);
|
||||
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
||||
expect(capturedRequest!.headers['X-Auth-Token'], 'sprout_test_token');
|
||||
expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty);
|
||||
expect(capturedRequest!.bodyBytes, _pngBytes);
|
||||
|
||||
final authHeader = capturedRequest!.headers['Authorization'];
|
||||
expect(authHeader, isNotNull);
|
||||
expect(authHeader, startsWith('Nostr '));
|
||||
final encoded = authHeader!.substring('Nostr '.length);
|
||||
final decoded = utf8.decode(
|
||||
base64Url.decode(base64Url.normalize(encoded)),
|
||||
);
|
||||
final authEvent = jsonDecode(decoded) as Map<String, dynamic>;
|
||||
final tags = (authEvent['tags'] as List<dynamic>)
|
||||
.map((tag) => (tag as List<dynamic>).cast<String>())
|
||||
.toList();
|
||||
|
||||
expect(authEvent['kind'], 24242);
|
||||
expect(authEvent['pubkey'], keychain.public);
|
||||
expect(tags, anyElement(equals(<String>['t', 'upload'])));
|
||||
expect(
|
||||
tags,
|
||||
anyElement(
|
||||
equals(<String>['x', capturedRequest!.headers['X-SHA-256']!]),
|
||||
),
|
||||
);
|
||||
expect(tags, anyElement(equals(<String>['expiration', '1700000300'])));
|
||||
expect(
|
||||
tags,
|
||||
anyElement(equals(<String>['server', 'relay.example:8443'])),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null when the gallery picker is cancelled', () async {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: null,
|
||||
pickGalleryImage: () async => null,
|
||||
);
|
||||
|
||||
final result = await service.pickAndUploadImage();
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('uses a bracketed IPv6 server tag in Blossom auth', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
|
||||
http.Request? capturedRequest;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
capturedRequest = request;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'http://[::1]:3000/media/test.png',
|
||||
'sha256':
|
||||
'2222222222222222222222222222222222222222222222222222222222222222',
|
||||
'size': 16,
|
||||
'type': 'image/png',
|
||||
'uploaded': 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'http://[::1]:3000',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
||||
);
|
||||
|
||||
await service.pickAndUploadImage();
|
||||
|
||||
expect(capturedRequest, isNotNull);
|
||||
final authHeader = capturedRequest!.headers['Authorization'];
|
||||
expect(authHeader, isNotNull);
|
||||
final encoded = authHeader!.substring('Nostr '.length);
|
||||
final decoded = utf8.decode(
|
||||
base64Url.decode(base64Url.normalize(encoded)),
|
||||
);
|
||||
final authEvent = jsonDecode(decoded) as Map<String, dynamic>;
|
||||
final tags = (authEvent['tags'] as List<dynamic>)
|
||||
.map((tag) => (tag as List<dynamic>).cast<String>())
|
||||
.toList();
|
||||
|
||||
expect(tags, anyElement(equals(<String>['server', '[::1]:3000'])));
|
||||
});
|
||||
|
||||
test('transcodes HEIC gallery files on iOS before upload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
});
|
||||
|
||||
Uint8List? transcodedInput;
|
||||
http.Request? capturedRequest;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
capturedRequest = request;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'https://relay.example/media/test.jpg',
|
||||
'sha256':
|
||||
'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
||||
'size': _jpegBytes.length,
|
||||
'type': 'image/jpeg',
|
||||
'uploaded': 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_heicBytes, name: 'photo.heic'),
|
||||
transcodeImageToJpeg: (bytes) async {
|
||||
transcodedInput = bytes;
|
||||
return _jpegBytes;
|
||||
},
|
||||
);
|
||||
|
||||
final descriptor = await service.pickAndUploadImage();
|
||||
|
||||
expect(descriptor, isNotNull);
|
||||
expect(descriptor!.type, 'image/jpeg');
|
||||
expect(transcodedInput, _heicBytes);
|
||||
expect(capturedRequest, isNotNull);
|
||||
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
||||
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
||||
});
|
||||
|
||||
test('sanitizes iOS JPEG gallery files before upload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
});
|
||||
|
||||
Uint8List? sanitizedInput;
|
||||
String? sanitizedMimeType;
|
||||
http.Request? capturedRequest;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
capturedRequest = request;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'https://relay.example/media/test.jpg',
|
||||
'sha256':
|
||||
'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
||||
'size': _jpegBytes.length,
|
||||
'type': 'image/jpeg',
|
||||
'uploaded': 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_jpegBytes, name: 'photo.jpg'),
|
||||
sanitizeImageBytes: (bytes, mimeType) async {
|
||||
sanitizedInput = bytes;
|
||||
sanitizedMimeType = mimeType;
|
||||
return _jpegBytes;
|
||||
},
|
||||
);
|
||||
|
||||
final descriptor = await service.pickAndUploadImage();
|
||||
|
||||
expect(descriptor, isNotNull);
|
||||
expect(descriptor!.type, 'image/jpeg');
|
||||
expect(sanitizedInput, _jpegBytes);
|
||||
expect(sanitizedMimeType, 'image/jpeg');
|
||||
expect(capturedRequest, isNotNull);
|
||||
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
||||
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
||||
});
|
||||
|
||||
test('transcodes HEIC gallery files on Android before upload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
});
|
||||
|
||||
Uint8List? transcodedInput;
|
||||
http.Request? capturedRequest;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
capturedRequest = request;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'https://relay.example/media/test.jpg',
|
||||
'sha256':
|
||||
'1234512345123451234512345123451234512345123451234512345123451234',
|
||||
'size': _jpegBytes.length,
|
||||
'type': 'image/jpeg',
|
||||
'uploaded': 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_heicBytes, name: 'photo.heic'),
|
||||
transcodeImageToJpeg: (bytes) async {
|
||||
transcodedInput = bytes;
|
||||
return _jpegBytes;
|
||||
},
|
||||
);
|
||||
|
||||
final descriptor = await service.pickAndUploadImage();
|
||||
|
||||
expect(descriptor, isNotNull);
|
||||
expect(descriptor!.type, 'image/jpeg');
|
||||
expect(transcodedInput, _heicBytes);
|
||||
expect(capturedRequest, isNotNull);
|
||||
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
||||
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
||||
});
|
||||
|
||||
test('sanitizes Android JPEG gallery files before upload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
});
|
||||
|
||||
Uint8List? sanitizedInput;
|
||||
String? sanitizedMimeType;
|
||||
http.Request? capturedRequest;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
capturedRequest = request;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'https://relay.example/media/test.jpg',
|
||||
'sha256':
|
||||
'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
||||
'size': _jpegBytes.length,
|
||||
'type': 'image/jpeg',
|
||||
'uploaded': 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_jpegBytes, name: 'photo.jpg'),
|
||||
sanitizeImageBytes: (bytes, mimeType) async {
|
||||
sanitizedInput = bytes;
|
||||
sanitizedMimeType = mimeType;
|
||||
return _jpegBytes;
|
||||
},
|
||||
);
|
||||
|
||||
final descriptor = await service.pickAndUploadImage();
|
||||
|
||||
expect(descriptor, isNotNull);
|
||||
expect(descriptor!.type, 'image/jpeg');
|
||||
expect(sanitizedInput, _jpegBytes);
|
||||
expect(sanitizedMimeType, 'image/jpeg');
|
||||
expect(capturedRequest, isNotNull);
|
||||
expect(capturedRequest!.headers['Content-Type'], 'image/jpeg');
|
||||
expect(capturedRequest!.bodyBytes, _jpegBytes);
|
||||
});
|
||||
|
||||
test('sanitizes Android PNG gallery files before upload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
});
|
||||
|
||||
Uint8List? sanitizedInput;
|
||||
String? sanitizedMimeType;
|
||||
http.Request? capturedRequest;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
capturedRequest = request;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'https://relay.example/media/test.png',
|
||||
'sha256':
|
||||
'9999999999999999999999999999999999999999999999999999999999999999',
|
||||
'size': _pngBytes.length,
|
||||
'type': 'image/png',
|
||||
'uploaded': 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_pngBytes, name: 'photo.png'),
|
||||
sanitizeImageBytes: (bytes, mimeType) async {
|
||||
sanitizedInput = bytes;
|
||||
sanitizedMimeType = mimeType;
|
||||
return _pngBytes;
|
||||
},
|
||||
);
|
||||
|
||||
final descriptor = await service.pickAndUploadImage();
|
||||
|
||||
expect(descriptor, isNotNull);
|
||||
expect(descriptor!.type, 'image/png');
|
||||
expect(sanitizedInput, _pngBytes);
|
||||
expect(sanitizedMimeType, 'image/png');
|
||||
expect(capturedRequest, isNotNull);
|
||||
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
||||
expect(capturedRequest!.bodyBytes, _pngBytes);
|
||||
});
|
||||
|
||||
test('rejects GIF gallery files before upload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_gifBytes, name: 'animated.gif'),
|
||||
);
|
||||
|
||||
expect(
|
||||
service.pickAndUploadImage(),
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('GIF uploads are not supported on mobile yet'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects animated PNG gallery files before upload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient(
|
||||
(request) async => http.Response('{}', 200),
|
||||
),
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_apngBytes, name: 'animated.png'),
|
||||
);
|
||||
|
||||
expect(
|
||||
service.pickAndUploadImage(),
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('Animated PNG uploads are not supported on mobile yet'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('uploads static PNG when acTL appears only in chunk payload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
|
||||
http.Request? capturedRequest;
|
||||
final client = http_testing.MockClient((request) async {
|
||||
capturedRequest = request;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'url': 'https://relay.example/media/static.png',
|
||||
'sha256':
|
||||
'1111111111111111111111111111111111111111111111111111111111111111',
|
||||
'size': _staticPngWithActlPayloadBytes.length,
|
||||
'type': 'image/png',
|
||||
'uploaded': 1,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_staticPngWithActlPayloadBytes, name: 'static.png'),
|
||||
);
|
||||
|
||||
final descriptor = await service.pickAndUploadImage();
|
||||
|
||||
expect(descriptor, isNotNull);
|
||||
expect(descriptor!.type, 'image/png');
|
||||
expect(capturedRequest, isNotNull);
|
||||
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
||||
expect(capturedRequest!.bodyBytes, _staticPngWithActlPayloadBytes);
|
||||
});
|
||||
|
||||
test('rejects animated WebP gallery files before upload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient(
|
||||
(request) async => http.Response('{}', 200),
|
||||
),
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_animatedWebpBytes, name: 'animated.webp'),
|
||||
);
|
||||
|
||||
expect(
|
||||
service.pickAndUploadImage(),
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('Animated WebP uploads are not supported on mobile yet'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unsupported gallery files before upload', () async {
|
||||
final keychain = nostr.Keychain.generate();
|
||||
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
pickGalleryImage: () async => XFile.fromData(
|
||||
Uint8List.fromList(utf8.encode('not an image')),
|
||||
name: 'note.txt',
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
service.pickAndUploadImage(),
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('unsupported file type'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user