Lightbox

Chat

View as Markdown

A message thread with a +N album. Every photo in the chat opens in one viewer, and the chat scrolls along behind it.

Today 18:01

Made it to the coast!

No way. More please

The road down was unreal

Meanwhile, my view

Ha. Tomorrow: waterfalls

import { cn } from 'cn'
import { landscapes, type Photo, portraits } from '../../data/photos'
import { Lightbox, useLightbox, Viewer } from '../lightbox'

type Message = { from: 'me' | 'them'; text?: string; photos?: Photo[]; time?: string }

const messages: Message[] = [
  { from: 'them', text: 'Made it to the coast!' },
  { from: 'them', photos: [landscapes[0]!], time: '18:02' },
  { from: 'me', text: 'No way. More please' },
  {
    from: 'them',
    photos: [2, 4, 5, 1, 3, 6, 8].map((i) => landscapes[i]!),
    time: '18:05',
  },
  { from: 'them', text: 'The road down was unreal' },
  { from: 'me', text: 'Meanwhile, my view' },
  { from: 'me', photos: [landscapes[10]!], time: '18:09' },
  { from: 'them', text: 'Ha. Tomorrow: waterfalls' },
  { from: 'them', photos: [portraits[3]!, portraits[0]!], time: '18:12' },
]

// One lightbox for the whole thread: swipe through every photo in the conversation, and the chat scrolls behind.
const items = messages.flatMap((m) =>
  (m.photos ?? []).map((photo) => ({ ...photo, caption: `${m.from === 'me' ? 'You' : 'Mira'} ยท Today ${m.time}` })),
)
const indexOf = (photo: Photo) => items.findIndex((item) => item.src === photo.src)

function Bubble({ from, text, photos = [] }: Message) {
  const { index: current, phase } = useLightbox()
  const mine = from === 'me'
  return (
    <div className={cn('flex', mine && 'justify-end')}>
      {text ? (
        <p
          className={cn(
            'max-w-[75%] rounded-[18px] px-3 py-1.5',
            mine ? 'bg-blue-500 text-white' : 'bg-neutral-200 dark:bg-neutral-800',
          )}
        >
          {text}
        </p>
      ) : (
        // Every photo carries its own radius, so the morph starts and ends on the exact corner. Past four, the rest
        // hide behind a "+N" tile, which takes the index of whichever hidden photo is open: closing shrinks into it.
        <div className={cn('grid w-[70%] gap-1', photos.length > 1 && 'grid-cols-2')}>
          {photos.slice(0, 4).map((photo, i) => {
            const first = indexOf(photo)
            const hidden = i === 3 && phase !== 'closed' && current > first && current < first + photos.length - 3
            return (
              <Lightbox.Trigger
                key={photo.src}
                index={hidden ? current : first}
                className={cn(
                  'group relative block overflow-hidden bg-neutral-100 outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:bg-neutral-900',
                  photos.length > 1 ? 'rounded-xl' : 'rounded-[18px]',
                )}
              >
                <img
                  src={photo.thumb}
                  alt={photo.alt}
                  width={photo.width}
                  height={photo.height}
                  className={cn('w-full', photos.length > 1 ? 'aspect-square object-cover' : 'h-auto')}
                />
                {i === 3 && photos.length > 4 && (
                  <span className="absolute inset-0 grid place-items-center bg-black/45 text-lg font-medium text-white group-data-active:opacity-0">
                    +{photos.length - 4}
                  </span>
                )}
              </Lightbox.Trigger>
            )
          })}
        </div>
      )}
    </div>
  )
}

export default function Chat() {
  return (
    <Lightbox.Root items={items}>
      <div className="flex aspect-360/600 flex-col-reverse overflow-y-auto">
        <div className="flex flex-col gap-1">
          <p className="mb-2 text-center text-xs text-neutral-500">Today 18:01</p>
          {messages.map((message) => (
            <Bubble key={message.text ?? message.photos![0]!.src} {...message} />
          ))}
        </div>
      </div>
      <Viewer />
    </Lightbox.Root>
  )
}