The source code for our eny.social landing page, which is mirrored in a different repository as part of the CI setup. eny.social
social-network eny local-first
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(RememberWhen): implement transitions and migrate to WebP

+45 -31
+45 -31
app/components/RememberWhen.tsx
··· 7 7 8 8 // Active GIF duration per slot (must match `items` order). 9 9 // Adjust these values to control how long each GIF stays active. 10 - const GIF_INTERVALS_MS = [6250, 6350, 6700] as const; 10 + const GIF_INTERVALS_MS = [3000, 3000, 3000] as const; 11 + const FADE_MS = 600; 11 12 12 13 export default function RememberWhen() { 13 14 const items = useMemo( 14 15 () => [ 15 16 { 16 - src: "/images/gif/hug.gif", 17 + staticSrc: "/images/webp/money-lovers.webp", 18 + animatedSrc: "/images/webp/money-lovers.webp", 17 19 mask: "mask-1", 18 20 position: "bottom" as const, 19 21 }, 20 22 { 21 - src: "/images/gif/dance.gif", 23 + staticSrc: "/images/webp/dancer.webp", 24 + animatedSrc: "/images/webp/dancer.webp", 22 25 mask: "mask-2", 23 26 position: "center" as const, 24 27 }, 25 28 { 26 - src: "/images/gif/hands-up.gif", 29 + staticSrc: "/images/webp/cute-dog.webp", 30 + animatedSrc: "/images/webp/cute-dog.webp", 27 31 mask: "mask-3", 28 32 position: "top" as const, 29 33 }, ··· 32 36 ); 33 37 34 38 const [activeIndex, setActiveIndex] = useState(0); 39 + const [overlayOpacity, setOverlayOpacity] = useState(1); 35 40 const [staticFrameByIndex, setStaticFrameByIndex] = useState< 36 41 Record<number, string> 37 42 >({}); ··· 40 45 const durationMs = 41 46 GIF_INTERVALS_MS[activeIndex] ?? GIF_INTERVALS_MS[0] ?? 6000; 42 47 43 - const timeoutId = window.setTimeout(() => { 48 + // Fade in at start, fade out near the end for smooth transitions. 49 + // Use timeouts so state updates happen asynchronously (avoids cascading render warning). 50 + const fadeInStartId = window.setTimeout(() => setOverlayOpacity(0), 0); 51 + const fadeInId = window.setTimeout(() => setOverlayOpacity(1), 30); 52 + const fadeOutAt = Math.max(0, durationMs - FADE_MS); 53 + const fadeOutId = window.setTimeout(() => setOverlayOpacity(0), fadeOutAt); 54 + 55 + const nextId = window.setTimeout(() => { 44 56 setActiveIndex((prev) => (prev + 1) % items.length); 45 57 }, durationMs); 46 58 47 - return () => window.clearTimeout(timeoutId); 59 + return () => { 60 + window.clearTimeout(fadeInStartId); 61 + window.clearTimeout(fadeInId); 62 + window.clearTimeout(fadeOutId); 63 + window.clearTimeout(nextId); 64 + }; 48 65 }, [activeIndex, items.length]); 49 66 50 67 useEffect(() => { ··· 69 86 const ctx = canvas.getContext("2d"); 70 87 if (!ctx) return; 71 88 72 - // Capture immediately after load: in most browsers the first frame is available. 73 89 ctx.drawImage(img, 0, 0); 74 90 75 91 try { ··· 77 93 if (cancelled) return; 78 94 setStaticFrameByIndex((prev) => ({ ...prev, [index]: dataUrl })); 79 95 } catch { 80 - // Canvas is tainted (CORS) or similar. Just don't set a static frame. 96 + // Canvas is tainted (CORS) or similar. Keep using item.staticSrc. 81 97 } 82 98 }; 83 99 84 - items.forEach((it, i) => { 85 - void captureFirstFrame(it.src, i); 100 + items.forEach((item, i) => { 101 + void captureFirstFrame(item.animatedSrc, i); 86 102 }); 87 103 88 104 return () => { ··· 139 155 className="relative w-[28vw] h-[32vw] max-w-[263px] max-h-[300px] overflow-hidden" 140 156 style={{ clipPath: `url(#${item.mask})` }} 141 157 > 142 - {activeIndex === i ? ( 143 - <img 144 - src={item.src} 145 - alt="" 146 - className="h-full w-full object-cover" 147 - style={{ objectPosition: item.position }} 148 - draggable={false} 149 - /> 150 - ) : staticFrameByIndex[i] ? ( 151 - <img 152 - src={staticFrameByIndex[i]} 153 - alt="" 154 - className="h-full w-full object-cover" 155 - style={{ objectPosition: item.position }} 156 - draggable={false} 157 - /> 158 - ) : ( 159 - // Hide the GIF while the static first-frame snapshot loads. 158 + {/* Static base (always visible) */} 159 + <img 160 + src={staticFrameByIndex[i] ?? item.staticSrc} 161 + alt="" 162 + className="absolute inset-0 h-full w-full object-cover" 163 + style={{ objectPosition: item.position }} 164 + draggable={false} 165 + /> 166 + 167 + {/* Animated overlay (only one animates at a time) */} 168 + {activeIndex === i && ( 160 169 <img 161 - src={item.src} 170 + src={item.animatedSrc} 162 171 alt="" 163 - className="h-full w-full object-cover" 164 - style={{ objectPosition: item.position, opacity: 0 }} 172 + className="absolute inset-0 h-full w-full object-cover" 173 + style={{ 174 + objectPosition: item.position, 175 + opacity: overlayOpacity, 176 + transition: `opacity ${FADE_MS}ms ease-in-out`, 177 + willChange: "opacity", 178 + }} 165 179 draggable={false} 166 180 /> 167 181 )}
public/images/gif/dance.gif

This is a binary file and will not be displayed.

public/images/gif/hands-up.gif

This is a binary file and will not be displayed.

public/images/gif/hug.gif

This is a binary file and will not be displayed.

public/images/webp/cute-dog.webp

This is a binary file and will not be displayed.

public/images/webp/dancer.webp

This is a binary file and will not be displayed.

public/images/webp/money-lovers.webp

This is a binary file and will not be displayed.