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.
## 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( 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 (
{...}
);
};
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) => ,
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}) => (
onGestureEvent.onStart?.( ...args )}
onResponderEnd={( ...args ) => onGestureEvent.onFinish?.( ...args )}
onResponderMove={( ...args ) => onGestureEvent.onActive?.( ...args )}
testID="gestureContainer"
>
{children}
),
Gesture: {
Pan: () => createGesture(),
},
GestureDetector: ({ gesture, children }) => (
gesture?._onStart?.( ...args )}
onResponderMove={( ...args ) => gesture?._onUpdate?.( ...args )}
onResponderEnd={( ...args ) => gesture?._onFinalize?.( ...args )}
testID="gestureContainer"
>
{children}
),
GestureHandlerRootView: ({ children, ...props }) => {children},
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 ;
};
});
jest.mock('@shopify/flash-list', () => {
const React = require('react');
const { ScrollView } = require('react-native');
return {FlashList: ({ data, renderItem, ...props }) => {
return (
{data.map(( item, index ) => renderItem({ item, index }))}
)
}};
});
================================================
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": [
"/tests/*.test.js"
],
"setupFilesAfterEnv": [
"@testing-library/jest-native/extend-expect",
"/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 = ( { 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 (
{children}
);
};
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 = ( {
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 (
{Boolean( showName ) && (
{name}
)}
);
};
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 = ( {
stories, loadingStory, seenStories, colors, seenColors, size,
showName, nameTextStyle, nameTextProps,
avatarListContainerProps, avatarListContainerStyle, avatarBorderRadius, onPress,
} ) => {
const renderItem = ( story: InstagramStoryProps ) => (
onPress( story.id )}
colors={colors}
seenColors={seenColors}
size={size}
showName={showName}
nameTextStyle={nameTextStyle}
nameTextProps={nameTextProps}
avatarBorderRadius={avatarBorderRadius}
key={`avatar${story.id}`}
/>
);
if ( FlashList ) {
return (
renderItem( item )}
keyExtractor={( item: InstagramStoryProps ) => item.id}
contentContainerStyle={avatarListContainerStyle}
testID="storiesList"
/>
);
}
return (
{stories.map( renderItem )}
);
};
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 = ( { 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 ? {content} : 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 = ( { 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 ? {footer} : 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 = ( {
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 (
{renderStoryHeader()}
);
}
return (
onStoryHeaderPress?.()}>
{( Boolean( avatarSource ) ) && (
)}
{Boolean( name ) && {name}}
);
};
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 = ( { color } ) => (
);
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 = ( {
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( 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 (
<>
{data.data?.source && (
data.isVideo ? (
) : (
onImageLayout( Math.min( HEIGHT, e.nativeEvent.layout.height ) )}
onLoad={() => onContentLoad()}
{...imageProps}
/>
)
)}
>
);
};
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 = ( {
source, paused, isActive, onLoad, onLayout, ...props
} ) => {
try {
// eslint-disable-next-line global-require
const Video = require( 'react-native-video' ).default;
const ref = useRef( 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 (