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:
Wes
2026-04-22 12:54:29 -06:00
committed by GitHub
parent 3df7e7107f
commit e2c94a8538
30 changed files with 4167 additions and 140 deletions

12
Cargo.lock generated
View File

@@ -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]]

View File

@@ -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",

View File

@@ -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
);
}
}

View File

@@ -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");
}
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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: ', '');
}

View 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,
),
],
],
);
},
);
}
}

View File

@@ -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(

View 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';

View File

@@ -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(

View File

@@ -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)

View File

@@ -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,

View File

@@ -114,6 +114,7 @@ class ForumPostCard extends ConsumerWidget {
child: MessageContent(
content: preview,
mentionNames: mentionNames,
tags: post.tags,
),
),
),

View File

@@ -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;
},
),
],
],

View File

@@ -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));

View File

@@ -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,
),
),
],

View 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/') ? '![video]($url)' : '![image]($url)';
}
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;
});

View File

@@ -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';

View File

@@ -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:

View File

@@ -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:

View File

@@ -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: '![video]($videoUrl)',
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 {

View 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![image](https://relay.example/media/test.png)');
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,
);
});
});
}

View File

@@ -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![image](https://example.com/media/image.png)',
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: '''
![image](https://example.com/media/repeated.png)
![image](https://example.com/media/repeated.png)
''',
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![image](https://example.com/media/transformed.png)',
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![image](https://example.com/media/transformed-back.png)',
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: '![image](https://example.com/media/tall.png)',
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: '![image](https://example.com/media/no-dim.png)',
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: '![video](https://example.com/media/clip.mp4)',
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: '''
![mp4](https://example.com/media/clip.mp4)
![mov](https://example.com/media/clip.mov)
''',
),
),
);
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(

View 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,
);
});
});
}

View File

@@ -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: '![image]($imageUrl)',
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: '![image]($imageUrl)',
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: '![image]($imageUrl)',
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(

View 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'),
),
),
);
});
});
}