Skip to content

Commit 85bbeeb

Browse files
committed
fix(example): stabilize attachment previews
1 parent aa75686 commit 85bbeeb

3 files changed

Lines changed: 176 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Keep web image bubbles fitted to their previews and replace attachment loading
6+
flashes with stable image and voice-message placeholders.
57
- Fix voice-message uploads and slide-to-cancel gestures in the Expo native
68
example, and show concise user-facing errors instead of Convex stack traces.
79

apps/example/app/message-bubble.tsx

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export function MessageBubble({
7070
.filter((part) => part.type === "text")
7171
.map((part) => (part.type === "text" ? part.text : ""))
7272
.join("\n");
73+
const hasImage =
74+
message.status === "published" &&
75+
message.parts.some(
76+
(part) =>
77+
part.type === "attachment" && part.mediaType.startsWith("image/"),
78+
);
7379

7480
async function copyText() {
7581
if (text) await navigator.clipboard.writeText(text);
@@ -87,7 +93,7 @@ export function MessageBubble({
8793
)}
8894
<div className="message-stack">
8995
<article
90-
className={`message ${mine ? "mine" : ""} ${message.status === "redacted" ? "deleted" : ""}`}
96+
className={`message ${mine ? "mine" : ""} ${hasImage ? "image-message" : ""} ${message.status === "redacted" ? "deleted" : ""}`}
9197
onContextMenu={(event) => {
9298
event.preventDefault();
9399
setMenuOpen(true);
@@ -309,11 +315,14 @@ function Attachment({
309315
partId,
310316
});
311317

312-
if (!url) {
318+
if (url === undefined) {
319+
return <AttachmentPlaceholder mediaType={mediaType} />;
320+
}
321+
if (url === null) {
313322
return (
314-
<div className="attachment-placeholder">
315-
<Skeleton className="attachment-skeleton" />
316-
<Skeleton className="attachment-skeleton-line" />
323+
<div className="attachment-unavailable">
324+
<FileText />
325+
<span>{fallbackText} is unavailable</span>
317326
</div>
318327
);
319328
}
@@ -346,6 +355,47 @@ function Attachment({
346355
);
347356
}
348357

358+
function AttachmentPlaceholder({ mediaType }: { mediaType: string }) {
359+
if (mediaType.startsWith("image/")) {
360+
return (
361+
<div
362+
className="attachment-placeholder image"
363+
role="status"
364+
aria-label="Loading image"
365+
>
366+
<Skeleton className="attachment-skeleton" />
367+
</div>
368+
);
369+
}
370+
371+
if (mediaType.startsWith("audio/")) {
372+
return (
373+
<div
374+
className="attachment-placeholder audio"
375+
role="status"
376+
aria-label="Loading voice message"
377+
>
378+
<Skeleton className="attachment-skeleton-audio-button" />
379+
<div className="attachment-skeleton-audio-track">
380+
<Skeleton className="attachment-skeleton-audio-label" />
381+
<Skeleton className="attachment-skeleton-audio-line" />
382+
<Skeleton className="attachment-skeleton-audio-time" />
383+
</div>
384+
</div>
385+
);
386+
}
387+
388+
return (
389+
<div
390+
className="attachment-placeholder file"
391+
role="status"
392+
aria-label="Loading attachment"
393+
>
394+
<Skeleton className="attachment-skeleton-line" />
395+
</div>
396+
);
397+
}
398+
349399
function ImageAttachment({
350400
fallbackText,
351401
message,
@@ -361,6 +411,9 @@ function ImageAttachment({
361411
}) {
362412
const [open, setOpen] = useState(false);
363413
const [showReactions, setShowReactions] = useState(false);
414+
const [loadState, setLoadState] = useState<"loading" | "loaded" | "error">(
415+
"loading",
416+
);
364417
const replyAfterClose = useRef(false);
365418

366419
function closeAndReply() {
@@ -381,8 +434,30 @@ function ImageAttachment({
381434
className="message-image-trigger"
382435
type="button"
383436
aria-label={`Open ${fallbackText}`}
437+
disabled={loadState !== "loaded"}
384438
>
385-
<img className="message-image" src={url} alt={fallbackText} />
439+
{loadState === "loading" && (
440+
<div className="message-image-loading" aria-hidden="true">
441+
<Skeleton />
442+
</div>
443+
)}
444+
{loadState === "error" && (
445+
<span className="message-image-error">Image unavailable</span>
446+
)}
447+
<img
448+
className={`message-image ${loadState === "loaded" ? "loaded" : ""}`}
449+
src={url}
450+
alt={fallbackText}
451+
decoding="async"
452+
onLoad={(event) => {
453+
const image = event.currentTarget;
454+
void image
455+
.decode()
456+
.catch(() => undefined)
457+
.then(() => setLoadState("loaded"));
458+
}}
459+
onError={() => setLoadState("error")}
460+
/>
386461
</button>
387462
</DialogTrigger>
388463
<DialogContent

apps/example/app/styles.css

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,9 @@ select {
496496
background: #191916;
497497
box-shadow: 0 8px 24px rgb(0 0 0 / 16%);
498498
}
499+
.message.image-message {
500+
width: 444px;
501+
}
499502
.message.mine {
500503
border-color: rgb(255 91 53 / 23%);
501504
border-radius: 14px 14px 5px 14px;
@@ -646,12 +649,19 @@ select {
646649
.message-image {
647650
display: block;
648651
width: 100%;
649-
max-height: 360px;
652+
height: 100%;
650653
border-radius: 10px;
651654
object-fit: cover;
655+
opacity: 0;
656+
transition: opacity 140ms ease-out;
657+
}
658+
.message-image.loaded {
659+
opacity: 1;
652660
}
653661
.message-image-trigger {
662+
position: relative;
654663
width: min(100%, 420px);
664+
aspect-ratio: 16 / 9;
655665
display: block;
656666
margin: -3px -5px 7px;
657667
padding: 0;
@@ -661,6 +671,25 @@ select {
661671
cursor: zoom-in;
662672
overflow: hidden;
663673
}
674+
.message-image-trigger:disabled {
675+
cursor: default;
676+
}
677+
.message-image-loading,
678+
.message-image-loading > div,
679+
.message-image-error {
680+
position: absolute;
681+
inset: 0;
682+
}
683+
.message-image-loading > div {
684+
border-radius: 10px;
685+
}
686+
.message-image-error {
687+
display: grid;
688+
place-items: center;
689+
color: rgb(255 255 255 / 48%);
690+
background: rgb(0 0 0 / 14%);
691+
font-size: 12px;
692+
}
664693
.message-image-trigger:focus-visible {
665694
outline: 2px solid var(--orange);
666695
outline-offset: 3px;
@@ -762,19 +791,69 @@ select {
762791
}
763792
}
764793
.attachment-placeholder {
765-
width: 270px;
766-
display: grid;
767-
gap: 8px;
768-
padding: 5px;
794+
max-width: 100%;
795+
}
796+
.attachment-placeholder.image {
797+
width: 420px;
798+
aspect-ratio: 16 / 9;
799+
margin: -3px -5px 7px;
769800
}
770-
.attachment-skeleton {
771-
height: 92px;
801+
.attachment-placeholder.image .attachment-skeleton {
802+
width: 100%;
803+
height: 100%;
772804
border-radius: 9px;
773805
}
806+
.attachment-placeholder.audio {
807+
width: min(310px, 70vw);
808+
min-width: 0;
809+
height: 50px;
810+
display: grid;
811+
grid-template-columns: 34px minmax(0, 1fr);
812+
align-items: center;
813+
gap: 11px;
814+
}
815+
.attachment-skeleton-audio-button {
816+
width: 34px;
817+
height: 34px;
818+
border-radius: 50%;
819+
}
820+
.attachment-skeleton-audio-track {
821+
min-width: 0;
822+
display: grid;
823+
gap: 6px;
824+
}
825+
.attachment-skeleton-audio-label {
826+
width: 44%;
827+
height: 9px;
828+
}
829+
.attachment-skeleton-audio-line {
830+
width: 100%;
831+
height: 3px;
832+
}
833+
.attachment-skeleton-audio-time {
834+
width: 24px;
835+
height: 8px;
836+
}
837+
.attachment-placeholder.file {
838+
width: 270px;
839+
padding: 10px 5px;
840+
}
774841
.attachment-skeleton-line {
775842
width: 48%;
776843
height: 8px;
777844
}
845+
.attachment-unavailable {
846+
width: min(290px, 100%);
847+
display: flex;
848+
align-items: center;
849+
gap: 8px;
850+
margin: 2px 0 5px;
851+
color: rgb(255 255 255 / 48%);
852+
font-size: 12px;
853+
}
854+
.attachment-unavailable svg {
855+
width: 15px;
856+
}
778857
.message-attachment {
779858
width: min(290px, 100%);
780859
justify-content: flex-start;
@@ -801,7 +880,8 @@ select {
801880
align-items: center;
802881
gap: 11px;
803882
}
804-
.message:has(.audio-player) .message-meta {
883+
.message:has(.audio-player) .message-meta,
884+
.message:has(.attachment-placeholder.audio) .message-meta {
805885
position: absolute;
806886
right: 12px;
807887
bottom: 8px;
@@ -1182,6 +1262,9 @@ select {
11821262
.audio-player {
11831263
width: min(265px, 68vw);
11841264
}
1265+
.attachment-placeholder.audio {
1266+
width: min(265px, 68vw);
1267+
}
11851268
}
11861269

11871270
@media (prefers-reduced-motion: reduce) {
@@ -1191,7 +1274,8 @@ select {
11911274
animation: none;
11921275
}
11931276
.conversation,
1194-
.message-hover-actions {
1277+
.message-hover-actions,
1278+
.message-image {
11951279
transition: none;
11961280
}
11971281
}

0 commit comments

Comments
 (0)