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": {