Repository: birdwingo/react-native-instagram-stories
Branch: main
Commit: 5eb9a91c32e7
Files: 48
Total size: 106.7 KB
Directory structure:
gitextract_ypb2kcij/
├── .eslintrc.js
├── .github/
│ └── workflows/
│ ├── public.yml
│ └── release.yml
├── .gitignore
├── .husky/
│ └── commit-msg
├── CHANGELOG.md
├── LICENSE
├── README.md
├── babel.config.js
├── commitlint.config.js
├── jest.setup.js
├── package.json
├── src/
│ ├── components/
│ │ ├── Animation/
│ │ │ ├── Animation.styles.ts
│ │ │ └── index.tsx
│ │ ├── Avatar/
│ │ │ ├── Avatar.styles.ts
│ │ │ └── index.tsx
│ │ ├── AvatarList/
│ │ │ └── index.tsx
│ │ ├── Content/
│ │ │ ├── Content.styles.ts
│ │ │ └── index.tsx
│ │ ├── Footer/
│ │ │ ├── Footer.styles.ts
│ │ │ └── index.tsx
│ │ ├── Header/
│ │ │ ├── Header.styles.ts
│ │ │ └── index.tsx
│ │ ├── Icon/
│ │ │ ├── close.tsx
│ │ │ └── index.tsx
│ │ ├── Image/
│ │ │ ├── Image.styles.ts
│ │ │ ├── index.tsx
│ │ │ └── video.tsx
│ │ ├── InstagramStories/
│ │ │ ├── InstagramStories.styles.ts
│ │ │ └── index.tsx
│ │ ├── List/
│ │ │ ├── List.styles.ts
│ │ │ └── index.tsx
│ │ ├── Loader/
│ │ │ └── index.tsx
│ │ ├── Modal/
│ │ │ ├── Modal.styles.ts
│ │ │ ├── gesture.tsx
│ │ │ └── index.tsx
│ │ └── Progress/
│ │ ├── Progress.styles.ts
│ │ ├── index.tsx
│ │ └── item.tsx
│ ├── core/
│ │ ├── constants/
│ │ │ └── index.ts
│ │ ├── dto/
│ │ │ ├── componentsDTO.ts
│ │ │ ├── helpersDTO.ts
│ │ │ └── instagramStoriesDTO.ts
│ │ └── helpers/
│ │ └── storage.ts
│ ├── declarations.d.ts
│ └── index.tsx
├── tests/
│ └── index.test.js
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.js
================================================
module.exports = {
"root": true,
"env": {
"es2021": true,
"node": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module",
"project": ["./tsconfig.json"],
},
"plugins": [
"@typescript-eslint",
"import"
],
"extends": [
"airbnb",
"airbnb-typescript"
],
"rules": {
// React
"react/jsx-props-no-spreading": "off",
"react/prop-types": "off",
"react/function-component-definition": "off",
// Spaces
'semi': [
'error',
'always'
],
'no-trailing-spaces': [
'error', {
'ignoreComments': true
}
],
'space-before-function-paren': [
'error', {
'anonymous': 'always',
'named': 'never',
'asyncArrow': 'always'
}
],
'space-in-parens': [
'error', 'always'
],
'space-before-blocks': 'error',
'no-whitespace-before-property': 'error',
'newline-before-return': 'error',
'no-multi-spaces': 'error',
'arrow-parens': [ 'error', 'always' ],
'array-bracket-spacing': [ 'error', 'always' ],
'arrow-spacing': 'error',
'lines-between-class-members': [ 'error', 'always', { 'exceptAfterSingleLine': true } ],
'@typescript-eslint/lines-between-class-members': [ 'error', 'always', { 'exceptAfterSingleLine': true } ],
// Basics
'camelcase': 'error',
'no-var': 'error',
'prefer-const': 'error',
'eqeqeq': [ 'error', 'always' ],
'no-return-assign': 'error',
'no-return-await': 'error',
'no-throw-literal': 'error',
'new-cap': [ 'error', { 'newIsCap': true } ],
'no-unneeded-ternary': 'error',
'no-template-curly-in-string': 'error',
'template-curly-spacing': 'error',
'curly': [ 'error', 'all' ],
'padded-blocks': [ 'error', 'always' ],
'no-underscore-dangle': ["error", { "allowAfterThis": true }],
'import/extensions':'off',
'no-plusplus': 'off',
'import/prefer-default-export': 'off',
'no-mixed-operators': 'off',
'no-param-reassign': 'off',
'import/no-named-as-default': 'off',
'import/no-extraneous-dependencies': 'off',
}
};
================================================
FILE: .github/workflows/public.yml
================================================
name: NPM Package Publish
on:
release:
types: [created]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16.x'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run build
- run: |
npm version ${{ github.event.release.tag_name }} --no-git-tag-version --allow-same-version && \
npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
slackNotification:
name: Slack Notification
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Slack Notification
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: frontend
SLACK_COLOR: ${{ job.status }}
SLACK_ICON: https://raw.githubusercontent.com/birdwingo/react-native-instagram-stories/main/src/assets/images/logo.png
SLACK_MESSAGE: Publish Release ${{ github.event.release.tag_name }} ${{ job.status == 'success' && 'has been successful' || 'has been failed' }}
SLACK_TITLE: 'Instagram stories publish release :rocket:'
SLACK_USERNAME: NPM
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
================================================
FILE: .github/workflows/release.yml
================================================
name: Github Release
on:
pull_request:
types: [closed]
branch: main
jobs:
release:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
token: ${{ secrets.AUTH_TOKEN }}
- uses: actions/setup-node@v2
with:
node-version: '16.x'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- name: Set up git user for release
run: |
git config --global user.email "actions@github.com"
git config --global user.name "GitHub Actions"
- run: npm run release
- name: Push changes
run: git push --follow-tags origin main
- run: npm run build
- run: npm run test
- name: Get version from package-lock.json
id: get_version
run: echo "::set-output name=version::$(node -p "require('./package-lock.json').version")"
- name: Get changelog
id: get_changelog
run: |
CHANGELOG=$(awk '/^### \[[0-9]+\.[0-9]+\.[0-9]+\]/{if (version!="") {exit}; version=$2} version!="" {print}' CHANGELOG.md)
echo "::set-output name=changelog::${CHANGELOG}"
- name: Create Release
uses: actions/create-release@master
env:
GITHUB_TOKEN: ${{ secrets.AUTH_TOKEN }}
with:
tag_name: "v${{ steps.get_version.outputs.version }}"
release_name: "v${{ steps.get_version.outputs.version }}"
================================================
FILE: .gitignore
================================================
/node_modules
/coverage
/dist
================================================
FILE: .husky/commit-msg
================================================
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit ${1}
npm run test
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### 1.3.18 (2025-07-17)
### 1.3.17 (2025-07-09)
### Bug Fixes
* readme ([ec9493f](https://github.com/birdwingo/react-native-instagram-stories/commit/ec9493fafc27122b6bb5b0c1312f89819a834569))
### 1.3.16 (2025-07-07)
### 1.3.15 (2025-07-04)
* image preload error
### 1.3.14 (2025-07-03)
### Bug Fixes
* ts error ([04905cb](https://github.com/birdwingo/react-native-instagram-stories/commit/04905cb051cdee58611c7d2a7d4104d945ed7fef))
### 1.3.13 (2025-07-03)
### Bug Fixes
* prefetch image ([5d8435d](https://github.com/birdwingo/react-native-instagram-stories/commit/5d8435d8b818fdb36a6dc51da4eba5e3257b9798))
### 1.3.12 (2025-02-01)
### 1.3.11 (2025-02-01)
### 1.3.10 (2024-10-21)
### 1.3.9 (2024-10-17)
### 1.3.8 (2024-10-12)
### 1.3.7 (2024-10-08)
### 1.3.6 (2024-10-08)
### 1.3.5 (2024-08-10)
### 1.3.4 (2024-07-30)
### 1.3.3 (2024-07-22)
### 1.3.2 (2024-07-12)
### Bug Fixes
* avatarSource property ([4ceed53](https://github.com/birdwingo/react-native-instagram-stories/commit/4ceed53ecc2df9e33a7a8584acd3f1159d553fca))
### 1.3.1 (2024-06-24)
### Bug Fixes
* import TextProps ([593503d](https://github.com/birdwingo/react-native-instagram-stories/commit/593503dcfc9acafbc131eb4cbe445bef05ee0529))
## 1.3.0 (2024-06-24)
### Features
* continue on last viewed story when scrolling between users ([697a157](https://github.com/birdwingo/react-native-instagram-stories/commit/697a157d9f3faa8b473a088c6f13da0335f75154))
### 1.2.12 (2024-06-24)
### 1.2.11 (2024-06-14)
### Bug Fixes
* opening url on footer press causes images to get stuck ([6aabd43](https://github.com/birdwingo/react-native-instagram-stories/commit/6aabd438afc645fe0324b374f9d4378d291e802f))
### 1.2.10 (2024-05-27)
### Bug Fixes
* show elements when swiping ([0d6da2f](https://github.com/birdwingo/react-native-instagram-stories/commit/0d6da2ff83c83c7497a19a2494ca69158bd0598a))
### 1.2.9 (2024-05-27)
### Bug Fixes
* avoid press when swiping ([8c003c9](https://github.com/birdwingo/react-native-instagram-stories/commit/8c003c98bab91584a92e191637fcb64fcf544daf))
### 1.2.8 (2024-05-27)
### 1.2.7 (2024-04-25)
### Bug Fixes
* imageOverlayView property ([7442dc9](https://github.com/birdwingo/react-native-instagram-stories/commit/7442dc91e14291cea58c1fc9b936fe04c8afba5e))
### 1.2.6 (2024-02-25)
### Bug Fixes
* image props ([316891e](https://github.com/birdwingo/react-native-instagram-stories/commit/316891e6036bc05f3f5b53137c0383b309bce4d4))
### 1.2.5 (2024-02-23)
### 1.2.4 (2024-02-07)
### 1.2.3 (2024-01-31)
### Bug Fixes
* listContainerStyle -> avatarListContainerProps ([ef195cb](https://github.com/birdwingo/react-native-instagram-stories/commit/ef195cb635e066410876f5e945cf415c7644e971))
### 1.2.2 (2024-01-30)
### Bug Fixes
* non worklet function ([bfe6016](https://github.com/birdwingo/react-native-instagram-stories/commit/bfe601605de019c4801eb98b528fea53621cd6d4))
### 1.2.1 (2024-01-29)
## 1.2.0 (2024-01-29)
### Features
* image styles ([37760c4](https://github.com/birdwingo/react-native-instagram-stories/commit/37760c4ba461747cf2a29828a0cac733f76d78f8))
### 1.1.1 (2024-01-09)
### Bug Fixes
* Property 'userId' doesn't exist ([57546c2](https://github.com/birdwingo/react-native-instagram-stories/commit/57546c2595689d058f8a01740e6aafd0e785978d))
## 1.1.0 (2024-01-05)
### Features
* getCurrentStory ([9c1256e](https://github.com/birdwingo/react-native-instagram-stories/commit/9c1256eaa0e58c6c8c42e94056dc13474fe907cf))
### 1.0.13 (2023-12-08)
### Bug Fixes
* non worklet function ([dc0f88e](https://github.com/birdwingo/react-native-instagram-stories/commit/dc0f88e26170d9129b30a7f8fee37c5beac55936))
### 1.0.12 (2023-11-20)
### Bug Fixes
* opening first story ([b21a57c](https://github.com/birdwingo/react-native-instagram-stories/commit/b21a57c7b40c188405f4ad94dfe9c05d096eaf18))
### 1.0.11 (2023-11-14)
### 1.0.10 (2023-11-14)
### Bug Fixes
* progress bar animation bug ([fa1b936](https://github.com/birdwingo/react-native-instagram-stories/commit/fa1b9360d40e26b3be79bae099500468ee32ada0))
### 1.0.9 (2023-11-08)
### Bug Fixes
* worklet error ([0de8cef](https://github.com/birdwingo/react-native-instagram-stories/commit/0de8cef208fef9203d33fc824b6d77acabec02c5))
### 1.0.8 (2023-11-08)
### Bug Fixes
* misspelled worklet ([3ec270b](https://github.com/birdwingo/react-native-instagram-stories/commit/3ec270b0a5712d97c6dfc46fd783acb27d974693))
### 1.0.7 (2023-10-31)
### 1.0.6 (2023-10-31)
### 1.0.5 (2023-10-31)
### Bug Fixes
* en gif ([542f651](https://github.com/birdwingo/react-native-instagram-stories/commit/542f651b572b204ad635f8a2f4095c9465648391))
### 1.0.4 (2023-09-11)
### Bug Fixes
* intro animation ([0e99a47](https://github.com/birdwingo/react-native-instagram-stories/commit/0e99a47fead4859303f87a7af2243b03f9f54d4a))
### 1.0.3 (2023-08-22)
### 1.0.2 (2023-08-21)
### 1.0.1 (2023-08-21)
### Bug Fixes
* run test ([c20e35e](https://github.com/birdwingo/react-native-instagram-stories/commit/c20e35eb18e9c953715798b5588341bf515d3309))
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 Birdwingo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# @birdwingo/react-native-instagram-stories




> **🚀 We are actively looking for maintainers!** If you're interested in contributing to this project and helping maintain it, feel free to create a PR or reach out.
## Features 🌟
📸 Capture Moments: Easily integrate Instagram-like stories in your React Native app to let users share their favorite moments.
✨ Inspired by Instagram: Crafted with inspiration from the real Instagram stories feature, capturing its essence and style.
📱 Mobile-Friendly: Designed with mobile users in mind, providing a smooth and responsive experience on all devices.
💾 Using Async Storage: Utilize Async Storage to save the progress of users and load them whenever they want.
🛠️ Developer Friendly: Well-documented and easy to set up, making the developer's life a breeze.
🚀 High Performance: Optimized for speed, ensuring a lag-free experience for users.
💡 Rich Features: Support for video, images, and text, plus more – all in one powerful package!
🎉 Community Support: Join a growing community of developers and users, eager to help and share their experiences.
## About
`react-native-instagram-stories` component is a versatile React Native component designed to display a horizontal scrollable list of user stories, similar to the stories feature found in the Instagram app. It provides a visually appealing way to showcase stories with various customizable options. It is used in the [Birdwingo mobile app](https://www.birdwingo.com) for **Birdwingo Academy** which allows users to learn the basics of investing in stocks and ETFs.
<img src="./src/assets/images/demo.gif" width="300">
## Installation
```bash
npm install react-native-svg
npm install react-native-reanimated
npm install react-native-gesture-handler
npm install @birdwingo/react-native-instagram-stories
```
## Integration with Storage, Flashlist and Video
The component offers an option to save and track the progress of seen stories using `saveProgress`. If you use `saveProgress`, please make sure you have `@react-native-async-storage/async-storage` installed.
If you have installed Flashlist, it will be automatically used for avatars list.
If you use video in your stories, please make sure you have `react-native-video` installed.
## Usage
To use the `InstagramStories` component, you need to import it in your React Native application and include it in your JSX code. Here's an example of how to use it:
```jsx
import React, { useRef } from 'react';
import { View } from 'react-native';
import InstagramStories, { InstagramStoriesPublicMethods } from '@birdwingo/react-native-instagram-stories';
const YourComponent = () => {
// to use public methods:
const ref = useRef( null ); // if using typescript - useRef<InstagramStoriesPublicMethods>( null )
const stories = [{ // if using typescript - const stories: InstagramStoriesProps['stories']
id: 'user1',
name: 'User 1',
avatarSource: { uri: 'user1-profile-image-url', },
stories: [
{ id: 'story1', source: { uri: 'story1-image-url' } },
{ id: 'story2', source: { uri: 'story1-video-url' }, mediaType: 'video' },
// ...
]}, // ...
];
// usage of public method
const setStories = () => ref.current?.setStories( stories );
return (
<View>
<InstagramStories
ref={ref}
stories={stories}
// ...
/>
<Pressable onPress={setStories}>{...}</Pressable>
</View>
);
};
export default YourComponent;
```
## Props
Name | Type | Default value | Description
----------------------------|----------------------------------------------|--------------------------------------------|---------------------
`stories` | [InstagramStoryProps](#instagramstoryprops)[]| **required** | An array of stories.
`saveProgress` | boolean | false | A boolean indicating whether to save and track the progress of seen stories.
`avatarBorderColors` | string[] | [DEFAULT_COLORS](#default-gradient-colors) | An array of string colors representing the border colors of story avatars.
`avatarSeenBorderColors` | string[] | [ '#2A2A2C' ] | An array of string colors representing the border colors of seen story avatars.
`avatarSize` | number | 60 | The size of the story avatars.
`storyAvatarSize` | number | 25 | The size of the avatars shown in the header of each story.
`avatarListContainerStyle` | ScrollViewProps['contentContainerStyle'], FlashListProps | | Additional styles for the avatar scroll list container.
`avatarListContainerProps` | ScrollViewProps | | Props to be passed to the avatar list ScrollView component.
`containerStyle` | ViewStyle | | Additional styles for the story container.
`textStyle` | TextStyle | | Additional styles for text elements.
`animationDuration` | number | 10000 | The duration of the story animations in ms.
`videoAnimationMaxDuration`| number | | The max duration of the video story animations in ms. If is this property not provided, the whole video will be played.
`backgroundColor` | string | '#000000' | The background color of story container.
`showName` | boolean | false | Whether you want to show user name under avatar in avatar list.
`nameTextStyle` | TextStyle | { width: `avatarSize` + 10 } | Additional styles for name text elements.
`nameTextProps` | TextProps | | Props to be passed to the name Text component.
`videoProps` | [react-native-video](https://www.npmjs.com/package/react-native-video?activeTab=readme#configurable-props)| | Additional props for video component. For more information, follow `react-native-video`.
`closeIconColor` | string | '#00000099' | The color of story close icon.
`progressColor` | string | '#00000099' | Background color of progress bar item in inactive state
`progressActiveColor` | string | '#FFFFFF' | Background color of progress bar item in active state
`modalAnimationDuration` | number | 800 | Duration of modal animation in ms (showing/closing instagram stories)
`storyAnimationDuration` | number | 800 | Duration of story animation (animation when swiping to the left/right)
`mediaContainerStyle` | ViewStyle | | Additional styles for media (video or image) container
`imageStyles` | ImageStyle | { width: WIDTH, aspectRatio: 0.5626 } | Additional styles image component
`imageProps` | ImageProps | | Additional props applied to image component
`isVisible` | boolean | false | A boolean indicating whether to show modal on load (modal will be show with first story item)
`headerStyle` | ViewStyle | | Additional styles for the story header
`headerContainerStyle` | ViewStyle | | Additional styles for the story header container
`progressContainerStyle` | ViewStyle | | Additional styles for the story progress container
`hideAvatarList` | boolean | false | A boolean indicating whether to hide avatar scroll list
`hideElementsOnLongPress` | boolean | false | A boolean indicating whether to hide all elements when story is paused by long press
`hideOverlayOnLongPress` | `boolean` | The value of `hideElementOnLongPress` | Controls whether the image overlay hides when `hideElementOnLongPress` is set to `true`. If `true`, the overlay will hide along with other elements on long press. If `false`, only the other elements (e.g., header, progress bar) will hide, and the overlay will remain visible.
`loopingStories` | `'none'` | `'onlyLast'` | `'all'` | `'none'` | A string indicating whether to continue stories after last story was shown. If set to `'none'` modal will be closed after all stories were played, if set to `'onlyLast'` stories will loop on last user only after all stories were played. If set to `'all'` stories will play from beginning after all stories were played.
`statusBarTranslucent` | boolean | false | A property passed to React native Modal component
`loaderColor` | string | '#FFFFFF' | The color of the loading spinner.
`loaderBackgroundColor` | string | | Background color of the loading overlay.
`footerComponent` | ReactNode | | A custom component, such as a floating element, that can be added to the modal.
`imageOverlayView` | ReactNode | | Image overlay compontent
`onShow` | ( id: string ) => void | | Callback when a story is shown.
`onHide` | ( id: string ) => void | | Callback when a story is hidden.
`onSwipeUp` | ( userId?: string, storyId?: string ) => void| | Callback when user swipes up.
`onStoryStart` | ( userId?: string, storyId?: string ) => void| | Callback when story started
`onStoryEnd` | ( userId?: string, storyId?: string ) => void| | Callback when story ended
## Public Methods
Name | Type | Description
---------------------- |--------------------------------------------------------------------------------------------------|---------------------------
`spliceStories` | ( stories: [InstagramStoryProps](#instagramstoryprops)[], index?: number ) => void | Insert new stories at a specific index. If you don't provide `index` property, stories will be pushed to the end of array.
`spliceUserStories` | ( stories: [InstagramStoryProps](#instagramstoryprops)[], user: string, index?: number ) => void | Insert new stories for a specific user at a specific index. If you don't provide `index` property, stories will be pushed to the end of array
`setStories` | ( stories: [InstagramStoryProps](#instagramstoryprops)[] ) => void | Replace the current stories with a new set of stories.
`clearProgressStorage`| () => void | Clear the progress storage for seen stories.
`hide` | () => void | Hide stories if currently visible
`show` | ( id?: string ) => void | Show stories modal with provided story `id`. If `id` is not provided, will be shown first story
`pause` | () => void | Pause story
`resume` | () => void | Resume story
`isPaused` | () => boolean | Returns true if story is paused
`goToPreviousStory` | () => void | Goes to previous story item
`goToNextStory` | () => void | Goes to next story item
`getCurrentStory` | () => {userId?: string, storyId?: string} | Returns current userId and storyId
`goToSpecificStory` | ( userId: string, index: number ) => void | Change current playing story to defined index if index not exist then start playing first story
## Types
### InstagramStoryProps
Parameter | Type | Required
-----------------------|----------------------------------------|----------------
`id` | string | true
`avatarSource` | ImageProps['source'] | false
`renderAvatar` | () => ReactNode | false
`renderStoryHeader` | () => ReactNode | false
`onStoryHeaderPress` | () => void | false
`name` | string | false
`stories` | [StoryItemProps](#storyitemprops)[] | true
**Please note that id parameter must be unique for every user**
### StoryItemProps
Parameter | Type | Required
-----------------------|------------------------------------------|-------------------
`id` | string | true
`source` | ImageProps['source'] | true
`mediaType` | 'video' \| 'image' (default: `'image'`) | false
`animationDuration` | number | false
`renderContent` | () => ReactNode | false
`renderFooter` | () => ReactNode | false
**Please note that id parameter must be unique for every story**
### Default Gradient Colors
Default colors for avatar gradient are the same as on Instagram - `[ '#F7B801', '#F18701', '#F35B04', '#F5301E', '#C81D4E', '#8F1D4E' ]`
## Sponsor
**react-native-instagram-stories** is sponsored by [Birdwingo](https://www.birdwingo.com).\
Download Birdwingo mobile app to see react-native-instagram-stories in action!
================================================
FILE: babel.config.js
================================================
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: ['react-native-reanimated/plugin'],
};
================================================
FILE: commitlint.config.js
================================================
module.exports = {extends: ['@commitlint/config-conventional']}
================================================
FILE: jest.setup.js
================================================
jest.mock('react-native-reanimated', () => {
const View = require('react-native').View;
return {
Value: jest.fn(),
event: jest.fn(),
add: jest.fn(),
eq: jest.fn(),
set: jest.fn(),
cond: jest.fn(),
interpolate: jest.fn(),
View: (props) => <View {...props} />,
createAnimatedComponent: (cb) => cb,
Extrapolate: { CLAMP: jest.fn() },
Transition: {
Together: 'Together',
Out: 'Out',
In: 'In',
},
useSharedValue: jest.fn(),
useDerivedValue: (a) => ({ value: a() }),
useAnimatedScrollHandler: () => () => {},
useAnimatedGestureHandler: ({onStart, onActive, onFinish}) => ({onStart, onActive, onFinish}),
useAnimatedStyle: (cb) => cb(),
useAnimatedRef: () => ({ current: null }),
useAnimatedReaction: jest.fn(),
useAnimatedProps: (cb) => cb(),
withTiming: (toValue, _, cb) => {
cb && cb(true);
return toValue;
},
withSpring: (toValue, _, cb) => {
cb && cb(true);
return toValue;
},
withDecay: (_, cb) => {
cb && cb(true);
return 0;
},
withDelay: (_, animationValue) => {
return animationValue;
},
withSequence: (..._animations) => {
return 0;
},
withRepeat: (animation, _, __, cb) => {
cb?.();
return animation;
},
cancelAnimation: () => {},
measure: () => ({
x: 0,
y: 0,
width: 0,
height: 0,
pageX: 0,
pageY: 0,
}),
Easing: {
linear: (cb) => cb(),
ease: (cb) => cb(),
quad: (cb) => cb(),
cubic: (cb) => cb(),
poly: (cb) => cb(),
sin: (cb) => cb(),
circle: (cb) => cb(),
exp: (cb) => cb(),
elastic: (cb) => cb(),
back: (cb) => cb(),
bounce: (cb) => cb(),
bezier: () => ({ factory: (cb) => cb() }),
bezierFn: (cb) => cb(),
steps: (cb) => cb(),
in: (cb) => cb(),
out: (cb) => cb(),
inOut: (cb) => cb(),
},
Extrapolation: {
EXTEND: 'extend',
CLAMP: 'clamp',
IDENTITY: 'identity',
},
runOnJS: (fn) => fn,
runOnUI: (fn) => fn,
};
});
jest.mock('react-native-gesture-handler', () => {
const View = require('react-native').View;
const createGesture = () => {
const gesture = {
_onStart: undefined,
_onUpdate: undefined,
_onFinalize: undefined,
onStart( cb ) {
this._onStart = cb;
return this;
},
onUpdate( cb ) {
this._onUpdate = cb;
return this;
},
onFinalize( cb ) {
this._onFinalize = cb;
return this;
},
};
return gesture;
};
return {
PanGestureHandler: ({onGestureEvent, children}) => (
<View
onResponderStart={( ...args ) => onGestureEvent.onStart?.( ...args )}
onResponderEnd={( ...args ) => onGestureEvent.onFinish?.( ...args )}
onResponderMove={( ...args ) => onGestureEvent.onActive?.( ...args )}
testID="gestureContainer"
>
{children}
</View>
),
Gesture: {
Pan: () => createGesture(),
},
GestureDetector: ({ gesture, children }) => (
<View
onResponderStart={( ...args ) => gesture?._onStart?.( ...args )}
onResponderMove={( ...args ) => gesture?._onUpdate?.( ...args )}
onResponderEnd={( ...args ) => gesture?._onFinalize?.( ...args )}
testID="gestureContainer"
>
{children}
</View>
),
GestureHandlerRootView: ({ children, ...props }) => <View {...props}>{children}</View>,
gestureHandlerRootHOC: (Component) => Component,
};
});
jest.mock('./src/core/helpers/storage', () => ({
clearProgressStorage: () => {},
getProgressStorage: jest.fn(),
setProgressStorage: jest.fn(),
}));
jest.mock('./src/components/Image/video', () => {
const React = require('react');
const { View } = require('react-native');
return ( props ) => {
const { onLoad, onLayout } = props;
onLoad?.(10000);
onLayout?.({ nativeEvent: { layout: { width: 100, height: 100 } } });
return <View testID="storyVideo" />;
};
});
jest.mock('@shopify/flash-list', () => {
const React = require('react');
const { ScrollView } = require('react-native');
return {FlashList: ({ data, renderItem, ...props }) => {
return (
<ScrollView {...props}>
{data.map(( item, index ) => renderItem({ item, index }))}
</ScrollView>
)
}};
});
================================================
FILE: package.json
================================================
{
"name": "@birdwingo/react-native-instagram-stories",
"version": "1.3.18",
"description": "A versatile and captivating React Native component that empowers developers to seamlessly integrate Instagram-style stories into their mobile applications, fostering an engaging and interactive user experience.",
"main": "src/index.tsx",
"source": "src/index.tsx",
"scripts": {
"prepare": "husky install",
"build": "tsc -p tsconfig.json",
"release": "standard-version",
"test": "jest --coverage"
},
"repository": "https://github.com/birdwingo/react-native-instagram-stories.git",
"keywords": [
"react-native",
"android",
"ios",
"react",
"react-native-reanimated",
"reanimated",
"animated",
"animation",
"performance",
"stories",
"instagram",
"instagram-stories",
"story"
],
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/birdwingo/react-native-instagram-stories/issues"
},
"homepage": "https://github.com/birdwingo/react-native-instagram-stories#readme",
"peerDependencies": {
"react": ">=18.0.0",
"react-native": ">=0.70.0",
"react-native-gesture-handler": ">=2.10.0",
"react-native-reanimated": ">=2.12.0",
"react-native-svg": ">=13.6.0"
},
"devDependencies": {
"@babel/preset-env": "^7.22.9",
"@babel/preset-typescript": "^7.22.5",
"@commitlint/cli": "^17.6.7",
"@commitlint/config-conventional": "^17.6.7",
"@react-native-async-storage/async-storage": "^1.19.2",
"@shopify/flash-list": "^1.7.1",
"@testing-library/jest-native": "^5.4.2",
"@testing-library/react-native": "^12.1.3",
"@tsconfig/react-native": "^3.0.0",
"@types/jest": "^29.5.3",
"@types/react": "^18.2.16",
"eslint": "^8.19.0",
"eslint-config-airbnb": "^19.0.2",
"eslint-config-airbnb-typescript": "^16.1.0",
"husky": "^8.0.3",
"jest": "^29.6.2",
"react-native-video": "^5.2.1",
"react-test-renderer": "^18.2.0",
"standard-version": "^9.5.0",
"typescript": "^5.1.6"
},
"jest": {
"preset": "react-native",
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"json",
"node"
],
"testMatch": [
"<rootDir>/tests/*.test.js"
],
"setupFilesAfterEnv": [
"@testing-library/jest-native/extend-expect",
"<rootDir>/jest.setup.js"
],
"setupFiles": [
"./node_modules/react-native-gesture-handler/jestSetup.js"
],
"verbose": true
}
}
================================================
FILE: src/components/Animation/Animation.styles.ts
================================================
import { StyleSheet } from 'react-native';
export default StyleSheet.create( {
container: StyleSheet.absoluteFillObject,
absolute: {
position: 'absolute',
top: 0,
left: 0,
},
cube: {
justifyContent: 'center',
},
} );
================================================
FILE: src/components/Animation/index.tsx
================================================
import React, { FC, memo } from 'react';
import { Platform } from 'react-native';
import Animated, { Extrapolation, interpolate, useAnimatedStyle } from 'react-native-reanimated';
import { HEIGHT, WIDTH } from '../../core/constants';
import { AnimationProps } from '../../core/dto/componentsDTO';
import AnimationStyles from './Animation.styles';
const StoryAnimation: FC<AnimationProps> = ( { children, x, index } ) => {
const angle = Math.PI / 3;
const ratio = Platform.OS === 'ios' ? 2 : 1.2;
const offset = WIDTH * index;
const inputRange = [ offset - WIDTH, offset + WIDTH ];
const maskInputRange = [ offset - WIDTH, offset, offset + WIDTH ];
const animatedStyle = useAnimatedStyle( () => {
const translateX = interpolate(
x.value,
inputRange,
[ WIDTH / ratio, -WIDTH / ratio ],
Extrapolation.CLAMP,
);
const rotateY = interpolate( x.value, inputRange, [ angle, -angle ], Extrapolation.CLAMP );
const alpha = Math.abs( rotateY );
const gamma = angle - alpha;
const beta = Math.PI - alpha - gamma;
const w = WIDTH / 2 - ( WIDTH / 2 * ( Math.sin( gamma ) / Math.sin( beta ) ) );
const translateX1 = rotateY > 0 ? w : -w;
const left = Platform.OS === 'android' ? interpolate(
rotateY,
[ -angle, -angle + 0.1, 0, angle - 0.1, angle ],
[ 0, 20, 0, -20, 0 ],
Extrapolation.CLAMP,
) : 0;
return {
transform: [
{ perspective: WIDTH },
{ translateX },
{ rotateY: `${rotateY}rad` },
{ translateX: translateX1 },
],
left,
};
} );
const maskAnimatedStyles = useAnimatedStyle( () => ( {
opacity: interpolate( x.value, maskInputRange, [ 0.5, 0, 0.5 ], Extrapolation.CLAMP ),
} ) );
return (
<Animated.View style={[ animatedStyle, AnimationStyles.container, AnimationStyles.cube ]}>
{children}
<Animated.View style={[ maskAnimatedStyles, AnimationStyles.absolute, { width: WIDTH, height: HEIGHT } ]} pointerEvents="none" />
</Animated.View>
);
};
export default memo( StoryAnimation );
================================================
FILE: src/components/Avatar/Avatar.styles.ts
================================================
import { StyleSheet } from 'react-native';
import { AVATAR_OFFSET } from '../../core/constants';
export default StyleSheet.create( {
container: {
flexDirection: 'row',
alignItems: 'center',
},
avatar: {
left: AVATAR_OFFSET,
top: AVATAR_OFFSET,
position: 'absolute',
},
name: {
alignItems: 'center',
},
} );
================================================
FILE: src/components/Avatar/index.tsx
================================================
import React, { FC, memo } from 'react';
import {
View, Image, Text, TouchableOpacity,
} from 'react-native';
import Animated, {
useSharedValue, useAnimatedStyle, useDerivedValue, withTiming,
} from 'react-native-reanimated';
import { StoryAvatarProps } from '../../core/dto/componentsDTO';
import AvatarStyles from './Avatar.styles';
import Loader from '../Loader';
import { AVATAR_OFFSET } from '../../core/constants';
const AnimatedImage = Animated.createAnimatedComponent( Image );
const StoryAvatar: FC<StoryAvatarProps> = ( {
id,
avatarSource,
name,
stories,
loadingStory,
seenStories,
onPress,
colors,
seenColors,
size,
showName,
nameTextStyle,
nameTextProps,
renderAvatar,
avatarBorderRadius,
} ) => {
const loaded = useSharedValue( false );
const isLoading = useDerivedValue( () => loadingStory.value === id || !loaded.value );
const seen = useDerivedValue(
() => seenStories.value[id] === stories[stories.length - 1]?.id,
);
const loaderColor = useDerivedValue( () => ( seen.value ? seenColors : colors ) );
const onLoad = () => {
loaded.value = true;
};
const imageAnimatedStyles = useAnimatedStyle( () => (
{ opacity: withTiming( isLoading.value ? 0.5 : 1 ) }
) );
if ( renderAvatar ) {
return renderAvatar( seen.value );
}
if ( !avatarSource ) {
return null;
}
return (
<View style={AvatarStyles.name}>
<View style={AvatarStyles.container}>
<TouchableOpacity activeOpacity={0.6} onPress={onPress} testID={`${id}StoryAvatar${stories.length}Story`}>
<Loader loading={isLoading} color={loaderColor} size={size + AVATAR_OFFSET * 2} />
<AnimatedImage
source={avatarSource}
style={[
AvatarStyles.avatar,
imageAnimatedStyles,
{ width: size, height: size, borderRadius: avatarBorderRadius ?? ( size / 2 ) },
]}
testID="storyAvatarImage"
onLoad={onLoad}
/>
</TouchableOpacity>
</View>
{Boolean( showName ) && (
<Text
{...nameTextProps}
style={[ { width: size + AVATAR_OFFSET * 2 }, nameTextStyle ]}
>
{name}
</Text>
)}
</View>
);
};
export default memo( StoryAvatar );
================================================
FILE: src/components/AvatarList/index.tsx
================================================
import React, { FC, memo } from 'react';
import { ScrollView } from 'react-native';
import StoryAvatar from '../Avatar';
import { StoryAvatarListProps } from '../../core/dto/componentsDTO';
import { InstagramStoryProps } from '../../core/dto/instagramStoriesDTO';
let FlashList: any;
try {
// eslint-disable-next-line global-require
FlashList = require( '@shopify/flash-list' ).FlashList;
} catch ( error ) {
FlashList = null;
}
const StoryAvatarList: FC<StoryAvatarListProps> = ( {
stories, loadingStory, seenStories, colors, seenColors, size,
showName, nameTextStyle, nameTextProps,
avatarListContainerProps, avatarListContainerStyle, avatarBorderRadius, onPress,
} ) => {
const renderItem = ( story: InstagramStoryProps ) => (
<StoryAvatar
{...story}
loadingStory={loadingStory}
seenStories={seenStories}
onPress={() => onPress( story.id )}
colors={colors}
seenColors={seenColors}
size={size}
showName={showName}
nameTextStyle={nameTextStyle}
nameTextProps={nameTextProps}
avatarBorderRadius={avatarBorderRadius}
key={`avatar${story.id}`}
/>
);
if ( FlashList ) {
return (
<FlashList
horizontal
{...avatarListContainerProps}
data={stories}
renderItem={( { item } : { item: InstagramStoryProps } ) => renderItem( item )}
keyExtractor={( item: InstagramStoryProps ) => item.id}
contentContainerStyle={avatarListContainerStyle}
testID="storiesList"
/>
);
}
return (
<ScrollView horizontal {...avatarListContainerProps} contentContainerStyle={avatarListContainerStyle} testID="storiesList">
{stories.map( renderItem )}
</ScrollView>
);
};
export default memo( StoryAvatarList );
================================================
FILE: src/components/Content/Content.styles.ts
================================================
import { StyleSheet } from 'react-native';
export default StyleSheet.create( {
container: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
},
} );
================================================
FILE: src/components/Content/index.tsx
================================================
import React, {
FC, memo, useState, useMemo,
} from 'react';
import { View } from 'react-native';
import { runOnJS, useAnimatedReaction } from 'react-native-reanimated';
import { StoryContentProps } from '../../core/dto/componentsDTO';
import ContentStyles from './Content.styles';
const StoryContent: FC<StoryContentProps> = ( { stories, active, activeStory } ) => {
const [ storyIndex, setStoryIndex ] = useState( 0 );
const onChange = async () => {
'worklet';
const index = stories.findIndex( ( item ) => item.id === activeStory.value );
if ( active.value && index >= 0 && index !== storyIndex ) {
runOnJS( setStoryIndex )( index );
}
};
useAnimatedReaction(
() => active.value,
( res, prev ) => res !== prev && onChange(),
);
useAnimatedReaction(
() => activeStory.value,
( res, prev ) => res !== prev && onChange(),
);
const content = useMemo( () => stories[storyIndex]?.renderContent?.(), [ storyIndex ] );
return content ? <View style={ContentStyles.container} pointerEvents="box-none">{content}</View> : null;
};
export default memo( StoryContent );
================================================
FILE: src/components/Footer/Footer.styles.ts
================================================
import { StyleSheet } from 'react-native';
export default StyleSheet.create( {
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
} );
================================================
FILE: src/components/Footer/index.tsx
================================================
import React, {
FC, memo, useState, useMemo,
} from 'react';
import { View } from 'react-native';
import { runOnJS, useAnimatedReaction } from 'react-native-reanimated';
import { StoryContentProps } from '../../core/dto/componentsDTO';
import ContentStyles from './Footer.styles';
const StoryFooter: FC<StoryContentProps> = ( { stories, active, activeStory } ) => {
const [ storyIndex, setStoryIndex ] = useState( 0 );
const onChange = async () => {
'worklet';
const index = stories.findIndex( ( item ) => item.id === activeStory.value );
if ( active.value && index >= 0 && index !== storyIndex ) {
runOnJS( setStoryIndex )( index );
}
};
useAnimatedReaction(
() => active.value,
( res, prev ) => res !== prev && onChange(),
);
useAnimatedReaction(
() => activeStory.value,
( res, prev ) => res !== prev && onChange(),
);
const footer = useMemo( () => stories[storyIndex]?.renderFooter?.(), [ storyIndex ] );
return footer ? <View style={ContentStyles.container} pointerEvents="box-none">{footer}</View> : null;
};
export default memo( StoryFooter );
================================================
FILE: src/components/Header/Header.styles.ts
================================================
import { StyleSheet } from 'react-native';
export default StyleSheet.create( {
container: {
position: 'absolute',
left: 16,
top: 32,
},
containerFlex: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
left: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
flex: 1,
},
avatar: {
borderWidth: 1.5,
borderColor: '#FFF',
overflow: 'hidden',
},
} );
================================================
FILE: src/components/Header/index.tsx
================================================
import React, { FC, memo } from 'react';
import {
View, Text, Image, TouchableOpacity,
Pressable,
} from 'react-native';
import { WIDTH } from '../../core/constants';
import HeaderStyles from './Header.styles';
import { StoryHeaderProps } from '../../core/dto/componentsDTO';
import Close from '../Icon/close';
const StoryHeader: FC<StoryHeaderProps> = ( {
avatarSource, name, onClose, avatarSize, textStyle, closeColor, headerStyle,
headerContainerStyle, renderStoryHeader, onStoryHeaderPress,
} ) => {
const styles = { width: avatarSize, height: avatarSize, borderRadius: avatarSize };
const width = WIDTH - HeaderStyles.container.left * 2;
if ( renderStoryHeader ) {
return (
<View
style={[ HeaderStyles.container, { width }, headerContainerStyle ]}
>
{renderStoryHeader()}
</View>
);
}
return (
<View style={[
HeaderStyles.container, HeaderStyles.containerFlex,
{ width }, headerContainerStyle,
]}
>
<Pressable style={[ HeaderStyles.left, headerStyle ]} onPress={() => onStoryHeaderPress?.()}>
{( Boolean( avatarSource ) ) && (
<View style={[ HeaderStyles.avatar, { borderRadius: styles.borderRadius } ]}>
<Image source={avatarSource!} style={styles} />
</View>
)}
{Boolean( name ) && <Text style={textStyle}>{name}</Text>}
</Pressable>
<TouchableOpacity
onPress={onClose}
hitSlop={16}
testID="storyCloseButton"
>
<Close color={closeColor} />
</TouchableOpacity>
</View>
);
};
export default memo( StoryHeader );
================================================
FILE: src/components/Icon/close.tsx
================================================
import React, { FC, memo } from 'react';
import { Path, Svg } from 'react-native-svg';
import { IconProps } from '../../core/dto/componentsDTO';
const Close: FC<IconProps> = ( { color } ) => (
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<Path fill={color} d="M17.3422 15.7964C17.7651 16.2193 17.7651 16.9193 17.3422 17.3422C17.1234 17.5609 16.8464 17.663 16.5693 17.663C16.2922 17.663 16.0151 17.5609 15.7964 17.3422L11.8297 13.3755L7.86302 17.3422C7.64427 17.5609 7.36719 17.663 7.0901 17.663C6.81302 17.663 6.53594 17.5609 6.31719 17.3422C5.89427 16.9193 5.89427 16.2193 6.31719 15.7964L10.2839 11.8297L6.31719 7.86302C5.89427 7.4401 5.89427 6.7401 6.31719 6.31719C6.7401 5.89427 7.4401 5.89427 7.86302 6.31719L11.8297 10.2839L15.7964 6.31719C16.2193 5.89427 16.9193 5.89427 17.3422 6.31719C17.7651 6.7401 17.7651 7.4401 17.3422 7.86302L13.3755 11.8297L17.3422 15.7964Z" />
</Svg>
);
export default memo( Close );
================================================
FILE: src/components/Icon/index.tsx
================================================
import Close from './close';
export { Close };
================================================
FILE: src/components/Image/Image.styles.ts
================================================
import { StyleSheet } from 'react-native';
export default StyleSheet.create( {
container: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
},
image: {
alignItems: 'center',
justifyContent: 'center',
},
} );
================================================
FILE: src/components/Image/index.tsx
================================================
import { Image, View } from 'react-native';
import React, { FC, memo, useState } from 'react';
import Animated, {
runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue,
} from 'react-native-reanimated';
import { StoryImageProps } from '../../core/dto/componentsDTO';
import Loader from '../Loader';
import { HEIGHT, LOADER_COLORS, WIDTH } from '../../core/constants';
import ImageStyles from './Image.styles';
import StoryVideo from './video';
import { StoryItemProps } from '../../core/dto/instagramStoriesDTO';
const StoryImage: FC<StoryImageProps> = ( {
stories, activeStory, defaultStory, isDefaultVideo, paused, videoProps, isActive,
mediaContainerStyle, imageStyles, imageProps, videoDuration, loaderColor,
loaderBackgroundColor, onImageLayout, onLoad,
} ) => {
const [ data, setData ] = useState<{ data?: StoryItemProps, isVideo?: boolean }>(
{ data: defaultStory, isVideo: isDefaultVideo },
);
const loading = useSharedValue( true );
const color = useSharedValue( loaderColor ? [ loaderColor ] : LOADER_COLORS );
const duration = useSharedValue<number | undefined>( undefined );
const isPaused = useDerivedValue( () => paused.value || !isActive.value );
const loaderHideStyle = useAnimatedStyle( () => ( {
opacity: loading.value ? 1 : 0,
} ) );
const loaderBackgroundStyle = useAnimatedStyle( () => ( {
backgroundColor: loaderBackgroundColor || 'transparent',
} ) );
const onImageChange = async () => {
if ( !activeStory.value ) {
return;
}
const story = stories.find( ( item ) => item.id === activeStory.value );
if ( !story ) {
return;
}
if ( data.data?.id === story.id ) {
if ( !loading.value ) {
onLoad( duration.value );
}
} else {
loading.value = true;
setData( { data: story, isVideo: story.mediaType === 'video' } );
}
const nextStory = stories[stories.indexOf( story ) + 1];
if ( nextStory && nextStory.mediaType !== 'video' && ( nextStory.source as any )?.uri ) {
Image.prefetch( ( nextStory.source as any )?.uri );
}
};
useAnimatedReaction(
() => isActive.value,
( res, prev ) => res !== prev && res && runOnJS( onImageChange )(),
);
useAnimatedReaction(
() => activeStory.value,
( res, prev ) => res !== prev && runOnJS( onImageChange )(),
);
const onContentLoad = ( newDuration?: number ) => {
const animationDuration = ( data?.data?.mediaType === 'video' ? videoDuration : undefined ) ?? data.data?.animationDuration ?? newDuration;
duration.value = animationDuration;
loading.value = false;
if ( isActive.value ) {
onLoad( animationDuration );
}
};
return (
<>
<Animated.View style={[ ImageStyles.container, loaderHideStyle, loaderBackgroundStyle ]}>
<Loader loading={loading} color={color} size={50} />
</Animated.View>
<View style={[ ImageStyles.image, mediaContainerStyle ]}>
{data.data?.source && (
data.isVideo ? (
<StoryVideo
onLoad={onContentLoad}
onLayout={onImageLayout}
source={data.data.source}
paused={isPaused}
isActive={isActive}
{...videoProps}
/>
) : (
<Image
source={data.data.source}
style={[ { width: WIDTH, aspectRatio: 0.5626 }, imageStyles ]}
resizeMode="contain"
testID="storyImageComponent"
onLayout={( e ) => onImageLayout( Math.min( HEIGHT, e.nativeEvent.layout.height ) )}
onLoad={() => onContentLoad()}
{...imageProps}
/>
)
)}
</View>
</>
);
};
export default memo( StoryImage );
================================================
FILE: src/components/Image/video.tsx
================================================
import React, {
FC, memo, useRef, useState,
} from 'react';
import { LayoutChangeEvent } from 'react-native';
import { runOnJS, useAnimatedReaction } from 'react-native-reanimated';
import { StoryVideoProps } from '../../core/dto/componentsDTO';
import { WIDTH } from '../../core/constants';
const StoryVideo: FC<StoryVideoProps> = ( {
source, paused, isActive, onLoad, onLayout, ...props
} ) => {
try {
// eslint-disable-next-line global-require
const Video = require( 'react-native-video' ).default;
const ref = useRef<any>( null );
const [ pausedValue, setPausedValue ] = useState( true );
const start = () => {
ref.current?.seek( 0 );
ref.current?.resume?.();
};
useAnimatedReaction(
() => paused.value,
( res, prev ) => res !== prev && runOnJS( setPausedValue )( res ),
);
useAnimatedReaction(
() => isActive.value,
( res ) => res && runOnJS( start )(),
);
return (
<Video
ref={ref}
style={{ width: WIDTH, aspectRatio: 0.5626 }}
{...props}
source={source}
paused={pausedValue}
controls={false}
repeat={false}
onLoad={( { duration }: { duration: number } ) => onLoad( duration * 1000 )}
onLayout={( e: LayoutChangeEvent ) => onLayout( e.nativeEvent.layout.height )}
/>
);
} catch ( error ) {
return null;
}
};
export default memo( StoryVideo );
================================================
FILE: src/components/InstagramStories/InstagramStories.styles.ts
================================================
================================================
FILE: src/components/InstagramStories/index.tsx
================================================
import React, {
forwardRef, useImperativeHandle, useState, useEffect, useRef, memo,
} from 'react';
import { useSharedValue } from 'react-native-reanimated';
import { Image } from 'react-native';
import { clearProgressStorage, getProgressStorage, setProgressStorage } from '../../core/helpers/storage';
import { InstagramStoriesProps, InstagramStoriesPublicMethods } from '../../core/dto/instagramStoriesDTO';
import { ProgressStorageProps } from '../../core/dto/helpersDTO';
import {
ANIMATION_DURATION, DEFAULT_COLORS, SEEN_LOADER_COLORS,
STORY_AVATAR_SIZE, AVATAR_SIZE, BACKGROUND_COLOR, CLOSE_COLOR,
} from '../../core/constants';
import StoryModal from '../Modal';
import { StoryModalPublicMethods } from '../../core/dto/componentsDTO';
import StoryAvatarList from '../AvatarList';
const InstagramStories = forwardRef<InstagramStoriesPublicMethods, InstagramStoriesProps>( ( {
stories,
saveProgress = false,
avatarBorderColors = DEFAULT_COLORS,
avatarSeenBorderColors = SEEN_LOADER_COLORS,
avatarSize = AVATAR_SIZE,
storyAvatarSize = STORY_AVATAR_SIZE,
avatarListContainerStyle,
avatarListContainerProps,
animationDuration = ANIMATION_DURATION,
backgroundColor = BACKGROUND_COLOR,
showName = false,
nameTextStyle,
nameTextProps,
videoAnimationMaxDuration,
videoProps,
closeIconColor = CLOSE_COLOR,
isVisible = false,
hideAvatarList = false,
avatarBorderRadius,
loaderColor,
loaderBackgroundColor,
...props
}, ref ) => {
const [ data, setData ] = useState( stories );
const seenStories = useSharedValue<ProgressStorageProps>( {} );
const loadedStories = useSharedValue( false );
const loadingStory = useSharedValue<string | undefined>( undefined );
const modalRef = useRef<StoryModalPublicMethods>( null );
const onPress = ( id: string ) => {
loadingStory.value = id;
if ( loadedStories.value ) {
modalRef.current?.show( id );
}
};
const onLoad = () => {
loadingStory.value = undefined;
};
const onStoriesChange = async () => {
seenStories.value = await ( saveProgress ? getProgressStorage() : {} );
const promises = stories.map( ( story ) => {
const seenStoryIndex = story.stories.findIndex(
( item ) => item.id === seenStories.value[story.id],
);
const seenStory = story.stories[seenStoryIndex + 1] || story.stories[0];
if ( !seenStory ) {
return true;
}
return seenStory.mediaType !== 'video' && ( seenStory.source as any )?.uri ? Image.prefetch( ( seenStory.source as any )?.uri ) : true;
} );
await Promise.all( promises );
loadedStories.value = true;
if ( loadingStory.value ) {
onPress( loadingStory.value );
}
};
const onSeenStoriesChange = async ( user: string, value: string ) => {
if ( !saveProgress ) {
return;
}
if ( seenStories.value[user] ) {
const userData = data.find( ( story ) => story.id === user );
const oldIndex = userData?.stories.findIndex(
( story ) => story.id === seenStories.value[user],
);
const newIndex = userData?.stories.findIndex( ( story ) => story.id === value );
if ( oldIndex! > newIndex! ) {
return;
}
}
seenStories.value = await setProgressStorage( user, value );
};
useImperativeHandle(
ref,
() => ( {
spliceStories: ( newStories, index ) => {
if ( index === undefined ) {
setData( [ ...data, ...newStories ] );
} else {
const newData = [ ...data ];
newData.splice( index, 0, ...newStories );
setData( newData );
}
},
spliceUserStories: ( newStories, user, index ) => {
const userData = data.find( ( story ) => story.id === user );
if ( !userData ) {
return;
}
const newData = index === undefined
? [ ...userData.stories, ...newStories ]
: [ ...userData.stories ];
if ( index !== undefined ) {
newData.splice( index, 0, ...newStories );
}
setData( data.map( ( value ) => ( value.id === user ? {
...value,
stories: newData,
} : value ) ) );
},
setStories: ( newStories ) => {
setData( newStories );
},
clearProgressStorage,
goToSpecificStory: ( userId, index ) => modalRef.current?.goToSpecificStory( userId, index ),
hide: () => modalRef.current?.hide(),
show: ( id ) => {
if ( id ) {
onPress( id );
} else if ( data[0]?.id ) {
onPress( data[0]?.id );
}
},
pause: () => modalRef.current?.pause()!,
resume: () => modalRef.current?.resume()!,
isPaused: () => modalRef.current?.isPaused()!,
goToPreviousStory: () => modalRef.current?.goToPreviousStory()!,
goToNextStory: () => modalRef.current?.goToNextStory()!,
getCurrentStory: () => modalRef.current?.getCurrentStory()!,
} ),
[ data ],
);
useEffect( () => {
onStoriesChange();
}, [ data ] );
useEffect( () => {
setData( stories );
}, [ stories ] );
useEffect( () => {
if ( isVisible && data[0]?.id ) {
modalRef.current?.show( data[0]?.id );
} else {
modalRef.current?.hide();
}
}, [ isVisible ] );
return (
<>
{!hideAvatarList && (
<StoryAvatarList
stories={data}
loadingStory={loadingStory}
seenStories={seenStories}
colors={avatarBorderColors}
seenColors={avatarSeenBorderColors}
size={avatarSize}
showName={showName}
nameTextStyle={nameTextStyle}
nameTextProps={nameTextProps}
avatarListContainerProps={avatarListContainerProps}
avatarListContainerStyle={avatarListContainerStyle}
avatarBorderRadius={avatarBorderRadius}
onPress={onPress}
/>
)}
{/* @ts-expect-error: imageProps type mismatch is intentionally ignored */}
<StoryModal
ref={modalRef}
stories={data}
seenStories={seenStories}
duration={animationDuration}
storyAvatarSize={storyAvatarSize}
onLoad={onLoad}
onSeenStoriesChange={onSeenStoriesChange}
backgroundColor={backgroundColor}
videoDuration={videoAnimationMaxDuration}
videoProps={videoProps}
closeIconColor={closeIconColor}
loaderColor={loaderColor}
loaderBackgroundColor={loaderBackgroundColor}
{...props}
/>
</>
);
} );
export default memo( InstagramStories );
================================================
FILE: src/components/List/List.styles.ts
================================================
import { StyleSheet } from 'react-native';
import { WIDTH } from '../../core/constants';
export default StyleSheet.create( {
container: {
borderRadius: 8,
overflow: 'hidden',
width: WIDTH,
},
content: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
zIndex: 3,
},
} );
================================================
FILE: src/components/List/index.tsx
================================================
import React, { FC, memo, useState } from 'react';
import Animated, {
runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming,
} from 'react-native-reanimated';
import StoryAnimation from '../Animation';
import ListStyles from './List.styles';
import StoryImage from '../Image';
import Progress from '../Progress';
import StoryHeader from '../Header';
import { StoryListProps } from '../../core/dto/componentsDTO';
import { HEIGHT } from '../../core/constants';
import StoryContent from '../Content';
import StoryFooter from '../Footer';
const StoryList: FC<StoryListProps> = ( {
id, stories, index, x, activeUser, activeStory, progress, seenStories, paused,
onLoad, videoProps, progressColor, progressActiveColor, mediaContainerStyle, imageStyles,
imageProps, progressContainerStyle, imageOverlayView, hideElements, hideOverlayViewOnLongPress,
videoDuration, loaderColor, loaderBackgroundColor, ...props
} ) => {
const imageHeight = useSharedValue( HEIGHT );
const isActive = useDerivedValue( () => activeUser.value === id );
const activeStoryIndex = useDerivedValue(
() => stories.findIndex( ( item ) => item.id === activeStory.value ),
);
const animatedStyles = useAnimatedStyle( () => ( { height: imageHeight.value } ) );
const contentStyles = useAnimatedStyle( () => ( {
opacity: withTiming( hideElements.value ? 0 : 1 ),
} ) );
const onImageLayout = ( height: number ) => {
imageHeight.value = height;
};
const [ lastSeenId, setLastSeenId ] = useState<string | undefined>( undefined );
useAnimatedReaction(
() => seenStories.value[id],
( res, prev ) => {
if ( res !== prev ) {
runOnJS( setLastSeenId )( res );
}
},
);
const lastSeenIndex = lastSeenId !== undefined
? stories.findIndex( ( item ) => item.id === lastSeenId )
: -1;
return (
<StoryAnimation x={x} index={index}>
<Animated.View style={[ animatedStyles, ListStyles.container ]}>
<StoryImage
stories={stories}
activeStory={activeStory}
defaultStory={stories[lastSeenIndex + 1] ?? stories[0]}
isDefaultVideo={( stories[lastSeenIndex + 1]?.mediaType ?? stories[0]?.mediaType ) === 'video'}
onImageLayout={onImageLayout}
onLoad={onLoad}
paused={paused}
isActive={isActive}
videoProps={videoProps}
mediaContainerStyle={mediaContainerStyle}
imageStyles={imageStyles}
imageProps={imageProps}
videoDuration={videoDuration}
loaderColor={loaderColor}
loaderBackgroundColor={loaderBackgroundColor}
/>
<Animated.View
style={[
hideOverlayViewOnLongPress ? contentStyles : {},
ListStyles.content,
]}
pointerEvents="auto"
>
{imageOverlayView}
<Animated.View style={[ contentStyles, ListStyles.content ]} pointerEvents="box-none">
<Progress
active={isActive}
activeStory={activeStoryIndex}
progress={progress}
length={stories.length}
progressColor={progressColor}
progressActiveColor={progressActiveColor}
progressContainerStyle={progressContainerStyle}
/>
<StoryHeader {...props} />
<StoryContent stories={stories} active={isActive} activeStory={activeStory} />
</Animated.View>
</Animated.View>
</Animated.View>
<StoryFooter stories={stories} active={isActive} activeStory={activeStory} />
</StoryAnimation>
);
};
export default memo( StoryList );
================================================
FILE: src/components/Loader/index.tsx
================================================
import React, {
FC, memo, useMemo, useState,
} from 'react';
import Animated, {
cancelAnimation, interpolate, runOnJS, useAnimatedProps, useAnimatedReaction, useAnimatedStyle,
useSharedValue, withRepeat, withTiming,
} from 'react-native-reanimated';
import {
Circle, Defs, LinearGradient, Stop, Svg,
} from 'react-native-svg';
import {
AVATAR_SIZE, LOADER_COLORS, LOADER_ID, LOADER_URL, STROKE_WIDTH,
} from '../../core/constants';
import { StoryLoaderProps } from '../../core/dto/componentsDTO';
const AnimatedCircle = Animated.createAnimatedComponent( Circle );
const AnimatedSvg = Animated.createAnimatedComponent( Svg );
const Loader: FC<StoryLoaderProps> = ( {
loading, color, size = AVATAR_SIZE + 10,
} ) => {
const RADIUS = useMemo( () => ( size - STROKE_WIDTH ) / 2, [ size ] );
const CIRCUMFERENCE = useMemo( () => RADIUS * 2 * Math.PI, [ RADIUS ] );
const [ colors, setColors ] = useState<string[]>( LOADER_COLORS );
const rotation = useSharedValue( 0 );
const progress = useSharedValue( 0 );
const animatedProps = useAnimatedProps( () => ( {
strokeDashoffset: interpolate( progress.value, [ 0, 1 ], [ 0, CIRCUMFERENCE * 2 / 3 ] ),
} ) );
const animatedStyles = useAnimatedStyle( () => ( {
transform: [ { rotate: `${rotation.value}deg` } ],
} ) );
const startAnimation = () => {
'worklet';
progress.value = withRepeat( withTiming( 1, { duration: 3000 } ), -1, true );
rotation.value = withRepeat( withTiming( 720, { duration: 3000 } ), -1, false, () => {
rotation.value = 0;
} );
};
const stopAnimation = () => {
'worklet';
cancelAnimation( progress );
progress.value = withTiming( 0 );
cancelAnimation( rotation );
rotation.value = withTiming( 0 );
};
const onColorChange = ( newColors: string[] ) => {
'worklet';
if ( JSON.stringify( colors ) === JSON.stringify( newColors ) ) {
return;
}
runOnJS( setColors )( newColors );
};
useAnimatedReaction(
() => loading.value,
( res ) => ( res ? startAnimation() : stopAnimation() ),
);
useAnimatedReaction(
() => color.value,
( res ) => onColorChange( res ),
);
return (
<AnimatedSvg width={size} height={size} style={animatedStyles}>
<Defs>
<LinearGradient id={LOADER_ID} x1="0%" y1="0%" x2="100%" y2="0%">
{colors?.map( ( item, i ) => (
<Stop key={item} offset={i / colors.length} stopColor={item} />
) )}
</LinearGradient>
</Defs>
<AnimatedCircle
cx={size / 2}
cy={size / 2}
r={RADIUS}
fill="none"
stroke={LOADER_URL}
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={[ CIRCUMFERENCE ]}
animatedProps={animatedProps}
/>
</AnimatedSvg>
);
};
export default memo( Loader );
================================================
FILE: src/components/Modal/Modal.styles.ts
================================================
import { StyleSheet } from 'react-native';
import { HEIGHT, WIDTH } from '../../core/constants';
export default StyleSheet.create( {
container: {
flex: 1,
},
absolute: {
position: 'absolute',
top: 0,
left: 0,
width: WIDTH,
height: HEIGHT,
},
bgAnimation: StyleSheet.absoluteFillObject,
} );
================================================
FILE: src/components/Modal/gesture.tsx
================================================
import React, { memo } from 'react';
import { PanGestureHandler, PanGestureHandlerProps, gestureHandlerRootHOC } from 'react-native-gesture-handler';
const GestureHandler = gestureHandlerRootHOC(
( { children, onGestureEvent } : PanGestureHandlerProps ) => (
<PanGestureHandler onGestureEvent={onGestureEvent}>{children}</PanGestureHandler>
),
);
export default memo( GestureHandler );
================================================
FILE: src/components/Modal/index.tsx
================================================
import React, {
forwardRef, memo, useEffect, useImperativeHandle, useState,
} from 'react';
import { GestureResponderEvent, Modal, Pressable } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
cancelAnimation, interpolate, runOnJS, useAnimatedReaction,
useAnimatedStyle,
useDerivedValue, useSharedValue, withTiming,
} from 'react-native-reanimated';
import {
HEIGHT, LONG_PRESS_DURATION, STORY_ANIMATION_DURATION, WIDTH,
} from '../../core/constants';
import { GestureContext, StoryModalProps, StoryModalPublicMethods } from '../../core/dto/componentsDTO';
import StoryList from '../List';
import ModalStyles from './Modal.styles';
const StoryModal = forwardRef<StoryModalPublicMethods, StoryModalProps>( ( {
stories, seenStories, duration, videoDuration, storyAvatarSize, textStyle, containerStyle,
backgroundColor, videoProps, closeIconColor, modalAnimationDuration = STORY_ANIMATION_DURATION,
storyAnimationDuration = STORY_ANIMATION_DURATION, hideElementsOnLongPress, loopingStories = 'none',
statusBarTranslucent, loaderColor, loaderBackgroundColor, onLoad, onShow, onHide,
onSeenStoriesChange, onSwipeUp, onStoryStart, onStoryEnd, footerComponent, ...props
}, ref ) => {
const [ visible, setVisible ] = useState( false );
const x = useSharedValue( 0 );
const y = useSharedValue( HEIGHT );
const animation = useSharedValue( 0 );
const currentStory = useSharedValue( stories[0]?.stories[0]?.id );
const paused = useSharedValue( false );
const durationValue = useSharedValue( duration );
const isLongPress = useSharedValue( false );
const hideElements = useSharedValue( false );
const lastViewed = useSharedValue<{ [key: string]:number }>( {} );
const firstRender = useSharedValue( true );
const userIndex = useDerivedValue( () => Math.round( x.value / WIDTH ) );
const storyIndex = useDerivedValue( () => stories[userIndex.value]?.stories.findIndex(
( story ) => story.id === currentStory.value,
) );
const userId = useDerivedValue( () => stories[userIndex.value]?.id );
const previousUserId = useDerivedValue( () => stories[userIndex.value - 1]?.id );
const nextUserId = useDerivedValue( () => stories[userIndex.value + 1]?.id );
const previousStory = useDerivedValue( () => ( storyIndex.value !== undefined
? stories[userIndex.value]?.stories[storyIndex.value - 1]?.id
: undefined ) );
const nextStory = useDerivedValue( () => ( storyIndex.value !== undefined
? stories[userIndex.value]?.stories[storyIndex.value + 1]?.id
: undefined ) );
const animatedStyles = useAnimatedStyle( () => ( { top: y.value } ) );
const backgroundAnimatedStyles = useAnimatedStyle( () => ( {
opacity: interpolate( y.value, [ 0, HEIGHT ], [ 1, 0 ] ),
backgroundColor,
} ) );
const gestureContext = useSharedValue<GestureContext>( {
x: 0,
pressedX: 0,
pressedAt: 0,
moving: false,
vertical: false,
userId: undefined,
} );
const onClose = () => {
'worklet';
y.value = withTiming(
HEIGHT,
{ duration: modalAnimationDuration },
() => runOnJS( setVisible )( false ),
);
lastViewed.value = {};
cancelAnimation( animation );
};
const stopAnimation = () => {
'worklet';
cancelAnimation( animation );
};
const startAnimation = ( resume = false, newDuration?: number ) => {
'worklet';
if ( newDuration ) {
durationValue.value = newDuration;
} else {
newDuration = durationValue.value;
}
if ( resume ) {
newDuration -= animation.value * newDuration;
} else {
animation.value = 0;
if ( userId.value !== undefined && currentStory.value !== undefined ) {
runOnJS( onSeenStoriesChange )( userId.value, currentStory.value );
}
if ( userId.value !== undefined && storyIndex.value! >= 0 ) {
lastViewed.value = { ...lastViewed.value, [userId.value]: storyIndex.value ?? 0 };
}
}
animation.value = withTiming( 1, { duration: newDuration } );
};
const scrollTo = (
id: string,
animated = true,
sameUser = false,
previousUser?: string,
index?: number,
) => {
'worklet';
const newUserIndex = stories.findIndex( ( story ) => story.id === id );
const newX = newUserIndex * WIDTH;
x.value = animated ? withTiming( newX, { duration: storyAnimationDuration } ) : newX;
if ( sameUser ) {
startAnimation( true );
return;
}
if ( onStoryEnd && animated ) {
runOnJS( onStoryEnd )( previousUser ?? userId.value, currentStory.value );
}
const newStoryIndex = lastViewed.value[id] !== undefined
? lastViewed.value[id]!
: ( ( stories[newUserIndex]?.stories.findIndex(
( story ) => story.id === seenStories.value[id],
) ?? 0 ) + 1 );
const userStories = stories[newUserIndex]?.stories;
const newStory = userStories?.[index ?? newStoryIndex]?.id ?? userStories?.[0]?.id;
currentStory.value = newStory;
if ( onStoryStart ) {
runOnJS( onStoryStart )( id, newStory );
}
};
const toNextStory = ( value = true ) => {
'worklet';
if ( !value ) {
return;
}
if ( !nextStory.value ) {
if ( nextUserId.value ) {
scrollTo( nextUserId.value );
} else if ( stories[0]?.id && loopingStories === 'all' ) {
scrollTo( stories[0].id, false );
} else if ( userId.value && loopingStories === 'onlyLast' ) {
scrollTo( userId.value, false, undefined, undefined, 0 );
} else {
onClose();
}
} else {
if ( onStoryEnd ) {
runOnJS( onStoryEnd )( userId.value, currentStory.value );
}
if ( onStoryStart ) {
runOnJS( onStoryStart )( userId.value, nextStory.value );
}
animation.value = 0;
currentStory.value = nextStory.value;
}
};
const toPreviousStory = () => {
'worklet';
if ( !previousStory.value ) {
if ( previousUserId.value ) {
scrollTo( previousUserId.value );
} else {
return false;
}
} else {
if ( onStoryEnd ) {
runOnJS( onStoryEnd )( userId.value, currentStory.value );
}
if ( onStoryStart ) {
runOnJS( onStoryStart )( userId.value, previousStory.value );
}
animation.value = 0;
currentStory.value = previousStory.value;
}
return true;
};
const show = ( id: string ) => {
setVisible( true );
scrollTo( id, false );
};
const onGestureEvent = Gesture.Pan()
.onStart ( _ => {
gestureContext.value.x = x.value;
gestureContext.value.userId = userId.value;
paused.value = true;
} )
.onUpdate ( e => {
if ( gestureContext.value.x === x.value
&& ( gestureContext.value.vertical || ( Math.abs( e.velocityX ) < Math.abs( e.velocityY ) ) ) ) {
gestureContext.value.vertical = true;
y.value = e.translationY / 2;
} else {
gestureContext.value.moving = true;
x.value = Math.max(
0,
Math.min( gestureContext.value.x + -e.translationX, WIDTH * ( stories.length - 1 ) ),
);
}
} )
.onFinalize ( e => {
if ( gestureContext.value.vertical ) {
if ( e.translationY > 100 ) {
onClose();
} else {
if ( e.translationY < -100 && onSwipeUp ) {
runOnJS( onSwipeUp )(
stories[userIndex.value]?.id,
stories[userIndex.value]?.stories[storyIndex.value ?? 0]?.id,
);
}
y.value = withTiming( 0 );
startAnimation( true );
}
} else if ( gestureContext.value.moving ) {
const diff = x.value - gestureContext.value.x;
let newX;
if ( Math.abs( diff ) < WIDTH / 4 ) {
newX = gestureContext.value.x;
} else {
newX = diff > 0
? Math.ceil( x.value / WIDTH ) * WIDTH
: Math.floor( x.value / WIDTH ) * WIDTH;
}
const newUserId = stories[Math.round( newX / WIDTH )]?.id;
if ( newUserId !== undefined ) {
scrollTo(
newUserId,
true,
newUserId === gestureContext.value.userId,
gestureContext.value.userId,
);
}
}
gestureContext.value.moving = false;
gestureContext.value.vertical = false;
gestureContext.value.userId = undefined;
hideElements.value = false;
paused.value = false;
} );
const onPressIn = () => {
stopAnimation();
paused.value = true;
};
const onLongPress = () => {
isLongPress.value = true;
hideElements.value = hideElementsOnLongPress ?? false;
};
const onPressOut = () => {
if ( !isLongPress.value ) {
return;
}
hideElements.value = false;
isLongPress.value = false;
paused.value = false;
startAnimation( true );
};
const onPress = ( { nativeEvent: { locationX } }: GestureResponderEvent ) => {
hideElements.value = false;
if ( isLongPress.value ) {
onPressOut();
return;
}
if ( locationX < WIDTH / 2 ) {
const success = toPreviousStory();
if ( !success ) {
startAnimation( true );
}
} else {
toNextStory();
}
paused.value = false;
};
useImperativeHandle( ref, () => ( {
show,
hide: onClose,
pause: () => {
stopAnimation();
paused.value = true;
},
resume: () => {
startAnimation( true );
paused.value = false;
},
isPaused: () => paused.value,
getCurrentStory: () => ( { userId: userId.value, storyId: currentStory.value } ),
goToPreviousStory: toPreviousStory,
goToNextStory: toNextStory,
goToSpecificStory: ( newUserId, index ) => scrollTo( newUserId, true, false, undefined, index ),
} ) );
useEffect( () => {
if ( visible ) {
if ( currentStory.value !== undefined ) {
onShow?.( currentStory.value );
}
onLoad?.();
y.value = withTiming( 0, { duration: modalAnimationDuration } );
} else if ( currentStory.value !== undefined && !firstRender.value ) {
onHide?.( currentStory.value );
}
firstRender.value = false;
}, [ visible ] );
useAnimatedReaction(
() => animation.value,
( res, prev ) => res !== prev && toNextStory( res === 1 ),
);
return (
<Modal statusBarTranslucent={statusBarTranslucent} visible={visible} transparent animationType="none" testID="storyRNModal" onRequestClose={onClose}>
<GestureHandlerRootView style={{ flex: 1 }}>
<GestureDetector gesture={onGestureEvent}>
<Animated.View style={ModalStyles.container} testID="storyModal">
<Pressable
onPressIn={onPressIn}
onPress={onPress}
onLongPress={onLongPress}
onPressOut={onPressOut}
delayLongPress={LONG_PRESS_DURATION}
style={ModalStyles.container}
>
<Animated.View style={[ ModalStyles.bgAnimation, backgroundAnimatedStyles ]} />
<Animated.View style={[ ModalStyles.absolute, animatedStyles, containerStyle ]}>
{stories?.map( ( story, index ) => (
<StoryList
{...story}
index={index}
x={x}
activeUser={userId}
activeStory={currentStory}
progress={animation}
seenStories={seenStories}
onClose={onClose}
onLoad={( value ) => {
onLoad?.();
startAnimation(
undefined,
value !== undefined ? value : duration,
);
}}
avatarSize={storyAvatarSize}
textStyle={textStyle}
paused={paused}
videoProps={videoProps}
closeColor={closeIconColor}
hideElements={hideElements}
videoDuration={videoDuration}
loaderColor={loaderColor}
loaderBackgroundColor={loaderBackgroundColor}
key={story.id}
{...props}
/>
) )}
</Animated.View>
</Pressable>
{footerComponent && footerComponent}
</Animated.View>
</GestureDetector>
</GestureHandlerRootView>
</Modal>
);
} );
export default memo( StoryModal );
================================================
FILE: src/components/Progress/Progress.styles.ts
================================================
import { StyleSheet } from 'react-native';
export default StyleSheet.create( {
container: {
position: 'absolute',
top: 16,
left: 16,
height: 2,
flexDirection: 'row',
gap: 4,
},
item: {
height: 3,
borderRadius: 8,
overflow: 'hidden',
},
} );
================================================
FILE: src/components/Progress/index.tsx
================================================
import React, { FC, memo } from 'react';
import { View } from 'react-native';
import ProgressItem from './item';
import { WIDTH } from '../../core/constants';
import ProgressStyles from './Progress.styles';
import { StoryProgressProps } from '../../core/dto/componentsDTO';
const Progress: FC<StoryProgressProps> = ( {
progress, active, activeStory, length,
progressActiveColor, progressColor, progressContainerStyle,
} ) => {
const width = ( (
WIDTH - ProgressStyles.container.left * 2 ) - ( length - 1 )
* ProgressStyles.container.gap ) / length;
return (
<View style={[ ProgressStyles.container, progressContainerStyle, { width: WIDTH } ]}>
{[ ...Array( length ).keys() ].map( ( val ) => (
<ProgressItem
active={active}
activeStory={activeStory}
progress={progress}
index={val}
width={width}
key={val}
progressActiveColor={progressActiveColor}
progressColor={progressColor}
/>
) )}
</View>
);
};
export default memo( Progress );
================================================
FILE: src/components/Progress/item.tsx
================================================
import React, { FC, memo } from 'react';
import { View } from 'react-native';
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
import { StoryProgressItemProps } from '../../core/dto/componentsDTO';
import ProgressStyles from './Progress.styles';
import { PROGRESS_ACTIVE_COLOR, PROGRESS_COLOR } from '../../core/constants';
const AnimatedView = Animated.createAnimatedComponent( View );
const ProgressItem: FC<StoryProgressItemProps> = ( {
progress, active, activeStory, index, width,
progressActiveColor = PROGRESS_ACTIVE_COLOR, progressColor = PROGRESS_COLOR,
} ) => {
const animatedStyle = useAnimatedStyle( () => {
if ( !active.value || activeStory.value < index ) {
return { width: 0 };
}
if ( activeStory.value > index ) {
return { width };
}
return { width: width * progress.value };
} );
return (
<View style={[ ProgressStyles.item, { backgroundColor: progressColor }, { width } ]}>
<AnimatedView
style={[ ProgressStyles.item, { backgroundColor: progressActiveColor }, animatedStyle ]}
/>
</View>
);
};
export default memo( ProgressItem );
================================================
FILE: src/core/constants/index.ts
================================================
import { Dimensions } from 'react-native';
export const { width: WIDTH, height: HEIGHT } = Dimensions.get( 'screen' );
export const STORAGE_KEY = '@birdwingo/react-native-instagram-stories';
export const DEFAULT_COLORS = [ '#F7B801', '#F18701', '#F35B04', '#F5301E', '#C81D4E', '#8F1D4E' ];
export const LOADER_COLORS = [ '#FFF' ];
export const SEEN_LOADER_COLORS = [ '#2A2A2C' ];
export const PROGRESS_COLOR = '#00000099';
export const PROGRESS_ACTIVE_COLOR = '#FFFFFF';
export const BACKGROUND_COLOR = '#000000';
export const CLOSE_COLOR = '#00000099';
export const LOADER_ID = 'gradient';
export const LOADER_URL = `url(#${LOADER_ID})`;
export const STROKE_WIDTH = 2;
export const AVATAR_SIZE = 60;
export const AVATAR_OFFSET = 5;
export const STORY_AVATAR_SIZE = 26;
export const STORY_ANIMATION_DURATION = 800;
export const ANIMATION_DURATION = 10000;
export const LONG_PRESS_DURATION = 500;
================================================
FILE: src/core/dto/componentsDTO.ts
================================================
import { SharedValue } from 'react-native-reanimated';
import {
ImageProps, ImageStyle, TextProps, TextStyle, ViewStyle,
} from 'react-native';
import { ReactNode } from 'react';
import { InstagramStoriesProps, InstagramStoryProps, StoryItemProps } from './instagramStoriesDTO';
import { ProgressStorageProps } from './helpersDTO';
export interface StoryAvatarListProps {
stories: InstagramStoryProps[];
loadingStory: StoryAvatarProps['loadingStory'];
seenStories: StoryAvatarProps['seenStories'];
colors: StoryAvatarProps['colors'];
seenColors: StoryAvatarProps['seenColors'];
size: StoryAvatarProps['size'];
showName: InstagramStoriesProps['showName'];
nameTextStyle: InstagramStoriesProps['nameTextStyle'];
nameTextProps: InstagramStoriesProps['nameTextProps'];
avatarListContainerStyle: InstagramStoriesProps['avatarListContainerStyle'];
avatarListContainerProps: InstagramStoriesProps['avatarListContainerProps'];
avatarBorderRadius?: number;
onPress: ( id: string ) => void;
}
export interface StoryAvatarProps extends InstagramStoryProps {
loadingStory: SharedValue<string | undefined>;
seenStories: SharedValue<ProgressStorageProps>;
onPress: () => void;
colors: string[];
seenColors: string[];
size: number;
showName?: boolean;
nameTextStyle?: TextStyle;
nameTextProps?: TextProps;
avatarBorderRadius?: number;
}
export interface StoryLoaderProps {
loading: SharedValue<boolean>;
color: SharedValue<string[]>;
size?: number;
}
export interface StoryModalProps {
stories: InstagramStoryProps[];
seenStories: SharedValue<ProgressStorageProps>;
duration: number;
videoDuration?: number;
storyAvatarSize: number;
textStyle?: TextStyle;
containerStyle?: ViewStyle;
backgroundColor?: string;
videoProps?: any;
closeIconColor: string;
progressActiveColor?: string;
progressColor?: string;
modalAnimationDuration?: number;
storyAnimationDuration?: number;
mediaContainerStyle?: ViewStyle;
imageStyles?: ImageStyle;
imageProps?: ImageProps;
footerComponent?: ReactNode;
hideElementsOnLongPress?: boolean;
hideOverlayViewOnLongPress?: boolean;
loopingStories?: 'none' | 'all' | 'onlyLast';
statusBarTranslucent?: boolean;
loaderColor?: string;
loaderBackgroundColor?: string;
onLoad: () => void;
onShow?: ( id: string ) => void;
onHide?: ( id: string ) => void;
onSeenStoriesChange: ( user: string, value: string ) => void;
onSwipeUp?: ( userId?: string, storyId?: string ) => void;
onStoryStart?: ( userId?: string, storyId?: string ) => void;
onStoryEnd?: ( userId?: string, storyId?: string ) => void;
}
export type StoryModalPublicMethods = {
show: ( id: string ) => void;
hide: () => void;
pause: () => void;
resume: () => void;
isPaused: () => boolean;
goToPreviousStory: () => void;
goToNextStory: () => void;
getCurrentStory: () => { userId?: string, storyId?: string };
goToSpecificStory: ( userId: string, index?: number ) => void;
};
export type GestureContext = {
x: number,
pressedX: number,
pressedAt: number,
moving: boolean,
vertical: boolean,
userId?: string,
};
export interface AnimationProps {
children: React.ReactNode;
x: SharedValue<number>;
index: number;
}
export interface StoryImageProps {
stories: InstagramStoryProps['stories'];
activeStory: SharedValue<string | undefined>;
defaultStory?: StoryItemProps;
isDefaultVideo: boolean;
paused: SharedValue<boolean>;
videoProps?: any;
mediaContainerStyle?: ViewStyle;
isActive: SharedValue<boolean>;
imageStyles?: ImageStyle;
imageProps?: ImageProps;
videoDuration?: number;
loaderColor?: string;
loaderBackgroundColor?: string;
onImageLayout: ( height: number ) => void;
onLoad: ( duration?: number ) => void;
}
export interface StoryProgressProps {
progress: SharedValue<number>;
active: SharedValue<boolean>;
activeStory: SharedValue<number>;
length: number;
progressActiveColor?: string;
progressColor?: string;
progressContainerStyle?: ViewStyle;
}
export interface StoryProgressItemProps extends Omit<StoryProgressProps, 'length'> {
index: number;
width: number;
}
export interface StoryHeaderProps {
avatarSource: ImageProps['source'];
name?: string;
avatarSize: number;
textStyle?: TextStyle;
closeColor: string;
headerStyle?: ViewStyle;
headerContainerStyle?: ViewStyle;
onClose: () => void;
renderStoryHeader?: () => ReactNode;
onStoryHeaderPress?: () => void;
}
export interface IconProps {
color: string;
}
export interface StoryContentProps {
stories: InstagramStoryProps['stories'];
active: SharedValue<boolean>;
activeStory: SharedValue<string | undefined>;
}
export interface StoryListProps extends InstagramStoryProps, StoryHeaderProps {
index: number;
x: SharedValue<number>;
activeUser: SharedValue<string | undefined>;
activeStory: SharedValue<string | undefined>;
progress: SharedValue<number>;
seenStories: SharedValue<ProgressStorageProps>;
paused: SharedValue<boolean>;
videoProps?: any;
progressActiveColor?: string;
progressColor?: string;
mediaContainerStyle?: ViewStyle;
imageStyles?: ImageStyle;
imageProps?: ImageProps;
progressContainerStyle?: ViewStyle;
imageOverlayView?: ReactNode;
hideElements: SharedValue<boolean>;
hideOverlayViewOnLongPress?: boolean;
videoDuration?: number;
loaderColor?: string;
loaderBackgroundColor?: string;
onLoad: ( duration?: number ) => void;
}
export interface StoryVideoProps {
source: ImageProps['source'];
paused: SharedValue<boolean>;
isActive: SharedValue<boolean>;
onLoad: ( duration: number ) => void;
onLayout: ( height: number ) => void;
}
================================================
FILE: src/core/dto/helpersDTO.ts
================================================
export interface ProgressStorageProps {
[key: string]: string;
}
================================================
FILE: src/core/dto/instagramStoriesDTO.ts
================================================
import { ReactNode } from 'react';
import {
ImageProps,
ImageStyle,
ScrollViewProps, TextStyle, ViewStyle, TextProps,
} from 'react-native';
import { FlashListProps } from '@shopify/flash-list';
export interface StoryItemProps {
id: string;
source: ImageProps['source'];
mediaType?: 'image' | 'video';
animationDuration?: number;
renderContent?: () => ReactNode;
renderFooter?: () => ReactNode;
}
export interface InstagramStoryProps {
id: string;
avatarSource: ImageProps['source'];
renderAvatar?: ( seen: boolean ) => ReactNode;
renderStoryHeader?: () => ReactNode;
onStoryHeaderPress?: () => void;
name?: string;
stories: StoryItemProps[];
}
export interface InstagramStoriesProps {
stories: InstagramStoryProps[];
saveProgress?: boolean;
avatarBorderColors?: string[];
avatarSeenBorderColors?: string[];
avatarSize?: number;
storyAvatarSize?: number;
avatarListContainerStyle?: ScrollViewProps['contentContainerStyle'];
avatarListContainerProps?: ScrollViewProps | Partial<FlashListProps<InstagramStoryProps>>;
containerStyle?: ViewStyle;
textStyle?: TextStyle;
animationDuration?: number;
videoAnimationMaxDuration?: number;
backgroundColor?: string;
showName?: boolean;
nameTextStyle?: TextStyle;
nameTextProps?: TextProps;
videoProps?: any;
closeIconColor?: string;
progressActiveColor?: string;
progressColor?: string;
modalAnimationDuration?: number;
storyAnimationDuration?: number;
mediaContainerStyle?: ViewStyle;
imageStyles?: ImageStyle;
imageProps?: Omit<ImageProps, 'source'>;
isVisible?: boolean;
headerStyle?: ViewStyle;
headerContainerStyle?: ViewStyle;
progressContainerStyle?: ViewStyle;
hideAvatarList?: boolean;
imageOverlayView?: ReactNode;
hideElementsOnLongPress?: boolean;
hideOverlayViewOnLongPress?: boolean;
loopingStories?: 'none' | 'all' | 'onlyLast';
statusBarTranslucent?: boolean;
footerComponent?: ReactNode;
avatarBorderRadius?: number;
loaderColor?: string;
loaderBackgroundColor?: string;
onShow?: ( id: string ) => void;
onHide?: ( id: string ) => void;
onSwipeUp?: ( userId?: string, storyId?: string ) => void;
onStoryStart?: ( userId?: string, storyId?: string ) => void;
onStoryEnd?: ( userId?: string, storyId?: string ) => void;
}
export type InstagramStoriesPublicMethods = {
spliceStories: ( stories: InstagramStoryProps[], index?: number ) => void;
spliceUserStories: ( stories: StoryItemProps[], user: string, index?: number ) => void;
setStories: ( stories: InstagramStoryProps[] ) => void;
clearProgressStorage: () => void;
hide: () => void;
show: ( id?: string ) => void;
pause: () => void;
resume: () => void;
goToPreviousStory: () => void;
goToNextStory: () => void;
getCurrentStory: () => { userId?: string, storyId?: string };
goToSpecificStory: ( userId: string, index?: number ) => void;
};
================================================
FILE: src/core/helpers/storage.ts
================================================
/* eslint-disable global-require */
import { STORAGE_KEY } from '../constants';
import { ProgressStorageProps } from '../dto/helpersDTO';
export const clearProgressStorage = async () => {
try {
const AsyncStorage = require( '@react-native-async-storage/async-storage' ).default;
return AsyncStorage.removeItem( STORAGE_KEY );
} catch ( error ) {
return null;
}
};
export const getProgressStorage = async (): Promise<ProgressStorageProps> => {
try {
const AsyncStorage = require( '@react-native-async-storage/async-storage' ).default;
const progress = await AsyncStorage.getItem( STORAGE_KEY );
return progress ? JSON.parse( progress ) : {};
} catch ( error ) {
return {};
}
};
export const setProgressStorage = async ( user: string, lastSeen: string ) => {
const progress = await getProgressStorage();
progress[user] = lastSeen;
try {
const AsyncStorage = require( '@react-native-async-storage/async-storage' ).default;
await AsyncStorage.setItem( STORAGE_KEY, JSON.stringify( progress ) );
return progress;
} catch ( error ) {
return {};
}
};
================================================
FILE: src/declarations.d.ts
================================================
declare module '*.png';
declare module '*.gif';
declare interface Keyframe {
composite?: 'accumulate' | 'add' | 'auto' | 'replace';
easing?: string;
offset?: number | null;
[property: string]: string | number | null | undefined;
}
================================================
FILE: src/index.tsx
================================================
import InstagramStories from './components/InstagramStories';
import { InstagramStoriesProps, InstagramStoriesPublicMethods } from './core/dto/instagramStoriesDTO';
export type { InstagramStoriesProps, InstagramStoriesPublicMethods };
export default InstagramStories;
================================================
FILE: tests/index.test.js
================================================
import { createRef } from 'react';
import { render, fireEvent, act } from '@testing-library/react-native';
import * as Reanimated from 'react-native-reanimated';
import { Platform, View } from 'react-native';
import InstagramStories from '../src';
import { WIDTH } from '../src/core/constants';
import StoryAvatar from '../src/components/Avatar';
import StoryImage from '../src/components/Image';
import Loader from '../src/components/Loader';
import * as Storage from '../src/core/helpers/storage';
const reactions = new Map();
const sleep = async ( ms ) => new Promise( ( resolve ) => setTimeout( resolve, ms ?? 250 ) );
jest.spyOn( Reanimated, 'useAnimatedReaction' ).mockImplementation( ( value, cb ) => {
if ( reactions.has( `${cb}` ) && reactions.get( `${cb}` ) === value() ) {
return;
}
reactions.set( `${cb}`, value() );
cb( value(), '' );
} );
jest.spyOn( Reanimated, 'useSharedValue' ).mockImplementation( ( value ) => ( { value } ) );
jest.spyOn( Storage, 'getProgressStorage' ).mockImplementation( () => ( {} ) );
jest.spyOn( Storage, 'setProgressStorage' ).mockImplementation( () => ( {} ) );
const stories = [ {
id: '1',
name: 'John Doe',
avatarSource: { uri: 'https://picsum.photos/200/300' },
stories: [ {
id: '1',
source: { uri: 'https://picsum.photos/200/300' },
renderContent: () => <View />,
} ],
} ];
const stories2 = [ {
id: '1',
name: 'John Doe',
avatarSource: { uri: 'https://picsum.photos/200/300' },
stories: [ {
id: '1',
source: { uri: 'https://picsum.photos/200/300' },
} ],
}, {
id: '2',
name: 'John Doe 2',
avatarSource: { uri: 'https://picsum.photos/200/300' },
stories: [ {
id: '1',
source: { uri: 'https://picsum.photos/200/300' },
} ],
} ];
const stories3 = [ {
id: '1',
name: 'John Doe',
avatarSource: { uri: 'https://picsum.photos/200/300' },
stories: [ {
id: '1',
source: { uri: 'https://picsum.photos/200/300' },
}, {
id: '2',
source: { uri: 'https://picsum.photos/200/300' },
} ],
} ];
const stories4 = [ {
id: '1',
name: 'John Doe',
avatarSource: { uri: 'https://picsum.photos/200/300' },
stories: [ {
id: '1',
source: { uri: 'https://picsum.photos/200/300' },
mediaType: 'video',
} ],
}, {
id: '2',
name: 'John Doe 2',
avatarSource: { uri: 'https://picsum.photos/200/300' },
stories: [ {
id: '1',
source: { uri: 'https://picsum.photos/200/300' },
mediaType: 'video',
} ],
} ];
describe( 'Instagram Stories test', () => {
beforeEach( () => {
reactions.clear();
} );
it( 'Should render the stories list', () => {
const { getByTestId } = render( <InstagramStories stories={stories} /> );
expect( getByTestId( 'storiesList' ) ).toBeTruthy();
} );
it( 'Should open story on press', async () => {
const { getByTestId } = render( <InstagramStories stories={stories} showName /> );
const story = getByTestId( '1StoryAvatar1Story' );
expect( story ).toBeTruthy();
await act( async () => {
fireEvent( story, 'click' );
await sleep();
expect( getByTestId( 'storyModal' ) ).toBeTruthy();
} );
} );
it( 'Should work with saveProgress', async () => {
jest.spyOn( Storage, 'getProgressStorage' ).mockImplementation( () => ( { 1: '1' } ) );
const { getByTestId, getAllByTestId } = render(
<InstagramStories
stories={stories2}
saveProgress
/>,
);
await act( async () => {
fireEvent( getByTestId( '1StoryAvatar1Story' ), 'click' );
await sleep();
getAllByTestId( 'storyAvatarImage' ).forEach( ( element ) => {
element.props.onLoad();
} );
getAllByTestId( 'storyImageComponent' ).forEach( ( element ) => {
element.props.onLoad();
} );
getAllByTestId( 'storyImageComponent' ).forEach( ( element ) => {
element.props.onLayout( { nativeEvent: { layout: { height: 100 } } } );
} );
getAllByTestId( 'storyCloseButton' ).forEach( ( element ) => {
fireEvent( element.parent, 'onPressIn', { nativeEvent: { locationX: 0, locationY: 0 } } );
} );
} );
} );
it( 'Should work if new seen story is older than saved', async () => {
jest.spyOn( Storage, 'getProgressStorage' ).mockImplementation( () => ( { 1: '2' } ) );
const { getByTestId } = render(
<InstagramStories
stories={stories3}
saveProgress
/>,
);
await act( async () => {
fireEvent( getByTestId( '1StoryAvatar2Story' ), 'click' );
await sleep();
} );
} );
it( 'Should work with public methods', async () => {
const ref = createRef();
const { getByTestId, queryByTestId } = render(
<InstagramStories
stories={stories}
ref={ref}
saveProgress
/>,
);
await act( async () => {
fireEvent( getByTestId( '1StoryAvatar1Story' ), 'click' );
await sleep();
expect( getByTestId( 'storyModal' ) ).toBeTruthy();
} );
await act( async () => {
ref.current.clearProgressStorage();
ref.current.hide();
await sleep();
expect( queryByTestId( 'storyModal' ) ).toBeFalsy();
ref.current.spliceStories( [ {
id: '2',
name: 'John Doe 2',
avatarSource: { uri: 'https://picsum.photos/200/300' },
stories: [ {
id: '1',
source: { uri: 'https://picsum.photos/200/300' },
} ],
} ] );
await sleep();
ref.current.spliceStories( [ {
id: '3',
name: 'John Doe 3',
avatarSource: { uri: 'https://picsum.photos/200/300' },
stories: [ {
id: '1',
source: { uri: 'https://picsum.photos/200/300' },
} ],
} ], -1 );
await sleep();
ref.current.spliceUserStories( [ {
id: '2',
source: { uri: 'https://picsum.photos/200/300' },
} ], '1' );
await sleep();
ref.current.spliceUserStories( [ {
id: '2',
source: { uri: 'https://picsum.photos/200/300' },
} ], '2', 2 );
ref.current.spliceUserStories( [ {
id: '2',
source: { uri: 'https://picsum.photos/200/300' },
} ], '20', 2 );
await sleep();
expect( getByTestId( '1StoryAvatar2Story' ) ).toBeTruthy();
expect( getByTestId( '2StoryAvatar2Story' ) ).toBeTruthy();
expect( getByTestId( '3StoryAvatar1Story' ) ).toBeTruthy();
ref.current.setStories( stories );
await sleep();
expect( getByTestId( '1StoryAvatar1Story' ) ).toBeTruthy();
ref.current.show();
ref.current.show('1');
} );
} );
it( 'Should not open if empty array', async () => {
const ref = createRef();
const { queryByTestId } = render(
<InstagramStories
stories={[]}
ref={ref}
saveProgress
/>,
);
await act( async () => {
ref.current.show();
await sleep();
expect( queryByTestId( 'storyModal' ) ).toBeFalsy();
} );
} );
it( 'Should work animations', async () => {
const { getByTestId, queryByTestId } = render( <InstagramStories stories={stories} /> );
await act( async () => {
fireEvent( getByTestId( '1StoryAvatar1Story' ), 'click' );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderStart', { x: 0 }, {} );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderMove', {
x: 0, velocityX: 0, velocityY: 10, translationY: 10,
}, { x: 0 } );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderMove', {
x: 0, velocityX: 10, velocityY: 0, translationX: 10,
}, { x: 0 } );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', { translationY: 10 }, { vertical: true } );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', {}, { moving: true, x: 10 } );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', {}, { moving: true, x: 300 } );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', {}, { moving: true, x: -300 } );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderStart', { x: 0 } );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderMove', {
x: 0, velocityX: 0, velocityY: 10, translationY: 200,
} );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', { translationY: 200 } );
await sleep();
expect( queryByTestId( 'gestureContainer' ) ).toBeFalsy();
} );
} );
it( 'Should not continue if button pressed', async () => {
jest.spyOn( Reanimated, 'useSharedValue' ).mockImplementation( ( value ) => ( { value: value === false ? true : value } ) );
const { getByTestId } = render( <InstagramStories stories={stories} /> );
await act( async () => {
fireEvent( getByTestId( '1StoryAvatar1Story' ), 'click' );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', {}, {} );
await sleep();
} );
} );
it( 'Should close with swipe down', async () => {
jest.spyOn( Reanimated, 'useSharedValue' ).mockImplementation( ( value ) => ( { value } ) );
const { getByTestId, queryByTestId } = render( <InstagramStories stories={stories} /> );
await act( async () => {
fireEvent( getByTestId( '1StoryAvatar1Story' ), 'click' );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderStart', { x: 0 } );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderMove', {
x: 0, velocityX: 0, velocityY: 10, translationY: 200,
} );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', { translationY: 200 } );
await sleep();
expect( queryByTestId( 'gestureContainer' ) ).toBeFalsy();
} );
} );
it( 'Should go to next story', async () => {
const { getByTestId } = render( <InstagramStories stories={stories3} /> );
await act( async () => {
fireEvent( getByTestId( '1StoryAvatar2Story' ), 'click' );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', {}, {} );
await sleep();
} );
} );
it( 'Should go to next user', async () => {
const { getByTestId } = render( <InstagramStories stories={stories2} /> );
await act( async () => {
fireEvent( getByTestId( '1StoryAvatar1Story' ), 'click' );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', {}, {} );
await sleep();
} );
} );
it( 'Should go to previous story', async () => {
jest.spyOn( Reanimated, 'useSharedValue' ).mockImplementation( ( value ) => ( { value: value === '1' ? '2' : value } ) );
const { getByTestId } = render( <InstagramStories stories={stories3} /> );
await act( async () => {
fireEvent( getByTestId( '1StoryAvatar2Story' ), 'click' );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', {}, { pressedX: 0, pressedAt: Date.now() } );
await sleep();
} );
} );
it( 'Should go to previous user + work for android', async () => {
Platform.OS = 'android';
jest.spyOn( Reanimated, 'useSharedValue' ).mockImplementation( ( value ) => ( { value: value === 0 ? 500 : value } ) );
jest.spyOn( Reanimated, 'interpolate' ).mockImplementation( () => WIDTH );
const { getByTestId } = render( <InstagramStories stories={stories2} /> );
await act( async () => {
fireEvent( getByTestId( '2StoryAvatar1Story' ), 'click' );
await sleep();
fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', {}, { pressedX: 0, pressedAt: Date.now() } );
await sleep();
} );
} );
it( 'Should work with video', async () => {
const { getByTestId } = render( <InstagramStories stories={stories4} /> );
await act( async () => {
fireEvent( getByTestId( '2StoryAvatar1Story' ), 'click' );
await sleep();
} );
} );
it( 'Should work with empty array', async () => {
render( <InstagramStories stories={[ {
id: '1',
name: 'John Doe',
avatarSource: { uri: 'https://picsum.photos/200/300' },
stories: [],
} ]} /> );
} );
it( 'Should work with video & default duration', async () => {
const { getByTestId } = render( <InstagramStories stories={stories4} videoAnimationMaxDuration={1000} /> );
await act( async () => {
fireEvent( getByTestId( '2StoryAvatar1Story' ), 'click' );
await sleep();
} );
} );
} );
describe( 'StoryAvatar test', () => {
it( 'Should work with seenStories & call onPress', () => {
jest.spyOn( Reanimated, 'useSharedValue' ).mockImplementation( ( value ) => ( { value: value === false ? true : value } ) );
const callback = jest.fn();
const { getByTestId } = render( <StoryAvatar {...stories[0]} seenStories={{ value: { 1: '1' } }} loadingStory={{ value: '2' }} onPress={callback} colors={[ '#FFF' ]} seenColors={[ '#FFF' ]} size={50} /> );
expect( getByTestId( '1StoryAvatar1Story' ) ).toBeTruthy();
act( () => {
fireEvent.press( getByTestId( '1StoryAvatar1Story' ) );
expect( callback ).toHaveBeenCalled();
} );
} );
} );
describe( 'Loader test', () => {
it( 'Should work with empty stories', () => {
jest.spyOn( Reanimated, 'useAnimatedReaction' ).mockImplementation( ( value, cb ) => cb( typeof value() !== 'boolean' ? [ '#AAA' ] : value() ) );
render( <Loader loading={{ value: false }} color={{ value: [ '#FFF' ] }} /> );
} );
} );
describe( 'Story Image test', () => {
it( 'Should work with wrong story', () => {
render( <StoryImage stories={stories[0].stories} active={{ value: true }} activeStory={{ value: '2' }} defaultImage="url" paused={{ value: true }} isActive={{ value: true }} /> );
} );
it( 'Should work if story already loaded', async () => {
jest.spyOn(Reanimated, 'useSharedValue').mockImplementation((value) => ({ value: value === true ? false : value }));
const onLoad = jest.fn();
render( <StoryImage
stories={[ { id: '1', source: { uri: '' } } ]}
active={{ value: true }}
activeStory={{ value: '1' }}
defaultImage="url"
onLoad={onLoad}
paused={{ value: true }}
isActive={{ value: true }}
/> );
expect( onLoad ).toHaveBeenCalled();
} );
} );
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"lib": [
"ES2021"
],
"allowJs": true,
"jsx": "react-native",
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"declarationDir": "./dist",
"noEmit": true,
"incremental": true,
"isolatedModules": true,
"strict": true,
"noImplicitAny": true,
"moduleResolution": "node",
"baseUrl": "./src",
"paths": {
"~/*": [
"*"
]
},
"types": ["react"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": false,
"resolveJsonModule": true,
"noUncheckedIndexedAccess": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"modules",
"babel.config.js",
"metro.config.js",
"jest.config.js",
"commitlint.config.js",
]
}
gitextract_ypb2kcij/ ├── .eslintrc.js ├── .github/ │ └── workflows/ │ ├── public.yml │ └── release.yml ├── .gitignore ├── .husky/ │ └── commit-msg ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── commitlint.config.js ├── jest.setup.js ├── package.json ├── src/ │ ├── components/ │ │ ├── Animation/ │ │ │ ├── Animation.styles.ts │ │ │ └── index.tsx │ │ ├── Avatar/ │ │ │ ├── Avatar.styles.ts │ │ │ └── index.tsx │ │ ├── AvatarList/ │ │ │ └── index.tsx │ │ ├── Content/ │ │ │ ├── Content.styles.ts │ │ │ └── index.tsx │ │ ├── Footer/ │ │ │ ├── Footer.styles.ts │ │ │ └── index.tsx │ │ ├── Header/ │ │ │ ├── Header.styles.ts │ │ │ └── index.tsx │ │ ├── Icon/ │ │ │ ├── close.tsx │ │ │ └── index.tsx │ │ ├── Image/ │ │ │ ├── Image.styles.ts │ │ │ ├── index.tsx │ │ │ └── video.tsx │ │ ├── InstagramStories/ │ │ │ ├── InstagramStories.styles.ts │ │ │ └── index.tsx │ │ ├── List/ │ │ │ ├── List.styles.ts │ │ │ └── index.tsx │ │ ├── Loader/ │ │ │ └── index.tsx │ │ ├── Modal/ │ │ │ ├── Modal.styles.ts │ │ │ ├── gesture.tsx │ │ │ └── index.tsx │ │ └── Progress/ │ │ ├── Progress.styles.ts │ │ ├── index.tsx │ │ └── item.tsx │ ├── core/ │ │ ├── constants/ │ │ │ └── index.ts │ │ ├── dto/ │ │ │ ├── componentsDTO.ts │ │ │ ├── helpersDTO.ts │ │ │ └── instagramStoriesDTO.ts │ │ └── helpers/ │ │ └── storage.ts │ ├── declarations.d.ts │ └── index.tsx ├── tests/ │ └── index.test.js └── tsconfig.json
SYMBOL INDEX (41 symbols across 6 files)
FILE: jest.setup.js
method onStart (line 99) | onStart( cb ) {
method onUpdate (line 105) | onUpdate( cb ) {
method onFinalize (line 111) | onFinalize( cb ) {
FILE: src/core/constants/index.ts
constant STORAGE_KEY (line 5) | const STORAGE_KEY = '@birdwingo/react-native-instagram-stories';
constant DEFAULT_COLORS (line 7) | const DEFAULT_COLORS = [ '#F7B801', '#F18701', '#F35B04', '#F5301E', '#C...
constant LOADER_COLORS (line 8) | const LOADER_COLORS = [ '#FFF' ];
constant SEEN_LOADER_COLORS (line 9) | const SEEN_LOADER_COLORS = [ '#2A2A2C' ];
constant PROGRESS_COLOR (line 10) | const PROGRESS_COLOR = '#00000099';
constant PROGRESS_ACTIVE_COLOR (line 11) | const PROGRESS_ACTIVE_COLOR = '#FFFFFF';
constant BACKGROUND_COLOR (line 12) | const BACKGROUND_COLOR = '#000000';
constant CLOSE_COLOR (line 13) | const CLOSE_COLOR = '#00000099';
constant LOADER_ID (line 15) | const LOADER_ID = 'gradient';
constant LOADER_URL (line 16) | const LOADER_URL = `url(#${LOADER_ID})`;
constant STROKE_WIDTH (line 18) | const STROKE_WIDTH = 2;
constant AVATAR_SIZE (line 20) | const AVATAR_SIZE = 60;
constant AVATAR_OFFSET (line 21) | const AVATAR_OFFSET = 5;
constant STORY_AVATAR_SIZE (line 22) | const STORY_AVATAR_SIZE = 26;
constant STORY_ANIMATION_DURATION (line 24) | const STORY_ANIMATION_DURATION = 800;
constant ANIMATION_DURATION (line 25) | const ANIMATION_DURATION = 10000;
constant LONG_PRESS_DURATION (line 26) | const LONG_PRESS_DURATION = 500;
FILE: src/core/dto/componentsDTO.ts
type StoryAvatarListProps (line 9) | interface StoryAvatarListProps {
type StoryAvatarProps (line 25) | interface StoryAvatarProps extends InstagramStoryProps {
type StoryLoaderProps (line 38) | interface StoryLoaderProps {
type StoryModalProps (line 44) | interface StoryModalProps {
type StoryModalPublicMethods (line 78) | type StoryModalPublicMethods = {
type GestureContext (line 90) | type GestureContext = {
type AnimationProps (line 99) | interface AnimationProps {
type StoryImageProps (line 105) | interface StoryImageProps {
type StoryProgressProps (line 123) | interface StoryProgressProps {
type StoryProgressItemProps (line 133) | interface StoryProgressItemProps extends Omit<StoryProgressProps, 'lengt...
type StoryHeaderProps (line 138) | interface StoryHeaderProps {
type IconProps (line 151) | interface IconProps {
type StoryContentProps (line 155) | interface StoryContentProps {
type StoryListProps (line 161) | interface StoryListProps extends InstagramStoryProps, StoryHeaderProps {
type StoryVideoProps (line 185) | interface StoryVideoProps {
FILE: src/core/dto/helpersDTO.ts
type ProgressStorageProps (line 1) | interface ProgressStorageProps {
FILE: src/core/dto/instagramStoriesDTO.ts
type StoryItemProps (line 9) | interface StoryItemProps {
type InstagramStoryProps (line 18) | interface InstagramStoryProps {
type InstagramStoriesProps (line 28) | interface InstagramStoriesProps {
type InstagramStoriesPublicMethods (line 75) | type InstagramStoriesPublicMethods = {
FILE: src/declarations.d.ts
type Keyframe (line 4) | interface Keyframe {
Condensed preview — 48 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (116K chars).
[
{
"path": ".eslintrc.js",
"chars": 2012,
"preview": "module.exports = {\n\t\"root\": true,\n\t\"env\": {\n\t\t\"es2021\": true,\n\t\t\"node\": true\n\t},\n\t\"parser\": \"@typescript-eslint/parser\","
},
{
"path": ".github/workflows/public.yml",
"chars": 1256,
"preview": "name: NPM Package Publish\non:\n release:\n types: [created]\n \njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n "
},
{
"path": ".github/workflows/release.yml",
"chars": 1474,
"preview": "name: Github Release\non:\n pull_request:\n types: [closed]\n branch: main\njobs:\n release:\n if: github.event.pull"
},
{
"path": ".gitignore",
"chars": 29,
"preview": "/node_modules\n/coverage\n/dist"
},
{
"path": ".husky/commit-msg",
"chars": 100,
"preview": "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpx --no -- commitlint --edit ${1}\nnpm run test"
},
{
"path": "CHANGELOG.md",
"chars": 5244,
"preview": "# Changelog\n\nAll notable changes to this project will be documented in this file. See [standard-version](https://github."
},
{
"path": "LICENSE",
"chars": 1066,
"preview": "MIT License\n\nCopyright (c) 2023 Birdwingo\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n"
},
{
"path": "README.md",
"chars": 16692,
"preview": "# @birdwingo/react-native-instagram-stories\n\n => {\n\n const View = require('react-native').View;\n\n return {\n Value: jest."
},
{
"path": "package.json",
"chars": 2515,
"preview": "{\n \"name\": \"@birdwingo/react-native-instagram-stories\",\n \"version\": \"1.3.18\",\n \"description\": \"A versatile and captiv"
},
{
"path": "src/components/Animation/Animation.styles.ts",
"chars": 244,
"preview": "import { StyleSheet } from 'react-native';\n\nexport default StyleSheet.create( {\n container: StyleSheet.absoluteFillObje"
},
{
"path": "src/components/Animation/index.tsx",
"chars": 2080,
"preview": "import React, { FC, memo } from 'react';\nimport { Platform } from 'react-native';\nimport Animated, { Extrapolation, inte"
},
{
"path": "src/components/Avatar/Avatar.styles.ts",
"chars": 344,
"preview": "import { StyleSheet } from 'react-native';\nimport { AVATAR_OFFSET } from '../../core/constants';\n\nexport default StyleSh"
},
{
"path": "src/components/Avatar/index.tsx",
"chars": 2307,
"preview": "import React, { FC, memo } from 'react';\nimport {\n View, Image, Text, TouchableOpacity,\n} from 'react-native';\nimport A"
},
{
"path": "src/components/AvatarList/index.tsx",
"chars": 1786,
"preview": "import React, { FC, memo } from 'react';\nimport { ScrollView } from 'react-native';\nimport StoryAvatar from '../Avatar';"
},
{
"path": "src/components/Content/Content.styles.ts",
"chars": 185,
"preview": "import { StyleSheet } from 'react-native';\n\nexport default StyleSheet.create( {\n container: {\n position: 'absolute',"
},
{
"path": "src/components/Content/index.tsx",
"chars": 1130,
"preview": "import React, {\n FC, memo, useState, useMemo,\n} from 'react';\nimport { View } from 'react-native';\nimport { runOnJS, us"
},
{
"path": "src/components/Footer/Footer.styles.ts",
"chars": 173,
"preview": "import { StyleSheet } from 'react-native';\n\nexport default StyleSheet.create( {\n container: {\n position: 'absolute',"
},
{
"path": "src/components/Footer/index.tsx",
"chars": 1123,
"preview": "import React, {\n FC, memo, useState, useMemo,\n} from 'react';\nimport { View } from 'react-native';\nimport { runOnJS, us"
},
{
"path": "src/components/Header/Header.styles.ts",
"chars": 452,
"preview": "import { StyleSheet } from 'react-native';\n\nexport default StyleSheet.create( {\n container: {\n position: 'absolute',"
},
{
"path": "src/components/Header/index.tsx",
"chars": 1633,
"preview": "import React, { FC, memo } from 'react';\nimport {\n View, Text, Image, TouchableOpacity,\n Pressable,\n} from 'react-nati"
},
{
"path": "src/components/Icon/close.tsx",
"chars": 943,
"preview": "import React, { FC, memo } from 'react';\nimport { Path, Svg } from 'react-native-svg';\nimport { IconProps } from '../../"
},
{
"path": "src/components/Icon/index.tsx",
"chars": 48,
"preview": "import Close from './close';\n\nexport { Close };\n"
},
{
"path": "src/components/Image/Image.styles.ts",
"chars": 328,
"preview": "import { StyleSheet } from 'react-native';\n\nexport default StyleSheet.create( {\n container: {\n position: 'absolute',"
},
{
"path": "src/components/Image/index.tsx",
"chars": 3806,
"preview": "import { Image, View } from 'react-native';\nimport React, { FC, memo, useState } from 'react';\nimport Animated, {\n runO"
},
{
"path": "src/components/Image/video.tsx",
"chars": 1447,
"preview": "import React, {\n FC, memo, useRef, useState,\n} from 'react';\nimport { LayoutChangeEvent } from 'react-native';\nimport {"
},
{
"path": "src/components/InstagramStories/InstagramStories.styles.ts",
"chars": 0,
"preview": ""
},
{
"path": "src/components/InstagramStories/index.tsx",
"chars": 6629,
"preview": "import React, {\n forwardRef, useImperativeHandle, useState, useEffect, useRef, memo,\n} from 'react';\nimport { useShared"
},
{
"path": "src/components/List/List.styles.ts",
"chars": 327,
"preview": "import { StyleSheet } from 'react-native';\nimport { WIDTH } from '../../core/constants';\n\nexport default StyleSheet.crea"
},
{
"path": "src/components/List/index.tsx",
"chars": 3688,
"preview": "import React, { FC, memo, useState } from 'react';\nimport Animated, {\n runOnJS, useAnimatedReaction, useAnimatedStyle, "
},
{
"path": "src/components/Loader/index.tsx",
"chars": 2868,
"preview": "import React, {\n FC, memo, useMemo, useState,\n} from 'react';\nimport Animated, {\n cancelAnimation, interpolate, runOnJ"
},
{
"path": "src/components/Modal/Modal.styles.ts",
"chars": 326,
"preview": "import { StyleSheet } from 'react-native';\nimport { HEIGHT, WIDTH } from '../../core/constants';\n\nexport default StyleSh"
},
{
"path": "src/components/Modal/gesture.tsx",
"chars": 396,
"preview": "import React, { memo } from 'react';\nimport { PanGestureHandler, PanGestureHandlerProps, gestureHandlerRootHOC } from 'r"
},
{
"path": "src/components/Modal/index.tsx",
"chars": 12774,
"preview": "import React, {\n forwardRef, memo, useEffect, useImperativeHandle, useState,\n} from 'react';\nimport { GestureResponderE"
},
{
"path": "src/components/Progress/Progress.styles.ts",
"chars": 286,
"preview": "import { StyleSheet } from 'react-native';\n\nexport default StyleSheet.create( {\n container: {\n position: 'absolute',"
},
{
"path": "src/components/Progress/index.tsx",
"chars": 1071,
"preview": "import React, { FC, memo } from 'react';\nimport { View } from 'react-native';\nimport ProgressItem from './item';\nimport "
},
{
"path": "src/components/Progress/item.tsx",
"chars": 1155,
"preview": "import React, { FC, memo } from 'react';\nimport { View } from 'react-native';\nimport Animated, { useAnimatedStyle } from"
},
{
"path": "src/core/constants/index.ts",
"chars": 904,
"preview": "import { Dimensions } from 'react-native';\n\nexport const { width: WIDTH, height: HEIGHT } = Dimensions.get( 'screen' );\n"
},
{
"path": "src/core/dto/componentsDTO.ts",
"chars": 5681,
"preview": "import { SharedValue } from 'react-native-reanimated';\nimport {\n ImageProps, ImageStyle, TextProps, TextStyle, ViewStyl"
},
{
"path": "src/core/dto/helpersDTO.ts",
"chars": 67,
"preview": "export interface ProgressStorageProps {\n [key: string]: string;\n}\n"
},
{
"path": "src/core/dto/instagramStoriesDTO.ts",
"chars": 2898,
"preview": "import { ReactNode } from 'react';\nimport {\n ImageProps,\n ImageStyle,\n ScrollViewProps, TextStyle, ViewStyle, TextPro"
},
{
"path": "src/core/helpers/storage.ts",
"chars": 1137,
"preview": "/* eslint-disable global-require */\nimport { STORAGE_KEY } from '../constants';\nimport { ProgressStorageProps } from '.."
},
{
"path": "src/declarations.d.ts",
"chars": 240,
"preview": "declare module '*.png';\ndeclare module '*.gif';\n\ndeclare interface Keyframe {\n composite?: 'accumulate' | 'add' | 'auto"
},
{
"path": "src/index.tsx",
"chars": 269,
"preview": "import InstagramStories from './components/InstagramStories';\nimport { InstagramStoriesProps, InstagramStoriesPublicMeth"
},
{
"path": "tests/index.test.js",
"chars": 14614,
"preview": "import { createRef } from 'react';\nimport { render, fireEvent, act } from '@testing-library/react-native';\nimport * as R"
},
{
"path": "tsconfig.json",
"chars": 901,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"esnext\",\n \"module\": \"commonjs\",\n \"lib\": [\n \"ES2021\"\n ],\n \"allow"
}
]
About this extraction
This page contains the full source code of the birdwingo/react-native-instagram-stories GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 48 files (106.7 KB), approximately 29.1k tokens, and a symbol index with 41 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.