From 1b057fc2a75078a267d7041808a9afe6334a2aa1 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 9 Oct 2017 02:30:25 +0100 Subject: [PATCH 01/36] feat(api): fixlets --- App.js | 2 +- package.json | 5 +- root.reducer.js | 7 + root.store.js | 3 +- routes.js | 11 +- src/api/rest/actions/index.js | 2 + src/api/rest/actions/orgs.js | 7 + src/api/rest/actions/search.js | 3 + src/api/rest/middleware/index.js | 62 +++++ .../rest/providers/github/endpoints/orgs.js | 65 +++++ .../rest/providers/github/endpoints/search.js | 23 ++ src/api/rest/providers/github/index.js | 70 ++++++ .../rest/providers/github/schemas/index.js | 12 + src/api/rest/providers/github/schemas/orgs.js | 152 ++++++++++++ .../rest/providers/github/schemas/repos.js | 193 +++++++++++++++ .../rest/providers/github/schemas/users.js | 89 +++++++ src/api/rest/reducers/entities.js | 17 ++ src/api/rest/reducers/errorMessage.js | 14 ++ src/api/rest/reducers/pagination.js | 75 ++++++ src/components/entity-info.component.js | 16 ++ src/components/index.js | 3 + src/components/org-profile.component.js | 133 +++++++++++ src/components/repo-list-item.component.js | 132 +++++++++++ src/components/users-avatar-list.component.js | 127 ++++++++++ src/organization/screens/index.js | 1 + .../screens/organization-profile.screen.js | 179 +++++++------- .../screens/repository-list.screen.js | 223 ++++++++++++++++++ 27 files changed, 1535 insertions(+), 91 deletions(-) create mode 100644 src/api/rest/actions/index.js create mode 100644 src/api/rest/actions/orgs.js create mode 100644 src/api/rest/actions/search.js create mode 100644 src/api/rest/middleware/index.js create mode 100644 src/api/rest/providers/github/endpoints/orgs.js create mode 100644 src/api/rest/providers/github/endpoints/search.js create mode 100644 src/api/rest/providers/github/index.js create mode 100644 src/api/rest/providers/github/schemas/index.js create mode 100644 src/api/rest/providers/github/schemas/orgs.js create mode 100644 src/api/rest/providers/github/schemas/repos.js create mode 100644 src/api/rest/providers/github/schemas/users.js create mode 100644 src/api/rest/reducers/entities.js create mode 100644 src/api/rest/reducers/errorMessage.js create mode 100644 src/api/rest/reducers/pagination.js create mode 100644 src/components/org-profile.component.js create mode 100644 src/components/repo-list-item.component.js create mode 100644 src/components/users-avatar-list.component.js create mode 100644 src/organization/screens/repository-list.screen.js diff --git a/App.js b/App.js index 6cd0aa8fb..09db06da2 100644 --- a/App.js +++ b/App.js @@ -62,7 +62,7 @@ class App extends Component { { storage: AsyncStorage, transforms: [encryptor], - blacklist: ['user'], + blacklist: ['entities', 'pagination', 'errorMessage', 'user'], }, () => { this.setState({ rehydrated: true }); diff --git a/package.json b/package.json index 3bed2d686..80850870d 100644 --- a/package.json +++ b/package.json @@ -48,11 +48,14 @@ "dependencies": { "entities": "^1.1.1", "fuzzy-search": "^1.4.0", + "lodash.merge": "^4.6.0", + "lodash.union": "^4.6.0", "lodash.uniqby": "^4.7.0", "lowlight": "^1.5.0", "md5": "^2.2.1", "moment": "^2.17.1", "node-emoji": "^1.7.0", + "normalizr": "^3.2.3", "opencollective": "^1.0.3", "parse-diff": "^0.4.0", "query-string": "^4.3.1", @@ -138,4 +141,4 @@ "url": "https://opencollective.com/git-point", "logo": "https://opencollective.com/opencollective/logo.txt" } -} \ No newline at end of file +} diff --git a/root.reducer.js b/root.reducer.js index 07ca2eabf..e44c5547e 100644 --- a/root.reducer.js +++ b/root.reducer.js @@ -7,7 +7,14 @@ import { issueReducer } from 'issue'; import { searchReducer } from 'search'; import { notificationsReducer } from 'notifications'; +import { entities } from './src/api/rest/reducers/entities'; +import { pagination } from './src/api/rest/reducers/pagination'; +import { errorMessage } from './src/api/rest/reducers/errorMessage'; + export const rootReducer = combineReducers({ + entities, + pagination, + errorMessage, auth: authReducer, user: userReducer, repository: repositoryReducer, diff --git a/root.store.js b/root.store.js index f45bb1b9a..37ce18bcc 100644 --- a/root.store.js +++ b/root.store.js @@ -6,9 +6,10 @@ import reduxThunk from 'redux-thunk'; import { composeWithDevTools } from 'redux-devtools-extension'; import 'config/reactotron'; import { rootReducer } from './root.reducer'; +import restApi from './src/api/rest/middleware'; const getMiddleware = () => { - const middlewares = [reduxThunk]; + const middlewares = [reduxThunk, restApi]; if (__DEV__) { if (process.env.LOGGER_ENABLED) { diff --git a/routes.js b/routes.js index a6e6617a7..8d1f499c2 100644 --- a/routes.js +++ b/routes.js @@ -32,7 +32,10 @@ import { } from 'user'; // Organization -import { OrganizationProfileScreen } from 'organization'; +import { + OrganizationProfileScreen, + OrgRepositoryListScreen, +} from 'organization'; // Search import { SearchScreen } from 'search'; @@ -61,6 +64,12 @@ import { } from 'issue'; const sharedRoutes = { + OrgRepositoryList: { + screen: OrgRepositoryListScreen, + navigationOptions: ({ navigation }) => ({ + title: navigation.state.params.title, + }), + }, RepositoryList: { screen: RepositoryListScreen, navigationOptions: ({ navigation }) => ({ diff --git a/src/api/rest/actions/index.js b/src/api/rest/actions/index.js new file mode 100644 index 000000000..671e8a174 --- /dev/null +++ b/src/api/rest/actions/index.js @@ -0,0 +1,2 @@ +export * from './orgs'; +export * from './search'; diff --git a/src/api/rest/actions/orgs.js b/src/api/rest/actions/orgs.js new file mode 100644 index 000000000..d7e52b455 --- /dev/null +++ b/src/api/rest/actions/orgs.js @@ -0,0 +1,7 @@ +import { createActionSet } from 'utils'; + +export const ORGS_GET_BY_ID = createActionSet('ORGS_GET_BY_ID'); +export const ORGS_GET_REPOS = createActionSet('ORGS_GET_REPOS'); + +export const REPOS_BY_ORG = createActionSet('REPOS_BY_ORG'); +export const MEMBERS_BY_ORG = createActionSet('MEMBERS_BY_ORG'); diff --git a/src/api/rest/actions/search.js b/src/api/rest/actions/search.js new file mode 100644 index 000000000..dd3b76268 --- /dev/null +++ b/src/api/rest/actions/search.js @@ -0,0 +1,3 @@ +import { createActionSet } from 'utils'; + +export const REPOS_BY_SEARCH = createActionSet('REPOS_BY_SEARCH'); diff --git a/src/api/rest/middleware/index.js b/src/api/rest/middleware/index.js new file mode 100644 index 000000000..eea96eea9 --- /dev/null +++ b/src/api/rest/middleware/index.js @@ -0,0 +1,62 @@ +import { performApiCall } from '../providers/github'; + +// Action key that carries API call info interpreted by this Redux middleware. +export const CALL_API = 'CALL_THIS_MIDDLEWARE'; + +// A Redux middleware that interprets actions with CALL_API info specified. +// Performs the call and promises when such actions are dispatched. +export default store => next => action => { + const apiCallParameters = action[CALL_API]; + + if (typeof apiCallParameters === 'undefined') { + return next(action); + } + + let { endpoint } = apiCallParameters; + const { types, schema, normalizrKey = null } = apiCallParameters; + + if (typeof endpoint === 'function') { + endpoint = endpoint(store.getState()); + } + + if (typeof endpoint !== 'string') { + throw new Error('Specify a string endpoint URL.'); + } + + if (!schema) { + throw new Error('Specify one of the exported Schemas.'); + } + + if (typeof types !== 'object') { + throw new Error('Expected an object containing the three action types.'); + } + + const accessToken = store.getState().auth.accessToken; + + const actionWith = data => { + const finalAction = Object.assign({}, action, data); + + delete finalAction[CALL_API]; + + return finalAction; + }; + + next(actionWith({ type: types.PENDING })); + + return performApiCall(endpoint, {}, schema, accessToken, normalizrKey).then( + response => + next( + actionWith({ + response, + type: types.SUCCESS, + }) + ), + error => + next( + actionWith({ + type: types.ERROR, + error: error.message || 'Something bad happened', + }) + ) + ); +}; diff --git a/src/api/rest/providers/github/endpoints/orgs.js b/src/api/rest/providers/github/endpoints/orgs.js new file mode 100644 index 000000000..a3af0d78a --- /dev/null +++ b/src/api/rest/providers/github/endpoints/orgs.js @@ -0,0 +1,65 @@ +import has from 'lodash.has'; +import { CALL_API } from 'api/rest/middleware'; +import { + ORGS_GET_BY_ID, + REPOS_BY_ORG, + MEMBERS_BY_ORG, +} from 'api/rest/actions/orgs'; +import { handlePaginatedApi } from 'api/rest/providers/github'; + +import Schemas from '../schemas'; + +const _getById = orgId => ({ + [CALL_API]: { + types: ORGS_GET_BY_ID, + endpoint: `orgs/${orgId}`, + schema: Schemas.ORG, + }, +}); + +export const getById = ( + orgId, + { requiredFields = [], forceRefresh = false } = {} +) => (dispatch, getState) => { + const org = getState().entities.orgs[orgId]; + + if (!forceRefresh && org && requiredFields.every(key => has(org, key))) { + return null; + } + + return dispatch(_getById(orgId)); +}; + +const _getRepos = (orgId, nextPageUrl) => ({ + id: orgId, + [CALL_API]: { + types: REPOS_BY_ORG, + endpoint: nextPageUrl, + schema: Schemas.REPO_ARRAY, + }, +}); + +export const getRepos = (orgId, options) => { + return handlePaginatedApi( + `orgs/${orgId}/repos`, + { name: 'reposByOrg', key: orgId, call: _getRepos }, + options + ); +}; + +const _getMembers = (orgId, nextPageUrl) => ({ + id: orgId, + [CALL_API]: { + types: MEMBERS_BY_ORG, + endpoint: nextPageUrl, + schema: Schemas.USER_ARRAY, + }, +}); + +export const getMembers = (orgId, options) => { + return handlePaginatedApi( + `orgs/${orgId}/members?per_page=8`, + { name: 'membersByOrg', key: orgId, call: _getMembers }, + options + ); +}; diff --git a/src/api/rest/providers/github/endpoints/search.js b/src/api/rest/providers/github/endpoints/search.js new file mode 100644 index 000000000..e612d5983 --- /dev/null +++ b/src/api/rest/providers/github/endpoints/search.js @@ -0,0 +1,23 @@ +import { CALL_API } from 'api/rest/middleware'; +import { REPOS_BY_SEARCH } from 'api/rest/actions/search'; +import { handlePaginatedApi } from 'api/rest/providers/github'; + +import Schemas from '../schemas'; + +const _searchRepos = (query, nextPageUrl) => ({ + id: query, + [CALL_API]: { + types: REPOS_BY_SEARCH, + endpoint: nextPageUrl, + schema: Schemas.REPO_ARRAY, + normalizrKey: 'items', + }, +}); + +export const searchRepos = (query, options) => { + return handlePaginatedApi( + `search/repositories?${query}`, + { name: 'reposBySearch', key: query, call: _searchRepos }, + options + ); +}; diff --git a/src/api/rest/providers/github/index.js b/src/api/rest/providers/github/index.js new file mode 100644 index 000000000..5136ccba1 --- /dev/null +++ b/src/api/rest/providers/github/index.js @@ -0,0 +1,70 @@ +import { normalize } from 'normalizr'; + +const API_ROOT = 'https://api.github.com/'; + +const getNextPageUrl = response => { + const link = response.headers.get('link'); + + if (!link) { + return null; + } + + const nextLink = link.split(',').find(s => s.indexOf('rel="next"') > -1); + + if (!nextLink) { + return null; + } + + return nextLink.split(';')[0].slice(1, -1); +}; + +export const handlePaginatedApi = ( + firstPageUrl, + { name, key, call }, + { loadMore = false, forceRefresh = false } = {} +) => (dispatch, getState) => { + let { nextPageUrl = firstPageUrl } = getState().pagination[name][key] || {}; + const { pageCount = 0, isFetching = false } = + getState().pagination[name][key] || {}; + + if (forceRefresh) { + // TODO: how to reset the state ? dispatch(clearPagination('paginationId')) ? + nextPageUrl = firstPageUrl; + } else if (isFetching || (pageCount > 0 && !loadMore) || !nextPageUrl) { + return null; + } + + return dispatch(call(key, nextPageUrl)); +}; + +export const performApiCall = ( + endpoint, + params, + schema, + accessToken, + normalizrKey +) => { + const fullUrl = + endpoint.indexOf(API_ROOT) === -1 ? API_ROOT + endpoint : endpoint; + + return fetch(fullUrl, { + headers: { + Authorization: `token ${accessToken}`, + 'Cache-Control': 'no-cache', + }, + }).then(response => + response.json().then(json => { + if (!response.ok) { + return Promise.reject(json); + } + + const nextPageUrl = getNextPageUrl(response); + + return Object.assign( + {}, + normalize(normalizrKey ? json[normalizrKey] : json, schema), + { nextPageUrl } + ); + }) + ); +}; diff --git a/src/api/rest/providers/github/schemas/index.js b/src/api/rest/providers/github/schemas/index.js new file mode 100644 index 000000000..54ef09bfc --- /dev/null +++ b/src/api/rest/providers/github/schemas/index.js @@ -0,0 +1,12 @@ +import { orgSchema } from './orgs'; +import { userSchema } from './users'; +import { repoSchema } from './repos'; + +export default { + USER: userSchema, + USER_ARRAY: [userSchema], + ORG: orgSchema, + ORG_ARRAY: [orgSchema], + REPO: repoSchema, + REPO_ARRAY: [repoSchema], +}; diff --git a/src/api/rest/providers/github/schemas/orgs.js b/src/api/rest/providers/github/schemas/orgs.js new file mode 100644 index 000000000..d9498e3b2 --- /dev/null +++ b/src/api/rest/providers/github/schemas/orgs.js @@ -0,0 +1,152 @@ +import { schema } from 'normalizr'; +import moment from 'moment/min/moment.min'; + +export const orgSchema = new schema.Entity( + 'orgs', + {}, + { + idAttribute: org => org.login.toLowerCase(), + processStrategy: entity => { + const processed = {}; + + // These are provided in both mini & full modes + processed.id = entity.login.toLowerCase(); // id should be always used for navigation + processed.login = entity.login; + processed.avatarUrl = entity.avatar_url; + processed.description = entity.description; + + // These flags should be in all our schemas. + processed._isComplete = false; // entity not fully fetched yet + processed._isAuth = false; // entity doesn't belong to the auth user + processed._entityUrl = false; // The github url for the entity. To be used in openInBrowser() + processed._fetchedAt = moment().format('X'); + + // name is only present in full mode, we base our full parsing on its presence + if (typeof entity.name !== 'undefined') { + processed.name = entity.name; + processed.webSite = entity.blog; + processed.location = entity.location; + + processed.countPublicRepos = entity.public_repos; + processed.countPrivateRepos = 0; + processed.countRepos = entity.public_repos; + + processed._entityUrl = entity.html_url; + + processed.createdAt = moment(entity.created_at).format('X'); // as unix timestamp + processed.updatedAt = moment(entity.updated_at).format('X'); // as unix timestamp + + // Clear avatar cached URL to make sure picture is refetched on profile change + processed.avatarUrl += `&updatedAt=${processed.updatedAt}`; + + // The entity is to be considered complete. + processed._isComplete = true; + + if (typeof entity.total_private_repos !== 'undefined') { + // This org belongs to the authenticated user, update some props + processed._isAuth = true; + processed.countPrivateRepos = entity.total_private_repos; + processed.countRepos += entity.total_private_repos; + } + } else { + // We can try our best to fill in some props on our own: + processed._entityUrl = `https://github.com/${processed.id}`; + } + + return processed; + }, + } +); + +/* + + // GITHUB ORGS SCHEMAS + +const fullAuth = { login: 'gitpoint', + id: 30082377, + url: 'https://api.github.com/orgs/gitpoint', + repos_url: 'https://api.github.com/orgs/gitpoint/repos', + events_url: 'https://api.github.com/orgs/gitpoint/events', + hooks_url: 'https://api.github.com/orgs/gitpoint/hooks', + issues_url: 'https://api.github.com/orgs/gitpoint/issues', + members_url: 'https://api.github.com/orgs/gitpoint/members{/member}', + public_members_url: 'https://api.github.com/orgs/gitpoint/public_members{/member}', + avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', + description: 'An open source GitHub client for iOS and Android. Built with React Native :iphone:', + + name: 'GitPoint', + company: null, + blog: 'https://gitpoint.co', + location: 'Toronto', + email: '', + has_organization_projects: true, + has_repository_projects: true, + public_repos: 2, + public_gists: 0, + followers: 0, + following: 0, + html_url: 'https://github.com/gitpoint', + created_at: '2017-07-11T15:49:07Z', + updated_at: '2017-08-04T00:50:57Z', + type: 'Organization', + + total_private_repos: 0, + owned_private_repos: 0, + private_gists: null, + disk_usage: null, + collaborators: null, + billing_email: null, + plan: { name: 'free', + space: 976562499, + private_repos: 0, + filled_seats: 12, + seats: 0 }, + default_repository_permission: null, + members_can_create_repositories: false, +}; + +const full = { + login: 'gitpoint', + id: 30082377, + url: 'https://api.github.com/orgs/gitpoint', + repos_url: 'https://api.github.com/orgs/gitpoint/repos', + events_url: 'https://api.github.com/orgs/gitpoint/events', + hooks_url: 'https://api.github.com/orgs/gitpoint/hooks', + issues_url: 'https://api.github.com/orgs/gitpoint/issues', + members_url: 'https://api.github.com/orgs/gitpoint/members{/member}', + public_members_url: 'https://api.github.com/orgs/gitpoint/public_members{/member}', + avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', + description: 'An open source GitHub client for iOS and Android. Built with React Native :iphone:', + + name: 'GitPoint', + company: null, + blog: 'https://gitpoint.co', + location: 'Toronto', + email: '', + has_organization_projects: true, + has_repository_projects: true, + public_repos: 2, + public_gists: 0, + followers: 0, + following: 0, + html_url: 'https://github.com/gitpoint', + created_at: '2017-07-11T15:49:07Z', + updated_at: '2017-08-04T00:50:57Z', + type: 'Organization', +}; + +const mini = { + login: 'gitpoint', + id: 30082377, + url: 'https://api.github.com/orgs/gitpoint', + repos_url: 'https://api.github.com/orgs/gitpoint/repos', + events_url: 'https://api.github.com/orgs/gitpoint/events', + hooks_url: 'https://api.github.com/orgs/gitpoint/hooks', + issues_url: 'https://api.github.com/orgs/gitpoint/issues', + members_url: 'https://api.github.com/orgs/gitpoint/members{/member}', + public_members_url: 'https://api.github.com/orgs/gitpoint/public_members{/member}', + avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', + description: 'An open source GitHub client for iOS and Android. Built with React Native :iphone:', +}; + +*/ diff --git a/src/api/rest/providers/github/schemas/repos.js b/src/api/rest/providers/github/schemas/repos.js new file mode 100644 index 000000000..ed43fb788 --- /dev/null +++ b/src/api/rest/providers/github/schemas/repos.js @@ -0,0 +1,193 @@ +import { schema } from 'normalizr'; +import moment from 'moment/min/moment.min'; + +import { userSchema } from './users'; +import { orgSchema } from './orgs'; + +export const repoSchema = new schema.Entity( + 'repos', + { + userOwner: userSchema, + orgOwner: orgSchema, + }, + { + idAttribute: repo => repo.full_name.toLowerCase(), + processStrategy: entity => { + const processed = {}; + + // These flags should be in all our schemas. + processed._isComplete = false; // entity not fully fetched yet + processed._isAuth = false; // entity doesn't belong to the auth user + processed._entityUrl = entity.html_url; // The github url for the entity. To be used in openInBrowser() + processed._fetchedAt = moment().format('X'); + + processed.id = entity.full_name; + processed.fullName = entity.full_name; + processed.shortName = entity.name; + processed.description = entity.description; + processed.private = entity.private; + processed.defaultBranch = entity.default_branch; + processed.language = entity.language; // needs to be normalized + + if (entity.owner.type === 'User') { + processed.userOwner = entity.owner; + processed.orgOwner = false; + } else { + processed.userOwner = false; + processed.orgOwner = entity.owner; + } + + processed.countStargazzers = entity.stargazers_count; + processed.countForks = entity.forks_count; + processed.countWatchers = entity.watchers_count; + processed.countOpenIssues = entity.open_issues_count; + + processed.hasIssues = entity.has_issues; + + processed._entityUrl = entity.html_url; + + /* + // These are provided in both mini & full modes + processed.id = entity.login; // id should be always used for navigation + processed.login = entity.login; + processed.avatarUrl = entity.avatar_url; + processed.description = entity.description; + + // These flags should be in all our schemas. + processed._isComplete = false; // entity not fully fetched yet + processed._isAuth = false; // entity doesn't belong to the auth user + processed._entityUrl = false; // The github url for the entity. To be used in openInBrowser() + + // name is only present in full mode, we base our full parsing on its presence + if (typeof entity.name !== 'undefined') { + processed.name = entity.name; + processed.webSite = entity.blog; + processed.location = entity.location; + + processed.countPublicRepos = entity.public_repos; + processed.countPrivateRepos = 0; + processed.countRepos = entity.public_repos; + + processed._entityUrl = entity.html_url; + + processed.since = moment(entity.created_at).format('X'); // as unix timestamp + + // The entity is to be considered complete. + processed._isComplete = true; + + if (typeof entity.total_private_repos !== 'undefined') { + // This org belongs to the authenticated user, update some props + processed._isAuth = true; + processed.countPrivateRepos = entity.total_private_repos; + processed.countRepos += entity.total_private_repos; + } + } else { + // We can try our best to fill in some props on our own: + processed._entityUrl = `https://github.com/${processed.id}`; + } */ + + return processed; + }, + } +); + +/* + + // GITHUB REPOS SCHEMAS + +const fullMember = { id: 93332398, + name: 'git-point-site', + full_name: 'gitpoint/git-point-site', + owner: + { login: 'gitpoint', + id: 30082377, + avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/gitpoint', + html_url: 'https://github.com/gitpoint', + followers_url: 'https://api.github.com/users/gitpoint/followers', + following_url: 'https://api.github.com/users/gitpoint/following{/other_user}', + gists_url: 'https://api.github.com/users/gitpoint/gists{/gist_id}', + starred_url: 'https://api.github.com/users/gitpoint/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/gitpoint/subscriptions', + organizations_url: 'https://api.github.com/users/gitpoint/orgs', + repos_url: 'https://api.github.com/users/gitpoint/repos', + events_url: 'https://api.github.com/users/gitpoint/events{/privacy}', + received_events_url: 'https://api.github.com/users/gitpoint/received_events', + type: 'Organization', + site_admin: false }, + private: false, + html_url: 'https://github.com/gitpoint/git-point-site', + description: null, + fork: false, + url: 'https://api.github.com/repos/gitpoint/git-point-site', + forks_url: 'https://api.github.com/repos/gitpoint/git-point-site/forks', + keys_url: 'https://api.github.com/repos/gitpoint/git-point-site/keys{/key_id}', + collaborators_url: 'https://api.github.com/repos/gitpoint/git-point-site/collaborators{/collaborator}', + teams_url: 'https://api.github.com/repos/gitpoint/git-point-site/teams', + hooks_url: 'https://api.github.com/repos/gitpoint/git-point-site/hooks', + issue_events_url: 'https://api.github.com/repos/gitpoint/git-point-site/issues/events{/number}', + events_url: 'https://api.github.com/repos/gitpoint/git-point-site/events', + assignees_url: 'https://api.github.com/repos/gitpoint/git-point-site/assignees{/user}', + branches_url: 'https://api.github.com/repos/gitpoint/git-point-site/branches{/branch}', + tags_url: 'https://api.github.com/repos/gitpoint/git-point-site/tags', + blobs_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/blobs{/sha}', + git_tags_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/tags{/sha}', + git_refs_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/refs{/sha}', + trees_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/trees{/sha}', + statuses_url: 'https://api.github.com/repos/gitpoint/git-point-site/statuses/{sha}', + languages_url: 'https://api.github.com/repos/gitpoint/git-point-site/languages', + stargazers_url: 'https://api.github.com/repos/gitpoint/git-point-site/stargazers', + contributors_url: 'https://api.github.com/repos/gitpoint/git-point-site/contributors', + subscribers_url: 'https://api.github.com/repos/gitpoint/git-point-site/subscribers', + subscription_url: 'https://api.github.com/repos/gitpoint/git-point-site/subscription', + commits_url: 'https://api.github.com/repos/gitpoint/git-point-site/commits{/sha}', + git_commits_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/commits{/sha}', + comments_url: 'https://api.github.com/repos/gitpoint/git-point-site/comments{/number}', + issue_comment_url: 'https://api.github.com/repos/gitpoint/git-point-site/issues/comments{/number}', + contents_url: 'https://api.github.com/repos/gitpoint/git-point-site/contents/{+path}', + compare_url: 'https://api.github.com/repos/gitpoint/git-point-site/compare/{base}...{head}', + merges_url: 'https://api.github.com/repos/gitpoint/git-point-site/merges', + archive_url: 'https://api.github.com/repos/gitpoint/git-point-site/{archive_format}{/ref}', + downloads_url: 'https://api.github.com/repos/gitpoint/git-point-site/downloads', + issues_url: 'https://api.github.com/repos/gitpoint/git-point-site/issues{/number}', + pulls_url: 'https://api.github.com/repos/gitpoint/git-point-site/pulls{/number}', + milestones_url: 'https://api.github.com/repos/gitpoint/git-point-site/milestones{/number}', + notifications_url: 'https://api.github.com/repos/gitpoint/git-point-site/notifications{?since,all,participating}', + labels_url: 'https://api.github.com/repos/gitpoint/git-point-site/labels{/name}', + releases_url: 'https://api.github.com/repos/gitpoint/git-point-site/releases{/id}', + deployments_url: 'https://api.github.com/repos/gitpoint/git-point-site/deployments', + created_at: '2017-06-04T18:11:50Z', + updated_at: '2017-09-28T22:13:04Z', + pushed_at: '2017-10-04T02:08:59Z', + git_url: 'git://github.com/gitpoint/git-point-site.git', + ssh_url: 'git@github.com:gitpoint/git-point-site.git', + clone_url: 'https://github.com/gitpoint/git-point-site.git', + svn_url: 'https://github.com/gitpoint/git-point-site', + homepage: null, + size: 4656, + stargazers_count: 9, + watchers_count: 9, + language: 'CSS', + has_issues: true, + has_projects: true, + has_downloads: true, + has_wiki: true, + has_pages: false, + forks_count: 3, + mirror_url: null, + open_issues_count: 0, + forks: 3, + open_issues: 0, + watchers: 9, + default_branch: 'master', + + permissions: { admin: false, push: true, pull: true } } ]; + + // only in full +parent: {}, +source: {}, +network_count: 9, +subscribers_count: 2 + +*/ diff --git a/src/api/rest/providers/github/schemas/users.js b/src/api/rest/providers/github/schemas/users.js new file mode 100644 index 000000000..d559b72ce --- /dev/null +++ b/src/api/rest/providers/github/schemas/users.js @@ -0,0 +1,89 @@ +import { schema } from 'normalizr'; +import moment from 'moment/min/moment.min'; + +export const userSchema = new schema.Entity( + 'users', + {}, + { + idAttribute: user => user.login.toLowerCase(), + processStrategy: entity => { + const processed = {}; + + // These are provided in both mini & full modes + processed.id = entity.login.toLowerCase(); // id should be always used for navigation + processed.login = entity.login; + processed.avatarUrl = entity.avatar_url; + + // These flags should be in all our schemas. + processed._isComplete = false; // entity not fully fetched yet + processed._isAuth = false; // entity doesn't belong to the auth user + processed._entityUrl = false; // The github url for the entity. To be used in openInBrowser() + processed._fetchedAt = moment().format('X'); + + // name is only present in full mode, we base our full parsing on its presence + if (typeof entity.name !== 'undefined') { + processed.fullName = entity.name; + processed.company = entity.company; + processed.webSite = entity.blog; + processed.location = entity.location; + processed.bio = entity.bio; + + processed.countPublicRepos = entity.public_repos; + processed.countPrivateRepos = 0; + processed.countRepos = entity.public_repos; + processed.countFollowers = entity.followers; + processed.countFollowing = entity.following; + + processed.createdAt = moment(entity.created_at).format('X'); // as unix timestamp + processed.updatedAt = moment(entity.updated_at).format('X'); // as unix timestamp + + // Clear avatar cached URL to make sure picture is refetched on profile change + processed.avatarUrl += `&updatedAt=${processed.updatedAt}`; + + // The entity is to be considered complete. + processed._isComplete = true; + } + + return processed; + }, + } +); + +/** + +const userFull = { + "login": "machour", + "id": 304450, + "avatar_url": "https://avatars2.githubusercontent.com/u/304450?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/machour", + "html_url": "https://github.com/machour", + "followers_url": "https://api.github.com/users/machour/followers", + "following_url": "https://api.github.com/users/machour/following{/other_user}", + "gists_url": "https://api.github.com/users/machour/gists{/gist_id}", + "starred_url": "https://api.github.com/users/machour/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/machour/subscriptions", + "organizations_url": "https://api.github.com/users/machour/orgs", + "repos_url": "https://api.github.com/users/machour/repos", + "events_url": "https://api.github.com/users/machour/events{/privacy}", + "received_events_url": "https://api.github.com/users/machour/received_events", + "type": "User", + "site_admin": false, + + + "name": "Mehdi Achour", + "company": "IDK", + "blog": "https://machour.idk.tn/", + "location": "Tunis", + "email": null, + "hireable": true, + "bio": null, + "public_repos": 55, + "public_gists": 4, + "followers": 61, + "following": 42, + "created_at": "2010-06-14T01:09:25Z", + "updated_at": "2017-10-04T06:11:32Z" +} + + */ diff --git a/src/api/rest/reducers/entities.js b/src/api/rest/reducers/entities.js new file mode 100644 index 000000000..678d88c24 --- /dev/null +++ b/src/api/rest/reducers/entities.js @@ -0,0 +1,17 @@ +import merge from 'lodash.merge'; + +// Updates an entity cache in response to any action with response.entities. +export const entities = ( + state = { + users: {}, + orgs: {}, + repos: {}, + }, + action +) => { + if (action.response && action.response.entities) { + return merge({}, state, action.response.entities); + } + + return state; +}; diff --git a/src/api/rest/reducers/errorMessage.js b/src/api/rest/reducers/errorMessage.js new file mode 100644 index 000000000..e9859a803 --- /dev/null +++ b/src/api/rest/reducers/errorMessage.js @@ -0,0 +1,14 @@ +const RESET_ERROR_MESSAGE = 'RESET_ERROR'; + +// Updates error message to notify about the failed fetches. +export const errorMessage = (state = null, action) => { + const { type, error } = action; + + if (type === RESET_ERROR_MESSAGE) { + return null; + } else if (error) { + return error; + } + + return state; +}; diff --git a/src/api/rest/reducers/pagination.js b/src/api/rest/reducers/pagination.js new file mode 100644 index 000000000..a5125eed7 --- /dev/null +++ b/src/api/rest/reducers/pagination.js @@ -0,0 +1,75 @@ +import { combineReducers } from 'redux'; +import union from 'lodash.union'; + +import { REPOS_BY_ORG, MEMBERS_BY_ORG } from '../actions/orgs'; +import { REPOS_BY_SEARCH } from '../actions/search'; + +// Creates a reducer managing pagination, given the action types to handle, +// and a function telling how to extract the key from an action. +const paginate = types => { + if (typeof types !== 'object') { + throw new Error('Expected types to be an object of three props.'); + } + + const updatePagination = ( + state = { + isFetching: false, + nextPageUrl: undefined, + pageCount: 0, + ids: [], + }, + action + ) => { + switch (action.type) { + case types.PENDING: + return { + ...state, + isFetching: true, + }; + case types.SUCCESS: + return { + ...state, + isFetching: false, + ids: union(state.ids, action.response.result), + nextPageUrl: action.response.nextPageUrl, + pageCount: state.pageCount + 1, + }; + case types.ERROR: + return { + ...state, + isFetching: false, + }; + default: + return state; + } + }; + + /* eslint-disable no-case-declarations */ + return (state = {}, action) => { + // Update pagination by key + switch (action.type) { + case types.PENDING: + case types.SUCCESS: + case types.ERROR: + const key = action.id; + + if (typeof key !== 'string') { + throw new Error('Expected key to be a string.'); + } + + return { + ...state, + [key]: updatePagination(state[key], action), + }; + default: + return state; + } + }; +}; + +// Updates the pagination data for different actions. +export const pagination = combineReducers({ + reposByOrg: paginate(REPOS_BY_ORG), + reposBySearch: paginate(REPOS_BY_SEARCH), + membersByOrg: paginate(MEMBERS_BY_ORG), +}); diff --git a/src/components/entity-info.component.js b/src/components/entity-info.component.js index 843ef2020..86bf4f1a1 100644 --- a/src/components/entity-info.component.js +++ b/src/components/entity-info.component.js @@ -143,6 +143,22 @@ export const EntityInfo = ({ entity, orgs, language, navigation }: Props) => { onPress={() => Communications.web(getBlogLink(entity.blog))} underlayColor={colors.greyLight} />} + + {!!entity.webSite && + entity.webSite !== '' && + Communications.web(getBlogLink(entity.webSite))} + underlayColor={colors.greyLight} + />} ); }; diff --git a/src/components/index.js b/src/components/index.js index e991d6c54..ace4d8d27 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -12,9 +12,11 @@ export * from './label-list-item.component'; export * from './github-htmlview.component'; export * from './markdown-webview.component'; export * from './members.component'; +export * from './users-avatar-list.component'; export * from './mention-area.component'; export * from './notification-list-item.component'; export * from './parallax-scroll.component'; +export * from './repo-list-item.component'; export * from './repository-list-item.component'; export * from './repository-profile.component'; export * from './repository-section-title.component'; @@ -23,6 +25,7 @@ export * from './section-list.component'; export * from './state-badge.component'; export * from './user-list-item.component'; export * from './user-profile.component'; +export * from './org-profile.component'; export * from './view-container.component'; export * from './image-zoom.component'; export * from './button.component'; diff --git a/src/components/org-profile.component.js b/src/components/org-profile.component.js new file mode 100644 index 000000000..0b4691117 --- /dev/null +++ b/src/components/org-profile.component.js @@ -0,0 +1,133 @@ +/* eslint-disable no-prototype-builtins */ +import React from 'react'; +import { StyleSheet, Text, View, TouchableOpacity } from 'react-native'; +import { colors, fonts, normalize } from 'config'; +import { translate } from 'utils'; +import { ImageZoom } from 'components'; + +type Props = { + initialOrg: Object, + org: Object, + language: string, + navigation: Object, +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + wrapperContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + profile: { + flex: 3, + alignItems: 'center', + justifyContent: 'flex-end', + }, + avatar: { + width: 75, + height: 75, + marginBottom: 20, + borderRadius: 37.5, + }, + userAvatar: { + borderColor: colors.white, + borderWidth: 2, + }, + title: { + color: colors.white, + ...fonts.fontPrimaryBold, + fontSize: normalize(16), + marginBottom: 2, + }, + subtitle: { + color: colors.white, + ...fonts.fontPrimary, + fontSize: normalize(12), + marginBottom: 50, + paddingLeft: 15, + paddingRight: 15, + textAlign: 'center', + }, + details: { + flex: 1, + flexDirection: 'row', + }, + unit: { + flex: 1, + }, + unitNumber: { + textAlign: 'center', + color: colors.white, + ...fonts.fontPrimaryBold, + fontSize: normalize(16), + }, + unitText: { + textAlign: 'center', + color: colors.white, + fontSize: normalize(10), + ...fonts.fontPrimary, + }, + unitStatus: { + textAlign: 'center', + color: colors.lighterBoldGreen, + fontSize: normalize(8), + ...fonts.fontPrimary, + }, + badge: { + paddingTop: 3, + paddingBottom: 3, + marginTop: 5, + marginLeft: 17, + marginRight: 17, + borderWidth: 0.5, + borderRadius: 5, + borderColor: colors.lighterBoldGreen, + justifyContent: 'center', + }, + green: { + color: colors.lightGreen, + }, +}); + +export const OrgProfile = ({ initialOrg, org, language, navigation }: Props) => + + + + + + {org.name || ' '} + + + {initialOrg.login || ' '} + + + + + navigation.navigate('OrgRepositoryList', { + title: translate('user.repositoryList.title', language), + orgId: org.id, + repoCount: org.countRepos > 15 ? 15 : org.countRepos, + })} + > + + {org.countRepos} + + + {translate('common.repositories', language)} + + + + + ; diff --git a/src/components/repo-list-item.component.js b/src/components/repo-list-item.component.js new file mode 100644 index 000000000..93e948ab4 --- /dev/null +++ b/src/components/repo-list-item.component.js @@ -0,0 +1,132 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { ListItem, Icon } from 'react-native-elements'; + +import { emojifyText, abbreviateNumber } from 'utils'; +import { colors, languageColors, fonts, normalize } from 'config'; + +type Props = { + repository: Object, + showFullName: boolean, + navigation: Object, +}; + +const styles = StyleSheet.create({ + wrapper: { + marginTop: 5, + marginBottom: 5, + marginLeft: 5, + }, + titleWrapper: { + flexDirection: 'row', + alignItems: 'center', + }, + title: { + color: colors.primaryDark, + ...fonts.fontPrimarySemiBold, + }, + privateIconContainer: { + marginLeft: 6, + }, + description: { + color: colors.primaryDark, + ...fonts.fontPrimaryLight, + }, + extraInfo: { + flexDirection: 'row', + flex: 1, + paddingTop: 5, + }, + extraInfoSubject: { + color: colors.greyDark, + paddingLeft: 3, + paddingTop: 2, + marginRight: 15, + fontSize: normalize(10), + ...fonts.fontPrimary, + }, + repositoryContainer: { + justifyContent: 'center', + flex: 1, + }, +}); + +const renderTitle = (repository, showFullName) => + + + + + {showFullName ? repository.fullName : repository.shortName} + + {repository.private && + + + } + + + {emojifyText(repository.description)} + + + + + + + {abbreviateNumber(repository.countStargazzers)} + + + + + + {abbreviateNumber(repository.countForks)} + + + {repository.language !== null && + } + + + {repository.language} + + + ; + +export const RepoListItem = ({ repository, showFullName, navigation }: Props) => + navigation.navigate('Repository', { repository })} + />; + +RepoListItem.defaultProps = { + showFullName: true, +}; diff --git a/src/components/users-avatar-list.component.js b/src/components/users-avatar-list.component.js new file mode 100644 index 000000000..7d188bfac --- /dev/null +++ b/src/components/users-avatar-list.component.js @@ -0,0 +1,127 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { + StyleSheet, + View, + Text, + FlatList, + TouchableHighlight, + Image, +} from 'react-native'; +import { List, ListItem } from 'react-native-elements'; + +import { colors, fonts } from 'config'; + +const mapStateToProps = state => ({ + authUser: state.auth.user, +}); + +type Props = { + title: string, + members: Array, + noMembersMessage: string, + containerStyle: Object, + smallTitle: string, + navigation: Object, + authUser: Object, + loadMore: Function, +}; + +const size = 120; + +const styles = StyleSheet.create({ + container: { + marginTop: 30, + }, + avatarContainer: { + backgroundColor: colors.greyLight, + borderRadius: size / 2, + width: size, + height: size, + marginRight: 5, + }, + avatar: { + borderRadius: size / 2, + height: size, + width: size, + }, + list: { + marginTop: 0, + }, + sectionTitle: { + color: colors.black, + ...fonts.fontPrimaryBold, + marginBottom: 10, + paddingLeft: 15, + }, + sectionTitleSmall: { + color: colors.primaryDark, + ...fonts.fontPrimarySemiBold, + marginBottom: 10, + paddingLeft: 15, + }, + flatList: { + paddingLeft: 15, + paddingRight: 15, + }, +}); + +const UsersAvatarListComponent = ({ + title, + members, + noMembersMessage, + containerStyle, + smallTitle, + navigation, + loadMore, + authUser, +}: Props) => + + + {title} + + + {noMembersMessage && + !members.length && + + + } + + loadMore()} + onEndReachedThreshold={0.4} + renderItem={({ item }) => + { + navigation.navigate( + authUser.login === item.login ? 'AuthProfile' : 'Profile', + { + user: item, + } + ); + }} + underlayColor="transparent" + style={styles.avatarContainer} + > + + } + keyExtractor={item => item.id} + horizontal + /> + ; + +export const UsersAvatarList = connect(mapStateToProps)( + UsersAvatarListComponent +); diff --git a/src/organization/screens/index.js b/src/organization/screens/index.js index 544587740..2b40d2905 100644 --- a/src/organization/screens/index.js +++ b/src/organization/screens/index.js @@ -1 +1,2 @@ export * from './organization-profile.screen'; +export * from './repository-list.screen'; diff --git a/src/organization/screens/organization-profile.screen.js b/src/organization/screens/organization-profile.screen.js index b7175c8ae..c6a449d5e 100644 --- a/src/organization/screens/organization-profile.screen.js +++ b/src/organization/screens/organization-profile.screen.js @@ -1,55 +1,19 @@ import React, { Component } from 'react'; -import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; -import { StyleSheet, RefreshControl } from 'react-native'; +import { StyleSheet, RefreshControl, Text } from 'react-native'; import { ListItem } from 'react-native-elements'; -import { createStructuredSelector } from 'reselect'; -import { - getAuthLanguage, -} from 'auth'; import { ViewContainer, - UserProfile, - LoadingMembersList, - MembersList, + OrgProfile, SectionList, ParallaxScroll, + LoadingMembersList, EntityInfo, + UsersAvatarList, } from 'components'; -import { - emojifyText, - translate, -} from 'utils'; +import { emojifyText, translate } from 'utils'; import { colors, fonts } from 'config'; -import { - // actions - fetchOrganizations, - fetchOrganizationMembers, - // Selectors - getOrganization, - getOrganizationRepositories, - getOrganizationMembers, - getOrganizationIsPendingOrg, - getOrganizationIsPendingRepos, - getOrganizationIsPendingMembers, -} from '../index'; - -const selectors = createStructuredSelector({ - organization: getOrganization, - repositories: getOrganizationRepositories, - members: getOrganizationMembers, - isPendingOrg: getOrganizationIsPendingOrg, - isPendingRepos: getOrganizationIsPendingRepos, - isPendingMembers: getOrganizationIsPendingMembers, - language: getAuthLanguage, -}); - -const actionCreators = { - fetchOrganizations, - fetchOrganizationMembers, -}; - -const actions = dispatch => bindActionCreators(actionCreators, dispatch); +import { getById, getMembers } from 'api/rest/providers/github/endpoints/orgs'; const styles = StyleSheet.create({ listTitle: { @@ -62,19 +26,22 @@ const styles = StyleSheet.create({ }, }); +/* eslint-disable no-shadow */ +const loadData = ({ orgId, getById, getMembers }) => { + getById(orgId, { requiredFields: ['name'] }); + getMembers(orgId); +}; + class OrganizationProfile extends Component { props: { - fetchOrganizations: Function, - // getOrgReposByDispatch: Function, - fetchOrganizationMembers: Function, - organization: Object, - // repositories: Array, + orgId: String, + org: Object, members: Array, - isPendingOrg: boolean, - // isPendingRepos: boolean, - isPendingMembers: boolean, + membersPagination: Object, navigation: Object, language: string, + getMembers: Function, + getById: Function, }; state: { @@ -88,94 +55,132 @@ class OrganizationProfile extends Component { }; } - componentDidMount() { - const organization = this.props.navigation.state.params.organization; + componentWillMount() { + loadData(this.props); + } - this.props.fetchOrganizations(organization.login); - this.props.fetchOrganizationMembers(organization.login); + componentWillReceiveProps(nextProps) { + if (nextProps.orgId !== this.props.orgId) { + loadData(nextProps); + } } - getOrgData = () => { - const organization = this.props.navigation.state.params.organization; + refreshData = () => { + const { navigation, getMembers, getById } = this.props; + const orgId = navigation.state.params.organization.login; + + navigation.setParams({ refreshing: true }); + getById(orgId, { forceRefresh: true }); + getMembers(orgId, { forceRefresh: true }); + }; + + loadMoreMembers = () => { + const { orgId, getMembers } = this.props; - this.setState({ refreshing: true }); - Promise.all([ - this.props.fetchOrganizations(organization.login), - this.props.fetchOrganizationMembers(organization.login), - ]).then(() => { - this.setState({ refreshing: false }); - }); + getMembers(orgId, { loadMore: true }); }; render() { const { - organization, + orgId, + org, members, - isPendingOrg, - isPendingMembers, + membersPagination, navigation, language, } = this.props; const { refreshing } = this.state; const initialOrganization = this.props.navigation.state.params.organization; + if (!org) { + return ( + + Loading organization {orgId} .. TODO: Make me look nicer + + ); + } + return ( - } refreshControl={ this.refreshData()} refreshing={refreshing} /> } - stickyTitle={organization.name} + stickyTitle={orgId} navigateBack navigation={navigation} > - {isPendingMembers && + {membersPagination.isFetching && + !membersPagination.pageCount && } - {!isPendingMembers && - 0) && + } - {!!organization.description && - organization.description !== '' && + {!!org.description && + org.description !== '' && } - - {!isPendingOrg && - } + ); } } -export const OrganizationProfileScreen = connect( - selectors, - actions -)(OrganizationProfile); +const mapStateToProps = (state, ownProps) => { + // TODO: This should be normalized to params.id + const orgId = ownProps.navigation.state.params.organization.login.toLowerCase(); + + const { pagination: { membersByOrg }, entities: { orgs, users } } = state; + + const membersPagination = membersByOrg[orgId] || { + ids: [], + isFetching: true, + }; + const members = membersPagination.ids.map(id => users[id]); + + if (ownProps.navigation.state.params.refreshing) { + // We were asked to refresh and we're here, so we're done. + ownProps.navigation.setParams({ refreshing: false }); + } + + return { + orgId, + members, + membersPagination, + org: orgs[orgId], + }; +}; + +export const OrganizationProfileScreen = connect(mapStateToProps, { + getById, + getMembers, +})(OrganizationProfile); diff --git a/src/organization/screens/repository-list.screen.js b/src/organization/screens/repository-list.screen.js new file mode 100644 index 000000000..eecea4000 --- /dev/null +++ b/src/organization/screens/repository-list.screen.js @@ -0,0 +1,223 @@ +/* eslint-disable no-shadow */ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { FlatList, View, Dimensions, StyleSheet } from 'react-native'; + +import { + ViewContainer, + RepoListItem, + LoadingRepositoryListItem, + SearchBar, +} from 'components'; +import { colors } from 'config'; + +import { getRepos } from 'api/rest/providers/github/endpoints/orgs'; +import { searchRepos } from 'api/rest/providers/github/endpoints/search'; + +const loadData = ({ orgId, getRepos }) => { + getRepos(orgId); +}; + +// TODO: clean up searchStart & query usage + +const styles = StyleSheet.create({ + header: { + borderBottomColor: colors.greyLight, + borderBottomWidth: 1, + }, + searchBarWrapper: { + flexDirection: 'row', + }, + searchContainer: { + width: Dimensions.get('window').width, + backgroundColor: colors.white, + flex: 1, + }, + searchCancelButton: { + color: colors.black, + }, + listContainer: { + marginBottom: 90, + }, +}); + +class OrgRepositoryList extends Component { + props: { + searchRepos: Function, + getRepos: Function, + orgId: String, + + searchedResults: Array, + repositories: Array, + + repositoriesPagination: Object, + searchedResultsPagination: Object, + + authUser: Object, + + navigation: Object, + }; + + state: { + query: string, + searchStart: boolean, + searchFocus: boolean, + }; + + constructor() { + super(); + + this.state = { + query: '', + searchStart: false, + searchFocus: false, + }; + + this.search = this.search.bind(this); + this.getList = this.getList.bind(this); + } + + componentWillMount() { + loadData(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.orgId !== this.props.orgId) { + loadData(nextProps); + } + } + + getList = () => { + const { searchedResults, repositories } = this.props; + const { searchStart } = this.state; + + return searchStart ? searchedResults : repositories; + }; + + loadMore = () => { + const { orgId, getRepos, searchRepos, navigation } = this.props; + + if (navigation.state.params.searchedKeyword) { + searchRepos(navigation.state.params.searchedKeyword, true); + } else { + getRepos(orgId, { loadMore: true }); + } + }; + + search(query) { + if (query !== '') { + const searchedKeyword = `q=${query}+user:${this.props.navigation.state + .params.orgId}+fork:true`; + + this.setState({ + searchStart: true, + query, + }); + + const { searchRepos, navigation } = this.props; + + navigation.setParams({ searchedKeyword }); + searchRepos(searchedKeyword); + } + } + + keyExtractor = item => { + return item.id; + }; + + render() { + const { + authUser, + navigation, + searchedResultsPagination, + repositoriesPagination, + } = this.props; + const repoCount = navigation.state.params.repoCount; + const { searchStart, searchFocus } = this.state; + const loading = + (searchedResultsPagination.isFetching && + !searchedResultsPagination.pageCount) || + (repositoriesPagination.isFetching && !repositoriesPagination.pageCount); + + return ( + + + + + + this.setState({ searchFocus: true })} + onCancelButtonPress={() => + this.setState({ searchStart: false, query: '' })} + onSearchButtonPress={query => { + this.search(query); + }} + hideBackground + /> + + + + + {loading && + [...Array(searchStart ? repoCount : 10)].map( + (item, index) => // eslint-disable-line react/no-array-index-key + )} + + {!loading && + + + } + /> + } + + + ); + } +} + +const mapStateToProps = (state, ownProps) => { + const orgId = ownProps.navigation.state.params.orgId; + + const { + auth: { user }, + pagination: { reposByOrg, reposBySearch }, + entities: { orgs, repos }, + } = state; + + const repositoriesPagination = reposByOrg[orgId] || { ids: [] }; + const repositories = repositoriesPagination.ids.map(id => repos[id]); + + const searchedKeyword = ownProps.navigation.state.params.searchedKeyword; + const searchedResultsPagination = searchedKeyword + ? reposBySearch[searchedKeyword] + : { ids: [] }; + const searchedResults = searchedResultsPagination.ids.map(id => repos[id]); + + return { + orgId, + authUser: user, + repositories, + repositoriesPagination, + searchedResults, + searchedResultsPagination, + org: orgs[orgId], + }; +}; + +export const OrgRepositoryListScreen = connect(mapStateToProps, { + getRepos, + searchRepos, +})(OrgRepositoryList); From 0f5746d1dffb4249fedd35e8e824c79b0d1459c3 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 9 Oct 2017 04:52:05 +0100 Subject: [PATCH 02/36] fix(browser): Fix openInBrowser url --- .../screens/organization-profile.screen.js | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/organization/screens/organization-profile.screen.js b/src/organization/screens/organization-profile.screen.js index f91283997..f5db58bdb 100644 --- a/src/organization/screens/organization-profile.screen.js +++ b/src/organization/screens/organization-profile.screen.js @@ -3,7 +3,6 @@ import { connect } from 'react-redux'; import { StyleSheet, RefreshControl, Text } from 'react-native'; import { ListItem } from 'react-native-elements'; import ActionSheet from 'react-native-actionsheet'; -import { getAuthLanguage } from 'auth'; import { ViewContainer, OrgProfile, @@ -13,7 +12,6 @@ import { EntityInfo, UsersAvatarList, } from 'components'; -import { emojifyText, translate } from 'utils'; import { emojifyText, translate, openURLInView } from 'utils'; import { colors, fonts } from 'config'; import { getById, getMembers } from 'api/rest/providers/github/endpoints/orgs'; @@ -89,7 +87,7 @@ class OrganizationProfile extends Component { handleActionSheetPress = index => { if (index === 0) { - openURLInView(this.props.org.html_url); + openURLInView(this.props.org._entityUrl); } }; @@ -126,8 +124,7 @@ class OrganizationProfile extends Component { : initialOrganization } navigation={navigation} - /> - )} + />} refreshControl={ this.refreshData()} @@ -140,13 +137,11 @@ class OrganizationProfile extends Component { showMenu menuAction={() => this.showMenuActionSheet()} > - {membersPagination.isFetching && !membersPagination.pageCount && - )} + />} {(!membersPagination.isFetching || membersPagination.pageCount > 0) && - )} + />} {!!org.description && org.description !== '' && @@ -169,7 +163,6 @@ class OrganizationProfile extends Component { /> } - { export const OrganizationProfileScreen = connect(mapStateToProps, { getById, getMembers, -})(OrganizationProfile); \ No newline at end of file +})(OrganizationProfile); From b710e80fd77d35dd20aaa4ea63bb5c9863d9d47a Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 9 Oct 2017 04:54:56 +0100 Subject: [PATCH 03/36] fix(build): add lodash --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 80850870d..740faaa34 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "dependencies": { "entities": "^1.1.1", "fuzzy-search": "^1.4.0", + "lodash.has": "^4.5.2", "lodash.merge": "^4.6.0", "lodash.union": "^4.6.0", "lodash.uniqby": "^4.7.0", From b52977ad20edaf472768c272abf4b3b1894e33be Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Tue, 10 Oct 2017 20:24:24 +0100 Subject: [PATCH 04/36] refactor(api): Remove useless files for clarity sake & reducer --- root.reducer.js | 2 - src/organization/index.js | 5 -- src/organization/organization.action.js | 77 -------------------- src/organization/organization.constants.js | 9 --- src/organization/organization.reducer.js | 83 ---------------------- src/organization/organization.selectors.js | 48 ------------- 6 files changed, 224 deletions(-) delete mode 100644 src/organization/organization.action.js delete mode 100644 src/organization/organization.constants.js delete mode 100644 src/organization/organization.reducer.js delete mode 100644 src/organization/organization.selectors.js diff --git a/root.reducer.js b/root.reducer.js index e44c5547e..4452f9973 100644 --- a/root.reducer.js +++ b/root.reducer.js @@ -2,7 +2,6 @@ import { combineReducers } from 'redux'; import { authReducer } from 'auth'; import { userReducer } from 'user'; import { repositoryReducer } from 'repository'; -import { organizationReducer } from 'organization'; import { issueReducer } from 'issue'; import { searchReducer } from 'search'; import { notificationsReducer } from 'notifications'; @@ -18,7 +17,6 @@ export const rootReducer = combineReducers({ auth: authReducer, user: userReducer, repository: repositoryReducer, - organization: organizationReducer, issue: issueReducer, search: searchReducer, notifications: notificationsReducer, diff --git a/src/organization/index.js b/src/organization/index.js index ec9c14b2a..c9de5c34d 100644 --- a/src/organization/index.js +++ b/src/organization/index.js @@ -1,6 +1 @@ -export * from './organization.action'; -export * from './organization.reducer'; -export * from './organization.constants'; -export * from './organization.selectors'; - export * from './screens'; diff --git a/src/organization/organization.action.js b/src/organization/organization.action.js deleted file mode 100644 index 19bd0eaae..000000000 --- a/src/organization/organization.action.js +++ /dev/null @@ -1,77 +0,0 @@ -import { createAction } from 'redux-actions'; - -import { fetchOrg, fetchOrgMembers, v3 } from 'api'; -import { - GET_ORG, - GET_ORG_LOADING, - GET_ORG_ERROR, - GET_ORG_REPOS, - GET_ORG_REPOS_LOADING, - GET_ORG_REPOS_ERROR, - GET_ORG_MEMBERS, - GET_ORG_MEMBERS_LOADING, - GET_ORG_MEMBERS_ERROR, -} from './organization.constants'; - -export const getOrg = createAction(GET_ORG); -export const getOrgLoading = createAction(GET_ORG_LOADING); -export const getOrgError = createAction(GET_ORG_ERROR); -export const getOrgRepos = createAction(GET_ORG_REPOS); -export const getOrgReposLoading = createAction(GET_ORG_REPOS_LOADING); -export const getOrgReposError = createAction(GET_ORG_REPOS_ERROR); -export const getOrgMembers = createAction(GET_ORG_MEMBERS); -export const getOrgMembersLoading = createAction(GET_ORG_MEMBERS_LOADING); -export const getOrgMembersError = createAction(GET_ORG_MEMBERS_ERROR); - -export const fetchOrganizations = orgName => (dispatch, getState) => { - // use a selector here - const accessToken = getState().auth.accessToken; - - dispatch(getOrgLoading(true)); - dispatch(getOrgError('')); - - return fetchOrg(orgName, accessToken) - .then(data => { - dispatch(getOrgLoading(false)); - dispatch(getOrg(data)); - }) - .catch(error => { - dispatch(getOrgLoading(false)); - dispatch(getOrgError(error)); - }); -}; - -export const fetchOrganizationRepos = url => (dispatch, getState) => { - const accessToken = getState().auth.accessToken; - - dispatch(getOrgReposLoading(true)); - dispatch(getOrgReposError('')); - - v3 - .getJson(url, accessToken) - .then(data => { - dispatch(getOrgReposLoading(false)); - dispatch(getOrgRepos(data)); - }) - .catch(error => { - dispatch(getOrgReposLoading(false)); - dispatch(getOrgReposError(error)); - }); -}; - -export const fetchOrganizationMembers = orgName => (dispatch, getState) => { - const accessToken = getState().auth.accessToken; - - dispatch(getOrgMembersLoading(true)); - dispatch(getOrgMembersError('')); - - return fetchOrgMembers(orgName, accessToken) - .then(data => { - dispatch(getOrgMembersLoading(false)); - dispatch(getOrgMembers(data)); - }) - .catch(error => { - dispatch(getOrgMembersLoading(false)); - dispatch(getOrgMembersError(error)); - }); -}; diff --git a/src/organization/organization.constants.js b/src/organization/organization.constants.js deleted file mode 100644 index 76e3d0e2c..000000000 --- a/src/organization/organization.constants.js +++ /dev/null @@ -1,9 +0,0 @@ -export const GET_ORG = 'GET_ORG'; -export const GET_ORG_LOADING = 'GET_ORG_LOADING'; -export const GET_ORG_ERROR = 'GET_ORG_ERROR'; -export const GET_ORG_REPOS = 'GET_ORG_REPOS'; -export const GET_ORG_REPOS_LOADING = 'GET_ORG_REPOS_LOADING'; -export const GET_ORG_REPOS_ERROR = 'GET_ORG_REPOS_ERROR'; -export const GET_ORG_MEMBERS = 'GET_ORG_MEMBERS'; -export const GET_ORG_MEMBERS_LOADING = 'GET_ORG_MEMBERS_LOADING'; -export const GET_ORG_MEMBERS_ERROR = 'GET_ORG_MEMBERS_ERROR'; diff --git a/src/organization/organization.reducer.js b/src/organization/organization.reducer.js deleted file mode 100644 index e719ee919..000000000 --- a/src/organization/organization.reducer.js +++ /dev/null @@ -1,83 +0,0 @@ -import { handleActions } from 'redux-actions'; - -import { - GET_ORG, - GET_ORG_LOADING, - GET_ORG_ERROR, - GET_ORG_REPOS, - GET_ORG_REPOS_LOADING, - GET_ORG_REPOS_ERROR, - GET_ORG_MEMBERS, - GET_ORG_MEMBERS_LOADING, - GET_ORG_MEMBERS_ERROR, -} from './organization.constants'; - -const initialState = { - organization: {}, - repositories: [], - members: [], - isPendingOrg: false, - isPendingRepos: false, - isPendingMembers: false, - organizationError: '', - organizationRepositoriesError: '', - organizationMembersError: '', -}; - -export const organizationReducer = handleActions({ - [GET_ORG]: (state, { payload }) => { - return { - ...state, - organization: payload, - }; - }, - [GET_ORG_LOADING]: (state, { payload }) => { - return { - ...state, - isPendingOrg: payload, - }; - }, - [GET_ORG_ERROR]: (state, { payload }) => { - return { - ...state, - organizationError: payload, - }; - }, - [GET_ORG_REPOS]: (state, { payload }) => { - return { - ...state, - repositories: payload, - }; - }, - [GET_ORG_REPOS_LOADING]: (state, { payload }) => { - return { - ...state, - isPendingRepos: payload, - }; - }, - [GET_ORG_REPOS_ERROR]: (state, { payload }) => { - return { - ...state, - organizationRepositoriesError: payload, - }; - }, - [GET_ORG_MEMBERS]: (state, { payload }) => { - return { - ...state, - members: payload, - }; - }, - [GET_ORG_MEMBERS_LOADING]: (state, { payload }) => { - return { - ...state, - isPendingMembers: payload, - }; - }, - [GET_ORG_MEMBERS_ERROR]: (state, { payload }) => { - return { - ...state, - organizationMembersError: payload, - }; - }, -}, initialState); - diff --git a/src/organization/organization.selectors.js b/src/organization/organization.selectors.js deleted file mode 100644 index a8f170a29..000000000 --- a/src/organization/organization.selectors.js +++ /dev/null @@ -1,48 +0,0 @@ -import { createSelector } from 'reselect'; - -const getOrganizationFromStore = state => state.organization; - -export const getOrganization = createSelector( - getOrganizationFromStore, - organization => organization.organization || {} -); - -export const getOrganizationRepositories = createSelector( - getOrganizationFromStore, - organization => organization.repositories || [] -); - -export const getOrganizationMembers = createSelector( - getOrganizationFromStore, - organization => organization.members || [] -); - -export const getOrganizationIsPendingOrg = createSelector( - getOrganizationFromStore, - organization => organization.isPendingOrg || false -); - -export const getOrganizationIsPendingRepos = createSelector( - getOrganizationFromStore, - organization => organization.isPendingRepos || false -); - -export const getOrganizationIsPendingMembers = createSelector( - getOrganizationFromStore, - organization => organization.isPendingMembers || false -); - -export const getOrganizationError = createSelector( - getOrganizationFromStore, - organization => organization.organizationError || '' -); - -export const getOrganizationRepositoriesError = createSelector( - getOrganizationFromStore, - organization => organization.organizationRepositoriesError || '' -); - -export const getOrganizationMembersError = createSelector( - getOrganizationFromStore, - organization => organization.organizationMembersError || '' -); From ae00beb999e28c15d60dfea7fd9d872eb47d3249 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Tue, 10 Oct 2017 20:55:46 +0100 Subject: [PATCH 05/36] refactor: group and host screens under src/screens --- src/organization/index.js | 1 - src/organization/screens/index.js | 2 -- src/screens/index.js | 5 +++++ src/screens/organization/index.js | 7 +++++++ .../organization/organization-profile.js} | 0 .../organization/repository-list.js} | 0 6 files changed, 12 insertions(+), 3 deletions(-) delete mode 100644 src/organization/index.js delete mode 100644 src/organization/screens/index.js create mode 100644 src/screens/index.js create mode 100644 src/screens/organization/index.js rename src/{organization/screens/organization-profile.screen.js => screens/organization/organization-profile.js} (100%) rename src/{organization/screens/repository-list.screen.js => screens/organization/repository-list.js} (100%) diff --git a/src/organization/index.js b/src/organization/index.js deleted file mode 100644 index c9de5c34d..000000000 --- a/src/organization/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './screens'; diff --git a/src/organization/screens/index.js b/src/organization/screens/index.js deleted file mode 100644 index 2b40d2905..000000000 --- a/src/organization/screens/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export * from './organization-profile.screen'; -export * from './repository-list.screen'; diff --git a/src/screens/index.js b/src/screens/index.js new file mode 100644 index 000000000..3365fee60 --- /dev/null +++ b/src/screens/index.js @@ -0,0 +1,5 @@ +import organization from './organization'; + +export default { + organization, +}; diff --git a/src/screens/organization/index.js b/src/screens/organization/index.js new file mode 100644 index 000000000..50b802bf8 --- /dev/null +++ b/src/screens/organization/index.js @@ -0,0 +1,7 @@ +import { OrganizationProfileScreen } from './organization-profile'; +import { OrgRepositoryListScreen } from './repository-list'; + +export default { + OrganizationProfileScreen, + OrgRepositoryListScreen, +}; diff --git a/src/organization/screens/organization-profile.screen.js b/src/screens/organization/organization-profile.js similarity index 100% rename from src/organization/screens/organization-profile.screen.js rename to src/screens/organization/organization-profile.js diff --git a/src/organization/screens/repository-list.screen.js b/src/screens/organization/repository-list.js similarity index 100% rename from src/organization/screens/repository-list.screen.js rename to src/screens/organization/repository-list.js From de4ab4206d5ec7132103818437d3168cdd61e09b Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Tue, 10 Oct 2017 20:55:58 +0100 Subject: [PATCH 06/36] refactor: group and host screens under src/screens --- routes.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/routes.js b/routes.js index 8d1f499c2..de7a38180 100644 --- a/routes.js +++ b/routes.js @@ -12,6 +12,8 @@ import { NotificationIcon } from 'components'; import { colors } from 'config'; import { translate } from 'utils'; +import screens from 'screens'; + // Auth import { SplashScreen, @@ -31,12 +33,6 @@ import { FollowingListScreen, } from 'user'; -// Organization -import { - OrganizationProfileScreen, - OrgRepositoryListScreen, -} from 'organization'; - // Search import { SearchScreen } from 'search'; @@ -65,7 +61,7 @@ import { const sharedRoutes = { OrgRepositoryList: { - screen: OrgRepositoryListScreen, + screen: screens.organization.OrgRepositoryListScreen, navigationOptions: ({ navigation }) => ({ title: navigation.state.params.title, }), @@ -101,7 +97,7 @@ const sharedRoutes = { }, }, Organization: { - screen: OrganizationProfileScreen, + screen: screens.organization.OrganizationProfileScreen, navigationOptions: { header: null, }, From 83e92b5d089335dcb33b204f628099b09692d77c Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Tue, 10 Oct 2017 21:09:05 +0100 Subject: [PATCH 07/36] refactor: get rid of initialOrg :-O --- src/components/org-profile.component.js | 9 +-- .../organization/organization-profile.js | 78 +++++++++---------- 2 files changed, 38 insertions(+), 49 deletions(-) diff --git a/src/components/org-profile.component.js b/src/components/org-profile.component.js index 0b4691117..cdcc60f53 100644 --- a/src/components/org-profile.component.js +++ b/src/components/org-profile.component.js @@ -6,7 +6,6 @@ import { translate } from 'utils'; import { ImageZoom } from 'components'; type Props = { - initialOrg: Object, org: Object, language: string, navigation: Object, @@ -92,15 +91,13 @@ const styles = StyleSheet.create({ }, }); -export const OrgProfile = ({ initialOrg, org, language, navigation }: Props) => +export const OrgProfile = ({ org, language, navigation }: Props) => @@ -108,7 +105,7 @@ export const OrgProfile = ({ initialOrg, org, language, navigation }: Props) => {org.name || ' '} - {initialOrg.login || ' '} + {org.login || ' '} diff --git a/src/screens/organization/organization-profile.js b/src/screens/organization/organization-profile.js index f5db58bdb..d212ad74f 100644 --- a/src/screens/organization/organization-profile.js +++ b/src/screens/organization/organization-profile.js @@ -33,10 +33,36 @@ const loadData = ({ orgId, getById, getMembers }) => { getMembers(orgId); }; +const mapStateToProps = (state, ownProps) => { + // TODO: This should be normalized to params.id + const orgId = ownProps.navigation.state.params.organization.login.toLowerCase(); + + const { pagination: { membersByOrg }, entities: { orgs, users } } = state; + + const membersPagination = membersByOrg[orgId] || { + ids: [], + isFetching: true, + }; + const members = membersPagination.ids.map(id => users[id]); + + if (ownProps.navigation.state.params.refreshing) { + // We were asked to refresh and we're here, so we're done. + ownProps.navigation.setParams({ refreshing: false }); + } + + return { + orgId, + members, + membersPagination, + // normalized attribute + entity: orgs[orgId], + }; +}; + class OrganizationProfile extends Component { props: { orgId: String, - org: Object, + entity: Object, members: Array, membersPagination: Object, navigation: Object, @@ -87,24 +113,23 @@ class OrganizationProfile extends Component { handleActionSheetPress = index => { if (index === 0) { - openURLInView(this.props.org._entityUrl); + openURLInView(this.props.entity._entityUrl); } }; render() { const { orgId, - org, + entity, members, membersPagination, navigation, language, } = this.props; const { refreshing } = this.state; - const initialOrganization = this.props.navigation.state.params.organization; const organizationActions = [translate('common.openInBrowser', language)]; - if (!org) { + if (!entity) { return ( Loading organization {orgId} .. TODO: Make me look nicer @@ -116,15 +141,7 @@ class OrganizationProfile extends Component { - } + } refreshControl={ this.refreshData()} @@ -151,18 +168,18 @@ class OrganizationProfile extends Component { navigation={navigation} />} - {!!org.description && - org.description !== '' && + {!!entity.description && + entity.description !== '' && } - + { - // TODO: This should be normalized to params.id - const orgId = ownProps.navigation.state.params.organization.login.toLowerCase(); - - const { pagination: { membersByOrg }, entities: { orgs, users } } = state; - - const membersPagination = membersByOrg[orgId] || { - ids: [], - isFetching: true, - }; - const members = membersPagination.ids.map(id => users[id]); - - if (ownProps.navigation.state.params.refreshing) { - // We were asked to refresh and we're here, so we're done. - ownProps.navigation.setParams({ refreshing: false }); - } - - return { - orgId, - members, - membersPagination, - org: orgs[orgId], - }; -}; - export const OrganizationProfileScreen = connect(mapStateToProps, { getById, getMembers, From a24f64eb60c692da89488558731dd53b4f918199 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Tue, 10 Oct 2017 22:08:23 +0100 Subject: [PATCH 08/36] feat: No more waiting screen, mock entity attributes instead --- src/assets/images/loading.gif | Bin 0 -> 102926 bytes src/components/org-profile.component.js | 84 +++++++++++------- src/components/users-avatar-list.component.js | 2 +- .../organization/organization-profile.js | 15 +--- 4 files changed, 55 insertions(+), 46 deletions(-) create mode 100644 src/assets/images/loading.gif diff --git a/src/assets/images/loading.gif b/src/assets/images/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..7be3a44a60f65ffb59f132dbec9a94f9ce7d818d GIT binary patch literal 102926 zcma&McUTi!*Y`gymEuewbTFZJLJ_*z|=kSZ?x$ozFe%JLr?{&{Vnc1_~-fPWT*_r*xcbT7$lQZ`o za1Z$M9r*LlKi|E3H$6T5=+UD)ckW!ebZK~a_|&OW?d|Oa1qC9JsJy&<*REYLF)`ZO z+Mb@Ct5>h)@pyK2b^rkU{rBI0{q@)P@85s^{Q2X@k8j_;efjd`>({Tdv$OZ^-TUz2 zgG?rypPxT-=FF>Cuj=dTU%Ytn>C>n8@88eO&3*p-`SRt<6%`eK{PD+y3m3-6$3K4j zSW;5*`|rO`PEL-Dj68h!P%IX|eED)_X6ES8qpx4T9vT`N9UVP?{`~Xj&mTX2+|tr= z`}Xa-ckjM^`}WC`Cl4Mxn3|f}yLa!KH*ap;y7lbYv-|h&KYjXi%a$!SZ{ECm_3G)< zrzH}}l`B_n+_*6?FwoZ4cJ}Pqj*gDse*3Mlv9YkQ@cQ-ZQmORXwQJ|jojY;j#Ngmy zO-;?=!-se8-hJ`n#jRVn9zTA(sHkXcY^=Jvy0^EtudlDOvvXo%;@GicU0q$Vv9YPC zsfP|7>hA77aNs~sPfuA{S#xugMK_l$2y@YPw>@iiHao(r7dj6BA`+<#p@U z`S|$I>2v~tprfOM$K#hQSz=*f;qLBEB9UMi4hRTvadAOWR8v#a(a}*wMFj*wTU%Re zYik1ogQ%z|B_$;UK^81nK&4U{42GehA%#LwP*5O~$wo#-R#sLx98N<+!^XyD&6+jx zYLwUQ|2*jP3M;&WS9`M9-gedu7?j_WH-#@})4aH;T=}Q#(lj17c4K@hEhc_L;$}Df zKl(4}(-Py{^urb{XDsJ=#wR5DW~9Wg&R7u~o3Sz0IZl76JIyuCB`ukk9G}Xir6q6L zyv-%eO`jXLB_`fQe*UZ3MxXXim(-1J`mA3!Xusz4+>#PcTV(BM6)O*sw%FO)c9E04 z-6Bhx9m95!4a3>SmSM%Pb+Ki**fHeK@Sj6pK3huMdY4sRKL46ae&wd0kebSKv9Xco z)_SMC^_G+kHn#GF*f8vD?Ch-MJ*>8+Z%*Z=S#92C@b3}4;4yd>re<#5fmcM-Ly*#fzOiZG9YU zZM_#cIeRa5Vl4KtwRd7TIk6W1+tzDK?DpjN&8h#kjr%{gtpC;aS3xB6v;dNjr-qyar$3vZRF0_{7UctNcX>+*tT}-@bnN^Yf>VALeHNc>nJ0o0-?IUcQ)~dj9OG?Dr>+A3dCW zFmeC4@q2ggj7e|bx_RUJ=*aN3t5=3D4-QB!UA%C<|J>QWGpA3T?Ct3mpXfT?+0owC zdaR|nsj;D6RClztrn;)K;>h9hLkAC(3HO(l6c-g1#J#FWX z?Wx;Rw(_=YPTsUJDKTNg`uMom7%pes+BMNpkrClxp&`MmR|T!z;P2h9*c#KqZZvEw2Kdplc(jkT4fg}IrjiLsHPfj*tKP)}D!TT4?zU5%=$Lc!1l z%1Vj~WD=2p$00BzUn1z=5E2>q4p{zLNq+s10>pXXGfBr+EE>ors+dO)i@BHeRg5jS z`gS*5Dblb{dNf>S;8mdOS*YXJ)8tpY(Bx?JNKfub=inhpZ1>sDR%*Ny$m4iy^Mr@5J*PG(z& zhOE)3JZV5wTKn?c<0$*f_8n%gl5`^mECBH?l}oAX%gZ?@=~kT~fe)s^jz7%vJNxH0 zKN%IRO!D0@E_pFPSIG3%Ik(-{KlE}<-i;tT;B?ovZB5)o_un4ZnS-Pq{Z2j$?sVFb zMw}aGE?;e2*SUTi-FEMkNx<*#QNeiJ{lty)sw-;x({n5uBHl^6zg>B`@^(Opfn>_6 zBYZOUeuDPmr%E53r$7seWYt>Vu<-GcJ+5|1MAU#&w?4$rEAk4uvoLhIeoVF+S4QO59Hq=)4`pB(&DnyfpK@(<;(Z|H&DT4Z~*nzP#}5qsLc- zB5fzM<7W+kOD@3z6D_sXp1$_skAD$PWLet}bN_wm^%+GM%oP($J0bJy7m8P?Z=D50zv$p6!xjT0BO zxU;b&<*-96W1pkapv9rz_}v5K?N@@A1xK1~9wsWW3-g*%L6Zx+DlvhvR>{S$!Xf(1t_H60 zm&mw%A)E7W+Ig@RERSgiJLk{+>Ah59&RVE#%N@6*g*lvJ1*FHH@;=`_{PuU{WPK9_ ztq@*m$kyk79Up(aYF{hjzZ0!gIQWMisvuIH%<}+vmAF z+SkJ^7ajXr-3r*BU3y#euz4vY8*QI(H*^c_%OcupQ?!eK5!K@YB{$a}K;N@8*!-UsMG=5ug0K}Tu z$kxUY(x6PTHkzKH<(yNG=V5(LGIh)10_sxVqeao89Z5w(LZJrWFqng{;mI^MaSo{P zo+U>F`r=!_if1?HoTpb4+k+R`mqbg5W`-bm`A&AQ6I+GNAgLhBoQhrznwRmK=8-#f zMPAmx!8iH%B%y~6k722uCV&?*_j#>H6jLU--qz3g+r9=ZtowL_XZg&M$_m)6W7_b$ z>cN1{x9;dE@MjQJILU{1eggU6mb^FsyV< zhqy_HJ)gtt>M2*{9wmKvEu@H0fko1y3ldtY6%6!&$J zO%Q0>K)BVWVQ-4+Aw@O!+@m)z(%d}itv-n#pGK+c$ctdwn+ zA*^1~qO^W2kW1=41TBxYuhxDlp&N;cMS1ZDc3(F4ymR)};QA4KK>kY|`I<8@k2NeZ z{TdWw5hGPK{uD;lV0kW_m6iajJy!zD?P1>CFF6jm2HQ2jSs631V&K5TLwwsTW@XA` zX_My%eaM>=NlzCMl@H}C6#AnS)mUsD$7cEAJLik}%nstz%%pUdL^ur*G|RFAJhKIl z?S{ID_u^iE0<$j+ZqZm9KFd-rV(JXpZPes+!VBjnI)mEstINgA6j{XA=Byrt(|i{! z9KK%|zR*!48+K-8-}$UopK7%R*CkaT^j35*ZU zBKtOwR9DpRTbC}nvgoXYlycz>D3Rc$XNkB@Nxm2QV*%HR39OIFirhv(K6r0%=y2mN z&=Bm>*BqqQ%)Rp+0&m*1DB?qz$PNoS(ORznoscQScrw+J(U6M|V+7P$$?xDRv0pwr z*HdGc;_8au2L;b84aM3dV@Xdyk~Gg(4ilCy1Qb2wVl!cT6yXFh86bYeA!>K`!D@` zxpMyd!}-6JhA;j7<@WsFKja%Q4gL`mt~wLIr}2pud~zROae}X`!ACU&6e1t)mWm!&SS%1x9Xk^hXsMaG1w9w{8&{v#fu?%#R;&!1RCWa}-0G(YQCqKXp}7cSgSLZPdRb4(@hdSE}oM2fFcyky93 z;r>i9{B?cKcL0ERdCjdjSALkm9GJi+RXgDivVrF>%81>dp%k3`3j51)wC2IwWJYeD z6P_o-jr>WWey~tw?$;;-7jba2eYtDC25~g-)xKnN0O$08;wL4HjGHeM!ai&j^TSA{ zMnTeW-sc$HCX`qmzR!g%uoi&ZB!D8HsP)BIXBNS+0b72FI+vLdUbv?Zy5$MwCgk_< z;AL!~l>p8KjwqsJXIoR25ZW05xNr{GjOFhSFN}~J{>}pv{Bp}=1#iz#A~=NNvm}dI z3oRaU&2sPpq z8`mT8l8SIiD7_b2&Zvw+53yO`NewEM4dhEIGUg?fm-O=wenVb|?=<1zi$@6OJ}&m6 z;SQ!Fqh}mLImBW!!u=kOH4Q#k39YHzvU#LJkqI{zSg&)c#4!+w*}}q+ih6Y8xApLW z^dseGIW7{yv9?;OEleh88B5Ek-BriGRYkynyR`aJC1%1Sg-ekcqKYa5Uctms%{(qU zS2B4b^$%b;tyUE8k|6|0G{WJ6fGi$B02;NpwFe&{I+Ny93B;9Dxbqs7pW?i#fHj_) z`l2QR1JULl-T4h+ppCbOkMc$cJNqzyKF(_tcYF-=pFtj-!XR4RPQSY2cc8DNBPpB> z8X%y+Y+5X>T@?hb6*XDTa}L2lkjreH|Af98N=_> z6({95fl!=Cv_dI?;^x2?TK+&w>>W(irBdB|p`ECnmyQoA#2m#0ugF;CGK45WF12IU zLL>`qQXD}1wIK8qc4VSzp}8)=CR{MXOA5es%%&241#T;#AO;RNQV>2sl!hsB!5C4~ zDp?Jl1u~d5IC0}05MD|?63_0Sm*Kq%ara#eOW0ld8K}At)D*^oqOK&K$XJH>x3AJ4 z0Pm+CSuU(P7X&V0H}le)4Q!DpEq_%Eo_2wh=3uf2NIr+Ai@LTwK<$B+ADR@6c`5kP zETL-vtd<^iVN$I8o3_i+>Fh3NVUNYb9`YPKU%67qCTRoIPnfG3C@4Csh`revl2RZPF;-&BQAt|f6PqW zoWBZ#*y**cU3D%LfmVCs*6d}-}`*~Pb&%F znMwTH=GNk>Hl=QGDd#v>0A)xj^4Wk=B$_N~10ylUJiDuyv$@s6!$H(#?2lQaP(ubq zbr$@y@C1vF{1^q|(O$!?NR;_%^=^1NGUHlDAGr)SV=hs$?eH(WWH10m%{FO8sZeP| zz3adx<|%D1V#92@@I`MOyNg_dPCrB!iMkwwo241Rok*rrREx_J6$^k|oJfd}P&6b& zFETzn6ZL0zZD6wLH&Ch+Hxt=Q<${*LiHhr}p$vDhE#H0sDJBupqbR|s_-YYr+Qm|J z?y@2so^fec#{fzupxRF{72*#14O!cg75VsqOiVwZX<2~&Vs_WP*J$*46cTl9MH_9I zy;eb>EgLxQucE~`YxxvRou{3&^+%@9kp`--S>46YxLjHJ5Ls1ySsO#P=xC|330^Lz zwVvXp{c&pa5pp+frf{hyc4k{q%SIR%RX9BPf-pVVoK|yEO@Pd}5E3F`j!WOIYp`hJ zs8$d>-40b2j*qF!xw8{89o! z$oW2ClQ*FC2wTqX`si>Qe*;}B>O!Pg5PJM{KW9;3lY(f393*%3tVRjuA?l(_F`v$+ zwc-m_^Mg3>;eH(qnJ&Dnu~k|ajGL+Iq|&dd^l!BlH@`iHxv;y2&fU?NLuSnDwQLcN zd24gyuva@l?22w;cdfc`<=HL_7Ij^{fvNIG2ojv}@%onwyLP zqAnuyv?d6oWCBs*aZvxdm-+3k$S&7jW)(0}7Gd^&%Q` zKaSdpx*~Nb2>X~~iG!AuIH`Dn_!Qixd*@jH1XW(lYwlCGg0FS~F``M(1AhDVmfM=H zphH;gade5O>txOCACGQ3G~%M#AL-WPr)M4=Q+!C}b`m+cCf}sKD#(6jlVJXo#Wt&Z zV4y=r@_^dh7(s~dsx{K!!G_6Cs4QUG-$dK?5qSVNF&~ZInRb`529X z(~$&S?(bv_nrTltmNl|W^l*#?JgdbL*j<`89}w4KOi>pW`ZO$hw>Dd>FndAk=`&I{ zr2D>gQ3W!cfuj#hk+uReGo3!ZT`wZ1m+JjKQ+3a~0`%QHUby^r^;@x{@*d-U!1T>@ z!bQxc2yjZy@$Ls=E>5kx^yj1NXc4f6d3Rj>qQWdL%MCreV$sVY;I%@IH#Og{_*#}r>)D#dh|GmNuu z8O|z`7vI^8y?gS6B6Bwd!T*fUsVnQ8=lzF#?i$%m&gaqxT5i{AtUR9~wS-4%<%_~~ z9Io~5F`{k$0UG_eSdKPL9OSRZM8N+vws*HCzKNt{-D-KM24hy@naPlgvA7I~LTUOdz}!yDhB zfA(`xKSbV;6e7qPhP92!L=Toof^w8my?YIVi3?|OJyE-SB}1c5l&1D zVWb7qeEfLvWM!iWKvF^}tzgrQ{Xcm69c6FNE7FMkL=L-hD0rX**wx(Vp_vw=Z#-Ty zgbz8G9JkKCA@7@^fEKH%z`I2WYiLb5fgo_L?+ox0{5@FYRCNim&PT+pVY zb^r+Wn_cG<w7F4=_&ea3LufxscBCxADnl}H@rFuxqt7ZvfP4~0rq z6%*u>+VLJC)Ian_*#^;nrM%g9?nAqcC;Mka!ZLGqx$nTBOO9fgcg~b0;Ozbg)pe^H z8&eFn8U+1ktyOc(qMdiD99&WOmW|&W_?ors7<(Ld9dGdo7j`!_)dq;0jK&n}S|60@ z1cOlORmH7?R14kdZ=HA4>gP7-=tQunR@TuumL56@mqS!_7G*qdj1CJPYVzPA$|30$ zk5fE$^5&^GZ!}!pT}DW&c$L?#>E=N-%OI2pjiYV5u&%}L&A)mxePf;(X=BU~Gt=XLF`jX&lWshivk~bMm|1zq3>t?)H?s5(9vB%AN|aJ zI(zs~U6~;uwPXdV6uouXu=$5>@Nf=fN@PA*?|~ya2~~M9BSsm9y+t`$=8CWCz7kg{ zbeTeX9X5{OoKQX`>x7XLFBhis6s*N@uyZD$w_^up%5K*D7?nSd8~~GeMu>}E6N2lehA66y(#*G9m%VJw<61OS|T2QbSd zv?4#FHcol32ke8g)Cv=9tyG4CsaLjB6yFJeLp@2(Qy=d#MaFZRh({uKqP<{yjgbC& zV}+kNFUu}TWVJASklaxZAFN<)TOh0P)@PNw4;@H{UL#?Qt=M>_A?$5QCnyrjl^kMn z(TNZeCXiF0qQWJ1<#V=8% zL(d$ZZj)GO4OWo=gH%;af2D7@hl==sog;6EJSQ70lW{bR3xOphJ8jagLi$3Mm#4m{ zx^U`FiopY><9?@A;$Xhg@>H6d$dqF9^EH`D0(q47Re7o#Aote%hfKV&oz`6OaN>ZL z?!?tH*FmofuK@38i4G}YJ6+ppfVAfl71&&l)6&O_ce2w2Hnt2mQyhLOz1>>uINwmQxww#+9wbA zZ4V*ZTu;0H#*|f|tdj$Jcw{e~KE60$dxwuWaVU9y#uOF-CZ^U^WsHgh^@TDeT*c5s z+T`8*uTuxXudgM}l9ED0=9;L!GK-J!Um&VUTc7kmd_7JIN2c6neEvyQ~c> zbLt`o(vP9O639L!dnvcf?xW5Iui8kDR`(m^!ti>NNKu&r@e_D#Vr7hB6?XbC57#q( zL_sVbTq>rYM6dxyMm<6CE`pR~z4mtb=J&R=NTE2FA8X%(gZ8&Tk2Vb_SVTrP)47Tk zIzwvweduJ8{nhARiLsjg{Qod(_Onfe<5qE7;N+rex=&-?~{ z@&5dw{6%vCgD11JI+^tH1b)-j`YhA%Z${DRRWi(C``&Z{j9YC32jd_LQFd<;r4n4@lU};RB0-p$?H#um_xTj zQ>V3~jyuKMQ(woaMzv)E~(>zZCk~LMqB82tz zw0NUL@H!#~@|+#ezVj{*c`F-ZL=5y@gP{c*rXV9m*2R@pka8SzT;ny18qS(!H$Cm< zsNKXx)8-0_WXO^E7x|P)G(UB*M43Qi4S&u8)h9U5Ac=tRDU(9EQBSm904wP`eXI+Y zz)q60O0Mj;M`SWUz8|Hv)216P4M8Tp--k+MSuGC@foO4#_8qIDAVtp8&|^fR9S_1} zkBCN$Ppd=Oe7oY^4`QXB6rH1g3exi7&2?$$vnSf>X-~02-!Bv&06F_*H%)bd*=VvN zized#!3CKr|_1|zGASbD0NBW9kZ9VZ5P}&sQ|#c-$o?Z zE$K|W38ah{Z67|O3QX2jA;xI;MFxZ|S1o?!eTfmWYDXF{OYD!|Zx@j2tPEYpgtLBe_}1Nax8bISh3=PI&(@wxmg zi{Ut{gn#&)Z?GT$#9IL$TUy82y|k-&w9;;}s>q&&#onOaKughHU7a`eG-HKw|96>H`8D z;k2}TIn*uKF6VP_^1PIgWt#8J!v#v>i~*R*%ilKvxyr!%FZI+oxDYm}%xT{)8N8ku zO5X`u%z~Az1&9>%osAbB01*c61iMg?O;b&ylgGlLac~nv*qBqwc?!DlV6Lc$ z#K(K^i$nLtL88(jQv{qXV#`q<5C1W|FcXD*XA6E1AX6sf$1Fyqun!90{@R5088Ha|e4b9W$ zzW*uIAXPJp z8j839WqveZmxikwO2`ov<%<9pAemtcr|?}a=mS9 zN7jB*AxEgF0=RH-RUEtexkp8h8SEvlTkss*F0RUC7jzmxF0^VZ_JOS`a;{RfPI@pa z8(L4R&WuPcIa@^%;$J>k>%lx)L0a!4A$Fvbep|mCM5_-c;-Mt_rOcZBZG`vhF^2R& z!&WFU4bv3W93H7T(g?1R)|!dy^mzCZ2|}EqZ25|qu@6z{N2d~xMqh?GXx)6y(l{}o zAR)eKQ`Z#2au&LbFVf@G9WJQT_XM41>-ImuhbCcmv=#-KZvhucj0CnfVu*RQE85&$ zFY5RPwRU4(^8h}^94E%$#P7(8N(?oxSIyA%FP!xb+Jn`2m zR9AE|uo?wtd*1hfX2O<@{*>e9)e&e%CKpVkHAD-$N=0E~I#7BJ5GG+OdEzC%p~w3BNFbuv-%7Pb4o0@= zbD{e#jgvE~fzspqU&GGTYa7l1xyCEE&-28FnWu+~P?kjg?#4Y<)!q?_X>xF1$Z7x+ z|8wR9;VBZ+4pqECeR<+dV&1ifr?VvDn||0ua;Hg9cX?u^#v4_CN%#H-#CsW(Tjnj( z*HJT`_>1>NRW4q!1E)bho=84rxV3jnd)H|*oLd0Qj5_}O9Pla(qsm~yl7XuoeX3ko z@j8yIc>+hnrH+_}T2zz#RCIW_j&DTTdHEWFhceILSqLGDu$?NI^1vwuoKob1%Y`j2 zz)^zjnVttA`w@nSs}`e8CUc1Xl8b6%2;mKm%Dtjok5km{)S&k|Jfr|jpeI6N4d!Rb z&Z>^V8A+$rSzx0){&h2G>LXNNBF+-mF&L+;Z2R%-UYl;Pe2}uvq9s{ew}>askd^Mg zAxFaUkgkS(KaCSY@iiFwr{NxjzN{z=&l8W^U2}@YmPy3wk5Hk-**`bhyNHg})(o%a ziFajOZVkW;CE_J~kx6CC&FH;`v;F-wm?KXd#_Kp%JW7{{&sSl$qJWjWn$KrI16qqa za~P_J37PI{*eyj2r??SUD`>|_k=af5bm67WU04!N+!b;ALNw+j5r68`xfBTu=%nb1 zj-_>CES`9$unp$k&UU^<5Z%&XA$$D?z10Dmw}>u=BRmWu{&j;|4=V;NylH-dHGjTv z#d&~?UMG&>2-o}72gVcx^0K+9HE?CE;#C)YoPNL^buL6$BGv%!v7gCrT~O_Y?4y9H z8*v_@WBf5}DNlS#Wt^0Z;U!}Co0!M!G1Kd@b?1Tl8<7NyyGz&nMy*E_Ew1@IA9WVp zZSnxu0DYm$F&&VZGbc~REl~8m?lRmC6hA37) z&Q_|)6r+WP+b~91zAJC=W zNu0U5AQlqQS_n*vDQ6=4oyDCsn5lHass>{UTh{U?gehPnt%WT7U4x4wRI6)aj}*!v z#mt*+cdy`tIA1HRwX_~bOA4JQ4(P@1Id3JoC`xDJEXycDV>g+x; zuP@m8wZ>SVoLDyd=Qef#%@&l@qiFl}bMdA)LeY_JrLQV3i`Q7>1sGnusq{u|>qUa_ zV(i2vvJ5WYJ=1y%hq&fBiv|qoZ%I!F2pl`E)8$1qZ~Hc5F1r9%%RDC`VLLiQu7At* z!DTUl_!YV3R_K#bG^^lci~GA7W$Mc(?``JZ&px4iSYiI3qB}c^Ve5a6?iR|?T_uwJ zOInvPkzIHMCzsZ(Pitu}FZeHM-Rl8Z^Ov;F?5Y3`ac?dxFT9de*-2Zb_rYa-XH@I* z&ZKsRUE(!`_VX#}*YCIB)`srCH|}DWdw+xGck`o~qccqdiJmZ(>)herPu23jZX7XWUNc_ioHys6c=``vOy{$K3+1uaCmi-z z+|Spq2(h5MZHRjoQnhb-!p5a1!`NkGY2NwJ`Rgl=>Qg&Tc{qN*Xj>VEOh32z#JM@c zFEyXo@K&~HlKd?uNRaK<8NMCf^PQWM8+N852O$71QxF~POJVsXgyR&l&c@#>2@5-v z!?IJkfzdsKn4%b6qvN!K5MSP*b^FwB%BY4uhg63VSI4c-Jl3^-C7?zn+>m_jo8dY> z2h(sYznF?a1KLvSIMB>IBjvI0n(*fQVp8P2qA)_KY7Xq$pb}M1p5ql2<_J}XiWU%s zaTTh&Wg(9GLPTl*N9MH`9?`8t7SRxlp=$5E6OQSuwUTdz#*w-1Bio`fi%?{wvY1HhZ#y8ygH#w@>AT7{@fd@!px18|bUP>2j>A zt4?5R3)SGr?$ftHT z!Jpg}o5QciCwQ@pL#e{ddxR@b$DNg8y?mOFsqYQKl@3?O`s9X(jC{;7X(|d+Ovv%F zbhV3}-T&oO+z?G6Y)hG1sKDOBd%sN+=(bki;bSdP)VuO5%fN{@~su(#ziyc+Q!Sva5+fWYL?hMW^DKtZFvFeFvPdWAIsYe5Ac_L!r7Z4M;= z^2?RHS#^d_(STjBWW@oz=b~HsIgEgX*Ea}HIdP&hI#e}6SAj}RLGYiw1qxGQFaI0# za(!nVWR}k?9GV7?@r^uACkPdOdf2GH1{i6ng$MaSN#YGayM7SzwzLJ0YZoZE);HKP zh5CC*#hz&q=w&C4mRV{(e$Rjb0g)Jb2FIy255mhV$JDdT{1jpcf`Eu7O6b`kM+5zN z!oI#i3-8{1>ztQE2cjj1yuISr*@^f@jxzFnRg$)1Zh2wc3$QwIxcJtfkX+;Gp!V9M zd5}#}F+Wgtcm$#y*1#_*$;tdMs_yMLTW~MIL0@N0{ze^!g1t));cd0VkvUODMs4f8 zLITn6;vp|rf#Jv17@S%uoOIvaef z(bAPWVIz6?7`P2=DPq#|a zbYik%XY)1O9}nA7+irZ#AE>lEIpnX0H}wi4CmBNR!a1C6}=Bl=Ct6E@n0 zKu)cv6;`jvSHjmT+SPovQm5s(O^&q{p?&#RZaz|>bPu^(ewo(~(nv`>jdmCD@8?h6 z-3JVEo|sO3qs@iH9(G$Vuy9I6zFIx0RDb<^>6_<7(q_KF_l^suFca4LyO%`l_VN}p zGtJg^H!fHuaAwC8q=@wIBic`VELxDtt`Ws2=+q+K%{%0BGVx*{>V^ywbkppXvjP10 zX|D7`L~2!b%pj$LdG&t*3zfF;ly9Me5aCO2^dA8P`Gc_jTcoGhT?A=0HsBNi8tBB|^xM zhq&SuJYpc;rz<1BiFIzhbL18X3{4t3i6FJBNBQV^^d0jo062+1pT6|i$Y)}}@ahwS z!#&C$mIDC!>%~thQyG?W%%yXcnX*JGga7i`Cj5Q%|N6CEtBg7s?A zI#gxHo;MY1b_cV?II2$8lwySk&I_glt)Ak{y~5vZ9wXN1 z_8doE4%V49)hXW5F!uK!FZ($UvU`Ae^poU$&ZVdLz=|(*{VTV<%Km+gx-X=y!}^dL z7M!VFk^W=L-=8W3=gIj(-tF=Y8`XJ^`?jUGY+EDUCYQVI#RGvaQ*RtY5~Rsp+rYKg zcKr+8{SRr~f1$g7N$XxwEYWPc|3Y_iX`OF&G!Jl1111jhb*VOpw<1SToD(>a z^9$W+0Yxsqm+n*{@aXF-+-{A=I7g-o0Rqp+tH*n1UjO)Fw>U_e?(*amvY zkQ7=`Pah#kq?P_cu3dxA_=gcJ`Is+lDO zGVt3gHGqRBiV1JRiHS5s!8u8l4>R)p1e@@t0w|fM;UVDxLn5SwA`993VJ4zOJGiJDisBI#r{h<$;gUXi4IFtZdaEd;+BA}?mkPfT_ z$cxhnF0|D0d{jdWdC?B}iwgx;;3c%e@qC=S1Tni$xz%T-$~mMj0Ci}9$2ZVNQmDZK zPY~sS;fb@bGC_qQA-)Nfnn45s?iO6m=R-F3~EH znp9*#OgXd_A9M@?hrgCwH!UqIKv~R^@wAdq@opO~)QHx|H3AH&xrr1Sp7#T6IY*N3 zmE1}%^5EC1vWY!Tq-_l3&{>KVKz`(hugx`6V*t+|fMNT#en=2Ltw^Uc2?Uxjs2fa_ z)N*L>!q?iWH0Ze>;Li|RvOpuYaKlb?KY?nYLIpGEGIkr8m$sV=)g%Fj@-PRNdi~k9>)nlR-y1c9U;}nr z6iu6fb{ucRST0Ask}Tc$O#>tNok^HBA1K!%lxnF`nLtej5?Y9Aq43Jc&ZSx4N=EZa z@p0k+RGxv`7O4e@;2QG{b!E-Z{g4Ht>MR+&&VQ?J8PZ8U(JO$v{cCivqt6ptFbGM~ zs@bGPG3MdQGh!4$I7^5$j$*(p7{uGDguykFJUSP2<1_|**VdLHidDG2$$&NNwh(?T zp$sDOfvctzH#UHqXY|Th+A>ModiII$OwdWvIzFQYiGWAWkk&)FQmM^ATp!wixBbzH z`v?7<-w8Qd;6i6WJ4u@dgHm5uquQaPN`qU@0j(~Sg_8DfKA1n+n=VDIB-x8DqsuwH zEBa9{v}093$0C=bXI!-PB#i@Y&=&Z#JG+f8J;p|Rll#$anZWjb)SlD3?+vOdg=(sB z)EHV zA3%E^sB5r+)#fE(?^b-Q%XSs=uy|1>|T{^fh~ zx+seNY{#Z_EDG&yJB!}6IIC=nI4(J>YTH>;Rj1Dq4?80pl2nM-Yu89ybg|>*{_uDt zm@JR82>m1A5}6A*zQJ{bf$N3GUreGji=LQzkihG?*n!3fyWY&85Zdb?IjB^8)|u05 z(1E`D-nB0W7Cmfbvk)Kc8sC1(M(GI+I&52wQ!N`*?#4R?L{LF-jk(xJ02!N~rv9K% zIlzW$eA_5yArq5`{s}`eMkud9IK(e(Qdp5u5h8Ki;&ab2h^T|agth*Ok%hd zbYQm;*;hzGux(L1EAWC+5NO8f-Q9t@T<>)+LP5E-^10bL`!Vp5Gr2OH5+yn5A1zm; z^?o_i&3}Z3a(e%c#PraP>3)hfa2^q!*W~uB6^$x)pDzCnYqG$tXzzj1k=X#uhtn%p zW4cPtczn7b`%ckjueS(-3Oaxl!X77ihGnfBo#D0e-~+MB>uo{OvywsM_NX@Z*`ao= z-hoJL%<_z5N4REpf6YwW!d6^o(hW7a>Jshknz>l}2zBE0vSs}s+F@``;KaZ9yar>T zy>Xnb=^N;!#NNxBFb}lj>-7vxHV*f8^t%XW8&$Zs<~kKb9JO0CSOaS31vp{X!A)2? z+B;i(J@p2v&*_D0F;iI=ex85p!eXQ|X`?n5vhBb(W}>9p-c_7lEE&^BI}}`#!lK*i ze4K6H;54v%id?*7#|dp)M7R2C>H2GFo!68w$Tn(pYS(RL7UbAJqPcY#FYV0_8zD2G zBgL2w+H0jf9#=B3lG7`*9PR#Io3Ib*G{3Gp2HO4z-f?s6OKk6+o7klrZ6@zrOeOg8 zb2sQLoUOv0*pdk<9dO(jtF!ecUU;+A2c*kxLYAtwML^`uJDS~~V-(O;glP+LHA%S5 zI`sF^ZACXRKeTt@VQkf7bQ!1j=5_3^8*TG7&fuE1-zk(VwAXPrHgFSl<@D|`rYuMX z?AZ5g?Z?XJCvob!iItyY|Pe2E=8fEOL_T78Gi!|gA@==%WWR_yLgG?3H# z+48P~?qi?qyX8qky4|pCfK09IcZGVy@!>_?t@moG?p+-OGbHy6L7;8-f5ARgId!8QC zo8?9^k>1yfg*#_ta5T>L`eSXSDP;lV_yd{Th54c#~|! zGmkFla)1vH9%mlM)}p=VC!gu;!8|yQ~86Lobr(y&intOe7TXKq~ep;gYxdL|3&$}4YwB#L@v|% zFq0&wd{Ld}>e-7^hRGMszn4?KI6OD>c)pJ6CBoq^R6N8FUHS~ap0cj#?KoA5=P*lX0(7Opm0YO8P z&ISZTL^mKRplCpG5mAE`%ev^gu4P^B@%!96bD#O$ncwp-8D=t>lg#J5&g=DlZ#F+~ zS-0&|UPAxN6qT)(0>CsFXJ?-eHDJ^&vr%O7!B5^|ZFQ>KZ=9g87i)!YVsOe8Uy=^4 z(7^k-G*xxEEGN2TXkc*&HY*lV2NaWrK#@l_Y+vYAY%-h)7yYaz3jdI1!oTGld`$~dvZ2} zUAr-OW@Sls=!yM7#Jb_le&brJSKknmKcooAS5*_G*`dpmqS?h7LFblvYNxx_@7Mbp zRa&S$na9=HQZ+HIvdvMDU%tl4m~CjLJu$8o61;5Ruqyae>$IMo;F+~e=(=%DVeUY{ zqL9c`=<*7)=g^WZO+8{rl)KP;z>^=A``W82>@VaI&#WXXRKYLtXwu+*{M)nn%&?$^ zX2A=Fvhn1=Vv9?sn0Q@LrYbGoL`l;%TiG~{hRjyGPV8Tt?q;z?`CNdpy_=!?8|pR8 zT$3Ka&IxvqpZVe%lpIqqpzQ!Q&B?X~2~FPJK0K}y60lx8LGXO4^@^q}Q7LTr=aP}i zl3ohHFbjuVJlSFURbMWe+#)AFOfFsGrDIPh461H9xnjU{Yc@a|93-0}xkOqM+p^WI z?T^_~ikzA+HVO1H9n&C|64jMCB*iGf(WXYaK$4qqPWa4&1EzRM|5HwVC|7oj?H<;rc@Cxo;=iL)oE*uwB=Me;%-MZq2qziCqAS z18ti!Rl`kY(7<~*XS^(P!k zwFMLDK7?;F8_l3-IGHm>aoC9d#*t9AB$1SFzXH}Zt~b{Il0m&D6ik!}G`xjuSb9T`DrPKyUl$ zM(HW1OALzz_rSq)71zw!hVwMQFkm>#K<%YR$#A(lSFT6;w1Y|G3spN0i8Q!3d^N@g zZ24We;yQqQcIM`OE_;-e&s68ubBKD0HLF_pGpB`svy)-g`~o)}el8Q6@9h(-P7P3^ z^fEuU7pHBj;c7gYAuXaL8nQ~fwReTha!UA_n}&pjxw3J)PuDSH*0##n*Nc=%;@iRl z>4ZYRLIZ|paG{8)9rZl(y|Qmz|%HiirJiYodGS_i7mZ&rg-P7T2sTG_YEw{=^h zP?whg`=y(s3;hL8aKXT^DGuPz95w`c!d(<;SRM-~X#M2ThDL4OIj^dx?q01WPKAfc z)R#7@6IQXFQY%H%=?JKEhH*Z|63B{k-nPKkxyYspR6T;!{*L9cb|Z6=*}S*`X?*8#*@Ir-mp zRkZBc*@Df}3@dYwzztj%d!Bp#nBdPA)j-fvG?=}W5k8p;sM{WUrse%!6)5_l$()KD z$qn8J_NWbQE3vm^l+8`3xn>&kE}ELpr7`GpA|x>s?lt?M1S}%D*F## zziPOu-A6Mz(qnb#TX+_&T{TB5IS>l~BqiMT5TJTNsgKcEf&LLT&^#GE?vvsp3VazjQGwDW&SyL>4qS@^AWrWt1~{&SoL>>Gz_G8T$y?wg9hcX&gP?gO zQ_c5@J4wX)et!>w{Bq*Q+|p;D{tWQ9^?HDw&N}qO2>GHdaFNF>=u$i)5l?QPNgN7q zcUoZlUbuSU(Q>}<@pmtHO(VRxWH-q!`TKXp!3&?(`XBpqn;DFh4O*4#-gD&Y@#oK0 zB@IM@AzZ?u8+(5$YqEWl&-QInN_u|q;M`u&R!%%BNwV>F7Jc3qp(B)SOG*?0)~_N& z#1z-qgU8Jd_;U}aae-5rpxKxG^Ecoiwx2pDdH&S@BcS~+%IDkrlk(Y!;s2t1KLtJe zj6f;;Q_$6KGCXTIb}sJS)vxpiT= z=aO?9%W|80b6d-D4duu`0WMTN^xhC?kPY3Q%eRw4y1e7ZQb9KcDOOO>*N8tm zg}cifwdNP&ln-Jjh4;2{1S37cY>`gZ`oxejx$h%k*ecqQ3+J zzY?4P`0=YFq>GRp@5J9!z|`A-&LhOhN#G%vAVJ0&#JV!Efe+*=fUGREP2}JTfCw(U zR@%Zw7QIemNO8yp8CU?WT!?0&Z6hJsE@<8wp-9D|u&rs4(>%di zAPx|N1nC(gKJw7VAj$kJ%z&2fAnQ65;ueZg3fZ1Zc$))Q)GxzX)W@%&TAU_DRAc7~ zhO?lzBhZ+G&?K zJ?;Rl5uAgC&}J^dhu64#!4Cg!Ljka)?+`98S>r`38@995rph6N z4ZyNS2&-N0*c|Hyu94M_TM*a0LGj;Kk}Ty&BGljuRK~lwTAl^jbsB+?zo70|7hd`i zByYHNOyF{S10@k!=LmQJ_0K+l8|YOl6bE(0U>wjK#5lLj6}06y`>@+ZrS(%6>yJIH ze|wSerVn)hnxp3%PIiF${N`QZ&M}r}WBZIuCFjOR&M|G_UKP9$11{4~(LbE~XwmSa zg9y{B{26Up17JMRyyQCSEP|H{I&?Zf2te*#M1A?qY(+C=ykY7A9C$!qR|e6W3C#=P zKea1Kh0=*FMEU#p=I!8?7ooA}?621n#nILhEbrPEyj;-0+^%J;U%m+-Hotj`7Maedo%#y)IOs|(TU5A!%UZKu`rZ(@D{T;6 zjjr=Wxm8ykb#3^qkC@;yhZJRE+={MpjDIK0=G-ng7&90|2mCPc*yqAezQnu zv-HeP#hH>Dt;Y{x-auEMMQ^%OzcLf?y!opd*w$d#Nd-F+`z|aOBU&v0Q?yMVY=7F^ z1C1xJTY5$@$GY#1Do+!_D$~^+w*oeuaH0j-sR@0cBP`F)OQsD+^&#O zlrHZ<)#vvPwc>UK$Ql<*I<4PE8v-?;V2~03DM`E>+)S#3)nKcRX_Ai(JryX;9HuKu`}6E(j9RaV*_0$yoO`n zp)j{AF9|b|w>(H7-_EPDK0@Y50HWfmmMhZRmsn*ZlnLK{jW@5=&w;9bum)lGWhL5lN2Ru4}D7T_>?4d6}|FZ_6c(cc?zlb^8r^ z->(mk?>$?V(!~7@({R#1%a;f)Upo)8RS&*7GNy(hGQZa64_iWSrg^b{?a#)nfG#F5 z_*PA(k_bN73CIs)9%!3W8F&b2zSob109^rmY*E!(&(oUz2ig%)yJO=ZNkTjoVZc5; zs%|@>y?`GG9&W2myXrWT;v#6_wqklR`IIlZqAGY9w+rb!*UnB8%(L0Fj zO|IGj_q4=_r`4~t@#~1R^U6UmNPgpZCAuEyig#-*t9oQ=T6Dmzqg=J`gOu zg=1ItTO`H>4SUq-nEHf025#pA zay{}Epv!d0gy<1!&+YQ<$0#h|@evULXx_}4(wc?Xhk-r$=<&92;n<}Qt^J>l?D$)) zxugw)fiC^SleLe~CETv8qj;|=7afpG=o1lOSH1Og!cokQ+x7J!rt@P`x=>(49~V~Fk&KX-RmcgKGGQnJ#XI8OT5}ZpANozSE4db z0}1T!cfamZs51f2gqzHcdkxd~8b98XSWfC!gXe!dQWZa@RD;4tzp2hn>dPL-8s3jy zM`i(CT|JnAKmNq5Yh(uVIo!OrZJD>Eo8Uqw0$pmW@80+w_270@>yk6p0XNl6j0G(Z z^~p2^!rnA~|Hm^m5D@+tqeVVIWV1iS;9sNAz9pzHeyKix>iQlH=XN=rDQ_-#;Is_z z*@Za*T`Bzv{Q>-fsxyiNpZz$f4ZKiNywpGTQau2QbGlbEGYqrers{uqs`UZ(%=@kB z%lU^?a-l!w%kA>MnY%@`=cNCfdSVYz)FW$t6Q1v{4j zG+xSeQ0<~@!QQIf(GTb+^84F*FDuC}H~IHoxs5TMz1lfr zPIYR{dv>0Bl%3U5h8MXI*Q5K!%tO64zIx`*A~&fq5pWShfI{w8^<)N=RJ{A!zL4&L4;+D zB>KRh7TshskcYI+-Qc+`@!&-nm{ci# zbqdhHi(E0VjOaQ+U6pB+Uan>r8HHGgC@R59LmTTnck&4&pA9qSLcx(`&$Dv&kXV8A zpmpcQwf2S}q)<{m#oc06J9&q@`BY?$Nla!m60H7Z;nX;#$}t@dU38C8L_}wLZz?ye zG)62;{cVf%gA%K;wyeN9VrW%<&`3dE9M*6HUc;L zGRo$1m6>98sqTuXp`8ac2u$Tr&d{b?1!p*o?QT{`l%YNpOCbgmZqeVn?c=}j;%L?F ztu;4P^TW{Nju=@zsC1TpUj6bw@HTznY}{4)h9J)qaMIA#ew(T9d^dx4-(_d?YzOL) zCp~@`QQ2>!zPFI8HSPI#)N}k8nv|4UMvk6nmTOGTXIAJ3yG|OdEbiwTN7UcMELNQ) zFTbzl`XRPZ_b9v65MUks)71pIsui!xTu?O9cWzCB>zC{{Rt-M1@m`l_1b@G(_i~Y? zD_^)N9a?Nk-dpBX%MH$c6lIo93N6^#7_{?+_Ft^boa*>{aBxn4$oNwVIIkQk z*l|y(`(fv?0Q&KY@dwnP5y_WF1T9&xX+_LXYVB##i2%ahGAidX-p7@y+{`-0HHkQS zMog-ine!U_eTQt-TEb~_rSIw{uBA4itI`j<>k6(0&+9a?VVjd3C*KJUvuFfZK3`f| zHoKlV%psg)mm19!9>UMBp$njjRUshC)0NdYOue0QGV*EIfl32|Z@m=# zupJ^tfVy6V^XFH0854{60(9XIR@_7R+Vmbo!9~C|X1S1+3*6cQ84%{~sEY0L(ShA&t#J{TH_GE(59?rvQ zzd_Km3?rwL_#w4 z6&UKKg0cV9XHt!C!D`#YY7&2_GVUdZ1Axo7TwS!Q3Dg(GWj_{21Ha_4&8<4ih-koZ zYCjTcnYbW+;={xISU&XLNUwn2;w9P=kW6D`?Km)`e7w`w)8?+=mSTDFw|TWV>p;L; zSZ%CJN~h^FRP7Ckq-u5)QxT-&7`I5MHo`Xa-#6~4oo=`2OFC6I^@?u&)05gXXxS}} znnuoc!fCeQ;$C~QX0O{Zw#o2LI@c!i`2>R}U*~Db&e7F!KWTVz(E3)Rvcb?f$SH1{ z%2D1#VchKcch%XTV8h)^$}5mrH%!y(P80J529Rgz(<#zxQSC}~7c z*#=OJjJJmmQbl!&^*PVfX?iiOSIcz^s8gSN_-?7afb z5qr78N8*J{k$KkZ?RAJeU$7#w7>t?8)*O8_OwW(>3Qm3(q-UQNSb9NB8`9+0GLh~Y znHb9t&Tf$2>$a3Z-OB`Ry28esk74Cz$w@}L9G|NaTRIj|Pl90rp_ip%f@JUJaacLk z_K0tEK2@f@Jz^-Xg9t3HJfPHUm%YJD6ZuhwUy){edU|mA|V%J3su?-CU zbYC74##ebQ-+F(e@Tyx7;ehUDk>(NCrcK+c7Pm)w1xd>%2~BJe6?(Yz_r#cTGH54k zb1cog;K=PcRbJLl53DyC3C|&YaYn- zcv#*BgLEi^Bj`7?&0^Y@m~Oa_A7lrzG|6YNVFj3q-#eif!>JlL{dJYTe4K!3DgI!s7WAdc*a$A`HvibiOvNthYg z9HX;^s5+DQ^sbGv06JN%UTo&9tTbeCTu@`_dNvK6PuqIQSotXLL6gddyx#B# z!T>L8+>C$1;~nFLdNr`<0Fc?ZZ9vJdk>(e{c>g>|kio(PdbyFg9qu(+TN<6*ka>jb z;Q}6%gMEnwRO&mS_Dkw_>yQq@Nrg-`*1jL=;h9}OZ=!rsB%3m0sS|;D6IVg%VvRf5 zdWuJXu4b~aW40+r`rWRuHx6jO=YLu5TzyZv)rS&hTQGiiker6xRPN+vU0vm-2C??d zTiiziJ+{4j$-8dMD5vWD!=r>{e_Mc%YUK6$Cx&>Q%D8TOU<0ETTUMwJ+!6>}JT9X< z&{PF&j}SE<)Zf;iJ}^(HI;j6&o>_pm(jnQdS6S$rm81^V0hvVkpfve+G3bLToj~{h z6x8^;q*+kBK>01}z(;{{E#sg#6|@y8Y5cvH97|Yx!%#ZKB5s-ccTT4pQOc@tFU_bOuIYGU_3lPkyGNRGb&?K%a#H+2pZ@kJ;o z{9Evi4Xx+<-PeR$*->%Sti8C_7xv$P-~ZP7?6YyLZ;2TC?^<8-om^A85|V>Gc8)C2+6=P?2rB4+9_ySjh_+vJ7J>VFUXFv?J*LKIm(*$chVT%TKi8 zmKQ+S$q+$@l$_>`GJ8M+zKFg9QW+7^_kkP*VLwCsDVacEAnVPYDI(B@2hqztO$-fCN#wrw$&1hVN?|uz^GKr#7PmLT^FV2$*xRj6Hz!FQwx?@ETd> zY%&P&%H2mm04O!Z0BPl z5t}!M|Kt=Ip->2Kzg0DuKEhx~PHi%&boZ$=*imULN5H1a2o|zpl_s>nhIgXW*m-YS6eG89ruD> z(rRO%mIc-rR#Xv$@M;mf{wswhM=0S0HcK+`8q~D}Oxaa>t|w2IqJGnumq>|{OZ=t5 zR+jC#op@7dD(DBV7fI*}*6SYSiP1ulbJYqTO=)s05 z6-4RCN^JqGn|tQWLC}v~O+pEWrk7C`q>zuLq}8;VGELY{I85uSwpUcY8LXjL5?A)2 z_CQ5RHF&leUFKW6esm=bAh=J1yc85*6Z~25FK_TS0l6@ciWHO~sMDnb0l9i!0%CAV zJuW4!!|@q|^jVpr1Hdo~@Zh!(#bCIKBx1BtpMCC|G9h*pb(gd%DR|id(6}S;0uE6dI*-Hx?5X%YHex4PMS@R$)Tp)9{lwXoHHxSk!!9 z3_7stmhd}CiD00lHA-?`$rZqBXqGYA4wp$K(QiM3@EBMg2`mrO~4u6ZDm_4`W zbv-U30Yq+-su+oXxQ7%8y^8PF6`xC(K9}>Mo#IQ3)g=Qm!e}4i-ZU8F)S&VKtV`%q zafRY<#%h2N4uAT5$^WWIk{#*JastJdkZ}ncZf=bbojI_gm%u#_FEIP-AEGAG-jPhu z4Q=yaT%v;=X}MjIiMZU^L4AwPHn%<>MOSirxWKumYx|W!C`8huNI+lG+g{Vj#;5D7 z&3QKrunx+ZP44k)xz%Zw=&fMWRwo+KSW)*Jr48D zj5J3NQT}jfau0o!IG?)+(n?2X4>dwb)=QY9{FkQOH1f z-;xfQeLtDTuQqADq$Ec)`k@%7tLo!p+-WdAVMx8Y|JJ1zy0F~H4WHe3d1e!4%k6m) zjD4y^eI-5Qo!DHNYl9bDj!;`V&i zy|sEBW+mx)QI2hL2DHYKV_RE?Ph$p>iGTF)i6`Co(_Wi*s2=;qqB+RzFkpQhbK&+} z--j)!LRpfY&~=!|8Q?k{wQFsa%wWs7J+8msdv+KzmGtQ9vD^L`mHq}Edw5ej0E+pJ zRNoqiT+$uC7O=zT?D5+l-byxPwC5bfSlpibso1_M)J@W}?kM(5ukA~O16_9ey&l<- z+oOJYJmdH2_`_qm)puZde*sZGz3Dzh2*qpy4(&vn3Q%p)gPR4OID>_N3HoyQspFIm zK1nsfm9{WGLmG$UTW(QjA;Vp9CT*?WQ&<4EXK(AnKYqu@U-rmuV#{5C(`~le45Gcu z7^xZ@sd|#GKdAL#Cf;hK(fQs$yuP=5$fphCaCM~1jP{6@^-G3GAm5j~>a+ytvG z*j!$94BO7_xsy5Vdj&I;^z6KJ%f==PpMXu6AKI(``bU57zefw~xIG;wpYM5$B}jUV z)C;2%dTidlri*(LzMqDez3JcIG~C8=*8*(O+_5hg325KB$1k*neTby*$=oFZ_lZE` zEu9J6_lTPr#Ed>tA$iP9%W;!`RoI zu1^2*Kcs@DH&l&r4Y29?e>A|8a!vE1+u4@h_vyxFI2BZeJ+pZk&;7;T3^w(-W^-uc z>T{hPxh2n`HvX07{nL^0j82BP#zaL-+oQAMa~iZm-9}(r_j{Fd4pElSl^D-v`L{Uf zwo~EfGF|mn)#1H=-a9U;;Z@=O%cg{F1EAAS?=>0P<_FRp_zbVhO+K!${XlUFPZ}R4no#Vn?%k7uG zW|(u@N^4a2N_plHRPs$Ea5+y;VHYriY?klIps|}138BvHAdq`~Bt7 z6yTVi8Pb{mDK+nM5~IbPAXiT3IQdNmd7WGKJUgT<<5RjMRB!FLj(%oml()T~-SrDP zo-JJ9c-%FE-mAU#nLU&$9w+N`c8M&A%YJC(9u)W2ZcbZoV~+ZKU^JmB4QQlrdK$HGMH-cjk-tm$-eXY(z|RU(Hs!uB}xRy-)8i+u-fYHT`mp z@UIhMrLffs;!W*YnRnLyr1a}SmJ;K=Zq;$6OT2fjo>#uX4y}W$mhsbvg0kYc%g<_5 z3vxmvyC_+byTQA}i?VZNV*L%1G2)9#F4e(iddiWnVb8{eqvPlFS8sX~lz-%C02FQ^ z6YJVM#afgisL^G1mWZU#l+;e?o(c;dd){VMO%%DRZDiJ4+nYQy4>aJ5a&7K zWkE{ueod8wmkrA*)*S8cL4ndVi* zD&-_)9J{)lv!rXHFE1HbWb=)-b{N%I=ehSZocNW^VCh88z$~V(I9sN<+WOkiy{NRMB9VqIOIgkI2H+fC z$HMH@)Mk@WF6*UX^X=R{wu`pk(9!J1l^SQ>;yG;I z#ZgL`t+|d5!#t}n(mYcuTuCYt8Tv5cQQ&;0hZ(d(b7Vh5A<0Z;Q*B1KaxA4e>rzA% z;L&Yt^+uWFy=6vr+^1oBhG{PE1V;>+uDa0y23b=Bo|Vauy*%WC2+Kd6FaPu6m3^qisiq3YF?Yu}f>5ITl+WzzBH;m>bWhwS(1x=qkpmnqtgmXtwGU6oXOd6ImoXj&9Ew>ezsivzw^1HgHtzXE!b$(GpM<+Xq@d!Ewp0CdvSUlLW z!B(?wq2GAsYWUh!8DD*6>Z}8Qvdsa)kD}^!Yt3SvJ5rvDZaS$TnI-Dh%FsB>*O@=6 zLgbW+^p6M$LHlxvaSTrF;9F?<=UK~?oyBVWNVR63xtryD2BG(xT0~>#hAzo7=;N0# zWAjd>Vprn2#WLC*zCaq>@gl8qU~+g1VD2UvJB#3@>Fr(Bb&QxrmhN3EJ^`rCL#-%B!1yl%+thz(88`B14|>X#;#Nb;8 zhOX($)vc)srl!M#l-;+9`gJp}SNII7ifsSd;^#Kao&_}T@&9yO0gmxj<gUPd{NQEiE|ADdQ=HM>y*?*t_@t5tynW2h<3Er|D=fP0n2*1Yh;q8%Nb~0X7&SEX-T$uD?32Z{IR0(X3IT*gEBUE?_CTVX3Q@&e;sz zd(wTshw+F(Qyv4WTr)t*!`Fblz~)?HV#=JMhVjx86iIxyuSM_xINrIS)W^=*81WAd zgx!yy2nP2quTi|#URCwi(GOeoYJdl6l``cs@9v)TfAi#khFF>yS3k9 zld7JKeY`Aqo!m)egk%Z=`cM6zIeq`n*mrLGn)(0i(fB{HZ)AEjos0apJsOR`|E&S~ zQ;%_aAWhbPo73n2pE-T&NEy9G8Jof~w7rn_kU}EI2DYG1Ek3RtpOgJO8pu8 z{)Y%QaaO{OAdC_W#vl4 za?9sd*@*}%q`57PL<{L*(LRs{l(0|Z~u@u-)1GE)9k5_BUK z*(4{9WCOdT1ZQ^sZX$%!!dm+R+bD1`K!_Fyzo<)F2DlTa|sXZmArw2#$g~}aY9!Bnwe3}Q8-FeKoTj1&+oUM!*>i6 zU=Cs~UVTboDh)yyhtuZ7sD;>n7F;4DP<;scY+?4C=$8faLgt}8Y!sqekbJmD@;D$> zR6K5;XN@5UAD~`@-9<;m9Moe+$v|ALVMk6?S$;4VST5pQ59~I!Fa4NZLKDIPlHBmu z1Q+0>r5PzM)EwYJQNv`9uX#^7C)EPL?&Q3Y#yl45lyv|ubOHGNvt`pJP*fHftT~%p z0_UK5g8Y%*e2hr3Q4rD(0YU7D7jaMy4bW2**nouxEDGP7qgt|4ICY!(Lh%(5%O44~hLpcX{0~?I^W~rdGW@DJPb&KcRIP}FqD1A^ zGVy#AXf=73?o6KekY}lA?e{9Zv5)hfjMeXNnFUs|807K@DG{4P}IK3xeCo zG94Ls!J;a71IQMYE3wwB4}nINOY8TQmyZyBm~W>k%5!KHoO1{hJv}#qy7G!sah>QD z+I{Hs#gS!KH&))b=wLCAytkklFaSG7g_^kX>_}y>CSc90N~poSaVty&|4XB!SO5?Y zxmSi!rByi(s;o8PS)Z~1MgAYvwFLI&v}%xT349$vUmQ9!Ujwd_ooNWgtmYBN>76GA zz+iffb_a;kfsYnecD%FcIcN3oL1vQLgcABnpw5tWmeO(d$3}Coq8zEf=d{-b3M7R> z&={y&wJ?eizm;eA=^Nm&^t6QLig4*3-9jjs-lY9Oa@L~y(+5z?w``rfT4xptl+=d) zhMQCfBxt5pS2^?Rkw}Q`3;9=Ii@%k}h3jggglj(MaII~bq;`{{N~ME{8+D}?WLMn3 z6lqeJ5PP(o4%X&a)ZTWjy?>|-AI$Ig4WZ|vIt+L$JV$xFQn>@1n8u%+p)_v&-%PUS zXw_&R&0SijBttIcI&&*(R54^sB}$q3(@ebAhc0Hcy%|NVRO&iYu#Ml!by9Qyd3`z! z+FF6pMUZ2>R1Jg2ETu|9smfu?iTEbfNQ6CIV_bdi?1OU^t`L7A1=un#)fd;|_ng1> z#XHJddzu%SUy|bD<{zW&%88&IP!~uiAFC+a`<1%ZqVYp6<_6TSzlnnKRzFsqYCx9; z2v59eBDvPH9lNwYH0JE+akXtyeRGJ3opTJY*)rL>asrKd+dN@;K`rted%E^)-?^G^ zT6GoWn^+z6fCd7;L0e21^NlBUTv%$^c1U+cN(%XMMOm(s=Adtx|NKegfJOqp5ucSd z=6ELMwA8BwWw3i-MUPT-V}2iby06k#RnwJQn{Gi=(8-XfiE0asN6w)RQ*lRm zIZ^*1rX_(Lr};V^&{zfGVgfpR6Ln*>yRQ z^489m=#NY2s1u;}8vRqH?u8{eKo?k(K=$SX`uuLaNSHgC=QP>+tJB5SO_&LzjRo{* zV;voD$mY^YyGT%1?>u7@V5qxv>`kw>-c^b#xZni5Ye8*p*4WU=+R|U|44hxxf)BN9 zt-&o#y`G7_?xWLPuA@R0P`_pdWyp}R+^UHDAv|YX(T~L?)YFH-sBqxG_v@ZVu+9Cb z3!|;71r=BUsd>>6FW@(ONq&wY>nvGyyYXw0+pr_S>7JMgJg*O`FH!fjjwQ zZELX!MXHI9nen0Na9$yOj0>E zZQXWf6kD%$KIAj(SOGzoQGk2DeXK=eU8Ird)6=V9BQ4dDj zvwjTV*2gbcSsiGty=F~j%Ui7v52@|K^cZb{CS)saz3wh!s+8bZVMlDm7e`E}d>Efd zxm|i>git(E-*TSr3XM%SU+=#|`7i;acXbm-+200xiS_NRn7+KVVswJ{J8H#fOQ^yw zSp%TjQga1C=0~P+Z|k_+T(b-RG;as(!fw60#V#iNr8|LR*)d;051hn_>ID6c?Mm5i z{U)}o_UJJzMBcjp_q(rmVy29?J-cMVxew^L=@CKJ=H6b{FR$M<`quj9bVR-Ivi=e- zpkhw8m$!;P-%DJF`7+vyE@2EsXWV5IJLyApF*4?OuQqr_1H(7EJk(7D4~+?_4+QN* z`OUc*?7br}PcQw(gU51(#k(G9B+kIxyXnKNMEO{qK6%`+E#Of5YsY=>j|TriFGUgn zcYJY16{hY4gtuiICjj-Wca#b-FKMTk+C^kMd8SY1Bs>oC%if#-Y)x3Z;L;QGJ$t#} zf|2V3jY#a3KS3lJd52(0gKTZJ7 zuA6Zx#7E3O%l$GQFduIC_<5KWVlQ|ooO(35>jmyo>lZ$rbbK16LGjq~a%JMnIDgIf z+spokwCgI08K(b_X_tyUPP;b#XDbDm)Dfiob1Q|dqIm`Ub1TJCYLtvO{L_IW$H@UX z0pChdR6YF?-%7EncNxBwVh6c7GbPt!x&vaghyQfos>zcEMz8mF4QiblznmTEUrje- z-4`46Hgj+XPN_NF{MJ#uqCskV0Peu;7|@tpbo$N#y}|AcKOMMlk`%`NkiylygSZ1{ z3^vE?%sX;5*z1pH^i`DVT;n=LzDutqBlSy!(pt_(@#4wGj_VC%t z4Ho24x2#YKRW3n>7Oy&1jXQ8Nyr{7A34MOI9w> zf4~mL4>S*Vxm)!POdk1NWA8R9x#E&YDXFY6YrDRdTdBs!#~}e=LQ(+O;kR~(n%^6s>59Tb`p7oC0uwV(Lo znC{FNS!Q~Fpx@Jt`WJZbNU#wLwA_g+i8~{ z#*CH3<&d2}2n-DNm>4i%Ok1ntMffSu2+P^l8h`T5Cn*bvPm}cQWIYbSX2P=B^sL_tJ zoGpMXw?6|sDr2}sg$Y9xFAO|$+!$Mk9ElU^kONi z(m0Z?$ltzl2-?;wHWT@fb@RdeN|d%KRguO#=1MYkh2DG)5np3TYyr@J+p|V_*vu3z+8Dwv)tm#m+;!`$>GA`Rol@6x!@Y_-8OV@Np)hSzJQuxCtuNi#BhL=*$9C0&vnY8tH9r#P}qz)N^Hxfu|O|p%KCU+ zlB-3`{Kibaf*cm-R$A=_E;Bb;Y*g61c=yArhSp`V`sSv}hfj;aFUbol+|8#e!@=w8 zCCrp>BtZAp4ku^bA=Rrutu9Xydo+Tr0xyvXzxD{d=MMHHYY!gRQKWtOXyd4{omZ{; zG}Q)~9K;uVn#rg_)}C+%Wq2@W5qG+{ilN4P=b7%6wyk2W?1tb)xpvu%dt}lV3o823 zm9I$CV=zbT9BHm(~6~yRc0{z<&#cG^tQVl86?W07n`!`pW#q7_1;Xp34NI30C z2CEMD<~iHPz)pP6<;LtklxFs)Ji2`WYUct>`3X}~>NWHZJC3u9DR-&!NZJvYx}u*+ z`&8JARERgHMF>dTdEu2yN3-aitWefvrUrH^PAysx!F8MWLMyC=+T?}{=I=yl zrMl^maoiwfWxD|5N0QnOD63I1^R2IEDcduAfM!8n#83ounU$_qQGlx8yFh~#UrBB} z0_7G$*z|ZkfrHPrkq&6)C4Vy;L@TZ%``qe%_ zp*-VaZS|VuSXH%u_<0R$6%=0YeLs6ihQqBY;u`zNr$?8;TG4|ak{P`loY}zcKl@b! zq@P}fMiCq;2S23EyGK?BrfSw0)bT`@S8B zO*U1tja5I&+TXB!zoTfs3J~AFkND}c@?rUbud%oxwzIw#**l-`_0<1j>^#Go$Q!jk zGns@`m;=_fwS7EXG9JQke1v^nW zB~be%)KFUpJ4*^R<`cQHq?lNcEg^mY3diK6jgrJNDd@`w*6<2k6rdY$ctZlVyd%B< z3NHz%xvDLhAmFZm%?<(4e7rgTM06OqnoAx83SY~qg^E>}x)KNA_kV@Ai;FsS@HHG7 zXDQggCmsH*X~)6M=bem1aMO!(U>1TC_ClqoI@I!+~c(P<6Go-vnR)w zak8+i4)aaoRd7&+iIy-7;Dao_CMW?QQenLUm~XyF{i}EzE1%IyHJJ#0f9K7+AwguRf52B z{1#=Q{1IsQ4R+_1jeuC=@xtI&h8u}eFWyIp0(s$d0~y|M3_j4cNQ+gLaS>e6O8@(= zHfCk{Ng?IRE}5PHU9&*SSQ4f}HnhUGhBzN{EZY!g=Ou;=t#GyT9XDr5-e(@vn&ZHR z;edzu(hcK_u2qt>CaK{mX#Ch#CjSy30}JBJ9TP7s#o{DE#ZD2p=M>@+e)-}i(BDe3 zX6zzHmRiUH4rb}F0087e(khLjEJ?7Hqk#gBa=tY55W=Fa09h3`05nGcPyn^|tvHu; z$-)D~6EELCg)EcR5G03+^1&fJpnV<6;9e%iRX!}ZoJ2mU8xJYN1!W>k##);`a=~=Q zdNH>Gs=^Ht!&k{_U{y6X6~>;*t&s{S09=-MX*Sa!_gh7Nn_hnv?#(G3CQ=IutJKk& zjGj7bN+ncP$!0^!w&3=9%-L9_HCUMyCrfmL7h6eI&52W(BFI@*LmolQIk?5bYq}!P zh>z1AMeKNW=yjwvOQLI~rwzcX$vCPDqQxilC&E8jBOI*p#!oi40wC@I3>k+#*cz8XTF_P!kSn$&++hO{Tm&9bO&i7xCEY+Hy>F zthxk^ff3^f2-F?+T@V>@ii84KZ7}9EV$5pN%|_a-YF}m6&FEbn3D?z=T!yUhsxeeo zjPDC0juf!b%Z5=hGwcw2b<1UHQ5bbF(k{dVQgPt&1F9Ai3+^!jZ z!?{+1DgR1k7xHGNG&^sQ^>=b$_ME;=Wp@#E46UbC_2YJb&LtxjCz_v5GE$uUJjbasta z=hdNao%;C|x)UwO$E5|=>45AS23eaPgCeV&@owGJdNBHXa%fPy-@C5L?>8N!pua*I zDYxnDg}g_e zxzzSfBzXdL8tZR%<|Js<-}{B#WXyX&b*meixs`BC-k%t$D}o{^@V1!;WYON}?E8cO zNUJ@K-Ckqbj+!bP-!8TO;Ey2^n{N4|3k(2Cb(+1PzC0QAQ8va*-0xYB0(023G#-34 z);)L>rwret4+1p9UI-i{2Y@GRfG=6d76YJUz$L!DMt%)-R5pGZd%#|ex^aj6alO@d zdI^%o>ZwLen}=U(5KC55xWTeh19vmkaLTMcsuYa$1KeGQLv0^KXAc|3w+v-{yXRbfpP1(3_$5ZkebTO;xh^_1P$;r&ANB!YLdV{#g(~p?(Psvl@ z_YGKFSNHs$g*8xDV?fv8JO-BE)c@dr=-!iWl7aG_hC1?F1?%acvJpOq+SDOV$|*DH z6Fb)P3=z)d1G2#n_;tgi>j%Wn4j4RhJM_jOCcndnI`kzM)mFXeSc;e{8w>8BtLuLG)Bgo+4XOT_AOWp8zePKw-&b=yG;}0ZxUQ{TKs_e{#V=1 zp{T)lXBcRa+>!hA6(edo<7v(>e!$1?Z%=;cUX^q6UIt8AM$gMb&}ENq$97p_Hl8(T za`JTM{WPvW;GQ#cD)MIak=HkRdthb1|G9Tj2K{%59S8mC^>xFCC*Fl;K#K$~Th7gN zdO#TWgKL+5e%UNB>hjNavjayyaNR!4e4xL7`oZSohl~GPR2`10Bm3W`cB{y4SZe3< z{GX0m95Eoa;6c92%IFM+_fP+z>T0+aZYf5lh3O4+wmWt-bj! zb&G0-q(a7QFIm)fAff;9>olK`6~p-Xd%Pce>tLw5rFX1~JGZ$QPdi6rs5+0DE~$Rt zJ1=rnTU@LEH`8=x$f1l+HTA1P*J)#@x+gs|c_Rlrn%*XEYWk%%q*G}=k^j?U6AV>1 zAiGl~yx+R?$aJFjQ4Cc#5_T9m`^C#)!-F5FIy57gH)*oHW;j?4L)AGJ)n{EWDW2BL z+-H^s9T`$TXV%SI1dif??AUwv?v#P=r6V74#@097U9ts9c zK1ztkyVmt72qqQN9?|UhInEoB-o1)^(uS3Y&|BB3>xKSbm3;__U*TQlO%5wwS<83+ zm$rmHX)MNv=r-t|~?x1r+8N}(a%erul2lPpwi z)4t%1?Lt+FFl}b>%Pyu>Q>X5#oUq?Lygo<5D?>@CL2mPweT#ONGe~X~RQ4-AyaXm`@>IGP?^6-2j*Rl@X2kM9IQVi;blm=Ys zCGQ*zo@ZDr*n*3y&~5MlD3|ynMAirb%V)6#*U;ulW`LDl@F!pmRr1~TL5hJgN zm}?$-218nOgUmVm`3HAIFx)Sl-hwG7lAhdia9_7?v)ZalzfQ*}wd-fBYIHNBg7qC2 z96?Mr49Wrs)IarXUTLA##!6ihe}tlIX?MU~zkRTnuQ%CwLEm1I7tmxCU5w%>Va2KM zbFB7xhGrNfqnd@cr(C=k4O4ab`pi#(@j8Hw#amE1;nCq9cJUyia-+LYp+%59Fm@g#kr5uYi*{$`7HUZJMwZ^ zO-BmNed53;_`+CNG! zRi0ya*b(z-$<~=bR-@2&ovC_W<`zaZw={cw)WMZDnd(&^=X%VPA8d7E zQxGj#+Lk?@gw(7}D;E|D@$w0QtgxDk=2&DlryYu>z8Xy-$w{LtpZQO zo&_v}6itsd%t8?)ME;K0?A;01jgX*`3T9HDIhIY9!Cm*_BRZRb$KbPiqX5~ zE>EEMse6hpQb~kliLqIrrwfU{!CS5g(i~y@Ze_m7j;eg}980Nj(?GfZ5qALNbDuUK zb?H6iqL3v(X6pMO2V`n_OJVA=z*N7b(eSF&!j`}7L0anc(VusD=3C9Y-)(pI!sWnj z;OO*a`jLGiTWW*EaH%kho4@`EQKGr5z+=@yAS*8=r3v{a_g)nim>0_}rRb!&eUAn` zcdTt@=D+`GU3nE9vjv2P;Z|kH*BmT(eU2tLn!cnUz3x{9>cu-5cDD+!7k)A(2BTE( z-;VmkuQ9P3wbq#9;BQuuW$vCrVw@jA%l>&u@)XxPp?qy_gIsfCP^#8wlL*TBRZU}a zhT07U(L83Im6jmILJ;6oeHCn8)(xeGE>3-FrJQTl6?KaJ9^y1Nl($X2?E3VnK4msf zc0Fl%ul7*?E&KAl4`vNxHMI>|s1=e6r-eHt#T-6aDNE@m@z6d(pJ0@NcYz5|KJA z-d++T?=vyzL~kUQNu|0_5@5%(QNT8_#^JFjv;2f#avJ76Rw|5m2qQPD zBvzy_6IC-OFbLWC28J%blY8`nNb6C>SKLDTOcMteHiOfp@hkAjioqv1B$0p$+B|Z} zCo{L{E*(?${$+9z5OdHQAqoJA317`a(Nv}7;mwtGyl!;5RTO#^Vx`JIq8A7fp)e#%u&QLW>}9dao0tenjKX%2+8<%K2OW zwRomKhD(g(;pP-CkTkvZ9p7-BLUkh%xl+x9zD6>VV50&p#o)Aq7`!(4GqOw5T{G|x zQidF#_=k~>@JHK<`X|g9uUQ}WMZs)AA1|DfuMGct=VWM24Zxhfrnek@nRvVSaB zOS0Q~kA+?XGfRBa{*~HIr~glCH=1+*OU{qws#ESTwp?|}wNT)g8P2|ZBIW22%$Bi8 z0b{E-y;VyENeUSL0viCPeOKDO4%hL zWS=z9=0aOoMLMN84+I!iOU8)z)k1Zr`814|z12crp9O@gU|SO2UIs-}rZE9LRiN%f zBJ~N>_BQG0$#JU`1$Y@ACxCWi;5RvZfJ<>IrCk;fl1#M$3GA;txln-%D9zczCr%0r z)n!zHAkRSr%@gB~RN^>_!h0p~0wML(6H1|E1!oF!XW@?M;(diNkDPD<@o}dov@p51 zd44WIdD349TOP_Kh+r!j^@*_XE}yt>1f6oqv;7LM;KEk1bZr27yay!Sr5g)y2tab0 zE2Ts!^M*~1f6B=JiBEuddu*bh0Dh9lo%l-bx+n?`6;K9+g}=zje~%(sh?tp~PgsYe zZ~LM0#6qcs%3Xig<3D2&tdP?4m2NErD4B&-0%ElC+&3PC^A+o5;tb~>fMpVb7{A%a zY_CF{?hO9UisY=q6^YfIzEZ<1u?4L7RSCp7RD_8=oP}k!g0rBu`hpqSG|JEu0QXhY zZxv*-Dvj;jA}?Jn9Rz0?2Q1^1JQjgeK6$}6wc4$kK1&Hz3i9U{7tN-yscqRR*3Z^! z0d-Z`9WgFS1a9tQGPv+UVVMW#Viz4&sLGzH$P1su+6v2@=fo$cGiVFOs7UicRc6^1 z{%O%BLbs}{UL25gQW9YWbF1tC32}il*v69<;eai|vUQT+s!N3Dsu0oVx~I97@V@-EX)(PHQvObQdBSE0{_Uibog+H{zbIzS*HqrhcV z*(Dx~%|dl$6_l|Gg8=Be)%olw5OAqd0||0f*#IvwhzIt zVO8Dh86*awEa;=b0NWU=rj0?a*-N;bx_7S-BRS5RC1ZTNVj;N(=Yv}MbZng!`acfNaY+G9Mr;K+%(H?1Jbb{Zjk||S= z@LegP6NFg6nadcnt!0BB@)HFhokq-sWP(1S|GTEXT!u*-+ODGm&ULP`Vn+b8pd#`) z>-Hbxz*u7SLd+hfn$@Lz7Trpv9^gF;aEv_}^f<)z3J0QrvJKRb|~0hh6!NmT`Df z8wXQAzszmN?zpj&yyQ69=jnTx`(GAsi-9I+MDvU?hBYf9|x4+AX0wb_6QNP#apNOJ5+whF)<8*4mke)yu-G?E~_)i zMM0p2?sq3PcO>ofJG1IA7o0;M}qChbwu?ZH3v>DsKZ4Ex|~Z@svO z={ z82@A8ltu9*`0&X@%^IQ0EyKuXG!Xb=Yh0CWZ^?OJ77tqANA1obOuka@;<%2pKr5|4g+{{63`q2s|J!8m&Ogas=D#Eqaibq!4g8Q$L|4RAQiMNLvo)&CosiiN3B}S>5a+Q9 zQ_c2uRh3{8il&dgKUA}vnj_L5s#$V+yE!JIc%8N=08>+d#PYQ5ZU} z>FBYXsQuj^9vk#0n6cVhZ&Pz?vQz^RCiE*S7dt&W@=-WqKAgXOuKfA$xafDZ7(HbbjE#K#;BjbeBQ8lj&?-LojV z^5;ayqT#gQb&B5eEAa_E-adt*<@&DQqPBVH26aX=w;z~$*?Gp!CUL8WK|+t5Y`mH8 zbNqm$#md7eM;?8V&E^+tH>J-Yj7X9F5W}^=xLc;a&CQZ(Y>%Z~_fGg?A`PF;x8*^6 zEZJkrrafv6OF5VGnD%k`YEcQBJ-p3ZhBmR6SB@j(9kOXSUB5mmcpLR;n^+y=)CS`B zebOObfn0*v*(*}Zj|Mx$#Oz+VBtdKIsx|&(&)Zr%?M)^6_$VzMJaXK^A5SLgA6nUSHSCg?ao9y@r?nCSe~;=dptB zTsnxWe*b{|*(F1(Jv#HZb*6CPLJvP1$uRe|3+*F>O<@6-XNlai5ad(@cqI!m`EsNJ_ukaT-=(_6? zk}Hja6=wmzJ#+BJgh4cAsvlp*@`O22rG{TYsVoq^XCquh6K(de6azzHsX*b7fsc&< zzhE&Jj18h{w6Q$t1*wT2o|}7cBq<}F4yS0DG~5?SPfh*;Xdkh>vP-#=YkJqg5;=-w z#$Nh7p9R->5_f3}eNw!O*lWIEAUa-#QLC|leaPLoee~Fw;dIE*@&Zwc1dbvzN7dQ) zp`5iVN)~!9r7Bn}D>(BTd}-=O-lwdw--L6|Qix5qb%_OBbUG|eM(LCj7ISYNRRJ!J zdovL4!3#+TObMr7PO-IH4SD4FA|JzWW>`?#Rl9B%3OB^N_ZIM(I-nj_Q|V=es4baG zs7D@;-v`p?yW3Ydn3#{Af<~vsraBF&CM0)IE~!ZW0v?}!=?&O`_pcE%&b&5t_b!n1 zzIKQTYo7^*E<=;VkY2X83ysS}+S4svEwGq^bRCFvS{H}mZ0 zLS7Gl?djGIBC4Z!_?lo<;W2sHD^9isH|#}gTlCbcTNZ2-Zk#g;RfnP6N4W+fUh^#~ zval_mt}H&bOVSyM+YH|{258^=h`w5K!lqw)aCy@KYaAD*dI$=xx%UH%<>MlSRX2Wl z3l2F$)gr$aGVhyRHi+$(PSKyW%UAR2KehxjJ|CCh0>b5%* znuyPY4js}-pY~nQfzNdpnVb}%37^PUdlvot7lr@!qa1ZTt8q>P5<DKW8JoT4yr>To_ARh?H%iUeR8!m8yDZt`l5~&A)hrcQ&++)F ztUzR*e4r1i=IXai0GvOY%(Z(V0|NV4Dvyh(^)A*r!aM4A%X7dzrt_MJ0WbMYrWybD zOKqb73b7>#IeGxpy1M!oT)Bt@#oGA4Ucc4#st=__Wo-PXzb5b!pmYhEyVyKR`kEfX z7NvW~HNzAe6>HG0RgQYT9pU~i*Iee=uzEc!eK|jR>X*4lIm+3EkYSMMQt|~nBf*)K zoHBzXA`9DBsUbE3a|4OHS&?WDvBeD!$2K)RFWR z3|BNqmNKGyHN}*{eXS-;L6WI)JxZ2(zRJ1pY&_od^h?+y0UF!O0r*XaZ9HR-+GMGN z8)Z|X1@WEKK;eM}-SWpjt7XwIaR^=W%E)6hGMND28&*Wo)*VmwOBCQYBFpJJdE;RK zE7e@Gff^FlIlA&@o!kDh>7NC#o_ebe&9W17+kI7cMpBPIb|jp(Y>_`Po+ z%qpZ8vnI`C-}S$sdhp$&U6uza$Hlm{KSDexoU>b#34OV=yW=P{Ux-iO?Q7(!IScll z2n3O1J2hhC3q$r;9NQz4?4g&~+o|omx6D1owyBoXiXc@+rq@oNqO*AF=FRPM@D zszs{_zsaEOz?SVMP$+k$`#%zj|5nZZkWeI~{kv+`4#fRSLV>MMu|6J0fm@_V-!+=< z+q^h!G4$_Dx}-4R-EoqkWE{DWfq~?(WG}xWox_56|CfYf zx6ZM!MaPoA{9w`@BBuXOve%ORAALNX96KSr>YWr#x&|9Q(*6UIsS^^QL2| z2LQayW05%rSjRd3umWd~z)w$PuvLVM^5dhUKPr!Rd(D{$yhws0Y{UKr2P*RN7hzXF z6`2qIs7vyH9*+mTII!L}aR3J}2MP*LfPq3{*&MInBA0wfdGJ04PZGlmW%>Rr`0Ca) z9RM#?0cJiFp#m@6s;4W&g~;*=a+o84#w=2`#CQ^)X4Xo~Bw-Xh0Ym1ZxJ zK&8Br1OZ`Up{AJ}7tSx{vVa{+ayUx0H(>>r0Ychdx}5}G!7n!CLR#x`@N6KILu}#| zv~$%4Q2GO%+>mW}N4dyM1V+p^FmlpdEGL|k5zW4m!sNLRaTxYbyig1~A!3Vo$d-3v z2c&jW=E^~VxH%!El~>TgC%(smJK*@Q-e*5oWSYBy5LUMW;QUM@e}ty41gOYqyLc#8 za@=e_`2a7DB!ckU#KcN57en-M&%+9+SIaU*Mg9I3(BL0*2!OV!2zPk}_jsPRkIxyr z&3Evjg9;FF21e(zmT{=eQ|gI;mc9&Er=U)M0TwL=0FH9Sv0qq<1)^ zI4f6e1rEEZ88Vk15zv@frU#?-iq-5)#vyQrEbcsRBL1%dzsB zy8>seJdqs_rt&prk!3pZ;ATnrcOTLzXB#uY#ZRRdyF6g5T*g5zySYKHunZJYSCt|J z#l`QfMD0r{SWdRB4F!=}oaACOsy6BjQe~J=l)xmD7I14n*9+=)|Etc%}_mxBAZw_Q=x z6{TJ);#}FxZ{tOQINoIilis3N3(2e1KQ=+)8W;pH86@o1e89!wp0M`0QoxSK6Rw{> zB|cQr3EIv9Qkox~1=M2V>mX}{p=^N!&EJ&m_0{K!v)gH%xO!pjxbk{R3YFRVItRBIv9;`zuuPWs$q@V}?+MaRrPrcfS zyNEf^JTTt=;2kpb?gm~;0k_+klI-+4nTu=M>h&(ksJb~U)-Mtrfw2s#S>$9kk|L@)IKZc)!b4n2jG@2DY&hEA(r94=)hB zd;D|9Afq1q$&enUsB?hh{=}XNJ4Yd*{L1(cQv~kM9$>_SKA#iT_Hc%R1BVV<(CvWczvIv+xkwWYcz6~eD(V8QTpd?;nMC0P z))TiTpAe!Z0O5qz6lwGu%`U^%7~X$ zg~U&1R*!29KKB)F^bt<-NGl2!yepiyW z)ZmTUI}v!716KDfv=C0Kp|2s%>lnKix`SQjBG~4B@gv&E#vO0}^muu7K4Ph8V+Ty9 zFTZ`EPNPr1YyF1TTB5fXjJju&W@Ky7qKM|X_t`aAyD;MI#&ZZd@G`QZ{q%jL;s|ne zO-@7IR5Eo&H~$?Vdw0F!B&6su&VNr}-=?q6v1o5Mkl)@_(CGNdrxc<8QsbL^-#H#VZ0r=Jk8QbI9PDtZ6 z)y?KYEg$aw*y9=oU2tOF_(%$Me1z?B4Y+uP z?e1EX0YsX-a?=8LN5&3nub*@)02eP5_nf0S29)X9WDSGChjujJ&uC3{7OR_#$dBb- z;EwSKhl)zlqYPW+TYlDkJ14L7uozgkZI!knYLhNdGA2fYiz?1~$MQx}ysql`257IJ z&!N*68<(YCQEyaQXc(j1V$I(BV?4w4JK1z3SDRbw!C>&BnTrl|gQsUBi)ykNt(>h0 zyB5jH-I%<8mj|HWmFb{cJaNI>yJu$IR|tB%y|s2kUtnAO>6Z^2Ie-jl1O-TCS82Ij z!Ka!X^m=<(=X+Y5cT;E3%me+r&hmSr^FN&TJ~O17_g=_fEm=o`mKvp+Bu?Y zII?3Cd($`nccnyedjKOSKHj01!j_L{8jeJn%vh23x;y|0nq)hyTolpJX|#Ss zo=?q9iECQP^PfxH?iI(o*t9Y}bQlcH6_|EaPz_p}S5{Q@RSg7XvKI`gHFg%ix_h^N z`<3Gn@Q%z4TVim*p`Nq6De4V*F`(ZQwMl1QYF>V$Svc0`Tg$`arb?Jaa43c0A3(|7 zv+MdwMn-@!?PFvN5X=wlPV|Tum}Phw=tRHJ{W~ibyl)|8aUghW5H2{;|GwgPN|cHi z`Y<}EQ&)5Da3E#y=Du#wkoe3eLtHx^&fdvnY>kFDXI>Vt&IJ?u;PW;oSa4W7Gpi!E>7HXrV#mm}EZ(UW+f zB^m{OFQj^kj;$#H;mjc8g+T+xy8{6I#p}e5da2N!7C)ZO8z=Pu5hL_ZsOH?SB19#i zu=RWh3ZAi5%W-z{$Nm{@rxKWP+gQgkHCF~W7t~0Q&}3Z4ndjhqm6)>EG?SK+CL9WQ zNeV(w>M@C>aIeC6Z^`5@zECQ6{UBY|-;XECJ%HEwCWflDdWO++x_JJUGgerKPg|v% z9VQKMu_O_y1U*Fmshx+|ATwdwt-#@zL-%$N3BDJEJFL2k;B~8^NW?4r&~fl|3zlDr zh1PA+9@0U!h6is&zC=R7nOt;>xd3#S*(ccaVo9G0ub<}rvuENht+%Q>k9mAzMy04?@L|mf#j>yuSzP}nb2|XUdby@ zOZp_vsba3P{oV{Zg55lF%GHv^FO}j=BELNflD*>XgVYS+GHwuiG(ax%e0~-d-eCil zM{}pS!MQ;!+NO-nbKOBauXm@8OnohU$&7)wcEJ?>u42Epxl|{!B~0f#pd0p0LhV zEF!Hi;#cW|Dd(RrEj4BB!_E~`4Lu|w3+s!I*(i*?EafS0I+|?lrjOGQ3R0p@lrdk(K7TA$rN5HB&7psN0>^| z7KD3ooiXG_O!_HhPSBmXWlxI5vJ|@JZn+NDkJ@~`0JkVM<<5B(&KUk|rZ3yS-x7It zvIbbPE90wvvT9TQ8HpM}l>n9qQ<)yl3%tBNEDM&$hXlFZv4|HTF|6M$Ek%z*{~(dn z`Nv9@u#lYWHU8XE-+s`y;2K6B*eM#Jq-Ou8R$1Uv^c12VR5 zVyfAyQs(Y-2zP`q#|o?jUIY83knW$!KWTANwAv?k#}~THVyrU*I=XlLnJJ=)+Wc=# zgzk`^fhAk#Bk}f+$x8rO=Z^(?cjEv0Y`OK(fzOKx2YGu#SAkBK_u?h7{hH8|&x9R{ zeUJXw7y8-Bn1`?0a@g+6f6(WgO8!$qo1rt0m-;{Sx!zQ7{$9(qTi5;DSRzYX z{||jGG8Xvv{4Xx{NYYveQKkQTvoG!+3GKGt^er#a5B@`++drC`UXto9$7QwrEB~9$ z;KkbO0_p$SwpHyUXRiL%uwp6f$jU$K1aAc>w6B0rPB3__sV~IYs|xK|P{@)jcMh~m zaUx5AUjb|Cu^hIpO}OSSG+_Z>HC^5LPS&ybxhcg@&UL5VArMoHco0O$rGSmc|9P0y^*f(!n2Y3^Pq2zOi_YiLL zTo=_tv2yYwg}9VMdN_(a%E)cMoBL~QfY*;|74A^wye%>!odu=}OD*Q6z#t%xR!Q2- z!c|pjFj(Rzqo685%+3eBF%>Nfj(uztsh~ZZ5B32E97Rwx55BGtw*x5zdtBcr=*<_e z`MB@14}9~9k*S#cS1I*1$RO#Gsb8}zBq5fPe1o?PEf(~7DbW4L0wzFs`t@wJFk;12 zewc)mq&!sg2l)9b7`x3XKu-FrmEsO*=|~C_LC9QC=Eys{x(nQ-IG+tHe{>A=s*Dh_%PNUb7Y<8MB#2?byN;jGYj!oUU>GEMEtw} zGue7uRYOzY6)Rv|S38JbtuDl4Il?hzxfQqOI{RW!Top4v@k-pq#3^DEnKXO?4HjQ~ z_5@fzW*SMplzs{^`&PB%o1VT3Xo{muCZISmX-glJ`r}Ws^@ALdc0jt@(^7A(6bQOvvs#mNa-ZY3+@R$*34HjC; zs91;YCBOQ%xQ2n++ggF;!de*!R5}CmgpFGD(4DqAhOFVnT?`EeGB^!tY#gteHj#)p z3#d(PWRs7$^NRCll&i0xHMgAUdV(6yDnwV-aDD4lEF*m9gNT8~+F_(nUbB5_-)?0& zpIhq?Rg2kjBdgJq->STTX7Ut-g;d&#mg6o63N%*mP^OUF6=wkBGCA!g< z>r4l$uAC~@lE(YFNUPtq(5CP_3U##$Vk#jwRgvuakw3=H_lPTwbzZTEgLj4_x97f9 z#Y7;P`hYdRjga4tO`Nqj>b&n5oDLcYCz$1fCy>jqag zCcU9PNaAIam{a6udK=6F0Hg0`5x&C?_SMhx0ke0=tQFwI+UM2Q@Xi_`P9QEo<2#ki zDx3OSTZ=IpIw!xj7SKMKSmvHY2dx2I61q$mSTcc_QT<;5jjkT32Zz!$L$HWn5#d6B z-qZP~FMLZvwZ2yo+A)JhL+F_sHGU`!Xgn%JL*+FlGJ3d-a>P&MnBQcP)nJX06;)N2 z-ym!Qz{J06gTYOnKIPzgq=;XAO|jWL9;#NB3wRy+Q>{LgMpN`YhzE>R^NA|bZeFOTbZaCgM^$Z(ifxKp-i>@mM=yI}S0QkVVt;7nr zcp7!x0X1D3pn$-yKx5w~lqtZzD02A@`cmG&6uVvaZ2%HMe((COtiP)+BJn6hjO{=4 z4yfQikZRuO?W?kuHT0et_|sq^&qcj^1|IlETtt+QpXx2J+4r&?u>+_){W>N}dN$*| zV6<}{HTvtoEsaU!6wp{ML-qL7rnys;pVr4N^@k4UAQVHu3M1cjAP0fQK{@Ippf&lD z7KHV<-$ivKO=prEaHy2sJU1J;YD09j>3D-0uAj;ECn?YnD;|Dsn32!j3V*;D5g_t1L100 zaCi3j)4$ywXYNwc>;c|(^H4;iXhk6um9Ppd*~OSaSxYnu6V4@A_;Ac zQ)~f`NL0%R=wE$zb1ixWXzbz9d4hg*IdD0#20ntC83Dbsiq+>(AE5CPaKg}yIB<&h zCJ_AluY0ET;F3IR%MZvypwUXgQz!wNeDYEXwBZQi`+pdF53eQ?hHZal(hFfGw9qu6 zNEHL3B9=|40W1OOB8H*@f*TYStO-pz2}(x|O{y3a6%jEADuN{_Dq;(YiaqS&E|!(w zxbOMS?|shs&UcfO^qb4TXYz?^;`@dKq`Lst5o&%O%E2xl*BSmmloI*5 zgqGmz%?0p-1F*$%a6~v!#kf6m`@@-^3)?yU%>#oY;UC|Af;TP)<&vByYv|7pz-#2! zpMFZy=0o$?H)M8`M(yB;gc7+chpBv{GXDJAy|?qEpJ7cc`fpQbUaNI~*V6gj`oG-Z zbJZtn|M)MN-@NN&Bkw`me>eE_77oaU>)#97{`^}bMA#3mE*nYnZ%?TU_t+VIU+fiR z1|QlBSV#AP=pf)-{A-pH3SOm#KSm3MXIXk=iWN8pmU z=)O5tUFMdqx>@3CZPQ|vIXTbcorJP!j=^0$tHm|F^%~_WY$ku#tXY=-rA;FnTtlNX z6uj8FEF9A6?1-r{%Q}+oADQ0lYZzkBx@&OI1+jHsFBr(kHcC;MpVNKX#V2j5fbWt@ z>n1U{d(L&B9z&Nw*YVqwNk68sQ=((Y@|Ah!B$ZXzxtb`Jy77#8U(!!bJGh~PJn2WS z96$T>Jq$0vD-nWUfV8(e-=PjJYx^)foO;Yx>w#Ut zFyfaW?Q-{zc2Dw{qm)J(PoB;a4_KUh+*apzwtcUAWi-y#Lwlt6)MX2eh-IthYr|9C zixgikxhL|>IgdjP&D;V5HXrFbjya!>$J8qQUBQ_xptX;{Ke zaXgaw4%Qw0c%eXqJ&M!PV1oy7YM1t4lZ@hsI=o+drK{}{3Au`8SYi5&sw^;hz0I8`XQlE#X0o<4L36aC|Vn+#8S7h zEG)9eK)>Lna@c-9)_w@m-6Wm+<1Nv~*F`@%3s(kSg*Xb8)@Wdnr8aq$ZUhCg{Q|RT z>Bx=?SxC6VI-HhS1nPhMQSkJHjs;xq-JDz%qA4IodxMU_VkA^-F zaX*(w6NPlKyGfep#`LfVd*LQD;VxzTu)@tNPowH{Fs-C(cq@gBcH50UW-Owd2phmT zrvV|>>FKp0BJ!@uS&6Eir+HP2L?g5M_)qTI8#qDudf`gD_V++n3oj0qIg2H1g1m|M z#V;vaOX7w?w()uLm$6E5QiaOo$I=ko*ud~X5p1p zfbR0GEfQ}vm2*j(D$Kf^7KyE3xSWjItZsO|2@5PO7BKRr7ts8XgR2|IKd{ZRdLGnc zx6i!s5QuP#@A^iD%~xnW$wB~rFUfW%6;~n3D2R(D>@1dPN^I=G<@AG|Sq-j2j{hx~ zbwzJnG0*=I`%#GQnTp~KTxXc9uN%3*L3bd|ess)8Gadi3<3u4YGCk?VTKt(!VHzbi zT-V@;0i5`gOT%?1OmZo>MQwRR!y%jsiCgKe;yKY-L|mLl8)sahyRZ5AK~nXsf^Li1 zT?su&qcX`WEbaMhCAicfIh0YtGIR<%Oq*fCd-yzUs$$aiCbQ|cDRsE}%^IE7;M85j zA;YumRGypao>=gX%a`ChD#rllEH3Ppa9Ho<0^C59FFnq-*KpRXdWqy}!){|SFCJ#u zO!n5SO#t*;d+{*`yr^9U;ks5VDVl6t&6lMuG)jFa+uV29css;mi$|}nfoO^kbkhly zB5;q(TwRKm9*2voQ|!WC5s79tV6k&%6o?`Luo(fG7l2MJMK|fRWRMmmSu%G_ZX)gE zkbYC`Ch%0x=eL*xxH7jZ?HfprvWx3b_HtRw1YFiXnddY++B;{$SiI6nJh%V^5TfGu zg=&%1qJczmkpLx8VzN`uS-@HG`+R2g?WMnw=t?D_WNjqjuYKP5I3;vomP4{bha_6O zG0O(_Qnx3k9{X4X8U>4=uM&w%&KF4aB_OhKBcNUJ4}&@?&~N~jXr(JRpR+L|trLj4 zHLFwSGoLQeV_$)jfKQg_Y~cYt9^oBKOt~yZLS!8~Q`wknT91c8VZ!aDx5z1{BC#>l z5)z&t7um4q{`zw&jc3fyyvgw)%UX{7(Ehln>t%@!@^jtBy_sh6Z`+>!{Hy8K-t)zO z8X#YuIrMzFHJLfMjd1+6tDZs6PCX{~Ps=mltifxeuYXj|{+L{Q`P;)gKmWQo`;WTt z@{c!bXTLq3{W-JcGV!lFvp>Jj&H@Y(nR?ehx80juf z>2AU4Uw5bT%F@06Z<)X8^B|YAE!q_d5=DA7fF5cPO%v_ky(9quVF~eoJi!$Y0Rjx0 zpP4kX)L|Y4Wqv66jd5RWDZ=dF9ojUCF%zxWZHnJ7BBhialFNanSqppgD$BaQYv~Fg9#Fu8fjVM_HyalvmmK2b*l*~DFyIXoESE!JybL!bKG`z2 zM}a>WNH>77rjK&nL~_bsRf7YVYsHG~HBl7+pXKc6(n%-tq~_Lsp2vvN{-S#u;u z?9gtZuv9OEWvry2M0C4K%TAP)Gn1XB$|6faq1T}m!tA}9F}tmRMS{Wy!&s%VRFgxR ztG8pR2zP~dSV#8w1O_b!43zYMqw!S*2y)iRQUV*XCRt)IO zQXp;{B}`IDEI|;W6#@{_)TJbGJf<>06wl`O?yUJ@Ptwd`d{)Cvi?q=}SqX49^(Wm_zl6$;8V zg(TNZ&`Dh;P1zpKrk1?LZ@lVcE~?nwRDpI^J)3kx6;=!}rtMMbamCy`Eubf*3SFf~ zH|XX))Y&K}UR7_^8z=NXDs|`7ltM@EmX~TL{wjB_D%a%-s3?bQQXHj5;2%CJO;(na zzBpR=wm=VUsm4hwrm$RVP@oEO7nB#Vp|`2Wb~hbEV`aY9`Kb{kZWnQV4YET`D!G82 zx?%EXN~x2uTBCaJGVk4}{$dcWs}ePgj^n?Q)+F3LzWZ&1&N#Fz4sr|CTQMu4a-H$4 zL=xmJvyB7^drC=r7W#T0E#Tq}`CiNEHx&ShiIYun3P(hNt zgInObdd@mV1ja{DUZq(7f>l%KRU37>)&^)zzd_?x=*uHq!78kY;#Rigq|5uqfTv{`Mq zYES8!E<_)$w~B-HWUWOPuv%=;T?t=Mx0;N=Tln>lv+6_NIX8^gL%<2sh?-?xs3Ygs z`W&`ePJIl!t6Puo5giqt8%ou@fy5|I?0-b&&c0y)e9Ux6n{IXO@`^SBcxaH^fr&V+ zF$Ck)t!Mah91E&}>zX(ny6FD$SZSY-UC<8o#5R7`sUOdQ2^65HNz>y!{6^gx{|d(O z>w`k+Nle)S!O7nunuyNlbi|-<*RexOPt$zQX&|IK%@n<1!WQdLV-Qy}R@$W49~RN9 zmD8*j(EuvW5)<3}Y~Tay1MfI%Y}^a$OGx|3r%fU-QL2`GBPcs=-EDP>A#PkwzJNo{ z?W(*$oub^?3)5D&3Y|E_~`So3kFEhGVAk1)@eZ0?S4`2`4~Q= zZuJo%=p+6BIo+5^{Z3wIJ_|z6X3AC=(Dj5hprSdUzE$pX?ye7lQ@1uN&fur;or9~< ziuf*B{W4cA10N{bnQkO#9T?iL3VdpQd{pMn9~g#a5V2!IO_U>8gN~WEZimn z*t6Am>l-*Mz`*4XSb!kQAy4AEhaYSP-`R~dkj1h2s&%*s521dh>mFm`~O2k4zLgH`H z|Lg_fD0FmxlG)sdt(oQ}h_OUWxv5 zXw6M{jmCZ7lZa5=I>e!`R`dp&0V@KFvTzje74=?MZjO5q6GTA*mNcHusr>kBU}2 zO4^SsRJTf0g+?GDpzC4^8T{cclny)WCf17~ZCZ7cD zIwrkUNAi(Xz^U<*>ECXtt{z1+-vbTnGcaq$O%S|wZU%YHGh#3L+76n8c0d21*6neV zDO$|l48z+mSiR&s@2EkR&rP^5C{pOoH)>nLK^wc5tZMT4mht%Ibmt!U{(C^;ow34b z$V@sxX5s&^8J#W#SA^01dKTT0kx%k_R$IOxx4$Auv4L|`rs1lXXFAb6uNMrz-u8?x zy#3!|uRaWTR<>{bt<>WKv-H`4x9Pgn_;3KiG>RZsZJ6o-5p z1=SVRalh0P{La_EqHeV2uwUv4X{u%$4^>Zuh|y&dR6SwVd-it5rK~MKWn?o{J+Zpe z+Y41sXf!XI*rnYny`tSV7gbNBur%%iJLVqJ?)@@2J}mcis2(%<@~FU}Q#b3@vO1vI zHh#;i;EWc@vw=mCMD9*^-fc#L@KO}(!k65T#2WhnM)7)X2f>DJRFMdQwb z?(Z48Rs0p=pXaUtVV?2Hn&&d{hn@M0-1hfvO2P((l}&KPKg`p)6db#{oafE|f)yL@ z?7c5C3*cOX)_iy9__)=tY_>*RS?*Z-Ev zcMl`BH-Ko4fSLK>6PngOmlH9yvE492f7v&57efy#M6=CxTbUs6&iYMWX)+IYN4BT=26{Wu#{ zR#?`UwYbg~W?bXpMu~V*xAS!G@xTm6AYgU>pq2X?p}UBOajJ0dp!urPvaN0oNGS=; zTZgMD)f@`~D@C1=zhM=x&Bbs+@B;a^c>)*U9gRKZvQ_Br%G2(Mc&T^p(?tn#Q1_xQ z)n8wrZm0V8-q&Q^P&HnqFLcBfuG}^O2CRt|d^kr-?D6>f2wxut`78CrJHK)8Ya|n_gpjD2*vpZnJJEoxAlVo zECJu~I%$)T5F|<%Y0O_=dQw@3bB7Q6 z*SCLrc2X-zjK=UJzI~dW@qU_j`W(|iHvd48#=+FyA6Tvq0=MnQaG@ZNh@Ol|h3|?O z5*{lRIVrIO`~VXaTrE1RqasaTA;OI{85qCM%y5Z&EyD%4@E_cH?N=$U?WFBQzD&o@Rsf57oi?v8HUbKFVKB-6- zCuoW(@H_04r6gL}$poBU#ZL!IjEN)Plp`B?Zu>_GhWMfg1J1#Xib`{_0)M762`3Zb ztct{GYY{PRdkn6$xF3?*C?;(0mR@$kCa3#~B5b#Vi${6jSFLQqKIYXG?CSZJY*$(> ze_>IpDhTIf>|UTK+h`)Bgr!8d*Qr{6D>1_-NnYp;FiOZoR_;GG+3?S3Y;M?Vpge{5 zeK}^wxClI+<6obzsb15=VD?3_jSQH3i4xI_PZ&UmGd%KN!}n46YT~2-{g> zXMF$Wp$cT)6ur0kCO%ZsXM7-g9}Wn1-@F4@P>keZhQ-^gUBq@Hg=W7ci?zRS=8p13 zgpemMctDJa(b9<}1mf-0hJHpkNjxh==wIzpBeol`$H_)=3FHN=nhF)K#@w?gq$8SV&BSOWjMoO=r+M|%iquZg*%UPx`R2N} z)w$jc52TxD>-i7!J4JCgg%vkhlBOT;XJ!FF2I&(L$E=gvjHWoo6;>;L&bsZWVB9An zKcm+?>tIS*`Yz5}28g@5t{)B2b}?R1bff3!F2{L5Mm|l6Q2Es||1#Ty5RA)ocC@_k zhK>)G;~Azm7y8niTlCOHG>_=uHdl!puu2sZHuZ6+rw7tV zhHesK0Bx+q|#WcHzLIJZyDi4I&2Yw?ivXf=r0)5c`zo#TeElEB z##(h@s4B~V1&o(|<7JUw~Xzq6HBQbzY)-skb*ZwtMdZCt(*no}hk zHuJU5G!PG^Q96bs$@^WSOr;qTX&drDs__W;)+_1KmZV$%!z{|U4n8gfW@DLxfy7FkDf2Z4n0A_~Gva68CBsh4O18H~v__Uns zLi|fP#jXO$0d9FXh>1P2o(q^W7rnr}SmS6Wn_8k27hK(*vmw35dee#QFWb351+{pOs4yGE?hT|@vG|EnBnxV zXmv?r+NLXM?oNl$s;kRnYVyBPw|%4kp`Q33(WWS1r9P#BALDYILh@Vf=LC8{_l{%?Fj(*m1 z4km(s#GOsp@P@9d0-Yq%#VY8kWtN!`XlX1EEAf&fdR}t&^BAn1{9twrHWnstlNGe` zafmkkNK8&b2qd&Zci#^0a{(YZ8Ek=zZG9oWD)$|cxP$}5 zW`g+aT(n?F4na^ks(lR9`E`!3MDr{Oj1q}w=V62>M9iX?;GrT?!HeW7)J-~X7EZHJ zqL(xnF?{rjgvp60uoh8TgGprH+`ov&hF(GrL?Bh)>gqIM_yWOxi`LfxBAUd*`+|R^ zAS$gi8)enLyWlbSXto3^m?~WhLQ{-1JBh4z988Cgmh*A1LmZx1qz#;3z!DLL_3`5# zJInObj0Dxge}F#l(KMLv?+KbS)5@15>C2#D5?HB{nIEniYRVrxgG+;tMk$gCOpi@0 ztJ9PSu0iXC-N4UhMrd;!hR~Lr7?4j$qyQ)M|&$YQxzGFog`HzSH8X8#INOW5vMm zSHe0@J(L0seJwzVU7>fKfJHcHP3k*=#L8;Uz9oiVvsge*v)`i?MFxyXhWZ@R!5oq_ z6Y++@#W^dkb=KOC)*4B{!oli!Q{Y8)Lxiwg2G_|%b>|zZ*fG#Zb6{ayLxhxYq>F7F z0r-Sg`bbJXwSx}9$I`wuKJtc_Fu`EX7VYi$D*`Spf^cw8h`Hn6uF!?ORjn z*{%kSb}GueS~>LXXnkCc)gA}0s2enZW|}X!PIUt25EHEpb{GIVo#_P?4ebNqm_+W> z3>yd`3qiZFO5UMt#|?v*)D4BRY`T|(IQ*6*xnh{pj0&=1QwC42l*lDYI15eksUeLi z678`QEfIHQ%*@0W3YIj*E1DdAYaYh6FAr!r?~OD{-d6*x}i^U z#JCsNN(KugIq^sf$ZjzeH|+6_vIM9kpR<%H@|MT2r%GNgtieTKD%1@pWoL0>Y?PqZ zCY|9tSgTrkS`vrEN#y9l=mKFU)farAk{7rmOF2ykgg%vUUB2q|hB76`-IgM1=MFG2G zhG2(TmHejQB09deP2N&5ig3}pBl6ibh@9;tpC@iL+l%aw$X^MnpY3hGrjk3aL)dE4 z!BDJ)FBl*p4SNyaF4NMLL)5{ODBgRVgQQ918XV;N-cz((B14Qn;!LoSo>|tybDTQE z9%#hfY~vyn()*sLK3y6hj%GuKZBZK8a9kd}p@UHFylP?ZSyW+i`g@t7qyfJkVM*j8 zoStuzUX2LwmP%f+9hoB{9DIj&-G_vs(~@t=+lT0r)-BRFq-t4BjzoTzkF0qDQ&sY6 z34&#k4(=h%@5R)?NBuckGuhB!v!)9L=-LYFsc0lh+ht0hhA%*y{SKHqLr3=TY$DLv ziFQBtv z5fZHuWo(@XyRi2zE~HmAL@oc77bGnspUoT4;NoaoHT)~kA3>Otby3uftd__X%0XPj z(3BsUw%%1gv0IZ#GPB)(zont#30x?)5Dk*e zts6$&M_nr7`Z2rZ)D#coc#cAP{|vV=vzw)`Y>$1?wTXet^@o?#Grl`z{$yQAm%8!5i+; zd~vi&QrdlTW9y*#pMy1JSkhLScEh!}r|@C{g*J58K#b{AH$2!(wuxfPgQ+TIJex6NVpl-Ot4DH^2+dy&~sFG_5+Q9uI ze@)2gl84h(YyhSBH#;5XH0s z3=l)RNL<>4(%`8_HP z5Kqgc%=5+A8Kr^>8p&{zq1|IfJDyfaxm1xwmdMY*bh{q7TO~IDK8nm3&~`bvM*hCzHoTPGcjr-9CekI!!FKA!W*(f-o}P3!d&@&O@sy?HqH2Dn+TkAarMajwMkbeso{tL)Hcy72EhDZwu#k|J6Hd*O>BPK z{L41MZ9O4FZ4*4QV;32E9+#;rna6Limqc7pHLyB=M6CqZo!9&fugp|**|tuFs# zo7mCyN9U?<(KxHXrKoLUm>1-r_scfX`z{Z)P5hiBZ#wr`;?%mhcXemfu{PcTn}=E8 zPO;X}9XqIZ1M)LojV_?e$4vK3s^3J(Tk{v&;S519QTw`Hn4y1l-~qcO$w#A`9W^bH?5esm{m#pdIbtD+j(f}Hg9fK}RY(eBHVzQlOW3lrBC zNnDb#Ih)PSN%7m_WYQ{)>RJAlHLM6FuHZ-Un1tXU3zqSGgBOK*$Q`s@gHrQGumz?n zS&OUNOjZETJ9uY*OQxThN03(**0nZtIl4p_C@XW%H>y_6>(bT9-F$RT`b4UxQzpN< zld=iM<;;K5BBcd>%p`p~#Vfm`C1@Znlzq`(xakBMf2kU%NR4ztk7RHkg8gn$4 zjF{$3ZTC+NSR1z6toLi+u+DMAY-Jr(oUzImx0xSn?&%Qh4qXddY~~ikN-~>Q;8+%W z#x=I>k*{W8Iq2vSp5)H)OtBVTCHt(Ow#X50n-B;1eF&3YB!tL3f>IMYJZg{c`Y5Tx z^9P4@12gt79wP0ZRR+v4%5ZLeq8A}-2Aj4yiUK&TRqZbPq@9T#dP-4CTKc*PapC37 zNzfzjW$C-P49Rc+Y?#p7dZv7%_WYcCk#3-NVEdZkNi1vlzR}OXX|VI;&@(8vawLFp)=@9viG_Jsj@`J=z)Z>AVgtcM z44G%(GmAOGlj=Fm?@o zqK^j?l3#JH51C?D$xZDhQ;`6r)A<+yaX~yN;hkQpeW_tXLGR01;uY**nA=V=L;TFc zBz*y6n9%sr2bZOE`&OJzSXoo=>ab39Yq?pW49RytqHLUdWCHSIzktYs*>gQY8E!!y z*UH^RJgzTG*6H06_iX8EaVH*>HxbDvf>sovlM^04t^nTmT-+n2ruqX{{zA18kjN@m zLZW64Xp9 z?B*k4^RK6`ExvZdRL4<7-PSC59lWWWbU}o(Rjf%P zD3w4!&A=|Qnk7vqfG-ny_-0Y9-H|UQGVM*|qw1mORSw0m_le{YNB0~FlRP&on)kpm zYs+G6Ug)|8k1%CsgzmzX23cH(-e##&O-w<(zDsS_7suacPry~n47O-x%Z4(S`w=S4 zZ;_pDq!tKNq;LCNe)CC!L>@w!er!ERR6dr6F=?0LZQboEhI{_H0j=OiJ1yD{%}x2s zVbn;hxdg&ioRDk$l@hpSW9PkmnU;IaM+)5lcPTF(Xtu(p(N27mHM_7$K?zOVTKLg< zK&0W+9OHqN0j342Ek2d+DefFWT0E2D00Tr@SCILnmoVktO4Yt7f@6ZhK#n<=mACeXB~J<6q8XqHml)kg)b| z5!NF4yjMkw=>~f-@@7AYylWkPrv*UspApr30U-^>IOfY=9e|TOtVAFfzg+&;sxSq1 zuAo;X7zH)a9XbsxmxRpD1&}~~qT}~GM1Mt04Izv_1b z>q~+aSRQXF9TIdOf7p*-DoDTi50DY_xF51%x(pV>Snp-~%@!qJ(T_fGXuj~)e1=N& z!1Sk>86)W5NRR^kN@Dp#7NzwS1;2(VI?|^g0;0W7{%pW>BW0IOodzgC{Zx;0uh?UbT$5l`R)cH0cfCQ&oS~m#avOl7nbI z4I3=NC;vx24QBkGd|HhD9EsSVQ*1Q)Z$52OY@q;+6bH7Xg0_-W>&{f$fAeX!5)3^ws}v()sXG|EH5GU}U15RP)Is zU3MC~EHkb%Gd5YgZRS5Zss9_)p)NN|_`nPu8zc8`B0#B~zj8scS&JphU=UFdHYhRk zI!&1Z&|Hy~Kbn})oF!#PN!E6x{YbYvpSY}IQ1dj6H&+6Rn$=u6~yuM&-eHqSFLB7ENTiB4N zD(4IrG=%eeVjyz?m>MKvD8ZdD@$n2kPl8J?Nij8j~s0f%9tQzjaWE-H#4)bUvQ%XE1Q#zvb)6)09lq4I7Yqjoq2+n| zl=zY?q`Wix@6nt~O3GJ%yOl z@_Z>Q!huj;N=0ET!jM;>%7WfUdR!%;{Sj#`prb3wmojt2{7vS90t!i|i&ypVtF4X_ zzYVSwa3qei?FB1g;vN~i*(%0BUSYr~A8p4Uf-8R)`W@=u@_Thwe|E)&m(X^&@(7#o z$!en^r)u9l-vNK) zn3ukTFFAO&ItsL3GcPX{UvZ7rFv&*xURX!!)SW1b;6!=k~na;0q{*A+2umP@h*p$59Or{n1;fsSBlDg?T{P|WU`MIXdf?2Atu(^Fs#eJl_OMP zy%tQUtHy+r!FV}h4iMVW_#Dfw`9cNmTgVAveJ9hfQdNIY9i`90XTy~xvS{4Wsb`0mGtPoceT18x`J1K9hGwU{+a;HRaP(b{2C>DiqR8@T%`>@FrHan+$zhGz1c;mnxIR>4+64tl# zThVIa8Fky*)3E)rGa6iA`Ws2rsL5p$!`~63ZP2LzymqsBuJ36B1A2tw#0CLEP`AC| zZ%Cw^oh&C!cQJKXm_+9Wvj{LqRZZr!b9`G`Q(7o2+(Fd!Aw;^=ZEY$zL)}43Bu$5s z^z0j9HKEa&?x~`*za_>|bgqY1UDpSSoWaZdCXy7qr*0Epg{@Ux8j0BHL3G<>a~T;t zrbw0*0qB>HFSo7eg4AL*ksL&(qO;p!>bA?G+*>y1Q8?ScIhEj!@I>f=!kw@u9Dysh zi!Ln$yWl-^f#QV32k}w29pWJGmnq1;`026c4fKXsIej5~I?8+fBHz;%K6DO14az~U zM!{xvTisolvpkU-cFtvpX{Z+JdL2NW7X6&iV6FizcehkXh1 zy+RX%-RSR-h}g-9;RfKbi-@0+)E-MjV>#C=T5X0&L2=|Gdyy7(TMr*``v8}4P8CX! zKs6z}tHqWJWx2Z4C9`aG;SPmCeNd*cm!!8hu*ZQlkzpz%J=sdCPCJlsGOimdouN9D*Wb=w(9 zJHGk`Y7aQhIb~Rf$T*F-vI`|j#4hTR&-BWejO2J*=BfN%lN)D_# zrmBx(_WRUQ*4yHYrP$A8(jOK#$xmPlCTaTHrv931u$;8f6BBQMXH7w$o2mMVcd1kO zX=jMli%+cRkGY1K64pOf4`9XA&wB_Fm+sN*3DXs~jeWstDtI3_m%anMDy)B@JZWH$ z`COs#cw$5!!TS9JEZC1M77Rsj`X;8x@m)7rAmQ^KvH|M>wH-U1bHR8Rn;437E5SS$ z)*oZjtJG~a8y@!ULkrob?%2}vRrTfSTN|RF>%#he<}p1n_VYWy_v@WA9~Vu31Kw|P z{DTTkS1mm#y5na`w^p|e$r0HtScIOEv&Qtf0_9$gQ>EgBkV79rKal@C+O-;q=bRF8 z=q&m0hA7;h!g?j(`AFTCnSeY-sUGK45in{nd>2irUs`W%Afhc?t*41O+BYn z6Q?UWO~*m523W@#nF5LHZQD#i@Ut^X>)A8yMBKD9-W#q($8JSDkVO3ERUDF%*v{Z@)900!#K!K0h#7>Z`_IW)P37 z_qVI<^!_{NgewC_R{ZyT&l2T(Z&Ux}dyx#-+~=2bB2Z?MCCd1h@4;6^Vx3>kiP)D4 z&~7E_oEYniD@2_W6;pA)oD=g}JxGQQv7Y3x?ic^^J=?&wc^yOcSwYsG->oy3_BBhS zD>@3WFTPwyof8;T;ovesy}`2jpbo{L&Iz5Q;4dour0YQVy)dWMmC@9exuFdoN3Yl% z9#gpD+xNKr9av!7O(xzBdx_Hz+>rjGd+$r~DHhxD`GLgz0P}krexF_SVi5&(P9y}M zeIosqn2+oJgR}M^3qOhX?$A3vl^$e)Iw$Vqz@j7R0e>b=gnPOs*Qa@zQ<+S63z!XT zFd0X+4BrDvhqc1Ro^+foQ{sx(vP*|R+Z=uoE>Is3rT*A3apj<85f%$AFOs})f#f{V z0ke<@9mH`@@-Q`ApHHU+sDqyrl6_%JuGh`Qej0&@WMZ#gy-KIdaFYmM=pW;?$;B{9 z5xLED6I_cooZpC$!;Lv_cuUp1Ou<$=ZIOg484(8z#YS)Oz$D{vUJKP(iV(3w9X6Nq zL7z?v+{xdPufrX`A7zp#!xyghA5-GIkGLOx_U=UqJv;@eU@X5Ydw*FYU^MbV zV2J&xBAUJ`fXdp*jIGgLrfrt%wk_QyZ)|>(&_&;ATxYA%BiJ0eT)awJ9NgR_)eU?k zVPB`R#?e)|^6V3LoFDqwg2HVm%k-kIp6BYEeAdHrzlnOwJzb5#$ZEr4K{0jJlKyyC zM!Kr_oS_EOs$LK@4W6jjJe?MBKXkTZv8!9MIUd!=&cr;`Z6A!J$|LL216(^-b-4H@ z*SGJuWTLitp;yeTYuCRZD5k|Om_F~K*SQ}EG_bc%cwV(WeSWSe5({YsN>`)LGo`Bg zw4Z@){TJG!I<{T>9Ewc~SUB^~X|8ATt5^?9rJ|P6doPH)WAPgDxIIF!V(syZ&|;x4r_5x_FvqS9>i2 zBC?5mUA`UXH%5hQ;k*lmpu25>Ydkk#HUCRP)mUOyoF6Q(!47 zk-8;fv&4p^KS~wR+qdKFZ-Ve)Cu=oP!}ewnu_QU8`GPryC#r_481y^sS zPxLV?s={y%v*t_uQ(cO#E&3@yb(FP6Z#$)wHK$KdQr09f{7^$4vk`Y;jT@#5rvG6{ zHWkxN{(e|RcJxavqvgj5 zUt=`}w2-9HgvEO+*8V{ZH5zaD?L6D}+YymWi@MoeVC6FwrYI!>^E@2~eT)4_b#(36 zN9ofeSB)YEc>NTCMiIMep1wFe$8^AKVgg%Xw^IL7ojzm zlv>F4OTE{uCQEIe#hG{Be8HMHcj05?)E-J7?Rzj&FXqbj%G}O3y8d0O7jpHS;vSi5 zVKERfHuil9_AhHDmVNFtha-T#I)nrm3VIFB0^=3_ve{eaB)Jjg$0>tV!5IOT*8bLt zfYeqQ-bM*j6`Q0|G@YHaol>)P_<&{K1jH8f>g|%i@CXMenXDftbFHc5Qk=cz_~UOtFWyNQ8$)3d&1HXi zc>`&JQzT+M@xv-W2|DIgJ~@yKX^v4-sX`9+(}CS2<)s$%1_Mgkoc*Q?z;9n5z*;fI zCkaJjE#{M_9zwA1s*$$l4~~ZJ>_&x71t;imuma=6>(mGX;)*a=v28vn#?ipVQh@@q znCC=WE?HiA&y46Sa2;%rWIX+e#n`b^m23G)wa5GMs~cSh?<>;hd%l9qC0!TYg{f4h zC+~J6T-qneAB$rm8gjtB=#x{37O(mc^Uwt8RuOX3#CYT6&ynXWqIP5UUQ>y?0a-`@*(8lVm2D zB*0AQ)dUb}Vo^P3Xmvs7L^jP(u+6hzizVLBJNQAnFco z8@3(Y>bCQa=X~$`ob%WBz`tZI7MWROuHSv%*Pdfdb87yn<;Cb4{6W!Q%loJoh?AQ) z|8p2*|8-JpKB3Y4Y)s+gRZ?3=Zxnnwy!oL^TGT=8+1oS6Zf6S;<%VfAphu}&QL%KgKS!CKnhR~d#x%os8FF0KIpU#PY^S$`+f?q+r@B)247|>(xjzh9F8`+8i z6M^v65@$$BjPAh8G{hl^#fC(1qf}^DFqbbYERq6gD)LQ3NA{!uH0Bo3rJ$9eV}Jl! zEG#M-p{AnbvhXQ;q@b2h@s3Oj6F$61Nv&)yEaO3;ysf&dlKMwN@E0MH+1`-;kyaA7vV$f*b|23JqZA`imLx4ard2~ z-7&U6umCwV0NAskPDqbDC^Y4Z*V+>DUK#_TLV|2Z_d-@N*x0;dHXADJDJ@r!XBC*_ ze3o#&>~Ij1?NRvDXHu|iy%|=tchaYbJx|wKQ{1#<&-8IHN=cj9Te3y~Y)_nT(JM4o z>~q1WPjTAaF7j^K>REv#H)7mUQJSYH+GSKSuUOcSsB5DBm5KVKfLh;!4FoF1;mzU1 zHA0d0loqI9Pu_;I%ffz|-gKT$!aVQ|m)d$0l&BA2VxS~g*Axd9@u{c6k-z4kZ1KKq zV?CK#OJuD5Xz^4VoLXeZXkqD{kd_uDBDW2S%Z>GHG*nR%or#uz3sOM8mP#ZetQ4r1uCYf zSWCcLU^l6Oiy~;B)~;EJGDN=2)bThHA1N}Xt@21WkugN`z!LSrqBwAcP>S)Ot_ml@ zpeeG`PUdfnRMU%Eq>GwJN>n<=TcgjR+2k27M19IAmFvF1g)8QOLi!P7c#$2tK*Jmc zsJm!--4jR;uUgx^>iEQgIpQkOV`vmV7$n1txJ-g+ol{EU`oMMqajK|xStwb2aNQs^ zW1o{DkJ5xn*P zuudFlA|XGSIFK$lWak0D+;ZRnzsxv>bX--h!z(I@0gLd4LSVDeq&q1Ot$E`+l~Aev zGm3nTz*Yv0BabT*O7p~f zKXd{ksPrm-pGgdKPgP%nV`&0L0=w3XLrP^ca{om6ctZ(VXxvFk7fRO)qn~5I1x@89 zZXH|;)~V{B5Z&=k3Q@kTbz_AQ;8H~;#vRw=4ZDGz1Z+w|>oE=Fa?&VB4G?)VVgb5M zRexE-CLEmI;*PJBSx#z_(un-F1o>f)&cYEdH2gqEA!?Bk#JL z$Iwu9M<%}+i6IH`25&!>s~9Mnpues)vQ;Dhv@nK?ur-244j-E@?zpQeN;f?g;(@$v zJ)oaPiGEpSEdoQ-H7oc>Tw~yisPr99wuu2!2{(uUlZZPsU%NMqp)z&H9s!niA6Tbs z*_YO~E(v)G=?heC2@%ZO_b~@?!uJGsxbgF>nH3Za^VoMeDqAf@yF$RZ9UkK(oV8}u`vMdLy6?d$+ z@8d;b8S0M3rI@E0_R6A8=McLBQtoTk1}-QR_n3`S!d`Y85&csgaYw)1<)g1qH+4q~ zdXeN#j*hIg5rOfFBeR_=9XUX&s$LFsM2I`aR0R6F#E^g|)E#U6F?~J~KEHqJFiD?b zMr_3;o1WH|023yNLEjML9qsK`$OHqnjseXPJN5_~m&u5q*P5to)OWv1myqB3A-VmO zXcOv(Y2-GCt0wNiL?pe}5gj>*nh2?HU!EnJ+UfL}?=FMAs``HZ0R0_p!jEp)c^#1e zZ(D&i=K-&;@Wj=5`&IS#_yH!M_CzIha=~Sz*B#YLY=MmSov|ZK&`3Fhg^D}6mCarL zz1kv@RNb+(1dCNR`s~~6DL${Q0?a2l)CoGx2vi|~$lLo$vq0Ls*H;!D2M1L3$7OQ; zH2TC)?}dQj-LE>%s<4QFh8XnH#y{!5Kj`1Mx5NM7tz|1Q3k~&c>tRF5Wv`dFtjpoB zNSH{QO;pm1Yi}Wa)VFRBXBdjky42}OdZ4PW6rZI?kcmhr?Z+K`cj&DfoOOrl^^#!| zJ;x9L7y2>hAH=xgj^jXsbIOPw2E=NCWp5&{90U%Ww%I3WOk}gRi#uFaj!3p)yVV`5 zOjr@h#=M9H#IoZ;dxljSVo|~`-OGj@X zNhQ+E9VoYgNQLd5B*NK0tV)!s#0OZIxFc7Aov%hE>JGId%Xn!+a^#$ig2wGQD_7j{ zkNJaxE3s5{#}h}Eqq1?{eHUjyXJIAXI);2)+;N#PXp@S$sylY@ub6PCUiXod-=~|h z5$_)(#(nTa3DvCg5t2rGn*}VWy`&|ig)ZbU75$W;e$ zSo(aLmauj*ovJip^@uzEZhy3N8J42%DC4ub`*%zeGba4x-?JYYaVT$Fnbz3_iQs1z)Et~cEA7kz>hNMbmEe;5BL+zyS=2nyB4Pgba^~&w_)RGw12i1EC%|lId5ozeW>bO z{K)x<`!8h{sw0> zF|_JTcj5lv1y$;1sl_agSyzNXi;hs3LHx`EjHdF2u`sv?F3 z#6P{qgIt+-UbH!c-$B7-xr7;Y&6@=+H4{*_({1w31J*E)1qWk_zG#Ekyqs*~{oVbb zOO8REhqrhx08IUehoqTa3mc|J{onrBp8!owmUoR6@I=r1ZD$Ml^Rzta76sIm9Nm+v$KdqA1hm zr1@AGc0L;8A#U(h2`w0@eS3_N)Tz(pXi02^+AH>36)#9{a4!j3<-+wgSkQSVi6|*P z0sRYJi}&$LT87bJ=eC*sh(uAwG>_0c1!L|<2Ua9(3MPk`OYDRUM8Zj;PR~uLpY<-`Go= z<;@Ov4;j%beuQdAxyP*cbhS=?`qEH@Q+l7RvIar~_q4m+r>A_J?`?8*D}E**RHgCz z@^6-9hb_J9C3=%r*Y33tkLS_&eXmxLa&)_S zfK79Zk90Ob4)U@OPKtUl+ZJsXOf96wkal^}=tW z2roy9kILZNW^uQkdX7Qh`LWR0yajAb3;*g1ku+X9DSNCpv{fyBwxMw%3wbfZ-4Ns*?g{K`N3bvg5029oLAizQxr zyqEIp?*(L@cX}b2>5#%fv`w>u`^RiF0;&IWL1By-?2G9iL=sV~|66(Zrj?l3ByF<;GUeS2DUGxoZY0I5b0ls8Y z-Y#}h_p(BiQ#I|uvt71IP%!D|!jZU4T@x*aP0UNJK3$xqYjDa!>+>xU0ChO5XXvf8 z5JD&0Y1ErSFB3s=pz%hv_q@FUEYRP@oEN%k_DrGGAzqWZbzE?AKtmqGO5L8Y*VH!5Vav;Gi`09u9LJFcnkdn{RNy?P;66n$zQc^a>;(O|+q z1_Sbcc{2^%^!^4k$wX8iny;M%K6aow=Nx{{;WUKHwF00 zLL>xg3Wgt|kSBdA4asjffa8ox~K+@vPx7Kgou-%sTc&|)J>fe+e=l@a)mY3v7ErtcmOr_3pKGTFf!EOE1&J;8DI+_|d6Y&F7Qz0AXp| zcWax;&!CpUBIT2=Zc8{L5Mm^-{vpsaZvA6RD95{Z_ z?va(sq^R=h7t_uGtRQu+V6{7wtogXkRt%mz^6l0nYzQyXFKL?|t~cL&{+e=ReC>rU#-V8mjBTp9HV^t&FU+65-}KMt%l?tc z1Lm^%C+TjQe@3C4b*jRblt7=qHRU(HKbt@KMYZSb2l?_JFQX>Ej@&r=>BNm6Z?{f< zE9w+Ze!jf?=l`9Zd$;Z=4cx736BJ_VUm9ELl7q&yVi`f;C{oTytZT zxH_~~f6l+JV9t@nQ)KW~ydd6|x{OWfKVbpjR0Yit76wnw zMYf>iedSY}xO6F7CwDIRN{A!_o8}~do+x#nx(LzG(!`V;xv80eS_v>Kj2O9GYU7D* zhFtO%oZ9f2l&S)cHk-O@pgx&a#S85cl(hKMHhnfFiAQVbq0;#9=`drGiZ;NfSIpHG zvf=EWZF(|FjshSiZtFGV=%a>yT;wWWyTVATK}9({CtHsV#HwfwQedMP>U^$8<|EzW zVqLD*VNKNBaPo2i{4tXnC0O*EgmOfv^Yb%fu_|m)B6*&Y_VERk$6dvJM`m)he%cZv z@xdNOlqhaNLngdRlVH^ewQ(60iQ3%?YM@mftq*h)P(M!6=*tzUX6}pH)p@yz^h<2& zf>G9N&F;&A;L`z?lajJX2sO~)`NDjo&irD5PQ`$BpNwQb$O_gl@>PzIifTDQj7*Vf zJh*{IO`v(3swrA1qr#|=u1Rz11Vw5<8`YAXxAhUym`Wg#TRg26#QB8{KGbC@^2LoH zG57aKNKX;(nc+;)k(F!X%pBDIca$y~1oBm7P7<(mq-ghC#3YHB7t6o>E8e-X(~2}p=Bb_lS{4l$^RUxQ^Dj?Z%lAK zg-oe0lgi65S67IPD`#zjjtVO(#qs*%5Nv5OMI3aqtV%RUrFqVEz>%vF@L02swMwg0 zKal4JYWjO$YlasMjf1;?QC8}bRvj+?kf<1H z*S9p(ki^1@buzIL2uIn2%TW`Y-Gr$rv44_ljA2J!(@72>7gikQlERPDqWow_CTrnX zH@Gi@Lo$&j2#e-CrqEi;`*91?KJcKTaY$UUTm&NQrp4+EZ4N12BOAVGO{iM+O%S6Y zG)~bt$WE#aBzLi!R`Xoal(lK9>dO*Jzp$cJDK~e9Hu}N+!<4K_5@(oHps7e#hrg-? z?g=ZJdHanqa-*WL4aYbtBn+t6)g^5L+O@~2v0vc_C7782t{J3yku8WP+^b(`?vbb| z07ji;K*{KC0@?{+>p)0L1!UluEuR|eM>}7#DHdpC@~A5!jbT1RTEX~e!m3p?mdYR^ z7}$p^)+C|EJkq*;)I+Q&5p+^Glm>Rw3e;%Lq^2UAyH7#*aWzd@zQ}<}Y(7s8`(uu{ zB2$IVAs{60D3Mx1N*ndup)C8Z{ZHj0V|YHh*`<$M$!`t=d@m^)_bam&;|jj4JK$9} zqYp+H)Lv_9>`;SsEqUJ%P{o!Pi{OjGiUICwBHR>dPdwzdGdsZ=T)}EV34w%`Gll57 z)wSvnPKH>&CTkHnUsF*l3oQbHE?%ujRchickBS5g#vw`-!oS^ z#xmkk&HTvN@4BIEx^z%0iO&joXWVi^CHi8MnP z@!L3cw6z?al;v&s(83ZU``UDf^bBRJoH>44D+WSdF{jW4jV9n?b+LIGX*Ksq&^J9B zF>xa>_bHCh(`ZqV^t)r|Qd}X|tb6+^q+P5Cjlx_taO@DRJ`fNZOS6RMW`U4NTlo`3 zy-gsru@yFQr%|F{y?4qtRf;oN7*8f=WU%}MN0XABXN$l*^-*mZ&3z0bYuZ^Fn1}>c z;tJ~rXMgv{aIr$D*fmGh@i>YFaEZ1hEjkMG0kouu&PFB6RE$J@C7SD~8?HFbJ@-|8 zo*@Ez#EL^zvD-9?GgVjwpIU>^v#zi<3oG{W^K6Ex>?>yt-q9$(Bl?r${fso??QJ$1 zCdaXxX#D*GO@-_O>+=xMV9Ijj$#0fo`a(K|0hKGMjktM^*9GcPTPt*UIDFWhb zNhNjyS48uNo1=dffV-5KHNOwz7G9Z@!y6+9O=Bu_P-b%pYPgGty3>$TO$3?DN*+p> zb1}+~Zk`5HOcA4Zw`lKZNEBrL4tg_mM!64+cLWa#`n5m?W$UT8-%sj|Lx?HOTtr&T zZbFp(v*QqU)gakJu~(3q{O!8IIBA_&aeNC#<_==~JJZ5|jg`mEok^76F9x2uX%Gk_ z{$$QLCClydtOHk~D(O#N!<6|{W6m8~Ab^;{8gJr;x=QGn4=EZi@h+K&D4X6iPJcZ@ zSYEOp0v*+ujhY@CNqchDB7rBbGG?XV3V#_U+x4s8JJp2sn@k?ejf>=;9(80@;|kmF zqund9Xt81w(Z658ZfcNS$lE|VrD#NfYs^40Z zT+tZEow^rS{N*-=; zHie5FYRl`=L5WA&SDaz86GJt}7WQ#q$sb|7F~Hn3FX0;y)A9VY1#6M!xJxg1O4f7n z=WBDYS4)LFH_dJR1{~h{=HPWq4?U&DJ+;V)PMMEb1->peKjSVv`)cg%$RkWM2J`^; zxIIc88FKDn|HnuBgzP@N2yzHuz3q68l=dwux=5*deSOheqCC!vem?RX+$yA>7F@n& zsxbpEm+@{cOq@Gaq7g;CvWasyC;v1k% z0v{1GABC(hm!@kUJ)ns3WKn2E|4T4vvxII;BKAOG+%a1q7wai=^GF}y#G z1)Cj^N_J@{abuTRfucIYkdPLX?sR}?1FvZ3S#38&WFge`b>RS#Jlk3ZSZ9t7(^p{5 z(~1l>%Jge2d)ZJ)=BH6H&Ctz=RlZ{B3$tqdHC)S`oI&AZgG#?j9_%z^$I@AmCc~u6 zJCibf)T&+dShu5+==(ao)o0a`jlNEm>m8Go0hXh(Y3ok9#b3;Ky?R*!nH})M4%4@^ zmK`xm5|c`PbMYUBT%!jfeS8ASIDQvr=jDd3Ey|#j%oEx>c^a&G;qC(lnvgb@ZBmDg#Jrx*dHCA1)m}4K?up@}2$U?Op~Ly)Ec0 zNsx`v%5*dzJS$A&NzVzb?(8jK?c(gvNq4J5|WNcg4_Ts6wf)nQrZIO7+Q> zbO2POa@ubdMliX+P8y!<;4Cuo9w6y|$s?(vWQcF9D{dahk%&akW#Jb@S1$oB^vKb~ zjeCvf#^gm$l)yomIXZj2QkQ4=tm>DF@BX+11b6v7qO0;IQe*Ns`~zW5N)c4a-){M( z3NU*vKF<_xOVN9T8^j6w!}HRh%=7RPOOn%09{uhLe zH+IC=TjVIyCHa^#QAxJ9*&sbS&-ZWH^BnTh>)#9>|F&z_MT(=^l9@#A*JA})pxVLI>#KB z=sDeMx5019Mr#|U&M%Qz)Ab zcRiTdos8I&6cl3&ukpga^~x8QO7G=Q3W3VsLj2)$<+WWDb|Vzu=XdE&kaKeUX407} z)IKM4TYetLFFc9(Ad9ccj0<4?fNta#E+nv^)bZ8;BV*2_eg9VPt-`@+fu0L-f!=JlrbI$G5h7Egj@L ztaJf_QX^2|R=|1g9lIBImcZSo{;1R|&z|w-)`lgv59Pr);3g9>_3A)i#0W6%QJSO% zt!yA}Q?+@F$-p;ViOYzBO^Uu!yGl5*aX7H-tbq}p4(PDbItUuv80JE9<)-*{y zW}#lfC0!zm%vyS;x6-T*^(pymtXlp^DRXdSZ8$^$h~j3$yMu*Oy7yU?FPa6oEKOX- zXrg$IxHn+JfWJzt?=mjj$%8|48qQ}8>c3ZQ25LzZ@ikbd-;2V<}Q zzSt?>uA1;>AB~A6k@EO==lkRwsEJJtN!U6%*6%|ss}x3*a^S?J{=J>;Z!*?jgqKJ? z8`?CKGvAx8K0A&Vzu6@=@#1JpLURrC(8sp`v#}(b&+e-n$tGTvOx|a_YzH}~km@!> zIVY5ip%b%H+!OXrzRZM!wbrbS!jYA`GYsRouK8ugx0dbx{Nm-dgaxTw4-MvEKLRrt<6W2d>Z2j?yuXAeT^0H4|RX<)Q zE6&7UAOEVC{Qr1UjPsJw0C>A%~nx13Ja5!l(^Zt zvnIL78MJ6XL;C)1o4bshA}m}XhNj$QnG2zZXc5s<)U(O{$|nV&JDkF)Gugy?Fr`4u zw3cdJk&$c@O5kzuHyNCn2`yD5YQ+FC>THyT>=2NZ1K9E7mU^;+sevSl3gWACti+@j zVVIqg{uiDzEMSDGOB~-(`ZdtWo0N233E@I97s8Zg(T9W`MBXG;Kx@Tweij0Ca}o~x znjWLs-kRnmgb*aLc5F} zr_5bWSQ>?rQUE4Xtyi(dWMA@(Q5IK8TCE_{V@S(KehD9q29YfdTt5XcWt5h(SfDR} zONEj+RnbWj5U-Jh15n8GWJ@({6hQ|*&kayer$kWo=D9&^y3rSE=bbsTHP94aYT?8x zV-?lN4y?s9@LVuSBcTi?5#CXw2xj+8khKWLP;lCpm1`8$WD}Mi01fsYyw*oKI4P9W z;8o`DXtzfh?epBFGWm-l)K*nXsio}5qFrsF6e+4*xU2;LJf#;nk!hk2z|ZZ#^|C`| ziaR}X}kq=Lx5WA-ABxa(5(kn<`k_=tN^ertgfx9l?)p2|* zTqfVHXgl+@jWG=E$RZo0!DGhsb%+JSO`OGU%i+*22_>vZ)~zos^G(K%?wA%Vz?UXHuyS0%C50Q*g7l&ipRfp#Bu_&3`XsG?ieY8j;pKr%~ z?^|Ql2T3d79E^+#C3VE9Zww561Oe z>jzS9e?i<|p%F5<0>|d~W4kr-%R}f~C3S3qd{ER(%#^Rb!U|M1fA3*T@n>yN9ZD2p z=>#FePdxO)QZ(|7Ld=a_6O+vHQqfxc;%B9S0!?e1vcnQ2*WlHQQN3K5Jmn<1@fGT; zk&DFGBD^WU)W(HfZtAN`0vJZd+VLvZI=nhlAg88LvLYG!*eR_vP@<75A~1m9=dM|#=u$GkNN4++`SFe`3<7BzvJ1|C3ou*>B3&>NF&d%tg zv%~;ah1sbNEvUj;WOB#2qpx3~xJJ&DVUFyY>1tMv82IR~Yr*TBPHYY0)dZKpW%3gm zg47YD5p2`QEBvr0LqLJ@jQKFxcF4>hum0GIt(M96@G zBgaf@fvifv@ZBY5A8kkSF+&b@ECSAbwJPNsa1cGKH;iO`rKBBH>Pd*JuhDdpL)vx) z*zS;)s5+#77+WZlOO-7&&Sgd|Sg4V=WnnYfHPqL+Ed<<9VPP_Ptq`j`joNGEyRKjx z@uvIFS6%uBNQI{v;|u~DFHO0u)d}smPc;ubGZsOc@TNYADkA7g)>rEB-vG2kAbf^ftEhf5zz8p zT_ZpHy6^p9SN$#U3|>8h@T|(@omYo$Mq|r0@~vE!%cP>Z2skCx^r!ObRQg06;i5ym%OfFGi=GV}D8o9MO%T+ayBAiWC9r}C_ zlgQ+$!aIwXV)HffeK-qd*A!bHIE7cs7qCDLH7mL4)$i90V<VIkfk7b$f$;1;)*` zr6(9^+cm6ACXdFI5;GWm6swf7-f zrjge>W^Xwue_#DTe_SpsDg6on<#h0D_KhR&T5c}fHc#AU>HLQlb{Nx~yiwkc0%PCK zJp3guPA)NKGr!zde_x4Bnt)y0+)JA~QDwj3s=J05Y~heSivj+j_1I`T=4J|#nb|=M z@N3&c$L)SQvhJu1PoP|y^|)Faqh&I(1wii-i^m3my_ja;+Genw0kRg||7{(CichJg zQFE22m#_NM2t!1~Rz~7BL*cDO9lA>qb$-AzfwsQSqUVbOpKn^Fn|a;&e~G{@0A!f* zzeZrF(f@N(I2>+vuOpPKmUbCZ5$P8Kv%8J5`DEV)&O(%}I!|QdmWL7h2EVd#3y!}B zuH0>bTU~K8S0Wo%+CJ@9HcmtdX|wE1&*(gsd+$uq%1YT#jFXSV>$oN1rg$0!rjc)H zA?Jtg_Lq59z4mv_eRkR0U$29G@?%K9{6@N-;$!ZsF%HPn$D-o*w#TP2ZumqBCwAw| zsvDu_ebhWRAoH%3Zku)4Bq|2~V;Oeot$5J6V{$Y(=hT;c{bss?XMW6E>*|bylPCM; z4YGq5c9&NNdTrWgs^->s>h+*XBwZ3r3L$01S9^P=Tw1-v&zS4AjBFq84w#X6L0Q2u z&4HQvsS16Ncz(?T9pUmA4_ko|QKvJO)a1U6$-5A`aDN7=6i)o7&O=Y29H$pHdY5U< zU6vTgjDp-*@Didco9$_tAwtYf;y0m}jhR@n_8NiZE=#SxI&F)!y()NSGS4bx)$Y&g z#j{gf0bfgCR93|>9;n@B*-M1lOeLc#Ac|wpqPr=K$TF=!1*y=?x&@O4V6dd9n{GYmxBeJWOs>XotUTMZ**tBz^kX?cK}OwA%A(;q_}!53GPaSFPS@ZuPHaL1{BI=GbtBBT{9Y*z?A+J?4a59D#=BX z82H=6z%&Cyqdtz+)Of76P6S3{tw|w4VcXI@98$_COT>PW9~U2pjdWT(n;|~xvDENG zU#N9TynQQLbB5Puc73uk5DwOFe50g?0IuNFgl>n@voBKv6=sj4Fj^Etiv0*wME!XpUnpY~J;`Ct_J;GK9Y=w2{Dg5kLaTfWD47y4`I3>Nc@_DwutE@ky zVGW#V?C+z7Km#E%x)q^03UMHwonu@P0lbVq{mOV((kH8;(4HKR+>89B<+=#$rtX_Y zt1Yl@5yFI!@<=zDZtuaUO(bV~S@#R8xqw<3=mrQz#J2MnKcQ1YuIWjwfXYD;e*a(y!$9F37R1U}! zl6XOWv^OPYfzK_szl}?!fKQMaQ3_S}kq+1HUEQlhM-wiV?sNifxB8?dM~ItR3V>@Z zKCjnOFBa^_sTKi!UG{NGV&K8DcSYFZh6}zftRS{^^DdWHYlMzGT`Q6}hq?!yJU?`r zC7Nb7_<$A z-RTROa|0@$*Ssoy%J0uKozs=L#>{xFbD=8Wh3$$x`73?ye}8eiCRhk${y8w`!n|d3 z%Kk1TTI7Xi{VIaG2IR(r&ll~JPCZ?z=%iFM`qaMnH-L1l3;++E7-8g#Noh#^oqYx_ zy5Dpe1J{A>(wYEhByZWwt4)flcAn`};Hd{hA&K1$e4^|)>|=m=Pux{0UAXZ4r&eAX zxYV=`?4IkRi?Gk_~Axadd$C@@-|V37xL{Hmf}7X?A?pj&za9cOjix)WyEL z2~7U}c}Q0S+m8T|mC>ILzkRq2eP*vb`SVF+fRE??d)okepR!4|slbCAD|$NR_c&v^ z*w$BbKW6+O(Bk}J!xBeju4A6?THv!B;}r1-<24)mr}=Nn2*%X^j@S%!J7+8slSvn> z$$l!4E}AVg2RO9mcA;S;i1=338KsAJ`P%}2{ymamVh9;{m;rzGfI?y zt|MO}J8MYYE4aCHZp8x!WYBSWUx{tcRvWYM3%W9`d{FSo&QD62b(J%bD6T_tex8V3 z%7>isQ-}xqeHcYWix&s+40WJ$w4d_AeSu;HEvaW#AJ zaOon_eyqB{N-TV$0s*b$n5S-Jvi>5Y&g|iBiC!z*8J76BZ8Omvc)#b(&1d_zGgfY% z&4?YwZiwsWJMVV$=bsy2d$c9+VQR<{*&SWw`Rxhl8iR0fWa+&^X1MbaRT*FFmiC$d zL|_Tazuh1E`9BD3yY&p({~$2KpAKl>_h;u8WEj2>{%1DMT=}nToLcAHzp`=Zh1W?m zpZ`#@{`TdUl6B7?-*_8_dk`` ze@)(*&?xC#zfT)Uf$Ahy7`Q}1tszK`0R9G1=rSNgMz%y#B7(q~D(YQj&H`KJw|v;y zC~t)V%Bi1Y06?4g%mo9v-3m&YV#|C1)!isB1%SN7nW|2ZXt`cf z#D?0SO+J%i(js+X7#k+5iuU(`D*;+JUZ{i8dEBD6p`e$FQm-!Dtc09-!mJo_h?rK2 z7v2>Efx`T$)AV63w0$lqSOKrTY~aeHYVjEvH0?ctsoFl^Y#BLOT}W3`rn6V$edO(2 zT81qsXH!G@B6k#8soc2*2js!JTmhWP+f^YW^>&GDG-Ma$&J8js{W9x!0G9K1olsMU z?ux3&dD}+_!Bc{fJCLqqWF#_j1yI*B5j;*Z7gMkLz=6DoO&cg{6gjJUXor+AYmf!v zrS0zEnM9T?AL4LH+ry!myj*t8X-AmrYre}=gB?@;qO2Bji9;p~F$hMX$8bbjDsSC_IK{A@Z{RKI+C}5!~f0dNE zUJR>Vu%_S$?+b820qvi;JFFL|SA@)p0oN84B!yFnmbZ>F_nMkMUsmEMp`KD5Xa~X~ z9)t1Rav8TQ;1d|6Ds+}o>F?-uJj%|`dKP?YsvR;%R(3;_%jcFS;Znym&__&OBuIkO zNV0GiA(TulMk3K!UNT_AJkmz8zQq^f&>uRZIuN7W-&O}wx#fH@H2br|Y!pl#L|lmA zjEuBl6pBk+Jx_+Djsgo~SG%jp8z#R%GmfPg3y{<=z#d`cvm6lOmWSI_t|O7xsSaFF zL##Q#YHoQ7cb_l^t`SN*e6c_vyp~vr$ibXc@P-zWxonOFk5V&1ElwbRkul>{2OZqO zr9!A2-BV^sZroDFQ64h(05W-1YZZ3H@-+%DTK>_S%BB)LGfS9Hl2wHbRxwwAR%|3S zf-)XXl$h#J8M)S(>{Oc(`A;p`lf!0HX7 z@aViLcFJnr1Ux^2pfZS)8|-<{z(_!LJqeingQjo~{V~tlRCp+L3vmX7=Bew>Ya+S- zkX-#>$H~7bnlF^j3L_6*4Kb8vhq5^QhG@1~q@p1JWi3?HgiVma4HjuiB(;}fT8vE* zQk_mx3G%fGC6)AI?wUh2FHsj>{Z>V5=ml9=3km02SfFst7V5bc9b&f2sgl@aKr@J< zFAuVIhY0)_CJWugt6z<_P~1T`ZXH8S$sICSD?|u0&;@&zy}GVKnX0U#-HbehNS0V! zBHxRoY{?`}VIbE^*vg$`#Vx0c#dU$;cA$Z0*FYpbz5UwtVt|`pVJ(bAa6S&PXeVfQ z@WXhD2KZzdM|N-~yU|HRZs#{9@~m>zbup?K0lPhg*J)VQ$($Ug+2 zpJUYtCdm%Miql$we|GzKe{9xOG*r-jK%6-b=$f7euHtsM29jVk%Ujijs%gdLl$))L z1hdNw!p(9-(Ej1%QCB~zmlNR1nr0*##7NyI+o?D?vW`lGP}K)+kEUGYOJK*qn|NyhxjGU zZegTib)CPUA9kkSjHDT+T}1+^_exF}^__C1!%w65;p)@AJFzZy`%=ZV55Hq~`T@mB zY_@{xdLL@N40+QPrtiR;t+Xv!=mWxE%cI;&RuCB8|0{WEA%z}5GY^Drex(^Xt7y)o zd-pGy4Z|i^2fwWWCcl81f-+OsCWdq{+k{ol2c>Hd7 zW5%Mg+YiVv-Yzsj&>qBQ{jR+7sf<#guG__BZDO~3uN=;J(|L~$|DVR*JF1DjQQMx$ z%p{Wpa3&B4O%s}Q1AHi~FKR0K;x2LS^%ni!C#w4jJIHDE(gcd#P1 zVDCG)_qIQqZ}xM}dCz&j^{w@t|5;&?%$hLw@4m0gw2UmIDI831+}U`Q5)0p&0RHyF z@^@iIqIM1T{YRe>=Lz&>pjD=7u!qA)V7J%F@z!0KwW!?{u=!F0MA$n0_)xEUgx9g# zEjNx=F2#dI?Xm@oKzYL%{kaQ7bbH#E>2w!q6T2PqA03N!xNksCjKkWJ`SzH=ZT*DR)h!38+P?u+Y4Gc*hi^T@GS$BFjP=dsP~H+_-P20 zK$ZT>D3treXbQbWJNNCeN~?=_Pl50|o4UhE2iWaziyzFY#mz+R1(;G7MQ&ZARrtTq znj+o*ahY~}#+Y0Cs+lkKW823KUJ(i1=XXceoqMD&fxq`ZKQ|lFwqZDyVuquD{}YMB zL%{9lE0Yw!wNRuC8nQx|90;utalXXAC=sC2#DFkM|Wey5O-o!QjDE9b9hgQJ+Gl?F?gf6HSyY^ z_MMgktb6@g-+bsGc41)C2TcihP)LmyzQintNki``5@@{R)kMduIa421PZ-Y@eXRk-m>XTu*t)er zv(p3%*j738uILyCNrFMs-(UPT+bUF3%n?188Vr6x>HVjDFM)!JWe>*+?i+M$$!{|f zUi#2(v^afh-9WE5ZH3>4MK>J&63=872b_O1bmBv4l>>RoKdyg!k)iFJ>E^RRh2g5F zw`X~6`s?lG54!VPN;_6tlkb_f65Y8YeV2hpm1`xLf7yBQ$-qYO1K&wR!Qb;@-P$L> z>;qmy02~^j^3VfR9HD8L)JS6aJ--ZDt;vh?F~L;Nwi;TjmE;4;`M0PmroPq|>Q$-^ z0_F!*CLr%}3x*n|w}88i6NGoO-M!Os#%jLREyS;v1?~4X@B&NprDpYE^A1_ATVk0J zHsL#~cYKH(?U)0WBo*bX0SvqfYnRxhFM<8|8v48%P0pLx${Q;iW4+a`UOD0C3L8Mo z-kKlpsS_`Ik!4h(57aZAtv3{H-R73F*RL)8EuX1G<9xQin!akePbWpioMDPjnqb_H zer3(f6GdhFLEDV77WzUrUcb!+1A~K@jeKV(pq#)m@pbR>Fs~8aLA8eeOjrvGbfb zSru1dM131qhPs>C0ReH%_5-Z5?&3zH*yArR=$qa-k*~8k$KyOZ#w#nFpLWLrVyK*4`|${8SHIFO)xHb$e@&%$_m0Wl7$>kuln=RiRG&ndyJ=w;W)||GKSPIqJ}? zch(3zOodQ^Ii+xizPyPTD<2MC z6MFB#wWF2FNN8MIb_xDxvk_VlSaoU-d*w6C9{2Gor17DQ!a&h+S5F#!HqWMW~; z(~z4pjNb5elhd`AaxpYS_4^Eq2*1ZOeef3_J2sCR`i7r-wmAEd?FO46o(4M}FgqYM zixUBVmzHJg-16JGS5os`D{Ik@Ff+jO!~?(S1tJ)o8JpomB{`|TmV{d_GYwiJ^8j29 z@A)Wa*Gv^Jv3ObExv~It6sxeEU$tF?(4I?*gSk!z*`US6Y_~rT^HI-X`A`XP+|iHR z2Jnr&F59hVlmC9khxXt3m`#49#bdDRZ{O>R?rxlzvfhJjA*)Bd? zwZOxB$?D-eK%s$M&$?~)ha_1wgJsPweE%pN8;@t?kmThaJ#&Faf*ewxDetWBGalN* zOE}ucW7+mD3|*`CpWjem)owX`GY8!O$dXX4ygR>@v`BLo#5YAWurGY%n;@p(Q9JtZ z%$KYd5>t9)XYJgeESW6n6P=2eR9w3X3t8Xh<{JCz+s)h7!TIt1)h*HsDXdj@tipsu zzfw_))TCryF9`RUht_HX;zpe=1ub0=oMjmsUU$)#9I3n#u{$4lq=>N{!OCl?A&$JX zGR&1B4!AaFzXORkd%=Ukhx{^cTJANnoAOc}O>)S^SxY}ovUn39F@3mE6ZuqerDTD2 zR#3(I!%{_YQdV6^M`DQ?{R)mR5bU;732W>HaKPg}~P9cSd|)ZqI4jW(BnPCa|RM%$LTBtApyqnqv8~VnTmwTRaqA z*GLkoHXE7^j3A#4m^wQ9IUyg9%^12TiBT;#N>mzokfcap|6d{lW$tX|)RPMYhyu$2 zxBq?@Ik#yaYrUXz5z)K(zGSkNWRK3Mk}Te+;BPs!wRyd^t&~3a%)})AAZo)cG6frJ?zAc)9u-$H~%}cF?*}4rfKzMWLLbvBsx6o|?!EYa4FYcTS(d^^VBBn|`-Ps0bdb8ffGxAfh;c$|RXsII`n7g3v zHXGoRA5*DF9=FNd1vn|J(Fmj)9( zI9xskFe=3F3D!R4=|^9RT&Wr2F4h)RfW1$%NakL3j~p=%UbJJj-=D*eN7DI>SH=ez z3LfR<&0)$~+Wr}eSp83*@;IbG_2G0G#nE!U_5ty088)fWJd$@jA(mqC;mk9Q={m(g zkKmK3T5)v^K4g3Vq=p>L1_i$Ju37bGv3TJ9<;pBTMXh3f;~C%LI=ih10@%Mt%^K17 z&=T1pbEV=3N2kOFkGS-Z);Oy3ZnxIFUoV#hfT)S6Kgl1XL@O`cxvjhex-FZer#+ze ze(|8!oV3<#7}8s}=ucbAF=XCTI+XFMn?MvHrZo!|>RKu|yJO#Z?ekEWjXQO0QNO)% z#rIB!%auA;jwIJrJF=k?eDLO|L>;S zo%N5lW2X2k ze~+?$4>bNyOb343#c+`@BG8B1C9L>e^XARhBZ5)2gw4wOXTnO5>*BM<4*$2A;{P>a zomT;Gz=HEl&FY*t>+k@fm@-2RWUz&PVgQv=B8Bk1d*)0Ld{|2LNzXmP7G^1kk{u;b z3+x^EjaDg0278amN@+vW$c;CQWffT5zWi8Yk zB^)t-Gm{NC@}L@DvOo>CTI$bM!DX06SpjVyk*Y+bFa=N$MyY8buV%xB=`22%vYAaS ztDxq}kgdI2bi|Mkmr@gfEaw7gvvpl$E8TuE;(AT z-?oeN+C@LcQ5)bwhybanpezP*&u)YF3v)B$HQU5w)3*#afF5<)nkOxD?gAGQv1mfU z77P)NW4df;os9TX^FrBWhOuBZkwwWgez0cG6H$mzc9pz~8T++dnz}0Vz_7=#A4bdjS z^XdO+LQCIL2M;(ni-ChHk;pIFOleIpA;O%NQcL~NO_ehZ6{Lf%q)6;Q4g=OuRiw-F z^7q5xf=Z68Vv8x1t*Qv%t=qDO6fLM+B+ev}50_FwYoHe00xlI)W?0qiy<3s$ik5qe zX7MU__(2twj!|Mt>07jQ8y=~I+Bqbyd{IZ`VY%ykdognUDtRjl?p0BjaZB1Sz$I#A zlOMQPShHg#aOjQA3K@F$0g2$;@A`*gM?30D+!X#vNY`>pOk_uP6atsMEtyf^HX=JtOete4O~M5CIGRZP4yS~b7H;5B)sw=wm{T5hkN{oidcg-CbOHX z{bg1}vZq9;{Z#QoELzayBg7-wodv4Jw(>JpXMnE;)tVeQ@miB<3Mh}PT&nK0n*vW; zAHFMtEF9pB2_RgAo3cB@R9I!}DJ?$o)qtWm*-Qt>$E_K@3i81RNSYy&BW}Ym@{@kt z95}9tk@xKd+t{78)mUHaX^koHs~gpAdONsLbbRJ}SB|=hZpfS|>PW4mhRHezBUNA( zdf_2v%fu z7)y-W z^s`$Zb8=Lp4F1{=nB0RBs}O}A)RjpgVzTXCu`Mh>vOOdq3s&Zwh59?^{@f6X8hhcYzTB}Tt>xa{)Ajkp=RbB(&=S>WxP zImlOQ0@4O>27oT-%|3#r)uT9v-AR|??6cT0QK$1A+)>sPUL&+n(E}$a>ed-lcBepb zFY+|U>$`pv&R9s|6?ao) z-Lw?c{Et&%fnpV0p>@gLtJo4M4XBs&CwFrq=ij!qcwf>foN+3^u9|n=nL;`CHeAZw>TJKxV;e*wHV`erBqmH{X2JIP7ssYvB?5~Cd*W$$w^@qIHh7o6>65`2G_UyM#c)yvhL#E6uQYyt%$=Hxk5z(pABXQty%IP z5nqsw0x9?N+=Q`ZWaPMTXgvV1p?~DF9jbm4BI45;_zxP!*1`qw}&?h_etH?t1{BNlgf*Y z>16%jlVYHAOa4Wpe$&^*CcRB}m;Q=NP@Kw7ib`gsOuc5VnLT4imd&O&~8yb&eszRTQZFS}-=V%Ea5xP-?F7eiM{-6!;$N z{0#7}=#`S+gudaGnBb^_{HN9SGEG`}+7QLmq^_=%veIFBu2SkXbLH|#oi&~_Q@7sr zbuJ#yUMaBe_RG~)@PqdPgT1l4Sbd7HQqvTX>7I3tfBNK(S2&bQy%M|4J-tOQhkf<` zSpKkSz6{Z4=N67nm(8o zt=W9c{!dyJ3j3{)S$6pc)sc)0%IWiiS0s7r>4em|%*qLU+c@KU(T9vpn{p(WDUEl( zi#~-d&wZHIk5}Jaalp!>gC_R$WxKeDv6rc>m}Yz)UXBc$OK9-qi(c}5O-g%ZgF0`^ z>vfF7q_Lf%;LxFrlSApIM_~7cp*f3Ip9uo$IF zox|W9&wtUxuf1cnxa+U!9v8nNnayc?d4(FrBZcD|*1j&7|3@Hhy_2E9ujn0Fg#l3x z{XzAn_f&r_vET)B=FfG9=T^RAln8(*XS+MiP|lCPmRMB;$Idpi3cS}dHgEd6GB~QP zq^!;KmfQR#+hl&;8$z2ue)Wi;vq?|ebu(SO^|&Fiwo~iUz9{$EtR;mT#ixBN@Dj{? zLUZrU@v>z)-tM}v=XkC3E(x>;I(1LGn+)17bqj&CxQYS4wX&67-l1jA10KEakAdON z<2MBgNMn$JkTBJ@fbA~qb7l_Rcl?T0r!PCC(pa*%A4raE7%GvU?B;90FQ$L_`SMK9 zLFyJL08o_B)_D@{r{bUmy8YR2KBa3}t=um|K=;d0?op zVaJMC;RB6eR;SQxgS-1xDS; z%?D*{s$O8$m)&t$p}&4-?H`}zvn+@sOjDT*c0IXUYKi^!;-yJcrqa(+46&osP-H{l ztd6P0s8nGFig>xZV_ou$d_vc36!K}etBM4`rc|3kBtyh!1611;*rn;-GV*@kp-tLK z#NcZE(8IEzSv?LbmZ0;+%=Ir|yIvLc!;%S%JlmrsLSR=0*=g&LN$iJetI;>cE36yf z`5v-U{oDB_fTMLh`|`BZ0X9!wvt>cc$gHgouva>_IL(H<2 zo(jmSP1 z{isgeDblmT8H|1L&7J_Sm}cyGKx4=3t!>Y_`|FB>U0OIs7mfjLMZfD1ibQ>RP~Bp| z3zDHi;b)fx&KT#V({V{fQ9;>~d7sN3_+>f#-Ap5V23qkXB&EWoUXlnO)GLf?#=*BxE?%;~w84O|%T!ez3VlC7g0e&cQ99s2>hZ?%Q6 zs3^5mY>Frv=0>f_F?0^)@RhT{4B}&)j#eSSR`t-d!!R^a+7M=im#JyJ{04KqGVwvA_F&;Lb5S12J>Y!1mp zkeE@Imb>!Ta&51X5u+ZJfu{JNi7tz0T3CIO!+yT!qKo1sHY^E!wY-zAJ3@{EN5j_k z#4#g2Ac#0RD;GHVOgoop(k9_Val7$FC{-|ZKIz+JYS&qIa!9e19{7wk9e@a^^TvLG zAjOz`jjB%2HkM}hE%nt8JofGBQGs}bv?JHDh>e`Ql>-$4b97}JO}^a|Q2%iDLjslA zmKw778B%do^<7E8A zZu>b4RC|8IypE}VsOHC5pbkn!jc4D$lmmYF{5|3u@LUUnR5H=i<3_L6*wd z`N>(JVH=-Zqm?$J=9ZK;qQK6wPs6Day7QawW})A5tGw4iI=3&9Tzp9#TW-Ld8ukcu zT!G8g)Yv)0+QhR*z((1v4-0l!=(k9ishV$0cPgN`HWYlLpvOLp!@>)!OYBa8j5P6M z@;3=1XKT5$cv5Hhkiza4kiF|XlEedgb<-8`hZHJ}bl-FfVh5Q|L68MpNI~W0+^@}{ z?4D+M4KL^9`uNrezCMFjq)Yw2Ps}*?CYj0NdsKODy0?M#E;K$O<;x{yLKECkH2%JkMGn zt=AdWMNCfAe911}0R#!pXyplWxO>S+XaNB-xKZr;K{b5%`KhDG;%qi+^?XB10HQ5@ z7d*&;;pI~ZxBYF@hFHKt)}L+*Anq*B@h0-8tmK8s%Y0(*kKZfn{3+B7erhs|zfL>d zd3JO1dD%mTi)Rj^{IvZ_<`8j#FiZwPvz&9k{|`#te>L&`i&A(0x4#b-|0ku6XyQGb z{(JTINMyZY`tP6rL8)VjJ&2(KA)iH|8UHt>?*17eO8~Gc{)c?lMes(5+kMg=4T1XFIG6{%b>Jr0e$zk+;n1DrcT)MoD(Q4$WI4C zWr&GrM_(Q2sz%RXg_wZ8PmUb2$Z!_Hq4GkClu~ zsX*i`QVSmN!a006OoXDY% z(nMnK7_QQ2n*K&9!ziY=%PAn>0U*;6fS)wiuq(G%m0R|W5-S3(cr%QIr5E|2dK@<( zK717{A0gR^^H^PZ)+%ySi^f47vh5{fv68mQ+1if>{m9$oFM}fc3b(P~k#%6E3)+Mh z#PP~)38f|xS*fTRm9zN*1Qi3f;SO|pj_S;Dc6M(&;ymCF+;h2J=Rf-auDfo!0a+i38AdAp)RIL?M@KQi$HPq>w zm&N0!a#4|LZ;*sIm#N&OBnPiF(gEOt%8I4DY8$+Gy#fi|kIf`9vuOutv#K(myOOyE ziWgMv;5i8R;Q2SYOfKbsir}gpK2{F83aY}Cq#q-87Cf}}IBF|$ohu_Zt^}(5xIO?{ z`xf20&DBUsZuEm}{nm)zR%&ud6FY46Fr={%2xQkXOo)+kr5HozT!9)YE1LU+%cW@V z_XE^1@P?zoJf*9igcPT$R$QG+=wg)*klP8QG9Jxcebg@K$aWRIm?TrIClSd&+XQk>nMIf+I1`q+$kCxiTLh`4THcTG0NmVHlr07h-F$N%yZQv_x zqKV*D5c2B*=>N_D6jEy|(X1AHE|(lL-S2WN7Fh2_Ds03d9=XwqtYbAZP$kQ{3g}hg z<^njT3b<;?)Waz2i-6W~Ogpmfs5OW1&l+Sc*4YbR7A!JxKw|u99z2{VEJ22KR0(J(4bzZ7=tFd(5Hb}uT{mEO zODRd#n>q+NK{VE!MWp}%c%-Nq3BcELX*J9)kd5#YCc^uOVpQ5Iv>a?;++YmUD!@9AVQUUbFrfbf~JL!p&f%;-7eJ zvpysGN25e|fF29(uq-Y@|DtZnG zWRs~;*RMGRqlVqgE~G3nx7xM6aAo`EA<_fu3aYw;po!HRfDO~v!VYrfi;H@FTYDd8 z$*ZyTdOYZO{|QSX0hve%o+PEJDhciKj}ut2x^c6tRjUk!9#UR54eq&sqCc~lL#^wp zT2`2JHTrc4%HWmO6`OhIEy|$nG=x=0h79wxVig)3GP?Ji&ScyAqFM`wQ%Mv1or1u% zvQwG>dEs8%Ox=!)po%pp^uGIQ<~jLZc?|pD2O|1q_nMS}OBF|X+!n%8kf*9VBIeq} zf;sBOoq`Jp4neD^Z_S74LEr|l+$aGz6a(x1p}tHkZB>;m`;6OUWdS$ZMgqjRkzWSj zPF(rL09-hTWr^hoU^J%{_;rbKXjN6`^iKwnp=1F4#6Fo5rROo#wA?M%);vQo8 zd~yC_>@?Bj^AQ8=FzvlN83af$#+q{53Wk2tq=DAe#Y?Nj@=k-`WxyGc5yMQ-btni- z1$uoyVDVgeh!A%bj1u`PTD?eS-M8W0Mdp6;_II=O`5=`mZ+MT5*7PtPAoM-u$2V}N zLDj?i&~UX4L12SMDJy>5u((b{)+E&CtR8rl#wFR;Y_n8QV*JrlZ5W3uCpBEZ{sCJi zmN(bnACsEGfa_LUpuYpI=yy>d_W@M%CbNr-W>Sn|NoP+0*^jmhM9rE~G|ULoUA%V?F|9M(TmL^(17}@RY$2>0{(|-3lNk5{|6F4s+$! z26w0*u@z!DWubk18<6-bHB5Bncrk9mmB*^@%!$6MNpvd3@>hwtiC`pua5GQTynX<; z<;vZPagSZNlUTm!4qmsh$;}|P^;ct>{5%wdLO<_^-Wbyz1JP0P&6A@v1$q5rOQryg zc}IJcX|~p`sbt}AT0taq063D2+p}+QcsC~b$o3Os#vBA{-KpPopBR3kd)tgb==Gq` zgGY<0x^4oJq0yn!CHI!^m47f|EX9uhtHzPwS;iz%8PS*xSFRy!e^UHNbCT3JtvO*N zAqx0oca3N6R-NB9io0^<9u0WUE<8~zmp{f$lAFwL%v~yKw&F3Yx$@ARk4kqDl5Ke+ zkKre}y}*>zs%~7ckU_MMq0x5g)Dztyu<`MzuH+6;{+~EP7!;AEC6m9o%ST^5dGrwr z5z8Mx!Z&PeN|9ea#wTwt9x@q1q0Fa%{RFcNK{Er)f}q?FQIBpB_f84{vcPTvPG`s5 zmf;p+xlk^n55YV8UkvPMJuDt4*FjJw)tEo27eqmSE?JQZ(75AQNC;y!PUFf6EroCs z?kJYW3mN8uCpZ}>;L3H6JU5PoZRqFy(uwPr#KB+dK21T;){aG1ayawN>}5EmTE6J` z3`6!w4e_hpPmFE$zJBWic-b%tHsOH-z@lF{Yexav&zEJ(2&_hrT>`jG*jr!P9sPb3 zrPKD=~{l7xN2~j7$$#8@#4CAMTIS=v>En;E&dI~+Ef%D}@U;_wy;i`s z<9YAS(zg7z4erWH%DXCCPEvc{4Ol;D=pb?)bLxIy*3#1BMkLzzM>@A`U+c+ae3Tx6 zcHUdM;5jup^h}OMTafepdzX%dM|p3(`}wj_GDXck>%IgD-4H!}f6yU4==V2Fve4(R zeBlR!IU-G9;n~~nzJcUp*HjC?7XzY8(SCX|;ZaW;Cl%ntI?zB0yZtTSz}rZ)b#=5i zDkQC^Sa)YbY(_9ct4VM$$44D&HY?nyG%X9@CMqXAG{h)X$DHYT*T(Yi?LlI&MsSPF zr74Dr>B($rK~Dv*+$ePN_VXOwFqXL3G>lCuo_8nz1>b>qyt6i=3bn$ZjTdW{Ft6W_ z^92>Y9@#PWp_BrxZ+wPb=*s%_MY;)!gVK0rg7Aoj73rDtisEicSog2DYnIFw<-SZc zQ@k;&*GS=M71RT4hDPX8S&ny7@Ewc2CP8AT*ko|}YJ!KG5maw$>c_il)k1R5c8Y%?Wk(=;+i;R@^PCUT4YE{4zWsFi1ay2jiifQ**jCb|?!Tfu!M1*9>k3qVyzRHDfHdDLC3%j<(O0GE(gbpwC{$%s6hhNRBLt5 zpb|SJj?ndK6ELse2=%^xx6bgAm7>ODRT@%D6X_sf6Y39wK3e5<<`IK~f)|lttGuq( z&)%e?8MZwN-sa&p+P1!l1WUgxF?umgs&`<;c+tYDMwX^}*e^I+)+)c&zunW3M~X8u zbmlX;y+KZ2`8pNcFWyGjPZ#ffs)R6aX0hj8OWov60&-NldXwy4X@`k5`-I~rZ?PcP z?|uHP1L=Suh7i)8YJSbvvZu;u1r2?&A^SR2dO(}6l-D)e zRq~pyAFa{~a|(Uo#+Pd!D!b&+<0ZkNJG>Ah2FkX`QI`9PY)y+~{&p`8FU-=Ik*pV_ zUp%;?T!S48u*~iRz`$O>pKX*ZEF>3$^-IQYm~dn?XXP2`cj`rZFn+>grv)JEdI2v~ zsDJ`q&*3OQYp+^mJ?#`x?yPeIJixmeYJrh%I1AX>Q#9`e@6-F^{qiB%zU3zs)ES>V z=r;ObZHKswFTGUgVl%Ra9f6-3Dkw1@`7PaGww zzH_XX=hSIvXMuxnZ|+1%ym&)AuBBSNNCzC>Ct7T5y%slwGT4;5L$1uSluND_GkGQ!K^v(VQKa{0>y=Mzq zOM}f^?r>(rgn(SwLA;r5;-e*=y*lV3rsQpE&I@cf`wXCB|5+@|$syKlI?h)j$omE#8K2E;#*EMHDxXyxw9jk_@QFe5UPh zk|RQ2AC6$twhxDxTBsVJnaaou0tLC``3TR!5_U21gEa;5OqW}5GY%4a z>759sV91YfqaBQd>FM>PcqhR??O&n!i*JnMizfNYxOJ`oS=)_+_%bx0<=dXG?j@j@wLnwIZT6!N8-$DHMMe?^yqxC%J zZQnkA!CbF_w1M?I%ig_pYiv-_{wV^%R-YSvXFkn?&NW^drOlqPc6vsY!b96E^Fo+e zMEzVxeckZ4I>Oa_oFW65L0aRnQxM_n_ zIzwHrlj{2&YVJaait;jHgdO=&ozr@SroW*+6n@7nTx!EN%`0&PgR7QskEgy)5 z;Xqe`6FZBwaRJ#ykoLzofR!Wb2*5=+HopXZ46?kzKB}G1Ogx5vOQDgxx zr|*Ix%H{Hh_#q^F_A_nOJ(%;#C_Qt!51~?qDJ$gZj{zBQ5TNw#2rBdnUp?#4imdk5 z$Dp}L_3_dJK*x6KZOoo})K>WQjXf_Z@xlP)>jHil%La`POzNn#dgdk1PJSBxbK=2F z<1@s_K>&qZw(IN}e0$_`W;Xxn4EC|<;$)eK;q>cIKNko;GzjiH`uBg=`lkQGOz}T! zeRER&o%s>#+unbhDJ-{!g@6mVlK)uW>J`AVoR^%n7=XnSa@j7=v7T4?pdMe19iHJ$Q^VlCCLKJGbkdb1meh9ci zL|G%wFNmiE971-D7YL@g@W1|aVFB=bC9R;KpomAYk`ulGAXr&YQUN7lz=hYw`XYFP zn0~k*zYw61)nSf#po0!%Vo%fvp(U#I;7awYDquF1+ohE?4Ad#QWPU1 zcmcL0WIIrY6>DN>m>OL323*L7SXW_xV6PLOv_$~3^~p{uyZ8taR|c`BE0J016q`CY z8AI6#6bg`#WQkeI;2?3Cqd;Ig28AhTLvP4+QnI(;0JRSERluwSAeF^PdjoCnE!rdn z+y$F#@m;4pK#%zhLms?X4PXD3FDbxtTuAx7M5>7{=Ox&Ha48$T>w&{P z-wMilF&2-gBcekUg#uOfYzdh7k7s!4Fym1799a7 zj*9X*K@e43iK}SF3M&^La&!lLrQ$do@POcrUN4>9`g z0%K^mA3CtceI^?jxQgWTtz`+2ZhbHWkQJnYJb5(*fL}e}ob;>i0KEOfp(uIvS*tYy zHTlNdqr`){C51@p4(Q@Xj_Q-{RL-*$LC;s#5;E61F^X`H5z}5j0kPlSuOX=&=&KF8 z4eb;jJ--!a%Be&gwo6qVTL(H3l~@HyD-r|cM~_}5_2k+yG13}db+M{ZvkOYP1|*y6 zli1BD04Gil!}JN+IH`_tZGj;Nj+HlEV;K0bo5K__1UO6gN99s!-IG|dQBh@%xPc`h zMaY|S1*OR;U?{seURp!NiI6X4ZXAe8>rq8LaraG1q_{{8XYKd`>$&er=X#KG zc-0cwM&no@w+gv91{D<@HL5!{V1-Irhx7n@b`YL2|}v4m*F)h37=2`pApBw4-#OC+*6HEf0>M1R!b9F$-c+D$AdC>1D*1^Fj1WN^%G154xn0|Zt=;<94~e#_VJ*PJKqDKPs1T z;jJ_s3m%10WNJDD*2$Y<)Z3y}@|sInXwqrTI!OC{w-z6aV>d_pGi3eMOXc#7Q&0!5 znks8GE^A6kM0e^l=cwc{t=QJg({v6=I}?#7VgS~RvdQaOvDYs34x_sIT*N98sm{c6 z`kP9EaZi<;uGo6;A*RDcwCQp!K3tShZ59L?1IJ)_m*HCKt!pjjAawW|u!S(zs^ssS zPlqM-(YuHr?$RZ=gYS-;aY*hzDpk`Hx>mdnT!6m5h6BnI8tN0eDd2vUyg#7t_Iu2X z+qG5Rb<)4J=UV6A2Im(Ck)XYm4Ll?w5nCj0+9_z$b%3;NC|MB$J)>mpRSh~RZK)@E z%=zG4RoA6AI&*m^D-tNHRxum}Eq4O&9V&UV>Dg`XF#)$rClQ~pZrhB{LHG;sc+^+X}{CH^>k@NXV$yrI4O+lo)5HXKrq{y3MRp+(J z$XeAD=FnwgeNDV2mswq>QZr&~9r};D_Srkg*UI}1$4I$uXY}}Bu1fCx3M(4Ke7Rl6 zWS5L(Eh&QxMJ8bCt!=3!*$;tv#2aydSR%{!$*w{{*JvqV2Dhsy0N-iT?1Wu3iiJ2y ztcZKPVKrEmN}eLb6>ZpZZkO?X+)~vi3)mAUXz8xOb>u|7yrp5mc_s*HyCacpGX`y% zpBwF&B_F)ohLcqCIr8&AL^q1l5be>kth%$P012b+0R-eEYYNAPtYzqNuk9w2L)Ok5 z{#Z>;t)l!IoKLw9>{MUT9YeL=4;0#t={Nw|qXOA6vfi)VZi1HOr}2}OK<2dk5P9-9 zY8kkl+eOG=Az908!*mzk#jQ$wxk`?TahD@l6t`>90Fi}V+UM@Z5}<3_u4tr?wF27l z$HTffr2P;vE(5m|1EGQ|8kuOjf}NAQ4lY#Fx;W-}JZ zTK@Ru;x9&%u07z?5l?*W!yhMf#z@-L1lkU%$xvoa?^h-f+{Fc$k!&Kw?tE@CZjL={ z+clvYom*Z5V6qnl~JAh-Z)ZKf)KO~)jYr2F;apn9=3GSU z;q9D~7tA40+nv-9N%S>a8h@G_u%F6^Ld_JURlUh5=(+J2qFtTVK1@QTklM~TP(7(H z0klRFoj$3IQ()~;#3G2~ym61gdjVOiRk3IH3u7FJ-gG+-g|+B3&g3g&K5;F*Nf?*U z$b2h)f-?fTRuD7=^+~F7T=UbLMAcUBR-vPxSK-XTIYE zou`S;CyWrs@swMOKE8PJU=epHAsw&|WIVcw&uIg$MdmTZ7pd5tvwwdwp1eo{E^TN5 zIRZ3sj$+@$Nhw@k`^B-|wz|XC6DnnsZL`{FG75` - - - - - - {org.name || ' '} - - - {org.login || ' '} - - - - - navigation.navigate('OrgRepositoryList', { - title: translate('user.repositoryList.title', language), - orgId: org.id, - repoCount: org.countRepos > 15 ? 15 : org.countRepos, - })} - > - - {org.countRepos} +const mockAttribute = (entity, attribute, replacement) => { + return entity && entity[attribute] ? entity[attribute] : replacement; +}; + +export const OrgProfile = ({ org, language, navigation }: Props) => { + const countRepos = mockAttribute(org, 'countRepos', 0); + + return ( + + + + {org && + org.avatarUrl && + } + {(!org || !org.avatarUrl) && + } + + {mockAttribute(org, 'name', ' ')} - - {translate('common.repositories', language)} + + {mockAttribute(org, 'login', ' ')} - + + + + navigation.navigate('OrgRepositoryList', { + title: translate('user.repositoryList.title', language), + orgId: org.id, + repoCount: countRepos > 15 ? 15 : countRepos, + })} + > + + {countRepos} + + + {translate('common.repositories', language)} + + + - ; + ); +}; diff --git a/src/components/users-avatar-list.component.js b/src/components/users-avatar-list.component.js index 7d188bfac..da1ffd308 100644 --- a/src/components/users-avatar-list.component.js +++ b/src/components/users-avatar-list.component.js @@ -27,7 +27,7 @@ type Props = { loadMore: Function, }; -const size = 120; +const size = 30; const styles = StyleSheet.create({ container: { diff --git a/src/screens/organization/organization-profile.js b/src/screens/organization/organization-profile.js index d212ad74f..9732bf8c8 100644 --- a/src/screens/organization/organization-profile.js +++ b/src/screens/organization/organization-profile.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { StyleSheet, RefreshControl, Text } from 'react-native'; +import { StyleSheet, RefreshControl } from 'react-native'; import { ListItem } from 'react-native-elements'; import ActionSheet from 'react-native-actionsheet'; import { @@ -129,14 +129,6 @@ class OrganizationProfile extends Component { const { refreshing } = this.state; const organizationActions = [translate('common.openInBrowser', language)]; - if (!entity) { - return ( - - Loading organization {orgId} .. TODO: Make me look nicer - - ); - } - return ( } - {!!entity.description && + {entity && + !!entity.description && entity.description !== '' && } - + {entity && } Date: Wed, 11 Oct 2017 00:22:27 +0100 Subject: [PATCH 09/36] refactor: split repository-list into a dumb component --- src/components/index.js | 1 + src/components/repository-list.component.js | 166 ++++++++++++++++++ src/screens/organization/repository-list.js | 184 +++----------------- 3 files changed, 196 insertions(+), 155 deletions(-) create mode 100644 src/components/repository-list.component.js diff --git a/src/components/index.js b/src/components/index.js index ace4d8d27..3339fc919 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -17,6 +17,7 @@ export * from './mention-area.component'; export * from './notification-list-item.component'; export * from './parallax-scroll.component'; export * from './repo-list-item.component'; +export * from './repository-list.component'; export * from './repository-list-item.component'; export * from './repository-profile.component'; export * from './repository-section-title.component'; diff --git a/src/components/repository-list.component.js b/src/components/repository-list.component.js new file mode 100644 index 000000000..17abafbd5 --- /dev/null +++ b/src/components/repository-list.component.js @@ -0,0 +1,166 @@ +/* eslint-disable no-shadow */ +import React, { Component } from 'react'; +import { FlatList, View, Dimensions, StyleSheet } from 'react-native'; + +import { + ViewContainer, + RepoListItem, + LoadingRepositoryListItem, + SearchBar, +} from 'components'; +import { colors } from 'config'; + +const styles = StyleSheet.create({ + header: { + borderBottomColor: colors.greyLight, + borderBottomWidth: 1, + }, + searchBarWrapper: { + flexDirection: 'row', + }, + searchContainer: { + width: Dimensions.get('window').width, + backgroundColor: colors.white, + flex: 1, + }, + searchCancelButton: { + color: colors.black, + }, + listContainer: { + marginBottom: 90, + }, +}); + +export class RepositoryList extends Component { + props: { + // The actions + loadRepositories: Function, + loadSearchResults: Function, + + // Entity repositories + repositories: Array, + repositoriesPagination: Object, + + // Search repositories + searchResults: Array, + searchResultsPagination: Object, + + authUser: Object, + navigation: Object, + }; + + state: { + searchMode: boolean, + searchFocus: boolean, + }; + + constructor() { + super(); + + this.state = { + searchMode: false, + searchFocus: false, + }; + + this.search = this.search.bind(this); + this.getList = this.getList.bind(this); + } + + componentWillMount() { + this.props.loadRepositories(); + } + + getList = () => { + const { searchResults, repositories } = this.props; + const { searchMode } = this.state; + + return searchMode ? searchResults : repositories; + }; + + loadMore = () => { + const { loadRepositories, loadSearchResults, navigation } = this.props; + + if (navigation.state.params.searchedKeyword) { + loadSearchResults(navigation.state.params.searchedKeyword, true); + } else { + loadRepositories(true); + } + }; + + search(keyword) { + if (keyword !== '') { + this.setState({ + searchMode: true, + }); + + const { loadSearchResults } = this.props; + + loadSearchResults(keyword); + } + } + + keyExtractor = item => { + return item.id; + }; + + render() { + const { + authUser, + navigation, + searchResultsPagination, + repositoriesPagination, + } = this.props; + const repoCount = navigation.state.params.repoCount; + const { searchMode, searchFocus } = this.state; + const loading = + (searchResultsPagination.isFetching && + !searchResultsPagination.pageCount) || + (repositoriesPagination.isFetching && !repositoriesPagination.pageCount); + + return ( + + + + + + this.setState({ searchFocus: true })} + onCancelButtonPress={() => + this.setState({ searchMode: false })} + onSearchButtonPress={query => { + this.search(query); + }} + hideBackground + /> + + + + + {loading && + [...Array(searchMode ? repoCount : 10)].map( + (item, index) => // eslint-disable-line react/no-array-index-key + )} + + {!loading && + + + } + /> + } + + + ); + } +} diff --git a/src/screens/organization/repository-list.js b/src/screens/organization/repository-list.js index eecea4000..61db53191 100644 --- a/src/screens/organization/repository-list.js +++ b/src/screens/organization/repository-list.js @@ -1,45 +1,14 @@ /* eslint-disable no-shadow */ import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { FlatList, View, Dimensions, StyleSheet } from 'react-native'; -import { - ViewContainer, - RepoListItem, - LoadingRepositoryListItem, - SearchBar, -} from 'components'; -import { colors } from 'config'; +import { RepositoryList } from 'components'; import { getRepos } from 'api/rest/providers/github/endpoints/orgs'; import { searchRepos } from 'api/rest/providers/github/endpoints/search'; -const loadData = ({ orgId, getRepos }) => { - getRepos(orgId); -}; - -// TODO: clean up searchStart & query usage - -const styles = StyleSheet.create({ - header: { - borderBottomColor: colors.greyLight, - borderBottomWidth: 1, - }, - searchBarWrapper: { - flexDirection: 'row', - }, - searchContainer: { - width: Dimensions.get('window').width, - backgroundColor: colors.white, - flex: 1, - }, - searchCancelButton: { - color: colors.black, - }, - listContainer: { - marginBottom: 90, - }, -}); +const getQueryString = (keyword, orgId) => + `q=${keyword}+user:${orgId}+fork:true&per_page=8`; class OrgRepositoryList extends Component { props: { @@ -58,132 +27,34 @@ class OrgRepositoryList extends Component { navigation: Object, }; - state: { - query: string, - searchStart: boolean, - searchFocus: boolean, - }; - - constructor() { - super(); - - this.state = { - query: '', - searchStart: false, - searchFocus: false, - }; - - this.search = this.search.bind(this); - this.getList = this.getList.bind(this); - } - - componentWillMount() { - loadData(this.props); - } - - componentWillReceiveProps(nextProps) { - if (nextProps.orgId !== this.props.orgId) { - loadData(nextProps); - } - } - - getList = () => { - const { searchedResults, repositories } = this.props; - const { searchStart } = this.state; - - return searchStart ? searchedResults : repositories; - }; - - loadMore = () => { - const { orgId, getRepos, searchRepos, navigation } = this.props; - - if (navigation.state.params.searchedKeyword) { - searchRepos(navigation.state.params.searchedKeyword, true); - } else { - getRepos(orgId, { loadMore: true }); - } - }; - - search(query) { - if (query !== '') { - const searchedKeyword = `q=${query}+user:${this.props.navigation.state - .params.orgId}+fork:true`; - - this.setState({ - searchStart: true, - query, - }); - - const { searchRepos, navigation } = this.props; - - navigation.setParams({ searchedKeyword }); - searchRepos(searchedKeyword); - } - } - - keyExtractor = item => { - return item.id; - }; - render() { const { + orgId, authUser, navigation, - searchedResultsPagination, + getRepos, + searchRepos, + repositories, repositoriesPagination, + searchedResults, + searchedResultsPagination, } = this.props; - const repoCount = navigation.state.params.repoCount; - const { searchStart, searchFocus } = this.state; - const loading = - (searchedResultsPagination.isFetching && - !searchedResultsPagination.pageCount) || - (repositoriesPagination.isFetching && !repositoriesPagination.pageCount); return ( - - - - - - this.setState({ searchFocus: true })} - onCancelButtonPress={() => - this.setState({ searchStart: false, query: '' })} - onSearchButtonPress={query => { - this.search(query); - }} - hideBackground - /> - - - - - {loading && - [...Array(searchStart ? repoCount : 10)].map( - (item, index) => // eslint-disable-line react/no-array-index-key - )} - - {!loading && - - - } - /> - } - - + getRepos(orgId, { loadMore })} + loadSearchResults={(keyword, loadMore = false) => { + navigation.setParams({ searchedKeyword: keyword }); + + return searchRepos(getQueryString(keyword, orgId), { loadMore }); + }} + repositories={repositories} + repositoriesPagination={repositoriesPagination} + searchResults={searchedResults} + searchResultsPagination={searchedResultsPagination} + /> ); } } @@ -201,9 +72,12 @@ const mapStateToProps = (state, ownProps) => { const repositories = repositoriesPagination.ids.map(id => repos[id]); const searchedKeyword = ownProps.navigation.state.params.searchedKeyword; - const searchedResultsPagination = searchedKeyword - ? reposBySearch[searchedKeyword] - : { ids: [] }; + const queryString = getQueryString(searchedKeyword, orgId); + + const searchedResultsPagination = + searchedKeyword && reposBySearch[queryString] + ? reposBySearch[queryString] + : { ids: [] }; const searchedResults = searchedResultsPagination.ids.map(id => repos[id]); return { From 29fd81f83bc25831b919536950e27fb5ceb2e465 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Wed, 11 Oct 2017 01:17:14 +0100 Subject: [PATCH 10/36] feat(list): add load progress indicator, adjust list margin --- src/components/repository-list.component.js | 38 ++++++++++++++------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/components/repository-list.component.js b/src/components/repository-list.component.js index 17abafbd5..69ca7a8f2 100644 --- a/src/components/repository-list.component.js +++ b/src/components/repository-list.component.js @@ -27,7 +27,7 @@ const styles = StyleSheet.create({ color: colors.black, }, listContainer: { - marginBottom: 90, + marginBottom: 57, }, }); @@ -74,7 +74,7 @@ export class RepositoryList extends Component { const { searchResults, repositories } = this.props; const { searchMode } = this.state; - return searchMode ? searchResults : repositories; + return [...(searchMode ? searchResults : repositories), { id: 'fake' }]; }; loadMore = () => { @@ -117,6 +117,10 @@ export class RepositoryList extends Component { !searchResultsPagination.pageCount) || (repositoriesPagination.isFetching && !repositoriesPagination.pageCount); + const noMoreElements = searchMode + ? searchResultsPagination.nextPageUrl === null + : repositoriesPagination.nextPageUrl === null; + return ( @@ -145,20 +149,30 @@ export class RepositoryList extends Component { )} {!loading && - - + { + if (item.id === 'fake') { + if (noMoreElements) { + return ; + } + + return ; + } + + return ( } - /> - } + /> + ); + }} + />} ); From 82495a62accfc4b7f383bcb3e5862592a3efd7ff Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Wed, 11 Oct 2017 17:48:16 +0100 Subject: [PATCH 11/36] refactor: Namespace the API client --- src/api/rest/middleware/index.js | 2 +- src/api/rest/providers/github/client.js | 70 ++++++++++++++++++ .../rest/providers/github/endpoints/index.js | 7 ++ .../rest/providers/github/endpoints/orgs.js | 2 +- .../rest/providers/github/endpoints/search.js | 2 +- src/api/rest/providers/github/index.js | 72 +------------------ .../organization/organization-profile.js | 26 +++---- src/screens/organization/repository-list.js | 14 ++-- 8 files changed, 103 insertions(+), 92 deletions(-) create mode 100644 src/api/rest/providers/github/client.js create mode 100644 src/api/rest/providers/github/endpoints/index.js diff --git a/src/api/rest/middleware/index.js b/src/api/rest/middleware/index.js index eea96eea9..b818dd421 100644 --- a/src/api/rest/middleware/index.js +++ b/src/api/rest/middleware/index.js @@ -1,4 +1,4 @@ -import { performApiCall } from '../providers/github'; +import { performApiCall } from '../providers/github/client'; // Action key that carries API call info interpreted by this Redux middleware. export const CALL_API = 'CALL_THIS_MIDDLEWARE'; diff --git a/src/api/rest/providers/github/client.js b/src/api/rest/providers/github/client.js new file mode 100644 index 000000000..5136ccba1 --- /dev/null +++ b/src/api/rest/providers/github/client.js @@ -0,0 +1,70 @@ +import { normalize } from 'normalizr'; + +const API_ROOT = 'https://api.github.com/'; + +const getNextPageUrl = response => { + const link = response.headers.get('link'); + + if (!link) { + return null; + } + + const nextLink = link.split(',').find(s => s.indexOf('rel="next"') > -1); + + if (!nextLink) { + return null; + } + + return nextLink.split(';')[0].slice(1, -1); +}; + +export const handlePaginatedApi = ( + firstPageUrl, + { name, key, call }, + { loadMore = false, forceRefresh = false } = {} +) => (dispatch, getState) => { + let { nextPageUrl = firstPageUrl } = getState().pagination[name][key] || {}; + const { pageCount = 0, isFetching = false } = + getState().pagination[name][key] || {}; + + if (forceRefresh) { + // TODO: how to reset the state ? dispatch(clearPagination('paginationId')) ? + nextPageUrl = firstPageUrl; + } else if (isFetching || (pageCount > 0 && !loadMore) || !nextPageUrl) { + return null; + } + + return dispatch(call(key, nextPageUrl)); +}; + +export const performApiCall = ( + endpoint, + params, + schema, + accessToken, + normalizrKey +) => { + const fullUrl = + endpoint.indexOf(API_ROOT) === -1 ? API_ROOT + endpoint : endpoint; + + return fetch(fullUrl, { + headers: { + Authorization: `token ${accessToken}`, + 'Cache-Control': 'no-cache', + }, + }).then(response => + response.json().then(json => { + if (!response.ok) { + return Promise.reject(json); + } + + const nextPageUrl = getNextPageUrl(response); + + return Object.assign( + {}, + normalize(normalizrKey ? json[normalizrKey] : json, schema), + { nextPageUrl } + ); + }) + ); +}; diff --git a/src/api/rest/providers/github/endpoints/index.js b/src/api/rest/providers/github/endpoints/index.js new file mode 100644 index 000000000..6f6f96df7 --- /dev/null +++ b/src/api/rest/providers/github/endpoints/index.js @@ -0,0 +1,7 @@ +import * as orgs from './orgs'; +import * as search from './search'; + +export default { + orgs, + search, +}; diff --git a/src/api/rest/providers/github/endpoints/orgs.js b/src/api/rest/providers/github/endpoints/orgs.js index a3af0d78a..15fdde966 100644 --- a/src/api/rest/providers/github/endpoints/orgs.js +++ b/src/api/rest/providers/github/endpoints/orgs.js @@ -5,8 +5,8 @@ import { REPOS_BY_ORG, MEMBERS_BY_ORG, } from 'api/rest/actions/orgs'; -import { handlePaginatedApi } from 'api/rest/providers/github'; +import { handlePaginatedApi } from '../client'; import Schemas from '../schemas'; const _getById = orgId => ({ diff --git a/src/api/rest/providers/github/endpoints/search.js b/src/api/rest/providers/github/endpoints/search.js index e612d5983..853686236 100644 --- a/src/api/rest/providers/github/endpoints/search.js +++ b/src/api/rest/providers/github/endpoints/search.js @@ -1,8 +1,8 @@ import { CALL_API } from 'api/rest/middleware'; import { REPOS_BY_SEARCH } from 'api/rest/actions/search'; -import { handlePaginatedApi } from 'api/rest/providers/github'; import Schemas from '../schemas'; +import { handlePaginatedApi } from '../client'; const _searchRepos = (query, nextPageUrl) => ({ id: query, diff --git a/src/api/rest/providers/github/index.js b/src/api/rest/providers/github/index.js index 5136ccba1..9648e03b6 100644 --- a/src/api/rest/providers/github/index.js +++ b/src/api/rest/providers/github/index.js @@ -1,70 +1,4 @@ -import { normalize } from 'normalizr'; +import endpoints from './endpoints'; -const API_ROOT = 'https://api.github.com/'; - -const getNextPageUrl = response => { - const link = response.headers.get('link'); - - if (!link) { - return null; - } - - const nextLink = link.split(',').find(s => s.indexOf('rel="next"') > -1); - - if (!nextLink) { - return null; - } - - return nextLink.split(';')[0].slice(1, -1); -}; - -export const handlePaginatedApi = ( - firstPageUrl, - { name, key, call }, - { loadMore = false, forceRefresh = false } = {} -) => (dispatch, getState) => { - let { nextPageUrl = firstPageUrl } = getState().pagination[name][key] || {}; - const { pageCount = 0, isFetching = false } = - getState().pagination[name][key] || {}; - - if (forceRefresh) { - // TODO: how to reset the state ? dispatch(clearPagination('paginationId')) ? - nextPageUrl = firstPageUrl; - } else if (isFetching || (pageCount > 0 && !loadMore) || !nextPageUrl) { - return null; - } - - return dispatch(call(key, nextPageUrl)); -}; - -export const performApiCall = ( - endpoint, - params, - schema, - accessToken, - normalizrKey -) => { - const fullUrl = - endpoint.indexOf(API_ROOT) === -1 ? API_ROOT + endpoint : endpoint; - - return fetch(fullUrl, { - headers: { - Authorization: `token ${accessToken}`, - 'Cache-Control': 'no-cache', - }, - }).then(response => - response.json().then(json => { - if (!response.ok) { - return Promise.reject(json); - } - - const nextPageUrl = getNextPageUrl(response); - - return Object.assign( - {}, - normalize(normalizrKey ? json[normalizrKey] : json, schema), - { nextPageUrl } - ); - }) - ); -}; +// Courtesy export for the app +export default endpoints; diff --git a/src/screens/organization/organization-profile.js b/src/screens/organization/organization-profile.js index 9732bf8c8..9f3caf24f 100644 --- a/src/screens/organization/organization-profile.js +++ b/src/screens/organization/organization-profile.js @@ -14,7 +14,7 @@ import { } from 'components'; import { emojifyText, translate, openURLInView } from 'utils'; import { colors, fonts } from 'config'; -import { getById, getMembers } from 'api/rest/providers/github/endpoints/orgs'; +import client from 'api/rest/providers/github'; const styles = StyleSheet.create({ listTitle: { @@ -28,9 +28,9 @@ const styles = StyleSheet.create({ }); /* eslint-disable no-shadow */ -const loadData = ({ orgId, getById, getMembers }) => { - getById(orgId, { requiredFields: ['name'] }); - getMembers(orgId); +const loadData = ({ orgId, getOrgById, getOrgMembers }) => { + getOrgById(orgId, { requiredFields: ['name'] }); + getOrgMembers(orgId); }; const mapStateToProps = (state, ownProps) => { @@ -67,8 +67,8 @@ class OrganizationProfile extends Component { membersPagination: Object, navigation: Object, language: string, - getMembers: Function, - getById: Function, + getOrgMembers: Function, + getOrgById: Function, }; state: { @@ -93,18 +93,18 @@ class OrganizationProfile extends Component { } refreshData = () => { - const { navigation, getMembers, getById } = this.props; + const { navigation, getOrgMembers, getOrgById } = this.props; const orgId = navigation.state.params.organization.login; navigation.setParams({ refreshing: true }); - getById(orgId, { forceRefresh: true }); - getMembers(orgId, { forceRefresh: true }); + getOrgById(orgId, { forceRefresh: true }); + getOrgMembers(orgId, { forceRefresh: true }); }; loadMoreMembers = () => { - const { orgId, getMembers } = this.props; + const { orgId, getOrgMembers } = this.props; - getMembers(orgId, { loadMore: true }); + getOrgMembers(orgId, { loadMore: true }); }; showMenuActionSheet = () => { @@ -193,6 +193,6 @@ class OrganizationProfile extends Component { } export const OrganizationProfileScreen = connect(mapStateToProps, { - getById, - getMembers, + getOrgById: client.orgs.getById, + getOrgMembers: client.orgs.getMembers, })(OrganizationProfile); diff --git a/src/screens/organization/repository-list.js b/src/screens/organization/repository-list.js index 61db53191..d02b430b8 100644 --- a/src/screens/organization/repository-list.js +++ b/src/screens/organization/repository-list.js @@ -4,8 +4,7 @@ import { connect } from 'react-redux'; import { RepositoryList } from 'components'; -import { getRepos } from 'api/rest/providers/github/endpoints/orgs'; -import { searchRepos } from 'api/rest/providers/github/endpoints/search'; +import client from 'api/rest/providers/github'; const getQueryString = (keyword, orgId) => `q=${keyword}+user:${orgId}+fork:true&per_page=8`; @@ -13,7 +12,7 @@ const getQueryString = (keyword, orgId) => class OrgRepositoryList extends Component { props: { searchRepos: Function, - getRepos: Function, + getOrgRepos: Function, orgId: String, searchedResults: Array, @@ -32,7 +31,7 @@ class OrgRepositoryList extends Component { orgId, authUser, navigation, - getRepos, + getOrgRepos, searchRepos, repositories, repositoriesPagination, @@ -44,7 +43,8 @@ class OrgRepositoryList extends Component { getRepos(orgId, { loadMore })} + loadRepositories={(loadMore = false) => + getOrgRepos(orgId, { loadMore })} loadSearchResults={(keyword, loadMore = false) => { navigation.setParams({ searchedKeyword: keyword }); @@ -92,6 +92,6 @@ const mapStateToProps = (state, ownProps) => { }; export const OrgRepositoryListScreen = connect(mapStateToProps, { - getRepos, - searchRepos, + getOrgRepos: client.orgs.getRepos, + searchRepos: client.search.searchRepos, })(OrgRepositoryList); From 455229d28edc4c5e4a3535d5dc56d97ce5be334a Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Wed, 11 Oct 2017 17:55:16 +0100 Subject: [PATCH 12/36] chore: not need to disable no-shadow anymore --- src/screens/organization/organization-profile.js | 1 - src/screens/organization/repository-list.js | 1 - 2 files changed, 2 deletions(-) diff --git a/src/screens/organization/organization-profile.js b/src/screens/organization/organization-profile.js index 9f3caf24f..8f8e74212 100644 --- a/src/screens/organization/organization-profile.js +++ b/src/screens/organization/organization-profile.js @@ -27,7 +27,6 @@ const styles = StyleSheet.create({ }, }); -/* eslint-disable no-shadow */ const loadData = ({ orgId, getOrgById, getOrgMembers }) => { getOrgById(orgId, { requiredFields: ['name'] }); getOrgMembers(orgId); diff --git a/src/screens/organization/repository-list.js b/src/screens/organization/repository-list.js index d02b430b8..f5c56ed7e 100644 --- a/src/screens/organization/repository-list.js +++ b/src/screens/organization/repository-list.js @@ -1,4 +1,3 @@ -/* eslint-disable no-shadow */ import React, { Component } from 'react'; import { connect } from 'react-redux'; From cc41686fdcf92e4bdadf230d312611ce38da9b0a Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Wed, 11 Oct 2017 18:14:31 +0100 Subject: [PATCH 13/36] refactor: enum style for actions --- src/api/rest/providers/github/endpoints/orgs.js | 12 ++++-------- src/api/rest/providers/github/endpoints/search.js | 4 ++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/api/rest/providers/github/endpoints/orgs.js b/src/api/rest/providers/github/endpoints/orgs.js index 15fdde966..e937d4537 100644 --- a/src/api/rest/providers/github/endpoints/orgs.js +++ b/src/api/rest/providers/github/endpoints/orgs.js @@ -1,17 +1,13 @@ import has from 'lodash.has'; import { CALL_API } from 'api/rest/middleware'; -import { - ORGS_GET_BY_ID, - REPOS_BY_ORG, - MEMBERS_BY_ORG, -} from 'api/rest/actions/orgs'; +import * as Actions from 'api/rest/actions/orgs'; import { handlePaginatedApi } from '../client'; import Schemas from '../schemas'; const _getById = orgId => ({ [CALL_API]: { - types: ORGS_GET_BY_ID, + types: Actions.ORGS_GET_BY_ID, endpoint: `orgs/${orgId}`, schema: Schemas.ORG, }, @@ -33,7 +29,7 @@ export const getById = ( const _getRepos = (orgId, nextPageUrl) => ({ id: orgId, [CALL_API]: { - types: REPOS_BY_ORG, + types: Actions.REPOS_BY_ORG, endpoint: nextPageUrl, schema: Schemas.REPO_ARRAY, }, @@ -50,7 +46,7 @@ export const getRepos = (orgId, options) => { const _getMembers = (orgId, nextPageUrl) => ({ id: orgId, [CALL_API]: { - types: MEMBERS_BY_ORG, + types: Actions.MEMBERS_BY_ORG, endpoint: nextPageUrl, schema: Schemas.USER_ARRAY, }, diff --git a/src/api/rest/providers/github/endpoints/search.js b/src/api/rest/providers/github/endpoints/search.js index 853686236..f93503964 100644 --- a/src/api/rest/providers/github/endpoints/search.js +++ b/src/api/rest/providers/github/endpoints/search.js @@ -1,5 +1,5 @@ import { CALL_API } from 'api/rest/middleware'; -import { REPOS_BY_SEARCH } from 'api/rest/actions/search'; +import * as Actions from 'api/rest/actions/search'; import Schemas from '../schemas'; import { handlePaginatedApi } from '../client'; @@ -7,7 +7,7 @@ import { handlePaginatedApi } from '../client'; const _searchRepos = (query, nextPageUrl) => ({ id: query, [CALL_API]: { - types: REPOS_BY_SEARCH, + types: Actions.REPOS_BY_SEARCH, endpoint: nextPageUrl, schema: Schemas.REPO_ARRAY, normalizrKey: 'items', From 166d6a99dd0d645cdb1899f5ee76a45ba7bb8f24 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Wed, 11 Oct 2017 21:45:53 +0100 Subject: [PATCH 14/36] refactor: Use new middleware for paged events --- routes.js | 3 +- src/api/rest/actions/activity.js | 3 + src/api/rest/actions/index.js | 1 + .../providers/github/endpoints/activity.js | 22 ++++ .../rest/providers/github/endpoints/index.js | 2 + .../rest/providers/github/schemas/events.js | 38 ++++++ .../rest/providers/github/schemas/index.js | 3 + .../rest/providers/github/schemas/repos.js | 14 ++- src/api/rest/reducers/entities.js | 1 + src/api/rest/reducers/pagination.js | 2 + src/auth/auth.action.js | 24 ---- src/auth/auth.reducer.js | 18 --- src/auth/auth.type.js | 1 - src/auth/screens/index.js | 1 - src/components/user-list-item.component.js | 2 +- .../activity}/events.screen.js | 110 ++++++++++-------- src/screens/activity/index.js | 5 + src/screens/index.js | 2 + 18 files changed, 153 insertions(+), 99 deletions(-) create mode 100644 src/api/rest/actions/activity.js create mode 100644 src/api/rest/providers/github/endpoints/activity.js create mode 100644 src/api/rest/providers/github/schemas/events.js rename src/{auth/screens => screens/activity}/events.screen.js (89%) create mode 100644 src/screens/activity/index.js diff --git a/routes.js b/routes.js index aff1e7837..57b288519 100644 --- a/routes.js +++ b/routes.js @@ -20,7 +20,6 @@ import { LoginScreen, WelcomeScreen, AuthProfileScreen, - EventsScreen, PrivacyPolicyScreen, UserOptionsScreen, } from 'auth'; @@ -206,7 +205,7 @@ const sharedRoutes = { const HomeStackNavigator = StackNavigator( { Events: { - screen: EventsScreen, + screen: screens.activity.EventsScreen, navigationOptions: { headerTitle: 'GitPoint', }, diff --git a/src/api/rest/actions/activity.js b/src/api/rest/actions/activity.js new file mode 100644 index 000000000..77fba2762 --- /dev/null +++ b/src/api/rest/actions/activity.js @@ -0,0 +1,3 @@ +import { createActionSet } from 'utils'; + +export const ACTIVITY_GET_EVENTS = createActionSet('ACTIVITY_GET_EVENTS'); diff --git a/src/api/rest/actions/index.js b/src/api/rest/actions/index.js index 671e8a174..06745f8c5 100644 --- a/src/api/rest/actions/index.js +++ b/src/api/rest/actions/index.js @@ -1,2 +1,3 @@ export * from './orgs'; export * from './search'; +export * from './activity'; diff --git a/src/api/rest/providers/github/endpoints/activity.js b/src/api/rest/providers/github/endpoints/activity.js new file mode 100644 index 000000000..e0b808faf --- /dev/null +++ b/src/api/rest/providers/github/endpoints/activity.js @@ -0,0 +1,22 @@ +import { CALL_API } from 'api/rest/middleware'; +import * as Actions from 'api/rest/actions/activity'; + +import Schemas from '../schemas'; +import { handlePaginatedApi } from '../client'; + +const _getEvents = (id, nextPageUrl) => ({ + id, + [CALL_API]: { + types: Actions.ACTIVITY_GET_EVENTS, + endpoint: nextPageUrl, + schema: Schemas.EVENT_ARRAY, + }, +}); + +export const getEventsReceived = (userId, options) => { + return handlePaginatedApi( + `users/${userId}/received_events`, + { name: 'eventsByUser', key: userId, call: _getEvents }, + options + ); +}; diff --git a/src/api/rest/providers/github/endpoints/index.js b/src/api/rest/providers/github/endpoints/index.js index 6f6f96df7..56733fb02 100644 --- a/src/api/rest/providers/github/endpoints/index.js +++ b/src/api/rest/providers/github/endpoints/index.js @@ -1,7 +1,9 @@ import * as orgs from './orgs'; import * as search from './search'; +import * as activity from './activity'; export default { orgs, search, + activity, }; diff --git a/src/api/rest/providers/github/schemas/events.js b/src/api/rest/providers/github/schemas/events.js new file mode 100644 index 000000000..2c91af5fd --- /dev/null +++ b/src/api/rest/providers/github/schemas/events.js @@ -0,0 +1,38 @@ +import { schema } from 'normalizr'; +import moment from 'moment/min/moment.min'; + +import { userSchema } from './users'; +import { orgSchema } from './orgs'; +import { repoSchema } from './repos'; + +export const eventSchema = new schema.Entity( + 'events', + { + actor: userSchema, + org: orgSchema, + repo: repoSchema, + }, + { + idAttribute: event => event.id, + processStrategy: entity => { + const processed = {}; + + processed.id = entity.id; + processed.type = entity.type; // TODO: needs to be normalized in an Enum + processed.payload = entity.payload; // TODO: needs to be inspected for more nested entities (forkee) + processed.createdAt = moment(entity.created_at).format('X'); // as unix timestamp + + processed.actor = entity.actor; + processed.org = entity.org; + processed.repo = entity.repo; + + // These flags should be in all our schemas. + processed._isComplete = true; // entity not fully fetched yet + processed._isAuth = false; // entity doesn't belong to the auth user + processed._entityUrl = false; // The github url for the entity. To be used in openInBrowser() + processed._fetchedAt = moment().format('X'); + + return processed; + }, + } +); diff --git a/src/api/rest/providers/github/schemas/index.js b/src/api/rest/providers/github/schemas/index.js index 54ef09bfc..694a6b017 100644 --- a/src/api/rest/providers/github/schemas/index.js +++ b/src/api/rest/providers/github/schemas/index.js @@ -1,6 +1,7 @@ import { orgSchema } from './orgs'; import { userSchema } from './users'; import { repoSchema } from './repos'; +import { eventSchema } from './events'; export default { USER: userSchema, @@ -9,4 +10,6 @@ export default { ORG_ARRAY: [orgSchema], REPO: repoSchema, REPO_ARRAY: [repoSchema], + EVENT: eventSchema, + EVENT_ARRAY: [eventSchema], }; diff --git a/src/api/rest/providers/github/schemas/repos.js b/src/api/rest/providers/github/schemas/repos.js index ed43fb788..4be6ba149 100644 --- a/src/api/rest/providers/github/schemas/repos.js +++ b/src/api/rest/providers/github/schemas/repos.js @@ -4,6 +4,8 @@ import moment from 'moment/min/moment.min'; import { userSchema } from './users'; import { orgSchema } from './orgs'; +const isInMinimalisticForm = entity => typeof entity.full_name === 'undefined'; + export const repoSchema = new schema.Entity( 'repos', { @@ -11,7 +13,8 @@ export const repoSchema = new schema.Entity( orgOwner: orgSchema, }, { - idAttribute: repo => repo.full_name.toLowerCase(), + idAttribute: repo => + (isInMinimalisticForm(repo) ? repo.name : repo.full_name).toLowerCase(), processStrategy: entity => { const processed = {}; @@ -21,6 +24,15 @@ export const repoSchema = new schema.Entity( processed._entityUrl = entity.html_url; // The github url for the entity. To be used in openInBrowser() processed._fetchedAt = moment().format('X'); + // Repo received from events + if (isInMinimalisticForm(entity)) { + processed.id = entity.name.toLowerCase(); + processed.fullName = entity.name; + processed._entityUrl = `https://github.com/${entity.name}`; + + return processed; + } + processed.id = entity.full_name; processed.fullName = entity.full_name; processed.shortName = entity.name; diff --git a/src/api/rest/reducers/entities.js b/src/api/rest/reducers/entities.js index 678d88c24..7be3ffcea 100644 --- a/src/api/rest/reducers/entities.js +++ b/src/api/rest/reducers/entities.js @@ -6,6 +6,7 @@ export const entities = ( users: {}, orgs: {}, repos: {}, + events: {}, }, action ) => { diff --git a/src/api/rest/reducers/pagination.js b/src/api/rest/reducers/pagination.js index a5125eed7..665deb950 100644 --- a/src/api/rest/reducers/pagination.js +++ b/src/api/rest/reducers/pagination.js @@ -3,6 +3,7 @@ import union from 'lodash.union'; import { REPOS_BY_ORG, MEMBERS_BY_ORG } from '../actions/orgs'; import { REPOS_BY_SEARCH } from '../actions/search'; +import { ACTIVITY_GET_EVENTS } from '../actions/activity'; // Creates a reducer managing pagination, given the action types to handle, // and a function telling how to extract the key from an action. @@ -69,6 +70,7 @@ const paginate = types => { // Updates the pagination data for different actions. export const pagination = combineReducers({ + eventsByUser: paginate(ACTIVITY_GET_EVENTS), reposByOrg: paginate(REPOS_BY_ORG), reposBySearch: paginate(REPOS_BY_SEARCH), membersByOrg: paginate(MEMBERS_BY_ORG), diff --git a/src/auth/auth.action.js b/src/auth/auth.action.js index 7bc2b94f0..1f568b041 100644 --- a/src/auth/auth.action.js +++ b/src/auth/auth.action.js @@ -9,7 +9,6 @@ import { fetchAuthUser, fetchAuthUserOrgs, fetchUserOrgs, - fetchUserEvents, fetchStarCount, } from 'api'; import { @@ -17,7 +16,6 @@ import { LOGOUT, GET_AUTH_USER, GET_AUTH_ORGS, - GET_EVENTS, CHANGE_LANGUAGE, GET_AUTH_STAR_COUNT, } from './auth.type'; @@ -138,28 +136,6 @@ export const getOrgs = () => { }; }; -export const getUserEvents = user => { - return (dispatch, getState) => { - const accessToken = getState().auth.accessToken; - - dispatch({ type: GET_EVENTS.PENDING }); - - fetchUserEvents(user, accessToken) - .then(data => { - dispatch({ - type: GET_EVENTS.SUCCESS, - payload: data, - }); - }) - .catch(error => { - dispatch({ - type: GET_EVENTS.ERROR, - payload: error, - }); - }); - }; -}; - export const changeLanguage = lang => { return dispatch => { dispatch({ type: CHANGE_LANGUAGE.SUCCESS, payload: lang }); diff --git a/src/auth/auth.reducer.js b/src/auth/auth.reducer.js index f015d0d04..d9fe7917e 100644 --- a/src/auth/auth.reducer.js +++ b/src/auth/auth.reducer.js @@ -4,7 +4,6 @@ import { LOGOUT, GET_AUTH_USER, GET_AUTH_ORGS, - GET_EVENTS, CHANGE_LANGUAGE, GET_AUTH_STAR_COUNT, } from './auth.type'; @@ -115,23 +114,6 @@ export const authReducer = (state = initialState, action = {}) => { error: action.payload, isPendingOrgs: false, }; - case GET_EVENTS.PENDING: - return { - ...state, - isPendingEvents: true, - }; - case GET_EVENTS.SUCCESS: - return { - ...state, - events: action.payload, - isPendingEvents: false, - }; - case GET_EVENTS.ERROR: - return { - ...state, - error: action.payload, - isPendingEvents: false, - }; case CHANGE_LANGUAGE.SUCCESS: return { ...state, diff --git a/src/auth/auth.type.js b/src/auth/auth.type.js index c7f95c2ad..800d77774 100644 --- a/src/auth/auth.type.js +++ b/src/auth/auth.type.js @@ -4,6 +4,5 @@ export const LOGIN = createActionSet('LOGIN'); export const LOGOUT = createActionSet('LOGOUT'); export const GET_AUTH_USER = createActionSet('GET_AUTH_USER'); export const GET_AUTH_ORGS = createActionSet('GET_AUTH_ORGS'); -export const GET_EVENTS = createActionSet('GET_EVENTS'); export const CHANGE_LANGUAGE = createActionSet('CHANGE_LANGUAGE'); export const GET_AUTH_STAR_COUNT = createActionSet('GET_AUTH_STAR_COUNT'); diff --git a/src/auth/screens/index.js b/src/auth/screens/index.js index a398ffb53..58df4bb3b 100644 --- a/src/auth/screens/index.js +++ b/src/auth/screens/index.js @@ -2,6 +2,5 @@ export * from './login.screen'; export * from './splash.screen'; export * from './welcome.screen'; export * from './auth-profile.screen'; -export * from './events.screen'; export * from './privacy-policy.screen'; export * from './user-options.screen'; diff --git a/src/components/user-list-item.component.js b/src/components/user-list-item.component.js index 74d7a9ae3..fd85c48f5 100644 --- a/src/components/user-list-item.component.js +++ b/src/components/user-list-item.component.js @@ -131,7 +131,7 @@ class UserListItemComponent extends Component { diff --git a/src/auth/screens/events.screen.js b/src/screens/activity/events.screen.js similarity index 89% rename from src/auth/screens/events.screen.js rename to src/screens/activity/events.screen.js index 44cefcfe1..4ef6dd855 100644 --- a/src/auth/screens/events.screen.js +++ b/src/screens/activity/events.screen.js @@ -2,33 +2,34 @@ /* eslint-disable no-shadow */ import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; import { StyleSheet, Text, FlatList, View } from 'react-native'; import moment from 'moment/min/moment-with-locales.min'; - +import client from 'api/rest/providers/github'; import { LoadingUserListItem, UserListItem, ViewContainer } from 'components'; import { colors, fonts, normalize } from 'config'; import { emojifyText, translate } from 'utils'; -import { getUserEvents, getUser } from 'auth'; -import { getNotificationsCount } from 'notifications'; - -const mapStateToProps = state => ({ - user: state.auth.user, - userEvents: state.auth.events, - language: state.auth.language, - isPendingEvents: state.auth.isPendingEvents, - accessToken: state.auth.accessToken, -}); -const mapDispatchToProps = dispatch => - bindActionCreators( - { - getUserEvents, - getNotificationsCount, - getUser, - }, - dispatch, - ); +const mapStateToProps = state => { + const { + auth: { user }, + pagination: { eventsByUser }, + entities: { repos, users, events }, + } = state; + + const userEventsPagination = eventsByUser[user.login] || { ids: [] }; + const userEvents = userEventsPagination.ids.map(id => events[id]); + + return { + repos, + users, + userEvents, + userEventsPagination, + user: state.auth.user, + language: state.auth.language, + isPendingEvents: state.auth.isPendingEvents, + accessToken: state.auth.accessToken, + }; +}; const styles = StyleSheet.create({ descriptionContainer: { @@ -79,25 +80,23 @@ const styles = StyleSheet.create({ class Events extends Component { componentDidMount() { - const { user: { login }, getUser } = this.props; + const { getEvents, user: { login } } = this.props; - if (login) { - this.getUserEvents(); - } else { - getUser(); - } + getEvents(login); } componentWillReceiveProps(nextProps) { if (nextProps.user.login && !this.props.user.login) { - this.getUserEvents(nextProps); + this.nextProps.getUserEvents(); } } + /* + // TODO: Put back getNotificationsCount && getUser getUserEvents = ({ user, accessToken } = this.props) => { - this.props.getUserEvents(user.login); - this.props.getNotificationsCount(accessToken); - }; + // this.props.getUserEvents(user.login); + // this.props.getNotificationsCount(accessToken); + }; */ getAction = userEvent => { const { language } = this.props; @@ -170,7 +169,7 @@ class Events extends Component { language, { action: translate('auth.events.actions.commented', language), - }, + } ); } else if (action === 'edited') { return translate( @@ -178,7 +177,7 @@ class Events extends Component { language, { action: translate(`auth.events.actions.${action}`, language), - }, + } ); } else if (action === 'deleted') { return translate( @@ -186,7 +185,7 @@ class Events extends Component { language, { action: translate(`auth.events.actions.${action}`, language), - }, + } ); } @@ -227,7 +226,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent)} > - {userEvent.repo.name} + {userEvent.repo} ); } @@ -248,7 +247,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent)} > - {userEvent.repo.name} + {userEvent.repo} ); case 'GollumEvent': @@ -259,7 +258,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent)} > - {userEvent.repo.name} + {userEvent.repo} {' '} wiki @@ -312,7 +311,7 @@ class Events extends Component { } }} > - {userEvent.repo.name} + {userEvent.repo} ); default: @@ -385,7 +384,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent)} > - {userEvent.repo.name} + {userEvent.repo} ); case 'ForkEvent': @@ -461,13 +460,13 @@ class Events extends Component { }); navigateToRepository = (userEvent, isForkEvent) => { + const repo = this.props.repos[userEvent.repo]; + this.props.navigation.navigate('Repository', { repository: !isForkEvent ? { - ...userEvent.repo, - name: userEvent.repo.name.substring( - userEvent.repo.name.indexOf('/') + 1, - ), + ...repo, + name: repo.id.substring(repo.id.indexOf('/') + 1), } : userEvent.payload.forkee, }); @@ -500,7 +499,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToProfile(userEvent, true)} > - {userEvent.actor.login}{' '} + {userEvent.actor}{' '} {this.getAction(userEvent)}{' '} @@ -512,14 +511,20 @@ class Events extends Component { {this.getSecondItem(userEvent)} {this.getItem(userEvent) && this.getConnector(userEvent) && ' '} - {moment(userEvent.created_at).fromNow()} + {moment.unix(userEvent.createdAt).fromNow()} ); } render() { - const { isPendingEvents, userEvents, language, navigation } = this.props; + const { + users, + isPendingEvents, + userEvents, + language, + navigation, + } = this.props; const linebreaksPattern = /(\r\n|\n|\r)/gm; let content; @@ -544,10 +549,13 @@ class Events extends Component { onRefresh={this.getUserEvents} refreshing={isPendingEvents} keyExtractor={this.keyExtractor} + onEndReached={() => + this.props.getEvents(this.props.user.login, { loadMore: true })} + onEndReachedThreshold={0.5} renderItem={({ item }) => {emojifyText( - item.payload.comment.body.replace(linebreaksPattern, ' '), + item.payload.comment.body.replace(linebreaksPattern, ' ') )} } @@ -582,6 +590,6 @@ class Events extends Component { } } -export const EventsScreen = connect(mapStateToProps, mapDispatchToProps)( - Events, -); +export const EventsScreen = connect(mapStateToProps, { + getEvents: client.activity.getEventsReceived, +})(Events); diff --git a/src/screens/activity/index.js b/src/screens/activity/index.js new file mode 100644 index 000000000..83835e09f --- /dev/null +++ b/src/screens/activity/index.js @@ -0,0 +1,5 @@ +import { EventsScreen } from './events.screen'; + +export default { + EventsScreen, +}; diff --git a/src/screens/index.js b/src/screens/index.js index 3365fee60..81a34b93b 100644 --- a/src/screens/index.js +++ b/src/screens/index.js @@ -1,5 +1,7 @@ import organization from './organization'; +import activity from './activity'; export default { organization, + activity, }; From 57e2f740b5e1b8bcb9381bd2f4609841be5b99fc Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Wed, 11 Oct 2017 22:29:09 +0100 Subject: [PATCH 15/36] fix: Cleaning up (mostly @lex111 remarks) --- root.reducer.js | 4 +--- root.store.js | 2 +- routes.js | 2 +- src/api/rest/middleware/index.js | 4 ++-- src/api/rest/providers/github/client.js | 6 +++--- src/api/rest/providers/github/schemas/repos.js | 4 ++++ src/api/rest/reducers/index.js | 3 +++ src/api/rest/reducers/pagination.js | 2 +- src/assets/images/loading.gif | Bin 102926 -> 0 bytes src/components/org-profile.component.js | 13 ++++++++----- src/components/users-avatar-list.component.js | 4 ++-- src/screens/activity/events.screen.js | 7 +------ src/screens/organization/index.js | 6 +++--- ...ofile.js => organization-profile.screen.js} | 0 ... => organization-repository-list.screen.js} | 6 +++--- 15 files changed, 33 insertions(+), 30 deletions(-) create mode 100644 src/api/rest/reducers/index.js delete mode 100644 src/assets/images/loading.gif rename src/screens/organization/{organization-profile.js => organization-profile.screen.js} (100%) rename src/screens/organization/{repository-list.js => organization-repository-list.screen.js} (93%) diff --git a/root.reducer.js b/root.reducer.js index 4452f9973..139f00ffb 100644 --- a/root.reducer.js +++ b/root.reducer.js @@ -6,9 +6,7 @@ import { issueReducer } from 'issue'; import { searchReducer } from 'search'; import { notificationsReducer } from 'notifications'; -import { entities } from './src/api/rest/reducers/entities'; -import { pagination } from './src/api/rest/reducers/pagination'; -import { errorMessage } from './src/api/rest/reducers/errorMessage'; +import { entities, pagination, errorMessage } from 'api/rest/reducers'; export const rootReducer = combineReducers({ entities, diff --git a/root.store.js b/root.store.js index 37ce18bcc..b9d84a5e2 100644 --- a/root.store.js +++ b/root.store.js @@ -5,8 +5,8 @@ import createLogger from 'redux-logger'; import reduxThunk from 'redux-thunk'; import { composeWithDevTools } from 'redux-devtools-extension'; import 'config/reactotron'; +import restApi from 'api/rest/middleware'; import { rootReducer } from './root.reducer'; -import restApi from './src/api/rest/middleware'; const getMiddleware = () => { const middlewares = [reduxThunk, restApi]; diff --git a/routes.js b/routes.js index 57b288519..d4afcb835 100644 --- a/routes.js +++ b/routes.js @@ -60,7 +60,7 @@ import { const sharedRoutes = { OrgRepositoryList: { - screen: screens.organization.OrgRepositoryListScreen, + screen: screens.organization.OrganizationRepositoryListScreen, navigationOptions: ({ navigation }) => ({ title: navigation.state.params.title, }), diff --git a/src/api/rest/middleware/index.js b/src/api/rest/middleware/index.js index b818dd421..05665a1b6 100644 --- a/src/api/rest/middleware/index.js +++ b/src/api/rest/middleware/index.js @@ -27,14 +27,14 @@ export default store => next => action => { throw new Error('Specify one of the exported Schemas.'); } - if (typeof types !== 'object') { + if (typeof types !== 'object' || Object.keys(types).length !== 3) { throw new Error('Expected an object containing the three action types.'); } const accessToken = store.getState().auth.accessToken; const actionWith = data => { - const finalAction = Object.assign({}, action, data); + const finalAction = { ...action, ...data }; delete finalAction[CALL_API]; diff --git a/src/api/rest/providers/github/client.js b/src/api/rest/providers/github/client.js index 5136ccba1..63be59f1d 100644 --- a/src/api/rest/providers/github/client.js +++ b/src/api/rest/providers/github/client.js @@ -23,9 +23,9 @@ export const handlePaginatedApi = ( { name, key, call }, { loadMore = false, forceRefresh = false } = {} ) => (dispatch, getState) => { - let { nextPageUrl = firstPageUrl } = getState().pagination[name][key] || {}; - const { pageCount = 0, isFetching = false } = - getState().pagination[name][key] || {}; + const paginator = getState().pagination[name][key]; + let { nextPageUrl = firstPageUrl } = paginator || {}; + const { pageCount = 0, isFetching = false } = paginator || {}; if (forceRefresh) { // TODO: how to reset the state ? dispatch(clearPagination('paginationId')) ? diff --git a/src/api/rest/providers/github/schemas/repos.js b/src/api/rest/providers/github/schemas/repos.js index 4be6ba149..5c512e924 100644 --- a/src/api/rest/providers/github/schemas/repos.js +++ b/src/api/rest/providers/github/schemas/repos.js @@ -28,6 +28,10 @@ export const repoSchema = new schema.Entity( if (isInMinimalisticForm(entity)) { processed.id = entity.name.toLowerCase(); processed.fullName = entity.name; + processed.shortName = entity.name.substring( + entity.name.indexOf('/') + 1 + ); + processed._entityUrl = `https://github.com/${entity.name}`; return processed; diff --git a/src/api/rest/reducers/index.js b/src/api/rest/reducers/index.js new file mode 100644 index 000000000..aec34e6a0 --- /dev/null +++ b/src/api/rest/reducers/index.js @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './pagination'; +export * from './errorMessage'; diff --git a/src/api/rest/reducers/pagination.js b/src/api/rest/reducers/pagination.js index 665deb950..72acc7ae4 100644 --- a/src/api/rest/reducers/pagination.js +++ b/src/api/rest/reducers/pagination.js @@ -8,7 +8,7 @@ import { ACTIVITY_GET_EVENTS } from '../actions/activity'; // Creates a reducer managing pagination, given the action types to handle, // and a function telling how to extract the key from an action. const paginate = types => { - if (typeof types !== 'object') { + if (typeof types !== 'object' || Object.keys(types).length !== 3) { throw new Error('Expected types to be an object of three props.'); } diff --git a/src/assets/images/loading.gif b/src/assets/images/loading.gif deleted file mode 100644 index 7be3a44a60f65ffb59f132dbec9a94f9ce7d818d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102926 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 { style={[styles.avatar]} />} {(!org || !org.avatarUrl) && - } + } {mockAttribute(org, 'name', ' ')} diff --git a/src/components/users-avatar-list.component.js b/src/components/users-avatar-list.component.js index da1ffd308..1d16cdf14 100644 --- a/src/components/users-avatar-list.component.js +++ b/src/components/users-avatar-list.component.js @@ -61,8 +61,8 @@ const styles = StyleSheet.create({ paddingLeft: 15, }, flatList: { - paddingLeft: 15, - paddingRight: 15, + marginLeft: 15, + marginRight: 15, }, }); diff --git a/src/screens/activity/events.screen.js b/src/screens/activity/events.screen.js index 4ef6dd855..459097b6d 100644 --- a/src/screens/activity/events.screen.js +++ b/src/screens/activity/events.screen.js @@ -463,12 +463,7 @@ class Events extends Component { const repo = this.props.repos[userEvent.repo]; this.props.navigation.navigate('Repository', { - repository: !isForkEvent - ? { - ...repo, - name: repo.id.substring(repo.id.indexOf('/') + 1), - } - : userEvent.payload.forkee, + repository: !isForkEvent ? repo : userEvent.payload.forkee, }); }; diff --git a/src/screens/organization/index.js b/src/screens/organization/index.js index 50b802bf8..a3191563a 100644 --- a/src/screens/organization/index.js +++ b/src/screens/organization/index.js @@ -1,7 +1,7 @@ -import { OrganizationProfileScreen } from './organization-profile'; -import { OrgRepositoryListScreen } from './repository-list'; +import { OrganizationProfileScreen } from './organization-profile.screen'; +import { OrganizationRepositoryListScreen } from './organization-repository-list.screen'; export default { OrganizationProfileScreen, - OrgRepositoryListScreen, + OrganizationRepositoryListScreen, }; diff --git a/src/screens/organization/organization-profile.js b/src/screens/organization/organization-profile.screen.js similarity index 100% rename from src/screens/organization/organization-profile.js rename to src/screens/organization/organization-profile.screen.js diff --git a/src/screens/organization/repository-list.js b/src/screens/organization/organization-repository-list.screen.js similarity index 93% rename from src/screens/organization/repository-list.js rename to src/screens/organization/organization-repository-list.screen.js index f5c56ed7e..93aac29e3 100644 --- a/src/screens/organization/repository-list.js +++ b/src/screens/organization/organization-repository-list.screen.js @@ -8,7 +8,7 @@ import client from 'api/rest/providers/github'; const getQueryString = (keyword, orgId) => `q=${keyword}+user:${orgId}+fork:true&per_page=8`; -class OrgRepositoryList extends Component { +class OrganizationRepositoryList extends Component { props: { searchRepos: Function, getOrgRepos: Function, @@ -90,7 +90,7 @@ const mapStateToProps = (state, ownProps) => { }; }; -export const OrgRepositoryListScreen = connect(mapStateToProps, { +export const OrganizationRepositoryListScreen = connect(mapStateToProps, { getOrgRepos: client.orgs.getRepos, searchRepos: client.search.searchRepos, -})(OrgRepositoryList); +})(OrganizationRepositoryList); From 9f58286be23e2fff670da8730733f1b8c39f56b3 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Thu, 12 Oct 2017 01:06:00 +0100 Subject: [PATCH 16/36] fix: Use ListFooterComponent instead of pushing a fake element --- src/components/repository-list.component.js | 33 +++++++++++---------- src/screens/activity/events.screen.js | 25 +++++++++++++++- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/components/repository-list.component.js b/src/components/repository-list.component.js index 69ca7a8f2..90898f889 100644 --- a/src/components/repository-list.component.js +++ b/src/components/repository-list.component.js @@ -1,6 +1,6 @@ /* eslint-disable no-shadow */ import React, { Component } from 'react'; -import { FlatList, View, Dimensions, StyleSheet } from 'react-native'; +import { View, Dimensions, StyleSheet, FlatList } from 'react-native'; import { ViewContainer, @@ -74,7 +74,14 @@ export class RepositoryList extends Component { const { searchResults, repositories } = this.props; const { searchMode } = this.state; - return [...(searchMode ? searchResults : repositories), { id: 'fake' }]; + return searchMode ? searchResults : repositories; + }; + + getPagination = () => { + const { searchResultsPagination, repositoriesPagination } = this.props; + const { searchMode } = this.state; + + return searchMode ? searchResultsPagination : repositoriesPagination; }; loadMore = () => { @@ -103,6 +110,14 @@ export class RepositoryList extends Component { return item.id; }; + renderFooter = () => { + if (this.getPagination().nextPageUrl === null) { + return null; + } + + return ; + }; + render() { const { authUser, @@ -117,10 +132,6 @@ export class RepositoryList extends Component { !searchResultsPagination.pageCount) || (repositoriesPagination.isFetching && !repositoriesPagination.pageCount); - const noMoreElements = searchMode - ? searchResultsPagination.nextPageUrl === null - : repositoriesPagination.nextPageUrl === null; - return ( @@ -154,16 +165,8 @@ export class RepositoryList extends Component { data={this.getList()} keyExtractor={this.keyExtractor} onEndReached={this.loadMore} - onEndReachedThreshold={0.5} + ListFooterComponent={this.renderFooter} renderItem={({ item }) => { - if (item.id === 'fake') { - if (noMoreElements) { - return ; - } - - return ; - } - return ( { + if (this.props.userEventsPagination.nextPageUrl === null) { + return null; + } + + return ( + + + + ); + }; + render() { const { users, @@ -547,6 +569,7 @@ class Events extends Component { onEndReached={() => this.props.getEvents(this.props.user.login, { loadMore: true })} onEndReachedThreshold={0.5} + ListFooterComponent={this.renderFooter} renderItem={({ item }) => Date: Fri, 13 Oct 2017 00:40:28 +0100 Subject: [PATCH 17/36] refactor: Remove middleware and use a proxy instead --- root.store.js | 3 +- routes.js | 14 +- src/api/rest/actions/activity.js | 4 +- src/api/rest/actions/orgs.js | 4 +- src/api/rest/actions/search.js | 2 +- src/api/rest/decorators/index.js | 1 + src/api/rest/decorators/with-reducers.js | 129 +++++++++++++ src/api/rest/middleware/index.js | 62 ------ src/api/rest/providers/base/client.js | 47 +++++ src/api/rest/providers/base/index.js | 1 + src/api/rest/providers/github/client.js | 180 ++++++++++++------ .../providers/github/endpoints/activity.js | 22 --- .../rest/providers/github/endpoints/index.js | 9 - .../rest/providers/github/endpoints/orgs.js | 61 ------ .../rest/providers/github/endpoints/search.js | 23 --- src/api/rest/providers/github/index.js | 5 +- src/api/rest/reducers/entities.js | 4 +- src/api/rest/reducers/pagination.js | 17 +- .../screens}/events.screen.js | 12 +- src/auth/screens/index.js | 1 + src/organization/index.js | 1 + src/organization/screens/index.js | 2 + .../screens}/organization-profile.screen.js | 9 +- .../organization-repository-list.screen.js | 15 +- src/screens/activity/index.js | 5 - src/screens/index.js | 7 - src/screens/organization/index.js | 7 - 27 files changed, 353 insertions(+), 294 deletions(-) create mode 100644 src/api/rest/decorators/index.js create mode 100644 src/api/rest/decorators/with-reducers.js delete mode 100644 src/api/rest/middleware/index.js create mode 100644 src/api/rest/providers/base/client.js create mode 100644 src/api/rest/providers/base/index.js delete mode 100644 src/api/rest/providers/github/endpoints/activity.js delete mode 100644 src/api/rest/providers/github/endpoints/index.js delete mode 100644 src/api/rest/providers/github/endpoints/orgs.js delete mode 100644 src/api/rest/providers/github/endpoints/search.js rename src/{screens/activity => auth/screens}/events.screen.js (98%) create mode 100644 src/organization/index.js create mode 100644 src/organization/screens/index.js rename src/{screens/organization => organization/screens}/organization-profile.screen.js (94%) rename src/{screens/organization => organization/screens}/organization-repository-list.screen.js (84%) delete mode 100644 src/screens/activity/index.js delete mode 100644 src/screens/index.js delete mode 100644 src/screens/organization/index.js diff --git a/root.store.js b/root.store.js index b9d84a5e2..f45bb1b9a 100644 --- a/root.store.js +++ b/root.store.js @@ -5,11 +5,10 @@ import createLogger from 'redux-logger'; import reduxThunk from 'redux-thunk'; import { composeWithDevTools } from 'redux-devtools-extension'; import 'config/reactotron'; -import restApi from 'api/rest/middleware'; import { rootReducer } from './root.reducer'; const getMiddleware = () => { - const middlewares = [reduxThunk, restApi]; + const middlewares = [reduxThunk]; if (__DEV__) { if (process.env.LOGGER_ENABLED) { diff --git a/routes.js b/routes.js index d4afcb835..849b5df19 100644 --- a/routes.js +++ b/routes.js @@ -12,8 +12,6 @@ import { NotificationIcon } from 'components'; import { colors } from 'config'; import { translate } from 'utils'; -import screens from 'screens'; - // Auth import { SplashScreen, @@ -22,6 +20,7 @@ import { AuthProfileScreen, PrivacyPolicyScreen, UserOptionsScreen, + EventsScreen, } from 'auth'; // User @@ -32,6 +31,11 @@ import { FollowingListScreen, } from 'user'; +import { + OrganizationRepositoryListScreen, + OrganizationProfileScreen, +} from 'organization'; + // Search import { SearchScreen } from 'search'; @@ -60,7 +64,7 @@ import { const sharedRoutes = { OrgRepositoryList: { - screen: screens.organization.OrganizationRepositoryListScreen, + screen: OrganizationRepositoryListScreen, navigationOptions: ({ navigation }) => ({ title: navigation.state.params.title, }), @@ -96,7 +100,7 @@ const sharedRoutes = { }, }, Organization: { - screen: screens.organization.OrganizationProfileScreen, + screen: OrganizationProfileScreen, navigationOptions: { header: null, }, @@ -205,7 +209,7 @@ const sharedRoutes = { const HomeStackNavigator = StackNavigator( { Events: { - screen: screens.activity.EventsScreen, + screen: EventsScreen, navigationOptions: { headerTitle: 'GitPoint', }, diff --git a/src/api/rest/actions/activity.js b/src/api/rest/actions/activity.js index 77fba2762..8a47386a5 100644 --- a/src/api/rest/actions/activity.js +++ b/src/api/rest/actions/activity.js @@ -1,3 +1,5 @@ import { createActionSet } from 'utils'; -export const ACTIVITY_GET_EVENTS = createActionSet('ACTIVITY_GET_EVENTS'); +export const ACTIVITY_GET_EVENTS_RECEIVED = createActionSet( + 'ACTIVITY_GET_EVENTS_RECEIVED' +); diff --git a/src/api/rest/actions/orgs.js b/src/api/rest/actions/orgs.js index d7e52b455..0d3fc0b1f 100644 --- a/src/api/rest/actions/orgs.js +++ b/src/api/rest/actions/orgs.js @@ -2,6 +2,4 @@ import { createActionSet } from 'utils'; export const ORGS_GET_BY_ID = createActionSet('ORGS_GET_BY_ID'); export const ORGS_GET_REPOS = createActionSet('ORGS_GET_REPOS'); - -export const REPOS_BY_ORG = createActionSet('REPOS_BY_ORG'); -export const MEMBERS_BY_ORG = createActionSet('MEMBERS_BY_ORG'); +export const ORGS_GET_MEMBERS = createActionSet('ORGS_GET_MEMBERS'); diff --git a/src/api/rest/actions/search.js b/src/api/rest/actions/search.js index dd3b76268..48187e1de 100644 --- a/src/api/rest/actions/search.js +++ b/src/api/rest/actions/search.js @@ -1,3 +1,3 @@ import { createActionSet } from 'utils'; -export const REPOS_BY_SEARCH = createActionSet('REPOS_BY_SEARCH'); +export const SEARCH_GET_REPOS = createActionSet('SEARCH_GET_REPOS'); diff --git a/src/api/rest/decorators/index.js b/src/api/rest/decorators/index.js new file mode 100644 index 000000000..59b10f343 --- /dev/null +++ b/src/api/rest/decorators/index.js @@ -0,0 +1 @@ +export * from './with-reducers'; diff --git a/src/api/rest/decorators/with-reducers.js b/src/api/rest/decorators/with-reducers.js new file mode 100644 index 000000000..5f6cc73b9 --- /dev/null +++ b/src/api/rest/decorators/with-reducers.js @@ -0,0 +1,129 @@ +import * as Actions from 'api/rest/actions'; +import { normalize } from 'normalizr'; +import { Alert } from 'react-native'; + +const displayError = error => { + Alert.alert('API Error', error); +}; + +export const withReducers = Provider => { + const client = new Provider(); + + return new Proxy(withReducers, { + get: (c, namespace) => { + return new Proxy(client[namespace], { + get: (endpoint, call) => (...args) => (dispatch, getState) => { + // Used as a key for state.pagination + const actionName = `${namespace}_${call}` + .replace(/([A-Z])/g, '_$1') + .toUpperCase(); + + const action = Actions[actionName]; + + if (typeof action === 'undefined') { + return displayError( + `Unknown action. Did you forget to define Actions.${actionName}?` + ); + } + + // Identify if we have our magical last argument + const declaredArgsNumber = endpoint[call].length; + const isMagicArgAvailable = args.length === declaredArgsNumber; + + const pureArgs = isMagicArgAvailable + ? args.slice(0, args.length - 1) + : args; + const magicArg = isMagicArgAvailable ? args[args.length - 1] : {}; + + const paginator = getState().pagination[actionName]; + + // pagination or entity ? <- + + // Get accessToken from state + client.setAccessToken(getState().auth.accessToken); + + let finalArgs = args; + + if (typeof paginator !== 'undefined') { + const { loadMore = false } = magicArg; + const { pageCount = 0, isFetching = false, nextPageUrl } = + paginator[pureArgs.join('-')] || {}; + + if ( + isFetching || + (pageCount > 0 && !loadMore) || + (loadMore && !nextPageUrl) + ) { + return Promise.resolve(); // Already fetching, don't retrigger a call + } + + if (loadMore) { + // next page explicitely requested + magicArg.url = nextPageUrl; + } + + finalArgs = [...pureArgs, magicArg]; + } + + dispatch({ + id: args[0], + type: Actions[actionName].PENDING, + }); + + /* eslint-disable no-unexpected-multiline */ + return endpoint + [call](...finalArgs) + .then(struct => { + if (!struct.response.ok) { + return struct.response.json().then(error => { + return Promise.reject( + `Call: client.${namespace}.call()\nUrl: ${struct.response + .url}\nError: [${struct.response + .status}] ${error.message}` + ); + }); + } + + return struct.response.json().then(json => { + // Treat the JSON & normalize it + const normalized = normalize( + struct.normalizrKey ? json[struct.normalizrKey] : json, + struct.schema + ); + + // TODO: only for paginated + if (typeof paginator !== 'undefined') { + normalized.pagination = { + name: actionName, + key: args[0], + ids: normalized.result, + nextPageUrl: struct.nextPageUrl, + }; + delete normalized.result; + } + + // Success, let's dispatch it + dispatch({ + ...normalized, + id: args[0], + type: Actions[actionName].SUCCESS, + }); + + return Promise.resolve(); + }); + }) + .catch(error => { + displayError(error.toString()); + + dispatch({ + id: args[0], + type: Actions[actionName].ERROR, + }); + + return error; + }); + }, + }); + }, + }); +}; diff --git a/src/api/rest/middleware/index.js b/src/api/rest/middleware/index.js deleted file mode 100644 index 05665a1b6..000000000 --- a/src/api/rest/middleware/index.js +++ /dev/null @@ -1,62 +0,0 @@ -import { performApiCall } from '../providers/github/client'; - -// Action key that carries API call info interpreted by this Redux middleware. -export const CALL_API = 'CALL_THIS_MIDDLEWARE'; - -// A Redux middleware that interprets actions with CALL_API info specified. -// Performs the call and promises when such actions are dispatched. -export default store => next => action => { - const apiCallParameters = action[CALL_API]; - - if (typeof apiCallParameters === 'undefined') { - return next(action); - } - - let { endpoint } = apiCallParameters; - const { types, schema, normalizrKey = null } = apiCallParameters; - - if (typeof endpoint === 'function') { - endpoint = endpoint(store.getState()); - } - - if (typeof endpoint !== 'string') { - throw new Error('Specify a string endpoint URL.'); - } - - if (!schema) { - throw new Error('Specify one of the exported Schemas.'); - } - - if (typeof types !== 'object' || Object.keys(types).length !== 3) { - throw new Error('Expected an object containing the three action types.'); - } - - const accessToken = store.getState().auth.accessToken; - - const actionWith = data => { - const finalAction = { ...action, ...data }; - - delete finalAction[CALL_API]; - - return finalAction; - }; - - next(actionWith({ type: types.PENDING })); - - return performApiCall(endpoint, {}, schema, accessToken, normalizrKey).then( - response => - next( - actionWith({ - response, - type: types.SUCCESS, - }) - ), - error => - next( - actionWith({ - type: types.ERROR, - error: error.message || 'Something bad happened', - }) - ) - ); -}; diff --git a/src/api/rest/providers/base/client.js b/src/api/rest/providers/base/client.js new file mode 100644 index 000000000..54d948728 --- /dev/null +++ b/src/api/rest/providers/base/client.js @@ -0,0 +1,47 @@ +export class Client { + Method = { + GET: 'GET', + HEAD: 'HEAD', + PUT: 'PUT', + DELETE: 'DELETE', + PATCH: 'PATCH', + POST: 'POST', + }; + + fetch = async ( + url, + { + method = this.Method.GET, + schema = null, + normalizrKey = null, + headers = {}, + }, + params = {} + ) => { + let finalUrl = params.url || url; + + if (finalUrl.indexOf(this.API_ROOT) === -1) { + finalUrl = `${this.API_ROOT}${finalUrl}`; + } + + const parameters = { + method, + headers: { + 'Cache-Control': 'no-cache', + ...this.authHeaders, + ...headers, + }, + }; + + return fetch(finalUrl, parameters) + .then(response => { + // analyze headers for pagination, rates, etc + return { + response, + schema, + normalizrKey, + }; + }) + .catch(error => error); + }; +} diff --git a/src/api/rest/providers/base/index.js b/src/api/rest/providers/base/index.js new file mode 100644 index 000000000..4f1cce44f --- /dev/null +++ b/src/api/rest/providers/base/index.js @@ -0,0 +1 @@ +export * from './client'; diff --git a/src/api/rest/providers/github/client.js b/src/api/rest/providers/github/client.js index 63be59f1d..9bf2ce7ca 100644 --- a/src/api/rest/providers/github/client.js +++ b/src/api/rest/providers/github/client.js @@ -1,70 +1,132 @@ -import { normalize } from 'normalizr'; +import { Client } from '../base'; +import Schemas from './schemas'; -const API_ROOT = 'https://api.github.com/'; +export class Github extends Client { + API_ROOT = 'https://api.github.com/'; -const getNextPageUrl = response => { - const link = response.headers.get('link'); + setAccessToken = accessToken => { + this.authHeaders = { Authorization: `token ${accessToken}` }; + }; - if (!link) { - return null; - } + getNextPageUrl = response => { + const link = response.headers.get('link'); - const nextLink = link.split(',').find(s => s.indexOf('rel="next"') > -1); + if (!link) { + return null; + } - if (!nextLink) { - return null; - } + const nextLink = link.split(',').find(s => s.indexOf('rel="next"') > -1); - return nextLink.split(';')[0].slice(1, -1); -}; + if (!nextLink) { + return null; + } -export const handlePaginatedApi = ( - firstPageUrl, - { name, key, call }, - { loadMore = false, forceRefresh = false } = {} -) => (dispatch, getState) => { - const paginator = getState().pagination[name][key]; - let { nextPageUrl = firstPageUrl } = paginator || {}; - const { pageCount = 0, isFetching = false } = paginator || {}; + return nextLink.split(';')[0].slice(1, -1); + }; - if (forceRefresh) { - // TODO: how to reset the state ? dispatch(clearPagination('paginationId')) ? - nextPageUrl = firstPageUrl; - } else if (isFetching || (pageCount > 0 && !loadMore) || !nextPageUrl) { - return null; - } - - return dispatch(call(key, nextPageUrl)); -}; - -export const performApiCall = ( - endpoint, - params, - schema, - accessToken, - normalizrKey -) => { - const fullUrl = - endpoint.indexOf(API_ROOT) === -1 ? API_ROOT + endpoint : endpoint; - - return fetch(fullUrl, { - headers: { - Authorization: `token ${accessToken}`, - 'Cache-Control': 'no-cache', + /** + * The organizations endpoint + */ + orgs = { + /** + * Gets an organization by its id + * + * @param {string} orgId + */ + getById: async (orgId, params) => { + return this.fetch( + `orgs/${orgId}`, + { + schema: Schemas.ORG, + }, + params + ).then(struct => struct); + }, + /** + * Gets organization members + * + * @param {string} orgId + */ + getMembers: async (orgId, params) => { + return this.fetch( + `orgs/${orgId}/members`, + { + schema: Schemas.USER_ARRAY, + }, + params + ).then(struct => { + return { + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + }; + }); }, - }).then(response => - response.json().then(json => { - if (!response.ok) { - return Promise.reject(json); - } + /** + * Gets organization members + * + * @param {string} orgId + */ + getRepos: async (orgId, params) => { + return this.fetch( + `orgs/${orgId}/repos`, + { + schema: Schemas.REPO_ARRAY, + }, + params + ).then(struct => { + return { + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + }; + }); + }, + }; - const nextPageUrl = getNextPageUrl(response); + /** + * The activity endpoint + */ + activity = { + /** + * Gets received events + * + * @param {string} userId + */ + getEventsReceived: async (userId, params) => { + return this.fetch( + `users/${userId}/received_events`, + { + schema: Schemas.EVENT_ARRAY, + }, + params + ).then(struct => { + return { + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + }; + }); + }, + }; - return Object.assign( - {}, - normalize(normalizrKey ? json[normalizrKey] : json, schema), - { nextPageUrl } - ); - }) - ); -}; + search = { + /** + * Search repositories + * + * @param {string} query + */ + getRepos: async (query, params) => { + return this.fetch( + `search/repositories?${query}`, + { + schema: Schemas.REPO_ARRAY, + normalizrKey: 'items', + }, + params + ).then(struct => { + return { + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + }; + }); + }, + }; +} diff --git a/src/api/rest/providers/github/endpoints/activity.js b/src/api/rest/providers/github/endpoints/activity.js deleted file mode 100644 index e0b808faf..000000000 --- a/src/api/rest/providers/github/endpoints/activity.js +++ /dev/null @@ -1,22 +0,0 @@ -import { CALL_API } from 'api/rest/middleware'; -import * as Actions from 'api/rest/actions/activity'; - -import Schemas from '../schemas'; -import { handlePaginatedApi } from '../client'; - -const _getEvents = (id, nextPageUrl) => ({ - id, - [CALL_API]: { - types: Actions.ACTIVITY_GET_EVENTS, - endpoint: nextPageUrl, - schema: Schemas.EVENT_ARRAY, - }, -}); - -export const getEventsReceived = (userId, options) => { - return handlePaginatedApi( - `users/${userId}/received_events`, - { name: 'eventsByUser', key: userId, call: _getEvents }, - options - ); -}; diff --git a/src/api/rest/providers/github/endpoints/index.js b/src/api/rest/providers/github/endpoints/index.js deleted file mode 100644 index 56733fb02..000000000 --- a/src/api/rest/providers/github/endpoints/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import * as orgs from './orgs'; -import * as search from './search'; -import * as activity from './activity'; - -export default { - orgs, - search, - activity, -}; diff --git a/src/api/rest/providers/github/endpoints/orgs.js b/src/api/rest/providers/github/endpoints/orgs.js deleted file mode 100644 index e937d4537..000000000 --- a/src/api/rest/providers/github/endpoints/orgs.js +++ /dev/null @@ -1,61 +0,0 @@ -import has from 'lodash.has'; -import { CALL_API } from 'api/rest/middleware'; -import * as Actions from 'api/rest/actions/orgs'; - -import { handlePaginatedApi } from '../client'; -import Schemas from '../schemas'; - -const _getById = orgId => ({ - [CALL_API]: { - types: Actions.ORGS_GET_BY_ID, - endpoint: `orgs/${orgId}`, - schema: Schemas.ORG, - }, -}); - -export const getById = ( - orgId, - { requiredFields = [], forceRefresh = false } = {} -) => (dispatch, getState) => { - const org = getState().entities.orgs[orgId]; - - if (!forceRefresh && org && requiredFields.every(key => has(org, key))) { - return null; - } - - return dispatch(_getById(orgId)); -}; - -const _getRepos = (orgId, nextPageUrl) => ({ - id: orgId, - [CALL_API]: { - types: Actions.REPOS_BY_ORG, - endpoint: nextPageUrl, - schema: Schemas.REPO_ARRAY, - }, -}); - -export const getRepos = (orgId, options) => { - return handlePaginatedApi( - `orgs/${orgId}/repos`, - { name: 'reposByOrg', key: orgId, call: _getRepos }, - options - ); -}; - -const _getMembers = (orgId, nextPageUrl) => ({ - id: orgId, - [CALL_API]: { - types: Actions.MEMBERS_BY_ORG, - endpoint: nextPageUrl, - schema: Schemas.USER_ARRAY, - }, -}); - -export const getMembers = (orgId, options) => { - return handlePaginatedApi( - `orgs/${orgId}/members?per_page=8`, - { name: 'membersByOrg', key: orgId, call: _getMembers }, - options - ); -}; diff --git a/src/api/rest/providers/github/endpoints/search.js b/src/api/rest/providers/github/endpoints/search.js deleted file mode 100644 index f93503964..000000000 --- a/src/api/rest/providers/github/endpoints/search.js +++ /dev/null @@ -1,23 +0,0 @@ -import { CALL_API } from 'api/rest/middleware'; -import * as Actions from 'api/rest/actions/search'; - -import Schemas from '../schemas'; -import { handlePaginatedApi } from '../client'; - -const _searchRepos = (query, nextPageUrl) => ({ - id: query, - [CALL_API]: { - types: Actions.REPOS_BY_SEARCH, - endpoint: nextPageUrl, - schema: Schemas.REPO_ARRAY, - normalizrKey: 'items', - }, -}); - -export const searchRepos = (query, options) => { - return handlePaginatedApi( - `search/repositories?${query}`, - { name: 'reposBySearch', key: query, call: _searchRepos }, - options - ); -}; diff --git a/src/api/rest/providers/github/index.js b/src/api/rest/providers/github/index.js index 9648e03b6..4f1cce44f 100644 --- a/src/api/rest/providers/github/index.js +++ b/src/api/rest/providers/github/index.js @@ -1,4 +1 @@ -import endpoints from './endpoints'; - -// Courtesy export for the app -export default endpoints; +export * from './client'; diff --git a/src/api/rest/reducers/entities.js b/src/api/rest/reducers/entities.js index 7be3ffcea..7fcc5aadd 100644 --- a/src/api/rest/reducers/entities.js +++ b/src/api/rest/reducers/entities.js @@ -10,8 +10,8 @@ export const entities = ( }, action ) => { - if (action.response && action.response.entities) { - return merge({}, state, action.response.entities); + if (action && action.entities) { + return merge({}, state, action.entities); } return state; diff --git a/src/api/rest/reducers/pagination.js b/src/api/rest/reducers/pagination.js index 72acc7ae4..ba9206275 100644 --- a/src/api/rest/reducers/pagination.js +++ b/src/api/rest/reducers/pagination.js @@ -1,9 +1,7 @@ import { combineReducers } from 'redux'; import union from 'lodash.union'; -import { REPOS_BY_ORG, MEMBERS_BY_ORG } from '../actions/orgs'; -import { REPOS_BY_SEARCH } from '../actions/search'; -import { ACTIVITY_GET_EVENTS } from '../actions/activity'; +import * as Actions from '../actions'; // Creates a reducer managing pagination, given the action types to handle, // and a function telling how to extract the key from an action. @@ -31,8 +29,8 @@ const paginate = types => { return { ...state, isFetching: false, - ids: union(state.ids, action.response.result), - nextPageUrl: action.response.nextPageUrl, + ids: union(state.ids, action.pagination.ids), + nextPageUrl: action.pagination.nextPageUrl, pageCount: state.pageCount + 1, }; case types.ERROR: @@ -54,6 +52,7 @@ const paginate = types => { case types.ERROR: const key = action.id; + // console.log("PAGINATE", action); if (typeof key !== 'string') { throw new Error('Expected key to be a string.'); } @@ -70,8 +69,8 @@ const paginate = types => { // Updates the pagination data for different actions. export const pagination = combineReducers({ - eventsByUser: paginate(ACTIVITY_GET_EVENTS), - reposByOrg: paginate(REPOS_BY_ORG), - reposBySearch: paginate(REPOS_BY_SEARCH), - membersByOrg: paginate(MEMBERS_BY_ORG), + ACTIVITY_GET_EVENTS_RECEIVED: paginate(Actions.ACTIVITY_GET_EVENTS_RECEIVED), + ORGS_GET_REPOS: paginate(Actions.ORGS_GET_REPOS), + ORGS_GET_MEMBERS: paginate(Actions.ORGS_GET_MEMBERS), + SEARCH_GET_REPOS: paginate(Actions.SEARCH_GET_REPOS), }); diff --git a/src/screens/activity/events.screen.js b/src/auth/screens/events.screen.js similarity index 98% rename from src/screens/activity/events.screen.js rename to src/auth/screens/events.screen.js index 71f25db2f..946ce522c 100644 --- a/src/screens/activity/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -10,19 +10,25 @@ import { ActivityIndicator, } from 'react-native'; import moment from 'moment/min/moment-with-locales.min'; -import client from 'api/rest/providers/github'; import { LoadingUserListItem, UserListItem, ViewContainer } from 'components'; import { colors, fonts, normalize } from 'config'; import { emojifyText, translate } from 'utils'; +import { Github } from 'api/rest/providers/github'; +import { withReducers } from 'api/rest/decorators'; + +const client = withReducers(Github); + const mapStateToProps = state => { const { auth: { user }, - pagination: { eventsByUser }, + pagination: { ACTIVITY_GET_EVENTS_RECEIVED }, entities: { repos, users, events }, } = state; - const userEventsPagination = eventsByUser[user.login] || { ids: [] }; + const userEventsPagination = ACTIVITY_GET_EVENTS_RECEIVED[user.login] || { + ids: [], + }; const userEvents = userEventsPagination.ids.map(id => events[id]); return { diff --git a/src/auth/screens/index.js b/src/auth/screens/index.js index 58df4bb3b..a398ffb53 100644 --- a/src/auth/screens/index.js +++ b/src/auth/screens/index.js @@ -2,5 +2,6 @@ export * from './login.screen'; export * from './splash.screen'; export * from './welcome.screen'; export * from './auth-profile.screen'; +export * from './events.screen'; export * from './privacy-policy.screen'; export * from './user-options.screen'; diff --git a/src/organization/index.js b/src/organization/index.js new file mode 100644 index 000000000..c9de5c34d --- /dev/null +++ b/src/organization/index.js @@ -0,0 +1 @@ +export * from './screens'; diff --git a/src/organization/screens/index.js b/src/organization/screens/index.js new file mode 100644 index 000000000..15fe341fc --- /dev/null +++ b/src/organization/screens/index.js @@ -0,0 +1,2 @@ +export * from './organization-profile.screen'; +export * from './organization-repository-list.screen'; diff --git a/src/screens/organization/organization-profile.screen.js b/src/organization/screens/organization-profile.screen.js similarity index 94% rename from src/screens/organization/organization-profile.screen.js rename to src/organization/screens/organization-profile.screen.js index 8f8e74212..88fd13c28 100644 --- a/src/screens/organization/organization-profile.screen.js +++ b/src/organization/screens/organization-profile.screen.js @@ -14,7 +14,10 @@ import { } from 'components'; import { emojifyText, translate, openURLInView } from 'utils'; import { colors, fonts } from 'config'; -import client from 'api/rest/providers/github'; +import { Github } from 'api/rest/providers/github'; +import { withReducers } from 'api/rest/decorators'; + +const client = withReducers(Github); const styles = StyleSheet.create({ listTitle: { @@ -36,9 +39,9 @@ const mapStateToProps = (state, ownProps) => { // TODO: This should be normalized to params.id const orgId = ownProps.navigation.state.params.organization.login.toLowerCase(); - const { pagination: { membersByOrg }, entities: { orgs, users } } = state; + const { pagination: { ORGS_GET_MEMBERS }, entities: { orgs, users } } = state; - const membersPagination = membersByOrg[orgId] || { + const membersPagination = ORGS_GET_MEMBERS[orgId] || { ids: [], isFetching: true, }; diff --git a/src/screens/organization/organization-repository-list.screen.js b/src/organization/screens/organization-repository-list.screen.js similarity index 84% rename from src/screens/organization/organization-repository-list.screen.js rename to src/organization/screens/organization-repository-list.screen.js index 93aac29e3..5667e5c95 100644 --- a/src/screens/organization/organization-repository-list.screen.js +++ b/src/organization/screens/organization-repository-list.screen.js @@ -3,7 +3,10 @@ import { connect } from 'react-redux'; import { RepositoryList } from 'components'; -import client from 'api/rest/providers/github'; +import { Github } from 'api/rest/providers/github'; +import { withReducers } from 'api/rest/decorators'; + +const client = withReducers(Github); const getQueryString = (keyword, orgId) => `q=${keyword}+user:${orgId}+fork:true&per_page=8`; @@ -63,19 +66,19 @@ const mapStateToProps = (state, ownProps) => { const { auth: { user }, - pagination: { reposByOrg, reposBySearch }, + pagination: { ORGS_GET_REPOS, SEARCH_GET_REPOS }, entities: { orgs, repos }, } = state; - const repositoriesPagination = reposByOrg[orgId] || { ids: [] }; + const repositoriesPagination = ORGS_GET_REPOS[orgId] || { ids: [] }; const repositories = repositoriesPagination.ids.map(id => repos[id]); const searchedKeyword = ownProps.navigation.state.params.searchedKeyword; const queryString = getQueryString(searchedKeyword, orgId); const searchedResultsPagination = - searchedKeyword && reposBySearch[queryString] - ? reposBySearch[queryString] + searchedKeyword && SEARCH_GET_REPOS[queryString] + ? SEARCH_GET_REPOS[queryString] : { ids: [] }; const searchedResults = searchedResultsPagination.ids.map(id => repos[id]); @@ -92,5 +95,5 @@ const mapStateToProps = (state, ownProps) => { export const OrganizationRepositoryListScreen = connect(mapStateToProps, { getOrgRepos: client.orgs.getRepos, - searchRepos: client.search.searchRepos, + searchRepos: client.search.getRepos, })(OrganizationRepositoryList); diff --git a/src/screens/activity/index.js b/src/screens/activity/index.js deleted file mode 100644 index 83835e09f..000000000 --- a/src/screens/activity/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import { EventsScreen } from './events.screen'; - -export default { - EventsScreen, -}; diff --git a/src/screens/index.js b/src/screens/index.js deleted file mode 100644 index 81a34b93b..000000000 --- a/src/screens/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import organization from './organization'; -import activity from './activity'; - -export default { - organization, - activity, -}; diff --git a/src/screens/organization/index.js b/src/screens/organization/index.js deleted file mode 100644 index a3191563a..000000000 --- a/src/screens/organization/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import { OrganizationProfileScreen } from './organization-profile.screen'; -import { OrganizationRepositoryListScreen } from './organization-repository-list.screen'; - -export default { - OrganizationProfileScreen, - OrganizationRepositoryListScreen, -}; From 77e40a213105691300a1baa48c2c08a3b0429bd0 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Fri, 13 Oct 2017 10:49:21 +0100 Subject: [PATCH 18/36] chore: import lodash entirely --- package.json | 5 +---- src/api/rest/reducers/entities.js | 2 +- src/api/rest/reducers/pagination.js | 2 +- src/auth/auth.action.js | 2 +- yarn.lock | 8 ++++---- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 740faaa34..f3cdbeae0 100644 --- a/package.json +++ b/package.json @@ -48,10 +48,7 @@ "dependencies": { "entities": "^1.1.1", "fuzzy-search": "^1.4.0", - "lodash.has": "^4.5.2", - "lodash.merge": "^4.6.0", - "lodash.union": "^4.6.0", - "lodash.uniqby": "^4.7.0", + "lodash": "^4.17.4", "lowlight": "^1.5.0", "md5": "^2.2.1", "moment": "^2.17.1", diff --git a/src/api/rest/reducers/entities.js b/src/api/rest/reducers/entities.js index 7fcc5aadd..dbdcd5a17 100644 --- a/src/api/rest/reducers/entities.js +++ b/src/api/rest/reducers/entities.js @@ -1,4 +1,4 @@ -import merge from 'lodash.merge'; +import { merge } from 'lodash'; // Updates an entity cache in response to any action with response.entities. export const entities = ( diff --git a/src/api/rest/reducers/pagination.js b/src/api/rest/reducers/pagination.js index ba9206275..318dffb37 100644 --- a/src/api/rest/reducers/pagination.js +++ b/src/api/rest/reducers/pagination.js @@ -1,5 +1,5 @@ import { combineReducers } from 'redux'; -import union from 'lodash.union'; +import { union } from 'lodash'; import * as Actions from '../actions'; diff --git a/src/auth/auth.action.js b/src/auth/auth.action.js index 1f568b041..8c24c7ce6 100644 --- a/src/auth/auth.action.js +++ b/src/auth/auth.action.js @@ -1,6 +1,6 @@ import { AsyncStorage } from 'react-native'; -import uniqby from 'lodash.uniqby'; +import { uniqby } from 'lodash'; import { delay, resetNavigationTo, configureLocale } from 'utils'; import { saveLanguage } from 'locale'; diff --git a/yarn.lock b/yarn.lock index 5c4453f95..bcd49ac51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4236,10 +4236,6 @@ lodash.toarray@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561" -lodash.uniqby@^4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" - lodash@^3.5.0: version "3.10.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" @@ -4679,6 +4675,10 @@ normalize-path@^2.0.0, normalize-path@^2.0.1: dependencies: remove-trailing-separator "^1.0.1" +normalizr@^3.2.3: + version "3.2.4" + resolved "https://registry.yarnpkg.com/normalizr/-/normalizr-3.2.4.tgz#16aafc540ca99dc1060ceaa1933556322eac4429" + npm-path@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.3.tgz#15cff4e1c89a38da77f56f6055b24f975dfb2bbe" From 9c07e82253f58a5d64da361ab4920bd2c80f25af Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Fri, 13 Oct 2017 11:02:14 +0100 Subject: [PATCH 19/36] refactor: clean up withReducers() --- src/api/rest/decorators/with-reducers.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/api/rest/decorators/with-reducers.js b/src/api/rest/decorators/with-reducers.js index 5f6cc73b9..27ac38822 100644 --- a/src/api/rest/decorators/with-reducers.js +++ b/src/api/rest/decorators/with-reducers.js @@ -20,7 +20,7 @@ export const withReducers = Provider => { const action = Actions[actionName]; - if (typeof action === 'undefined') { + if (!action) { return displayError( `Unknown action. Did you forget to define Actions.${actionName}?` ); @@ -44,17 +44,17 @@ export const withReducers = Provider => { let finalArgs = args; - if (typeof paginator !== 'undefined') { + if (paginator) { const { loadMore = false } = magicArg; const { pageCount = 0, isFetching = false, nextPageUrl } = paginator[pureArgs.join('-')] || {}; if ( - isFetching || - (pageCount > 0 && !loadMore) || - (loadMore && !nextPageUrl) + isFetching || // Already fetching, don't retrigger a call + (pageCount > 0 && !loadMore) || // We already have the first page of data + (loadMore && !nextPageUrl) // We've already fetched the last page ) { - return Promise.resolve(); // Already fetching, don't retrigger a call + return Promise.resolve(); } if (loadMore) { @@ -91,8 +91,7 @@ export const withReducers = Provider => { struct.schema ); - // TODO: only for paginated - if (typeof paginator !== 'undefined') { + if (paginator) { normalized.pagination = { name: actionName, key: args[0], From a54da4be5707609f16741ef11ca00af39df53b83 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Fri, 13 Oct 2017 11:16:27 +0100 Subject: [PATCH 20/36] refactor: Add schema helpers and use milli-seconds timestamps everywhere --- .../rest/providers/github/schemas/events.js | 12 ++--- src/api/rest/providers/github/schemas/orgs.js | 14 ++--- .../rest/providers/github/schemas/repos.js | 53 ++----------------- .../rest/providers/github/schemas/users.js | 14 ++--- src/auth/screens/events.screen.js | 2 +- src/utils/index.js | 1 + src/utils/schema-helper.js | 10 ++++ 7 files changed, 26 insertions(+), 80 deletions(-) create mode 100644 src/utils/schema-helper.js diff --git a/src/api/rest/providers/github/schemas/events.js b/src/api/rest/providers/github/schemas/events.js index 2c91af5fd..224defeb3 100644 --- a/src/api/rest/providers/github/schemas/events.js +++ b/src/api/rest/providers/github/schemas/events.js @@ -1,5 +1,5 @@ import { schema } from 'normalizr'; -import moment from 'moment/min/moment.min'; +import { initSchema, toTimestamp } from 'utils'; import { userSchema } from './users'; import { orgSchema } from './orgs'; @@ -15,23 +15,17 @@ export const eventSchema = new schema.Entity( { idAttribute: event => event.id, processStrategy: entity => { - const processed = {}; + const processed = initSchema(); processed.id = entity.id; processed.type = entity.type; // TODO: needs to be normalized in an Enum processed.payload = entity.payload; // TODO: needs to be inspected for more nested entities (forkee) - processed.createdAt = moment(entity.created_at).format('X'); // as unix timestamp + processed.createdAt = toTimestamp(entity.created_at); processed.actor = entity.actor; processed.org = entity.org; processed.repo = entity.repo; - // These flags should be in all our schemas. - processed._isComplete = true; // entity not fully fetched yet - processed._isAuth = false; // entity doesn't belong to the auth user - processed._entityUrl = false; // The github url for the entity. To be used in openInBrowser() - processed._fetchedAt = moment().format('X'); - return processed; }, } diff --git a/src/api/rest/providers/github/schemas/orgs.js b/src/api/rest/providers/github/schemas/orgs.js index d9498e3b2..d489e02bd 100644 --- a/src/api/rest/providers/github/schemas/orgs.js +++ b/src/api/rest/providers/github/schemas/orgs.js @@ -1,5 +1,5 @@ import { schema } from 'normalizr'; -import moment from 'moment/min/moment.min'; +import { initSchema, toTimestamp } from 'utils'; export const orgSchema = new schema.Entity( 'orgs', @@ -7,7 +7,7 @@ export const orgSchema = new schema.Entity( { idAttribute: org => org.login.toLowerCase(), processStrategy: entity => { - const processed = {}; + const processed = initSchema(); // These are provided in both mini & full modes processed.id = entity.login.toLowerCase(); // id should be always used for navigation @@ -15,12 +15,6 @@ export const orgSchema = new schema.Entity( processed.avatarUrl = entity.avatar_url; processed.description = entity.description; - // These flags should be in all our schemas. - processed._isComplete = false; // entity not fully fetched yet - processed._isAuth = false; // entity doesn't belong to the auth user - processed._entityUrl = false; // The github url for the entity. To be used in openInBrowser() - processed._fetchedAt = moment().format('X'); - // name is only present in full mode, we base our full parsing on its presence if (typeof entity.name !== 'undefined') { processed.name = entity.name; @@ -33,8 +27,8 @@ export const orgSchema = new schema.Entity( processed._entityUrl = entity.html_url; - processed.createdAt = moment(entity.created_at).format('X'); // as unix timestamp - processed.updatedAt = moment(entity.updated_at).format('X'); // as unix timestamp + processed.createdAt = toTimestamp(entity.created_at); + processed.updatedAt = toTimestamp(entity.updated_at); // Clear avatar cached URL to make sure picture is refetched on profile change processed.avatarUrl += `&updatedAt=${processed.updatedAt}`; diff --git a/src/api/rest/providers/github/schemas/repos.js b/src/api/rest/providers/github/schemas/repos.js index 5c512e924..19cbc87a0 100644 --- a/src/api/rest/providers/github/schemas/repos.js +++ b/src/api/rest/providers/github/schemas/repos.js @@ -1,5 +1,5 @@ import { schema } from 'normalizr'; -import moment from 'moment/min/moment.min'; +import { initSchema } from 'utils'; import { userSchema } from './users'; import { orgSchema } from './orgs'; @@ -16,13 +16,7 @@ export const repoSchema = new schema.Entity( idAttribute: repo => (isInMinimalisticForm(repo) ? repo.name : repo.full_name).toLowerCase(), processStrategy: entity => { - const processed = {}; - - // These flags should be in all our schemas. - processed._isComplete = false; // entity not fully fetched yet - processed._isAuth = false; // entity doesn't belong to the auth user - processed._entityUrl = entity.html_url; // The github url for the entity. To be used in openInBrowser() - processed._fetchedAt = moment().format('X'); + const processed = initSchema(); // Repo received from events if (isInMinimalisticForm(entity)) { @@ -31,13 +25,12 @@ export const repoSchema = new schema.Entity( processed.shortName = entity.name.substring( entity.name.indexOf('/') + 1 ); - processed._entityUrl = `https://github.com/${entity.name}`; return processed; } - processed.id = entity.full_name; + processed.id = entity.full_name.toLowerCase(); processed.fullName = entity.full_name; processed.shortName = entity.name; processed.description = entity.description; @@ -62,46 +55,6 @@ export const repoSchema = new schema.Entity( processed._entityUrl = entity.html_url; - /* - // These are provided in both mini & full modes - processed.id = entity.login; // id should be always used for navigation - processed.login = entity.login; - processed.avatarUrl = entity.avatar_url; - processed.description = entity.description; - - // These flags should be in all our schemas. - processed._isComplete = false; // entity not fully fetched yet - processed._isAuth = false; // entity doesn't belong to the auth user - processed._entityUrl = false; // The github url for the entity. To be used in openInBrowser() - - // name is only present in full mode, we base our full parsing on its presence - if (typeof entity.name !== 'undefined') { - processed.name = entity.name; - processed.webSite = entity.blog; - processed.location = entity.location; - - processed.countPublicRepos = entity.public_repos; - processed.countPrivateRepos = 0; - processed.countRepos = entity.public_repos; - - processed._entityUrl = entity.html_url; - - processed.since = moment(entity.created_at).format('X'); // as unix timestamp - - // The entity is to be considered complete. - processed._isComplete = true; - - if (typeof entity.total_private_repos !== 'undefined') { - // This org belongs to the authenticated user, update some props - processed._isAuth = true; - processed.countPrivateRepos = entity.total_private_repos; - processed.countRepos += entity.total_private_repos; - } - } else { - // We can try our best to fill in some props on our own: - processed._entityUrl = `https://github.com/${processed.id}`; - } */ - return processed; }, } diff --git a/src/api/rest/providers/github/schemas/users.js b/src/api/rest/providers/github/schemas/users.js index d559b72ce..46f62fda6 100644 --- a/src/api/rest/providers/github/schemas/users.js +++ b/src/api/rest/providers/github/schemas/users.js @@ -1,5 +1,5 @@ import { schema } from 'normalizr'; -import moment from 'moment/min/moment.min'; +import { initSchema, toTimestamp } from 'utils'; export const userSchema = new schema.Entity( 'users', @@ -7,19 +7,13 @@ export const userSchema = new schema.Entity( { idAttribute: user => user.login.toLowerCase(), processStrategy: entity => { - const processed = {}; + const processed = initSchema(); // These are provided in both mini & full modes processed.id = entity.login.toLowerCase(); // id should be always used for navigation processed.login = entity.login; processed.avatarUrl = entity.avatar_url; - // These flags should be in all our schemas. - processed._isComplete = false; // entity not fully fetched yet - processed._isAuth = false; // entity doesn't belong to the auth user - processed._entityUrl = false; // The github url for the entity. To be used in openInBrowser() - processed._fetchedAt = moment().format('X'); - // name is only present in full mode, we base our full parsing on its presence if (typeof entity.name !== 'undefined') { processed.fullName = entity.name; @@ -34,8 +28,8 @@ export const userSchema = new schema.Entity( processed.countFollowers = entity.followers; processed.countFollowing = entity.following; - processed.createdAt = moment(entity.created_at).format('X'); // as unix timestamp - processed.updatedAt = moment(entity.updated_at).format('X'); // as unix timestamp + processed.createdAt = toTimestamp(entity.created_at); // as unix timestamp + processed.updatedAt = toTimestamp(entity.updated_at); // as unix timestamp // Clear avatar cached URL to make sure picture is refetched on profile change processed.avatarUrl += `&updatedAt=${processed.updatedAt}`; diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index 946ce522c..3ccc06333 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -518,7 +518,7 @@ class Events extends Component { {this.getSecondItem(userEvent)} {this.getItem(userEvent) && this.getConnector(userEvent) && ' '} - {moment.unix(userEvent.createdAt).fromNow()} + {moment(userEvent.createdAt).fromNow()} ); diff --git a/src/utils/index.js b/src/utils/index.js index 93e20d8fe..2b30ebf22 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,4 +1,5 @@ export * from './action-helper'; +export * from './schema-helper'; export * from './loading-animation'; export * from './text-helper'; export * from './method-helpers'; diff --git a/src/utils/schema-helper.js b/src/utils/schema-helper.js new file mode 100644 index 000000000..ede4c5d51 --- /dev/null +++ b/src/utils/schema-helper.js @@ -0,0 +1,10 @@ +import moment from 'moment/min/moment.min'; + +export const toTimestamp = value => parseInt(moment(value).format('x'), 10); + +export const initSchema = () => ({ + _isComplete: false, // entity not fully fetched yet + _isAuth: false, // entity doesn't belong to the auth user + _entityUrl: false, // The github url for the entity. To be used in openInBrowser() + _fetchedAt: toTimestamp(), +}); From 2304b4bee32a7f6607b73c4a84324fb4811a71b9 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Fri, 13 Oct 2017 12:39:45 +0100 Subject: [PATCH 21/36] fix: pagination/action key should be made of all pureArgs --- src/api/rest/decorators/with-reducers.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/api/rest/decorators/with-reducers.js b/src/api/rest/decorators/with-reducers.js index 27ac38822..ecec4aecd 100644 --- a/src/api/rest/decorators/with-reducers.js +++ b/src/api/rest/decorators/with-reducers.js @@ -26,6 +26,12 @@ export const withReducers = Provider => { ); } + if (!endpoint[call]) { + return displayError( + `Unknown API call. Did you implement client.${namespace}.${call}()?` + ); + } + // Identify if we have our magical last argument const declaredArgsNumber = endpoint[call].length; const isMagicArgAvailable = args.length === declaredArgsNumber; @@ -36,6 +42,7 @@ export const withReducers = Provider => { const magicArg = isMagicArgAvailable ? args[args.length - 1] : {}; const paginator = getState().pagination[actionName]; + const actionKey = pureArgs.join('-'); // pagination or entity ? <- @@ -47,7 +54,7 @@ export const withReducers = Provider => { if (paginator) { const { loadMore = false } = magicArg; const { pageCount = 0, isFetching = false, nextPageUrl } = - paginator[pureArgs.join('-')] || {}; + paginator[actionKey] || {}; if ( isFetching || // Already fetching, don't retrigger a call @@ -66,7 +73,7 @@ export const withReducers = Provider => { } dispatch({ - id: args[0], + id: actionKey, type: Actions[actionName].PENDING, }); @@ -94,7 +101,7 @@ export const withReducers = Provider => { if (paginator) { normalized.pagination = { name: actionName, - key: args[0], + key: actionKey, ids: normalized.result, nextPageUrl: struct.nextPageUrl, }; @@ -104,7 +111,7 @@ export const withReducers = Provider => { // Success, let's dispatch it dispatch({ ...normalized, - id: args[0], + id: actionKey, type: Actions[actionName].SUCCESS, }); @@ -115,7 +122,7 @@ export const withReducers = Provider => { displayError(error.toString()); dispatch({ - id: args[0], + id: actionKey, type: Actions[actionName].ERROR, }); From e7a87f103d57a21cfe951541b0552d4c8e8972cd Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Fri, 13 Oct 2017 14:00:04 +0100 Subject: [PATCH 22/36] feat: add two new decorators: withCounter() and withAuth() --- src/api/rest/decorators/index.js | 2 ++ src/api/rest/decorators/with-auth.js | 17 +++++++++ src/api/rest/decorators/with-counter.js | 48 +++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 src/api/rest/decorators/with-auth.js create mode 100644 src/api/rest/decorators/with-counter.js diff --git a/src/api/rest/decorators/index.js b/src/api/rest/decorators/index.js index 59b10f343..bd489bc20 100644 --- a/src/api/rest/decorators/index.js +++ b/src/api/rest/decorators/index.js @@ -1 +1,3 @@ +export * from './with-auth'; +export * from './with-counter'; export * from './with-reducers'; diff --git a/src/api/rest/decorators/with-auth.js b/src/api/rest/decorators/with-auth.js new file mode 100644 index 000000000..9f9a143ab --- /dev/null +++ b/src/api/rest/decorators/with-auth.js @@ -0,0 +1,17 @@ +export const withAuth = Provider => { + const client = new Provider(); + + return new Proxy(withAuth, { + get: (c, namespace) => { + return new Proxy(client[namespace], { + get: (endpoint, call) => (...args) => (dispatch, getState) => { + // Get accessToken from state + client.setAccessToken(getState().auth.accessToken); + + /* eslint-disable no-unexpected-multiline */ + return endpoint[call](...args); + }, + }); + }, + }); +}; diff --git a/src/api/rest/decorators/with-counter.js b/src/api/rest/decorators/with-counter.js new file mode 100644 index 000000000..0e702a408 --- /dev/null +++ b/src/api/rest/decorators/with-counter.js @@ -0,0 +1,48 @@ +export const withCounter = Provider => { + const client = new Provider(); + + return new Proxy(withCounter, { + get: (c, namespace) => { + return new Proxy(client[namespace], { + get: (endpoint, call) => (...args) => (dispatch, getState) => { + // Get accessToken from state + client.setAccessToken(getState().auth.accessToken); + + // Identify if we have our magical last argument + const declaredArgsNumber = endpoint[call].length; + const isMagicArgAvailable = args.length === declaredArgsNumber; + + const pureArgs = isMagicArgAvailable + ? args.slice(0, args.length - 1) + : args; + const magicArg = isMagicArgAvailable ? args[args.length - 1] : {}; + + magicArg.per_page = 1; + + /* eslint-disable no-unexpected-multiline */ + return endpoint[call]([...pureArgs, magicArg]).then(struct => { + if (struct.response.status === 404) { + return 0; + } + + let linkHeader = struct.response.headers.get('Link'); + let number; + + if (linkHeader !== null) { + linkHeader = linkHeader.match(/page=(\d)+/g).pop(); + number = linkHeader.split('=').pop(); + } else { + // TODO: copied from v3.count(), but doesn't make sense. + // If we're passing per_page=1, we should be getting one response. + number = struct.response.json().then(data => { + return data.length; + }); + } + + return number; + }); + }, + }); + }, + }); +}; From da4e6b1395bbba0f838f3819d4bfad51f7564642 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sun, 15 Oct 2017 13:52:37 +0100 Subject: [PATCH 23/36] refactor: add helpers for decorators --- src/api/rest/decorators/with-reducers.js | 44 +++++++++--------------- src/utils/decorator-helpers.js | 27 +++++++++++++++ src/utils/index.js | 1 + 3 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 src/utils/decorator-helpers.js diff --git a/src/api/rest/decorators/with-reducers.js b/src/api/rest/decorators/with-reducers.js index ecec4aecd..6b574a1be 100644 --- a/src/api/rest/decorators/with-reducers.js +++ b/src/api/rest/decorators/with-reducers.js @@ -1,10 +1,11 @@ import * as Actions from 'api/rest/actions'; import { normalize } from 'normalizr'; -import { Alert } from 'react-native'; -const displayError = error => { - Alert.alert('API Error', error); -}; +import { + splitArgs, + displayError, + actionNameForCall, +} from 'utils/decorator-helpers'; export const withReducers = Provider => { const client = new Provider(); @@ -13,11 +14,14 @@ export const withReducers = Provider => { get: (c, namespace) => { return new Proxy(client[namespace], { get: (endpoint, call) => (...args) => (dispatch, getState) => { - // Used as a key for state.pagination - const actionName = `${namespace}_${call}` - .replace(/([A-Z])/g, '_$1') - .toUpperCase(); + if (!endpoint[call]) { + return displayError( + `Unknown API call. Did you implement client.${namespace}.${call}()?` + ); + } + // Used as a key for state.pagination + const actionName = actionNameForCall(namespace, call); const action = Actions[actionName]; if (!action) { @@ -26,29 +30,10 @@ export const withReducers = Provider => { ); } - if (!endpoint[call]) { - return displayError( - `Unknown API call. Did you implement client.${namespace}.${call}()?` - ); - } - - // Identify if we have our magical last argument - const declaredArgsNumber = endpoint[call].length; - const isMagicArgAvailable = args.length === declaredArgsNumber; - - const pureArgs = isMagicArgAvailable - ? args.slice(0, args.length - 1) - : args; - const magicArg = isMagicArgAvailable ? args[args.length - 1] : {}; - + const { pureArgs, magicArg } = splitArgs(endpoint[call], args); const paginator = getState().pagination[actionName]; const actionKey = pureArgs.join('-'); - // pagination or entity ? <- - - // Get accessToken from state - client.setAccessToken(getState().auth.accessToken); - let finalArgs = args; if (paginator) { @@ -72,6 +57,9 @@ export const withReducers = Provider => { finalArgs = [...pureArgs, magicArg]; } + // Get accessToken from state + client.setAccessToken(getState().auth.accessToken); + dispatch({ id: actionKey, type: Actions[actionName].PENDING, diff --git a/src/utils/decorator-helpers.js b/src/utils/decorator-helpers.js new file mode 100644 index 000000000..ebf91c8bf --- /dev/null +++ b/src/utils/decorator-helpers.js @@ -0,0 +1,27 @@ +import { Alert } from 'react-native'; + +export const actionNameForCall = (namespace, method, prefix = '') => { + const upperCased = `${namespace}_${method}` + .replace(/([A-Z])/g, '_$1') + .toUpperCase(); + + return `${prefix}${upperCased}`; +}; + +export const splitArgs = (fn, args) => { + const declaredArgsNumber = fn.length; + const isMagicArgAvailable = args.length === declaredArgsNumber; + + return { + pureArgs: isMagicArgAvailable ? args.slice(0, args.length - 1) : args, + magicArg: isMagicArgAvailable ? args[args.length - 1] : {}, + }; +}; + +// TODO: used mainly for developping this PR, but may come handy for #430 +export const displayError = error => { + Alert.alert('API Error', error); +}; + +export const getCountFromState = (state, name, key) => + state.counters[name] ? state.counters[name][key] : 0; diff --git a/src/utils/index.js b/src/utils/index.js index 2b30ebf22..a4cfc48ad 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -3,3 +3,4 @@ export * from './schema-helper'; export * from './loading-animation'; export * from './text-helper'; export * from './method-helpers'; +export * from './decorator-helpers'; From 80558622c628d668f61c0bd3b16909d3d96d6bc7 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sun, 15 Oct 2017 13:56:10 +0100 Subject: [PATCH 24/36] feat: make withCounter() store its computed data in store.counters --- App.js | 8 ++- root.reducer.js | 8 ++- src/api/rest/decorators/with-counter.js | 94 +++++++++++++++++-------- src/api/rest/reducers/counters.js | 15 ++++ src/api/rest/reducers/index.js | 1 + 5 files changed, 96 insertions(+), 30 deletions(-) create mode 100644 src/api/rest/reducers/counters.js diff --git a/App.js b/App.js index 09db06da2..97bb79c17 100644 --- a/App.js +++ b/App.js @@ -62,7 +62,13 @@ class App extends Component { { storage: AsyncStorage, transforms: [encryptor], - blacklist: ['entities', 'pagination', 'errorMessage', 'user'], + blacklist: [ + 'counters', + 'entities', + 'pagination', + 'errorMessage', + 'user', + ], }, () => { this.setState({ rehydrated: true }); diff --git a/root.reducer.js b/root.reducer.js index 139f00ffb..e752bbb92 100644 --- a/root.reducer.js +++ b/root.reducer.js @@ -6,10 +6,16 @@ import { issueReducer } from 'issue'; import { searchReducer } from 'search'; import { notificationsReducer } from 'notifications'; -import { entities, pagination, errorMessage } from 'api/rest/reducers'; +import { + entities, + counters, + pagination, + errorMessage, +} from 'api/rest/reducers'; export const rootReducer = combineReducers({ entities, + counters, pagination, errorMessage, auth: authReducer, diff --git a/src/api/rest/decorators/with-counter.js b/src/api/rest/decorators/with-counter.js index 0e702a408..2252b8c5d 100644 --- a/src/api/rest/decorators/with-counter.js +++ b/src/api/rest/decorators/with-counter.js @@ -1,3 +1,11 @@ +import * as Actions from 'api/rest/actions'; + +import { + splitArgs, + displayError, + actionNameForCall, +} from 'utils/decorator-helpers'; + export const withCounter = Provider => { const client = new Provider(); @@ -5,42 +13,72 @@ export const withCounter = Provider => { get: (c, namespace) => { return new Proxy(client[namespace], { get: (endpoint, call) => (...args) => (dispatch, getState) => { - // Get accessToken from state - client.setAccessToken(getState().auth.accessToken); + if (!endpoint[call]) { + return displayError( + `Unknown API call. Did you implement client.${namespace}.${call}()?` + ); + } - // Identify if we have our magical last argument - const declaredArgsNumber = endpoint[call].length; - const isMagicArgAvailable = args.length === declaredArgsNumber; + // Used as a key for state.pagination + const actionName = actionNameForCall(namespace, call, 'COUNT_'); - const pureArgs = isMagicArgAvailable - ? args.slice(0, args.length - 1) - : args; - const magicArg = isMagicArgAvailable ? args[args.length - 1] : {}; + const { pureArgs, magicArg } = splitArgs(endpoint[call], args); magicArg.per_page = 1; + const finalArgs = [...pureArgs, magicArg]; + const actionKey = pureArgs.join('-'); + + dispatch({ + key: actionKey, + type: Actions[actionName].PENDING, + }); + + client.setAccessToken(getState().auth.accessToken); + /* eslint-disable no-unexpected-multiline */ - return endpoint[call]([...pureArgs, magicArg]).then(struct => { - if (struct.response.status === 404) { - return 0; - } - - let linkHeader = struct.response.headers.get('Link'); - let number; - - if (linkHeader !== null) { - linkHeader = linkHeader.match(/page=(\d)+/g).pop(); - number = linkHeader.split('=').pop(); - } else { - // TODO: copied from v3.count(), but doesn't make sense. - // If we're passing per_page=1, we should be getting one response. - number = struct.response.json().then(data => { - return data.length; + return endpoint + [call](...finalArgs) + .then(struct => { + if (struct.response.status === 404) { + return 0; + } + + let linkHeader = struct.response.headers.get('Link'); + let number; + + if (linkHeader !== null) { + linkHeader = linkHeader.match(/page=(\d)+/g).pop(); + number = linkHeader.split('=').pop(); + dispatch({ + counters: number, + key: actionKey, + name: actionName, + type: Actions[actionName].SUCCESS, + }); + } else { + number = struct.response.json().then(data => { + dispatch({ + counters: data.length, + key: actionKey, + name: actionName, + type: Actions[actionName].SUCCESS, + }); + }); + } + + return number; + }) + .catch(error => { + displayError(error.toString()); + + dispatch({ + key: actionKey, + type: Actions[actionName].ERROR, }); - } - return number; - }); + return error; + }); }, }); }, diff --git a/src/api/rest/reducers/counters.js b/src/api/rest/reducers/counters.js new file mode 100644 index 000000000..3b72920d9 --- /dev/null +++ b/src/api/rest/reducers/counters.js @@ -0,0 +1,15 @@ +import { merge } from 'lodash'; + +// Updates an entity cache in response to any action with response.counters. +export const counters = (state = {}, action) => { + if (action && action.counters) { + const pushed = {}; + + pushed[action.name] = {}; + pushed[action.name][action.key] = action.counters; + + return merge({}, state, pushed); + } + + return state; +}; diff --git a/src/api/rest/reducers/index.js b/src/api/rest/reducers/index.js index aec34e6a0..61ce5c113 100644 --- a/src/api/rest/reducers/index.js +++ b/src/api/rest/reducers/index.js @@ -1,3 +1,4 @@ export * from './entities'; +export * from './counters'; export * from './pagination'; export * from './errorMessage'; From df3515f7d349445809a7fe84229f1bfa1a3162fc Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sun, 15 Oct 2017 18:26:57 +0100 Subject: [PATCH 25/36] refactor: switch notifications to the new API --- src/api/rest/actions/activity.js | 10 + src/api/rest/decorators/with-counter.js | 8 +- src/api/rest/decorators/with-reducers.js | 21 +- src/api/rest/providers/base/client.js | 15 +- src/api/rest/providers/github/client.js | 38 +++ .../rest/providers/github/schemas/index.js | 3 + .../providers/github/schemas/notifications.js | 25 ++ src/api/rest/reducers/pagination.js | 1 + src/auth/screens/events.screen.js | 17 +- src/components/notification-icon.component.js | 8 +- .../notification-list-item.component.js | 8 +- .../screens/notifications.screen.js | 278 +++++++++++------- src/utils/decorator-helpers.js | 10 + 13 files changed, 315 insertions(+), 127 deletions(-) create mode 100644 src/api/rest/providers/github/schemas/notifications.js diff --git a/src/api/rest/actions/activity.js b/src/api/rest/actions/activity.js index 8a47386a5..4a4f1b699 100644 --- a/src/api/rest/actions/activity.js +++ b/src/api/rest/actions/activity.js @@ -3,3 +3,13 @@ import { createActionSet } from 'utils'; export const ACTIVITY_GET_EVENTS_RECEIVED = createActionSet( 'ACTIVITY_GET_EVENTS_RECEIVED' ); +export const ACTIVITY_GET_NOTIFICATIONS = createActionSet( + 'ACTIVITY_GET_NOTIFICATIONS' +); + +export const COUNT_ACTIVITY_GET_NOTIFICATIONS = createActionSet( + 'COUNT_ACTIVITY_GET_NOTIFICATIONS' +); +export const ACTIVITY_MARK_NOTIFICATION_THREAD_AS_READ = createActionSet( + 'ACTIVITY_MARK_NOTIFICATION_THREAD_AS_READ' +); diff --git a/src/api/rest/decorators/with-counter.js b/src/api/rest/decorators/with-counter.js index 2252b8c5d..b83ffd811 100644 --- a/src/api/rest/decorators/with-counter.js +++ b/src/api/rest/decorators/with-counter.js @@ -45,19 +45,17 @@ export const withCounter = Provider => { } let linkHeader = struct.response.headers.get('Link'); - let number; if (linkHeader !== null) { linkHeader = linkHeader.match(/page=(\d)+/g).pop(); - number = linkHeader.split('=').pop(); dispatch({ - counters: number, + counters: linkHeader.split('=').pop(), key: actionKey, name: actionName, type: Actions[actionName].SUCCESS, }); } else { - number = struct.response.json().then(data => { + struct.response.json().then(data => { dispatch({ counters: data.length, key: actionKey, @@ -67,7 +65,7 @@ export const withCounter = Provider => { }); } - return number; + return Promise.resolve(); }) .catch(error => { displayError(error.toString()); diff --git a/src/api/rest/decorators/with-reducers.js b/src/api/rest/decorators/with-reducers.js index 6b574a1be..03eedc212 100644 --- a/src/api/rest/decorators/with-reducers.js +++ b/src/api/rest/decorators/with-reducers.js @@ -37,14 +37,15 @@ export const withReducers = Provider => { let finalArgs = args; if (paginator) { - const { loadMore = false } = magicArg; + const { loadMore = false, forceRefresh = false } = magicArg; const { pageCount = 0, isFetching = false, nextPageUrl } = paginator[actionKey] || {}; if ( - isFetching || // Already fetching, don't retrigger a call + !forceRefresh && + (isFetching || // Already fetching, don't retrigger a call (pageCount > 0 && !loadMore) || // We already have the first page of data - (loadMore && !nextPageUrl) // We've already fetched the last page + (loadMore && !nextPageUrl)) // We've already fetched the last page ) { return Promise.resolve(); } @@ -52,6 +53,9 @@ export const withReducers = Provider => { if (loadMore) { // next page explicitely requested magicArg.url = nextPageUrl; + } else if (forceRefresh) { + // TODO: reset pagination state properly via an action + // console.log('TODO: reset pagination'); } finalArgs = [...pureArgs, magicArg]; @@ -79,6 +83,17 @@ export const withReducers = Provider => { }); } + // Successful PUT or PATCH request, there will be no JSON, simply dispatch success + // TODO: withReducers() is not an accurate name anymore here. + if (struct.response.status === 205) { + dispatch({ + id: actionKey, + type: Actions[actionName].SUCCESS, + }); + + return Promise.resolve(); + } + return struct.response.json().then(json => { // Treat the JSON & normalize it const normalized = normalize( diff --git a/src/api/rest/providers/base/client.js b/src/api/rest/providers/base/client.js index 54d948728..0f3279e56 100644 --- a/src/api/rest/providers/base/client.js +++ b/src/api/rest/providers/base/client.js @@ -18,7 +18,20 @@ export class Client { }, params = {} ) => { - let finalUrl = params.url || url; + let finalUrl; + + if (params.url) { + // a different url was provided, use it instead (paginated) + finalUrl = params.url; + } else { + finalUrl = url; + // add explicitely specified parameters + if (params.per_page) { + finalUrl = `${finalUrl}${finalUrl.indexOf('?') !== -1 + ? '&' + : '?'}per_page=${params.per_page}`; + } + } if (finalUrl.indexOf(this.API_ROOT) === -1) { finalUrl = `${this.API_ROOT}${finalUrl}`; diff --git a/src/api/rest/providers/github/client.js b/src/api/rest/providers/github/client.js index 9bf2ce7ca..a33183e5d 100644 --- a/src/api/rest/providers/github/client.js +++ b/src/api/rest/providers/github/client.js @@ -105,6 +105,44 @@ export class Github extends Client { }; }); }, + /** + * Get all notifications for the current user + * + * @param {boolean} all If true, show notifications marked as read. + * @param {boolean} participating If true, in which the user is directly participating or mentioned. + */ + getNotifications: async (all, participating, params) => { + const finalParams = { + per_page: 100, + ...params, + }; + + return this.fetch( + `notifications?all=${all}&participating=${participating}`, + { + schema: Schemas.NOTIFICATION_ARRAY, + }, + finalParams + ).then(struct => { + return { + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + }; + }); + }, + markNotificationThreadAsRead: async (id, params) => { + return this.fetch( + `notifications/threads/${id}`, + { + method: this.Method.PATCH, + }, + params + ).then(struct => { + return { + ...struct, + }; + }); + }, }; search = { diff --git a/src/api/rest/providers/github/schemas/index.js b/src/api/rest/providers/github/schemas/index.js index 694a6b017..61c78a5d0 100644 --- a/src/api/rest/providers/github/schemas/index.js +++ b/src/api/rest/providers/github/schemas/index.js @@ -2,6 +2,7 @@ import { orgSchema } from './orgs'; import { userSchema } from './users'; import { repoSchema } from './repos'; import { eventSchema } from './events'; +import { notificationSchema } from './notifications'; export default { USER: userSchema, @@ -12,4 +13,6 @@ export default { REPO_ARRAY: [repoSchema], EVENT: eventSchema, EVENT_ARRAY: [eventSchema], + NOTIFICATION: notificationSchema, + NOTIFICATION_ARRAY: [notificationSchema], }; diff --git a/src/api/rest/providers/github/schemas/notifications.js b/src/api/rest/providers/github/schemas/notifications.js new file mode 100644 index 000000000..65de77429 --- /dev/null +++ b/src/api/rest/providers/github/schemas/notifications.js @@ -0,0 +1,25 @@ +import { schema } from 'normalizr'; +import { initSchema, toTimestamp } from 'utils'; + +import { repoSchema } from './repos'; + +export const notificationSchema = new schema.Entity( + 'notifications', + { + repo: repoSchema, + }, + { + idAttribute: notification => notification.id, + processStrategy: entity => ({ + ...initSchema(), + id: entity.id, + updatedAt: toTimestamp(entity.updated_at), + unread: entity.unread, + reason: entity.reason, // TODO: normalize it + type: entity.subject.type, // TODO: normalize it + link: entity.subject.url, + title: entity.subject.title, + repo: entity.repository, + }), + } +); diff --git a/src/api/rest/reducers/pagination.js b/src/api/rest/reducers/pagination.js index 318dffb37..8432b414c 100644 --- a/src/api/rest/reducers/pagination.js +++ b/src/api/rest/reducers/pagination.js @@ -73,4 +73,5 @@ export const pagination = combineReducers({ ORGS_GET_REPOS: paginate(Actions.ORGS_GET_REPOS), ORGS_GET_MEMBERS: paginate(Actions.ORGS_GET_MEMBERS), SEARCH_GET_REPOS: paginate(Actions.SEARCH_GET_REPOS), + ACTIVITY_GET_NOTIFICATIONS: paginate(Actions.ACTIVITY_GET_NOTIFICATIONS), }); diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index 3ccc06333..e2d94d7bf 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -15,9 +15,10 @@ import { colors, fonts, normalize } from 'config'; import { emojifyText, translate } from 'utils'; import { Github } from 'api/rest/providers/github'; -import { withReducers } from 'api/rest/decorators'; +import { withReducers, withCounter } from 'api/rest/decorators'; const client = withReducers(Github); +const countingClient = withCounter(Github); const mapStateToProps = state => { const { @@ -92,23 +93,22 @@ const styles = StyleSheet.create({ class Events extends Component { componentDidMount() { - const { getEvents, user: { login } } = this.props; + const { getEvents, getNotificationsCount, user: { login } } = this.props; getEvents(login); + getNotificationsCount(false, false); } componentWillReceiveProps(nextProps) { + // TODO: not sure if needed if (nextProps.user.login && !this.props.user.login) { - this.nextProps.getUserEvents(); + this.nextProps.getEvents(nextProps.user.login); } } /* - // TODO: Put back getNotificationsCount && getUser - getUserEvents = ({ user, accessToken } = this.props) => { - // this.props.getUserEvents(user.login); - // this.props.getNotificationsCount(accessToken); - }; */ + // TODO: getUser() should be called and waited for in the auth process + */ getAction = userEvent => { const { language } = this.props; @@ -616,4 +616,5 @@ class Events extends Component { export const EventsScreen = connect(mapStateToProps, { getEvents: client.activity.getEventsReceived, + getNotificationsCount: countingClient.activity.getNotifications, })(Events); diff --git a/src/components/notification-icon.component.js b/src/components/notification-icon.component.js index 267730fa8..a417cf4cf 100644 --- a/src/components/notification-icon.component.js +++ b/src/components/notification-icon.component.js @@ -5,8 +5,8 @@ import { View, StyleSheet } from 'react-native'; import { Icon } from 'react-native-elements'; import { Badge } from 'components'; - import { colors } from 'config'; +import { getCountFromState } from 'utils'; const styles = StyleSheet.create({ badgeContainer: { @@ -17,7 +17,11 @@ const styles = StyleSheet.create({ }); const mapStateToProps = state => ({ - notificationsCount: state.notifications.notificationsCount, + notificationsCount: getCountFromState( + state, + 'COUNT_ACTIVITY_GET_NOTIFICATIONS', + 'false-false' + ), }); class NotificationIconComponent extends Component { diff --git a/src/components/notification-list-item.component.js b/src/components/notification-list-item.component.js index 01730fd99..a706387b5 100644 --- a/src/components/notification-list-item.component.js +++ b/src/components/notification-list-item.component.js @@ -54,7 +54,7 @@ export const NotificationListItem = ({ navigationAction, }: Props) => { const TitleComponent = - notification.subject.type === 'Commit' ? View : TouchableOpacity; + notification.type === 'Commit' ? View : TouchableOpacity; return ( @@ -67,9 +67,9 @@ export const NotificationListItem = ({ color={colors.grey} size={22} name={ - notification.subject.type === 'Commit' + notification.type === 'Commit' ? 'git-commit' - : notification.subject.type === 'PullRequest' + : notification.type === 'PullRequest' ? 'git-pull-request' : 'issue-opened' } @@ -78,7 +78,7 @@ export const NotificationListItem = ({ - {notification.subject.title} + {notification.title} diff --git a/src/notifications/screens/notifications.screen.js b/src/notifications/screens/notifications.screen.js index b1f416627..82d4e0bd6 100644 --- a/src/notifications/screens/notifications.screen.js +++ b/src/notifications/screens/notifications.screen.js @@ -2,7 +2,6 @@ /* eslint-disable no-shadow */ import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; import { StyleSheet, FlatList, @@ -15,6 +14,9 @@ import { } from 'react-native'; import { ButtonGroup, Card, Icon } from 'react-native-elements'; +import { Github } from 'api/rest/providers/github'; +import { withReducers, withCounter } from 'api/rest/decorators'; + import { v3 } from 'api'; import { Button, @@ -23,43 +25,68 @@ import { NotificationListItem, } from 'components'; import { colors, fonts, normalize } from 'config'; -import { translate } from 'utils'; -import { - getUnreadNotifications, - getParticipatingNotifications, - getAllNotifications, +import { translate, getPaginationFromState } from 'utils'; +/* import { markAsRead, markRepoAsRead, - getNotificationsCount, markAllNotificationsAsRead, -} from '../index'; - -const mapStateToProps = state => ({ - unread: state.notifications.unread, - participating: state.notifications.participating, - all: state.notifications.all, - issue: state.issue.issue, - language: state.auth.language, - isPendingUnread: state.notifications.isPendingUnread, - isPendingParticipating: state.notifications.isPendingParticipating, - isPendingAll: state.notifications.isPendingAll, - isPendingMarkAllNotificationsAsRead: - state.notifications.isPendingMarkAllNotificationsAsRead, -}); +} from '../index'; */ -const mapDispatchToProps = dispatch => - bindActionCreators( +const mapStateToProps = state => { + const { entities: { orgs, repos, users, notifications } } = state; + + const unreadPagination = getPaginationFromState( + state, + 'ACTIVITY_GET_NOTIFICATIONS', + 'false-false', { - getUnreadNotifications, - getParticipatingNotifications, - getAllNotifications, - markAsRead, - markRepoAsRead, - getNotificationsCount, - markAllNotificationsAsRead, - }, - dispatch, + ids: [], + isFetching: true, + } ); + const unread = unreadPagination.ids.map(id => notifications[id]); + + const participatingPagination = getPaginationFromState( + state, + 'ACTIVITY_GET_NOTIFICATIONS', + 'true-false', + { + ids: [], + isFetching: true, + } + ); + const participating = participatingPagination.ids.map( + id => notifications[id] + ); + + const allPagination = getPaginationFromState( + state, + 'ACTIVITY_GET_NOTIFICATIONS', + 'false-true', + { + ids: [], + isFetching: true, + } + ); + const all = allPagination.ids.map(id => notifications[id]); + + return { + unread, + unreadPagination, + participating, + participatingPagination, + all, + allPagination, + issue: state.issue.issue, // ? + language: state.auth.language, + repos, + users, + orgs, + // TODO: Remove me + isPendingMarkAllNotificationsAsRead: + state.notifications.isPendingMarkAllNotificationsAsRead, + }; +}; const styles = StyleSheet.create({ buttonGroupWrapper: { @@ -134,24 +161,36 @@ const styles = StyleSheet.create({ }, }); +const NotificationsType = { + UNREAD: 0, + PARTICIPATING: 1, + ALL: 2, +}; + class Notifications extends Component { props: { - getUnreadNotifications: Function, - getParticipatingNotifications: Function, - getAllNotifications: Function, - markAsRead: Function, - markRepoAsRead: Function, + getNotifications: Function, getNotificationsCount: Function, + markThreadAsRead: Function, + markRepoAsRead: Function, markAllNotificationsAsRead: Function, + // Arrays holding notifications unread: Array, participating: Array, all: Array, + // Their paginations + unreadPagination: Array, + participatingPagination: Array, + allPagination: Array, + language: string, - isPendingUnread: boolean, - isPendingParticipating: boolean, - isPendingAll: boolean, - isPendingMarkAllNotificationsAsRead: boolean, navigation: Object, + repos: Array, + users: Array, + orgs: Array, + + // TODO: get rid of those + isPendingMarkAllNotificationsAsRead: boolean, }; constructor() { @@ -171,8 +210,9 @@ class Notifications extends Component { this.getNotifications(); } + // TODO: clean me componentWillReceiveProps(nextProps) { - const pendingType = this.getPendingType(); + const paginationName = this.getPaginationName(); if ( !nextProps.isPendingMarkAllNotificationsAsRead && @@ -182,53 +222,65 @@ class Notifications extends Component { this.getNotificationsForCurrentType()(); } - if (!nextProps[pendingType] && this.props[pendingType]) { - this.props.getNotificationsCount(); + if ( + !nextProps[paginationName].isFetching && + this.props[paginationName].isFetching + ) { + this.props.getNotificationsCount(false, false); } } + // OK getImage(repoName) { + const { repos, users, orgs } = this.props; const notificationForRepo = this.notifications().find( - notification => notification.repository.full_name === repoName, + notification => notification.repo === repoName ); - return notificationForRepo.repository.owner.avatar_url; + const repository = repos[notificationForRepo.repo]; + + return repository.userOwner + ? users[repository.userOwner].avatarUrl + : orgs[repository.orgOwner].avatarUrl; } + // OK getNotifications() { - this.props.getUnreadNotifications(); - this.props.getParticipatingNotifications(); - this.props.getAllNotifications(); - this.props.getNotificationsCount(); + this.props.getNotifications(false, false); // unread + this.props.getNotifications(true, false); // participating + this.props.getNotifications(false, true); // all } + // OK getNotificationsForCurrentType() { - const { - getUnreadNotifications, - getParticipatingNotifications, - getAllNotifications, - } = this.props; const { type } = this.state; + const { getNotifications } = this.props; switch (type) { - case 0: - return getUnreadNotifications; - case 1: - return getParticipatingNotifications; - case 2: - return getAllNotifications; + case NotificationsType.UNREAD: + return () => + getNotifications(false, false, { + forceRefresh: true, + }); + case NotificationsType.PARTICIPATING: + return () => + getNotifications(true, false, { + forceRefresh: true, + }); + case NotificationsType.ALL: + return () => + getNotifications(false, true, { + forceRefresh: true, + }); default: return null; } } + // OK getSortedRepos = () => { const repositories = [ - ...new Set( - this.notifications().map( - notification => notification.repository.full_name, - ), - ), + ...new Set(this.notifications().map(notification => notification.repo)), ]; return repositories.sort((a, b) => { @@ -236,21 +288,23 @@ class Notifications extends Component { }); }; - getPendingType = () => { + // OK + getPaginationName = () => { const { type } = this.state; switch (type) { - case 0: - return 'isPendingUnread'; - case 1: - return 'isPendingParticipating'; - case 2: - return 'isPendingAll'; + case NotificationsType.UNREAD: + return 'unreadPagination'; + case NotificationsType.PARTICIPATING: + return 'participatingPagination'; + case NotificationsType.ALL: + return 'allPagination'; default: return null; } }; + // OK navigateToRepo = fullName => { const { navigation } = this.props; @@ -259,57 +313,62 @@ class Notifications extends Component { }); }; + // OK saveContentBlockHeight = e => { const { height } = e.nativeEvent.layout; this.setState({ contentBlockHeight: height }); }; + // OK keyExtractor = (item, index) => { return index; }; + // TODO: clean me isLoading() { const { unread, + unreadPagination, participating, + participatingPagination, all, - isPendingUnread, - isPendingParticipating, - isPendingAll, + allPagination, } = this.props; const { type } = this.state; switch (type) { - case 0: - return unread && isPendingUnread; - case 1: - return participating && isPendingParticipating; - case 2: - return all && isPendingAll; + case NotificationsType.UNREAD: + return unread && unreadPagination.isFetching; + case NotificationsType.PARTICIPATING: + return participating && participatingPagination.isFetching; + case NotificationsType.ALL: + return all && allPagination.isFetching; default: return false; } } + // OK notifications() { const { unread, participating, all } = this.props; const { type } = this.state; switch (type) { - case 0: + case NotificationsType.UNREAD: return unread; - case 1: + case NotificationsType.PARTICIPATING: return participating; - case 2: + case NotificationsType.ALL: return all; default: return []; } } + // OK switchType(selectedType) { - const { unread, participating, all } = this.props; + const { unread, participating, all, getNotifications } = this.props; if (this.state.type !== selectedType) { this.setState({ @@ -317,12 +376,15 @@ class Notifications extends Component { }); } - if (selectedType === 0 && unread.length === 0) { - this.props.getUnreadNotifications(); - } else if (selectedType === 1 && participating.length === 0) { - this.props.getParticipatingNotifications(); - } else if (selectedType === 2 && all.length === 0) { - this.props.getAllNotifications(); + if (selectedType === NotificationsType.UNREAD && unread.length === 0) { + getNotifications(false, false); + } else if ( + selectedType === NotificationsType.PARTICIPATING && + participating.length === 0 + ) { + getNotifications(true, false); + } else if (selectedType === NotificationsType.ALL && all.length === 0) { + getNotifications(false, true); } if (this.notifications().length > 0) { @@ -334,17 +396,19 @@ class Notifications extends Component { } } + // OK navigateToThread(notification) { - const { markAsRead, navigation } = this.props; + const { markThreadAsRead, navigation } = this.props; - markAsRead(notification.id); + markThreadAsRead(notification.id); navigation.navigate('Issue', { - issueURL: notification.subject.url.replace(/pulls\/(\d+)$/, 'issues/$1'), - isPR: notification.subject.type === 'PullRequest', + issueURL: notification.link.replace(/pulls\/(\d+)$/, 'issues/$1'), + isPR: notification.type === 'PullRequest', language: this.props.language, }); } + // OK navigateToRepo = fullName => { const { navigation } = this.props; @@ -353,15 +417,16 @@ class Notifications extends Component { }); }; + // TODO: Implement & use new api calls renderItem = ({ item }) => { const { - markAsRead, + markThreadAsRead, markRepoAsRead, markAllNotificationsAsRead, } = this.props; const { type } = this.state; const notifications = this.notifications().filter( - notification => notification.repository.full_name === item, + notification => notification.repo === item ); const isFirstItem = this.getSortedRepos().indexOf(item) === 0; const isFirstTab = type === 0; @@ -412,10 +477,10 @@ class Notifications extends Component { markAsRead(notificationID)} + iconAction={notificationID => markThreadAsRead(notificationID)} navigationAction={notify => this.navigateToThread(notify)} navigation={this.props.navigation} - />, + /> )} @@ -463,7 +528,7 @@ class Notifications extends Component { animating={isRetrievingNotifications} text={translate( 'notifications.main.retrievingMessage', - language, + language )} style={styles.marginSpacing} center @@ -502,6 +567,11 @@ class Notifications extends Component { } } -export const NotificationsScreen = connect(mapStateToProps, mapDispatchToProps)( - Notifications, -); +const client = withReducers(Github); +const countingClient = withCounter(Github); + +export const NotificationsScreen = connect(mapStateToProps, { + getNotifications: client.activity.getNotifications, + markThreadAsRead: client.activity.markNotificationThreadAsRead, + getNotificationsCount: countingClient.activity.getNotifications, +})(Notifications); diff --git a/src/utils/decorator-helpers.js b/src/utils/decorator-helpers.js index ebf91c8bf..f671998d5 100644 --- a/src/utils/decorator-helpers.js +++ b/src/utils/decorator-helpers.js @@ -25,3 +25,13 @@ export const displayError = error => { export const getCountFromState = (state, name, key) => state.counters[name] ? state.counters[name][key] : 0; + +export const getPaginationFromState = ( + state, + name, + key, + initialValue = { ids: [] } +) => + state.pagination[name] && state.pagination[name][key] + ? state.pagination[name][key] + : initialValue; From 1d65a0a2617aaa3373c03ae7ce78ed79247c9c94 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sun, 15 Oct 2017 22:06:12 +0100 Subject: [PATCH 26/36] fix: Some naming/code clean up using Alexey reviews --- src/api/rest/decorators/with-auth.js | 7 ++--- src/api/rest/decorators/with-counter.js | 18 ++++++------ src/api/rest/decorators/with-reducers.js | 29 ++++++++++--------- src/api/rest/providers/github/client.js | 6 ++-- src/auth/screens/events.screen.js | 6 ++-- .../screens/notifications.screen.js | 6 ++-- .../screens/organization-profile.screen.js | 4 +-- .../organization-repository-list.screen.js | 4 +-- src/utils/decorator-helpers.js | 8 ++--- 9 files changed, 45 insertions(+), 43 deletions(-) diff --git a/src/api/rest/decorators/with-auth.js b/src/api/rest/decorators/with-auth.js index 9f9a143ab..a34bb297c 100644 --- a/src/api/rest/decorators/with-auth.js +++ b/src/api/rest/decorators/with-auth.js @@ -4,12 +4,11 @@ export const withAuth = Provider => { return new Proxy(withAuth, { get: (c, namespace) => { return new Proxy(client[namespace], { - get: (endpoint, call) => (...args) => (dispatch, getState) => { + get: (endpoint, method) => (...args) => (dispatch, getState) => { // Get accessToken from state - client.setAccessToken(getState().auth.accessToken); + client.setAuthHeaders(getState().auth.accessToken); - /* eslint-disable no-unexpected-multiline */ - return endpoint[call](...args); + return endpoint[method](...args); }, }); }, diff --git a/src/api/rest/decorators/with-counter.js b/src/api/rest/decorators/with-counter.js index b83ffd811..ad6f8b788 100644 --- a/src/api/rest/decorators/with-counter.js +++ b/src/api/rest/decorators/with-counter.js @@ -12,21 +12,21 @@ export const withCounter = Provider => { return new Proxy(withCounter, { get: (c, namespace) => { return new Proxy(client[namespace], { - get: (endpoint, call) => (...args) => (dispatch, getState) => { - if (!endpoint[call]) { + get: (endpoint, method) => (...args) => (dispatch, getState) => { + if (!endpoint[method]) { return displayError( - `Unknown API call. Did you implement client.${namespace}.${call}()?` + `Unknown API method. Did you implement client.${namespace}.${method}()?` ); } // Used as a key for state.pagination - const actionName = actionNameForCall(namespace, call, 'COUNT_'); + const actionName = actionNameForCall(namespace, method, 'COUNT_'); - const { pureArgs, magicArg } = splitArgs(endpoint[call], args); + const { pureArgs, extraArg } = splitArgs(endpoint[method], args); - magicArg.per_page = 1; + extraArg.per_page = 1; - const finalArgs = [...pureArgs, magicArg]; + const finalArgs = [...pureArgs, extraArg]; const actionKey = pureArgs.join('-'); dispatch({ @@ -34,11 +34,11 @@ export const withCounter = Provider => { type: Actions[actionName].PENDING, }); - client.setAccessToken(getState().auth.accessToken); + client.setAuthHeaders(getState().auth.accessToken); /* eslint-disable no-unexpected-multiline */ return endpoint - [call](...finalArgs) + [method](...finalArgs) .then(struct => { if (struct.response.status === 404) { return 0; diff --git a/src/api/rest/decorators/with-reducers.js b/src/api/rest/decorators/with-reducers.js index 03eedc212..aa33af91b 100644 --- a/src/api/rest/decorators/with-reducers.js +++ b/src/api/rest/decorators/with-reducers.js @@ -13,15 +13,15 @@ export const withReducers = Provider => { return new Proxy(withReducers, { get: (c, namespace) => { return new Proxy(client[namespace], { - get: (endpoint, call) => (...args) => (dispatch, getState) => { - if (!endpoint[call]) { + get: (endpoint, method) => (...args) => (dispatch, getState) => { + if (!endpoint[method]) { return displayError( - `Unknown API call. Did you implement client.${namespace}.${call}()?` + `Unknown API method. Did you implement client.${namespace}.${method}()?` ); } // Used as a key for state.pagination - const actionName = actionNameForCall(namespace, call); + const actionName = actionNameForCall(namespace, method); const action = Actions[actionName]; if (!action) { @@ -30,14 +30,14 @@ export const withReducers = Provider => { ); } - const { pureArgs, magicArg } = splitArgs(endpoint[call], args); + const { pureArgs, extraArg } = splitArgs(endpoint[method], args); const paginator = getState().pagination[actionName]; const actionKey = pureArgs.join('-'); let finalArgs = args; if (paginator) { - const { loadMore = false, forceRefresh = false } = magicArg; + const { loadMore = false, forceRefresh = false } = extraArg; const { pageCount = 0, isFetching = false, nextPageUrl } = paginator[actionKey] || {}; @@ -52,17 +52,17 @@ export const withReducers = Provider => { if (loadMore) { // next page explicitely requested - magicArg.url = nextPageUrl; + extraArg.url = nextPageUrl; } else if (forceRefresh) { // TODO: reset pagination state properly via an action // console.log('TODO: reset pagination'); } - finalArgs = [...pureArgs, magicArg]; + finalArgs = [...pureArgs, extraArg]; } // Get accessToken from state - client.setAccessToken(getState().auth.accessToken); + client.setAuthHeaders(getState().auth.accessToken); dispatch({ id: actionKey, @@ -71,14 +71,16 @@ export const withReducers = Provider => { /* eslint-disable no-unexpected-multiline */ return endpoint - [call](...finalArgs) + [method](...finalArgs) .then(struct => { if (!struct.response.ok) { return struct.response.json().then(error => { return Promise.reject( - `Call: client.${namespace}.call()\nUrl: ${struct.response - .url}\nError: [${struct.response - .status}] ${error.message}` + [ + `Call: client.${namespace}.${method}()`, + `Url: ${struct.response.url}`, + `Error: [${struct.response.status}] ${error.message}`, + ].join('\n') ); }); } @@ -108,6 +110,7 @@ export const withReducers = Provider => { ids: normalized.result, nextPageUrl: struct.nextPageUrl, }; + delete normalized.result; } diff --git a/src/api/rest/providers/github/client.js b/src/api/rest/providers/github/client.js index a33183e5d..209a4a063 100644 --- a/src/api/rest/providers/github/client.js +++ b/src/api/rest/providers/github/client.js @@ -1,11 +1,11 @@ import { Client } from '../base'; import Schemas from './schemas'; -export class Github extends Client { +export class GitHub extends Client { API_ROOT = 'https://api.github.com/'; - setAccessToken = accessToken => { - this.authHeaders = { Authorization: `token ${accessToken}` }; + setAuthHeaders = token => { + this.authHeaders = { Authorization: `token ${token}` }; }; getNextPageUrl = response => { diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index e2d94d7bf..3de60ce6d 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -14,11 +14,11 @@ import { LoadingUserListItem, UserListItem, ViewContainer } from 'components'; import { colors, fonts, normalize } from 'config'; import { emojifyText, translate } from 'utils'; -import { Github } from 'api/rest/providers/github'; +import { GitHub } from 'api/rest/providers/github'; import { withReducers, withCounter } from 'api/rest/decorators'; -const client = withReducers(Github); -const countingClient = withCounter(Github); +const client = withReducers(GitHub); +const countingClient = withCounter(GitHub); const mapStateToProps = state => { const { diff --git a/src/notifications/screens/notifications.screen.js b/src/notifications/screens/notifications.screen.js index 82d4e0bd6..f473a64a3 100644 --- a/src/notifications/screens/notifications.screen.js +++ b/src/notifications/screens/notifications.screen.js @@ -14,7 +14,7 @@ import { } from 'react-native'; import { ButtonGroup, Card, Icon } from 'react-native-elements'; -import { Github } from 'api/rest/providers/github'; +import { GitHub } from 'api/rest/providers/github'; import { withReducers, withCounter } from 'api/rest/decorators'; import { v3 } from 'api'; @@ -567,8 +567,8 @@ class Notifications extends Component { } } -const client = withReducers(Github); -const countingClient = withCounter(Github); +const client = withReducers(GitHub); +const countingClient = withCounter(GitHub); export const NotificationsScreen = connect(mapStateToProps, { getNotifications: client.activity.getNotifications, diff --git a/src/organization/screens/organization-profile.screen.js b/src/organization/screens/organization-profile.screen.js index 88fd13c28..c24a5f972 100644 --- a/src/organization/screens/organization-profile.screen.js +++ b/src/organization/screens/organization-profile.screen.js @@ -14,10 +14,10 @@ import { } from 'components'; import { emojifyText, translate, openURLInView } from 'utils'; import { colors, fonts } from 'config'; -import { Github } from 'api/rest/providers/github'; +import { GitHub } from 'api/rest/providers/github'; import { withReducers } from 'api/rest/decorators'; -const client = withReducers(Github); +const client = withReducers(GitHub); const styles = StyleSheet.create({ listTitle: { diff --git a/src/organization/screens/organization-repository-list.screen.js b/src/organization/screens/organization-repository-list.screen.js index 5667e5c95..20e32aef2 100644 --- a/src/organization/screens/organization-repository-list.screen.js +++ b/src/organization/screens/organization-repository-list.screen.js @@ -3,10 +3,10 @@ import { connect } from 'react-redux'; import { RepositoryList } from 'components'; -import { Github } from 'api/rest/providers/github'; +import { GitHub } from 'api/rest/providers/github'; import { withReducers } from 'api/rest/decorators'; -const client = withReducers(Github); +const client = withReducers(GitHub); const getQueryString = (keyword, orgId) => `q=${keyword}+user:${orgId}+fork:true&per_page=8`; diff --git a/src/utils/decorator-helpers.js b/src/utils/decorator-helpers.js index f671998d5..b7a01bf2d 100644 --- a/src/utils/decorator-helpers.js +++ b/src/utils/decorator-helpers.js @@ -9,12 +9,12 @@ export const actionNameForCall = (namespace, method, prefix = '') => { }; export const splitArgs = (fn, args) => { - const declaredArgsNumber = fn.length; - const isMagicArgAvailable = args.length === declaredArgsNumber; + const declaredArgsCount = fn.length; + const isExtraArgAvailable = args.length === declaredArgsCount; return { - pureArgs: isMagicArgAvailable ? args.slice(0, args.length - 1) : args, - magicArg: isMagicArgAvailable ? args[args.length - 1] : {}, + pureArgs: isExtraArgAvailable ? args.slice(0, -1) : args, + extraArg: isExtraArgAvailable ? args[args.length - 1] : {}, }; }; From 8c24f81bf95fc10d8c0dfcccfffdd3e67e01af18 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sun, 15 Oct 2017 22:12:53 +0100 Subject: [PATCH 27/36] refactor: decorators -> proxies --- src/api/rest/decorators/index.js | 3 --- src/api/rest/decorators/with-auth.js | 16 ---------------- .../create-count-proxy.js} | 5 +++-- .../create-dispatch-proxy.js} | 6 +++--- src/api/rest/proxies/index.js | 2 ++ src/auth/screens/events.screen.js | 6 +++--- .../screens/notifications.screen.js | 6 +++--- .../screens/organization-profile.screen.js | 4 ++-- .../organization-repository-list.screen.js | 4 ++-- 9 files changed, 18 insertions(+), 34 deletions(-) delete mode 100644 src/api/rest/decorators/index.js delete mode 100644 src/api/rest/decorators/with-auth.js rename src/api/rest/{decorators/with-counter.js => proxies/create-count-proxy.js} (94%) rename src/api/rest/{decorators/with-reducers.js => proxies/create-dispatch-proxy.js} (96%) create mode 100644 src/api/rest/proxies/index.js diff --git a/src/api/rest/decorators/index.js b/src/api/rest/decorators/index.js deleted file mode 100644 index bd489bc20..000000000 --- a/src/api/rest/decorators/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export * from './with-auth'; -export * from './with-counter'; -export * from './with-reducers'; diff --git a/src/api/rest/decorators/with-auth.js b/src/api/rest/decorators/with-auth.js deleted file mode 100644 index a34bb297c..000000000 --- a/src/api/rest/decorators/with-auth.js +++ /dev/null @@ -1,16 +0,0 @@ -export const withAuth = Provider => { - const client = new Provider(); - - return new Proxy(withAuth, { - get: (c, namespace) => { - return new Proxy(client[namespace], { - get: (endpoint, method) => (...args) => (dispatch, getState) => { - // Get accessToken from state - client.setAuthHeaders(getState().auth.accessToken); - - return endpoint[method](...args); - }, - }); - }, - }); -}; diff --git a/src/api/rest/decorators/with-counter.js b/src/api/rest/proxies/create-count-proxy.js similarity index 94% rename from src/api/rest/decorators/with-counter.js rename to src/api/rest/proxies/create-count-proxy.js index ad6f8b788..39c26d23f 100644 --- a/src/api/rest/decorators/with-counter.js +++ b/src/api/rest/proxies/create-count-proxy.js @@ -6,10 +6,11 @@ import { actionNameForCall, } from 'utils/decorator-helpers'; -export const withCounter = Provider => { +// TODO: Merge this into createDispatchProxy +export const createCountProxy = Provider => { const client = new Provider(); - return new Proxy(withCounter, { + return new Proxy(createCountProxy, { get: (c, namespace) => { return new Proxy(client[namespace], { get: (endpoint, method) => (...args) => (dispatch, getState) => { diff --git a/src/api/rest/decorators/with-reducers.js b/src/api/rest/proxies/create-dispatch-proxy.js similarity index 96% rename from src/api/rest/decorators/with-reducers.js rename to src/api/rest/proxies/create-dispatch-proxy.js index aa33af91b..d6bd2a6da 100644 --- a/src/api/rest/decorators/with-reducers.js +++ b/src/api/rest/proxies/create-dispatch-proxy.js @@ -7,10 +7,10 @@ import { actionNameForCall, } from 'utils/decorator-helpers'; -export const withReducers = Provider => { +export const createDispatchProxy = Provider => { const client = new Provider(); - return new Proxy(withReducers, { + return new Proxy(createDispatchProxy, { get: (c, namespace) => { return new Proxy(client[namespace], { get: (endpoint, method) => (...args) => (dispatch, getState) => { @@ -86,7 +86,7 @@ export const withReducers = Provider => { } // Successful PUT or PATCH request, there will be no JSON, simply dispatch success - // TODO: withReducers() is not an accurate name anymore here. + // TODO: We need a better test here if (struct.response.status === 205) { dispatch({ id: actionKey, diff --git a/src/api/rest/proxies/index.js b/src/api/rest/proxies/index.js new file mode 100644 index 000000000..c4d4837a5 --- /dev/null +++ b/src/api/rest/proxies/index.js @@ -0,0 +1,2 @@ +export * from './create-dispatch-proxy'; +export * from './create-count-proxy'; diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index 3de60ce6d..3c41bf1b1 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -15,10 +15,10 @@ import { colors, fonts, normalize } from 'config'; import { emojifyText, translate } from 'utils'; import { GitHub } from 'api/rest/providers/github'; -import { withReducers, withCounter } from 'api/rest/decorators'; +import { createDispatchProxy, createCountProxy } from 'api/rest/proxies'; -const client = withReducers(GitHub); -const countingClient = withCounter(GitHub); +const client = createDispatchProxy(GitHub); +const countingClient = createCountProxy(GitHub); const mapStateToProps = state => { const { diff --git a/src/notifications/screens/notifications.screen.js b/src/notifications/screens/notifications.screen.js index f473a64a3..a7313e13e 100644 --- a/src/notifications/screens/notifications.screen.js +++ b/src/notifications/screens/notifications.screen.js @@ -15,7 +15,7 @@ import { import { ButtonGroup, Card, Icon } from 'react-native-elements'; import { GitHub } from 'api/rest/providers/github'; -import { withReducers, withCounter } from 'api/rest/decorators'; +import { createDispatchProxy, createCountProxy } from 'api/rest/proxies'; import { v3 } from 'api'; import { @@ -567,8 +567,8 @@ class Notifications extends Component { } } -const client = withReducers(GitHub); -const countingClient = withCounter(GitHub); +const client = createDispatchProxy(GitHub); +const countingClient = createCountProxy(GitHub); export const NotificationsScreen = connect(mapStateToProps, { getNotifications: client.activity.getNotifications, diff --git a/src/organization/screens/organization-profile.screen.js b/src/organization/screens/organization-profile.screen.js index c24a5f972..3d67f73fc 100644 --- a/src/organization/screens/organization-profile.screen.js +++ b/src/organization/screens/organization-profile.screen.js @@ -15,9 +15,9 @@ import { import { emojifyText, translate, openURLInView } from 'utils'; import { colors, fonts } from 'config'; import { GitHub } from 'api/rest/providers/github'; -import { withReducers } from 'api/rest/decorators'; +import { createDispatchProxy } from 'api/rest/proxies'; -const client = withReducers(GitHub); +const client = createDispatchProxy(GitHub); const styles = StyleSheet.create({ listTitle: { diff --git a/src/organization/screens/organization-repository-list.screen.js b/src/organization/screens/organization-repository-list.screen.js index 20e32aef2..068ab3405 100644 --- a/src/organization/screens/organization-repository-list.screen.js +++ b/src/organization/screens/organization-repository-list.screen.js @@ -4,9 +4,9 @@ import { connect } from 'react-redux'; import { RepositoryList } from 'components'; import { GitHub } from 'api/rest/providers/github'; -import { withReducers } from 'api/rest/decorators'; +import { createDispatchProxy } from 'api/rest/proxies'; -const client = withReducers(GitHub); +const client = createDispatchProxy(GitHub); const getQueryString = (keyword, orgId) => `q=${keyword}+user:${orgId}+fork:true&per_page=8`; From 9cbabee937aed3a02ccca81dde67e5307499de21 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 16 Oct 2017 18:55:00 +0100 Subject: [PATCH 28/36] refactor: Move counting logic in the provider. Add abstract methods --- src/api/rest/providers/base/client.js | 28 ++++++++++++++ src/api/rest/providers/github/client.js | 22 +++++++++++ src/api/rest/proxies/create-count-proxy.js | 45 +++++++--------------- 3 files changed, 63 insertions(+), 32 deletions(-) diff --git a/src/api/rest/providers/base/client.js b/src/api/rest/providers/base/client.js index 0f3279e56..a2a390804 100644 --- a/src/api/rest/providers/base/client.js +++ b/src/api/rest/providers/base/client.js @@ -1,4 +1,9 @@ export class Client { + /** + * Enum for HTTP methods. + * + * @enum {string} + */ Method = { GET: 'GET', HEAD: 'HEAD', @@ -57,4 +62,27 @@ export class Client { }) .catch(error => error); }; + + /* eslint-disable no-unused-vars */ + + /** + * Sets the authorization headers given an access token. + * + * @abstract + * @param {string} token The oAuth access token + */ + setAuthHeaders = token => { + throw new Error('Not implemented'); + }; + + /** + * Counts the entities available by analysing the Response object + * + * @abstract + * @async + * @param {Response} response + */ + getCount = async response => { + throw new Error('Not implemented'); + }; } diff --git a/src/api/rest/providers/github/client.js b/src/api/rest/providers/github/client.js index 209a4a063..41d10569f 100644 --- a/src/api/rest/providers/github/client.js +++ b/src/api/rest/providers/github/client.js @@ -24,6 +24,28 @@ export class GitHub extends Client { return nextLink.split(';')[0].slice(1, -1); }; + /** + * Counts the entities available by analysing the Response object + * + * @async + * @param {Response} response + */ + getCount = async response => { + if (!response.ok) { + return 0; + } + + let linkHeader = response.headers.get('Link'); + + if (linkHeader !== null) { + linkHeader = linkHeader.match(/page=(\d)+/g).pop(); + + return linkHeader.split('=').pop(); + } + + return response.json().then(data => data.length).catch(() => 0); + }; + /** * The organizations endpoint */ diff --git a/src/api/rest/proxies/create-count-proxy.js b/src/api/rest/proxies/create-count-proxy.js index 39c26d23f..2c7bbe589 100644 --- a/src/api/rest/proxies/create-count-proxy.js +++ b/src/api/rest/proxies/create-count-proxy.js @@ -38,46 +38,27 @@ export const createCountProxy = Provider => { client.setAuthHeaders(getState().auth.accessToken); /* eslint-disable no-unexpected-multiline */ - return endpoint - [method](...finalArgs) - .then(struct => { - if (struct.response.status === 404) { - return 0; - } - - let linkHeader = struct.response.headers.get('Link'); - - if (linkHeader !== null) { - linkHeader = linkHeader.match(/page=(\d)+/g).pop(); + return endpoint[method](...finalArgs).then(struct => { + client + .getCount(struct.response) + .then(count => { dispatch({ - counters: linkHeader.split('=').pop(), + counters: count, key: actionKey, name: actionName, type: Actions[actionName].SUCCESS, }); - } else { - struct.response.json().then(data => { - dispatch({ - counters: data.length, - key: actionKey, - name: actionName, - type: Actions[actionName].SUCCESS, - }); + }) + .catch(error => { + displayError(error.toString()); + dispatch({ + key: actionKey, + type: Actions[actionName].ERROR, }); - } - return Promise.resolve(); - }) - .catch(error => { - displayError(error.toString()); - - dispatch({ - key: actionKey, - type: Actions[actionName].ERROR, + return error; }); - - return error; - }); + }); }, }); }, From 5e576db9d470a62edf3b64374fb7fd05d3eea3f6 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 16 Oct 2017 19:19:02 +0100 Subject: [PATCH 29/36] refactor: merge createCountProxy into createDispatchProxy --- src/api/rest/proxies/create-count-proxy.js | 66 ------------------- src/api/rest/proxies/create-dispatch-proxy.js | 65 ++++++++++++++++++ src/api/rest/proxies/index.js | 1 - src/auth/screens/events.screen.js | 5 +- .../screens/notifications.screen.js | 5 +- 5 files changed, 69 insertions(+), 73 deletions(-) delete mode 100644 src/api/rest/proxies/create-count-proxy.js diff --git a/src/api/rest/proxies/create-count-proxy.js b/src/api/rest/proxies/create-count-proxy.js deleted file mode 100644 index 2c7bbe589..000000000 --- a/src/api/rest/proxies/create-count-proxy.js +++ /dev/null @@ -1,66 +0,0 @@ -import * as Actions from 'api/rest/actions'; - -import { - splitArgs, - displayError, - actionNameForCall, -} from 'utils/decorator-helpers'; - -// TODO: Merge this into createDispatchProxy -export const createCountProxy = Provider => { - const client = new Provider(); - - return new Proxy(createCountProxy, { - get: (c, namespace) => { - return new Proxy(client[namespace], { - get: (endpoint, method) => (...args) => (dispatch, getState) => { - if (!endpoint[method]) { - return displayError( - `Unknown API method. Did you implement client.${namespace}.${method}()?` - ); - } - - // Used as a key for state.pagination - const actionName = actionNameForCall(namespace, method, 'COUNT_'); - - const { pureArgs, extraArg } = splitArgs(endpoint[method], args); - - extraArg.per_page = 1; - - const finalArgs = [...pureArgs, extraArg]; - const actionKey = pureArgs.join('-'); - - dispatch({ - key: actionKey, - type: Actions[actionName].PENDING, - }); - - client.setAuthHeaders(getState().auth.accessToken); - - /* eslint-disable no-unexpected-multiline */ - return endpoint[method](...finalArgs).then(struct => { - client - .getCount(struct.response) - .then(count => { - dispatch({ - counters: count, - key: actionKey, - name: actionName, - type: Actions[actionName].SUCCESS, - }); - }) - .catch(error => { - displayError(error.toString()); - dispatch({ - key: actionKey, - type: Actions[actionName].ERROR, - }); - - return error; - }); - }); - }, - }); - }, - }); -}; diff --git a/src/api/rest/proxies/create-dispatch-proxy.js b/src/api/rest/proxies/create-dispatch-proxy.js index d6bd2a6da..3f76a73bc 100644 --- a/src/api/rest/proxies/create-dispatch-proxy.js +++ b/src/api/rest/proxies/create-dispatch-proxy.js @@ -7,6 +7,55 @@ import { actionNameForCall, } from 'utils/decorator-helpers'; +const handleCountOperation = ( + client, + namespace, + method, + args, + dispatch, + getState +) => { + // Used as a key for state.pagination + const actionName = actionNameForCall(namespace, method, 'COUNT_'); + + const { pureArgs, extraArg } = splitArgs(client[namespace][method], args); + + extraArg.per_page = 1; + + const finalArgs = [...pureArgs, extraArg]; + const actionKey = pureArgs.join('-'); + + dispatch({ + key: actionKey, + type: Actions[actionName].PENDING, + }); + + client.setAuthHeaders(getState().auth.accessToken); + + /* eslint-disable no-unexpected-multiline */ + return client[namespace][method](...finalArgs).then(struct => { + client + .getCount(struct.response) + .then(count => { + dispatch({ + counters: count, + key: actionKey, + name: actionName, + type: Actions[actionName].SUCCESS, + }); + }) + .catch(error => { + displayError(error.toString()); + dispatch({ + key: actionKey, + type: Actions[actionName].ERROR, + }); + + return error; + }); + }); +}; + export const createDispatchProxy = Provider => { const client = new Provider(); @@ -15,6 +64,22 @@ export const createDispatchProxy = Provider => { return new Proxy(client[namespace], { get: (endpoint, method) => (...args) => (dispatch, getState) => { if (!endpoint[method]) { + // Non existant method.. is the user asking for a count? + if (method.match(/Count$/)) { + const actualMethod = method.slice(0, -5); + + if (endpoint[actualMethod]) { + return handleCountOperation( + client, + namespace, + actualMethod, + args, + dispatch, + getState + ); + } + } + return displayError( `Unknown API method. Did you implement client.${namespace}.${method}()?` ); diff --git a/src/api/rest/proxies/index.js b/src/api/rest/proxies/index.js index c4d4837a5..cb889065d 100644 --- a/src/api/rest/proxies/index.js +++ b/src/api/rest/proxies/index.js @@ -1,2 +1 @@ export * from './create-dispatch-proxy'; -export * from './create-count-proxy'; diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index 3c41bf1b1..b875582a7 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -15,10 +15,9 @@ import { colors, fonts, normalize } from 'config'; import { emojifyText, translate } from 'utils'; import { GitHub } from 'api/rest/providers/github'; -import { createDispatchProxy, createCountProxy } from 'api/rest/proxies'; +import { createDispatchProxy } from 'api/rest/proxies'; const client = createDispatchProxy(GitHub); -const countingClient = createCountProxy(GitHub); const mapStateToProps = state => { const { @@ -616,5 +615,5 @@ class Events extends Component { export const EventsScreen = connect(mapStateToProps, { getEvents: client.activity.getEventsReceived, - getNotificationsCount: countingClient.activity.getNotifications, + getNotificationsCount: client.activity.getNotificationsCount, })(Events); diff --git a/src/notifications/screens/notifications.screen.js b/src/notifications/screens/notifications.screen.js index a7313e13e..37e713607 100644 --- a/src/notifications/screens/notifications.screen.js +++ b/src/notifications/screens/notifications.screen.js @@ -15,7 +15,7 @@ import { import { ButtonGroup, Card, Icon } from 'react-native-elements'; import { GitHub } from 'api/rest/providers/github'; -import { createDispatchProxy, createCountProxy } from 'api/rest/proxies'; +import { createDispatchProxy } from 'api/rest/proxies'; import { v3 } from 'api'; import { @@ -568,10 +568,9 @@ class Notifications extends Component { } const client = createDispatchProxy(GitHub); -const countingClient = createCountProxy(GitHub); export const NotificationsScreen = connect(mapStateToProps, { getNotifications: client.activity.getNotifications, markThreadAsRead: client.activity.markNotificationThreadAsRead, - getNotificationsCount: countingClient.activity.getNotifications, + getNotificationsCount: client.activity.getNotificationsCount, })(Notifications); From 5c1d82e5cf60a1db0232caf1969ed24be35fd3e7 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 16 Oct 2017 19:44:52 +0100 Subject: [PATCH 30/36] fix: Locale & Prettier related changes --- src/api/rest/providers/github/client.js | 5 ++- src/api/rest/proxies/create-dispatch-proxy.js | 4 +-- src/components/org-profile.component.js | 36 +++++++++---------- src/components/repo-list-item.component.js | 28 +++++++++------ src/components/repository-list.component.js | 5 +-- src/components/users-avatar-list.component.js | 27 +++++++------- .../screens/organization-profile.screen.js | 2 +- 7 files changed, 58 insertions(+), 49 deletions(-) diff --git a/src/api/rest/providers/github/client.js b/src/api/rest/providers/github/client.js index 41d10569f..9a2a366be 100644 --- a/src/api/rest/providers/github/client.js +++ b/src/api/rest/providers/github/client.js @@ -43,7 +43,10 @@ export class GitHub extends Client { return linkHeader.split('=').pop(); } - return response.json().then(data => data.length).catch(() => 0); + return response + .json() + .then(data => data.length) + .catch(() => 0); }; /** diff --git a/src/api/rest/proxies/create-dispatch-proxy.js b/src/api/rest/proxies/create-dispatch-proxy.js index 3f76a73bc..49417b6b5 100644 --- a/src/api/rest/proxies/create-dispatch-proxy.js +++ b/src/api/rest/proxies/create-dispatch-proxy.js @@ -134,9 +134,7 @@ export const createDispatchProxy = Provider => { type: Actions[actionName].PENDING, }); - /* eslint-disable no-unexpected-multiline */ - return endpoint - [method](...finalArgs) + return endpoint[method](...finalArgs) .then(struct => { if (!struct.response.ok) { return struct.response.json().then(error => { diff --git a/src/components/org-profile.component.js b/src/components/org-profile.component.js index cf3222069..bf0494ddf 100644 --- a/src/components/org-profile.component.js +++ b/src/components/org-profile.component.js @@ -13,7 +13,7 @@ import { ImageZoom } from 'components'; type Props = { org: Object, - language: string, + locale: string, navigation: Object, }; @@ -101,7 +101,7 @@ const mockAttribute = (entity, attribute, replacement) => { return entity && entity[attribute] ? entity[attribute] : replacement; }; -export const OrgProfile = ({ org, language, navigation }: Props) => { +export const OrgProfile = ({ org, locale, navigation }: Props) => { const countRepos = mockAttribute(org, 'countRepos', 0); return ( @@ -109,18 +109,18 @@ export const OrgProfile = ({ org, language, navigation }: Props) => { {org && - org.avatarUrl && - } - {(!org || !org.avatarUrl) && - } - - {mockAttribute(org, 'name', ' ')} - + org.avatarUrl && ( + + )} + {(!org || !org.avatarUrl) && ( + + )} + {mockAttribute(org, 'name', ' ')} {mockAttribute(org, 'login', ' ')} @@ -130,16 +130,14 @@ export const OrgProfile = ({ org, language, navigation }: Props) => { style={styles.unit} onPress={() => navigation.navigate('OrgRepositoryList', { - title: translate('user.repositoryList.title', language), + title: translate('user.repositoryList.title', locale), orgId: org.id, repoCount: countRepos > 15 ? 15 : countRepos, })} > - - {countRepos} - + {countRepos} - {translate('common.repositories', language)} + {translate('common.repositories', locale)} diff --git a/src/components/repo-list-item.component.js b/src/components/repo-list-item.component.js index 93e948ab4..c2b1c65be 100644 --- a/src/components/repo-list-item.component.js +++ b/src/components/repo-list-item.component.js @@ -51,14 +51,14 @@ const styles = StyleSheet.create({ }, }); -const renderTitle = (repository, showFullName) => +const renderTitle = (repository, showFullName) => ( {showFullName ? repository.fullName : repository.shortName} - {repository.private && + {repository.private && ( type="octicon" color={colors.greyDarkest} /> - } + + )} {emojifyText(repository.description)} @@ -99,21 +100,25 @@ const renderTitle = (repository, showFullName) => {abbreviateNumber(repository.countForks)} - {repository.language !== null && + {repository.language !== null && ( } + /> + )} - - {repository.language} - + {repository.language} - ; + +); -export const RepoListItem = ({ repository, showFullName, navigation }: Props) => +export const RepoListItem = ({ + repository, + showFullName, + navigation, +}: Props) => ( }} underlayColor={colors.greyLight} onPress={() => navigation.navigate('Repository', { repository })} - />; + /> +); RepoListItem.defaultProps = { showFullName: true, diff --git a/src/components/repository-list.component.js b/src/components/repository-list.component.js index 90898f889..16c566769 100644 --- a/src/components/repository-list.component.js +++ b/src/components/repository-list.component.js @@ -159,7 +159,7 @@ export class RepositoryList extends Component { (item, index) => // eslint-disable-line react/no-array-index-key )} - {!loading && + {!loading && ( ); }} - />} + /> + )} ); diff --git a/src/components/users-avatar-list.component.js b/src/components/users-avatar-list.component.js index 1d16cdf14..d4e6fbbd9 100644 --- a/src/components/users-avatar-list.component.js +++ b/src/components/users-avatar-list.component.js @@ -75,21 +75,22 @@ const UsersAvatarListComponent = ({ navigation, loadMore, authUser, -}: Props) => +}: Props) => ( {title} {noMembersMessage && - !members.length && - - - } + !members.length && ( + + + + )} loadMore()} onEndReachedThreshold={0.4} - renderItem={({ item }) => + renderItem={({ item }) => ( { navigation.navigate( @@ -116,11 +117,13 @@ const UsersAvatarListComponent = ({ uri: item.avatarUrl, }} /> - } + + )} keyExtractor={item => item.id} horizontal /> - ; + +); export const UsersAvatarList = connect(mapStateToProps)( UsersAvatarListComponent diff --git a/src/organization/screens/organization-profile.screen.js b/src/organization/screens/organization-profile.screen.js index 03c7e8389..325ca7c7f 100644 --- a/src/organization/screens/organization-profile.screen.js +++ b/src/organization/screens/organization-profile.screen.js @@ -137,7 +137,7 @@ class OrganizationProfile extends Component { ( - + )} refreshControl={ Date: Mon, 16 Oct 2017 21:12:26 +0100 Subject: [PATCH 31/36] refactor: get the proxied client directly from api/rest --- src/api/rest/index.js | 4 ++++ src/auth/screens/events.screen.js | 5 +---- src/notifications/screens/notifications.screen.js | 7 +------ .../screens/organization-profile.screen.js | 14 +++++++------- .../screens/organization-repository-list.screen.js | 6 +----- 5 files changed, 14 insertions(+), 22 deletions(-) create mode 100644 src/api/rest/index.js diff --git a/src/api/rest/index.js b/src/api/rest/index.js new file mode 100644 index 000000000..3ff1ab7ba --- /dev/null +++ b/src/api/rest/index.js @@ -0,0 +1,4 @@ +import { GitHub } from './providers/github'; +import { createDispatchProxy } from './proxies'; + +export const client = createDispatchProxy(GitHub); diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index d0127cbf1..bfb1bc791 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -13,10 +13,7 @@ import moment from 'moment/min/moment-with-locales.min'; import { LoadingUserListItem, UserListItem, ViewContainer } from 'components'; import { colors, fonts, normalize } from 'config'; import { emojifyText, translate } from 'utils'; -import { GitHub } from 'api/rest/providers/github'; -import { createDispatchProxy } from 'api/rest/proxies'; - -const client = createDispatchProxy(GitHub); +import { client } from 'api/rest'; const mapStateToProps = state => { const { diff --git a/src/notifications/screens/notifications.screen.js b/src/notifications/screens/notifications.screen.js index 6b1e10986..4b9614a62 100644 --- a/src/notifications/screens/notifications.screen.js +++ b/src/notifications/screens/notifications.screen.js @@ -13,10 +13,7 @@ import { Platform, } from 'react-native'; import { ButtonGroup, Card, Icon } from 'react-native-elements'; - -import { GitHub } from 'api/rest/providers/github'; -import { createDispatchProxy } from 'api/rest/proxies'; - +import { client } from 'api/rest'; import { v3 } from 'api'; import { Button, @@ -571,8 +568,6 @@ class Notifications extends Component { } } -const client = createDispatchProxy(GitHub); - export const NotificationsScreen = connect(mapStateToProps, { getNotifications: client.activity.getNotifications, markThreadAsRead: client.activity.markNotificationThreadAsRead, diff --git a/src/organization/screens/organization-profile.screen.js b/src/organization/screens/organization-profile.screen.js index 325ca7c7f..76db32352 100644 --- a/src/organization/screens/organization-profile.screen.js +++ b/src/organization/screens/organization-profile.screen.js @@ -3,7 +3,6 @@ import { connect } from 'react-redux'; import { StyleSheet, RefreshControl } from 'react-native'; import { ListItem } from 'react-native-elements'; import ActionSheet from 'react-native-actionsheet'; -import { getAuthLocale } from 'auth'; import { ViewContainer, OrgProfile, @@ -15,10 +14,7 @@ import { } from 'components'; import { emojifyText, translate, openURLInView } from 'utils'; import { colors, fonts } from 'config'; -import { GitHub } from 'api/rest/providers/github'; -import { createDispatchProxy } from 'api/rest/proxies'; - -const client = createDispatchProxy(GitHub); +import { client } from 'api/rest'; const styles = StyleSheet.create({ listTitle: { @@ -40,7 +36,11 @@ const mapStateToProps = (state, ownProps) => { // TODO: This should be normalized to params.id const orgId = ownProps.navigation.state.params.organization.login.toLowerCase(); - const { pagination: { ORGS_GET_MEMBERS }, entities: { orgs, users } } = state; + const { + auth: { locale }, + pagination: { ORGS_GET_MEMBERS }, + entities: { orgs, users }, + } = state; const membersPagination = ORGS_GET_MEMBERS[orgId] || { ids: [], @@ -59,7 +59,7 @@ const mapStateToProps = (state, ownProps) => { membersPagination, // normalized attribute entity: orgs[orgId], - locale: getAuthLocale, + locale, }; }; diff --git a/src/organization/screens/organization-repository-list.screen.js b/src/organization/screens/organization-repository-list.screen.js index 068ab3405..a066232db 100644 --- a/src/organization/screens/organization-repository-list.screen.js +++ b/src/organization/screens/organization-repository-list.screen.js @@ -2,11 +2,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { RepositoryList } from 'components'; - -import { GitHub } from 'api/rest/providers/github'; -import { createDispatchProxy } from 'api/rest/proxies'; - -const client = createDispatchProxy(GitHub); +import { client } from 'api/rest'; const getQueryString = (keyword, orgId) => `q=${keyword}+user:${orgId}+fork:true&per_page=8`; From e2e9ca3e9efd79f28f044cbedeb15e4cabea055c Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 16 Oct 2017 21:44:28 +0100 Subject: [PATCH 32/36] style: some code clean up from Jouderian --- src/api/rest/providers/base/client.js | 19 ++++----- src/api/rest/providers/github/client.js | 56 ++++++++++--------------- 2 files changed, 30 insertions(+), 45 deletions(-) diff --git a/src/api/rest/providers/base/client.js b/src/api/rest/providers/base/client.js index a2a390804..dfec418f1 100644 --- a/src/api/rest/providers/base/client.js +++ b/src/api/rest/providers/base/client.js @@ -13,6 +13,8 @@ export class Client { POST: 'POST', }; + authHeaders = {}; + fetch = async ( url, { @@ -32,13 +34,13 @@ export class Client { finalUrl = url; // add explicitely specified parameters if (params.per_page) { - finalUrl = `${finalUrl}${finalUrl.indexOf('?') !== -1 + finalUrl = `${finalUrl}${finalUrl.includes('?') ? '&' : '?'}per_page=${params.per_page}`; } } - if (finalUrl.indexOf(this.API_ROOT) === -1) { + if (!finalUrl.includes(this.API_ROOT)) { finalUrl = `${this.API_ROOT}${finalUrl}`; } @@ -52,14 +54,11 @@ export class Client { }; return fetch(finalUrl, parameters) - .then(response => { - // analyze headers for pagination, rates, etc - return { - response, - schema, - normalizrKey, - }; - }) + .then(response => ({ + response, + schema, + normalizrKey, + })) .catch(error => error); }; diff --git a/src/api/rest/providers/github/client.js b/src/api/rest/providers/github/client.js index 9a2a366be..560f496c6 100644 --- a/src/api/rest/providers/github/client.js +++ b/src/api/rest/providers/github/client.js @@ -79,12 +79,10 @@ export class GitHub extends Client { schema: Schemas.USER_ARRAY, }, params - ).then(struct => { - return { - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), - }; - }); + ).then(struct => ({ + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + })); }, /** * Gets organization members @@ -98,12 +96,10 @@ export class GitHub extends Client { schema: Schemas.REPO_ARRAY, }, params - ).then(struct => { - return { - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), - }; - }); + ).then(struct => ({ + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + })); }, }; @@ -123,12 +119,10 @@ export class GitHub extends Client { schema: Schemas.EVENT_ARRAY, }, params - ).then(struct => { - return { - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), - }; - }); + ).then(struct => ({ + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + })); }, /** * Get all notifications for the current user @@ -148,12 +142,10 @@ export class GitHub extends Client { schema: Schemas.NOTIFICATION_ARRAY, }, finalParams - ).then(struct => { - return { - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), - }; - }); + ).then(struct => ({ + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + })); }, markNotificationThreadAsRead: async (id, params) => { return this.fetch( @@ -162,11 +154,7 @@ export class GitHub extends Client { method: this.Method.PATCH, }, params - ).then(struct => { - return { - ...struct, - }; - }); + ).then(struct => struct); }, }; @@ -184,12 +172,10 @@ export class GitHub extends Client { normalizrKey: 'items', }, params - ).then(struct => { - return { - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), - }; - }); + ).then(struct => ({ + ...struct, + nextPageUrl: this.getNextPageUrl(struct.response), + })); }, }; } From ac036ec00dd1c3d0211ee84ea64ecb6c41ba2393 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 16 Oct 2017 22:12:35 +0100 Subject: [PATCH 33/36] refactor: simplify client.fetch() call --- src/api/rest/providers/base/client.js | 15 +---- src/api/rest/providers/github/client.js | 82 +++++++++---------------- 2 files changed, 33 insertions(+), 64 deletions(-) diff --git a/src/api/rest/providers/base/client.js b/src/api/rest/providers/base/client.js index dfec418f1..5385be5a0 100644 --- a/src/api/rest/providers/base/client.js +++ b/src/api/rest/providers/base/client.js @@ -17,13 +17,8 @@ export class Client { fetch = async ( url, - { - method = this.Method.GET, - schema = null, - normalizrKey = null, - headers = {}, - }, - params = {} + params = {}, + { method = this.Method.GET, headers = {} } = {} ) => { let finalUrl; @@ -54,11 +49,7 @@ export class Client { }; return fetch(finalUrl, parameters) - .then(response => ({ - response, - schema, - normalizrKey, - })) + .then(response => response) .catch(error => error); }; diff --git a/src/api/rest/providers/github/client.js b/src/api/rest/providers/github/client.js index 560f496c6..26fff155a 100644 --- a/src/api/rest/providers/github/client.js +++ b/src/api/rest/providers/github/client.js @@ -59,13 +59,10 @@ export class GitHub extends Client { * @param {string} orgId */ getById: async (orgId, params) => { - return this.fetch( - `orgs/${orgId}`, - { - schema: Schemas.ORG, - }, - params - ).then(struct => struct); + return this.fetch(`orgs/${orgId}`, params).then(response => ({ + response, + schema: Schemas.ORG, + })); }, /** * Gets organization members @@ -73,15 +70,10 @@ export class GitHub extends Client { * @param {string} orgId */ getMembers: async (orgId, params) => { - return this.fetch( - `orgs/${orgId}/members`, - { - schema: Schemas.USER_ARRAY, - }, - params - ).then(struct => ({ - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), + return this.fetch(`orgs/${orgId}/members`, params).then(response => ({ + response, + nextPageUrl: this.getNextPageUrl(response), + schema: Schemas.USER_ARRAY, })); }, /** @@ -90,15 +82,10 @@ export class GitHub extends Client { * @param {string} orgId */ getRepos: async (orgId, params) => { - return this.fetch( - `orgs/${orgId}/repos`, - { - schema: Schemas.REPO_ARRAY, - }, - params - ).then(struct => ({ - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), + return this.fetch(`orgs/${orgId}/repos`, params).then(response => ({ + response, + nextPageUrl: this.getNextPageUrl(response), + schema: Schemas.REPO_ARRAY, })); }, }; @@ -115,13 +102,11 @@ export class GitHub extends Client { getEventsReceived: async (userId, params) => { return this.fetch( `users/${userId}/received_events`, - { - schema: Schemas.EVENT_ARRAY, - }, params - ).then(struct => ({ - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), + ).then(response => ({ + response, + nextPageUrl: this.getNextPageUrl(response), + schema: Schemas.EVENT_ARRAY, })); }, /** @@ -138,23 +123,18 @@ export class GitHub extends Client { return this.fetch( `notifications?all=${all}&participating=${participating}`, - { - schema: Schemas.NOTIFICATION_ARRAY, - }, - finalParams - ).then(struct => ({ - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), + finalParams, + {} + ).then(response => ({ + response, + nextPageUrl: this.getNextPageUrl(response), + schema: Schemas.NOTIFICATION_ARRAY, })); }, markNotificationThreadAsRead: async (id, params) => { - return this.fetch( - `notifications/threads/${id}`, - { - method: this.Method.PATCH, - }, - params - ).then(struct => struct); + return this.fetch(`notifications/threads/${id}`, params, { + method: this.Method.PATCH, + }).then(response => ({ response })); }, }; @@ -167,14 +147,12 @@ export class GitHub extends Client { getRepos: async (query, params) => { return this.fetch( `search/repositories?${query}`, - { - schema: Schemas.REPO_ARRAY, - normalizrKey: 'items', - }, params - ).then(struct => ({ - ...struct, - nextPageUrl: this.getNextPageUrl(struct.response), + ).then(response => ({ + response, + nextPageUrl: this.getNextPageUrl(response), + schema: Schemas.REPO_ARRAY, + normalizrKey: 'items', })); }, }; From 34d6d280d0055dd51380192d05af34d326604d6b Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 16 Oct 2017 23:13:43 +0100 Subject: [PATCH 34/36] refactor: decorators-helpers => api-helpers. More clean up in proxy --- src/api/rest/proxies/create-dispatch-proxy.js | 38 +++++++++++-------- .../{decorator-helpers.js => api-helpers.js} | 2 + src/utils/index.js | 2 +- 3 files changed, 26 insertions(+), 16 deletions(-) rename src/utils/{decorator-helpers.js => api-helpers.js} (94%) diff --git a/src/api/rest/proxies/create-dispatch-proxy.js b/src/api/rest/proxies/create-dispatch-proxy.js index 49417b6b5..9fa6c32f4 100644 --- a/src/api/rest/proxies/create-dispatch-proxy.js +++ b/src/api/rest/proxies/create-dispatch-proxy.js @@ -2,10 +2,11 @@ import * as Actions from 'api/rest/actions'; import { normalize } from 'normalizr'; import { + getActionKeyFromArgs, splitArgs, displayError, actionNameForCall, -} from 'utils/decorator-helpers'; +} from 'utils/api-helpers'; const handleCountOperation = ( client, @@ -17,17 +18,24 @@ const handleCountOperation = ( ) => { // Used as a key for state.pagination const actionName = actionNameForCall(namespace, method, 'COUNT_'); + const action = Actions[actionName]; + + if (!action) { + return displayError( + `Unknown action. Did you forget to define Actions.${actionName}?` + ); + } const { pureArgs, extraArg } = splitArgs(client[namespace][method], args); extraArg.per_page = 1; const finalArgs = [...pureArgs, extraArg]; - const actionKey = pureArgs.join('-'); + const actionKey = getActionKeyFromArgs(pureArgs); dispatch({ key: actionKey, - type: Actions[actionName].PENDING, + type: action.PENDING, }); client.setAuthHeaders(getState().auth.accessToken); @@ -41,14 +49,14 @@ const handleCountOperation = ( counters: count, key: actionKey, name: actionName, - type: Actions[actionName].SUCCESS, + type: action.SUCCESS, }); }) .catch(error => { displayError(error.toString()); dispatch({ key: actionKey, - type: Actions[actionName].ERROR, + type: action.ERROR, }); return error; @@ -97,7 +105,7 @@ export const createDispatchProxy = Provider => { const { pureArgs, extraArg } = splitArgs(endpoint[method], args); const paginator = getState().pagination[actionName]; - const actionKey = pureArgs.join('-'); + const actionKey = getActionKeyFromArgs(pureArgs); let finalArgs = args; @@ -131,7 +139,7 @@ export const createDispatchProxy = Provider => { dispatch({ id: actionKey, - type: Actions[actionName].PENDING, + type: action.PENDING, }); return endpoint[method](...finalArgs) @@ -153,7 +161,7 @@ export const createDispatchProxy = Provider => { if (struct.response.status === 205) { dispatch({ id: actionKey, - type: Actions[actionName].SUCCESS, + type: action.SUCCESS, }); return Promise.resolve(); @@ -161,27 +169,27 @@ export const createDispatchProxy = Provider => { return struct.response.json().then(json => { // Treat the JSON & normalize it - const normalized = normalize( + const normalizedJson = normalize( struct.normalizrKey ? json[struct.normalizrKey] : json, struct.schema ); if (paginator) { - normalized.pagination = { + normalizedJson.pagination = { name: actionName, key: actionKey, - ids: normalized.result, + ids: normalizedJson.result, nextPageUrl: struct.nextPageUrl, }; - delete normalized.result; + delete normalizedJson.result; } // Success, let's dispatch it dispatch({ - ...normalized, + ...normalizedJson, id: actionKey, - type: Actions[actionName].SUCCESS, + type: action.SUCCESS, }); return Promise.resolve(); @@ -192,7 +200,7 @@ export const createDispatchProxy = Provider => { dispatch({ id: actionKey, - type: Actions[actionName].ERROR, + type: action.ERROR, }); return error; diff --git a/src/utils/decorator-helpers.js b/src/utils/api-helpers.js similarity index 94% rename from src/utils/decorator-helpers.js rename to src/utils/api-helpers.js index b7a01bf2d..fabf5acb2 100644 --- a/src/utils/decorator-helpers.js +++ b/src/utils/api-helpers.js @@ -1,5 +1,7 @@ import { Alert } from 'react-native'; +export const getActionKeyFromArgs = args => args.join('-'); + export const actionNameForCall = (namespace, method, prefix = '') => { const upperCased = `${namespace}_${method}` .replace(/([A-Z])/g, '_$1') diff --git a/src/utils/index.js b/src/utils/index.js index 3b1ef6178..d5aca5b61 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -3,5 +3,5 @@ export * from './schema-helper'; export * from './loading-animation'; export * from './text-helper'; export * from './method-helpers'; -export * from './decorator-helpers'; +export * from './api-helpers'; export * from './localization-helper'; From 82be98ac2f952cc6255c59d820c09c6e2009e7eb Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 16 Oct 2017 23:14:31 +0100 Subject: [PATCH 35/36] fix: Fix several bugs with schemas, remove dumps --- .../rest/providers/github/schemas/events.js | 4 +- src/api/rest/providers/github/schemas/orgs.js | 95 +------------- .../rest/providers/github/schemas/repos.js | 118 ++---------------- .../rest/providers/github/schemas/users.js | 41 +----- 4 files changed, 15 insertions(+), 243 deletions(-) diff --git a/src/api/rest/providers/github/schemas/events.js b/src/api/rest/providers/github/schemas/events.js index 224defeb3..86f78a2e7 100644 --- a/src/api/rest/providers/github/schemas/events.js +++ b/src/api/rest/providers/github/schemas/events.js @@ -22,8 +22,8 @@ export const eventSchema = new schema.Entity( processed.payload = entity.payload; // TODO: needs to be inspected for more nested entities (forkee) processed.createdAt = toTimestamp(entity.created_at); - processed.actor = entity.actor; - processed.org = entity.org; + processed.actor = entity.actor ? entity.actor : null; + processed.org = entity.org ? entity.org : null; processed.repo = entity.repo; return processed; diff --git a/src/api/rest/providers/github/schemas/orgs.js b/src/api/rest/providers/github/schemas/orgs.js index d489e02bd..d5b2dea69 100644 --- a/src/api/rest/providers/github/schemas/orgs.js +++ b/src/api/rest/providers/github/schemas/orgs.js @@ -13,13 +13,13 @@ export const orgSchema = new schema.Entity( processed.id = entity.login.toLowerCase(); // id should be always used for navigation processed.login = entity.login; processed.avatarUrl = entity.avatar_url; - processed.description = entity.description; // name is only present in full mode, we base our full parsing on its presence if (typeof entity.name !== 'undefined') { processed.name = entity.name; processed.webSite = entity.blog; processed.location = entity.location; + processed.description = entity.description; processed.countPublicRepos = entity.public_repos; processed.countPrivateRepos = 0; @@ -51,96 +51,3 @@ export const orgSchema = new schema.Entity( }, } ); - -/* - - // GITHUB ORGS SCHEMAS - -const fullAuth = { login: 'gitpoint', - id: 30082377, - url: 'https://api.github.com/orgs/gitpoint', - repos_url: 'https://api.github.com/orgs/gitpoint/repos', - events_url: 'https://api.github.com/orgs/gitpoint/events', - hooks_url: 'https://api.github.com/orgs/gitpoint/hooks', - issues_url: 'https://api.github.com/orgs/gitpoint/issues', - members_url: 'https://api.github.com/orgs/gitpoint/members{/member}', - public_members_url: 'https://api.github.com/orgs/gitpoint/public_members{/member}', - avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', - description: 'An open source GitHub client for iOS and Android. Built with React Native :iphone:', - - name: 'GitPoint', - company: null, - blog: 'https://gitpoint.co', - location: 'Toronto', - email: '', - has_organization_projects: true, - has_repository_projects: true, - public_repos: 2, - public_gists: 0, - followers: 0, - following: 0, - html_url: 'https://github.com/gitpoint', - created_at: '2017-07-11T15:49:07Z', - updated_at: '2017-08-04T00:50:57Z', - type: 'Organization', - - total_private_repos: 0, - owned_private_repos: 0, - private_gists: null, - disk_usage: null, - collaborators: null, - billing_email: null, - plan: { name: 'free', - space: 976562499, - private_repos: 0, - filled_seats: 12, - seats: 0 }, - default_repository_permission: null, - members_can_create_repositories: false, -}; - -const full = { - login: 'gitpoint', - id: 30082377, - url: 'https://api.github.com/orgs/gitpoint', - repos_url: 'https://api.github.com/orgs/gitpoint/repos', - events_url: 'https://api.github.com/orgs/gitpoint/events', - hooks_url: 'https://api.github.com/orgs/gitpoint/hooks', - issues_url: 'https://api.github.com/orgs/gitpoint/issues', - members_url: 'https://api.github.com/orgs/gitpoint/members{/member}', - public_members_url: 'https://api.github.com/orgs/gitpoint/public_members{/member}', - avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', - description: 'An open source GitHub client for iOS and Android. Built with React Native :iphone:', - - name: 'GitPoint', - company: null, - blog: 'https://gitpoint.co', - location: 'Toronto', - email: '', - has_organization_projects: true, - has_repository_projects: true, - public_repos: 2, - public_gists: 0, - followers: 0, - following: 0, - html_url: 'https://github.com/gitpoint', - created_at: '2017-07-11T15:49:07Z', - updated_at: '2017-08-04T00:50:57Z', - type: 'Organization', -}; - -const mini = { - login: 'gitpoint', - id: 30082377, - url: 'https://api.github.com/orgs/gitpoint', - repos_url: 'https://api.github.com/orgs/gitpoint/repos', - events_url: 'https://api.github.com/orgs/gitpoint/events', - hooks_url: 'https://api.github.com/orgs/gitpoint/hooks', - issues_url: 'https://api.github.com/orgs/gitpoint/issues', - members_url: 'https://api.github.com/orgs/gitpoint/members{/member}', - public_members_url: 'https://api.github.com/orgs/gitpoint/public_members{/member}', - avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', - description: 'An open source GitHub client for iOS and Android. Built with React Native :iphone:', -}; - -*/ diff --git a/src/api/rest/providers/github/schemas/repos.js b/src/api/rest/providers/github/schemas/repos.js index 19cbc87a0..f75749a3f 100644 --- a/src/api/rest/providers/github/schemas/repos.js +++ b/src/api/rest/providers/github/schemas/repos.js @@ -35,8 +35,6 @@ export const repoSchema = new schema.Entity( processed.shortName = entity.name; processed.description = entity.description; processed.private = entity.private; - processed.defaultBranch = entity.default_branch; - processed.language = entity.language; // needs to be normalized if (entity.owner.type === 'User') { processed.userOwner = entity.owner; @@ -46,12 +44,17 @@ export const repoSchema = new schema.Entity( processed.orgOwner = entity.owner; } - processed.countStargazzers = entity.stargazers_count; - processed.countForks = entity.forks_count; - processed.countWatchers = entity.watchers_count; - processed.countOpenIssues = entity.open_issues_count; + if (typeof entity.default_branch !== 'undefined') { + processed.defaultBranch = entity.default_branch; + processed.language = entity.language; // needs to be normalized - processed.hasIssues = entity.has_issues; + processed.countStargazzers = entity.stargazers_count; + processed.countForks = entity.forks_count; + processed.countWatchers = entity.watchers_count; + processed.countOpenIssues = entity.open_issues_count; + + processed.hasIssues = entity.has_issues; + } processed._entityUrl = entity.html_url; @@ -59,104 +62,3 @@ export const repoSchema = new schema.Entity( }, } ); - -/* - - // GITHUB REPOS SCHEMAS - -const fullMember = { id: 93332398, - name: 'git-point-site', - full_name: 'gitpoint/git-point-site', - owner: - { login: 'gitpoint', - id: 30082377, - avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', - gravatar_id: '', - url: 'https://api.github.com/users/gitpoint', - html_url: 'https://github.com/gitpoint', - followers_url: 'https://api.github.com/users/gitpoint/followers', - following_url: 'https://api.github.com/users/gitpoint/following{/other_user}', - gists_url: 'https://api.github.com/users/gitpoint/gists{/gist_id}', - starred_url: 'https://api.github.com/users/gitpoint/starred{/owner}{/repo}', - subscriptions_url: 'https://api.github.com/users/gitpoint/subscriptions', - organizations_url: 'https://api.github.com/users/gitpoint/orgs', - repos_url: 'https://api.github.com/users/gitpoint/repos', - events_url: 'https://api.github.com/users/gitpoint/events{/privacy}', - received_events_url: 'https://api.github.com/users/gitpoint/received_events', - type: 'Organization', - site_admin: false }, - private: false, - html_url: 'https://github.com/gitpoint/git-point-site', - description: null, - fork: false, - url: 'https://api.github.com/repos/gitpoint/git-point-site', - forks_url: 'https://api.github.com/repos/gitpoint/git-point-site/forks', - keys_url: 'https://api.github.com/repos/gitpoint/git-point-site/keys{/key_id}', - collaborators_url: 'https://api.github.com/repos/gitpoint/git-point-site/collaborators{/collaborator}', - teams_url: 'https://api.github.com/repos/gitpoint/git-point-site/teams', - hooks_url: 'https://api.github.com/repos/gitpoint/git-point-site/hooks', - issue_events_url: 'https://api.github.com/repos/gitpoint/git-point-site/issues/events{/number}', - events_url: 'https://api.github.com/repos/gitpoint/git-point-site/events', - assignees_url: 'https://api.github.com/repos/gitpoint/git-point-site/assignees{/user}', - branches_url: 'https://api.github.com/repos/gitpoint/git-point-site/branches{/branch}', - tags_url: 'https://api.github.com/repos/gitpoint/git-point-site/tags', - blobs_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/blobs{/sha}', - git_tags_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/tags{/sha}', - git_refs_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/refs{/sha}', - trees_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/trees{/sha}', - statuses_url: 'https://api.github.com/repos/gitpoint/git-point-site/statuses/{sha}', - languages_url: 'https://api.github.com/repos/gitpoint/git-point-site/languages', - stargazers_url: 'https://api.github.com/repos/gitpoint/git-point-site/stargazers', - contributors_url: 'https://api.github.com/repos/gitpoint/git-point-site/contributors', - subscribers_url: 'https://api.github.com/repos/gitpoint/git-point-site/subscribers', - subscription_url: 'https://api.github.com/repos/gitpoint/git-point-site/subscription', - commits_url: 'https://api.github.com/repos/gitpoint/git-point-site/commits{/sha}', - git_commits_url: 'https://api.github.com/repos/gitpoint/git-point-site/git/commits{/sha}', - comments_url: 'https://api.github.com/repos/gitpoint/git-point-site/comments{/number}', - issue_comment_url: 'https://api.github.com/repos/gitpoint/git-point-site/issues/comments{/number}', - contents_url: 'https://api.github.com/repos/gitpoint/git-point-site/contents/{+path}', - compare_url: 'https://api.github.com/repos/gitpoint/git-point-site/compare/{base}...{head}', - merges_url: 'https://api.github.com/repos/gitpoint/git-point-site/merges', - archive_url: 'https://api.github.com/repos/gitpoint/git-point-site/{archive_format}{/ref}', - downloads_url: 'https://api.github.com/repos/gitpoint/git-point-site/downloads', - issues_url: 'https://api.github.com/repos/gitpoint/git-point-site/issues{/number}', - pulls_url: 'https://api.github.com/repos/gitpoint/git-point-site/pulls{/number}', - milestones_url: 'https://api.github.com/repos/gitpoint/git-point-site/milestones{/number}', - notifications_url: 'https://api.github.com/repos/gitpoint/git-point-site/notifications{?since,all,participating}', - labels_url: 'https://api.github.com/repos/gitpoint/git-point-site/labels{/name}', - releases_url: 'https://api.github.com/repos/gitpoint/git-point-site/releases{/id}', - deployments_url: 'https://api.github.com/repos/gitpoint/git-point-site/deployments', - created_at: '2017-06-04T18:11:50Z', - updated_at: '2017-09-28T22:13:04Z', - pushed_at: '2017-10-04T02:08:59Z', - git_url: 'git://github.com/gitpoint/git-point-site.git', - ssh_url: 'git@github.com:gitpoint/git-point-site.git', - clone_url: 'https://github.com/gitpoint/git-point-site.git', - svn_url: 'https://github.com/gitpoint/git-point-site', - homepage: null, - size: 4656, - stargazers_count: 9, - watchers_count: 9, - language: 'CSS', - has_issues: true, - has_projects: true, - has_downloads: true, - has_wiki: true, - has_pages: false, - forks_count: 3, - mirror_url: null, - open_issues_count: 0, - forks: 3, - open_issues: 0, - watchers: 9, - default_branch: 'master', - - permissions: { admin: false, push: true, pull: true } } ]; - - // only in full -parent: {}, -source: {}, -network_count: 9, -subscribers_count: 2 - -*/ diff --git a/src/api/rest/providers/github/schemas/users.js b/src/api/rest/providers/github/schemas/users.js index 46f62fda6..c6b0944f9 100644 --- a/src/api/rest/providers/github/schemas/users.js +++ b/src/api/rest/providers/github/schemas/users.js @@ -34,6 +34,8 @@ export const userSchema = new schema.Entity( // Clear avatar cached URL to make sure picture is refetched on profile change processed.avatarUrl += `&updatedAt=${processed.updatedAt}`; + processed._entityUrl = `https://github.com/${entity.login}`; + // The entity is to be considered complete. processed._isComplete = true; } @@ -42,42 +44,3 @@ export const userSchema = new schema.Entity( }, } ); - -/** - -const userFull = { - "login": "machour", - "id": 304450, - "avatar_url": "https://avatars2.githubusercontent.com/u/304450?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/machour", - "html_url": "https://github.com/machour", - "followers_url": "https://api.github.com/users/machour/followers", - "following_url": "https://api.github.com/users/machour/following{/other_user}", - "gists_url": "https://api.github.com/users/machour/gists{/gist_id}", - "starred_url": "https://api.github.com/users/machour/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/machour/subscriptions", - "organizations_url": "https://api.github.com/users/machour/orgs", - "repos_url": "https://api.github.com/users/machour/repos", - "events_url": "https://api.github.com/users/machour/events{/privacy}", - "received_events_url": "https://api.github.com/users/machour/received_events", - "type": "User", - "site_admin": false, - - - "name": "Mehdi Achour", - "company": "IDK", - "blog": "https://machour.idk.tn/", - "location": "Tunis", - "email": null, - "hireable": true, - "bio": null, - "public_repos": 55, - "public_gists": 4, - "followers": 61, - "following": 42, - "created_at": "2010-06-14T01:09:25Z", - "updated_at": "2017-10-04T06:11:32Z" -} - - */ From 40429ca69ce9d009f2d6fb9b8548d3fe085a9368 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Mon, 16 Oct 2017 23:19:34 +0100 Subject: [PATCH 36/36] test: Implement all schemas tests --- .../github/__snapshots__/schemas.test.js.snap | 373 ++++++++++++++++++ .../api/rest/github/mocks/events.json.js | 220 +++++++++++ __tests__/api/rest/github/mocks/index.js | 13 + .../rest/github/mocks/notifications.json.js | 246 ++++++++++++ __tests__/api/rest/github/mocks/org.json.js | 30 ++ __tests__/api/rest/github/mocks/repo.json.js | 133 +++++++ __tests__/api/rest/github/mocks/user.json.js | 32 ++ __tests__/api/rest/github/schemas.test.js | 32 ++ __tests__/index.android.js | 10 - __tests__/index.ios.js | 10 - package.json | 9 +- yarn.lock | 6 +- 12 files changed, 1089 insertions(+), 25 deletions(-) create mode 100644 __tests__/api/rest/github/__snapshots__/schemas.test.js.snap create mode 100644 __tests__/api/rest/github/mocks/events.json.js create mode 100644 __tests__/api/rest/github/mocks/index.js create mode 100644 __tests__/api/rest/github/mocks/notifications.json.js create mode 100644 __tests__/api/rest/github/mocks/org.json.js create mode 100644 __tests__/api/rest/github/mocks/repo.json.js create mode 100644 __tests__/api/rest/github/mocks/user.json.js create mode 100644 __tests__/api/rest/github/schemas.test.js delete mode 100644 __tests__/index.android.js delete mode 100644 __tests__/index.ios.js diff --git a/__tests__/api/rest/github/__snapshots__/schemas.test.js.snap b/__tests__/api/rest/github/__snapshots__/schemas.test.js.snap new file mode 100644 index 000000000..ea13cd5b6 --- /dev/null +++ b/__tests__/api/rest/github/__snapshots__/schemas.test.js.snap @@ -0,0 +1,373 @@ +exports[`test normalizes events correctly 1`] = ` +Object { + "entities": Object { + "events": Object { + "6723241817": Object { + "_entityUrl": false, + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "actor": "machour2", + "createdAt": 1508176374000, + "id": "6723241817", + "org": null, + "payload": Object { + "action": "created", + "comment": Object { + "author_association": "NONE", + "body": "bla", + "created_at": "2017-10-16T17:52:54Z", + "html_url": "https://github.com/machour/git-point-playground/issues/15#issuecomment-336970090", + "id": 336970090, + "issue_url": "https://api.github.com/repos/machour/git-point-playground/issues/15", + "updated_at": "2017-10-16T17:52:54Z", + "url": "https://api.github.com/repos/machour/git-point-playground/issues/comments/336970090", + "user": Object { + "avatar_url": "https://avatars0.githubusercontent.com/u/32770098?v=4", + "events_url": "https://api.github.com/users/machour2/events{/privacy}", + "followers_url": "https://api.github.com/users/machour2/followers", + "following_url": "https://api.github.com/users/machour2/following{/other_user}", + "gists_url": "https://api.github.com/users/machour2/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/machour2", + "id": 32770098, + "login": "machour2", + "organizations_url": "https://api.github.com/users/machour2/orgs", + "received_events_url": "https://api.github.com/users/machour2/received_events", + "repos_url": "https://api.github.com/users/machour2/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/machour2/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/machour2/subscriptions", + "type": "User", + "url": "https://api.github.com/users/machour2", + }, + }, + "issue": Object { + "assignee": null, + "assignees": Array [], + "author_association": "NONE", + "body": "", + "closed_at": null, + "comments": 0, + "comments_url": "https://api.github.com/repos/machour/git-point-playground/issues/15/comments", + "created_at": "2017-10-16T17:31:03Z", + "events_url": "https://api.github.com/repos/machour/git-point-playground/issues/15/events", + "html_url": "https://github.com/machour/git-point-playground/issues/15", + "id": 265851173, + "labels": Array [], + "labels_url": "https://api.github.com/repos/machour/git-point-playground/issues/15/labels{/name}", + "locked": false, + "milestone": null, + "number": 15, + "repository_url": "https://api.github.com/repos/machour/git-point-playground", + "state": "open", + "title": "notif", + "updated_at": "2017-10-16T17:52:54Z", + "url": "https://api.github.com/repos/machour/git-point-playground/issues/15", + "user": Object { + "avatar_url": "https://avatars0.githubusercontent.com/u/32770098?v=4", + "events_url": "https://api.github.com/users/machour2/events{/privacy}", + "followers_url": "https://api.github.com/users/machour2/followers", + "following_url": "https://api.github.com/users/machour2/following{/other_user}", + "gists_url": "https://api.github.com/users/machour2/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/machour2", + "id": 32770098, + "login": "machour2", + "organizations_url": "https://api.github.com/users/machour2/orgs", + "received_events_url": "https://api.github.com/users/machour2/received_events", + "repos_url": "https://api.github.com/users/machour2/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/machour2/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/machour2/subscriptions", + "type": "User", + "url": "https://api.github.com/users/machour2", + }, + }, + }, + "repo": "machour/git-point-playground", + "type": "IssueCommentEvent", + }, + "6723243870": Object { + "_entityUrl": false, + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "actor": "machour2", + "createdAt": 1508176399000, + "id": "6723243870", + "org": null, + "payload": Object { + "action": "created", + "comment": Object { + "author_association": "NONE", + "body": "plop", + "created_at": "2017-10-16T17:53:19Z", + "html_url": "https://github.com/machour/git-point-playground/issues/14#issuecomment-336970278", + "id": 336970278, + "issue_url": "https://api.github.com/repos/machour/git-point-playground/issues/14", + "updated_at": "2017-10-16T17:53:19Z", + "url": "https://api.github.com/repos/machour/git-point-playground/issues/comments/336970278", + "user": Object { + "avatar_url": "https://avatars0.githubusercontent.com/u/32770098?v=4", + "events_url": "https://api.github.com/users/machour2/events{/privacy}", + "followers_url": "https://api.github.com/users/machour2/followers", + "following_url": "https://api.github.com/users/machour2/following{/other_user}", + "gists_url": "https://api.github.com/users/machour2/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/machour2", + "id": 32770098, + "login": "machour2", + "organizations_url": "https://api.github.com/users/machour2/orgs", + "received_events_url": "https://api.github.com/users/machour2/received_events", + "repos_url": "https://api.github.com/users/machour2/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/machour2/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/machour2/subscriptions", + "type": "User", + "url": "https://api.github.com/users/machour2", + }, + }, + "issue": Object { + "assignee": null, + "assignees": Array [], + "author_association": "NONE", + "body": "", + "closed_at": null, + "comments": 1, + "comments_url": "https://api.github.com/repos/machour/git-point-playground/issues/14/comments", + "created_at": "2017-10-15T14:48:14Z", + "events_url": "https://api.github.com/repos/machour/git-point-playground/issues/14/events", + "html_url": "https://github.com/machour/git-point-playground/issues/14", + "id": 265577462, + "labels": Array [], + "labels_url": "https://api.github.com/repos/machour/git-point-playground/issues/14/labels{/name}", + "locked": false, + "milestone": null, + "number": 14, + "repository_url": "https://api.github.com/repos/machour/git-point-playground", + "state": "open", + "title": "more test", + "updated_at": "2017-10-16T17:53:19Z", + "url": "https://api.github.com/repos/machour/git-point-playground/issues/14", + "user": Object { + "avatar_url": "https://avatars0.githubusercontent.com/u/32770098?v=4", + "events_url": "https://api.github.com/users/machour2/events{/privacy}", + "followers_url": "https://api.github.com/users/machour2/followers", + "following_url": "https://api.github.com/users/machour2/following{/other_user}", + "gists_url": "https://api.github.com/users/machour2/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/machour2", + "id": 32770098, + "login": "machour2", + "organizations_url": "https://api.github.com/users/machour2/orgs", + "received_events_url": "https://api.github.com/users/machour2/received_events", + "repos_url": "https://api.github.com/users/machour2/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/machour2/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/machour2/subscriptions", + "type": "User", + "url": "https://api.github.com/users/machour2", + }, + }, + }, + "repo": "machour/git-point-playground", + "type": "IssueCommentEvent", + }, + }, + "repos": Object { + "machour/git-point-playground": Object { + "_entityUrl": "https://github.com/machour/git-point-playground", + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "fullName": "machour/git-point-playground", + "id": "machour/git-point-playground", + "shortName": "git-point-playground", + }, + }, + "users": Object { + "machour2": Object { + "_entityUrl": false, + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "avatarUrl": "https://avatars.githubusercontent.com/u/32770098?", + "id": "machour2", + "login": "machour2", + }, + }, + }, + "result": Array [ + "6723243870", + "6723241817", + ], +} +`; + +exports[`test normalizes notifications correctly 1`] = ` +Object { + "entities": Object { + "notifications": Object { + "266802681": Object { + "_entityUrl": false, + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "id": "266802681", + "link": "https://api.github.com/repos/machour/git-point-playground/issues/14", + "reason": "subscribed", + "repo": "machour/git-point-playground", + "title": "more test", + "type": "Issue", + "unread": true, + "updatedAt": 1508176400000, + }, + "267118003": Object { + "_entityUrl": false, + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "id": "267118003", + "link": "https://api.github.com/repos/machour/git-point-playground/issues/15", + "reason": "subscribed", + "repo": "machour/git-point-playground", + "title": "notif", + "type": "Issue", + "unread": true, + "updatedAt": 1508176376000, + }, + }, + "repos": Object { + "machour/git-point-playground": Object { + "_entityUrl": "https://github.com/machour/git-point-playground", + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "description": null, + "fullName": "machour/git-point-playground", + "id": "machour/git-point-playground", + "orgOwner": false, + "private": false, + "shortName": "git-point-playground", + "userOwner": "machour", + }, + }, + "users": Object { + "machour": Object { + "_entityUrl": false, + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "avatarUrl": "https://avatars2.githubusercontent.com/u/304450?v=4", + "id": "machour", + "login": "machour", + }, + }, + }, + "result": Array [ + "266802681", + "267118003", + ], +} +`; + +exports[`test normalizes org correctly 1`] = ` +Object { + "entities": Object { + "orgs": Object { + "gitpoint": Object { + "_entityUrl": "https://github.com/gitpoint", + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": true, + "avatarUrl": "https://avatars0.githubusercontent.com/u/30082377?v=4&updatedAt=1501807857000", + "countPrivateRepos": 0, + "countPublicRepos": 2, + "countRepos": 2, + "createdAt": 1499788147000, + "description": "An open source GitHub client for iOS and Android. Built with React Native :iphone:", + "id": "gitpoint", + "location": "Toronto", + "login": "gitpoint", + "name": "GitPoint", + "updatedAt": 1501807857000, + "webSite": "https://gitpoint.co", + }, + }, + }, + "result": "gitpoint", +} +`; + +exports[`test normalizes repo correctly 1`] = ` +Object { + "entities": Object { + "orgs": Object { + "gitpoint": Object { + "_entityUrl": "https://github.com/gitpoint", + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "avatarUrl": "https://avatars0.githubusercontent.com/u/30082377?v=4", + "id": "gitpoint", + "login": "gitpoint", + }, + }, + "repos": Object { + "gitpoint/git-point": Object { + "_entityUrl": "https://github.com/gitpoint/git-point", + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": false, + "countForks": 224, + "countOpenIssues": 93, + "countStargazzers": 2501, + "countWatchers": 2501, + "defaultBranch": "master", + "description": "GitHub in your pocket :iphone:", + "fullName": "gitpoint/git-point", + "hasIssues": true, + "id": "gitpoint/git-point", + "language": "JavaScript", + "orgOwner": "gitpoint", + "private": false, + "shortName": "git-point", + "userOwner": false, + }, + }, + }, + "result": "gitpoint/git-point", +} +`; + +exports[`test normalizes user correctly 1`] = ` +Object { + "entities": Object { + "users": Object { + "machour": Object { + "_entityUrl": "https://github.com/machour", + "_fetchedAt": 1508189841664, + "_isAuth": false, + "_isComplete": true, + "avatarUrl": "https://avatars2.githubusercontent.com/u/304450?v=4&updatedAt=1507576478000", + "bio": null, + "company": "IDK", + "countFollowers": 65, + "countFollowing": 43, + "countPrivateRepos": 0, + "countPublicRepos": 56, + "countRepos": 56, + "createdAt": 1276477765000, + "fullName": "Mehdi Achour", + "id": "machour", + "location": "Tunis", + "login": "machour", + "updatedAt": 1507576478000, + "webSite": "https://machour.idk.tn/", + }, + }, + }, + "result": "machour", +} +`; diff --git a/__tests__/api/rest/github/mocks/events.json.js b/__tests__/api/rest/github/mocks/events.json.js new file mode 100644 index 000000000..f7c1222e9 --- /dev/null +++ b/__tests__/api/rest/github/mocks/events.json.js @@ -0,0 +1,220 @@ +export default [ + { + id: '6723243870', + type: 'IssueCommentEvent', + actor: { + id: 32770098, + login: 'machour2', + display_login: 'machour2', + gravatar_id: '', + url: 'https://api.github.com/users/machour2', + avatar_url: 'https://avatars.githubusercontent.com/u/32770098?', + }, + repo: { + id: 103052187, + name: 'machour/git-point-playground', + url: 'https://api.github.com/repos/machour/git-point-playground', + }, + payload: { + action: 'created', + issue: { + url: + 'https://api.github.com/repos/machour/git-point-playground/issues/14', + repository_url: + 'https://api.github.com/repos/machour/git-point-playground', + labels_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/14/labels{/name}', + comments_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/14/comments', + events_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/14/events', + html_url: 'https://github.com/machour/git-point-playground/issues/14', + id: 265577462, + number: 14, + title: 'more test', + user: { + login: 'machour2', + id: 32770098, + avatar_url: 'https://avatars0.githubusercontent.com/u/32770098?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/machour2', + html_url: 'https://github.com/machour2', + followers_url: 'https://api.github.com/users/machour2/followers', + following_url: + 'https://api.github.com/users/machour2/following{/other_user}', + gists_url: 'https://api.github.com/users/machour2/gists{/gist_id}', + starred_url: + 'https://api.github.com/users/machour2/starred{/owner}{/repo}', + subscriptions_url: + 'https://api.github.com/users/machour2/subscriptions', + organizations_url: 'https://api.github.com/users/machour2/orgs', + repos_url: 'https://api.github.com/users/machour2/repos', + events_url: 'https://api.github.com/users/machour2/events{/privacy}', + received_events_url: + 'https://api.github.com/users/machour2/received_events', + type: 'User', + site_admin: false, + }, + labels: [], + state: 'open', + locked: false, + assignee: null, + assignees: [], + milestone: null, + comments: 1, + created_at: '2017-10-15T14:48:14Z', + updated_at: '2017-10-16T17:53:19Z', + closed_at: null, + author_association: 'NONE', + body: '', + }, + comment: { + url: + 'https://api.github.com/repos/machour/git-point-playground/issues/comments/336970278', + html_url: + 'https://github.com/machour/git-point-playground/issues/14#issuecomment-336970278', + issue_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/14', + id: 336970278, + user: { + login: 'machour2', + id: 32770098, + avatar_url: 'https://avatars0.githubusercontent.com/u/32770098?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/machour2', + html_url: 'https://github.com/machour2', + followers_url: 'https://api.github.com/users/machour2/followers', + following_url: + 'https://api.github.com/users/machour2/following{/other_user}', + gists_url: 'https://api.github.com/users/machour2/gists{/gist_id}', + starred_url: + 'https://api.github.com/users/machour2/starred{/owner}{/repo}', + subscriptions_url: + 'https://api.github.com/users/machour2/subscriptions', + organizations_url: 'https://api.github.com/users/machour2/orgs', + repos_url: 'https://api.github.com/users/machour2/repos', + events_url: 'https://api.github.com/users/machour2/events{/privacy}', + received_events_url: + 'https://api.github.com/users/machour2/received_events', + type: 'User', + site_admin: false, + }, + created_at: '2017-10-16T17:53:19Z', + updated_at: '2017-10-16T17:53:19Z', + author_association: 'NONE', + body: 'plop', + }, + }, + public: true, + created_at: '2017-10-16T17:53:19Z', + }, + { + id: '6723241817', + type: 'IssueCommentEvent', + actor: { + id: 32770098, + login: 'machour2', + display_login: 'machour2', + gravatar_id: '', + url: 'https://api.github.com/users/machour2', + avatar_url: 'https://avatars.githubusercontent.com/u/32770098?', + }, + repo: { + id: 103052187, + name: 'machour/git-point-playground', + url: 'https://api.github.com/repos/machour/git-point-playground', + }, + payload: { + action: 'created', + issue: { + url: + 'https://api.github.com/repos/machour/git-point-playground/issues/15', + repository_url: + 'https://api.github.com/repos/machour/git-point-playground', + labels_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/15/labels{/name}', + comments_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/15/comments', + events_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/15/events', + html_url: 'https://github.com/machour/git-point-playground/issues/15', + id: 265851173, + number: 15, + title: 'notif', + user: { + login: 'machour2', + id: 32770098, + avatar_url: 'https://avatars0.githubusercontent.com/u/32770098?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/machour2', + html_url: 'https://github.com/machour2', + followers_url: 'https://api.github.com/users/machour2/followers', + following_url: + 'https://api.github.com/users/machour2/following{/other_user}', + gists_url: 'https://api.github.com/users/machour2/gists{/gist_id}', + starred_url: + 'https://api.github.com/users/machour2/starred{/owner}{/repo}', + subscriptions_url: + 'https://api.github.com/users/machour2/subscriptions', + organizations_url: 'https://api.github.com/users/machour2/orgs', + repos_url: 'https://api.github.com/users/machour2/repos', + events_url: 'https://api.github.com/users/machour2/events{/privacy}', + received_events_url: + 'https://api.github.com/users/machour2/received_events', + type: 'User', + site_admin: false, + }, + labels: [], + state: 'open', + locked: false, + assignee: null, + assignees: [], + milestone: null, + comments: 0, + created_at: '2017-10-16T17:31:03Z', + updated_at: '2017-10-16T17:52:54Z', + closed_at: null, + author_association: 'NONE', + body: '', + }, + comment: { + url: + 'https://api.github.com/repos/machour/git-point-playground/issues/comments/336970090', + html_url: + 'https://github.com/machour/git-point-playground/issues/15#issuecomment-336970090', + issue_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/15', + id: 336970090, + user: { + login: 'machour2', + id: 32770098, + avatar_url: 'https://avatars0.githubusercontent.com/u/32770098?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/machour2', + html_url: 'https://github.com/machour2', + followers_url: 'https://api.github.com/users/machour2/followers', + following_url: + 'https://api.github.com/users/machour2/following{/other_user}', + gists_url: 'https://api.github.com/users/machour2/gists{/gist_id}', + starred_url: + 'https://api.github.com/users/machour2/starred{/owner}{/repo}', + subscriptions_url: + 'https://api.github.com/users/machour2/subscriptions', + organizations_url: 'https://api.github.com/users/machour2/orgs', + repos_url: 'https://api.github.com/users/machour2/repos', + events_url: 'https://api.github.com/users/machour2/events{/privacy}', + received_events_url: + 'https://api.github.com/users/machour2/received_events', + type: 'User', + site_admin: false, + }, + created_at: '2017-10-16T17:52:54Z', + updated_at: '2017-10-16T17:52:54Z', + author_association: 'NONE', + body: 'bla', + }, + }, + public: true, + created_at: '2017-10-16T17:52:54Z', + }, +]; diff --git a/__tests__/api/rest/github/mocks/index.js b/__tests__/api/rest/github/mocks/index.js new file mode 100644 index 000000000..a6f8aa1fa --- /dev/null +++ b/__tests__/api/rest/github/mocks/index.js @@ -0,0 +1,13 @@ +import userJson from './user.json'; +import repoJson from './repo.json'; +import orgJson from './org.json'; +import eventsJson from './events.json'; +import notificationsJson from './notifications.json'; + +export default { + userJson, + repoJson, + orgJson, + eventsJson, + notificationsJson, +}; diff --git a/__tests__/api/rest/github/mocks/notifications.json.js b/__tests__/api/rest/github/mocks/notifications.json.js new file mode 100644 index 000000000..639ced6cc --- /dev/null +++ b/__tests__/api/rest/github/mocks/notifications.json.js @@ -0,0 +1,246 @@ +export default [ + { + id: '266802681', + unread: true, + reason: 'subscribed', + updated_at: '2017-10-16T17:53:20Z', + last_read_at: '2017-10-15T15:09:29Z', + subject: { + title: 'more test', + url: + 'https://api.github.com/repos/machour/git-point-playground/issues/14', + latest_comment_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/comments/336970278', + type: 'Issue', + }, + repository: { + id: 103052187, + name: 'git-point-playground', + full_name: 'machour/git-point-playground', + owner: { + login: 'machour', + id: 304450, + avatar_url: 'https://avatars2.githubusercontent.com/u/304450?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/machour', + html_url: 'https://github.com/machour', + followers_url: 'https://api.github.com/users/machour/followers', + following_url: + 'https://api.github.com/users/machour/following{/other_user}', + gists_url: 'https://api.github.com/users/machour/gists{/gist_id}', + starred_url: + 'https://api.github.com/users/machour/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/machour/subscriptions', + organizations_url: 'https://api.github.com/users/machour/orgs', + repos_url: 'https://api.github.com/users/machour/repos', + events_url: 'https://api.github.com/users/machour/events{/privacy}', + received_events_url: + 'https://api.github.com/users/machour/received_events', + type: 'User', + site_admin: false, + }, + private: false, + html_url: 'https://github.com/machour/git-point-playground', + description: null, + fork: false, + url: 'https://api.github.com/repos/machour/git-point-playground', + forks_url: + 'https://api.github.com/repos/machour/git-point-playground/forks', + keys_url: + 'https://api.github.com/repos/machour/git-point-playground/keys{/key_id}', + collaborators_url: + 'https://api.github.com/repos/machour/git-point-playground/collaborators{/collaborator}', + teams_url: + 'https://api.github.com/repos/machour/git-point-playground/teams', + hooks_url: + 'https://api.github.com/repos/machour/git-point-playground/hooks', + issue_events_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/events{/number}', + events_url: + 'https://api.github.com/repos/machour/git-point-playground/events', + assignees_url: + 'https://api.github.com/repos/machour/git-point-playground/assignees{/user}', + branches_url: + 'https://api.github.com/repos/machour/git-point-playground/branches{/branch}', + tags_url: + 'https://api.github.com/repos/machour/git-point-playground/tags', + blobs_url: + 'https://api.github.com/repos/machour/git-point-playground/git/blobs{/sha}', + git_tags_url: + 'https://api.github.com/repos/machour/git-point-playground/git/tags{/sha}', + git_refs_url: + 'https://api.github.com/repos/machour/git-point-playground/git/refs{/sha}', + trees_url: + 'https://api.github.com/repos/machour/git-point-playground/git/trees{/sha}', + statuses_url: + 'https://api.github.com/repos/machour/git-point-playground/statuses/{sha}', + languages_url: + 'https://api.github.com/repos/machour/git-point-playground/languages', + stargazers_url: + 'https://api.github.com/repos/machour/git-point-playground/stargazers', + contributors_url: + 'https://api.github.com/repos/machour/git-point-playground/contributors', + subscribers_url: + 'https://api.github.com/repos/machour/git-point-playground/subscribers', + subscription_url: + 'https://api.github.com/repos/machour/git-point-playground/subscription', + commits_url: + 'https://api.github.com/repos/machour/git-point-playground/commits{/sha}', + git_commits_url: + 'https://api.github.com/repos/machour/git-point-playground/git/commits{/sha}', + comments_url: + 'https://api.github.com/repos/machour/git-point-playground/comments{/number}', + issue_comment_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/comments{/number}', + contents_url: + 'https://api.github.com/repos/machour/git-point-playground/contents/{+path}', + compare_url: + 'https://api.github.com/repos/machour/git-point-playground/compare/{base}...{head}', + merges_url: + 'https://api.github.com/repos/machour/git-point-playground/merges', + archive_url: + 'https://api.github.com/repos/machour/git-point-playground/{archive_format}{/ref}', + downloads_url: + 'https://api.github.com/repos/machour/git-point-playground/downloads', + issues_url: + 'https://api.github.com/repos/machour/git-point-playground/issues{/number}', + pulls_url: + 'https://api.github.com/repos/machour/git-point-playground/pulls{/number}', + milestones_url: + 'https://api.github.com/repos/machour/git-point-playground/milestones{/number}', + notifications_url: + 'https://api.github.com/repos/machour/git-point-playground/notifications{?since,all,participating}', + labels_url: + 'https://api.github.com/repos/machour/git-point-playground/labels{/name}', + releases_url: + 'https://api.github.com/repos/machour/git-point-playground/releases{/id}', + deployments_url: + 'https://api.github.com/repos/machour/git-point-playground/deployments', + }, + url: 'https://api.github.com/notifications/threads/266802681', + subscription_url: + 'https://api.github.com/notifications/threads/266802681/subscription', + }, + { + id: '267118003', + unread: true, + reason: 'subscribed', + updated_at: '2017-10-16T17:52:56Z', + last_read_at: '2017-10-16T17:51:33Z', + subject: { + title: 'notif', + url: + 'https://api.github.com/repos/machour/git-point-playground/issues/15', + latest_comment_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/comments/336970090', + type: 'Issue', + }, + repository: { + id: 103052187, + name: 'git-point-playground', + full_name: 'machour/git-point-playground', + owner: { + login: 'machour', + id: 304450, + avatar_url: 'https://avatars2.githubusercontent.com/u/304450?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/machour', + html_url: 'https://github.com/machour', + followers_url: 'https://api.github.com/users/machour/followers', + following_url: + 'https://api.github.com/users/machour/following{/other_user}', + gists_url: 'https://api.github.com/users/machour/gists{/gist_id}', + starred_url: + 'https://api.github.com/users/machour/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/machour/subscriptions', + organizations_url: 'https://api.github.com/users/machour/orgs', + repos_url: 'https://api.github.com/users/machour/repos', + events_url: 'https://api.github.com/users/machour/events{/privacy}', + received_events_url: + 'https://api.github.com/users/machour/received_events', + type: 'User', + site_admin: false, + }, + private: false, + html_url: 'https://github.com/machour/git-point-playground', + description: null, + fork: false, + url: 'https://api.github.com/repos/machour/git-point-playground', + forks_url: + 'https://api.github.com/repos/machour/git-point-playground/forks', + keys_url: + 'https://api.github.com/repos/machour/git-point-playground/keys{/key_id}', + collaborators_url: + 'https://api.github.com/repos/machour/git-point-playground/collaborators{/collaborator}', + teams_url: + 'https://api.github.com/repos/machour/git-point-playground/teams', + hooks_url: + 'https://api.github.com/repos/machour/git-point-playground/hooks', + issue_events_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/events{/number}', + events_url: + 'https://api.github.com/repos/machour/git-point-playground/events', + assignees_url: + 'https://api.github.com/repos/machour/git-point-playground/assignees{/user}', + branches_url: + 'https://api.github.com/repos/machour/git-point-playground/branches{/branch}', + tags_url: + 'https://api.github.com/repos/machour/git-point-playground/tags', + blobs_url: + 'https://api.github.com/repos/machour/git-point-playground/git/blobs{/sha}', + git_tags_url: + 'https://api.github.com/repos/machour/git-point-playground/git/tags{/sha}', + git_refs_url: + 'https://api.github.com/repos/machour/git-point-playground/git/refs{/sha}', + trees_url: + 'https://api.github.com/repos/machour/git-point-playground/git/trees{/sha}', + statuses_url: + 'https://api.github.com/repos/machour/git-point-playground/statuses/{sha}', + languages_url: + 'https://api.github.com/repos/machour/git-point-playground/languages', + stargazers_url: + 'https://api.github.com/repos/machour/git-point-playground/stargazers', + contributors_url: + 'https://api.github.com/repos/machour/git-point-playground/contributors', + subscribers_url: + 'https://api.github.com/repos/machour/git-point-playground/subscribers', + subscription_url: + 'https://api.github.com/repos/machour/git-point-playground/subscription', + commits_url: + 'https://api.github.com/repos/machour/git-point-playground/commits{/sha}', + git_commits_url: + 'https://api.github.com/repos/machour/git-point-playground/git/commits{/sha}', + comments_url: + 'https://api.github.com/repos/machour/git-point-playground/comments{/number}', + issue_comment_url: + 'https://api.github.com/repos/machour/git-point-playground/issues/comments{/number}', + contents_url: + 'https://api.github.com/repos/machour/git-point-playground/contents/{+path}', + compare_url: + 'https://api.github.com/repos/machour/git-point-playground/compare/{base}...{head}', + merges_url: + 'https://api.github.com/repos/machour/git-point-playground/merges', + archive_url: + 'https://api.github.com/repos/machour/git-point-playground/{archive_format}{/ref}', + downloads_url: + 'https://api.github.com/repos/machour/git-point-playground/downloads', + issues_url: + 'https://api.github.com/repos/machour/git-point-playground/issues{/number}', + pulls_url: + 'https://api.github.com/repos/machour/git-point-playground/pulls{/number}', + milestones_url: + 'https://api.github.com/repos/machour/git-point-playground/milestones{/number}', + notifications_url: + 'https://api.github.com/repos/machour/git-point-playground/notifications{?since,all,participating}', + labels_url: + 'https://api.github.com/repos/machour/git-point-playground/labels{/name}', + releases_url: + 'https://api.github.com/repos/machour/git-point-playground/releases{/id}', + deployments_url: + 'https://api.github.com/repos/machour/git-point-playground/deployments', + }, + url: 'https://api.github.com/notifications/threads/267118003', + subscription_url: + 'https://api.github.com/notifications/threads/267118003/subscription', + }, +]; diff --git a/__tests__/api/rest/github/mocks/org.json.js b/__tests__/api/rest/github/mocks/org.json.js new file mode 100644 index 000000000..2ce6a9fbc --- /dev/null +++ b/__tests__/api/rest/github/mocks/org.json.js @@ -0,0 +1,30 @@ +export default { + login: 'gitpoint', + id: 30082377, + url: 'https://api.github.com/orgs/gitpoint', + repos_url: 'https://api.github.com/orgs/gitpoint/repos', + events_url: 'https://api.github.com/orgs/gitpoint/events', + hooks_url: 'https://api.github.com/orgs/gitpoint/hooks', + issues_url: 'https://api.github.com/orgs/gitpoint/issues', + members_url: 'https://api.github.com/orgs/gitpoint/members{/member}', + public_members_url: + 'https://api.github.com/orgs/gitpoint/public_members{/member}', + avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', + description: + 'An open source GitHub client for iOS and Android. Built with React Native :iphone:', + name: 'GitPoint', + company: null, + blog: 'https://gitpoint.co', + location: 'Toronto', + email: '', + has_organization_projects: true, + has_repository_projects: true, + public_repos: 2, + public_gists: 0, + followers: 0, + following: 0, + html_url: 'https://github.com/gitpoint', + created_at: '2017-07-11T15:49:07Z', + updated_at: '2017-08-04T00:50:57Z', + type: 'Organization', +}; diff --git a/__tests__/api/rest/github/mocks/repo.json.js b/__tests__/api/rest/github/mocks/repo.json.js new file mode 100644 index 000000000..098b85080 --- /dev/null +++ b/__tests__/api/rest/github/mocks/repo.json.js @@ -0,0 +1,133 @@ +export default { + id: 86202845, + name: 'git-point', + full_name: 'gitpoint/git-point', + owner: { + login: 'gitpoint', + id: 30082377, + avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/gitpoint', + html_url: 'https://github.com/gitpoint', + followers_url: 'https://api.github.com/users/gitpoint/followers', + following_url: + 'https://api.github.com/users/gitpoint/following{/other_user}', + gists_url: 'https://api.github.com/users/gitpoint/gists{/gist_id}', + starred_url: 'https://api.github.com/users/gitpoint/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/gitpoint/subscriptions', + organizations_url: 'https://api.github.com/users/gitpoint/orgs', + repos_url: 'https://api.github.com/users/gitpoint/repos', + events_url: 'https://api.github.com/users/gitpoint/events{/privacy}', + received_events_url: + 'https://api.github.com/users/gitpoint/received_events', + type: 'Organization', + site_admin: false, + }, + private: false, + html_url: 'https://github.com/gitpoint/git-point', + description: 'GitHub in your pocket :iphone:', + fork: false, + url: 'https://api.github.com/repos/gitpoint/git-point', + forks_url: 'https://api.github.com/repos/gitpoint/git-point/forks', + keys_url: 'https://api.github.com/repos/gitpoint/git-point/keys{/key_id}', + collaborators_url: + 'https://api.github.com/repos/gitpoint/git-point/collaborators{/collaborator}', + teams_url: 'https://api.github.com/repos/gitpoint/git-point/teams', + hooks_url: 'https://api.github.com/repos/gitpoint/git-point/hooks', + issue_events_url: + 'https://api.github.com/repos/gitpoint/git-point/issues/events{/number}', + events_url: 'https://api.github.com/repos/gitpoint/git-point/events', + assignees_url: + 'https://api.github.com/repos/gitpoint/git-point/assignees{/user}', + branches_url: + 'https://api.github.com/repos/gitpoint/git-point/branches{/branch}', + tags_url: 'https://api.github.com/repos/gitpoint/git-point/tags', + blobs_url: 'https://api.github.com/repos/gitpoint/git-point/git/blobs{/sha}', + git_tags_url: + 'https://api.github.com/repos/gitpoint/git-point/git/tags{/sha}', + git_refs_url: + 'https://api.github.com/repos/gitpoint/git-point/git/refs{/sha}', + trees_url: 'https://api.github.com/repos/gitpoint/git-point/git/trees{/sha}', + statuses_url: + 'https://api.github.com/repos/gitpoint/git-point/statuses/{sha}', + languages_url: 'https://api.github.com/repos/gitpoint/git-point/languages', + stargazers_url: 'https://api.github.com/repos/gitpoint/git-point/stargazers', + contributors_url: + 'https://api.github.com/repos/gitpoint/git-point/contributors', + subscribers_url: + 'https://api.github.com/repos/gitpoint/git-point/subscribers', + subscription_url: + 'https://api.github.com/repos/gitpoint/git-point/subscription', + commits_url: 'https://api.github.com/repos/gitpoint/git-point/commits{/sha}', + git_commits_url: + 'https://api.github.com/repos/gitpoint/git-point/git/commits{/sha}', + comments_url: + 'https://api.github.com/repos/gitpoint/git-point/comments{/number}', + issue_comment_url: + 'https://api.github.com/repos/gitpoint/git-point/issues/comments{/number}', + contents_url: + 'https://api.github.com/repos/gitpoint/git-point/contents/{+path}', + compare_url: + 'https://api.github.com/repos/gitpoint/git-point/compare/{base}...{head}', + merges_url: 'https://api.github.com/repos/gitpoint/git-point/merges', + archive_url: + 'https://api.github.com/repos/gitpoint/git-point/{archive_format}{/ref}', + downloads_url: 'https://api.github.com/repos/gitpoint/git-point/downloads', + issues_url: 'https://api.github.com/repos/gitpoint/git-point/issues{/number}', + pulls_url: 'https://api.github.com/repos/gitpoint/git-point/pulls{/number}', + milestones_url: + 'https://api.github.com/repos/gitpoint/git-point/milestones{/number}', + notifications_url: + 'https://api.github.com/repos/gitpoint/git-point/notifications{?since,all,participating}', + labels_url: 'https://api.github.com/repos/gitpoint/git-point/labels{/name}', + releases_url: 'https://api.github.com/repos/gitpoint/git-point/releases{/id}', + deployments_url: + 'https://api.github.com/repos/gitpoint/git-point/deployments', + created_at: '2017-03-26T02:45:46Z', + updated_at: '2017-10-16T17:15:40Z', + pushed_at: '2017-10-16T20:44:40Z', + git_url: 'git://github.com/gitpoint/git-point.git', + ssh_url: 'git@github.com:gitpoint/git-point.git', + clone_url: 'https://github.com/gitpoint/git-point.git', + svn_url: 'https://github.com/gitpoint/git-point', + homepage: 'https://gitpoint.co/', + size: 3798, + stargazers_count: 2501, + watchers_count: 2501, + language: 'JavaScript', + has_issues: true, + has_projects: true, + has_downloads: true, + has_wiki: true, + has_pages: false, + forks_count: 224, + mirror_url: null, + open_issues_count: 93, + forks: 224, + open_issues: 93, + watchers: 2501, + default_branch: 'master', + organization: { + login: 'gitpoint', + id: 30082377, + avatar_url: 'https://avatars0.githubusercontent.com/u/30082377?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/gitpoint', + html_url: 'https://github.com/gitpoint', + followers_url: 'https://api.github.com/users/gitpoint/followers', + following_url: + 'https://api.github.com/users/gitpoint/following{/other_user}', + gists_url: 'https://api.github.com/users/gitpoint/gists{/gist_id}', + starred_url: 'https://api.github.com/users/gitpoint/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/gitpoint/subscriptions', + organizations_url: 'https://api.github.com/users/gitpoint/orgs', + repos_url: 'https://api.github.com/users/gitpoint/repos', + events_url: 'https://api.github.com/users/gitpoint/events{/privacy}', + received_events_url: + 'https://api.github.com/users/gitpoint/received_events', + type: 'Organization', + site_admin: false, + }, + network_count: 224, + subscribers_count: 50, +}; diff --git a/__tests__/api/rest/github/mocks/user.json.js b/__tests__/api/rest/github/mocks/user.json.js new file mode 100644 index 000000000..c236701de --- /dev/null +++ b/__tests__/api/rest/github/mocks/user.json.js @@ -0,0 +1,32 @@ +export default { + login: 'machour', + id: 304450, + avatar_url: 'https://avatars2.githubusercontent.com/u/304450?v=4', + gravatar_id: '', + url: 'https://api.github.com/users/machour', + html_url: 'https://github.com/machour', + followers_url: 'https://api.github.com/users/machour/followers', + following_url: 'https://api.github.com/users/machour/following{/other_user}', + gists_url: 'https://api.github.com/users/machour/gists{/gist_id}', + starred_url: 'https://api.github.com/users/machour/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/machour/subscriptions', + organizations_url: 'https://api.github.com/users/machour/orgs', + repos_url: 'https://api.github.com/users/machour/repos', + events_url: 'https://api.github.com/users/machour/events{/privacy}', + received_events_url: 'https://api.github.com/users/machour/received_events', + type: 'User', + site_admin: false, + name: 'Mehdi Achour', + company: 'IDK', + blog: 'https://machour.idk.tn/', + location: 'Tunis', + email: null, + hireable: true, + bio: null, + public_repos: 56, + public_gists: 4, + followers: 65, + following: 43, + created_at: '2010-06-14T01:09:25Z', + updated_at: '2017-10-09T19:14:38Z', +}; diff --git a/__tests__/api/rest/github/schemas.test.js b/__tests__/api/rest/github/schemas.test.js new file mode 100644 index 000000000..09eeeacab --- /dev/null +++ b/__tests__/api/rest/github/schemas.test.js @@ -0,0 +1,32 @@ +import 'react-native'; +import { normalize } from 'normalizr'; +import schemas from 'api/rest/providers/github/schemas'; + +import mocks from './mocks'; + +jest.mock('react-native-i18n', () => { + return {}; +}); +Date.now = jest.fn(() => 1508189841664); + +it('normalizes user correctly', () => { + expect(normalize(mocks.userJson, schemas.USER)).toMatchSnapshot(); +}); + +it('normalizes repo correctly', () => { + expect(normalize(mocks.repoJson, schemas.REPO)).toMatchSnapshot(); +}); + +it('normalizes org correctly', () => { + expect(normalize(mocks.orgJson, schemas.ORG)).toMatchSnapshot(); +}); + +it('normalizes events correctly', () => { + expect(normalize(mocks.eventsJson, schemas.EVENT_ARRAY)).toMatchSnapshot(); +}); + +it('normalizes notifications correctly', () => { + expect( + normalize(mocks.notificationsJson, schemas.NOTIFICATION_ARRAY) + ).toMatchSnapshot(); +}); diff --git a/__tests__/index.android.js b/__tests__/index.android.js deleted file mode 100644 index 84e6b670d..000000000 --- a/__tests__/index.android.js +++ /dev/null @@ -1,10 +0,0 @@ -import 'react-native'; -import React from 'react'; -import renderer from 'react-test-renderer'; -import Index from '../index.android'; - -// Note: test renderer must be required after react-native. - -it('renders correctly', () => { - const tree = renderer.create(); -}); diff --git a/__tests__/index.ios.js b/__tests__/index.ios.js deleted file mode 100644 index 3b7b2e010..000000000 --- a/__tests__/index.ios.js +++ /dev/null @@ -1,10 +0,0 @@ -import 'react-native'; -import React from 'react'; -import renderer from 'react-test-renderer'; -import Index from '../index.ios'; - -// Note: test renderer must be required after react-native. - -it('renders correctly', () => { - const tree = renderer.create(); -}); diff --git a/package.json b/package.json index 51d76116b..d0e4bca92 100644 --- a/package.json +++ b/package.json @@ -118,12 +118,17 @@ "minicat": "^1.0.0", "prettier": "^1.7.4", "react-native-cli": "^2.0.1", - "react-test-renderer": "16.0.0-alpha.6", + "react-test-renderer": "16.0.0-alpha.12", "reactotron-react-native": "^1.12.2", "reactotron-redux": "^1.12.2" }, "jest": { - "preset": "react-native" + "preset": "react-native", + "testPathIgnorePatterns": [ + "/node_modules/", + "/node_modules/", + "/mocks/" + ] }, "rnpm": { "assets": [ diff --git a/yarn.lock b/yarn.lock index 9bdb7b63b..d54700b10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5541,9 +5541,9 @@ react-syntax-highlighter@^5.6.2: highlight.js "~9.12.0" lowlight "~1.9.1" -react-test-renderer@16.0.0-alpha.6: - version "16.0.0-alpha.6" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.0.0-alpha.6.tgz#c032def0dc8319cee39caa4e4373a60019cb3786" +react-test-renderer@16.0.0-alpha.12: + version "16.0.0-alpha.12" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.0.0-alpha.12.tgz#9e4cc5d8ce8bfca72778340de3e1454b9d6c0cc5" dependencies: fbjs "^0.8.9" object-assign "^4.1.0"