diff --git a/app/__mocks__/state.js b/app/__mocks__/state.js index 448756c0..16c5d1aa 100644 --- a/app/__mocks__/state.js +++ b/app/__mocks__/state.js @@ -64,9 +64,9 @@ export const state = { "dropCapFamily": "header", "isCoverInline": true, }, - "showMercuryContent": true, + "showMercuryContent": false, "content_html": "It wasn’t all a mess. Here’s how the government and tech companies tamed foreign interference.", - "hasLoadedMercuryStuff": true, + "hasLoadedMercuryStuff": false, "date_published": 1615232395000, "original_created_at": 1615232395000, "id": "https://www.nytimes.com/2021/03/08/technology/what-went-right-in-the-2020-election.html", @@ -83,7 +83,7 @@ export const state = { "feed_id": "b63b3782-8a69-8a31-bd95-1b48674b71e3", "date_modified": 1615232395000, "feed_title": "NYT > Technology", - "showCoverImage": true, + "showCoverImage": false, "external_url": "https://www.nytimes.com/2021/03/08/technology/what-went-right-in-the-2020-election.html", "excerpt": "It wasn’t all a mess. Here’s how the government and tech companies tamed foreign interference.", "created_at": 1615232395000, diff --git a/app/components/AppStateListener.js b/app/components/AppStateListener.js index e371bdd4..a53ebe3a 100644 --- a/app/components/AppStateListener.js +++ b/app/components/AppStateListener.js @@ -2,41 +2,21 @@ import React, { useEffect } from 'react' import { AppState } from 'react-native' -import Clipboard from "@react-native-community/clipboard" -import SharedGroupPreferences from 'react-native-shared-group-preferences' -import { parseString } from 'react-native-xml2js' - -import { isIgnoredUrl, addIgnoredUrl } from '../storage/async-storage' -import log from '../utils/log' import DarkModeListener from './DarkModeListener' class AppStateListener extends React.Component { - group = 'group.com.adam-butler.rizzle' - MINIMUM_UPDATE_INTERVAL = 600000 // 10 minutes constructor (props) { super(props) this.props = props - this.checkClipboard = this.checkClipboard.bind(this) - this.checkPageBucket = this.checkPageBucket.bind(this) - this.checkFeedBucket = this.checkFeedBucket.bind(this) this.handleAppStateChange = this.handleAppStateChange.bind(this) - this.showSavePageModal = this.showSavePageModal.bind(this) - this.showSaveFeedModal = this.showSaveFeedModal.bind(this) AppState.addEventListener('change', this.handleAppStateChange) - this.checkBuckets() - } - - async checkBuckets () { - await this.checkClipboard() - await this.checkPageBucket() - await this.checkFeedBucket() - // see Rizzle component + this.props.checkBuckets() } async handleAppStateChange (nextAppState) { @@ -47,7 +27,7 @@ class AppStateListener extends React.Component { this.setState({ doNothing: Date.now() }) - await this.checkBuckets() + this.props.checkBuckets() if (!global.isStarting && (Date.now() - this.props.lastUpdated > this.MINIMUM_UPDATE_INTERVAL)) { this.props.fetchData() @@ -59,192 +39,6 @@ class AppStateListener extends React.Component { } } - async checkClipboard () { - console.log('Checking clipboard') - try { - const hasUrl = await Clipboard.hasURL() - if (!hasUrl) { - return - } - let contents = await Clipboard.getString() ?? '' - // TODO make this more robust - // right now we're ignoring any URLs that include 'rizzle.net' - // this is due to links getting into clipboard during the email auth process - if (contents.substring(0, 4) === 'http' && - contents.indexOf('rizzle.net') === -1) { - const isIgnored = await isIgnoredUrl(contents) - if (!isIgnored) { - this.showSavePageModal.call(this, contents, true) - } - } else if (contents.substring(0, 6) === '') { - } - } catch(err) { - log('checkClipboard', err) - } - } - - async checkPageBucket () { - SharedGroupPreferences.getItem('page', this.group).then(value => { - console.log('CHECKING PAGE BUCKET: ' + value) - if (value !== null) { - SharedGroupPreferences.setItem('page', null, this.group) - const parsed = JSON.parse(value) - const pages = typeof parsed === 'object' ? - parsed : - [parsed] - console.log(`Got ${pages.length} page${pages.length === 1 ? '' : 's'} to save`) - const that = this - pages.forEach(page => { - // ugh, need a timeout to allow for rehydration - setTimeout(() => { - that.savePage.call(that, page) - }, 100) - }) - } - }).catch(err => { - // '1' just means that there is nothing in the bucket - if (err !== 1) { - log('checkPageBucket', err) - } - }) - } - - async checkFeedBucket () { - SharedGroupPreferences.getItem('feed', this.group).then(value => { - if (value !== null) { - const url = value - const that = this - SharedGroupPreferences.setItem('feed', null, this.group) - console.log(`Got a feed to subscribe to: ${url}`) - // TODO check that value is a feed url - // TODO check that feed is not already subscribed! - // right now it will just get ignored if it's already subscribed - // but it might be nice to say that in the message - fetch(url) - .then((response) => { - if (!response.ok) { - throw Error(response.statusText) - } - return response - }) - .then((response) => { - return response.text() - }) - .then((xml) => { - parseString(xml, (error, result) => { - if (error) { - throw error - } - let title, description - if (result.rss) { - title = typeof result.rss.channel[0].title[0] === 'string' ? - result.rss.channel[0].title[0] : - result.rss.channel[0].title[0]._ - description = result.rss.channel[0].description ? - (typeof result.rss.channel[0].description[0] === 'string' ? - result.rss.channel[0].description[0] : - result.rss.channel[0].description[0]._) : - '' - } else if (result.feed) { - // atom - title = typeof result.feed.title[0] === 'string' ? - result.feed.title[0] : - result.feed.title[0]._ - description = result.feed.subtitle ? - (typeof result.feed.subtitle[0] === 'string' ? - result.feed.subtitle[0] : - result.feed.subtitle[0]._) : - '' - } - this.showSaveFeedModal(url, title, description, that) - }) - }) - .catch(err => { - log('checkFeedBucket', err) - }) - } - }).catch(err => { - // '1' just means that there is nothing in the bucket - if (err !== 1) { - log('checkFeedBucket', err) - } - }) - } - - savePage (page) { - console.log(`Saving page: ${page.url}`) - this.props.saveURL(page.url, page.title) - this.props.addMessage('Saved page: ' + (page.title ?? page.url)) - } - - addFeed (feed) { - this.props.addFeed(feed) - this.props.addMessage('Added feed: ' + (feed.title ?? feed.url)) - this.props.fetchData() - } - - showSavePageModal (url, isClipboard = false) { - let displayUrl = url - if (displayUrl.length > 64) { - displayUrl = displayUrl.slice(0, 64) + '…' - } - let modalText = [ - { - text: 'Save this page?', - style: ['title'] - }, - { - text: displayUrl, - style: ['em'] - } - ] - if (isClipboard) { - modalText.push({ - text: 'This URL was in your clipboard. Copying a URL is an easy way to save a page in Rizzle.', - style: ['hint'] - }) - } - const onOk = () => { - this.savePage({ url }) - } - // onOk = onOk.bind(this) - this.props.showModal({ - modalText, - modalHideCancel: false, - modalShow: true, - modalOnOk: onOk.bind(this) - }) - } - - showSaveFeedModal (url, title, description, scope) { - scope.props.showModal({ - modalText: [ - { - text: 'Add this feed?', - style: ['title'] - }, - { - text: title, - style: ['em'] - }, - { - text: description, - style: ['em', 'smaller'] - } - ], - modalHideCancel: false, - modalShow: true, - modalOnOk: () => { - scope.props.addFeed({ - url, - title, - description - }) - scope.props.fetchData() - } - }) - } - render () { return } diff --git a/app/containers/AppStateListener.js b/app/containers/AppStateListener.js index 2b9838fe..c5be4d6e 100644 --- a/app/containers/AppStateListener.js +++ b/app/containers/AppStateListener.js @@ -1,21 +1,13 @@ -import { - SAVE_EXTERNAL_URL, - ItemType -} from '../store/items/types' import { STATE_ACTIVE, STATE_INACTIVE } from '../store/config/types' import { connect } from 'react-redux' -import { ADD_FEED } from '../store/feeds/types' import { + CHECK_BUCKETS, ADD_MESSAGE, FETCH_ITEMS, - SHOW_MODAL } from '../store/ui/types' -import { - SET_DISPLAY_MODE -} from '../store/items/types' import AppStateListener from '../components/AppStateListener' const mapStateToProps = (state) => { @@ -29,49 +21,16 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { - fetchData: () => dispatch({ - type: FETCH_ITEMS + checkBuckets: () => dispatch({ + type: CHECK_BUCKETS }), updateCurrentAppState: (state) => dispatch(updateCurrentAppState(state)), - saveURL: (url, title) => { - dispatch({ - type: SAVE_EXTERNAL_URL, - url, - title - }) - dispatch({ - type: SET_DISPLAY_MODE, - displayMode: ItemType.saved - }) - }, - addFeed: (feed) => dispatch({ - type: ADD_FEED, - feed - }), - showModal: (modalProps) => { - console.log("SHOW MODAL!") - dispatch({ - type: SHOW_MODAL, - modalProps - }) - }, appWentInactive: () => dispatch({ type: STATE_INACTIVE }), appWentActive: () => dispatch({ type: STATE_ACTIVE }), - setDarkMode: (isDarkMode) => dispatch({ - type: SET_DARK_MODE, - isDarkMode - }), - addMessage: (messageString) => dispatch({ - type: ADD_MESSAGE, - message: { - messageString, - isSelfDestruct: true - } - }) } } diff --git a/app/docs/todo.md b/app/docs/todo.md index 94863bfd..5940ab56 100644 --- a/app/docs/todo.md +++ b/app/docs/todo.md @@ -1,4 +1,5 @@ +- check bucket when closing browser - why do saved external items (sometimes) render twice? - onboarding in dark mode - muting a feed on non-rizzle accounts diff --git a/app/sagas/check-buckets.js b/app/sagas/check-buckets.js new file mode 100644 index 00000000..8b48d435 --- /dev/null +++ b/app/sagas/check-buckets.js @@ -0,0 +1,224 @@ +import SharedGroupPreferences from 'react-native-shared-group-preferences' +import Clipboard from "@react-native-community/clipboard" +import { isIgnoredUrl, addIgnoredUrl } from "../storage/async-storage" +import { parseString } from 'react-native-xml2js' +import log from '../utils/log' +import { call, delay } from '@redux-saga/core/effects' + +const GROUP_NAME = 'group.com.adam-butler.rizzle' + +export function * checkBuckets () { + yield checkClipboard() + yield checkPageBucket() + yield checkFeedBucket() +} + +function * checkClipboard () { + console.log('Checking clipboard') + try { + const hasUrl = yield call(Clipboard.hasURL) + if (!hasUrl) { + return + } + let contents = yield call(Clipboard.getString) + contents = contents ?? '' + // TODO make this more robust + if (contents.substring(0, 4) === 'http') { + const isIgnored = yield call(isIgnoredUrl, contents) + if (!isIgnored) { + yield showSavePageModal(contents, true) + } + } else if (contents.substring(0, 6) === '') { + } + } catch(err) { + log('checkClipboard', err) + } +} + +function * checkPageBucket () { + SharedGroupPreferences.getItem('page', GROUP_NAME).then(value => { + if (value !== null) { + SharedGroupPreferences.setItem('page', null, GROUP_NAME) + const parsed = JSON.parse(value) + const pages = typeof parsed === 'object' ? + parsed : + [parsed] + console.log(`Got ${pages.length} page${pages.length === 1 ? '' : 's'} to save`) + // ugh, need a timeout to allow for rehydration + yield delay(100) + pages.forEach(page => { + yield call(savePage, page) + }) + } + }).catch(err => { + // '1' just means that there is nothing in the bucket + if (err !== 1) { + log('checkPageBucket', err) + } + }) +} + +function * checkFeedBucket () { + SharedGroupPreferences.getItem('feed', GROUP_NAME).then(value => { + if (value !== null) { + const url = value + SharedGroupPreferences.setItem('feed', null, GROUP_NAME) + console.log(`Got a feed to subscribe to: ${url}`) + // TODO check that value is a feed url + // TODO check that feed is not already subscribed! + // right now it will just get ignored if it's already subscribed + // but it might be nice to say that in the message + fetch(url) + .then((response) => { + if (!response.ok) { + throw Error(response.statusText) + } + return response + }) + .then((response) => { + return response.text() + }) + .then((xml) => { + parseString(xml, (error, result) => { + if (error) { + throw error + } + let title, description + if (result.rss) { + title = typeof result.rss.channel[0].title[0] === 'string' ? + result.rss.channel[0].title[0] : + result.rss.channel[0].title[0]._ + description = result.rss.channel[0].description ? + (typeof result.rss.channel[0].description[0] === 'string' ? + result.rss.channel[0].description[0] : + result.rss.channel[0].description[0]._) : + '' + } else if (result.feed) { + // atom + title = typeof result.feed.title[0] === 'string' ? + result.feed.title[0] : + result.feed.title[0]._ + description = result.feed.subtitle ? + (typeof result.feed.subtitle[0] === 'string' ? + result.feed.subtitle[0] : + result.feed.subtitle[0]._) : + '' + } + showSaveFeedModal(url, title, description) + }) + }) + .catch(err => { + log('checkFeedBucket', err) + }) + } + }).catch(err => { + // '1' just means that there is nothing in the bucket + if (err !== 1) { + log('checkFeedBucket', err) + } + }) +} + +function * savePage (page) { + yield saveURL(page.url, page.title) + yield addMessage('Saved page: ' + (page.title ?? page.url)) +} + +function * showSavePageModal (url, isClipboard = false) { + let displayUrl = url + if (displayUrl.length > 64) { + displayUrl = displayUrl.slice(0, 64) + '…' + } + let modalText = [ + { + text: 'Save this page?', + style: ['title'] + }, + { + text: displayUrl, + style: ['em'] + } + ] + if (isClipboard) { + modalText.push({ + text: 'This URL was in your clipboard. Copying a URL is an easy way to save a page in Rizzle.', + style: ['hint'] + }) + } + yield showModal({ + modalText, + modalHideCancel: false, + modalShow: true, + modalOnOk: () => { + savePage(url) + } + }) +} + +function * showSaveFeedModal (url, title, description) { + yield showModal({ + modalText: [ + { + text: 'Add this feed?', + style: ['title'] + }, + { + text: title, + style: ['em'] + }, + { + text: description, + style: ['em', 'smaller'] + } + ], + modalHideCancel: false, + modalShow: true, + modalOnOk: () => { + addFeed({ + url, + title, + description + }) + fetchData() + } + }) +} + +function * showModal (modalProps) { + yield put({ + type: SHOW_MODAL, + modalProps + }) +} + +function * saveURL (url) { + yield put({ + type: SAVE_EXTERNAL_URL, + url + }) + // yield put({ + // type: SET_DISPLAY_MODE, + // displayMode: ItemType.saved + // }) +} + +function * addFeed (feed) { + yield put({ + type: ADD_FEED, + feed + }) +} + +function * fetchData () { + dispatch({ + type: FETCH_ITEMS + }) +} + +function * addMessage (messageString) { + dispatch({ + type: ADD_MESSAGE, + messageString, + isSelfDestruct: true + }) +} diff --git a/app/sagas/index.js b/app/sagas/index.js index 16661bd4..849d558b 100644 --- a/app/sagas/index.js +++ b/app/sagas/index.js @@ -28,11 +28,13 @@ import { SET_FEEDS } from '../store/feeds/types' import { + CHECK_BUCKETS, FETCH_ITEMS, ITEMS_SCREEN_BLUR, ITEMS_SCREEN_FOCUS } from '../store/ui/types' import { decorateItems } from './decorate-items' +import { checkBuckets } from './check-buckets' import { fetchAllItems, fetchUnreadItems } from './fetch-items' import { markLastItemRead, clearReadItems, filterItemsForRead } from './mark-read' import { pruneItems, removeItems, removeAllItems } from './prune-items' @@ -99,6 +101,7 @@ export function * initSagas () { yield takeEvery(UNSAVE_ITEM, inflateItems) yield takeEvery(SET_DISPLAY_MODE, inflateItems) yield takeEvery(UPDATE_CURRENT_INDEX, inflateItems) + yield takeEvery(CHECK_BUCKETS, checkBuckets) yield takeEvery(FETCH_ITEMS, clearReadItems) yield takeEvery(FETCH_ITEMS, fetchAllItems) yield takeEvery(CLEAR_READ_ITEMS, clearReadItems) diff --git a/app/store/ui/types.ts b/app/store/ui/types.ts index 86c4c97b..5134e22d 100644 --- a/app/store/ui/types.ts +++ b/app/store/ui/types.ts @@ -44,6 +44,7 @@ export const HIDE_IMAGE_VIEWER = 'HIDE_IMAGE_VIEWER' export const TOGGLE_HIDE_MODAL = 'TOGGLE_HIDE_MODAL' export const FETCH_ITEMS = 'FETCH_ITEMS' export const FETCH_DATA_SUCCESS = 'FETCH_DATA_SUCCESS' +export const CHECK_BUCKETS = 'CHECK_BUCKETS' export const SET_DARK_MODE = 'SET_DARK_MODE' export const TOGGLE_DARK_MODE = 'TOGGLE_DARK_MODE' export const INCREASE_FONT_SIZE = 'INCREASE_FONT_SIZE'