feat(market): embed offer photos inline instead of a media server

Replace the Blossom media-server approach with a fully decentralized one:
generate a small (~320px) JPEG thumbnail from the lot's cover photo and
embed it in the offer event as a data: URI, so photos ride the relays with
no server and no IP leak. The full photo stays in the encrypted inventory.

- offer_thumbnail.dart: downscale-until-it-fits data-URI builder + decoder
- market_widgets: render data: URIs from memory, http(s) URLs from network
- offers_cubit: publish a thumbnail (best-effort) in place of an upload
- drop MediaTransport/Blossom from commons_core (http/crypto deps) and the
  media-server setting; parked on branch parked/offer-photos-blossom

Relay event-size limits cap the image to a thumbnail; higher-res sharing
would need the parked Blossom path.
This commit is contained in:
vjrj 2026-07-10 21:27:59 +02:00
parent fa53295439
commit 9109581be0
24 changed files with 230 additions and 411 deletions

View file

@ -8,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/variety_repository.dart';
import '../services/offer_mapper.dart';
import '../services/offer_outbox.dart';
import '../services/offer_thumbnail.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
@ -137,24 +138,24 @@ class OffersState extends Equatable {
class OffersCubit extends Cubit<OffersState> {
OffersCubit(
this._transport, {
MediaTransport? media,
Future<Uint8List?> Function(String varietyId)? coverPhoto,
String? Function(Uint8List bytes)? thumbnail,
Future<void> Function()? onDispose,
}) : _media = media,
_coverPhoto = coverPhoto,
}) : _coverPhoto = coverPhoto,
_thumbnail = thumbnail,
_onDispose = onDispose,
super(const OffersState());
final OfferTransport? _transport;
/// Hosts an offer's cover photo so peers can see it; null when no media server
/// is configured, in which case offers publish without images.
final MediaTransport? _media;
/// Fetches a variety's cover photo bytes (for hosting); null in tests or when
/// no inventory repo is wired.
/// Fetches a variety's cover photo bytes; null in tests or when no inventory
/// repo is wired.
final Future<Uint8List?> Function(String varietyId)? _coverPhoto;
/// Turns full photo bytes into a small `data:` thumbnail embedded in the offer
/// (no media server). Null offers publish without a photo.
final String? Function(Uint8List bytes)? _thumbnail;
/// Closes the owning [SocialSession]/connection when the cubit is disposed
/// (null in tests, where the transport is a fake with nothing to close).
final Future<void> Function()? _onDispose;
@ -284,7 +285,7 @@ class OffersCubit extends Cubit<OffersState> {
areaGeohash: areaGeohash,
category: lot.category,
isOrganic: lot.isOrganic,
imageUrl: await _hostCoverPhoto(lot.varietyId),
imageUrl: await _coverThumbnail(lot.varietyId),
);
final result = await transport.publish(offer);
if (result.accepted) accepted++;
@ -297,18 +298,17 @@ class OffersCubit extends Cubit<OffersState> {
return accepted;
}
/// Uploads the lot's cover photo to the media server and returns its URL, or
/// null when there's no media server, no photo, or the upload fails. Hosting
/// is best-effort: a missing image never blocks publishing the offer.
Future<String?> _hostCoverPhoto(String varietyId) async {
final media = _media;
/// Builds a small inline `data:` thumbnail from the lot's cover photo to embed
/// in the offer, or null when there's no photo, no thumbnailer, or it can't be
/// shrunk to fit. Best-effort: a missing image never blocks publishing.
Future<String?> _coverThumbnail(String varietyId) async {
final coverPhoto = _coverPhoto;
if (media == null || coverPhoto == null) return null;
final thumbnail = _thumbnail;
if (coverPhoto == null || thumbnail == null) return null;
try {
final Uint8List? bytes = await coverPhoto(varietyId);
if (bytes == null || bytes.isEmpty) return null;
final url = await media.upload(bytes, mimeType: 'image/jpeg');
return url.toString();
return thumbnail(bytes);
} catch (_) {
return null; // degrade: publish the offer without a photo
}
@ -334,13 +334,11 @@ Future<OffersCubit> createOffersCubit(
final relays = await settings.relayUrls();
if (relays.isEmpty) return OffersCubit(null);
try {
final mediaServer = await settings.mediaServerUrl();
final session =
await social.openSession(relays, mediaServerUrl: mediaServer);
final session = await social.openSession(relays);
return OffersCubit(
session.offers,
media: session.media,
coverPhoto: repository?.coverPhotoFor,
thumbnail: offerThumbnailDataUri,
onDispose: session.close,
);
} catch (_) {