diff --git a/.github/workflows/jest-tests.yml b/.github/workflows/jest-tests.yml new file mode 100644 index 00000000..76835e21 --- /dev/null +++ b/.github/workflows/jest-tests.yml @@ -0,0 +1,30 @@ +name: Jest Tests + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: jest-tests-${{ format('{0}-{1}', github.head_ref || github.run_number, github.job) }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 24 + cache: yarn + + - name: Yarn install + run: yarn + + - name: Jest + run: yarn test:unit diff --git a/e2e/onboarding.test.js b/e2e/onboarding.test.js index bccc8ab1..ddbbad49 100644 --- a/e2e/onboarding.test.js +++ b/e2e/onboarding.test.js @@ -1,8 +1,3 @@ -/*global describe*/ -/*global beforeAll*/ -/*global beforeEach*/ -/*global it*/ - import { by, device, element, expect } from 'detox'; import i18n from 'i18n-js'; @@ -42,7 +37,7 @@ describe.each([['en'], ['fr']])(`Onboarding (locale: %s)`, locale => { it('should show the Discover screen', async () => { await element(by.text(i18n.t('discover'))).tap(); - await expect(element(by.text(i18n.t('discover_all')))).toBeVisible(); + await expect(element(by.text(i18n.t('discover_pick_tag')))).toBeVisible(); }); it('should show the Notifications screen', async () => { diff --git a/e2e/topiclist.test.js b/e2e/topiclist.test.js index 74c1892e..bc881594 100644 --- a/e2e/topiclist.test.js +++ b/e2e/topiclist.test.js @@ -1,8 +1,3 @@ -/*global describe*/ -/*global beforeAll*/ -/*global beforeEach*/ -/*global it*/ - import i18n from 'i18n-js'; import { by, device, element, expect } from 'detox'; diff --git a/eslint.config.js b/eslint.config.js index 15d5653a..1ebda5a8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,5 +19,13 @@ export default [ 'no-undef': 'warn', }, }, + { + files: ['**/__tests__/**/*.js', '**/*.test.js'], + languageOptions: { + globals: { + ...globals.jest, + }, + }, + }, { ignores: ['lib/*', 'react-native.config.js', '.*', '*.config.js'] }, ]; diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 293b918c..c1633306 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -2,8 +2,8 @@ fastlane_require "base64" fastlane_require "fileutils" fastlane_require "json" -IOS_APP_VERSION = "2.1.5" -ANDROID_APP_VERSION = "2.1.2" # run `fastlane bootstrap` after editing this +IOS_APP_VERSION = "2.2.0" +ANDROID_APP_VERSION = "2.2.0" # run `fastlane bootstrap` after editing this PROJECT_NAME = "Discourse" IOS_TEAM_ID = "6T3LU73T8S" KEYS_REPOSITORY = "git@github.com:discourse-org/discourse-mobile-keys.git" @@ -172,8 +172,8 @@ platform :ios do export_method: "app-store", export_options: { provisioningProfiles: { - "org.discourse.DiscourseApp" => "match AppStore org.discourse.DiscourseApp", - "org.discourse.DiscourseApp.ShareExtension" => "match AppStore org.discourse.DiscourseApp.ShareExtension" + "org.discourse.DiscourseApp" => "match AppStore org.discourse.DiscourseApp 1776778136", + "org.discourse.DiscourseApp.ShareExtension" => "match AppStore org.discourse.DiscourseApp.ShareExtension 1776778138" } }, clean: true, @@ -194,8 +194,8 @@ platform :ios do export_method: "ad-hoc", export_options: { provisioningProfiles: { - "org.discourse.DiscourseApp" => "match AdHoc org.discourse.DiscourseApp", - "org.discourse.DiscourseApp.ShareExtension" => "match AdHoc org.discourse.DiscourseApp.ShareExtension" + "org.discourse.DiscourseApp" => "match AdHoc org.discourse.DiscourseApp 1776778144", + "org.discourse.DiscourseApp.ShareExtension" => "match AdHoc org.discourse.DiscourseApp.ShareExtension 1776778146" } }, clean: true, diff --git a/ios/Discourse.xcodeproj/project.pbxproj b/ios/Discourse.xcodeproj/project.pbxproj index db53bd23..473286f1 100644 --- a/ios/Discourse.xcodeproj/project.pbxproj +++ b/ios/Discourse.xcodeproj/project.pbxproj @@ -471,7 +471,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 660; + CURRENT_PROJECT_VERSION = 671; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 6T3LU73T8S; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 6T3LU73T8S; @@ -498,7 +498,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.discourse.DiscourseApp; PRODUCT_NAME = Discourse; PROVISIONING_PROFILE_SPECIFIER = "match AdHoc org.discourse.DiscourseApp"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp 1776778144"; SWIFT_OBJC_BRIDGING_HEADER = "Discourse-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -516,7 +516,7 @@ CODE_SIGN_ENTITLEMENTS = Discourse/Discourse.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 660; + CURRENT_PROJECT_VERSION = 671; DEVELOPMENT_TEAM = 6T3LU73T8S; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 6T3LU73T8S; ENABLE_BITCODE = NO; @@ -543,7 +543,7 @@ PRODUCT_NAME = Discourse; PROVISIONING_PROFILE = "8a5dde79-abbd-4707-a921-2b4412ef65ad"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.discourse.DiscourseApp"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.discourse.DiscourseApp"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.discourse.DiscourseApp 1776778136"; SWIFT_OBJC_BRIDGING_HEADER = "Discourse-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -605,10 +605,7 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift$(inherited)"; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -664,10 +661,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 10.0; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift$(inherited)"; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -702,7 +696,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.discourse.DiscourseApp.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AdHoc org.discourse.DiscourseApp.ShareExtension"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp.ShareExtension"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp.ShareExtension 1776778146"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -737,7 +731,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.discourse.DiscourseApp.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AdHoc org.discourse.DiscourseApp.ShareExtension"; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.discourse.DiscourseApp.ShareExtension"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AdHoc org.discourse.DiscourseApp.ShareExtension 1776778146"; SKIP_INSTALL = YES; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; SWIFT_VERSION = 5.0; diff --git a/ios/Discourse/Info.plist b/ios/Discourse/Info.plist index 98dc4ffa..c98a0038 100644 --- a/ios/Discourse/Info.plist +++ b/ios/Discourse/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.1.5 + 2.2.0 CFBundleSignature ???? CFBundleURLTypes @@ -37,7 +37,7 @@ CFBundleVersion - 660 + 671 Fabric APIKey diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist index dc71f146..ed5589d9 100644 --- a/ios/ShareExtension/Info.plist +++ b/ios/ShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.1.5 + 2.2.0 CFBundleVersion - 660 + 671 NSExtension NSExtensionAttributes diff --git a/jest.config.js b/jest.config.js index 91210011..f4e738d8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,4 @@ module.exports = { preset: 'react-native', + testPathIgnorePatterns: ['/node_modules/', '/e2e/'], }; \ No newline at end of file diff --git a/js/locale/en.json b/js/locale/en.json index 6c554516..e7e92390 100644 --- a/js/locale/en.json +++ b/js/locale/en.json @@ -92,6 +92,18 @@ "sites": "Sites", "find_new_communities": "Find new communities to join", "no_hot_topics": "No hot topics found for this site.", + "discover_communities": "Communities", + "discover_pick_tag": "What are you interested in?", + "discover_pick_tag_description": "See what people have been discussing in Discourse communities recently and join the ones that most match your interests.", + "discover_explore_more": "Explore another topic", + "discover_communities_section": "%{tag} Communities", + "discover_topics_section": "Recent Discussions in %{tag}", + "discover_see_all_communities": "See all communities", + "discover_community_directory": "Community Directory", + "remove_from_home_screen": "Remove", + "preview": "Preview", + "community_recent_topics": "Hot Topics", "oops": "Oops, something went wrong.", - "still_loading": "Still loading..." + "still_loading": "Still loading...", + "tag_label_programming_language": "programming" } diff --git a/js/locale/fr.json b/js/locale/fr.json index 1d9bf389..3744fe24 100644 --- a/js/locale/fr.json +++ b/js/locale/fr.json @@ -90,5 +90,15 @@ "hot_topics": "Sujets populaires", "sites": "Sites", "find_new_communities": "Cherchez de nouvelles communautés à rejoindre", - "no_hot_topics": "Aucun sujet populaire n'a été trouvé pour ce site." + "no_hot_topics": "Aucun sujet populaire n'a été trouvé pour ce site.", + "discover_communities": "Communautés", + "discover_pick_tag": "Qu'est-ce qui vous intéresse ?", + "discover_explore_more": "Explorer un autre sujet", + "discover_communities_section": "COMMUNAUTÉS", + "discover_topics_section": "SUJETS", + "discover_see_all_communities": "Voir toutes les communautés", + "remove_from_home_screen": "Supprimer", + "preview": "Aperçu", + "community_recent_topics": "Sujets populaires", + "tag_label_programming_language": "programmation" } diff --git a/js/screens/CommonComponents/SiteLogo.js b/js/screens/CommonComponents/SiteLogo.js index 41808334..3e8778b8 100644 --- a/js/screens/CommonComponents/SiteLogo.js +++ b/js/screens/CommonComponents/SiteLogo.js @@ -5,16 +5,27 @@ import { useContext } from 'react'; import { Image, Text, View } from 'react-native'; import { ThemeContext } from '../../ThemeContext'; -const SiteLogo = props => { - const theme = useContext(ThemeContext); +export function isValidLogoUrl(url) { + return url && !url.endsWith('.webp') && !url.endsWith('.svg'); +} - function hashCode(str) { - let hash = 0; - for (var i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - return hash; +function hashCode(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); } + return hash; +} + +const SiteLogo = ({ + logoImage, + title, + size = 42, + borderRadius = 10, + style, +}) => { + const theme = useContext(ThemeContext); + const fontSize = Math.round(size * 0.62); function pickColor(str, text = false) { const darkMode = theme.name === 'dark'; @@ -29,30 +40,32 @@ const SiteLogo = props => { return `hsl(${hashCode(str) % 360}, ${s}%, ${l}%)`; } - if (props.logoImage === false) { - // Generate a placeholder icon in lieu of a logo from the site's first initial character + if (logoImage === false) { return ( - {props.title[0]} + {title[0]} ); @@ -60,15 +73,18 @@ const SiteLogo = props => { return ( ); diff --git a/js/screens/DiscoverScreen.js b/js/screens/DiscoverScreen.js index 5806f95e..e9cdfa10 100644 --- a/js/screens/DiscoverScreen.js +++ b/js/screens/DiscoverScreen.js @@ -2,96 +2,309 @@ 'use strict'; import React from 'react'; -import { - ActivityIndicator, - Alert, - FlatList, - Linking, - PermissionsAndroid, - Platform, - RefreshControl, - ScrollView, - StyleSheet, - Text, - TouchableHighlight, - View, -} from 'react-native'; +import { Alert, BackHandler, PermissionsAndroid, Platform } from 'react-native'; import DiscoverComponents from './DiscoverScreenComponents'; +import SplashView from './DiscoverScreenComponents/views/SplashView'; +import SearchView from './DiscoverScreenComponents/views/SearchView'; +import TagDetailView from './DiscoverScreenComponents/views/TagDetailView'; +import AllCommunitiesView from './DiscoverScreenComponents/views/AllCommunitiesView'; +import CommunityDetailView from './DiscoverScreenComponents/views/CommunityDetailView'; +import { + VIEWS, + defaultView, + FALLBACK_TAGS, + toastConfig, +} from './DiscoverScreenComponents/constants'; +import * as api from './DiscoverScreenComponents/api'; import { ThemeContext } from '../ThemeContext'; import Site from '../site'; import i18n from 'i18n-js'; -import fetch from './../../lib/fetch'; import { debounce } from 'lodash'; -import Toast, { BaseToast } from 'react-native-toast-message'; +import Toast from 'react-native-toast-message'; import { BottomTabBarHeightContext } from '@react-navigation/bottom-tabs'; import { SafeAreaView } from 'react-native-safe-area-context'; -const toastConfig = { - success: props => ( - { - if (Platform.OS === 'android') { - Linking.openSettings(); - } - if (Platform.OS === 'ios') { - // We can't call PushNotificationIOS.requestPermissions again - // per https://developer.apple.com/documentation/usernotifications/asking_permission_to_use_notifications?language=objc - // only the first authorization request is honored - Linking.openURL('App-Prefs:NOTIFICATIONS_ID'); - } - }} - contentContainerStyle={{ paddingHorizontal: 10 }} - text1Style={{ - fontSize: 17, - fontWeight: '400', - }} - text2Style={{ - fontSize: 17, - }} - /> - ), -}; - class DiscoverScreen extends React.Component { constructor(props) { super(props); - this.defaultSearch = '#locale-en'; - this.state = { - loading: true, - page: 1, - results: [], - selectionCount: 0, + // View navigation + view: defaultView, + previousView: defaultView, + + // Tag splash + splashTags: [], + splashTagsLoading: true, + + // Search mode (existing behavior) term: '', + results: [], + loading: false, + page: 1, selectedTag: '', hasMoreResults: false, - progress: 0.5, + selectionCount: 0, + + // Tag detail + activeTag: null, + tagCommunities: [], + tagCommunitiesLoading: true, + hotTopics: [], + hotTopicsLoading: true, + hotTopicsPage: 1, + hotTopicsHasMore: false, + + // All communities + communitiesFilter: null, // null = all, 'recent' = order by latest, otherwise a tag + allCommunities: [], + allCommunitiesLoading: false, + + // Community detail + activeCommunity: null, + communityTopics: [], + communityTopicsLoading: true, + communityTopicsPage: 1, + communityTopicsHasMore: false, + largerUI: props.screenProps.largerUI, }; this._siteManager = this.props.screenProps.siteManager; - this.baseUrl = `${Site.discoverUrl()}/search.json?q=`; this.maxPageNumber = 10; this.debouncedSearch = debounce(this.doSearch, 750); - this.doSearch(''); + this.fetchSplashTags(); + } + + componentDidMount() { + this._unsubscribeTabPress = this.props.navigation.addListener( + 'tabPress', + () => { + if ( + this.props.navigation.isFocused() && + this.state.view !== VIEWS.SPLASH + ) { + this._navigateToSplash(); + } + }, + ); + + if (Platform.OS === 'android') { + this._backHandler = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + if ( + this.state.view !== VIEWS.SPLASH && + this.state.view !== VIEWS.SEARCH + ) { + this._navigateBack(); + return true; + } + return false; + }, + ); + } + } + + componentWillUnmount() { + if (this._unsubscribeTabPress) { + this._unsubscribeTabPress(); + } + if (this._backHandler) { + this._backHandler.remove(); + } + } + + // ── API Methods ── + + fetchSplashTags() { + api + .fetchSplashTags() + .then(json => { + if (json.tags && json.tags.length > 0) { + this.setState({ splashTags: json.tags, splashTagsLoading: false }); + } else { + this.setState({ + splashTags: FALLBACK_TAGS, + splashTagsLoading: false, + }); + } + }) + .catch(() => { + this.setState({ splashTags: FALLBACK_TAGS, splashTagsLoading: false }); + }); + } + + onSelectTag(tag) { + this.setState({ + activeTag: tag, + hotTopics: [], + hotTopicsLoading: true, + hotTopicsPage: 1, + hotTopicsHasMore: false, + tagCommunities: [], + tagCommunitiesLoading: true, + view: VIEWS.TAG_DETAIL, + }); + + this._fetchTagCommunities(tag); + this._fetchHotTopicsForTag(tag); + } + + _fetchHotTopicsForTag(tag) { + api + .fetchHotTopics(tag, 1) + .then(json => { + if (tag !== this.state.activeTag) { + return; // stale response + } + + const topics = json.hot_topics || []; + this.setState({ + hotTopics: topics, + hotTopicsLoading: false, + hotTopicsHasMore: Boolean(json.more_topics_url), + }); + + if (topics.length === 0) { + this.setState({ + view: VIEWS.ALL_COMMUNITIES, + previousView: VIEWS.SPLASH, + communitiesFilter: tag, + allCommunities: [], + allCommunitiesLoading: true, + }); + this._fetchAllCommunities(tag); + } + }) + .catch(e => { + console.log(e); + if (tag !== this.state.activeTag) { + return; + } + this.setState({ hotTopicsLoading: false }); + }); + } + + fetchHotTopics(tag, opts = {}) { + const page = opts.pageNumber || 1; + + api + .fetchHotTopics(tag, page) + .then(json => { + if (tag !== this.state.activeTag) { + return; + } + + if (json.hot_topics) { + this.setState(prevState => ({ + hotTopics: opts.append + ? prevState.hotTopics.concat(json.hot_topics) + : json.hot_topics, + hotTopicsLoading: false, + hotTopicsHasMore: Boolean(json.more_topics_url), + })); + } else { + this.setState(prevState => ({ + hotTopics: opts.append ? prevState.hotTopics : [], + hotTopicsLoading: false, + hotTopicsHasMore: false, + })); + } + }) + .catch(e => { + console.log(e); + this.setState({ hotTopicsLoading: false }); + }); + } + + _fetchTagCommunities(tag) { + api + .fetchTagCommunities(tag) + .then(json => { + if (tag !== this.state.activeTag) { + return; + } + + if (json.topics) { + this.setState({ + tagCommunities: json.topics, + tagCommunitiesLoading: false, + }); + } else { + this.setState({ tagCommunities: [], tagCommunitiesLoading: false }); + } + }) + .catch(e => { + console.log(e); + this.setState({ tagCommunitiesLoading: false }); + }); + } + + onPressCommunity(community) { + this.setState({ + activeCommunity: community, + communityTopics: [], + communityTopicsLoading: true, + communityTopicsPage: 1, + communityTopicsHasMore: false, + view: VIEWS.COMMUNITY_DETAIL, + previousView: this.state.view, + }); + + this._fetchCommunityHotTopics(community.featured_link); + } + + _fetchCommunityHotTopics(communityUrl, opts = {}) { + const page = opts.page || 0; + + api + .fetchCommunityHotTopics(communityUrl, page) + .then(json => { + if (communityUrl !== this.state.activeCommunity?.featured_link) { + return; + } + + const rawTopics = (json.topic_list && json.topic_list.topics) || []; + const topics = rawTopics.map(topic => ({ + ...topic, + url: `${communityUrl}/t/${topic.slug}/${topic.id}`, + })); + + this.setState(prevState => ({ + communityTopics: opts.append + ? prevState.communityTopics.concat(topics) + : topics, + communityTopicsLoading: false, + communityTopicsHasMore: Boolean(json.topic_list?.more_topics_url), + })); + }) + .catch(e => { + console.log(e); + this.setState({ communityTopicsLoading: false }); + }); + } + + _fetchNextCommunityTopicsPage() { + if (this.state.communityTopicsHasMore && this.state.activeCommunity) { + const newPage = this.state.communityTopicsPage + 1; + this.setState({ + communityTopicsPage: newPage, + communityTopicsLoading: true, + }); + this._fetchCommunityHotTopics(this.state.activeCommunity.featured_link, { + append: true, + page: newPage, + }); + } } doSearch(term, opts = {}) { - const searchTerm = term === '' ? this.defaultSearch : term; - const order = term.startsWith('order:') ? '' : 'order:featured'; - const searchString = `#discover ${searchTerm} ${order}`; - const q = `${this.baseUrl}${encodeURIComponent(searchString)}&page=${ - opts.pageNumber || 1 - }`; - - fetch(q) - .then(res => res.json()) + api + .searchDiscover(term, opts.pageNumber || 1) .then(json => { if (json.topics) { this.setState({ @@ -105,9 +318,6 @@ class DiscoverScreen extends React.Component { }); } else { if (opts.pageNumber > 1) { - // Skip early if page number filter is somehow returning no results - // otherwise, this can cause a reset when scrolling - // TODO: handle this situation a little better maybe? return; } @@ -157,7 +367,6 @@ class DiscoverScreen extends React.Component { } if (opts.switchTabs) { - // TODO: use "Connect here?" this.props.screenProps.openUrl(site.url); } this.showToastPrompt(); @@ -179,10 +388,16 @@ class DiscoverScreen extends React.Component { }); } + removeSite(url) { + const site = this._siteManager.sites.find(s => s.url === url); + if (site) { + this._siteManager.remove(site); + this.setState({ selectionCount: this.state.selectionCount + 1 }); + } + } + async showToastPrompt() { let granted = true; - // Android 33+ has a permission request prompt - // versions before that permissions is always granted if (Platform.OS === 'android' && Platform.Version >= 33) { granted = await PermissionsAndroid.check( PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, @@ -202,82 +417,137 @@ class DiscoverScreen extends React.Component { }); } + // ── Navigation ── + + _navigateBack() { + if (this.state.view === VIEWS.COMMUNITY_DETAIL) { + if (this.state.previousView === VIEWS.TAG_DETAIL) { + this.setState({ + view: VIEWS.TAG_DETAIL, + activeCommunity: null, + communityTopics: [], + }); + } else if (this.state.previousView === VIEWS.ALL_COMMUNITIES) { + this.setState({ + view: VIEWS.ALL_COMMUNITIES, + activeCommunity: null, + communityTopics: [], + }); + } else { + this._navigateToSplash(); + } + } else if (this.state.view === VIEWS.ALL_COMMUNITIES) { + if (this.state.previousView === VIEWS.TAG_DETAIL) { + this.setState({ view: VIEWS.TAG_DETAIL }); + } else { + this._navigateToSplash(); + } + } else if (this.state.view === VIEWS.TAG_DETAIL) { + this._navigateToSplash(); + } else if (this.state.view === VIEWS.SEARCH) { + this.setState({ view: VIEWS.SPLASH, term: '', results: [] }); + } + } + + _navigateToSplash() { + this.setState({ + view: VIEWS.SPLASH, + activeTag: null, + hotTopics: [], + tagCommunities: [], + activeCommunity: null, + communityTopics: [], + term: '', + results: [], + }); + } + + _goToAllCommunities() { + const filter = this.state.activeTag; + if (this.state.tagCommunitiesLoading) { + this.setState({ + view: VIEWS.ALL_COMMUNITIES, + previousView: VIEWS.TAG_DETAIL, + communitiesFilter: filter, + allCommunities: [], + allCommunitiesLoading: true, + }); + this._fetchAllCommunities(filter); + } else { + this.setState({ + view: VIEWS.ALL_COMMUNITIES, + previousView: VIEWS.TAG_DETAIL, + communitiesFilter: filter, + allCommunities: this.state.tagCommunities, + allCommunitiesLoading: false, + }); + } + } + + _goToAllCommunitiesFromSplash(filter = null) { + this.setState({ + view: VIEWS.ALL_COMMUNITIES, + previousView: VIEWS.SPLASH, + communitiesFilter: filter, + allCommunities: [], + allCommunitiesLoading: true, + }); + this._fetchAllCommunities(filter); + } + + _selectFromTagDetail(key) { + if (key === null) { + this._goToAllCommunitiesFromSplash(null); + } else if (key === 'recent') { + this._goToAllCommunitiesFromSplash('recent'); + } else if (key !== this.state.activeTag) { + this.onSelectTag(key); + } + } + + _selectCommunitiesFilter(filter) { + if (filter === this.state.communitiesFilter) { + return; + } + this.setState({ + communitiesFilter: filter, + allCommunities: [], + allCommunitiesLoading: true, + }); + this._fetchAllCommunities(filter); + } + + _fetchAllCommunities(filter) { + api + .fetchAllCommunities(filter) + .then(json => { + if (filter !== this.state.communitiesFilter) { + return; // stale response + } + this.setState({ + allCommunities: json.topics || [], + allCommunitiesLoading: false, + }); + }) + .catch(e => { + console.log(e); + if (filter !== this.state.communitiesFilter) { + return; + } + this.setState({ allCommunitiesLoading: false }); + }); + } + + // ── Render ── + render() { const theme = this.context; - const resultCount = this.state.results.length; - const messageText = - this.state.term.length === 1 - ? i18n.t('discover_no_results_one_character') - : i18n.t('discover_no_results'); - - const emptyResult = ( - - {this.state.term === '' ? ( - - ) : ( - - - {messageText} - - { - this.setState({ - selectedTag: '', - term: '', - page: 1, - }); - this.doSearch(''); - }} - > - - {i18n.t('discover_reset')} - - - - )} - - ); return ( {tabBarHeight => ( - {this._renderSearchBox()} - {resultCount > 0 ? this._renderTags() : null} - - (this.discoverList = ref)} - contentContainerStyle={{ paddingBottom: tabBarHeight }} - data={this.state.results} - refreshing={this.state.loading} - refreshControl={ - { - // ensures we don't refresh for keyword searches - if (this.state.selectedTag !== false) { - this.debouncedSearch(this.state.selectedTag); - } - }} - /> - } - renderItem={({ item }) => this._renderItem({ item })} - onEndReached={() => { - this._fetchNextPage(); - }} - extraData={this.state.selectionCount} - maxToRenderPerBatch={20} - /> - + {this._renderContent(tabBarHeight)} )} @@ -285,112 +555,171 @@ class DiscoverScreen extends React.Component { ); } - _fetchNextPage() { - if (this.state.hasMoreResults) { - const newPageNumber = this.state.page + 1; - - this.setState({ page: newPageNumber, loading: true }); - this.doSearch(this.state.selectedTag || '', { - append: true, - pageNumber: newPageNumber, - }); + _renderContent(tabBarHeight) { + switch (this.state.view) { + case VIEWS.SPLASH: + return this._renderSplashView(); + case VIEWS.SEARCH: + return this._renderSearchView(tabBarHeight); + case VIEWS.TAG_DETAIL: + return this._renderTagDetailView(tabBarHeight); + case VIEWS.ALL_COMMUNITIES: + return this._renderAllCommunitiesView(tabBarHeight); + case VIEWS.COMMUNITY_DETAIL: + return this._renderCommunityDetailView(tabBarHeight); + default: + return this._renderSplashView(); } } - _renderSearchBox() { + _renderSplashView() { return ( - { - this.setState({ term, loading: true, selectedTag: false }); - this.debouncedSearch(term); + this.onSelectTag(tag)} + onSelectRecent={() => this._goToAllCommunitiesFromSplash('recent')} + onSeeAllCommunities={() => this._goToAllCommunitiesFromSplash()} + renderSearchBox={() => this._renderSearchBox()} + /> + ); + } + + _renderSearchView(tabBarHeight) { + return ( + (this.discoverList = ref)} + onResetToSplash={() => + this.setState({ + view: VIEWS.SPLASH, + term: '', + page: 1, + results: [], + }) + } + onRefresh={() => { + if (this.state.term) { + this.setState({ loading: true }); + this.debouncedSearch(this.state.term); + } }} + onEndReached={() => this._fetchNextSearchPage()} + renderSearchBox={() => this._renderSearchBox()} + renderSiteItem={({ item }) => this._renderSiteItem({ item })} /> ); } - _renderTags() { - const tagOptions = [ - '', - this.defaultSearch, - '#technology', - '#interests', - '#support', - '#media', - '#gaming', - '#open-source', - '#ai', - '#locale-intl', - 'order:latest_topic', - ]; - - if (!tagOptions.includes(this.state.term)) { - return null; - } + _renderTagDetailView(tabBarHeight) { + return ( + (this.hotTopicsList = ref)} + onPressCommunity={community => this.onPressCommunity(community)} + onSeeAll={() => this._goToAllCommunities()} + onClickTopic={url => this.props.screenProps.openUrl(url)} + onEndReached={() => this._fetchNextHotTopicsPage()} + onRefresh={() => { + this.setState({ hotTopicsLoading: true, hotTopicsPage: 1 }); + this.fetchHotTopics(this.state.activeTag); + }} + onExploreMore={() => this._navigateToSplash()} + onSelectTag={key => this._selectFromTagDetail(key)} + renderSearchBox={() => this._renderSearchBox()} + /> + ); + } + _renderAllCommunitiesView(tabBarHeight) { return ( - - {this._renderTag(i18n.t('discover_all'), '')} - {this._renderTag(i18n.t('discover_tech'), '#technology')} - {this._renderTag(i18n.t('discover_interests'), '#interests')} - {this._renderTag(i18n.t('discover_support'), '#support')} - {this._renderTag(i18n.t('discover_media'), '#media')} - {this._renderTag(i18n.t('discover_gaming'), '#gaming')} - {this._renderTag(i18n.t('discover_open_source'), '#open-source')} - {this._renderTag(i18n.t('discover_ai'), '#ai')} - {this._renderTag(i18n.t('discover_international'), '#locale-intl')} - {this._renderTag(i18n.t('discover_recent'), 'order:latest_topic')} - - + this._selectCommunitiesFilter(key)} + renderSearchBox={() => this._renderSearchBox()} + renderSiteItem={({ item }) => this._renderSiteItem({ item })} + /> ); } - _renderTag(label, searchString) { - const theme = this.context; - const isCurrentTerm = searchString === this.state.selectedTag; + _renderCommunityDetailView(tabBarHeight) { + const community = this.state.activeCommunity; + if (!community) { + return null; + } return ( - { + this.addSite(url)} + onRemoveFromSidebar={url => this.removeSite(url)} + onPreview={url => this.props.screenProps.openUrl(url)} + onClickTopic={url => this.props.screenProps.openUrl(url)} + onBack={() => this._navigateBack()} + onEndReached={() => this._fetchNextCommunityTopicsPage()} + onRefresh={() => { this.setState({ - selectedTag: searchString, - loading: true, - page: 1, + communityTopicsLoading: true, + communityTopicsPage: 1, }); - if (this.discoverList) { - this.discoverList.scrollToIndex({ - index: 0, - animated: true, + this._fetchCommunityHotTopics(community.featured_link); + }} + onExploreMore={() => this._navigateToSplash()} + renderSearchBox={() => this._renderSearchBox()} + /> + ); + } + + // ── Shared Render Helpers ── + + _renderSearchBox() { + return ( + { + if (term.length > 0) { + this.setState({ term, loading: true, view: VIEWS.SEARCH }); + this.debouncedSearch(term); + } else { + this.setState({ + term: '', + view: VIEWS.SPLASH, + results: [], + loading: false, + page: 1, }); } - this.doSearch(searchString); }} - > - - {label} - - + /> ); } - _renderItem({ item }) { + _renderSiteItem({ item }) { return ( ); } + + // ── Pagination ── + + _fetchNextSearchPage() { + if (this.state.hasMoreResults) { + const newPageNumber = this.state.page + 1; + this.setState({ page: newPageNumber, loading: true }); + this.doSearch(this.state.term || '', { + append: true, + pageNumber: newPageNumber, + }); + } + } + + _fetchNextHotTopicsPage() { + if (this.state.hotTopicsHasMore) { + const newPage = this.state.hotTopicsPage + 1; + this.setState({ hotTopicsPage: newPage, hotTopicsLoading: true }); + this.fetchHotTopics(this.state.activeTag, { + append: true, + pageNumber: newPage, + }); + } + } } DiscoverScreen.contextType = ThemeContext; -const styles = StyleSheet.create({ - container: { - alignItems: 'stretch', - justifyContent: 'center', - flex: 1, - }, - intro: { - flexDirection: 'row', - alignItems: 'center', - }, - emptyResult: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - paddingBottom: 120, - }, - desc: { - fontSize: 16, - padding: 24, - textAlign: 'center', - }, - noResultsReset: { - padding: 6, - flex: 1, - justifyContent: 'center', - alignItems: 'center', - maxHeight: 40, - }, - tags: { - paddingLeft: 12, - paddingBottom: 12, - width: '100%', - flexDirection: 'row', - maxHeight: 50, - }, - tag: { - paddingVertical: 8, - paddingHorizontal: 8, - borderRadius: 6, - borderWidth: StyleSheet.hairlineWidth, - margin: 2, - }, -}); - export default DiscoverScreen; diff --git a/js/screens/DiscoverScreenComponents/CommunityCarousel.js b/js/screens/DiscoverScreenComponents/CommunityCarousel.js new file mode 100644 index 00000000..9955fcd3 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/CommunityCarousel.js @@ -0,0 +1,208 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { + ScrollView, + StyleSheet, + Text, + TouchableHighlight, + useWindowDimensions, + View, +} from 'react-native'; +import { ThemeContext } from '../../ThemeContext'; +import SiteLogo, { isValidLogoUrl } from '../CommonComponents/SiteLogo'; + +const HORIZONTAL_PADDING = 16; +const COLUMN_GAP = 12; +const ROW_GAP = 10; +const VISIBLE_COLUMNS = 3.35; +const VISIBLE_GAPS = Math.ceil(VISIBLE_COLUMNS) - 1; + +function buildColumns(items) { + const columns = []; + + for (let index = 0; index < items.length; index += 2) { + columns.push(items.slice(index, index + 2)); + } + + return columns; +} + +const CommunityCarousel = props => { + const theme = useContext(ThemeContext); + const { width: windowWidth } = useWindowDimensions(); + const cardWidth = Math.max( + 96, + Math.floor( + (windowWidth - HORIZONTAL_PADDING * 2 - COLUMN_GAP * VISIBLE_GAPS) / + VISIBLE_COLUMNS, + ), + ); + const columns = buildColumns(props.communities || []); + + if (props.loading) { + return ( + + {[0, 1, 2, 3].map(columnIndex => ( + + {[0, 1].map(rowIndex => ( + + + + + + + ))} + + ))} + + ); + } + + if (!props.communities || props.communities.length === 0) { + return null; + } + + return ( + + {columns.map((column, columnIndex) => ( + + {column.map((community, rowIndex) => { + const iconUrl = community.discover_entry_logo_url; + const logoImage = isValidLogoUrl(iconUrl) + ? { uri: iconUrl } + : false; + + return ( + props.onPressCommunity(community)} + accessibilityRole="button" + accessibilityLabel={community.title} + > + + + + + + {community.title} + + + + ); + })} + + ))} + + ); +}; + +const styles = StyleSheet.create({ + scrollView: { + paddingVertical: 8, + }, + contentContainer: { + paddingHorizontal: HORIZONTAL_PADDING, + }, + column: { + justifyContent: 'flex-start', + }, + card: { + borderRadius: 10, + paddingVertical: 8, + }, + cardContent: { + alignItems: 'center', + minHeight: 86, + }, + logoContainer: { + width: 42, + height: 42, + alignItems: 'center', + justifyContent: 'center', + }, + cardTitle: { + fontSize: 12, + marginTop: 6, + textAlign: 'center', + width: '100%', + }, + placeholderLogo: { + height: 42, + width: 42, + borderRadius: 10, + opacity: 0.3, + }, + placeholderText: { + height: 12, + width: 60, + borderRadius: 4, + marginTop: 8, + opacity: 0.2, + }, +}); + +export default CommunityCarousel; diff --git a/js/screens/DiscoverScreenComponents/CommunityDetailView.js b/js/screens/DiscoverScreenComponents/CommunityDetailView.js new file mode 100644 index 00000000..496ddd14 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/CommunityDetailView.js @@ -0,0 +1,192 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { StyleSheet, Text, TouchableHighlight, View } from 'react-native'; +import FontAwesome5 from '@react-native-vector-icons/fontawesome5'; +import { ThemeContext } from '../../ThemeContext'; +import SiteLogo, { isValidLogoUrl } from '../CommonComponents/SiteLogo'; +import i18n from 'i18n-js'; + +const CommunityDetailView = props => { + const theme = useContext(ThemeContext); + + const { community, inLocalList } = props; + const iconUrl = community.discover_entry_logo_url; + const logoImage = isValidLogoUrl(iconUrl) ? { uri: iconUrl } : false; + + const link = community.featured_link || ''; + const displayLink = link.replace(/^https?:\/\//, ''); + + const addButtonColor = inLocalList ? theme.redDanger : theme.blueCallToAction; + const addButtonText = inLocalList + ? i18n.t('remove_from_home_screen') + : i18n.t('add_to_home_screen'); + const addButtonIcon = inLocalList ? 'minus' : 'plus'; + + return ( + + + + + {displayLink} + + + + {community.active_users_30_days ? ( + + + + {i18n.t('active_counts', { + active_users: i18n.toNumber(community.active_users_30_days, { + precision: 0, + }), + })} + + + ) : null} + + + + + + inLocalList + ? props.onRemoveFromSidebar(link) + : props.onAddToSidebar(link) + } + accessibilityRole="button" + accessibilityLabel={addButtonText} + > + + + + {addButtonText} + + + + + props.onPreview(link)} + accessibilityRole="button" + accessibilityLabel={i18n.t('preview')} + > + + + + {i18n.t('preview')} + + + + + + + {i18n.t('community_recent_topics')} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingBottom: 4, + }, + headerSection: { + alignItems: 'center', + paddingTop: 24, + paddingHorizontal: 16, + }, + communityLink: { + fontSize: 14, + marginTop: 8, + }, + statsRow: { + flexDirection: 'row', + marginTop: 12, + alignItems: 'center', + gap: 16, + }, + statItem: { + flexDirection: 'row', + alignItems: 'center', + }, + statText: { + fontSize: 14, + }, + buttonsSection: { + flexDirection: 'row', + paddingHorizontal: 16, + paddingTop: 20, + paddingBottom: 8, + gap: 10, + }, + actionButton: { + flex: 1, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + secondaryButton: { + borderWidth: 1, + }, + buttonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + buttonText: { + fontSize: 14, + fontWeight: '600', + }, + sectionLabel: { + fontSize: 13, + fontWeight: '600', + letterSpacing: 0.5, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 4, + }, +}); + +export default CommunityDetailView; diff --git a/js/screens/DiscoverScreenComponents/DiscoverTopicList.js b/js/screens/DiscoverScreenComponents/DiscoverTopicList.js new file mode 100644 index 00000000..f88fb522 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/DiscoverTopicList.js @@ -0,0 +1,282 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { + ActivityIndicator, + FlatList, + Image, + StyleSheet, + Text, + TouchableHighlight, + View, +} from 'react-native'; +import FontAwesome5 from '@react-native-vector-icons/fontawesome5'; +import { ThemeContext } from '../../ThemeContext'; +import i18n from 'i18n-js'; + +const DiscoverTopicList = props => { + const theme = useContext(ThemeContext); + + function _renderTopic({ item }) { + return ( + props.onClickTopic(item.url)} + underlayColor={theme.background} + activeOpacity={0.6} + style={{ + ...styles.topicRow, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: theme.grayBorder, + }} + > + + + + {item.title} + + + {item.excerpt ? ( + + + {item.excerpt} + + + ) : null} + + {_renderCommunity(item)} + + + + {item.reply_count} + + + + {item.like_count} + + + + + + ); + } + + function _renderCommunity(item) { + if (!item.community_name) { + return null; + } + + return ( + + {item.community_logo_url ? ( + + ) : null} + + {item.community_name} + + + ); + } + + function _renderPlaceholder() { + return ( + + {[0, 1, 2, 3].map(i => ( + + + + + ))} + + ); + } + + function _renderEmpty() { + return ( + + + {i18n.t('no_hot_topics')} + + + ); + } + + function _renderFooter() { + if (props.topics.length === 0) { + return null; + } + + if (props.loading) { + return ( + + + + ); + } + + return ( + + + + + {i18n.t('discover_explore_more')} + + + + ); + } + + if (props.loading && props.topics.length === 0) { + return _renderPlaceholder(); + } + + return ( + + String(item.id)} + renderItem={_renderTopic} + ListHeaderComponent={props.ListHeaderComponent} + ListEmptyComponent={_renderEmpty} + ListFooterComponent={_renderFooter} + onEndReached={props.onEndReached} + onEndReachedThreshold={0.5} + contentContainerStyle={props.contentContainerStyle} + refreshing={props.loading} + onRefresh={props.onRefresh} + onScroll={props.onScroll} + scrollEventThrottle={16} + keyboardDismissMode="on-drag" + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + placeholder: { + minHeight: 560, + paddingVertical: 12, + paddingHorizontal: 16, + flex: 1, + }, + placeholderHeading: { + height: 40, + opacity: 0.3, + marginBottom: 20, + }, + placeholderMetadata: { + height: 16, + opacity: 0.2, + marginBottom: 20, + }, + topicTitle: { + fontSize: 18, + fontWeight: 'bold', + }, + topicExcerpt: { + fontSize: 14, + paddingTop: 6, + paddingBottom: 6, + }, + topicRow: { + paddingVertical: 15, + paddingHorizontal: 16, + }, + metadataFirstRow: { + flexDirection: 'row', + paddingTop: 6, + }, + topicCounts: { + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + paddingLeft: 10, + }, + topicCountsNum: { + fontSize: 14, + paddingRight: 8, + paddingLeft: 4, + }, + communityBadge: { + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + opacity: 0.8, + flexShrink: 1, + }, + communityLogo: { + height: 16, + width: 16, + marginRight: 6, + borderRadius: 3, + }, + communityName: { + fontSize: 13, + }, + emptyText: { + padding: 24, + fontSize: 16, + textAlign: 'center', + }, + footer: { + paddingVertical: 24, + paddingHorizontal: 16, + alignItems: 'center', + }, + footerContent: { + flexDirection: 'row', + alignItems: 'center', + }, + footerText: { + fontSize: 16, + fontWeight: '600', + }, +}); + +export default DiscoverTopicList; diff --git a/js/screens/DiscoverScreenComponents/TagDetailHeader.js b/js/screens/DiscoverScreenComponents/TagDetailHeader.js new file mode 100644 index 00000000..5dc31923 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/TagDetailHeader.js @@ -0,0 +1,77 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { + Platform, + StyleSheet, + Text, + TouchableHighlight, + View, +} from 'react-native'; +import FontAwesome5 from '@react-native-vector-icons/fontawesome5'; +import { ThemeContext } from '../../ThemeContext'; + +const TagDetailHeader = props => { + const theme = useContext(ThemeContext); + + return ( + + + + + + + {props.title} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: Platform.OS === 'ios' ? 48 : 60, + justifyContent: 'center', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + }, + backButton: { + padding: 8, + borderRadius: 8, + marginRight: 8, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + flex: 1, + }, + separator: { + bottom: 0, + height: StyleSheet.hairlineWidth, + left: 0, + position: 'absolute', + right: 0, + }, +}); + +export default TagDetailHeader; diff --git a/js/screens/DiscoverScreenComponents/TagSplash.js b/js/screens/DiscoverScreenComponents/TagSplash.js new file mode 100644 index 00000000..616199dc --- /dev/null +++ b/js/screens/DiscoverScreenComponents/TagSplash.js @@ -0,0 +1,223 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { + ActivityIndicator, + Platform, + ScrollView, + StyleSheet, + Text, + TouchableHighlight, + View, +} from 'react-native'; +import { ThemeContext } from '../../ThemeContext'; +import i18n from 'i18n-js'; + +// Primary tags always appear at the top of the splash in this order. +// `tag` is the underlying tag filter; `label` is what the user sees. +export const PRIMARY_TAGS = [ + { tag: 'ai', label: 'ai' }, + { tag: 'technology', label: 'tech' }, + { tag: 'interests', label: 'interests' }, + { tag: 'support', label: 'support' }, + { tag: 'media', label: 'media' }, + { tag: 'gaming', label: 'gaming' }, + { tag: 'finance', label: 'finance' }, + { tag: 'open-source', label: 'open-source' }, +]; + +const TagSplash = props => { + const theme = useContext(ThemeContext); + + if (props.loading) { + return ( + + + + ); + } + + const primaryTagSet = new Set(PRIMARY_TAGS.map(t => t.tag)); + const secondaryTags = (props.tags || []) + .filter(tag => !primaryTagSet.has(tag)) + .sort((a, b) => a.localeCompare(b)); + + const renderTagButton = ({ + key, + label, + onPress, + accessibilityLabel, + secondary, + }) => ( + + + {label} + + + ); + + return ( + + + + {i18n.t('discover_pick_tag')} + + + {i18n.t('discover_pick_tag_description')} + + + + + {PRIMARY_TAGS.map(({ tag, label }) => + renderTagButton({ + key: tag, + label, + accessibilityLabel: tag, + onPress: () => props.onSelectTag(tag), + }), + )} + {renderTagButton({ + key: '__recent__', + label: i18n.t('discover_recent'), + onPress: () => props.onSelectRecent(), + })} + + + {secondaryTags.length > 0 && ( + + {secondaryTags.map(tag => + renderTagButton({ + key: tag, + label: tag, + onPress: () => props.onSelectTag(tag), + secondary: true, + }), + )} + + )} + + + + {i18n.t('discover_community_directory')} › + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + paddingTop: Platform.OS === 'android' ? 10 : 30, + paddingBottom: 30, + paddingHorizontal: 20, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + alignItems: 'center', + marginBottom: Platform.OS === 'android' ? 4 : 10, + }, + prompt: { + fontSize: 20, + fontWeight: '600', + marginTop: Platform.OS === 'android' ? 4 : 16, + }, + promptDescription: { + fontSize: 16, + marginTop: Platform.OS === 'android' ? 4 : 10, + textAlign: 'center', + padding: Platform.OS === 'android' ? 4 : 10, + }, + tagGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + }, + secondaryGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + marginTop: 15, + paddingTop: 15, + paddingBottom: 15, + marginBottom: 15, + borderTopWidth: StyleSheet.hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + tagButton: { + paddingVertical: 10, + paddingHorizontal: 12, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + margin: 6, + }, + secondaryTagButton: { + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 8, + borderWidth: StyleSheet.hairlineWidth, + margin: 4, + }, + tagLabel: { + fontSize: 16, + fontWeight: '500', + }, + secondaryTagLabel: { + fontSize: 14, + fontWeight: '500', + }, + directoryButton: { + alignSelf: 'stretch', + marginHorizontal: 10, + marginTop: 10, + paddingVertical: 15, + borderRadius: 10, + alignItems: 'center', + }, + directoryButtonText: { + fontSize: 16, + fontWeight: '600', + }, +}); + +export default TagSplash; diff --git a/js/screens/DiscoverScreenComponents/api.js b/js/screens/DiscoverScreenComponents/api.js new file mode 100644 index 00000000..2a43ea45 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/api.js @@ -0,0 +1,54 @@ +/* @flow */ +'use strict'; + +import Site from '../../site'; +import fetch from '../../../lib/fetch'; + +const SEARCH_BASE_URL = `${Site.discoverUrl()}search.json?q=`; + +export function fetchSplashTags() { + const url = `${Site.discoverUrl()}discover/hot-topics-tags.json`; + return fetch(url).then(res => res.json()); +} + +export function fetchHotTopics(tag, page = 1) { + const url = `${Site.discoverUrl()}discover/hot-topics.json?tag=${encodeURIComponent( + tag, + )}&page=${page}`; + return fetch(url).then(res => res.json()); +} + +export function fetchTagCommunities(tag) { + const searchString = `#discover #${tag} order:featured`; + const url = `${SEARCH_BASE_URL}${encodeURIComponent(searchString)}&page=1`; + return fetch(url).then(res => res.json()); +} + +export function fetchCommunityHotTopics(communityUrl, page = 0) { + const url = `${communityUrl}/hot.json?page=${page}`; + return fetch(url).then(res => res.json()); +} + +export function searchDiscover(term, page = 1) { + const defaultSearch = '#locale-en'; + const searchTerm = term === '' ? defaultSearch : term; + const order = term.startsWith('order:') ? '' : 'order:featured'; + const searchString = `#discover ${searchTerm} ${order}`; + const url = `${SEARCH_BASE_URL}${encodeURIComponent( + searchString, + )}&page=${page}`; + return fetch(url).then(res => res.json()); +} + +export function fetchAllCommunities(filter) { + let searchString; + if (filter === 'recent') { + searchString = '#discover order:latest_topic'; + } else if (filter) { + searchString = `#discover #${filter} order:featured`; + } else { + searchString = '#discover order:featured'; + } + const url = `${SEARCH_BASE_URL}${encodeURIComponent(searchString)}&page=1`; + return fetch(url).then(res => res.json()); +} diff --git a/js/screens/DiscoverScreenComponents/constants.js b/js/screens/DiscoverScreenComponents/constants.js new file mode 100644 index 00000000..69909338 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/constants.js @@ -0,0 +1,61 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import { Linking, Platform } from 'react-native'; +import { BaseToast } from 'react-native-toast-message'; + +export const VIEWS = { + SPLASH: 'splash', + SEARCH: 'search', + TAG_DETAIL: 'tagDetail', + ALL_COMMUNITIES: 'allCommunities', + COMMUNITY_DETAIL: 'communityDetail', +}; + +export const defaultView = VIEWS.SPLASH; + +export const FALLBACK_TAGS = [ + 'ai', + 'finance', + 'apple', + 'automation', + 'media', + 'research', + 'smart-home', + 'linux', + 'open-source', + 'webdev', + 'health', + 'gaming', + 'audio', + 'programming-language', + 'devops', + 'crypto', + 'mapping', +]; + +export const toastConfig = { + success: props => ( + { + if (Platform.OS === 'android') { + Linking.openSettings(); + } + if (Platform.OS === 'ios') { + Linking.openURL('App-Prefs:NOTIFICATIONS_ID'); + } + }} + contentContainerStyle={{ paddingHorizontal: 10 }} + text1Style={{ + fontSize: 17, + fontWeight: '400', + }} + text2Style={{ + fontSize: 17, + }} + /> + ), +}; diff --git a/js/screens/DiscoverScreenComponents/index.js b/js/screens/DiscoverScreenComponents/index.js index ea6475fd..426672b5 100644 --- a/js/screens/DiscoverScreenComponents/index.js +++ b/js/screens/DiscoverScreenComponents/index.js @@ -3,8 +3,18 @@ import SiteRow from './SiteRow'; import TermBar from './TermBar'; +import DiscoverTopicList from './DiscoverTopicList'; +import TagSplash from './TagSplash'; +import TagDetailHeader from './TagDetailHeader'; +import CommunityCarousel from './CommunityCarousel'; +import CommunityDetailView from './CommunityDetailView'; module.exports = { SiteRow: SiteRow, TermBar: TermBar, + DiscoverTopicList: DiscoverTopicList, + TagSplash: TagSplash, + TagDetailHeader: TagDetailHeader, + CommunityCarousel: CommunityCarousel, + CommunityDetailView: CommunityDetailView, }; diff --git a/js/screens/DiscoverScreenComponents/views/AllCommunitiesView.js b/js/screens/DiscoverScreenComponents/views/AllCommunitiesView.js new file mode 100644 index 00000000..9dda19eb --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/AllCommunitiesView.js @@ -0,0 +1,54 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { ActivityIndicator, FlatList, View } from 'react-native'; + +import { ThemeContext } from '../../../ThemeContext'; +import TagFilterBar from './TagFilterBar'; +import sharedStyles from './styles'; + +const AllCommunitiesView = props => { + const theme = useContext(ThemeContext); + const { + allCommunities, + allCommunitiesLoading, + communitiesFilter, + splashTags, + largerUI, + selectionCount, + tabBarHeight, + onSelectFilter, + renderSearchBox, + renderSiteItem, + } = props; + + const emptyComponent = allCommunitiesLoading ? ( + + + + ) : null; + + return ( + + {renderSearchBox()} + onSelectFilter(key)} + /> + String(item.id || item.featured_link)} + ListEmptyComponent={emptyComponent} + contentContainerStyle={{ paddingBottom: tabBarHeight }} + data={allCommunities} + renderItem={({ item }) => renderSiteItem({ item })} + extraData={selectionCount} + keyboardDismissMode="on-drag" + /> + + ); +}; + +export default AllCommunitiesView; diff --git a/js/screens/DiscoverScreenComponents/views/CommunityDetailView.js b/js/screens/DiscoverScreenComponents/views/CommunityDetailView.js new file mode 100644 index 00000000..6708db0d --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/CommunityDetailView.js @@ -0,0 +1,66 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import { View } from 'react-native'; + +import CommunityDetailCard from '../CommunityDetailView'; +import DiscoverTopicList from '../DiscoverTopicList'; +import TagDetailHeader from '../TagDetailHeader'; +import sharedStyles from './styles'; + +const CommunityDetailView = props => { + const { + community, + activeTag, + communityTopics, + communityTopicsLoading, + inLocalList, + largerUI, + tabBarHeight, + onAddToSidebar, + onRemoveFromSidebar, + onPreview, + onClickTopic, + onBack, + onEndReached, + onRefresh, + onExploreMore, + renderSearchBox, + } = props; + + if (!community) { + return null; + } + + const headerComponent = ( + onAddToSidebar(url)} + onRemoveFromSidebar={url => onRemoveFromSidebar(url)} + onPreview={url => onPreview(url)} + /> + ); + + return ( + + {renderSearchBox()} + onBack()} /> + onClickTopic(url)} + largerUI={largerUI} + contentContainerStyle={{ paddingBottom: tabBarHeight + 20 }} + ListHeaderComponent={headerComponent} + onEndReached={() => onEndReached()} + onRefresh={() => onRefresh()} + onExploreMore={() => onExploreMore()} + /> + + ); +}; + +export default CommunityDetailView; diff --git a/js/screens/DiscoverScreenComponents/views/SearchView.js b/js/screens/DiscoverScreenComponents/views/SearchView.js new file mode 100644 index 00000000..2c4cdccc --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/SearchView.js @@ -0,0 +1,109 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { + ActivityIndicator, + FlatList, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TouchableHighlight, + View, +} from 'react-native'; +import i18n from 'i18n-js'; + +import { ThemeContext } from '../../../ThemeContext'; +import sharedStyles from './styles'; + +const SearchView = props => { + const theme = useContext(ThemeContext); + const { + term, + results, + loading, + selectionCount, + tabBarHeight, + listRef, + onResetToSplash, + onRefresh, + onEndReached, + renderSearchBox, + renderSiteItem, + } = props; + + const messageText = + term.length === 1 + ? i18n.t('discover_no_results_one_character') + : i18n.t('discover_no_results'); + + const emptyResult = ( + + {loading ? ( + + + + ) : ( + + + {messageText} + + onResetToSplash()} + > + + {i18n.t('discover_reset')} + + + + )} + + ); + + return ( + + {renderSearchBox()} + String(item.id || item.featured_link)} + ListEmptyComponent={emptyResult} + ref={listRef} + contentContainerStyle={{ paddingBottom: tabBarHeight }} + data={results} + refreshing={loading} + refreshControl={ + onRefresh()} /> + } + renderItem={({ item }) => renderSiteItem({ item })} + onEndReached={() => onEndReached()} + extraData={selectionCount} + maxToRenderPerBatch={20} + /> + + ); +}; + +const styles = StyleSheet.create({ + desc: { + fontSize: 16, + padding: 24, + textAlign: 'center', + }, + noResultsReset: { + padding: 6, + flex: 1, + justifyContent: 'center', + alignItems: 'center', + maxHeight: 40, + }, +}); + +export default SearchView; diff --git a/js/screens/DiscoverScreenComponents/views/SplashView.js b/js/screens/DiscoverScreenComponents/views/SplashView.js new file mode 100644 index 00000000..440afce7 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/SplashView.js @@ -0,0 +1,34 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import { View } from 'react-native'; + +import TagSplash from '../TagSplash'; +import sharedStyles from './styles'; + +const SplashView = props => { + const { + splashTags, + splashTagsLoading, + onSelectTag, + onSelectRecent, + onSeeAllCommunities, + renderSearchBox, + } = props; + + return ( + + {renderSearchBox()} + onSelectTag(tag)} + onSelectRecent={() => onSelectRecent()} + onSeeAllCommunities={() => onSeeAllCommunities()} + /> + + ); +}; + +export default SplashView; diff --git a/js/screens/DiscoverScreenComponents/views/TagDetailView.js b/js/screens/DiscoverScreenComponents/views/TagDetailView.js new file mode 100644 index 00000000..f31005ab --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/TagDetailView.js @@ -0,0 +1,117 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { StyleSheet, Text, TouchableHighlight, View } from 'react-native'; +import i18n from 'i18n-js'; + +import { ThemeContext } from '../../../ThemeContext'; +import CommunityCarousel from '../CommunityCarousel'; +import DiscoverTopicList from '../DiscoverTopicList'; +import TagFilterBar from './TagFilterBar'; +import sharedStyles from './styles'; + +const TagDetailView = props => { + const theme = useContext(ThemeContext); + const { + activeTag, + tagCommunities, + tagCommunitiesLoading, + hotTopics, + hotTopicsLoading, + splashTags, + largerUI, + tabBarHeight, + listRef, + onPressCommunity, + onSeeAll, + onClickTopic, + onEndReached, + onRefresh, + onExploreMore, + onSelectTag, + renderSearchBox, + } = props; + + const activeTagLabel = activeTag; + + const headerComponent = ( + + + {i18n.t('discover_communities_section', { tag: activeTagLabel })} + + onPressCommunity(community)} + /> + onSeeAll()} + > + + {i18n.t('discover_see_all_communities')} › + + + + {i18n.t('discover_topics_section', { tag: activeTagLabel })} + + + ); + + return ( + + {renderSearchBox()} + onSelectTag(key)} + /> + onClickTopic(url)} + largerUI={largerUI} + contentContainerStyle={{ paddingBottom: tabBarHeight + 20 }} + listRef={listRef} + ListHeaderComponent={headerComponent} + onEndReached={() => onEndReached()} + onRefresh={() => onRefresh()} + onExploreMore={() => onExploreMore()} + /> + + ); +}; + +const styles = StyleSheet.create({ + sectionLabel: { + fontSize: 13, + fontWeight: '600', + letterSpacing: 0.5, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 4, + textTransform: 'uppercase', + }, + seeAllButton: { + marginHorizontal: 16, + marginTop: 12, + marginBottom: 8, + paddingVertical: 14, + borderRadius: 10, + alignItems: 'center', + }, + seeAllButtonText: { + fontSize: 16, + fontWeight: '600', + }, +}); + +export default TagDetailView; diff --git a/js/screens/DiscoverScreenComponents/views/TagFilterBar.js b/js/screens/DiscoverScreenComponents/views/TagFilterBar.js new file mode 100644 index 00000000..5c2880ed --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/TagFilterBar.js @@ -0,0 +1,97 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableHighlight } from 'react-native'; +import i18n from 'i18n-js'; + +import { ThemeContext } from '../../../ThemeContext'; +import { PRIMARY_TAGS } from '../TagSplash'; + +const TagFilterBar = props => { + const theme = useContext(ThemeContext); + const { activeKey, splashTags, largerUI, onSelect } = props; + + const primaryTagSet = new Set(PRIMARY_TAGS.map(t => t.tag)); + const secondaryTags = (splashTags || []) + .filter(tag => !primaryTagSet.has(tag)) + .sort((a, b) => a.localeCompare(b)); + + const entries = [ + { key: null, label: i18n.t('discover_all') }, + ...PRIMARY_TAGS.map(({ tag, label }) => ({ + key: tag, + label: label, + })), + { key: 'recent', label: i18n.t('discover_recent') }, + ...secondaryTags.map(tag => ({ + key: tag, + label: tag, + })), + ]; + + return ( + + {entries.map(entry => { + const isSelected = entry.key === activeKey; + return ( + onSelect(entry.key)} + > + + {entry.label} + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + tagBar: { + flexGrow: 0, + flexShrink: 0, + paddingVertical: 8, + paddingHorizontal: 8, + }, + tagBarContent: { + alignItems: 'center', + paddingRight: 16, + }, + tagBarItem: { + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + marginHorizontal: 4, + }, + tagBarItemText: { + fontWeight: '500', + }, +}); + +export default TagFilterBar; diff --git a/js/screens/DiscoverScreenComponents/views/__tests__/AllCommunitiesView.test.js b/js/screens/DiscoverScreenComponents/views/__tests__/AllCommunitiesView.test.js new file mode 100644 index 00000000..4704d828 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/__tests__/AllCommunitiesView.test.js @@ -0,0 +1,73 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import TestRenderer from 'react-test-renderer'; +import { ActivityIndicator, FlatList } from 'react-native'; + +import { ThemeContext, themes } from '../../../../ThemeContext'; +import AllCommunitiesView from '../AllCommunitiesView'; + +jest.mock('i18n-js', () => ({ t: key => key })); + +jest.mock('../TagFilterBar', () => { + const MockTagFilterBar = () => null; + return { __esModule: true, default: MockTagFilterBar }; +}); + +const MockTagFilterBar = require('../TagFilterBar').default; + +const renderWithTheme = async ui => { + let tree; + await TestRenderer.act(async () => { + tree = TestRenderer.create( + {ui}, + ); + }); + return tree; +}; + +const baseProps = { + allCommunities: [], + allCommunitiesLoading: false, + communitiesFilter: null, + splashTags: [], + largerUI: false, + selectionCount: 0, + tabBarHeight: 0, + onSelectFilter: () => {}, + renderSearchBox: () => null, + renderSiteItem: () => null, +}; + +describe('AllCommunitiesView', () => { + it('renders an ActivityIndicator in the empty state while loading', async () => { + const tree = await renderWithTheme( + , + ); + const list = tree.root.findByType(FlatList); + + // ListEmptyComponent is an element, render it into the tree to inspect. + let emptyTree; + await TestRenderer.act(async () => { + emptyTree = TestRenderer.create(list.props.ListEmptyComponent); + }); + expect(emptyTree.root.findAllByType(ActivityIndicator).length).toBe(1); + }); + + it('does not render an ActivityIndicator when not loading', async () => { + const tree = await renderWithTheme( + , + ); + const list = tree.root.findByType(FlatList); + expect(list.props.ListEmptyComponent).toBeNull(); + }); + + it('passes communitiesFilter through to TagFilterBar as activeKey', async () => { + const tree = await renderWithTheme( + , + ); + const filterBar = tree.root.findByType(MockTagFilterBar); + expect(filterBar.props.activeKey).toBe('recent'); + }); +}); diff --git a/js/screens/DiscoverScreenComponents/views/__tests__/CommunityDetailView.test.js b/js/screens/DiscoverScreenComponents/views/__tests__/CommunityDetailView.test.js new file mode 100644 index 00000000..4c3b4dec --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/__tests__/CommunityDetailView.test.js @@ -0,0 +1,103 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import TestRenderer from 'react-test-renderer'; + +import { ThemeContext, themes } from '../../../../ThemeContext'; +import CommunityDetailView from '../CommunityDetailView'; + +jest.mock('i18n-js', () => ({ t: key => key })); + +jest.mock('../../CommunityDetailView', () => { + const MockCommunityDetailCard = () => null; + return { __esModule: true, default: MockCommunityDetailCard }; +}); + +jest.mock('../../DiscoverTopicList', () => { + const MockDiscoverTopicList = props => props.ListHeaderComponent || null; + return { __esModule: true, default: MockDiscoverTopicList }; +}); + +jest.mock('../../TagDetailHeader', () => { + const MockTagDetailHeader = () => null; + return { __esModule: true, default: MockTagDetailHeader }; +}); + +const MockCommunityDetailCard = require('../../CommunityDetailView').default; +const MockDiscoverTopicList = require('../../DiscoverTopicList').default; +const MockTagDetailHeader = require('../../TagDetailHeader').default; + +const renderWithTheme = async ui => { + let tree; + await TestRenderer.act(async () => { + tree = TestRenderer.create( + {ui}, + ); + }); + return tree; +}; + +const community = { + id: 42, + title: 'Example', + featured_link: 'https://example.com', +}; + +const baseProps = { + community, + activeTag: 'ai', + communityTopics: [], + communityTopicsLoading: false, + inLocalList: false, + largerUI: false, + tabBarHeight: 0, + onAddToSidebar: () => {}, + onRemoveFromSidebar: () => {}, + onPreview: () => {}, + onClickTopic: () => {}, + onBack: () => {}, + onEndReached: () => {}, + onRefresh: () => {}, + onExploreMore: () => {}, + renderSearchBox: () => null, +}; + +describe('CommunityDetailView (screen view)', () => { + it('returns null when community is not provided', async () => { + const tree = await renderWithTheme( + , + ); + expect(tree.toJSON()).toBeNull(); + }); + + it('renders the header with the community title and wires up onBack', async () => { + const onBack = jest.fn(); + const tree = await renderWithTheme( + , + ); + + const header = tree.root.findByType(MockTagDetailHeader); + expect(header.props.title).toBe('Example'); + + header.props.onBack(); + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('passes community state through to the detail card and topic list', async () => { + const tree = await renderWithTheme( + , + ); + + const card = tree.root.findByType(MockCommunityDetailCard); + expect(card.props.community).toBe(community); + expect(card.props.inLocalList).toBe(true); + + const topicList = tree.root.findByType(MockDiscoverTopicList); + expect(topicList.props.topics.length).toBe(1); + }); +}); diff --git a/js/screens/DiscoverScreenComponents/views/__tests__/SearchView.test.js b/js/screens/DiscoverScreenComponents/views/__tests__/SearchView.test.js new file mode 100644 index 00000000..668454cc --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/__tests__/SearchView.test.js @@ -0,0 +1,63 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import TestRenderer from 'react-test-renderer'; +import { Text, TouchableHighlight } from 'react-native'; + +import { ThemeContext, themes } from '../../../../ThemeContext'; +import SearchView from '../SearchView'; + +jest.mock('i18n-js', () => ({ t: key => key })); + +const renderWithTheme = async ui => { + let tree; + await TestRenderer.act(async () => { + tree = TestRenderer.create( + {ui}, + ); + }); + return tree; +}; + +const baseProps = { + term: 'react', + results: [], + loading: false, + selectionCount: 0, + tabBarHeight: 0, + listRef: () => {}, + onResetToSplash: () => {}, + onRefresh: () => {}, + onEndReached: () => {}, + renderSearchBox: () => null, + renderSiteItem: () => null, +}; + +describe('SearchView', () => { + it('shows the single-character empty message when term is 1 char long', async () => { + const tree = await renderWithTheme(); + const textNodes = tree.root + .findAllByType(Text) + .map(n => n.props.children) + .flat(); + expect(textNodes).toContain('discover_no_results_one_character'); + expect(textNodes).not.toContain('discover_no_results'); + }); + + it('calls onResetToSplash when the reset button is pressed', async () => { + const onResetToSplash = jest.fn(); + const tree = await renderWithTheme( + , + ); + + const resetButton = tree.root.findByType(TouchableHighlight); + const label = resetButton.findByType(Text).props.children; + expect(label).toBe('discover_reset'); + + await TestRenderer.act(async () => { + resetButton.props.onPress(); + }); + expect(onResetToSplash).toHaveBeenCalledTimes(1); + }); +}); diff --git a/js/screens/DiscoverScreenComponents/views/__tests__/SplashView.test.js b/js/screens/DiscoverScreenComponents/views/__tests__/SplashView.test.js new file mode 100644 index 00000000..7bd445f2 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/__tests__/SplashView.test.js @@ -0,0 +1,78 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import TestRenderer from 'react-test-renderer'; + +import { ThemeContext, themes } from '../../../../ThemeContext'; +import SplashView from '../SplashView'; + +jest.mock('i18n-js', () => ({ t: key => key })); + +jest.mock('../../TagSplash', () => { + const MockTagSplash = () => null; + return { __esModule: true, default: MockTagSplash }; +}); + +const MockTagSplash = require('../../TagSplash').default; + +const renderWithTheme = async ui => { + let tree; + await TestRenderer.act(async () => { + tree = TestRenderer.create( + {ui}, + ); + }); + return tree; +}; + +describe('SplashView', () => { + it('renders the search box and forwards tag props to TagSplash', async () => { + const renderSearchBox = jest.fn(() => null); + const onSelectTag = jest.fn(); + const tags = ['ai', 'finance']; + + const tree = await renderWithTheme( + {}} + onSeeAllCommunities={() => {}} + renderSearchBox={renderSearchBox} + />, + ); + + expect(renderSearchBox).toHaveBeenCalled(); + + const tagSplash = tree.root.findByType(MockTagSplash); + expect(tagSplash.props.tags).toBe(tags); + expect(tagSplash.props.loading).toBe(false); + }); + + it('forwards TagSplash callbacks to the corresponding props', async () => { + const onSelectTag = jest.fn(); + const onSelectRecent = jest.fn(); + const onSeeAllCommunities = jest.fn(); + + const tree = await renderWithTheme( + null} + />, + ); + + const tagSplash = tree.root.findByType(MockTagSplash); + tagSplash.props.onSelectTag('ai'); + tagSplash.props.onSelectRecent(); + tagSplash.props.onSeeAllCommunities(); + + expect(onSelectTag).toHaveBeenCalledWith('ai'); + expect(onSelectRecent).toHaveBeenCalledTimes(1); + expect(onSeeAllCommunities).toHaveBeenCalledTimes(1); + }); +}); diff --git a/js/screens/DiscoverScreenComponents/views/__tests__/TagDetailView.test.js b/js/screens/DiscoverScreenComponents/views/__tests__/TagDetailView.test.js new file mode 100644 index 00000000..07c52dc9 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/__tests__/TagDetailView.test.js @@ -0,0 +1,96 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import TestRenderer from 'react-test-renderer'; +import { TouchableHighlight } from 'react-native'; + +import { ThemeContext, themes } from '../../../../ThemeContext'; +import TagDetailView from '../TagDetailView'; + +jest.mock('i18n-js', () => ({ + t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key), +})); + +jest.mock('../../CommunityCarousel', () => { + const MockCommunityCarousel = () => null; + return { __esModule: true, default: MockCommunityCarousel }; +}); + +jest.mock('../../DiscoverTopicList', () => { + const MockDiscoverTopicList = props => props.ListHeaderComponent || null; + return { __esModule: true, default: MockDiscoverTopicList }; +}); + +jest.mock('../TagFilterBar', () => { + const MockTagFilterBar = () => null; + return { __esModule: true, default: MockTagFilterBar }; +}); + +const MockCommunityCarousel = require('../../CommunityCarousel').default; +const MockDiscoverTopicList = require('../../DiscoverTopicList').default; +const MockTagFilterBar = require('../TagFilterBar').default; + +const renderWithTheme = async ui => { + let tree; + await TestRenderer.act(async () => { + tree = TestRenderer.create( + {ui}, + ); + }); + return tree; +}; + +const baseProps = { + activeTag: 'ai', + tagCommunities: [{ id: 1 }], + tagCommunitiesLoading: false, + hotTopics: [], + hotTopicsLoading: false, + splashTags: ['ai', 'finance'], + largerUI: false, + tabBarHeight: 0, + listRef: () => {}, + onPressCommunity: () => {}, + onSeeAll: () => {}, + onClickTopic: () => {}, + onEndReached: () => {}, + onRefresh: () => {}, + onExploreMore: () => {}, + onSelectTag: () => {}, + renderSearchBox: () => null, +}; + +describe('TagDetailView', () => { + it('forwards state props to CommunityCarousel, TagFilterBar, and DiscoverTopicList', async () => { + const tree = await renderWithTheme(); + + const carousel = tree.root.findByType(MockCommunityCarousel); + expect(carousel.props.communities).toBe(baseProps.tagCommunities); + expect(carousel.props.loading).toBe(false); + + const filterBar = tree.root.findByType(MockTagFilterBar); + expect(filterBar.props.activeKey).toBe('ai'); + expect(filterBar.props.splashTags).toBe(baseProps.splashTags); + + const topicList = tree.root.findByType(MockDiscoverTopicList); + expect(topicList.props.topics).toBe(baseProps.hotTopics); + }); + + it('fires onSeeAll when the "see all communities" button is pressed', async () => { + const onSeeAll = jest.fn(); + const tree = await renderWithTheme( + , + ); + + // TagFilterBar and CommunityCarousel are mocked and render null, so the + // only TouchableHighlight in the tree is the "see all communities" button + // rendered in the DiscoverTopicList's ListHeaderComponent. + const seeAllButton = tree.root.findByType(TouchableHighlight); + + await TestRenderer.act(async () => { + seeAllButton.props.onPress(); + }); + expect(onSeeAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/js/screens/DiscoverScreenComponents/views/__tests__/TagFilterBar.test.js b/js/screens/DiscoverScreenComponents/views/__tests__/TagFilterBar.test.js new file mode 100644 index 00000000..d2948259 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/__tests__/TagFilterBar.test.js @@ -0,0 +1,77 @@ +/* @flow */ +'use strict'; + +import React from 'react'; +import TestRenderer from 'react-test-renderer'; +import { TouchableHighlight } from 'react-native'; + +import { ThemeContext, themes } from '../../../../ThemeContext'; +import TagFilterBar from '../TagFilterBar'; +import { PRIMARY_TAGS } from '../../TagSplash'; + +jest.mock('i18n-js', () => ({ + t: key => key, +})); + +const renderWithTheme = async ui => { + let tree; + await TestRenderer.act(async () => { + tree = TestRenderer.create( + {ui}, + ); + }); + return tree; +}; + +describe('TagFilterBar', () => { + it('builds entries from PRIMARY_TAGS + splashTags, dedupes primary and sorts secondary', async () => { + const primarySample = PRIMARY_TAGS[0].tag; + const splashTags = [primarySample, 'zebra', 'cats']; + + const tree = await renderWithTheme( + {}} + />, + ); + + const buttons = tree.root.findAllByType(TouchableHighlight); + + // "All" + all PRIMARY_TAGS + "recent" + 2 deduped secondary tags + expect(buttons.length).toBe(1 + PRIMARY_TAGS.length + 1 + 2); + + const labels = buttons.map(b => b.props.accessibilityLabel); + expect(labels[0]).toBe('discover_all'); + expect(labels[PRIMARY_TAGS.length + 1]).toBe('discover_recent'); + + const secondaryLabels = labels.slice(PRIMARY_TAGS.length + 2); + expect(secondaryLabels).toEqual(['cats', 'zebra']); + }); + + it('invokes onSelect with the entry key when a tag is pressed', async () => { + const onSelect = jest.fn(); + + const tree = await renderWithTheme( + , + ); + + const zebraButton = tree.root + .findAllByType(TouchableHighlight) + .find(b => b.props.accessibilityLabel === 'zebra'); + + expect(zebraButton).toBeDefined(); + await TestRenderer.act(async () => { + zebraButton.props.onPress(); + }); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith('zebra'); + }); +}); diff --git a/js/screens/DiscoverScreenComponents/views/styles.js b/js/screens/DiscoverScreenComponents/views/styles.js new file mode 100644 index 00000000..b6f24155 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/views/styles.js @@ -0,0 +1,19 @@ +/* @flow */ +'use strict'; + +import { StyleSheet } from 'react-native'; + +const sharedStyles = StyleSheet.create({ + container: { + flex: 1, + }, + emptyResult: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + paddingBottom: 120, + }, +}); + +export default sharedStyles; diff --git a/package.json b/package.json index 8e5fbe5a..685ada32 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "eslint:fix": "eslint . --fix", "e2e-build": "detox build --configuration ios.sim.debug", "test": "detox test --configuration ios.sim.debug", + "test:unit": "jest", + "test:unit:watch": "jest --watch", "test-ipad": "detox test --configuration ios.sim.debug -n 'iPad (10th generation)'" }, "dependencies": {