Merge pull request 'Restructuring and Refactor' (#5) from feature-structural_improvements into master

Reviewed-on: https://git.maharshi.ninja/root/Piped-redesign/pulls/5
This commit is contained in:
M
2021-10-29 09:54:35 +05:30
20 changed files with 265 additions and 217 deletions
+2
View File
@@ -9,6 +9,8 @@
"dependencies": {
"@babel/eslint-parser": "^7.15.0",
"@fontsource/hind-siliguri": "^4.5.0",
"@mdi/font": "^6.4.95",
"axios": "^0.23.0",
"core-js": "^3.16.1",
"dompurify": "^2.3.1",
"hotkeys-js": "^3.8.7",
-1
View File
@@ -6,7 +6,6 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css" rel="stylesheet">
</head>
<body>
<noscript>
+5 -4
View File
@@ -4,10 +4,9 @@
app
color="primary"
dark
class="pt-3"
v-if="$vuetify.breakpoint.mdAndUp"
>
<v-row dense>
<v-row dense align="center" justify="center">
<v-col md="1" v-for="link in links" :key="link.id">
<v-btn
text
@@ -18,8 +17,9 @@
{{ $t(link.name) }}
</v-btn>
</v-col>
<v-col md="2">
<v-col md="2" class="mx-3">
<v-select
class="mt-6"
dense
:items="languageOptions"
label="Language"
@@ -133,7 +133,8 @@ export default {
}
},
created () {
this.$store.dispatch('loadState')
this.$store.dispatch('prefs/loadState')
this.$store.dispatch('auth/initializeAuth')
}
}
</script>
+9 -10
View File
@@ -55,7 +55,7 @@ export default {
},
computed: {
shouldAutoPlay () {
return this.$store.getters.getPreferenceBoolean('playerAutoPlay', true)
return this.$store.getters['prefs/getPreferenceBoolean']('playerAutoPlay', true)
},
preferredVideoCodecs () {
@@ -142,7 +142,7 @@ export default {
localPlayer.configure(
'streaming.bufferingGoal',
Math.max(this.$store.getters.getPreferenceNumber('bufferGoal', 10), 10)
this.$store.getters['prefs/getPreferenceNumber']('bufferGoal', 10)
)
this.setPlayerAttrs(localPlayer, videoEl, uri, mime, shaka)
@@ -166,14 +166,14 @@ export default {
})
videoEl.addEventListener('volumechange', () => {
this.$store.commit('setPrefs', {
this.$store.commit('prefs/setPrefs', {
id: 'volume',
value: videoEl.volume
})
})
videoEl.addEventListener('ratechange', () => {
this.$store.commit('setPrefs', {
this.$store.commit('prefs/setPrefs', {
id: 'rate',
value: videoEl.playbackRate
})
@@ -206,7 +206,7 @@ export default {
this.player = player
const disableVideo = this.$store.getters.getPreferenceBoolean('listen', false) && !this.video.livestream
const disableVideo = this.$store.getters['prefs/getPreferenceBoolean']('listen', false) && !this.video.livestream
this.player.configure({
preferredVideoCodecs: this.preferredVideoCodecs,
@@ -216,9 +216,8 @@ export default {
}
})
const quality = this.$store.getters.getPreferenceNumber('quality', 0)
const qualityConds =
quality > 0 && (this.video.audioStreams.length > 0 || this.video.livestream) && !disableVideo
const quality = this.$store.getters['prefs/getPreferenceNumber']('quality', 0)
const qualityConds = quality > 0 && (this.video.audioStreams.length > 0 || this.video.livestream) && !disableVideo
if (qualityConds) this.player.configure('abr.enabled', false)
player.load(uri, 0, mime).then(() => {
@@ -248,8 +247,8 @@ export default {
subtitle.name
)
})
videoEl.volume = this.$store.getters.getPreferenceNumber('volume', 1)
videoEl.playbackRate = videoEl.defaultPlaybackRate = this.$store.getters.getPreferenceNumber('rate', 1)
videoEl.volume = this.$store.getters['prefs/getPreferenceNumber']('volume', 1)
videoEl.playbackRate = videoEl.defaultPlaybackRate = this.$store.getters['prefs/getPreferenceNumber']('rate', 1)
})
}
},
+20 -23
View File
@@ -1,29 +1,26 @@
<template>
<v-card class="pa-1" elevation="2" outlined>
<v-row align="center" justify="center">
<v-col md="1">
<v-avatar size="96" class="mx-3">
<v-img :src="comment.thumbnail"></v-img>
</v-avatar>
</v-col>
<v-col md="11">
<v-card-title class="subtitle-1">
<v-icon v-if="comment.pinned" class="mx-2">mdi-pin</v-icon>
<router-link :to="comment.commentorUrl" class="text-decoration-none black--text">
<b>{{ comment.author }}</b><v-icon v-if="comment.verified">mdi-account-check</v-icon>
</router-link>
</v-card-title>
<v-card-text>
<div v-html="renderedCommentTxt" />
<v-row align="center" justify="center" no-gutters>
<v-col md="1">
<v-avatar size="96" class="mx-3">
<v-img :src="comment.thumbnail" />
</v-avatar>
</v-col>
<v-col md="11">
<v-card-text>
<v-icon v-if="comment.pinned" class="mx-2">mdi-pin</v-icon>
<router-link :to="comment.commentorUrl" class="text-decoration-none black--text">
<b>{{ comment.author }}</b><v-icon v-if="comment.verified">mdi-account-check</v-icon>
</router-link>
<v-divider class="my-2" />
<div v-html="renderedCommentTxt" />
<v-icon>mdi-thumb-up</v-icon> {{ numberFormat(comment.likeCount) }}
<v-icon v-if="comment.hearted" class="ml-4">mdi-heart</v-icon>
</v-card-text>
</v-col>
</v-row>
</v-card>
<v-divider class="my-2" />
<v-icon>mdi-thumb-up</v-icon> {{ numberFormat(comment.likeCount) }}
<v-icon v-if="comment.hearted" class="ml-4">mdi-heart</v-icon>
</v-card-text>
</v-col>
</v-row>
</template>
<script>
+1 -1
View File
@@ -1,7 +1,7 @@
import Vue from 'vue'
import App from './App.vue'
import '@fontsource/hind-siliguri/latin.css'
import '@mdi/font/css/materialdesignicons.min.css'
import '@/styles/main.scss'
import { i18n } from '@/plugins/i18n'
+5 -3
View File
@@ -65,10 +65,12 @@ export async function changeLocale (lang) {
}
function initializeLocalLocale () {
const lang = window.localStorage.getItem('LOCALE')
if (lang != null) {
changeLocale(lang).catch(e => console.error(e))
let lang = window.localStorage.getItem('LOCALE')
if (lang == null) {
// Default language
lang = 'en'
}
changeLocale(lang).catch(e => console.error(e))
}
initializeLocalLocale()
+2 -2
View File
@@ -55,7 +55,7 @@ export default {
},
methods: {
async fetchChannel () {
return this.$store.dispatch('fetchJson', {
return this.$store.dispatch('auth/makeRequest', {
path: '/' + this.$route.params.path + '/' + this.$route.params.channelId
})
},
@@ -71,7 +71,7 @@ export default {
},
fetchMoreVideos () {
this.$store.dispatch('fetchJson', {
this.$store.dispatch('auth/makeRequest', {
path: '/nextpage/channel/' + this.channel.id,
params: {
nextpage: this.channel.nextpage
+2 -2
View File
@@ -61,7 +61,7 @@ export default {
},
computed: {
getRssUrl () {
return this.$store.getters.apiUrl + '/rss/playlists/' + this.$route.query.list
return this.$store.getters['prefs/apiUrl'] + '/rss/playlists/' + this.$route.query.list
},
chunkedByFour () {
@@ -70,7 +70,7 @@ export default {
},
methods: {
async fetchPlaylist () {
return this.$store.dispatch('fetchJson', {
return this.$store.dispatch('auth/makeRequest', {
path: '/playlists/' + this.$route.query.list
})
},
+24 -3
View File
@@ -3,8 +3,24 @@
<h1 class="text-h4 text-center">{{ $t('titles.preferences') }}</h1>
<v-divider class="ma-4" />
<div style="display: flex;" v-for="(opt, optId) in options" :key="optId">
<v-simple-checkbox v-if="opt.type === 'bool'" :value="$store.getters.getPreferenceBoolean(opt.id, opt.default)" @input="setValue(opt.id, $event)" />
<v-select v-else-if="opt.type === 'select'" :label="$t('preferences.' + opt.id)" :value="$store.getters.getPreferenceString(opt.id, opt.default)" @input="setValue(opt.id, $event)" :items="opt.options" />
<v-simple-checkbox
v-if="opt.type === 'bool'"
:value="$store.getters['prefs/getPreferenceBoolean'](opt.id, opt.default)"
@input="setValue(opt.id, $event)"
/>
<v-text-field
v-else-if="opt.type === 'number'"
type="number"
:label="$t('preferences.' + opt.id)"
:value="$store.getters['prefs/getPreferenceNumber'](opt.id, opt.default)"
@input="setValue(opt.id, Number($event))"
/>
<v-select
v-else-if="opt.type === 'select'"
:label="$t('preferences.' + opt.id)"
:value="$store.getters['prefs/getPreference'](opt.id, opt.default)"
@input="setValue(opt.id, $event)" :items="opt.options"
/>
<p v-if="opt.type === 'bool'">{{ $t('preferences.' + opt.id) }}</p><br />
</div>
<h5 class="text-h5">{{ $t('actions.instances_list') }}</h5>
@@ -47,6 +63,11 @@ export default {
})))
]
},
{
id: 'bufferGoal',
type: 'number',
default: 10
},
{
id: 'region',
type: 'select',
@@ -139,7 +160,7 @@ export default {
},
methods: {
setValue (k, v) {
this.$store.commit('setPrefs', {
this.$store.commit('prefs/setPrefs', {
id: k,
value: v
})
+1 -1
View File
@@ -54,7 +54,7 @@ export default {
methods: {
async refreshSuggestions () {
this.requestInProgress = true
this.searchSuggestions = await this.$store.dispatch('fetchJson', {
this.searchSuggestions = await this.$store.dispatch('auth/makeRequest', {
path: '/suggestions',
params: {
query: this.searchText
+2 -2
View File
@@ -98,7 +98,7 @@ export default {
},
async fetchResults () {
return this.$store.dispatch('fetchJson', {
return this.$store.dispatch('auth/makeRequest', {
path: 'search',
params: {
q: this.$route.query.search_query,
@@ -127,7 +127,7 @@ export default {
},
fetchMoreResults () {
this.$store.dispatch('fetchJson', {
this.$store.dispatch('auth/makeRequest', {
path: '/nextpage/search',
params: {
nextpage: this.results.nextpage,
+3 -3
View File
@@ -31,16 +31,16 @@ export default {
},
mounted () {
const region = this.$store.getters.getPreferenceString('region', 'US')
const region = this.$store.getters['prefs/getPreference']('region', 'US')
this.fetchTrending(region).then(videos => (this.videos = videos))
},
methods: {
async fetchTrending (region) {
return this.$store.dispatch('fetchJson', {
return this.$store.dispatch('auth/makeRequest', {
path: '/trending',
params: {
region: region || 'US'
region: region
}
})
}
+20 -31
View File
@@ -5,7 +5,7 @@
:video="video"
:skip-to-time="'t' in $route.query ? Number($route.query.t) : null"
:sponsors="sponsors"
:selectedAutoPlay="this.$store.getters.getPreferenceBoolean('autoplay')"
:selectedAutoPlay="$store.getters['prefs/getPreferenceBoolean']('autoplay')"
:selectedAutoLoop="selectedAutoLoop"
@videoEnded="videoEnded"
/>
@@ -47,12 +47,12 @@
<div>
<v-img :src="video.uploaderAvatar" height="48" width="48" class="rounded-circle" />
</div>
<div class="text-h5 ml-4">
<a :href="video.uploaderUrl" class="text-h5 ml-4 text-decoration-none">
{{ video.uploader }}
</div>
</a>
<div class="ml-4">
<v-btn
v-if="$store.state.isAuthenticated"
v-if="$store.state['auth/isAuthenticated']"
@click="subscribeHandler"
color="primary"
>
@@ -135,7 +135,7 @@ export default {
initialize () {
this.getVideoData()
this.getSponsors()
if (this.$store.getters.getPreferenceBoolean('comments', true)) this.getComments()
if (this.$store.getters['prefs/getPreferenceBoolean']('comments', true)) this.getComments()
},
videoEnded () {
@@ -158,18 +158,19 @@ export default {
},
fetchVideo () {
return this.$store.dispatch('fetchJson', {
return this.$store.dispatch('auth/makeRequest', {
method: 'GET',
path: '/streams/' + this.getVideoId()
})
},
async fetchSponsors () {
return this.$store.dispatch('fetchJson', {
return this.$store.dispatch('auth/makeRequest', {
path: '/sponsors/' + this.getVideoId(),
params: {
category:
'["' +
this.$store.getters.getPreferenceString('selectedSkip', 'sponsor,interaction,selfpromo,music_offtopic').replaceAll(
this.$store.getters['prefs/getPreference']('selectedSkip', 'sponsor,interaction,selfpromo,music_offtopic').replaceAll(
',',
'","'
) +
@@ -178,7 +179,7 @@ export default {
})
},
fetchComments () {
return this.$store.dispatch('fetchJson', {
return this.$store.dispatch('auth/makeRequest', {
path: '/comments/' + this.getVideoId()
})
},
@@ -190,7 +191,7 @@ export default {
},
fetchMoreComments () {
this.$store.dispatch('fetchJson', {
this.$store.dispatch('auth/makeRequest', {
path: '/nextpage/comments/' + this.getVideoId(),
params: {
nextpage: this.comments.nextpage
@@ -202,7 +203,7 @@ export default {
},
onAutoplayChg (ev) {
this.$store.commit('setPrefs', {
this.$store.commit('prefs/setPrefs', {
id: 'autoplay',
value: ev
})
@@ -231,41 +232,29 @@ export default {
})
},
async getSponsors () {
if (this.$store.getters.getPreferenceString('sponsorblock', true)) { this.fetchSponsors().then(data => (this.sponsors = data)) }
if (this.$store.getters['prefs/getPreference']('sponsorblock', true)) { this.fetchSponsors().then(data => (this.sponsors = data)) }
},
async getComments () {
this.fetchComments().then(data => (this.comments = data))
},
async fetchSubscribedStatus () {
if (!this.channelId) return
if (!this.channelId || !this.$store.state['auth/isAuthenticated']) return
this.$store.dispatch('fetchJson', {
this.$store.dispatch('auth/makeRequest', {
path: '/subscribed',
params: {
channelId: this.channelId
},
options: {
headers: {
Authorization: this.$store.getters.getAuthToken
}
}
}).then(json => {
this.subscribed = json.subscribed
})
},
subscribeHandler () {
this.$store.dispatch('fetchJson', {
this.$store.dispatch('auth/makeRequest', {
method: 'POST',
path: (this.subscribed ? '/unsubscribe' : '/subscribe'),
params: null,
options: {
method: 'POST',
body: JSON.stringify({
channelId: this.channelId
}),
headers: {
Authorization: this.$store.getters.getAuthToken,
'Content-Type': 'application/json'
}
data: {
channelId: this.channelId
}
})
this.subscribed = !this.subscribed
@@ -277,7 +266,7 @@ export default {
},
computed: {
isAutoplayEnabled () {
return this.$store.getters.getPreferenceBoolean('autoplay')
return this.$store.getters['prefs/getPreferenceBoolean']('autoplay')
}
},
components: {
+66
View File
@@ -0,0 +1,66 @@
import axios from 'axios'
import { isPlainObject } from 'lodash-es'
const AuthenticationStore = {
namespaced: true,
state: () => ({
isAuthenticated: false,
authToken: null
}),
mutations: {
replaceAuth (state, data) {
state.isAuthenticated = data.isAuthenticated ?? state.isAuthenticated
state.authToken = data.authToken ?? state.authToken
}
},
actions: {
initializeAuth ({ commit }) {
const data = window.localStorage.getItem('AUTH')
if (data != null) {
commit('replaceAuth', data)
}
},
async makeRequest ({
commit,
state,
rootGetters
}, {
path,
method,
data,
params
}) {
if (state.isAuthenticated) {
if (isPlainObject(params)) {
params.authToken = state.authToken
} else {
params = {
authToken: state.authToken
}
}
}
const { data: resp } = await axios({
baseURL: rootGetters['prefs/apiUrl'],
method,
url: path,
params,
headers: state.isAuthenticated
? {
Authorization: state.authToken
}
: undefined
})
return resp
}
}
}
export {
AuthenticationStore
}
+4 -130
View File
@@ -1,141 +1,15 @@
import Vue from 'vue'
import Vuex from 'vuex'
import { get as _get, keys as _keys, set as _set, cloneDeep as _cloneDeep, isPlainObject, isString } from 'lodash-es'
import { PrefsStore } from '@/store/prefs-store'
import { AuthenticationStore } from '@/store/authentication-store'
Vue.use(Vuex)
const store = new Vuex.Store({
state: {
isAuthenticated: false,
prefs: {}
},
mutations: {
setAuthenticated (state, authStatus) {
state.isAuthenticated = authStatus
},
setPrefs (state, {
id,
value
}) {
const clonedPrefs = _cloneDeep(state.prefs)
_set(clonedPrefs, id, value)
state.prefs = clonedPrefs
},
replacePrefs (state, nextPrefs) {
state.prefs = nextPrefs
}
},
actions: {
loadState ({ commit }) {
try {
const jv = window.localStorage.getItem('PREFERENCES')
if (isString(jv) && jv.length !== 0) {
const p = JSON.parse(jv)
commit('replacePrefs', p)
}
} catch (e) {
console.log('Error:', e)
}
},
fetchJson ({ getters }, {
path,
params = {},
options = {}
}) {
const url = new URL(getters.apiUrl)
url.pathname = path
if (params) {
for (const param of _keys(params)) {
url.searchParams.set(param, params[param])
}
const at = getters.getAuthToken
if (at) {
url.searchParams.set('authToken', at)
}
}
return fetch(url.href, options).then(response => {
return response.json()
})
}
},
getters: {
getPreferenceString: state => (id, default_) => {
return _get(state.prefs, id, default_)
},
getPreferenceBoolean: (_, getters) => (...args) => {
const v = getters.getPreferenceString(...args)
switch (v) {
case 'true':
case true:
case '1':
case 'on':
case 'yes':
return true
default:
return false
}
},
getPreferenceNumber: (_, getters) => (id, default_) => {
const v = getters.getPreferenceString(id, default_)
const n = Number(v)
if (!Number.isFinite(n)) {
return default_
} else {
return n
}
},
getEffectiveTheme (state, getters) {
let theme = getters.getPreferenceString('theme', 'light')
if (theme === 'auto') {
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return theme
},
backgroundColor (state, getters) {
return getters.getEffectiveTheme === 'light' ? '#fff' : '#0b0e0f'
},
secondaryBackgroundColor (state, getters) {
return getters.getEffectiveTheme === 'light' ? '#e5e5e5' : '#242727'
},
foregroundColor (state, getters) {
return getters.getEffectiveTheme === 'light' ? '#15191a' : '#0b0e0f'
},
secondaryForegroundColor (state, getters) {
return getters.getEffectiveTheme === 'light' ? '#666' : '#393d3d'
},
getAuthToken (state, getters) {
return getters.getPreferenceString(['authToken', getters])
},
apiUrl (state, getters) {
return getters.getPreferenceString('instance', 'https://pipedapi.kavin.rocks')
},
authenticated (state, getters) {
return getters.getAuthToken !== undefined
}
},
modules: {
}
})
store.watch(state => state.prefs, (nextPrefs) => {
if (isPlainObject(nextPrefs)) {
window.localStorage.setItem('PREFERENCES', JSON.stringify(nextPrefs))
prefs: PrefsStore,
auth: AuthenticationStore
}
})
+74
View File
@@ -0,0 +1,74 @@
import { get as _get, isString, set as _set } from 'lodash-es'
const PrefsStore = {
namespaced: true,
state: () => ({
prefs: {}
}),
mutations: {
setPrefs (state, {
id,
value
}) {
_set(state.prefs, id, value)
window.localStorage.setItem('PREFERENCES', JSON.stringify(state.prefs))
},
replacePrefs (state, nextPrefs) {
state.prefs = nextPrefs
}
},
actions: {
loadState ({ commit }) {
try {
const jv = window.localStorage.getItem('PREFERENCES')
if (isString(jv) && jv.length !== 0) {
const p = JSON.parse(jv)
commit('replacePrefs', p)
}
} catch (e) {
console.log('Error:', e)
}
}
},
getters: {
getPreference: state => (id, default_) => {
return _get(state.prefs, id, default_)
},
getPreferenceBoolean: (_, getters) => (...args) => {
const v = getters.getPreference(...args)
switch (v) {
case 'true':
case true:
case '1':
case 'on':
case 'yes':
return true
default:
return false
}
},
getPreferenceNumber: (_, getters) => (id, default_) => {
const v = getters.getPreference(id, default_)
const n = Number(v)
if (!Number.isFinite(n)) {
return default_
} else {
return n
}
},
apiUrl (state, getters) {
return getters.getPreference('instance', 'https://pipedapi.kavin.rocks')
}
}
}
export {
PrefsStore
}
+7 -1
View File
@@ -12,4 +12,10 @@
}
}
@include addFonts('Hind Siliguri');
.latin {
@include addFonts('Helvetica Neue');
}
.bengali {
@include addFonts('Hind Silliguri')
}
+1
View File
@@ -48,6 +48,7 @@
},
"preferences": {
"playerAutoplay": "Autoplay Video",
"bufferGoal": "Buffering Goal",
"listen": "Audio Only",
"quality": "Default Quality",
"region": "Country Selection",
+17
View File
@@ -1001,6 +1001,11 @@
cssnano-preset-default "^4.0.0"
postcss "^7.0.0"
"@mdi/font@^6.4.95":
version "6.4.95"
resolved "https://registry.yarnpkg.com/@mdi/font/-/font-6.4.95.tgz#d3b7a615c4c82ba2a9c29b2b59b158dced4f9b0e"
integrity sha512-jCQdXCD339EXirWYFbZv1Ansthm9j6O3FSQLsfi/mKamnGYxqbUBm5D4UjcVr6HVYIPNwTQQ46HNHlswPD8RcQ==
"@mrmlnc/readdir-enhanced@^2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
@@ -1998,6 +2003,13 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
axios@^0.23.0:
version "0.23.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.23.0.tgz#b0fa5d0948a8d1d75e3d5635238b6c4625b05149"
integrity sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==
dependencies:
follow-redirects "^1.14.4"
babel-extract-comments@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/babel-extract-comments/-/babel-extract-comments-1.0.0.tgz#0a2aedf81417ed391b85e18b4614e693a0351a21"
@@ -4390,6 +4402,11 @@ follow-redirects@^1.0.0:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==
follow-redirects@^1.14.4:
version "1.14.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379"
integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==
for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"