From 0ae4c59630d2b9ac872937c17f1fb628d4d54964 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 30 Oct 2026 22:00:31 +0700 Subject: [PATCH] feat(ux): cordn status line, payment readiness and image compression --- src/components/ListingOffersPanel.tsx | 3 ++ src/components/PaymentReadinessNote.tsx | 2 +- src/components/crew/CordnNativePanel.tsx | 5 ++++ src/hooks/useUploadFile.ts | 38 +++++++++++++++++++++++- src/pages/CreateListingPage.tsx | 3 ++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/components/ListingOffersPanel.tsx b/src/components/ListingOffersPanel.tsx index 84cf76a..178b118 100644 --- a/src/components/ListingOffersPanel.tsx +++ b/src/components/ListingOffersPanel.tsx @@ -20,6 +20,7 @@ import { genUserName } from '@/lib/genUserName'; import type { MarketplaceItem } from '@/lib/types'; import { PayWithSats } from '@/components/PayWithSats'; import { MarkSoldDialog } from '@/components/MarkSoldDialog'; +import { PaymentReadinessNote } from '@/components/PaymentReadinessNote'; import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react'; import { useTranslation } from '@/contexts/language'; @@ -183,6 +184,8 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')} + {isOwner && } + {mine.map((thread) => { const latest = thread.latest; const other = latest.author === user.pubkey diff --git a/src/components/PaymentReadinessNote.tsx b/src/components/PaymentReadinessNote.tsx index 7e8b7e9..ccb6b15 100644 --- a/src/components/PaymentReadinessNote.tsx +++ b/src/components/PaymentReadinessNote.tsx @@ -58,7 +58,7 @@ export function PaymentReadinessNote() { {t('marketUi.paymentReadiness.notReadyBefore')}{' '} - + {t('marketUi.paymentReadiness.nostrProfile')} {' '} {t('marketUi.paymentReadiness.notReadyAfter')} diff --git a/src/components/crew/CordnNativePanel.tsx b/src/components/crew/CordnNativePanel.tsx index 009f536..a987ff4 100644 --- a/src/components/crew/CordnNativePanel.tsx +++ b/src/components/crew/CordnNativePanel.tsx @@ -814,6 +814,11 @@ export function CordnNativePanel({

{copy.subtitle}

+ {native.group && native.lastCheckedAt && ( +

+ {t('cordnUi.lastChecked')} {formatDistanceToNow(new Date(native.lastCheckedAt), { addSuffix: true })} +

+ )} {!isCommunity && (
diff --git a/src/hooks/useUploadFile.ts b/src/hooks/useUploadFile.ts index 90e3e27..d8e2400 100644 --- a/src/hooks/useUploadFile.ts +++ b/src/hooks/useUploadFile.ts @@ -47,6 +47,41 @@ function validateImage(file: File): void { } } +/** + * Resize/compress camera photos before upload. Most phone photos are far larger + * than the app ever displays; 1600px WebP cuts upload time and Blossom storage + * without touching GIFs or formats the WebView cannot decode. + */ +async function prepareImage(file: File): Promise { + const type = (file.type || '').toLowerCase(); + if (type === 'image/gif' || type === 'image/heic' || type === 'image/heif') return file; + if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return file; + try { + const bitmap = await createImageBitmap(file); + const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height)); + // Already small enough: keep the original bytes/format. + if (scale === 1 && file.size <= 1_200_000) { + bitmap.close(); + return file; + } + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const ctx = canvas.getContext('2d'); + if (!ctx) { bitmap.close(); return file; } + ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close(); + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/webp', 0.84); + }); + if (!blob || blob.size >= file.size) return file; + const base = file.name.replace(/\.[^.]+$/, '') || 'image'; + return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified }); + } catch { + return file; + } +} + function hostOf(url: string): string { try { return new URL(url).hostname; @@ -65,6 +100,7 @@ export function useUploadFile() { } validateImage(file); + const prepared = await prepareImage(file); const uploader = new BlossomUploader({ servers: UPLOAD_SERVERS, @@ -73,7 +109,7 @@ export function useUploadFile() { let tags; try { - tags = await uploader.upload(file); + tags = await uploader.upload(prepared); } catch (error) { throw new Error(uploadErrorMessage(error)); } diff --git a/src/pages/CreateListingPage.tsx b/src/pages/CreateListingPage.tsx index bdd1d0f..ba52044 100644 --- a/src/pages/CreateListingPage.tsx +++ b/src/pages/CreateListingPage.tsx @@ -21,6 +21,7 @@ import { useListingRestrictions, recordListingCreation } from '@/hooks/useListin import { LoginArea } from '@/components/auth/LoginArea'; import { Nip05Required } from '@/components/Nip05Required'; import { SpamFeePayment } from '@/components/SpamFeePayment'; +import { PaymentReadinessNote } from '@/components/PaymentReadinessNote'; import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types'; import { GEAR_CATEGORY_KEYS, GEAR_CONDITION_KEYS, GEAR_CONDITION_DESC_KEYS, PRICE_TYPE_KEYS, PRICE_TYPE_DESC_KEYS } from '@/lib/labelKeys'; import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react'; @@ -857,6 +858,8 @@ export default function CreateListingPage() { ))} + {listingType === 'selling' && } + {priceType === 'fixed' && (