feat(desktop): add KLIPY GIF search to composers (#5554)

## Summary

- Supersedes #1913 with a KLIPY-hosted URL implementation.
- Adds KLIPY GIF search and trending results to desktop message and
forum composers.
- Keeps selected GIFs hosted by KLIPY; Buzz stores only the external URL
and media metadata (no imeta tag, since relays only accept hash-backed
local `/media/` entries).
- Aligns the Emoji/GIF picker with Buzz's standard segmented control,
theme surfaces, and motion behavior.

## Relay-to-provider boundary

- The relay proxies KLIPY search/share so the `BUZZ_KLIPY_API_KEY` never
reaches the desktop; the key stays server-side behind a redacted `Debug`
impl.
- The dedicated GIF `reqwest` client sets `redirect::Policy::none()`.
Because the API key rides in the request path, following a provider
`3xx` could replay a key-bearing URL to an attacker-chosen host (an
SSRF/key-disclosure primitive). With redirects disabled, a `3xx` returns
as a non-success status that the handlers map to a generic `502`; the
`Location` target is never read or forwarded.
- Admission reuses the established NIP-98, membership, replay, and
per-pubkey rate-limit gates, with an upstream response-size cap and
allowlisting so KLIPY error bodies never cross the relay boundary.

## Accessibility

- Under `prefers-reduced-motion: reduce`, the picker grid renders a
static provider poster (a normalized `jpg` asset) instead of the
animated preview, or a named static placeholder when no poster is
available. It reacts to preference changes while mounted.
`no-preference` keeps the animated preview.
- Selected GIFs carry their title through `ImetaMedia`'s `displayLabel`,
so the composer thumbnail, preview dialog, editor, lightbox, and remove
control all derive one non-empty accessible name instead of an empty
`Attachment ` label. Ordinary hashed uploads keep their existing
hash-derived names.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-26 00:09:55 +01:00
committed by GitHub
parent e8cd7516e6
commit 8471049c43
34 changed files with 2215 additions and 146 deletions

View File

@@ -59,9 +59,15 @@ RELAY_URL=ws://localhost:3000
# (use `just web` for Vite HMR instead).
# BUZZ_WEB_DIR=./web/dist
# Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and
# authenticated desktop clients use this relay as the metadata/search proxy.
# Keep the real value in your deployment's secret manager; never commit it.
# BUZZ_KLIPY_API_KEY=
# Shared Redis-backed admission limits. Defaults shown below; each value must
# be a positive integer.
# BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60
# BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN=30
# BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300
# BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10
# BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120

View File

@@ -60,6 +60,8 @@ pub enum LimitType {
Messages,
/// HTTP REST API calls.
ApiCalls,
/// Relay-proxied GIF metadata searches.
GifSearches,
/// All WebSocket events (broader than `Messages`).
WsEvents,
/// Concurrent WebSocket connections from a single IP address.
@@ -72,6 +74,7 @@ impl LimitType {
match self {
Self::Messages => "msg",
Self::ApiCalls => "api",
Self::GifSearches => "gif",
Self::WsEvents => "ws",
Self::IpConnections => "conn",
}
@@ -87,6 +90,10 @@ pub struct RateLimitConfig {
/// Maximum messages per minute for human users. Default: 60.
#[serde(default = "default_human_msg")]
pub human_messages_per_min: u64,
/// Maximum relay-proxied GIF searches per minute for each pubkey.
/// Default: 30.
#[serde(default = "default_gif_searches")]
pub gif_searches_per_min: u64,
/// Maximum HTTP API calls per minute for human users. Default: 300.
#[serde(default = "default_human_api")]
pub human_api_calls_per_min: u64,
@@ -110,6 +117,9 @@ pub struct RateLimitConfig {
fn default_human_msg() -> u64 {
60
}
fn default_gif_searches() -> u64 {
30
}
fn default_human_api() -> u64 {
300
}
@@ -133,6 +143,7 @@ impl Default for RateLimitConfig {
fn default() -> Self {
Self {
human_messages_per_min: default_human_msg(),
gif_searches_per_min: default_gif_searches(),
human_api_calls_per_min: default_human_api(),
human_ws_events_per_sec: default_human_ws(),
agent_standard_messages_per_min: default_agent_std_msg(),
@@ -272,6 +283,17 @@ mod tests {
assert!(key.ends_with(":msg"));
}
#[test]
fn gif_searches_have_an_independent_quota_key() {
let ctx = fixture_ctx("relay-a.example");
let keys = Keys::generate();
let gif_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::GifSearches);
let api_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::ApiCalls);
assert!(gif_key.ends_with(":gif"));
assert_ne!(gif_key, api_key);
}
#[test]
fn rate_limit_key_isolates_communities_for_same_pubkey() {
// The S1 cross-community isolation fence at the rate-limit key layer:

View File

@@ -0,0 +1,613 @@
//! Relay-owned KLIPY GIF metadata/search proxy.
//!
//! KLIPY requires a provider credential, but desktop applications cannot keep
//! build-time credentials secret. These narrow endpoints keep the key on the
//! operator's relay while returning only KLIPY-hosted media URLs and metadata;
//! GIF bytes are never downloaded, cached, or stored by Buzz.
//!
//! Search and share reporting are the only relay endpoints. Sending a selected
//! GIF is a normal message containing its CDN URL, and clients render that URL
//! through the existing image path. No GIF bytes transit the relay.
use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::State,
http::{header, HeaderMap, StatusCode},
response::Json,
};
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::Value;
use crate::state::AppState;
use buzz_auth::LimitType;
use super::{api_error, bridge, internal_error, relay_members};
const KLIPY_API_ROOT: &str = "https://api.klipy.com/api/v1/";
pub(crate) const SEARCH_PATH: &str = "/gifs/search";
pub(crate) const SHARE_PATH: &str = "/gifs/share";
const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_UPSTREAM_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
/// Build the dedicated KLIPY client. Redirects are disabled: the API key rides
/// in the request path, so following a provider 3xx could replay a key-bearing
/// URL to an attacker-chosen host. With no redirect policy, a 3xx comes back as
/// a non-success status that the handlers map to a generic `502`, and the
/// `Location` target is never read or forwarded.
pub fn build_gif_http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(UPSTREAM_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("static GIF HTTP client configuration")
}
#[derive(Debug, Deserialize)]
/// Client-owned search context forwarded to KLIPY by the relay.
pub struct SearchRequest {
/// Empty means trending; otherwise this is the user's search text.
query: String,
/// Stable anonymous installation identifier required by KLIPY.
customer_id: String,
/// Desktop locale used to localize provider results.
locale: String,
}
#[derive(Debug, Deserialize)]
/// Client-owned share context forwarded to KLIPY by the relay.
pub struct ShareRequest {
/// Provider slug for the selected GIF.
slug: String,
/// Stable anonymous installation identifier required by KLIPY.
customer_id: String,
}
fn validate_text(
name: &str,
value: &str,
max_chars: usize,
allow_empty: bool,
) -> Result<(), (StatusCode, Json<Value>)> {
let count = value.chars().count();
if (!allow_empty && value.trim().is_empty()) || count > max_chars {
return Err(api_error(
StatusCode::BAD_REQUEST,
&format!(
"{name} must be {} through {max_chars} characters",
if allow_empty { 0 } else { 1 }
),
));
}
Ok(())
}
fn klipy_url(
api_key: &str,
path: &[&str],
query: &[(&str, &str)],
) -> Result<url::Url, (StatusCode, Json<Value>)> {
let mut url = url::Url::parse(KLIPY_API_ROOT)
.map_err(|_| internal_error("invalid static KLIPY API root"))?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| internal_error("invalid static KLIPY API root"))?;
segments.pop_if_empty().push(api_key);
for segment in path {
segments.push(segment);
}
}
if !query.is_empty() {
url.query_pairs_mut().extend_pairs(query.iter().copied());
}
Ok(url)
}
fn klipy_share_request(
client: &reqwest::Client,
api_key: &str,
request: &ShareRequest,
) -> Result<reqwest::RequestBuilder, (StatusCode, Json<Value>)> {
let url = klipy_url(api_key, &["gifs", "share", request.slug.trim()], &[])?;
Ok(client
.post(url)
.json(&serde_json::json!({ "customer_id": request.customer_id })))
}
async fn authenticate(
state: &Arc<AppState>,
headers: &HeaderMap,
path: &str,
body: &[u8],
) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json<Value>)> {
let raw_host = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
.await
.map_err(|_| {
api_error(
StatusCode::NOT_FOUND,
"relay: no community is configured for this host",
)
})?;
let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path);
let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options(
headers,
"POST",
&expected_url,
Some(body),
true,
true,
)?;
bridge::enforce_http_admission(state, &tenant, &pubkey).await?;
bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?;
relay_members::enforce_relay_membership(
state,
tenant.community(),
&pubkey.to_bytes(),
headers
.get("x-auth-tag")
.and_then(|value| value.to_str().ok()),
)
.await?;
Ok((tenant, pubkey))
}
async fn send_upstream(
request: reqwest::RequestBuilder,
) -> Result<reqwest::Response, (StatusCode, Json<Value>)> {
request
.timeout(UPSTREAM_TIMEOUT)
.send()
.await
.map_err(|error| {
tracing::warn!(
timeout = error.is_timeout(),
"KLIPY upstream request failed"
);
api_error(StatusCode::BAD_GATEWAY, "GIF provider is unavailable")
})
}
async fn enforce_search_admission(
state: &AppState,
tenant: &buzz_core::TenantContext,
pubkey: &nostr::PublicKey,
) -> Result<(), (StatusCode, Json<Value>)> {
let limit = state.auth.config().rate_limits.gif_searches_per_min;
match crate::admission::check_principal(
state.admission_rate_limiter.as_ref(),
tenant,
pubkey,
LimitType::GifSearches,
60,
limit,
)
.await
{
Ok(()) => Ok(()),
Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => {
metrics::counter!("buzz_gif_search_rejections_total", "reason" => "quota").increment(1);
Err(api_error(
StatusCode::TOO_MANY_REQUESTS,
&format!("rate-limited: GIF search quota exceeded; retry in {reset_in_secs}s"),
))
}
Err(crate::admission::AdmissionError::Unavailable) => Err(api_error(
StatusCode::SERVICE_UNAVAILABLE,
"rate-limited: GIF search admission unavailable",
)),
}
}
async fn limited_json(response: reqwest::Response) -> Result<Value, (StatusCode, Json<Value>)> {
if response
.content_length()
.is_some_and(|length| length > MAX_UPSTREAM_RESPONSE_BYTES as u64)
{
return Err(api_error(
StatusCode::BAD_GATEWAY,
"GIF provider response was too large",
));
}
let mut body = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| {
api_error(
StatusCode::BAD_GATEWAY,
"GIF provider response could not be read",
)
})?;
if body.len().saturating_add(chunk.len()) > MAX_UPSTREAM_RESPONSE_BYTES {
return Err(api_error(
StatusCode::BAD_GATEWAY,
"GIF provider response was too large",
));
}
body.extend_from_slice(&chunk);
}
serde_json::from_slice(&body).map_err(|_| {
api_error(
StatusCode::BAD_GATEWAY,
"GIF provider returned an invalid response",
)
})
}
fn successful_search_payload(upstream: &Value) -> Result<Value, (StatusCode, Json<Value>)> {
if upstream.get("result").and_then(Value::as_bool) != Some(true) {
tracing::warn!("KLIPY search returned an unsuccessful result");
return Err(api_error(
StatusCode::BAD_GATEWAY,
"GIF provider rejected the search request",
));
}
let data = upstream.get("data").cloned().unwrap_or(Value::Null);
Ok(serde_json::json!({ "result": true, "data": data }))
}
/// Search or browse trending KLIPY GIF metadata for an authenticated member.
pub async fn search(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let Some(config) = state.config.klipy.as_ref() else {
return Err(api_error(
StatusCode::NOT_FOUND,
"GIF search is not configured",
));
};
let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?;
let request: SearchRequest = serde_json::from_slice(&body)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON"))?;
validate_text("query", &request.query, 200, true)?;
validate_text("customer_id", &request.customer_id, 128, false)?;
validate_text("locale", &request.locale, 32, false)?;
enforce_search_admission(&state, &tenant, &pubkey).await?;
let endpoint = if request.query.trim().is_empty() {
"trending"
} else {
"search"
};
let mut query = vec![
("page", "1"),
("per_page", "24"),
("customer_id", request.customer_id.as_str()),
("locale", request.locale.as_str()),
];
if !request.query.trim().is_empty() {
query.push(("q", request.query.trim()));
}
let url = klipy_url(config.api_key(), &["gifs", endpoint], &query)?;
let response = send_upstream(state.gif_http_client.get(url)).await?;
if !response.status().is_success() {
tracing::warn!(status = response.status().as_u16(), "KLIPY search failed");
return Err(api_error(
StatusCode::BAD_GATEWAY,
"GIF provider rejected the search request",
));
}
// Never forward the provider response wholesale. KLIPY may report an
// application-level failure with HTTP 200 and include request details in
// its error fields. Allowlist only successful result data so credentials
// and provider diagnostics cannot cross the relay boundary.
let upstream = limited_json(response).await?;
Ok(Json(successful_search_payload(&upstream)?))
}
/// Report a selected GIF to KLIPY so the provider can update Recents.
pub async fn share(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<StatusCode, (StatusCode, Json<Value>)> {
let Some(config) = state.config.klipy.as_ref() else {
return Err(api_error(
StatusCode::NOT_FOUND,
"GIF search is not configured",
));
};
authenticate(&state, &headers, SHARE_PATH, &body).await?;
let request: ShareRequest = serde_json::from_slice(&body)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON"))?;
validate_text("slug", &request.slug, 200, false)?;
validate_text("customer_id", &request.customer_id, 128, false)?;
let response = send_upstream(klipy_share_request(
&state.gif_http_client,
config.api_key(),
&request,
)?)
.await?;
if !response.status().is_success() {
tracing::warn!(status = response.status().as_u16(), "KLIPY share failed");
return Err(api_error(
StatusCode::BAD_GATEWAY,
"GIF provider rejected the share request",
));
}
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
routing::get,
Router,
};
use tower::ServiceExt;
async fn unconfigured_test_state() -> Arc<AppState> {
let mut config = crate::config::Config::from_env().expect("test config");
config.klipy = None;
config.redis_url = "redis://127.0.0.1:1".to_string();
let pool = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://buzz:buzz_dev@127.0.0.1:1/buzz") // sadscan:disable np.postgres.1
.expect("lazy test database pool");
let db = buzz_db::Db::from_pool(pool.clone());
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("lazy test Redis pool");
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.expect("test pubsub"),
);
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(pool.clone());
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let media_storage =
buzz_media::MediaStorage::new(&config.media).expect("test media storage config");
let (state, _audit_shutdown) = AppState::new(
config,
db,
redis_pool,
None::<buzz_audit::AuditService>,
pubsub,
auth,
search,
workflow_engine,
nostr::Keys::generate(),
media_storage,
);
Arc::new(state)
}
#[tokio::test]
async fn search_route_returns_not_found_before_auth_when_unconfigured() {
let state = unconfigured_test_state().await;
let response = Router::new()
.route(SEARCH_PATH, axum::routing::post(search))
.with_state(state)
.oneshot(
Request::post(SEARCH_PATH)
.body(Body::from("{}"))
.expect("search request"),
)
.await
.expect("search response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn limited_json_rejects_oversized_streamed_bodies() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let address = listener.local_addr().expect("test server address");
let server = tokio::spawn(async move {
axum::serve(
listener,
Router::new().route(
"/oversized",
get(|| async {
(
[(header::CONTENT_TYPE, "application/json")],
"x".repeat(MAX_UPSTREAM_RESPONSE_BYTES + 1),
)
}),
),
)
.await
.expect("serve oversized response");
});
let response = reqwest::get(format!("http://{address}/oversized"))
.await
.expect("test upstream response");
let (status, _) = limited_json(response)
.await
.expect_err("oversized body must be rejected");
server.abort();
let _ = server.await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
}
#[test]
fn klipy_url_encodes_credentials_as_a_path_segment() {
let url = klipy_url(
"key/with spaces",
&["gifs", "search"],
&[("customer_id", "customer")],
)
.expect("static URL is valid");
assert_eq!(
url.as_str(),
"https://api.klipy.com/api/v1/key%2Fwith%20spaces/gifs/search?customer_id=customer"
);
}
#[test]
fn klipy_share_request_uses_slug_path_and_customer_body() {
let request = ShareRequest {
slug: " ship/it ".to_string(),
customer_id: "customer-123".to_string(),
};
let built = klipy_share_request(&reqwest::Client::new(), "secret-key", &request)
.expect("share request builds")
.build()
.expect("share request is valid");
assert_eq!(built.method(), reqwest::Method::POST);
assert_eq!(
built.url().as_str(),
"https://api.klipy.com/api/v1/secret-key/gifs/share/ship%2Fit"
);
assert_eq!(
built.body().and_then(reqwest::Body::as_bytes),
Some(br#"{"customer_id":"customer-123"}"#.as_slice())
);
}
#[test]
fn validation_bounds_provider_control_fields() {
assert!(validate_text("query", "", 200, true).is_ok());
assert!(validate_text("customer_id", "", 128, false).is_err());
assert!(validate_text("query", &"x".repeat(201), 200, true).is_err());
}
#[test]
fn successful_payload_strips_provider_errors_and_unknown_fields() {
let payload = successful_search_payload(&serde_json::json!({
"result": true,
"data": { "data": [] },
"errors": { "message": ["request used secret-key"] },
"debug": "secret-key"
}))
.expect("successful payload");
assert_eq!(
payload,
serde_json::json!({ "result": true, "data": { "data": [] } })
);
}
#[test]
fn unsuccessful_payload_is_rejected_without_provider_details() {
let (status, body) = successful_search_payload(&serde_json::json!({
"result": false,
"errors": { "message": ["request used secret-key"] }
}))
.expect_err("unsuccessful provider payload must be rejected");
assert_eq!(status, StatusCode::BAD_GATEWAY);
let serialized = serde_json::to_string(&body.0).expect("serialize generic error");
assert!(!serialized.contains("secret-key"));
}
/// A provider 3xx must never cause a second connection, and the error
/// surfaced past the shared send/reject path must leak neither the API key
/// (carried in the request path) nor the redirect target.
///
/// Mutation check: swapping `build_gif_http_client`'s redirect policy back
/// to the default makes the client follow the 302, the redirect listener
/// records a request, and this test fails on the `redirect_hits` assertion.
#[tokio::test]
async fn gif_client_refuses_provider_redirects_without_leaking_secrets() {
use std::sync::atomic::{AtomicUsize, Ordering};
const SECRET_KEY: &str = "super-secret-klipy-key";
// Second listener: the redirect target. It must never be reached.
let redirect_hits = Arc::new(AtomicUsize::new(0));
let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind redirect target");
let redirect_addr = redirect_listener.local_addr().expect("redirect address");
let redirect_hits_server = redirect_hits.clone();
let redirect_server = tokio::spawn(async move {
axum::serve(
redirect_listener,
Router::new().route(
"/leaked",
get(move || {
redirect_hits_server.fetch_add(1, Ordering::SeqCst);
async { "reached the redirect target" }
}),
),
)
.await
.expect("serve redirect target");
});
// Fake upstream: answers the key-bearing path with a 302 whose Location
// points at the second listener, exactly the disclosure vector.
let redirect_location = format!("http://{redirect_addr}/leaked");
let upstream_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind fake upstream");
let upstream_addr = upstream_listener.local_addr().expect("upstream address");
let location_header = redirect_location.clone();
let upstream_server = tokio::spawn(async move {
axum::serve(
upstream_listener,
Router::new().route(
&format!("/{SECRET_KEY}/gifs/search"),
get(move || {
let location = location_header.clone();
async move {
(
StatusCode::FOUND,
[(header::LOCATION, location)],
"provider body naming the secret-key",
)
}
}),
),
)
.await
.expect("serve fake upstream");
});
let client = build_gif_http_client();
let response =
send_upstream(client.get(format!("http://{upstream_addr}/{SECRET_KEY}/gifs/search")))
.await
.expect("request completes without following the redirect");
// The redirect was not followed: the client surfaces the 3xx itself.
assert!(response.status().is_redirection());
assert!(!response.status().is_success());
assert_eq!(redirect_hits.load(Ordering::SeqCst), 0);
// The shared reject path (both handlers gate on `!is_success`) returns a
// static generic error carrying no key and no redirect target.
let (status, body) = api_error(
StatusCode::BAD_GATEWAY,
"GIF provider rejected the search request",
);
let serialized = serde_json::to_string(&body.0).expect("serialize generic error");
assert_eq!(status, StatusCode::BAD_GATEWAY);
assert!(!serialized.contains(SECRET_KEY));
assert!(!serialized.contains(&redirect_location));
upstream_server.abort();
redirect_server.abort();
let _ = upstream_server.await;
let _ = redirect_server.await;
}
}

View File

@@ -3,6 +3,7 @@
pub mod admin;
pub mod bridge;
pub mod events;
pub mod gifs;
pub mod git;
pub mod invites;
pub mod media;

View File

@@ -46,6 +46,30 @@ pub struct JoinPolicyConfig {
pub version: String,
}
/// Optional KLIPY GIF-search integration owned by the relay operator.
///
/// The API key deliberately stays private and its [`Debug`] implementation is
/// redacted so dumping [`Config`] cannot disclose it.
#[derive(Clone)]
pub struct KlipyConfig {
api_key: String,
}
impl KlipyConfig {
/// Return the key only to the outbound KLIPY client.
pub(crate) fn api_key(&self) -> &str {
&self.api_key
}
}
impl std::fmt::Debug for KlipyConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KlipyConfig")
.field("api_key", &"[REDACTED]")
.finish()
}
}
/// Maximum configured jitter, leaving ten seconds of the hard-drain budget for
/// WebSocket close-frame delivery after the final delayed cancellation.
pub const MAX_DRAIN_JITTER_MS: u64 = 20_000;
@@ -218,6 +242,10 @@ pub struct Config {
/// Default: `false`. Set via `BUZZ_ALLOW_NIP_OA_AUTH=true`.
pub allow_nip_oa_auth: bool,
/// Relay-owned KLIPY integration. Unset means GIF search is not advertised
/// and its proxy routes return 404.
pub klipy: Option<KlipyConfig>,
/// Media storage configuration (S3/MinIO).
pub media: buzz_media::MediaConfig,
/// Maximum concurrent media uploads handled by one relay process.
@@ -319,6 +347,10 @@ fn rate_limit_config_from_env() -> Result<buzz_auth::RateLimitConfig, ConfigErro
"BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN",
defaults.human_messages_per_min,
)?,
gif_searches_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN",
defaults.gif_searches_per_min,
)?,
human_api_calls_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN",
defaults.human_api_calls_per_min,
@@ -628,6 +660,12 @@ impl Config {
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let klipy = std::env::var("BUZZ_KLIPY_API_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|api_key| KlipyConfig { api_key });
// Note: intentionally not prefixed with BUZZ_ — this is a relay-identity
// config that may be shared across multiple services (e.g., ACP agent).
let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY")
@@ -1019,6 +1057,7 @@ impl Config {
relay_operator_api_origin,
relay_operator_pubkeys,
allow_nip_oa_auth,
klipy,
media,
media_max_concurrent_uploads,
media_max_concurrent_uploads_per_pubkey,
@@ -1049,6 +1088,17 @@ impl Config {
mod tests {
use super::*;
#[test]
fn klipy_config_debug_redacts_the_api_key() {
let config = KlipyConfig {
api_key: "private-klipy-key".to_string(),
};
let debug = format!("{config:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("private-klipy-key"));
}
// Mutex to serialize tests that mutate environment variables.
// Parallel env-var mutation causes `defaults_are_valid` to see the invalid
// value set by `invalid_bind_addr_returns_error`, causing a flaky failure.
@@ -1482,15 +1532,18 @@ mod tests {
fn rate_limits_can_be_overridden() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN", "1001");
std::env::set_var("BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN", "1004");
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN", "1002");
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "1003");
let config = Config::from_env().expect("config");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
assert_eq!(config.auth.rate_limits.human_messages_per_min, 1001);
assert_eq!(config.auth.rate_limits.gif_searches_per_min, 1004);
assert_eq!(config.auth.rate_limits.human_api_calls_per_min, 1002);
assert_eq!(config.auth.rate_limits.human_ws_events_per_sec, 1003);
}

View File

@@ -1563,6 +1563,7 @@ mod tests {
false,
crate::config::DEFAULT_MAX_FRAME_BYTES,
None,
None,
)
.limitation
.expect("limitation")

View File

@@ -52,11 +52,26 @@ pub struct RelayInfo {
/// Public WebSocket URL of the dedicated NIP-AB device-pairing relay.
#[serde(skip_serializing_if = "Option::is_none")]
pub pairing_relay_url: Option<String>,
/// Relay-owned GIF search integration. The descriptor is public and
/// provider-agnostic; provider credentials remain server-side.
#[serde(skip_serializing_if = "Option::is_none")]
pub gif: Option<GifDescriptor>,
/// Relay's own signing pubkey (NIP-11 `self` field, NIP-43).
#[serde(rename = "self", skip_serializing_if = "Option::is_none")]
pub relay_self: Option<String>,
}
/// Public capability descriptor for relay-proxied GIF search.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GifDescriptor {
/// Provider identifier understood by Buzz clients.
pub provider: String,
/// Relay-relative authenticated metadata search endpoint.
pub search: String,
/// Relay-relative authenticated share-reporting endpoint.
pub share: String,
}
/// Protocol and resource limits advertised in the NIP-11 document.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayLimitation {
@@ -138,12 +153,18 @@ impl RelayInfo {
/// gates on NIP-43 events — i.e. has a stable key AND enforces
/// membership. NIP-43 events are verified against `self`, so it is a
/// programmer error to advertise NIP-43 without a `relay_self`.
///
/// `gif_provider` is a config-derived provider identifier. When present,
/// `build` advertises the provider-agnostic `buzz-gif` extension and the
/// relay-relative metadata search endpoint. It must never contain a
/// provider credential.
pub fn build(
relay_self: Option<&str>,
icon: Option<&str>,
advertise_nip43: bool,
max_message_length: usize,
pairing_relay_url: Option<&str>,
gif_provider: Option<&str>,
) -> Self {
debug_assert!(
!advertise_nip43 || relay_self.is_some(),
@@ -155,6 +176,16 @@ impl RelayInfo {
supported_nips.push(NIP_RELAY_MEMBERSHIP);
}
let mut supported_extensions = vec!["nip-er".to_string()];
let gif = gif_provider.map(|provider| {
supported_extensions.push("buzz-gif".to_string());
GifDescriptor {
provider: provider.to_string(),
search: crate::api::gifs::SEARCH_PATH.to_string(),
share: crate::api::gifs::SHARE_PATH.to_string(),
}
});
Self {
name: "Buzz Relay".to_string(),
description: "Buzz — private team communication relay".to_string(),
@@ -162,12 +193,13 @@ impl RelayInfo {
pubkey: None,
contact: None,
supported_nips,
supported_extensions: Some(vec!["nip-er".to_string()]),
supported_extensions: Some(supported_extensions),
push: None,
software: "https://github.com/block/buzz".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
limitation: Some(relay_limitation(max_message_length)),
pairing_relay_url: pairing_relay_url.map(str::to_string),
gif,
relay_self: relay_self.map(|s| s.to_string()),
}
}
@@ -236,7 +268,8 @@ fn push_descriptor(
/// Centralised so the content-negotiated root handler and the dedicated
/// `/info` endpoint can't drift apart. Every input to `RelayInfo::build`
/// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the
/// host-scoped workspace icon.
/// host-scoped workspace icon. Optional provider capabilities are passed as
/// config-derived scalar identifiers; no provider credential enters NIP-11.
pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo {
let (relay_self, advertise_nip43) = nip11_facts(state);
let icon = workspace_icon_for_host(state, raw_host).await;
@@ -246,6 +279,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st
advertise_nip43,
state.config.max_frame_bytes,
state.config.pairing_relay_url.as_deref(),
state.config.klipy.as_ref().map(|_| "klipy"),
);
let tenant_host = if state.config.push_gateway_delivery_url.is_some() {
crate::tenant::bind_community(&state.db, raw_host)
@@ -337,6 +371,7 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
bool,
usize,
Option<&str>,
Option<&str>,
) -> RelayInfo = RelayInfo::build;
#[cfg(test)]
@@ -391,7 +426,7 @@ mod tests {
#[test]
fn build_advertises_buzz_repository_url() {
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert_eq!(info.software, "https://github.com/block/buzz");
}
@@ -403,6 +438,7 @@ mod tests {
false,
DEFAULT_MAX_FRAME_BYTES,
Some("wss://pairing.buzz.xyz"),
None,
);
let json = serde_json::to_value(&info).expect("serialize");
assert_eq!(
@@ -411,11 +447,40 @@ mod tests {
Some("wss://pairing.buzz.xyz")
);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
let json = serde_json::to_value(&info).expect("serialize");
assert!(json.get("pairing_relay_url").is_none());
}
#[test]
fn gif_descriptor_and_extension_are_config_gated_and_credential_free() {
let info = RelayInfo::build(
None,
None,
false,
DEFAULT_MAX_FRAME_BYTES,
None,
Some("klipy"),
);
let json = serde_json::to_value(&info).expect("serialize");
assert_eq!(json["gif"]["provider"], "klipy");
assert_eq!(json["gif"]["search"], "/gifs/search");
assert_eq!(json["gif"]["share"], "/gifs/share");
assert!(json["supported_extensions"]
.as_array()
.expect("extensions")
.contains(&serde_json::json!("buzz-gif")));
assert!(!json.to_string().contains("api_key"));
let unconfigured = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert!(unconfigured.gif.is_none());
assert!(!unconfigured
.supported_extensions
.expect("extensions")
.contains(&"buzz-gif".to_string()));
}
/// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard
/// `icon` field; no icon (or a cleared, empty icon) omits the field
/// entirely so the JSON matches pre-icon documents byte-for-byte.
@@ -427,6 +492,7 @@ mod tests {
false,
DEFAULT_MAX_FRAME_BYTES,
None,
None,
);
assert_eq!(
info.icon.as_deref(),
@@ -439,7 +505,7 @@ mod tests {
);
for icon in [None, Some("")] {
let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert!(info.icon.is_none());
let json = serde_json::to_value(&info).expect("serialize");
assert!(
@@ -459,7 +525,7 @@ mod tests {
#[test]
fn max_message_length_uses_configured_frame_limit() {
let info = RelayInfo::build(None, None, false, 262_144, None);
let info = RelayInfo::build(None, None, false, 262_144, None, None);
let limitation = info.limitation.expect("limitation");
assert_eq!(limitation.max_message_length, Some(262_144));
}
@@ -490,7 +556,7 @@ mod tests {
/// Open relay, ephemeral key — both `self` and NIP-43 are absent.
#[test]
fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert!(info.relay_self.is_none());
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
@@ -503,7 +569,7 @@ mod tests {
#[test]
fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert_eq!(info.relay_self.as_deref(), Some(pk));
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
@@ -512,7 +578,7 @@ mod tests {
#[test]
fn build_membership_relay_advertises_self_and_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None, None);
assert_eq!(info.relay_self.as_deref(), Some(pk));
assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
@@ -523,6 +589,6 @@ mod tests {
#[test]
#[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
fn build_nip43_without_self_panics_in_debug() {
let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None);
let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None, None);
}
}

View File

@@ -72,6 +72,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/events", post(api::bridge::submit_event))
.route("/query", post(api::bridge::query_events))
.route("/count", post(api::bridge::count_events))
// Relay-owned third-party GIF metadata proxy (NIP-98 auth).
.route(api::gifs::SEARCH_PATH, post(api::gifs::search))
.route(api::gifs::SHARE_PATH, post(api::gifs::share))
.route(
"/workflows/{workflow_id}/runs",
get(api::workflows::workflow_runs),

View File

@@ -722,6 +722,9 @@ pub struct AppState {
/// replace this with process-local caching; replay freshness must survive
/// cross-pod routing.
pub nip98_replay: Arc<dyn Nip98ReplayGuard>,
/// Shared HTTP client for relay-proxied GIF provider requests. Reusing the
/// connection pool avoids a fresh TLS handshake for every search/share.
pub gif_http_client: reqwest::Client,
/// Shared Redis-backed admission limits for ordinary HTTP and WebSocket work.
pub admission_rate_limiter: Arc<RedisRateLimiter>,
@@ -852,6 +855,7 @@ impl AppState {
);
let nip98_replay: Arc<dyn Nip98ReplayGuard> =
Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone()));
let gif_http_client = crate::api::gifs::build_gif_http_client();
let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone()));
let audit_enabled = audit_arc.is_some();
let state = Self {
@@ -912,6 +916,7 @@ impl AppState {
shutting_down: Arc::new(AtomicBool::new(false)),
started_at: Instant::now(),
nip98_replay,
gif_http_client,
admission_rate_limiter,
observer_rate_limiter: Arc::new(DashMap::new()),
media_upload_rate_limiter: Arc::new(DashMap::new()),

View File

@@ -44,6 +44,12 @@ The chart is designed for ArgoCD and Flux. Both render charts with `helm templat
Production deploys MUST use `secrets.existingSecret:`. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless.
To enable relay-proxied KLIPY search, add `BUZZ_KLIPY_API_KEY` to that Secret.
The key stays in the relay pod; clients discover the public `buzz-gif`
extension and `gif` descriptor in NIP-11, then receive KLIPY-hosted media URLs.
See [`docs/gif-search.md`](../../../docs/gif-search.md) for the protocol and
security boundaries.
See:
- [`examples/argocd-app.yaml`](examples/argocd-app.yaml) — ArgoCD Application

View File

@@ -12,6 +12,7 @@
# REDIS_URL — redis://... (required when replicaCount > 1)
# BUZZ_S3_ACCESS_KEY
# BUZZ_S3_SECRET_KEY
# BUZZ_KLIPY_API_KEY — omit to disable relay-proxied GIF search
apiVersion: v1
kind: Secret
metadata:
@@ -25,3 +26,4 @@ stringData:
REDIS_URL: "redis://:REPLACE@redis.buzz.svc.cluster.local:6379"
BUZZ_S3_ACCESS_KEY: "REPLACE"
BUZZ_S3_SECRET_KEY: "REPLACE"
BUZZ_KLIPY_API_KEY: "REPLACE"

View File

@@ -215,6 +215,12 @@ spec:
name: {{ include "buzz.envSecretName" . }}
key: BUZZ_S3_SECRET_KEY
optional: true
- name: BUZZ_KLIPY_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "buzz.envSecretName" . }}
key: BUZZ_KLIPY_API_KEY
optional: true
- name: BUZZ_HUDDLE_AUDIO_AVAILABLE
value: {{ include "buzz.huddleAudioAvailable" . | quote }}

View File

@@ -80,6 +80,27 @@ tests:
optional: true
template: templates/deployment.yaml
- it: Deployment env points BUZZ_KLIPY_API_KEY at existingSecret as optional
set:
relayUrl: wss://buzz.example.com
ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
externalPostgresql.url: postgres://u:p@h:5432/d
s3.endpoint: http://minio:9000
s3.accessKey: a
s3.secretKey: s
secrets.existingSecret: "buzz-secrets"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: BUZZ_KLIPY_API_KEY
valueFrom:
secretKeyRef:
name: buzz-secrets
key: BUZZ_KLIPY_API_KEY
optional: true
template: templates/deployment.yaml
- it: READ_DATABASE_URL stays optional against the chart-managed Secret
set:
relayUrl: wss://buzz.example.com

View File

@@ -94,6 +94,7 @@ ownerPubkey: ""
# REDIS_URL — full Redis URL with auth
# BUZZ_S3_ACCESS_KEY — S3 access key
# BUZZ_S3_SECRET_KEY — S3 secret key
# BUZZ_KLIPY_API_KEY — KLIPY GIF search key; omit to disable GIF search
secrets:
existingSecret: ""
# Inline overrides (NOT recommended for production; they land in values).

View File

@@ -82,11 +82,14 @@ type EmojiPickerProps = {
autoFocus?: boolean;
/** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */
onSelect: (emoji: string) => void;
/** Number of emoji columns. Defaults to the compact picker used elsewhere. */
perLine?: number;
};
export const EmojiPicker = React.memo(function EmojiPicker({
autoFocus = false,
onSelect,
perLine = 8,
}: EmojiPickerProps) {
const customEmoji = useCustomEmoji();
const custom = React.useMemo(
@@ -116,7 +119,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({
onSelect(value);
}
}}
perLine={8}
perLine={perLine}
previewPosition="bottom"
set="native"
skinTonePosition="search"

View File

@@ -610,6 +610,7 @@ export function ForumComposer({
) : undefined
}
formattingDisabled={Boolean(disabled || isSubmissionPending)}
gifMediaController={media}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={Boolean(isSending || isSubmissionPending)}

View File

@@ -0,0 +1,179 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
klipyGifAttachment,
klipyGifFilename,
normalizeKlipyGifs,
relayKlipyCapability,
} from "./api.ts";
const GIF_ASSET = {
url: "https://static.klipy.com/example.gif",
width: 640,
height: 360,
size: 42,
};
test("normalizeKlipyGifs selects a compact preview and medium GIF", () => {
const [gif] = normalizeKlipyGifs([
{
id: 7,
title: " Ship it ",
slug: "ship-it",
type: "gif",
file: {
md: { gif: GIF_ASSET },
sm: {
webp: {
url: "https://static.klipy.com/preview.webp",
width: 220,
height: 124,
size: 12,
},
},
},
},
]);
assert.equal(gif.title, "Ship it");
assert.equal(gif.original.url, GIF_ASSET.url);
assert.equal(gif.preview.url, "https://static.klipy.com/preview.webp");
assert.equal(gif.poster, null);
});
test("normalizeKlipyGifs carries a static jpg poster when present", () => {
const [gif] = normalizeKlipyGifs([
{
id: 8,
title: "Static poster",
slug: "static-poster",
type: "gif",
file: {
md: { gif: GIF_ASSET },
sm: {
jpg: {
url: "https://static.klipy.com/poster.jpg",
width: 220,
height: 124,
size: 8,
},
},
},
},
]);
assert.equal(gif.poster?.url, "https://static.klipy.com/poster.jpg");
});
test("normalizeKlipyGifs omits ads and malformed file records", () => {
const gifs = normalizeKlipyGifs([
{ id: 1, slug: "ad", type: "ad" },
{ id: 2, slug: "missing", type: "gif", file: {} },
]);
assert.deepEqual(gifs, []);
});
test("relayKlipyCapability requires safe search and share endpoints", () => {
assert.deepEqual(
relayKlipyCapability({
gif: {
provider: "klipy",
search: "/gifs/search",
share: "/gifs/share",
},
supported_extensions: ["nip-er", "buzz-gif"],
}),
{ searchPath: "/gifs/search", sharePath: "/gifs/share" },
);
assert.equal(relayKlipyCapability({}), null);
assert.equal(
relayKlipyCapability({
gif: {
provider: "another",
search: "/gifs/search",
share: "/gifs/share",
},
supported_extensions: ["buzz-gif"],
}),
null,
);
assert.equal(
relayKlipyCapability({
gif: { provider: "klipy", search: "/gifs/search" },
supported_extensions: ["buzz-gif"],
}),
null,
);
for (const path of [
"https://attacker.example/search",
"//attacker.example/search",
"/\\attacker.example/search",
"/%5c%5cattacker.example/search",
"/gifs/../admin",
"/gifs/%2e%2e/admin",
"/gifs/search?redirect=https://attacker.example",
"/gifs/search#fragment",
]) {
assert.equal(
relayKlipyCapability({
gif: {
provider: "klipy",
search: path,
share: "/gifs/share",
},
supported_extensions: ["buzz-gif"],
}),
null,
);
assert.equal(
relayKlipyCapability({
gif: {
provider: "klipy",
search: "/gifs/search",
share: path,
},
supported_extensions: ["buzz-gif"],
}),
null,
);
}
});
test("klipyGifFilename sanitizes provider slugs", () => {
const gif = {
id: 1,
original: GIF_ASSET,
poster: null,
preview: GIF_ASSET,
slug: " That's a wrap! ",
title: "That's a wrap",
};
const filename = klipyGifFilename(gif);
assert.equal(filename, "that-s-a-wrap.gif");
});
test("klipyGifAttachment references KLIPY media without an uploaded copy", () => {
const attachment = klipyGifAttachment({
id: 1,
original: GIF_ASSET,
poster: null,
preview: GIF_ASSET,
slug: "ship-it",
title: "Ship it",
});
assert.deepEqual(attachment, {
dim: "640x360",
displayLabel: "Ship it",
filename: "ship-it.gif",
sha256: "",
size: 42,
type: "image/gif",
uploaded: 0,
url: GIF_ASSET.url,
});
});

View File

@@ -0,0 +1,185 @@
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
type KlipyAsset = {
height?: number;
size?: number;
url?: string;
width?: number;
};
type KlipyFileSet = {
gif?: KlipyAsset;
jpg?: KlipyAsset;
webp?: KlipyAsset;
};
type KlipyRawGif = {
file?: {
hd?: KlipyFileSet;
md?: KlipyFileSet;
sm?: KlipyFileSet;
xs?: KlipyFileSet;
};
id?: number;
slug?: string;
title?: string;
type?: string;
};
export type KlipyResponse = {
data?: {
data?: KlipyRawGif[];
};
result?: boolean;
};
export type KlipyGif = {
id: number | null;
original: Required<KlipyAsset>;
/**
* Static (non-animated) poster for the GIF, when KLIPY exposes a `jpg`
* asset. Rendered in place of the animated preview under
* `prefers-reduced-motion: reduce`.
*/
poster: Required<KlipyAsset> | null;
preview: Required<KlipyAsset>;
slug: string;
title: string;
};
export type RelayGifSearchInfo = {
gif?: {
provider?: string;
search?: string;
share?: string;
};
supported_extensions?: string[];
};
export type RelayKlipyCapability = {
searchPath: string;
sharePath: string;
};
function safeRelayPath(path: unknown): path is string {
return (
typeof path === "string" &&
path.startsWith("/") &&
!path.startsWith("//") &&
!path.includes("\\") &&
!path.includes("%") &&
!path.includes("?") &&
!path.includes("#") &&
!path.split("/").some((segment) => segment === "." || segment === "..")
);
}
/** The safe relay-relative KLIPY endpoints advertised by NIP-11, if any. */
export function relayKlipyCapability(
info: RelayGifSearchInfo,
): RelayKlipyCapability | null {
const searchPath = info.gif?.search;
const sharePath = info.gif?.share;
if (
info.supported_extensions?.includes("buzz-gif") === true &&
info.gif?.provider === "klipy" &&
safeRelayPath(searchPath) &&
safeRelayPath(sharePath)
) {
return { searchPath, sharePath };
}
return null;
}
function isCompleteAsset(
asset: KlipyAsset | undefined,
): asset is Required<KlipyAsset> {
return (
typeof asset?.url === "string" &&
asset.url.length > 0 &&
typeof asset.width === "number" &&
typeof asset.height === "number" &&
typeof asset.size === "number"
);
}
function firstCompleteAsset(
...assets: Array<KlipyAsset | undefined>
): Required<KlipyAsset> | null {
return assets.find(isCompleteAsset) ?? null;
}
/**
* Normalize KLIPY's mixed media response to GIF-only results. The API can
* interleave ad/content records without a file payload; those are intentionally
* omitted until Buzz has an explicit third-party ad surface.
*/
export function normalizeKlipyGifs(items: KlipyRawGif[]): KlipyGif[] {
const gifs: KlipyGif[] = [];
for (const item of items) {
if (item.type !== "gif" || !item.file || !item.slug) continue;
const original = firstCompleteAsset(
item.file.md?.gif,
item.file.hd?.gif,
item.file.sm?.gif,
item.file.xs?.gif,
);
const preview = firstCompleteAsset(
item.file.sm?.webp,
item.file.sm?.gif,
item.file.xs?.webp,
item.file.xs?.gif,
item.file.md?.webp,
original ?? undefined,
);
if (!original || !preview) continue;
const poster = firstCompleteAsset(
item.file.sm?.jpg,
item.file.xs?.jpg,
item.file.md?.jpg,
item.file.hd?.jpg,
);
gifs.push({
id: item.id ?? null,
original,
poster,
preview,
slug: item.slug,
title: item.title?.trim() || "GIF",
});
}
return gifs;
}
export function klipyGifFilename(gif: KlipyGif): string {
const safeSlug = gif.slug
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return `${safeSlug || "klipy-gif"}.gif`;
}
/**
* Represent a selected KLIPY GIF as externally hosted media. The empty hash
* marks it as content-only media: the outgoing builder appends the image URL
* to the message body but deliberately omits an imeta tag, since Buzz relays
* only accept verified local `/media/` entries in imeta.
*/
export function klipyGifAttachment(gif: KlipyGif): ImetaMedia {
return {
dim: `${gif.original.width}x${gif.original.height}`,
displayLabel: gif.title,
filename: klipyGifFilename(gif),
sha256: "",
size: gif.original.size,
type: "image/gif",
uploaded: 0,
url: gif.original.url,
};
}

View File

@@ -0,0 +1,142 @@
import {
type KlipyGif,
type KlipyResponse,
normalizeKlipyGifs,
relayKlipyCapability,
type RelayKlipyCapability,
type RelayGifSearchInfo,
} from "@/features/gifs/api";
import { relayHttpFromWs } from "@/shared/api/inviteHelpers";
import { signRelayEvent } from "@/shared/api/tauri";
const KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX = "buzz:klipy-customer-id:v1:";
const NIP98_KIND = 27235;
function customerId(relayUrl: string): string {
if (typeof window === "undefined") return globalThis.crypto.randomUUID();
try {
const storageKey = `${KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX}${relayUrl}`;
const existing = window.localStorage.getItem(storageKey);
if (existing) return existing;
const created = globalThis.crypto.randomUUID();
window.localStorage.setItem(storageKey, created);
return created;
} catch {
// Storage can be unavailable in hardened webviews. Prefer an ephemeral ID
// over a process-wide fallback that would correlate unrelated relays.
return globalThis.crypto.randomUUID();
}
}
async function sha256Hex(text: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(text),
);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function nip98PostHeader(url: string, body: string): Promise<string> {
const authEvent = await signRelayEvent({
kind: NIP98_KIND,
content: "",
tags: [
["u", url],
["method", "POST"],
["payload", await sha256Hex(body)],
["nonce", crypto.randomUUID()],
],
});
return `Nostr ${btoa(JSON.stringify(authEvent))}`;
}
const FRIENDLY_GIF_ERRORS: Record<string, string> = {
relay_membership_required: "Join this community to search GIFs.",
};
function gifErrorMessage(error: string | undefined, status: number): string {
if (error && FRIENDLY_GIF_ERRORS[error]) return FRIENDLY_GIF_ERRORS[error];
return error || `GIF request failed (${status})`;
}
async function relayPost<T>(
relayUrl: string,
path: string,
payload: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}${path}`;
const body = JSON.stringify(payload);
const response = await fetch(url, {
body,
headers: {
Authorization: await nip98PostHeader(url, body),
"Content-Type": "application/json",
},
method: "POST",
signal,
});
if (!response.ok) {
const json = (await response.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(gifErrorMessage(json.error, response.status));
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
/** The selected relay's advertised KLIPY endpoints, when supported. */
export async function relayKlipyEndpoints(
relayUrl: string,
signal?: AbortSignal,
): Promise<RelayKlipyCapability | null> {
const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}/info`;
const response = await fetch(url, {
headers: { Accept: "application/nostr+json" },
signal,
});
if (!response.ok)
throw new Error(`Could not read relay capabilities (${response.status})`);
const info = (await response.json()) as RelayGifSearchInfo;
return relayKlipyCapability(info);
}
/** Search KLIPY through the selected relay without exposing its provider key. */
export async function fetchKlipyGifs(
relayUrl: string,
searchPath: string,
query: string,
signal?: AbortSignal,
): Promise<KlipyGif[]> {
const response = await relayPost<KlipyResponse>(
relayUrl,
searchPath,
{
customer_id: customerId(relayUrl),
locale: navigator.language || "en-US",
query: query.trim(),
},
signal,
);
if (response.result === false) {
throw new Error("GIF search failed");
}
return normalizeKlipyGifs(response.data?.data ?? []);
}
/** Report a selected GIF so KLIPY can update the anonymous user's Recents. */
export async function reportKlipyShare(
relayUrl: string,
sharePath: string,
slug: string,
): Promise<void> {
await relayPost<void>(relayUrl, sharePath, {
customer_id: customerId(relayUrl),
slug,
});
}

View File

@@ -0,0 +1,161 @@
import { useQuery } from "@tanstack/react-query";
import { LoaderCircle, Search } from "lucide-react";
import { useReducedMotion } from "motion/react";
import * as React from "react";
import type { KlipyGif } from "@/features/gifs/api";
import { fetchKlipyGifs } from "@/features/gifs/relay";
import { Input } from "@/shared/ui/input";
import { Skeleton } from "@/shared/ui/skeleton";
type KlipyGifPickerProps = {
onSelect: (gif: KlipyGif) => void;
relayUrl: string;
searchPath: string;
};
const LOADING_SKELETONS = [
"tall-a",
"short-a",
"short-b",
"tall-b",
"short-c",
"short-d",
"tall-c",
"short-e",
"short-f",
"tall-d",
] as const;
export const KlipyGifPicker = React.memo(function KlipyGifPicker({
onSelect,
relayUrl,
searchPath,
}: KlipyGifPickerProps) {
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const prefersReducedMotion = useReducedMotion() ?? false;
React.useEffect(() => {
const timeout = window.setTimeout(
() => setDebouncedSearch(search.trim()),
500,
);
return () => window.clearTimeout(timeout);
}, [search]);
const gifsQuery = useQuery({
queryFn: ({ signal }) =>
fetchKlipyGifs(relayUrl, searchPath, debouncedSearch, signal),
queryKey: ["klipy-gifs", relayUrl, searchPath, debouncedSearch],
retry: false,
staleTime: 5 * 60 * 1_000,
});
return (
<div className="flex h-[435px] w-[352px] flex-col bg-muted text-foreground">
<div className="p-2.5">
<div className="relative">
<Search
aria-hidden
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label="Search KLIPY"
autoFocus
className="h-9 bg-background/75 pl-8 pr-8 hover:bg-background/85 focus-visible:bg-background focus-visible:ring-ring/40"
onChange={(event) => setSearch(event.target.value)}
placeholder="Search KLIPY"
type="search"
value={search}
/>
{gifsQuery.isFetching ? (
<LoaderCircle
aria-hidden
className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground"
/>
) : null}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{gifsQuery.isPending ? (
<div className="grid grid-cols-2 gap-1.5">
<span className="sr-only">Loading GIFs</span>
{LOADING_SKELETONS.map((id) => (
<Skeleton
className={id.startsWith("tall") ? "h-28" : "h-20"}
key={id}
/>
))}
</div>
) : gifsQuery.isError ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<p className="text-sm text-muted-foreground">
{gifsQuery.error.message}
</p>
<button
className="text-sm font-medium text-primary hover:underline"
onClick={() => void gifsQuery.refetch()}
type="button"
>
Try again
</button>
</div>
) : gifsQuery.data.length === 0 ? (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
No GIFs found.
</div>
) : (
<div className="columns-2 gap-1.5" data-testid="klipy-gif-grid">
{gifsQuery.data.map((gif) => {
const staticPoster = prefersReducedMotion ? gif.poster : null;
const showAnimated = !prefersReducedMotion;
return (
<button
aria-label={`Choose ${gif.title}`}
className="mb-1.5 block w-full break-inside-avoid overflow-hidden rounded-lg bg-muted outline-hidden ring-offset-background transition-[filter,transform] hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-[0.98]"
key={gif.slug}
onClick={() => onSelect(gif)}
title={gif.title}
type="button"
>
{showAnimated ? (
<img
alt={gif.title}
className="block h-auto w-full"
height={gif.preview.height}
loading="lazy"
src={gif.preview.url}
width={gif.preview.width}
/>
) : staticPoster ? (
<img
alt={gif.title}
className="block h-auto w-full"
height={staticPoster.height}
loading="lazy"
src={staticPoster.url}
width={staticPoster.width}
/>
) : (
<span
className="flex aspect-video w-full items-center justify-center bg-muted px-2 text-center text-xs text-muted-foreground"
data-testid="klipy-gif-static-placeholder"
>
{gif.title}
</span>
)}
</button>
);
})}
</div>
)}
</div>
<div className="border-t border-border/60 px-3 py-2 text-center text-2xs text-muted-foreground">
Powered by KLIPY
</div>
</div>
);
});

View File

@@ -597,22 +597,31 @@ test("imetaMediaFromTags: entry without size leaves size 0", () => {
assert.equal(out[0].size, 0);
});
test("buildImetaTags: omits x line when sha256 is empty", () => {
test("buildImetaTags: omits hashless external media entirely", () => {
const tags = buildImetaTags([
{
url: "https://b/a.png",
type: "image/png",
url: "https://static.klipy.com/a.gif",
type: "image/gif",
sha256: "",
size: 1,
uploaded: 0,
},
]);
assert.equal(tags.length, 1);
// No element starts with "x " or "x\t" — no empty x line emitted.
assert.ok(
!tags[0].some((part) => /^x[\s\t]/.test(part)),
`expected no x line, got ${JSON.stringify(tags[0])}`,
);
assert.deepEqual(tags, []);
});
test("buildOutgoingMessage: hashless external media is content-only", () => {
const out = buildOutgoingMessage("", [
{
url: "https://static.klipy.com/a.gif",
type: "image/gif",
sha256: "",
size: 1,
uploaded: 0,
},
]);
assert.equal(out.content, "\n![image](https://static.klipy.com/a.gif)");
assert.equal(out.mediaTags, undefined);
});
test("buildImetaTags: omits size line when size is 0", () => {
@@ -632,31 +641,14 @@ test("buildImetaTags: omits size line when size is 0", () => {
);
});
test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", () => {
// Legacy / cross-client entry: only url + m. No x, no size.
test("round-trip: sparse legacy imeta is not re-emitted without a hash", () => {
const legacyTags = [["imeta", "url https://b/legacy.png", "m image/png"]];
const projected = imetaMediaFromTags(legacyTags);
assert.equal(projected.length, 1);
assert.equal(projected[0].sha256, "");
assert.equal(projected[0].size, 0);
const rebuilt = buildImetaTags(projected);
assert.equal(rebuilt.length, 1);
// Neither "x " nor "size 0" leaked into the rebuilt tag.
assert.ok(
!rebuilt[0].some((part) => /^x[\s\t]/.test(part)),
`expected no x line, got ${JSON.stringify(rebuilt[0])}`,
);
assert.ok(
!rebuilt[0].some((part) => /^size[\s\t]/.test(part)),
`expected no size line, got ${JSON.stringify(rebuilt[0])}`,
);
// url and m survived.
assert.deepEqual(rebuilt[0], [
"imeta",
"url https://b/legacy.png",
"m image/png",
]);
assert.deepEqual(buildImetaTags(projected), []);
});
const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"];

View File

@@ -79,29 +79,31 @@ export function imetaMediaFromTags(
* Shared by the send path (initial post) and the edit path (full new tag set
* on the edit event), so the two stay perfectly symmetric.
*
* `url` and `m` are always emitted (NIP-92's only de-facto required fields;
* `m` carries a fallback in `imetaMediaFromTags`). All other fields are
* conditional — including `x` and `size` — because legacy and cross-client
* imeta entries can land without a sha256 or size, and our relay validator
* rejects literal `"x "` / `"size 0"` empties. NIP-92 itself treats every
* field except `url` as optional, so dropping them is spec-clean.
* `url`, `m`, and `x` are emitted for verified relay-hosted media. Entries
* without a hash represent external content (for example KLIPY GIFs); their
* markdown URL remains in the message body, but they are omitted from imeta
* because Buzz's relay validator requires a hash-backed local `/media/` path.
* Other fields remain conditional so legacy entries do not emit invalid
* literal `"size 0"` values.
*/
export function buildImetaTags(
imetaMedia: ReadonlyArray<ImetaMedia>,
): string[][] {
return imetaMedia.map((d) => [
"imeta",
`url ${d.url}`,
`m ${d.type}`,
...(d.sha256 ? [`x ${d.sha256}`] : []),
...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []),
...(d.dim ? [`dim ${d.dim}`] : []),
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
...(d.filename ? [`filename ${d.filename}`] : []),
]);
return imetaMedia
.filter((d) => d.sha256.length > 0)
.map((d) => [
"imeta",
`url ${d.url}`,
`m ${d.type}`,
`x ${d.sha256}`,
...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []),
...(d.dim ? [`dim ${d.dim}`] : []),
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
...(d.filename ? [`filename ${d.filename}`] : []),
]);
}
const MEDIA_LINE_RE =
@@ -329,9 +331,11 @@ export function buildOutgoingMessage(
spoiler: spoileredMediaUrls.has(d.url),
});
}
const mediaTags =
pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined;
return { content, mediaTags };
const mediaTags = buildImetaTags(pendingImeta);
return {
content,
mediaTags: mediaTags.length > 0 ? mediaTags : undefined,
};
}
/**

View File

@@ -13,7 +13,6 @@ import {
X,
} from "lucide-react";
import type { BlobDescriptor } from "@/shared/api/tauri";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import {
@@ -226,7 +225,7 @@ function composerMediaStyle(): React.CSSProperties {
}
type MediaAttachmentItemProps = {
attachment: BlobDescriptor;
attachment: ImetaMedia;
isSpoilered: boolean;
onEditSave?: (url: string, bytes: Uint8Array) => Promise<void>;
onRemove: (url: string) => void;
@@ -266,6 +265,18 @@ const MediaAttachmentItem = React.forwardRef<
const hash = shortHash(attachment.sha256);
const isVideo = attachment.type.startsWith("video/");
// One accessible name for every control/label in this item. Provider media
// (e.g. KLIPY GIFs) carries a `displayLabel` but no content hash; ordinary
// uploads keep their historical type-aware `Attachment <hash>` /
// `Video attachment <hash>` name; only genuinely hashless non-provider media
// falls back to a filename.
const mediaLabel =
attachment.displayLabel?.trim() ||
(attachment.sha256
? isVideo
? `Video attachment ${hash}`
: `Attachment ${hash}`
: attachment.filename?.trim() || `Attachment ${hash}`);
const thumbUrl = attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: rewriteRelayUrl(attachment.url);
@@ -275,7 +286,11 @@ const MediaAttachmentItem = React.forwardRef<
? rewriteRelayUrl(attachment.thumb)
: undefined;
const canEdit = !isVideo && onEditSave !== undefined;
// Only Buzz-hosted uploads have a content hash. URL-only provider media
// must remain externally hosted instead of being copied into storage by the
// image editor's save path.
const canEdit =
!isVideo && onEditSave !== undefined && attachment.sha256.length === 64;
const canRevert =
!isVideo && onRevert !== undefined && originalUrl !== undefined;
@@ -338,40 +353,41 @@ const MediaAttachmentItem = React.forwardRef<
style={composerMediaStyle()}
>
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
<DialogPrimitive.Trigger asChild>
<div className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70">
{isVideo ? (
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
{videoPosterUrl ? (
<img
src={videoPosterUrl}
alt={`Video attachment ${hash}`}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-muted/80" />
)}
<div className="absolute inset-0 bg-black/15" />
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
<Play className="h-4 w-4 fill-white text-white" />
</div>
<DialogPrimitive.Trigger
aria-label={mediaLabel}
className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70"
>
{isVideo ? (
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
{videoPosterUrl ? (
<img
src={videoPosterUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-muted/80" />
)}
<div className="absolute inset-0 bg-black/15" />
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
<Play className="h-4 w-4 fill-white text-white" />
</div>
) : (
<img
src={thumbUrl}
alt={`Attachment ${hash}`}
className="h-full w-full object-cover"
/>
)}
{isSpoilered ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
data-composer-media-spoiler=""
>
<HatGlasses className="h-4 w-4" />
</div>
) : null}
</div>
</div>
) : (
<img
src={thumbUrl}
alt=""
className="h-full w-full object-cover"
/>
)}
{isSpoilered ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
data-composer-media-spoiler=""
>
<HatGlasses className="h-4 w-4" />
</div>
) : null}
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
@@ -387,7 +403,7 @@ const MediaAttachmentItem = React.forwardRef<
onEscapeKeyDown={handleEscapeKeyDown}
>
<DialogPrimitive.Title className="sr-only">
Attachment {hash} preview
{mediaLabel} preview
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size attachment preview. Press Escape or click outside to
@@ -401,7 +417,7 @@ const MediaAttachmentItem = React.forwardRef<
) : null}
{mode === "edit" && !isVideo ? (
<ComposerImageEditor
alt={`Attachment ${hash}`}
alt={mediaLabel}
src={rewriteRelayUrl(attachment.url)}
sourceUrl={attachment.url}
sourceType={attachment.type}
@@ -421,7 +437,7 @@ const MediaAttachmentItem = React.forwardRef<
/>
) : (
<img
alt={`Attachment ${hash}`}
alt=""
className={cn(
"relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain",
isSpoilered && "blur-2xl brightness-75",
@@ -515,7 +531,7 @@ const MediaAttachmentItem = React.forwardRef<
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
aria-label="Remove attachment"
aria-label={`Remove ${mediaLabel}`}
type="button"
onClick={() => onRemove(attachment.url)}
className={COMPOSER_MEDIA_REMOVE_CLASS}

View File

@@ -1,13 +1,22 @@
import { SmilePlus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Images, Smile, SmilePlus } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { klipyGifAttachment, type KlipyGif } from "@/features/gifs/api";
import { relayKlipyEndpoints, reportKlipyShare } from "@/features/gifs/relay";
import { KlipyGifPicker } from "@/features/gifs/ui/KlipyGifPicker";
import { useCommunities } from "@/features/communities/useCommunities";
import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type ComposerEmojiPickerProps = {
disabled?: boolean;
gifMediaController: Pick<MediaUploadController, "setPendingImeta">;
/** Called when the popover closes without an emoji selection (Escape,
* click-outside). Use this to restore focus to the editor. */
onClose?: () => void;
@@ -17,21 +26,64 @@ type ComposerEmojiPickerProps = {
open: boolean;
};
type ComposerPickerTab = "emoji" | "gifs";
const PICKER_TAB_ORDER: ComposerPickerTab[] = ["emoji", "gifs"];
const ACTIVE_TAB_OUTER_CORNER_RADIUS = "calc(var(--radius-2xl) - 0.375rem)";
export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
disabled = false,
gifMediaController,
onClose,
onEmojiSelect,
onOpenChange,
onTriggerMouseDown,
open,
}: ComposerEmojiPickerProps) {
const [pickerTab, setPickerTab] = React.useState<ComposerPickerTab>("emoji");
const shouldReduceMotion = useReducedMotion();
const { activeCommunity } = useCommunities();
const relayUrl = activeCommunity?.relayUrl ?? "";
const klipyCapabilityQuery = useQuery({
enabled: relayUrl.length > 0,
queryFn: ({ signal }) => relayKlipyEndpoints(relayUrl, signal),
queryKey: ["relay-capability", relayUrl, "klipy"],
retry: 1,
staleTime: 5 * 60 * 1_000,
});
const gifSearchPath = klipyCapabilityQuery.data?.searchPath ?? "";
const gifSharePath = klipyCapabilityQuery.data?.sharePath ?? "";
const gifsAvailable = gifSearchPath.length > 0 && gifSharePath.length > 0;
React.useEffect(() => {
if (!open) setPickerTab("emoji");
}, [open]);
const handleGifSelect = React.useCallback(
(gif: KlipyGif) => {
if (!relayUrl) return;
onOpenChange(false);
gifMediaController.setPendingImeta((current) => [
...current,
klipyGifAttachment(gif),
]);
void reportKlipyShare(relayUrl, gifSharePath, gif.slug).catch(() => {
// Sharing the GIF remains successful if best-effort Recents reporting
// fails; the authenticated relay endpoint keeps provider details hidden.
});
},
[gifMediaController.setPendingImeta, gifSharePath, onOpenChange, relayUrl],
);
return (
<Popover onOpenChange={onOpenChange} open={open}>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Insert emoji"
aria-label={
gifsAvailable ? "Insert emoji or GIF" : "Insert emoji"
}
data-testid="composer-emoji-button"
disabled={disabled}
onMouseDown={onTriggerMouseDown}
@@ -43,7 +95,9 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Insert emoji</TooltipContent>
<TooltipContent>
{gifsAvailable ? "Emoji and GIFs" : "Emoji"}
</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
@@ -64,7 +118,69 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
side="top"
sideOffset={10}
>
<EmojiPicker autoFocus onSelect={onEmojiSelect} />
{gifsAvailable ? (
<Tabs
className="w-[352px] overflow-hidden rounded-2xl bg-muted"
onValueChange={(value) => setPickerTab(value as ComposerPickerTab)}
value={pickerTab}
>
<TabsList className="relative isolate grid h-11 w-full grid-cols-2 overflow-hidden rounded-none border-b border-border/60 bg-muted p-1.5 text-muted-foreground">
<motion.div
animate={{
transform: `translateX(${PICKER_TAB_ORDER.indexOf(pickerTab) * 100}%)`,
}}
aria-hidden="true"
className="absolute bottom-1.5 left-1.5 top-1.5 z-0 rounded-md bg-background shadow"
initial={false}
style={{
borderTopLeftRadius:
pickerTab === "emoji"
? ACTIVE_TAB_OUTER_CORNER_RADIUS
: undefined,
borderTopRightRadius:
pickerTab === "gifs"
? ACTIVE_TAB_OUTER_CORNER_RADIUS
: undefined,
width: "calc((100% - 12px) / 2)",
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.18,
ease: [0.77, 0, 0.175, 1],
}
}
/>
<TabsTrigger
className="relative z-10 h-8 gap-1.5 bg-transparent shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:shadow-none"
value="emoji"
>
<Smile aria-hidden className="h-4 w-4" />
Emoji
</TabsTrigger>
<TabsTrigger
className="relative z-10 h-8 gap-1.5 bg-transparent shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:shadow-none"
value="gifs"
>
<Images aria-hidden className="h-4 w-4" />
GIFs
</TabsTrigger>
</TabsList>
<TabsContent className="m-0" value="emoji">
<EmojiPicker autoFocus onSelect={onEmojiSelect} perLine={9} />
</TabsContent>
<TabsContent className="m-0" value="gifs">
<KlipyGifPicker
onSelect={handleGifSelect}
relayUrl={relayUrl}
searchPath={gifSearchPath}
/>
</TabsContent>
</Tabs>
) : (
<EmojiPicker autoFocus onSelect={onEmojiSelect} perLine={9} />
)}
</PopoverContent>
</Popover>
);

View File

@@ -170,33 +170,32 @@ function MessageComposerImpl({
media.queuedAttachmentsRef.current.length === 0;
const ownsDropZone = mediaController === undefined;
const backgroundUpload = useBackgroundMediaUpload();
const { trackAuthoredContent: trackDraftAuthoredContent } =
useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
loadDraft: drafts.loadDraft,
persistDraft: drafts.persistDraft,
getMentionRefs: mentions.getDraftMentionRefs,
restoreMentionRefs: mentions.restoreDraftMentionRefs,
livePendingImeta: media.pendingImeta,
setPendingImeta: media.setPendingImeta,
getQueuedAttachments: () => media.queuedAttachmentsRef.current,
saveQueuedAttachmentsForDraft,
clearQueuedAttachments: media.clearQueuedAttachments,
restoreQueuedAttachments: media.restoreQueuedAttachments,
takeQueuedAttachmentsForDraft,
setContent: (content) => {
setComposerContent(content);
richText.setContent(content);
},
clearContent: () => {
setComposerContent("");
richText.clearContent();
},
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
});
const { trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
loadDraft: drafts.loadDraft,
persistDraft: drafts.persistDraft,
getMentionRefs: mentions.getDraftMentionRefs,
restoreMentionRefs: mentions.restoreDraftMentionRefs,
livePendingImeta: media.pendingImeta,
setPendingImeta: media.setPendingImeta,
getQueuedAttachments: () => media.queuedAttachmentsRef.current,
saveQueuedAttachmentsForDraft,
clearQueuedAttachments: media.clearQueuedAttachments,
restoreQueuedAttachments: media.restoreQueuedAttachments,
takeQueuedAttachmentsForDraft,
setContent: (content) => {
setComposerContent(content);
richText.setContent(content);
},
clearContent: () => {
setComposerContent("");
richText.clearContent();
},
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
media.setUploadState({ status: "idle" });
@@ -273,7 +272,7 @@ function MessageComposerImpl({
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onUpdate: ({ cursor, linkPreviewContent, text }) => {
trackDraftAuthoredContent(text);
trackAuthoredContent(text);
contentRef.current = text;
setComposerContentFromText(text);
setPreviewContent(linkPreviewContent);
@@ -969,6 +968,7 @@ function MessageComposerImpl({
editor={richText.editor}
extraActions={toolbarExtraActions}
formattingDisabled={composerDisabled}
gifMediaController={media}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={isSending || mentionSendFlow.isPreparingMentionSend}

View File

@@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/react";
import { AnimatePresence, motion } from "motion/react";
import { ALargeSmall, Paperclip, X } from "lucide-react";
import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import {
@@ -31,6 +32,7 @@ export const MessageComposerToolbar = React.memo(
editor,
extraActions,
formattingDisabled,
gifMediaController,
isEmojiPickerOpen,
isFormattingOpen,
isSending,
@@ -55,6 +57,7 @@ export const MessageComposerToolbar = React.memo(
editor: Editor | null;
extraActions?: React.ReactNode;
formattingDisabled: boolean;
gifMediaController: Pick<MediaUploadController, "setPendingImeta">;
isEmojiPickerOpen: boolean;
isFormattingOpen: boolean;
isSending: boolean;
@@ -210,6 +213,7 @@ export const MessageComposerToolbar = React.memo(
</Tooltip>
<ComposerEmojiPicker
disabled={composerDisabled}
gifMediaController={gifMediaController}
onClose={() => editor?.commands.focus()}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}

View File

@@ -1133,6 +1133,15 @@ test("drops an expanded DM after agent startup fails", async ({ page }) => {
await input.fill(retryMessage);
const retryBaseline = commandsAfterFailure.length;
// The first send left the cursor parked over the bottom-right error toast,
// which overlaps the send button. Sonner pauses its dismiss timer while the
// toaster is hovered, so move the cursor away and let the transient toast
// clear before retrying — otherwise the retry click is intercepted for the
// full timeout.
await page.mouse.move(0, 0);
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, {
timeout: 10_000,
});
await page.getByTestId("send-message").click();
await expect(page.getByTestId("chat-title")).toHaveText("charlie");

View File

@@ -75,7 +75,9 @@ test("image annotation overlay and editor controls", async ({ page }) => {
await page.getByRole("button", { name: "Attach file" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
await expect(
composer.getByRole("button", { name: "Attachment aaaa", exact: true }),
).toBeVisible();
await composer.getByTestId("composer-media-attachment").hover();
await expect(page.getByTestId("composer-attachment-annotate")).toBeVisible();
await waitForAnimations(page);
@@ -104,7 +106,9 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as
// Attach the original image via the mocked paperclip flow.
await page.getByRole("button", { name: "Attach file" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
await expect(
composer.getByRole("button", { name: "Attachment aaaa", exact: true }),
).toBeVisible();
// Open the composer lightbox.
await composer.getByTestId("composer-media-attachment").hover();
@@ -146,7 +150,9 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as
// Saving closes the lightbox; the composer thumbnail now shows the
// annotated image.
await expect(dialog).toHaveCount(0);
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
await expect(
composer.getByRole("button", { name: "Attachment bbbb", exact: true }),
).toBeVisible();
// The annotated PNG went through the real upload command.
const uploadCommandCount = await page.evaluate(
@@ -174,7 +180,9 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as
// Closing the dialog shows the (restored) original thumbnail.
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
await expect(
composer.getByRole("button", { name: "Attachment aaaa", exact: true }),
).toBeVisible();
});
test("spoiler marking survives drawing on the attachment", async ({ page }) => {
@@ -184,7 +192,9 @@ test("spoiler marking survives drawing on the attachment", async ({ page }) => {
await page.getByRole("button", { name: "Attach file" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment aaaa")).toBeVisible();
await expect(
composer.getByRole("button", { name: "Attachment aaaa", exact: true }),
).toBeVisible();
// Spoiler the attachment from its lightbox (media spoilers are
// per-attachment; the text spoiler control no longer affects media),
@@ -215,6 +225,8 @@ test("spoiler marking survives drawing on the attachment", async ({ page }) => {
await expect(page.getByRole("dialog")).toHaveCount(0);
// The annotated replacement is still marked as a spoiler.
await expect(composer.getByAltText("Attachment bbbb")).toBeVisible();
await expect(
composer.getByRole("button", { name: "Attachment bbbb", exact: true }),
).toBeVisible();
await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible();
});

View File

@@ -102,6 +102,6 @@ test("emoji picker tooltip dismisses when cursor leaves the trigger", async ({
await expectTooltipDismissesOnLeave(
page,
page.getByTestId("composer-emoji-button"),
"Insert emoji",
"Emoji",
);
});

View File

@@ -1879,6 +1879,394 @@ test("emoji picker inserts emoji into the draft and keeps focus in the composer"
await expect(input).toHaveText("Ship🚀 now");
});
test("relay GIF capability gates the composer picker", async ({ page }) => {
await page.route("http://localhost:3000/info", (route) =>
route.fulfill({
body: JSON.stringify({ supported_extensions: [] }),
contentType: "application/nostr+json",
}),
);
await page.goto("/");
await page.getByTestId("channel-general").click();
const pickerButton = page.getByTestId("composer-emoji-button");
await expect(pickerButton).toHaveAccessibleName("Insert emoji");
await pickerButton.click();
await expect(page.getByRole("tab", { name: "GIFs" })).toHaveCount(0);
});
test("relay GIF search selects content-only media and reports the share", async ({
page,
}) => {
const searchBodies: Array<{
customer_id: string;
locale: string;
query: string;
}> = [];
const shareBodies: Array<{ customer_id: string; slug: string }> = [];
const gifUrl = "https://static.klipy.com/e2e-ship-it.gif";
const previewUrl = "https://static.klipy.com/e2e-ship-it.webp";
await page.route("http://localhost:3000/info", (route) =>
route.fulfill({
body: JSON.stringify({
gif: {
provider: "klipy",
search: "/gifs/search",
share: "/gifs/share",
},
supported_extensions: ["buzz-gif"],
}),
contentType: "application/nostr+json",
}),
);
await page.route("http://localhost:3000/gifs/search", async (route) => {
searchBodies.push(JSON.parse(route.request().postData() ?? "{}"));
await route.fulfill({
body: JSON.stringify({
result: true,
data: {
data: [
{
id: null,
file: {
md: {
gif: { height: 180, size: 42, url: gifUrl, width: 320 },
},
sm: {
webp: {
height: 90,
size: 12,
url: previewUrl,
width: 160,
},
},
},
slug: "e2e-ship-it",
title: "Ship it",
type: "gif",
},
],
},
}),
contentType: "application/json",
});
});
await page.route("http://localhost:3000/gifs/share", async (route) => {
shareBodies.push(JSON.parse(route.request().postData() ?? "{}"));
await route.fulfill({ status: 204 });
});
await page.route("https://static.klipy.com/e2e-ship-it.*", (route) =>
route.fulfill({
body: '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="#7c3aed"/></svg>',
contentType: "image/svg+xml",
}),
);
await page.goto("/");
await page.getByTestId("channel-general").click();
const pickerButton = page.getByTestId("composer-emoji-button");
await expect(pickerButton).toHaveAccessibleName("Insert emoji or GIF");
await pickerButton.click();
await page.getByRole("tab", { name: "GIFs" }).click();
await expect.poll(() => searchBodies.map(({ query }) => query)).toContain("");
await expect(
page.getByRole("button", { name: "Choose Ship it" }),
).toBeVisible();
await page.getByRole("searchbox", { name: "Search KLIPY" }).fill("celebrate");
await expect
.poll(() => searchBodies.map(({ query }) => query))
.toContain("celebrate");
await page.getByRole("button", { name: "Choose Ship it" }).click();
await expect(page.getByTestId("composer-media-attachment")).toBeVisible();
await expect
.poll(() => shareBodies)
.toEqual([
{
customer_id: searchBodies.at(-1)?.customer_id,
slug: "e2e-ship-it",
},
]);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("composer-media-attachment")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate((expectedUrl) => {
return Boolean(
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
content?: string;
kind?: number;
}>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.some(
(event) => event.kind === 9 && event.content?.includes(expectedUrl),
),
);
}, gifUrl),
)
.toBe(true);
const matchingEvent = await page.evaluate((expectedUrl) => {
return (
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
content?: string;
kind?: number;
tags?: string[][];
}>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.find(
(event) => event.kind === 9 && event.content?.includes(expectedUrl),
);
}, gifUrl);
expect(matchingEvent?.content).toBe(`![image](${gifUrl})`);
expect(matchingEvent?.tags?.some((tag) => tag[0] === "imeta")).toBe(false);
});
async function routeGifMocks(page: import("@playwright/test").Page) {
const gifs = [
{
animated: "https://static.klipy.com/e2e-party.gif",
poster: "https://static.klipy.com/e2e-party.jpg",
slug: "e2e-party",
title: "Party parrot",
},
{
animated: "https://static.klipy.com/e2e-thumbs-up.gif",
poster: "https://static.klipy.com/e2e-thumbs-up.jpg",
slug: "e2e-thumbs-up",
title: "Thumbs up",
},
];
await page.route("http://localhost:3000/info", (route) =>
route.fulfill({
body: JSON.stringify({
gif: {
provider: "klipy",
search: "/gifs/search",
share: "/gifs/share",
},
supported_extensions: ["buzz-gif"],
}),
contentType: "application/nostr+json",
}),
);
await page.route("http://localhost:3000/gifs/search", (route) =>
route.fulfill({
body: JSON.stringify({
result: true,
data: {
data: gifs.map((gif) => ({
id: null,
file: {
md: {
gif: { height: 180, size: 42, url: gif.animated, width: 320 },
},
sm: {
jpg: { height: 90, size: 8, url: gif.poster, width: 160 },
webp: {
height: 90,
size: 12,
url: `${gif.animated.replace(/\.gif$/, ".webp")}`,
width: 160,
},
},
},
slug: gif.slug,
title: gif.title,
type: "gif",
})),
},
}),
contentType: "application/json",
}),
);
await page.route("http://localhost:3000/gifs/share", (route) =>
route.fulfill({ status: 204 }),
);
await page.route("https://static.klipy.com/e2e-*", (route) =>
route.fulfill({
body: '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="#7c3aed"/></svg>',
contentType: "image/svg+xml",
}),
);
return gifs;
}
async function openGifGrid(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("composer-emoji-button").click();
await page.getByRole("tab", { name: "GIFs" }).click();
await expect(page.getByTestId("klipy-gif-grid")).toBeVisible();
}
test("reduced-motion GIF grid renders static posters, not animated previews", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
const gifs = await routeGifMocks(page);
await openGifGrid(page);
const grid = page.getByTestId("klipy-gif-grid");
// Each GIF stays identifiable and selectable by its accessible name.
for (const gif of gifs) {
await expect(
grid.getByRole("button", { name: `Choose ${gif.title}` }),
).toBeVisible();
}
const sources = await grid
.locator("img")
.evaluateAll((imgs) =>
imgs.map((img) => (img as HTMLImageElement).getAttribute("src")),
);
// The static poster is shown; no animated preview URL enters the DOM.
expect(sources).toEqual(gifs.map((gif) => gif.poster));
for (const gif of gifs) {
expect(sources).not.toContain(gif.animated);
expect(sources.some((src) => src?.endsWith(".webp"))).toBe(false);
}
});
test("reduced-motion GIF grid falls back to a named static placeholder", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.route("http://localhost:3000/info", (route) =>
route.fulfill({
body: JSON.stringify({
gif: {
provider: "klipy",
search: "/gifs/search",
share: "/gifs/share",
},
supported_extensions: ["buzz-gif"],
}),
contentType: "application/nostr+json",
}),
);
// A GIF with no jpg asset: reduced motion must not fall back to animation.
await page.route("http://localhost:3000/gifs/search", (route) =>
route.fulfill({
body: JSON.stringify({
result: true,
data: {
data: [
{
id: null,
file: {
md: {
gif: {
height: 180,
size: 42,
url: "https://static.klipy.com/e2e-no-poster.gif",
width: 320,
},
},
sm: {
webp: {
height: 90,
size: 12,
url: "https://static.klipy.com/e2e-no-poster.webp",
width: 160,
},
},
},
slug: "e2e-no-poster",
title: "No poster clip",
type: "gif",
},
],
},
}),
contentType: "application/json",
}),
);
await page.route("http://localhost:3000/gifs/share", (route) =>
route.fulfill({ status: 204 }),
);
await openGifGrid(page);
const grid = page.getByTestId("klipy-gif-grid");
await expect(
grid.getByRole("button", { name: "Choose No poster clip" }),
).toBeVisible();
await expect(grid.getByTestId("klipy-gif-static-placeholder")).toHaveText(
"No poster clip",
);
await expect(grid.locator("img")).toHaveCount(0);
});
test("normal-motion GIF grid renders animated previews", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
const gifs = await routeGifMocks(page);
await openGifGrid(page);
const sources = await page
.getByTestId("klipy-gif-grid")
.locator("img")
.evaluateAll((imgs) =>
imgs.map((img) => (img as HTMLImageElement).getAttribute("src")),
);
// Animated `.webp` previews are used; static jpg posters stay out of the DOM.
expect(sources.every((src) => src?.endsWith(".webp"))).toBe(true);
for (const gif of gifs) {
expect(sources).not.toContain(gif.poster);
}
});
test("selected GIFs keep distinct accessible names in the composer and lightbox", async ({
page,
}) => {
const gifs = await routeGifMocks(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
// Select two differently titled GIFs.
for (const gif of gifs) {
await page.getByTestId("composer-emoji-button").click();
await page.getByRole("tab", { name: "GIFs" }).click();
await page
.getByTestId("klipy-gif-grid")
.getByRole("button", { name: `Choose ${gif.title}` })
.click();
}
const thumbnails = page.getByTestId("composer-media-attachment");
await expect(thumbnails).toHaveCount(2);
// Composer thumbnails carry distinct accessible names from the GIF titles.
for (const gif of gifs) {
await expect(
thumbnails.getByRole("button", { name: gif.title, exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: `Remove ${gif.title}` }),
).toHaveCount(1);
}
// Each lightbox dialog is titled by the same GIF name.
for (const gif of gifs) {
await thumbnails
.getByRole("button", { name: gif.title, exact: true })
.click();
await expect(
page.getByRole("dialog", { name: `${gif.title} preview` }),
).toBeVisible();
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
}
});
test("empty message cannot be sent", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();

View File

@@ -99,7 +99,9 @@ test("image attachments can be marked and sent as hidden spoilers", async ({
await page.getByRole("button", { name: "Attach file" }).click();
const composer = page.getByTestId("message-composer");
await expect(composer.getByAltText("Attachment cccc")).toBeVisible();
await expect(
composer.getByRole("button", { name: "Attachment cccc", exact: true }),
).toBeVisible();
// Media spoilers are toggled per-attachment from the lightbox.
await composer.getByTestId("composer-media-attachment").hover();
@@ -155,7 +157,9 @@ test("text spoiler stays usable while attachment upload is pending", async ({
);
await expect(
page.getByTestId("message-composer").getByAltText("Attachment cccc"),
page
.getByTestId("message-composer")
.getByRole("button", { name: "Attachment cccc", exact: true }),
).toBeVisible();
});

View File

@@ -233,7 +233,9 @@ async function openReviewWithPostedTimecode(
await page.getByRole("button", { name: "Attach file" }).click();
await expect(
page.getByTestId("message-composer").getByAltText("Video attachment bbbb"),
page
.getByTestId("message-composer")
.getByRole("button", { name: "Video attachment bbbb", exact: true }),
).toBeVisible();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
@@ -289,8 +291,12 @@ test("video upload previews use poster frames and inline videos open review mode
await page.getByRole("button", { name: "Attach file" }).click();
const composer = page.getByTestId("message-composer");
const composerPoster = composer.getByAltText("Video attachment bbbb");
await expect(composerPoster).toBeVisible();
const composerTrigger = composer.getByRole("button", {
name: "Video attachment bbbb",
exact: true,
});
await expect(composerTrigger).toBeVisible();
const composerPoster = composerTrigger.locator("img");
await expect(composerPoster).toHaveAttribute("src", POSTER_DATA_URL);
const box = await composerPoster.boundingBox();

41
docs/gif-search.md Normal file
View File

@@ -0,0 +1,41 @@
# Relay-proxied GIF search
Buzz relays can optionally provide GIF search without distributing a provider
credential to desktop clients. An operator configures `BUZZ_KLIPY_API_KEY` in
the relay's secret store. When present, the relay advertises this NIP-11 shape:
```json
{
"supported_extensions": ["buzz-gif"],
"gif": {
"provider": "klipy",
"search": "/gifs/search",
"share": "/gifs/share"
}
}
```
The descriptor is provider-agnostic so a relay can advertise another provider
or paths later. Clients must require `buzz-gif`, recognize the provider, and use
only safe relay-relative search and share paths.
`POST /gifs/search` requires NIP-98 authentication and relay membership. Its
per-pubkey, per-community Redis admission limit defaults to 30 requests per
minute and can be tuned with `BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN`. The relay
sends the provider credential upstream and returns only allowlisted successful
result data. Provider error bodies are never returned to clients or written to
logs.
`POST /gifs/share` uses the same authentication and membership boundary. It
accepts a bounded GIF slug plus anonymous customer ID and forwards KLIPY's share
signal so selected media can enter the user's provider-backed Recents. The
endpoint returns no provider body.
## Message and rendering boundary
Selecting a GIF sends a normal Buzz message containing a KLIPY CDN image URL.
Because Buzz's imeta validator permits only hash-verified local `/media/` paths,
external GIFs are deliberately content-only and carry no imeta tag. Buzz does
not download, cache, or store the GIF bytes. Existing image URL rendering
handles the message, including pasted GIF URLs on relays that do not advertise
`buzz-gif`. Only the picker is capability-gated.

View File

@@ -40,7 +40,7 @@ Conformance obligations:
| Surface | Today's observable behavior | Tenant source | Community-global vs operator-global | Required DB/index/RLS scope | Auth/fan-out/search effects | Single-community compatibility check | Open decision/test |
|---|---|---|---|---|---|---|---|
| Row zero: host binding | A user connects to one relay URL and all state they can observe belongs to that relay. | `resolve_host(connection.host)` before handler entry. | Community-global selector; operator only manages the host map. | `communities(host, id, signing_key, …)`; every scoped table references immutable `community_id`. | All auth, event, REST, media, git, search, pub/sub, and workflow paths consume `TenantContext`; host/token mismatch rejects generically. | One host maps to the default community; no client-visible protocol field changes. | Add model/prose gate that `ctx.community` is derived from host, not supplied by the client. |
| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs, a stable relay signing pubkey when configured, and the community's workspace icon (NIP-WP). | Host-derived community for community-specific facts; the workspace `icon` is pre-fetched as a scalar via `bind_community` (fail-open to absent on unmapped host); no other DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version may be shared; relay `self` for group/system/audit signing is per-community; `icon` is per-community intentionally-public presentation. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities: apart from the requesting host's own `icon`, no field varies by community, and an unmapped host's document carries no `icon`. NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs — host-scoped scalars (like `icon`) are passed in pre-derived. |
| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs, a stable relay signing pubkey when configured, the community's workspace icon (NIP-WP), and the config-derived `buzz-gif` descriptor when GIF search is configured. | Host-derived community for community-specific facts; the workspace `icon` is pre-fetched as a scalar via `bind_community` (fail-open to absent on unmapped host); GIF provider availability is passed as a credential-free config scalar; no other DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version and GIF provider configuration may be shared; relay `self` for group/system/audit signing is per-community; `icon` is per-community intentionally-public presentation. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities: apart from the requesting host's own `icon`, no field varies by community. The GIF descriptor reveals provider availability but never its credential; NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today and the opt-in GIF descriptor. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs — host-scoped or config-derived scalars (like `icon`, pairing URL, and GIF provider) are passed in pre-derived. |
| API tokens and NIP-98 replay | API/NIP-98 clients authenticate REST/media/git; API tokens may carry scopes and channel IDs; NIP-98 replay uses an in-process seen-set today. | Host-derived community plus token's stamped community; stamps must agree. | Community-global token namespace; operator-global only for deployment health/secrets. | `api_tokens` gains `community_id`; token hash uniqueness and lookup are `(community_id, token_hash)` or the token cryptographically embeds community and lookup verifies both. Channel claims must reference channels in the same community. | Replay seen-set key is `(community_id, event_id)` in shared HA storage or equivalent sticky routing; NIP-98 `u` URL host must match `req.community`. | Existing single-community tokens continue to authorize the same scopes/channels after backfill to default community. | HA gate: Redis/shared seen-set with atomic insert-if-absent and TTL ≥ replay window, or documented single-replica/sticky alternative. |
| Relay membership, pubkey allowlist, archived identities | `relay_members`, `pubkey_allowlist`, and `archived_identities` are relay-global gates over pubkeys. | Host-derived community for tenant access; operator context only for platform administration. | Community-global membership/allowlist/archive by default. Operator-global only for explicit platform ops tables that are never tenant-observable. | Add `community_id` to these tables; primary/unique keys become `(community_id, pubkey)` and indexes include `community_id`. | Membership errors remain generic. NIP-OA owner checks test owner membership in the same community. Identity archive requests cannot hide/archive a key in another community. | One default community preserves today's closed/open relay behavior and admin CLI semantics after commands target the default community. | Decide any future operator-global super-admin surface separately; do not reuse tenant membership tables for it. |
| Users, profiles, NIP-05, and user search | Kind:0 updates sync a `users` row; NIP-05 handles are unique; `/api/users/search` searches display name/NIP-05/pubkey. | Channel-less events use `req.community`; NIP-05 domain is the connected community host. | Community-global. Same pubkey can have one profile per community; users repost kind:0 in each community they join. | `users` gains `community_id`; keys/uniques are `(community_id, pubkey)`, `(community_id, lower(nip05_handle))`, and `(community_id, okta_user_id)` where applicable. Profile event replacement is scoped by `(community_id, pubkey)`. | Search and batch profile reads include `community_id`; NIP-05 lookup only resolves handles for the requested host/community. No cross-community profile inheritance. | Existing users backfill into the default community; profile APIs and CLI output stay unchanged. | Add tests for same pubkey with different profile/NIP-05 in two communities and for NIP-05 same local part on two hosts. |