Skip to content

feat(mobile): full-screen avatar viewer on profile screen tap#14444

Merged
dylanjeffers merged 1 commit into
mainfrom
feat/mobile-avatar-viewer
Jun 4, 2026
Merged

feat(mobile): full-screen avatar viewer on profile screen tap#14444
dylanjeffers merged 1 commit into
mainfrom
feat/mobile-avatar-viewer

Conversation

@dylanjeffers
Copy link
Copy Markdown
Contributor

Tapping the avatar on a profile page now opens a full-window viewer of the user's profile picture at the largest cached image size.

What's new

packages/mobile/src/screens/profile-screen/AvatarViewer.tsx (new)

Self-contained viewer component — no nav stack changes, no provider.

  • React Native <Modal> with transparent + animationType="fade" handles the open/close fade-in/out automatically. statusBarTranslucent keeps the image flush to the top edge under the notch.
  • Image rendered with resizeMode="contain" so the square avatar fits the rectangular window with letterboxing instead of cropping. Width capped at useWindowDimensions().width.
  • Highest-resolution source: useProfilePicture({ userId, size: SquareSizes.SIZE_1000_BY_1000 }). The header uses SIZE_150_BY_150 for the small tile; the viewer uses 1000 so it isn't a blurry upscale.
  • Close button: IconClose in the top-right at insets.top + 8 with a generous hitSlop. Accessible role + label.
  • Swipe to dismiss: Gesture.Pan() from react-native-gesture-handler. The image follows the finger in any direction via Reanimated useSharedValue/useAnimatedStyle. On release:
    • Drag distance > 120 px OR velocity > 800 px/srunOnJS(onClose)() (the Modal fade-out handles the rest)
    • Otherwise → spring back to center with withTiming(0, { duration: 200 }) on both axes
  • Reset on open: translation shared values reset to 0 in a useEffect keyed on isOpen, so a partial-then-cancelled drag from a previous open doesn't carry over.
  • Black #000 backdrop; StatusBar set to light-content + black while mounted.

packages/mobile/src/screens/profile-screen/ProfileHeader/ArtistProfilePicture.tsx

  • Wraps the existing <ProfilePicture> in a <TouchableOpacity> with activeOpacity={0.85} to signal tappability (no visual change beyond a brief fade on press).
  • Local isViewerOpen state with useState.
  • Renders <AvatarViewer userId={userId} isOpen={isViewerOpen} onClose={…}> as a sibling.

The fan-club badge overlay keeps its own tap target — it sits at zIndex: PROFILE_PAGE_PROFILE_PICTURE + 1 over the avatar, so badge taps still route to CoinDetailsScreen (the avatar tap doesn't fire under it).

Verification

  • tsc --noEmit clean in packages/mobile.
  • Manual: tap an avatar on any profile screen → black full-screen viewer fades in, image at the largest cached resolution.
  • Manual: tap the X in the top-right → viewer fades out.
  • Manual: short drag in any direction (< ~100 px, slow) → image springs back, viewer stays open.
  • Manual: longer drag or quick flick in any of the four directions → viewer dismisses.
  • Manual: tap the fan-club badge (where present) → still navigates to CoinDetailsScreen without opening the viewer.
  • Manual: Android hardware back → viewer dismisses (handled by Modal's onRequestClose).

Out of scope

  • Pinch-to-zoom — the spec listed dismiss-via-any-swipe; zoom can be a separate enhancement (would conflict with the dismiss gesture without a more elaborate gesture composition).
  • Avatar viewer from other screens (lineup tiles, comments, etc.) — only wired into the profile screen header per the request. The AvatarViewer component itself is general-purpose if we want to add more entry points later.

🤖 Generated with Claude Code

Tapping the avatar on a profile page now opens a full-window viewer of
the user's profile picture at SquareSizes.SIZE_1000_BY_1000 (the largest
size the image API caches) on a black backdrop. The fan-club badge
overlay keeps its own tap target via the higher zIndex it already had,
so tapping the badge still routes to CoinDetailsScreen — only the
underlying avatar opens the viewer.

The viewer (packages/mobile/src/screens/profile-screen/AvatarViewer.tsx):
- React Native Modal with transparent + animationType="fade" handles the
  open/close fade-in/out automatically. statusBarTranslucent keeps the
  image flush to the top edge under the notch.
- Image rendered with resizeMode="contain" so the square avatar fits the
  rectangular window with letterboxing instead of cropping.
- IconClose button in the top-right at safe-area top inset + 8 px with a
  generous hitSlop. Accessible role/label.
- Pan gesture (react-native-gesture-handler) follows the user's finger
  in any direction via Reanimated shared values. On release, if the drag
  distance exceeds 120 px or the velocity exceeds 800 px/s, the viewer
  dismisses; otherwise the image springs back to center with a 200 ms
  withTiming. Translation values are reset on each open.
- StatusBar light-content + black background while the modal is mounted.

ArtistProfilePicture wraps the existing ProfilePicture in a
TouchableOpacity with `activeOpacity={0.85}` to signal tappability, and
manages a local isOpen state for the viewer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 3, 2026

⚠️ No Changeset found

Latest commit: e287f10

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@dylanjeffers dylanjeffers merged commit be907a3 into main Jun 4, 2026
3 checks passed
@dylanjeffers dylanjeffers deleted the feat/mobile-avatar-viewer branch June 4, 2026 00:53
dylanjeffers added a commit that referenced this pull request Jun 4, 2026
…ver photo (#14445)

Follow-ups to #14444.

## 1. Avatar viewer keeps the circle

The avatar on the profile screen is a circle, so the full-screen viewer
should match — not a letterboxed square on black. `AvatarViewer` now
renders the image inside a fixed square wrapper with `borderRadius: size
/ 2` + `overflow: 'hidden'` and `resizeMode='cover'`, sized to
`min(windowWidth, windowHeight) - 48 px` so it doesn't touch the screen
edges on landscape phones.

## 2. Banner viewer on the profile cover photo

Tapping the cover photo at the top of the profile screen now opens a
parallel full-screen viewer for the banner. Uses `WidthSizes.SIZE_2000`
(the largest cached cover-photo size — the header uses `SIZE_640`) and
`resizeMode='contain'` to preserve the cover photo's native aspect ratio
on the black backdrop. No circular clip.

## Refactor: shared `FullscreenImageViewer`

Rather than duplicate the modal/gesture/close-button machinery between
Avatar and Banner viewers, the chrome is factored into a new
**`FullscreenImageViewer`** that owns:

- `<Modal transparent animationType='fade' statusBarTranslucent>` +
`light-content` StatusBar
- Pan gesture via `react-native-gesture-handler` — Reanimated shared
values follow the finger in any direction, dismiss thresholds at **120
px** distance or **800 px/s** velocity, **200 ms** `withTiming`
spring-back, translation reset on each open
- X close button at `insets.top + 8 px` with `hitSlop`, accessible
label/role
- Black `#000` backdrop

`AvatarViewer` and `BannerViewer` are now thin wrappers that supply the
image content as `children` and a `closeAccessibilityLabel`. Same
dismiss UX in both.

## ProfileCoverPhoto tap wiring

The existing `<CoverPhoto>` is wrapped in a `<Pressable>` that flips
local `isViewerOpen` state. The artist-badge overlay (which has no own
`onPress`) sits absolute on top and stays visually unchanged — taps in
the badge corner still hit the Pressable underneath.

```diff
+ <Pressable onPress={handleOpenViewer} accessibilityRole='imagebutton' …>
    <CoverPhoto userId={user_id}>
      <AnimatedBlurView … />
      {isArtist ? <Animated.View style={styles.darkOverlay} /> : null}
    </CoverPhoto>
+ </Pressable>
  {isArtist ? <Animated.View style={[styles.artistBadge, badgeStyle]}>…</Animated.View> : null}
+ <BannerViewer userId={user_id} isOpen={isViewerOpen} onClose={handleCloseViewer} />
```

## Files

| File | Change |
|---|---|
| `packages/mobile/src/screens/profile-screen/FullscreenImageViewer.tsx`
| **new** — shared chrome |
| `packages/mobile/src/screens/profile-screen/AvatarViewer.tsx` |
refactored — circle clip child of FullscreenImageViewer |
| `packages/mobile/src/screens/profile-screen/BannerViewer.tsx` |
**new** — `SIZE_2000`, `resizeMode='contain'`, child of
FullscreenImageViewer |
| `packages/mobile/src/screens/profile-screen/ProfileCoverPhoto.tsx` |
Pressable wrap + BannerViewer mount |

Net diff: **+288 / −144**.

## Verification

- [x] `tsc --noEmit` clean in `packages/mobile`
- [ ] Manual: tap an avatar → fades in, image rendered as a circle on
black backdrop.
- [ ] Manual: tap the cover photo → fades in, image rendered with
`contain` aspect ratio.
- [ ] Manual: short drag on either viewer → springs back.
- [ ] Manual: longer drag or flick in any of the four directions →
dismisses.
- [ ] Manual: tap the X in the top-right → dismisses.
- [ ] Manual: tap the artist badge (where present) → still navigates to
`CoinDetailsScreen` without opening the avatar viewer.
- [ ] Manual: cover-photo parallax/blur on scroll still works the same
once the Pressable is in place.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant