From de3413972a8efdcb685de1d5681eac0a178c955f Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Fri, 10 Apr 2026 18:26:40 -0300 Subject: [PATCH 01/14] FEATURE: revamping discover page + add hot topics See video: --- js/locale/en.json | 9 + js/screens/DiscoverScreen.js | 762 ++++++++++++++---- .../CommunityCarousel.js | 131 +++ .../CommunityDetailView.js | 250 ++++++ .../DiscoverTopicList.js | 272 +++++++ .../TagDetailHeader.js | 73 ++ .../DiscoverScreenComponents/TagSplash.js | 101 +++ js/screens/DiscoverScreenComponents/index.js | 10 + 8 files changed, 1447 insertions(+), 161 deletions(-) create mode 100644 js/screens/DiscoverScreenComponents/CommunityCarousel.js create mode 100644 js/screens/DiscoverScreenComponents/CommunityDetailView.js create mode 100644 js/screens/DiscoverScreenComponents/DiscoverTopicList.js create mode 100644 js/screens/DiscoverScreenComponents/TagDetailHeader.js create mode 100644 js/screens/DiscoverScreenComponents/TagSplash.js diff --git a/js/locale/en.json b/js/locale/en.json index 6c554516..1f0054c0 100644 --- a/js/locale/en.json +++ b/js/locale/en.json @@ -92,6 +92,15 @@ "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_explore_more": "Explore another topic", + "discover_communities_section": "COMMUNITIES", + "discover_topics_section": "TOPICS", + "discover_see_all_communities": "See all communities", + "remove_from_home_screen": "Remove", + "preview": "Preview", + "community_recent_topics": "Hot Topics", "oops": "Oops, something went wrong.", "still_loading": "Still loading..." } diff --git a/js/screens/DiscoverScreen.js b/js/screens/DiscoverScreen.js index 5806f95e..33a98c2c 100644 --- a/js/screens/DiscoverScreen.js +++ b/js/screens/DiscoverScreen.js @@ -5,6 +5,7 @@ import React from 'react'; import { ActivityIndicator, Alert, + BackHandler, FlatList, Linking, PermissionsAndroid, @@ -37,9 +38,6 @@ const toastConfig = { 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'); } }} @@ -55,35 +53,300 @@ const toastConfig = { ), }; +const FALLBACK_TAGS = [ + 'ai', + 'finance', + 'apple', + 'automation', + 'media', + 'research', + 'smart-home', + 'linux', + 'open-source', + 'webdev', + 'health', + 'gaming', + 'audio', + 'programming-language', + 'devops', + 'crypto', + 'mapping', +]; + 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: 'splash', // 'splash' | 'tagDetail' | 'allCommunities' | 'search' + previousView: 'splash', + + // 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, + + // 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.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 !== 'splash') { + this._navigateToSplash(); + } + }, + ); + + if (Platform.OS === 'android') { + this._backHandler = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + if (this.state.view !== 'splash' && this.state.view !== 'search') { + this._navigateBack(); + return true; + } + return false; + }, + ); + } + } + + componentWillUnmount() { + if (this._unsubscribeTabPress) { + this._unsubscribeTabPress(); + } + if (this._backHandler) { + this._backHandler.remove(); + } + } + + // ── API Methods ── + + fetchSplashTags() { + const url = `${Site.discoverUrl()}discover/hot-topics-tags.json`; + + fetch(url) + .then(res => res.json()) + .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: 'tagDetail', + }); + + this._fetchTagCommunities(tag); + this._fetchHotTopicsForTag(tag); + } + + _fetchHotTopicsForTag(tag) { + const url = `${Site.discoverUrl()}discover/hot-topics.json?tag=${encodeURIComponent( + tag, + )}&page=1`; + + fetch(url) + .then(res => res.json()) + .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: 'allCommunities', previousView: 'splash' }); + } + }) + .catch(e => { + console.log(e); + if (tag !== this.state.activeTag) { + return; + } + this.setState({ hotTopicsLoading: false }); + }); + } + + fetchHotTopics(tag, opts = {}) { + const page = opts.pageNumber || 1; + const url = `${Site.discoverUrl()}discover/hot-topics.json?tag=${encodeURIComponent( + tag, + )}&page=${page}`; + + fetch(url) + .then(res => res.json()) + .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) { + const searchString = `#discover #${tag} order:featured`; + const url = `${this.baseUrl}${encodeURIComponent(searchString)}&page=1`; + + fetch(url) + .then(res => res.json()) + .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: 'communityDetail', + previousView: this.state.view, + }); + + this._fetchCommunityHotTopics(community.featured_link); + } + + _fetchCommunityHotTopics(communityUrl, opts = {}) { + const page = opts.page || 0; + const url = `${communityUrl}/hot.json?page=${page}`; + + fetch(url) + .then(res => res.json()) + .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 defaultSearch = '#locale-en'; + const searchTerm = term === '' ? defaultSearch : term; const order = term.startsWith('order:') ? '' : 'order:featured'; const searchString = `#discover ${searchTerm} ${order}`; const q = `${this.baseUrl}${encodeURIComponent(searchString)}&page=${ @@ -105,9 +368,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 +417,6 @@ class DiscoverScreen extends React.Component { } if (opts.switchTabs) { - // TODO: use "Connect here?" this.props.screenProps.openUrl(site.url); } this.showToastPrompt(); @@ -179,10 +438,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,9 +467,104 @@ class DiscoverScreen extends React.Component { }); } + // ── Navigation ── + + _navigateBack() { + if (this.state.view === 'communityDetail') { + if (this.state.previousView === 'tagDetail') { + this.setState({ + view: 'tagDetail', + activeCommunity: null, + communityTopics: [], + }); + } else if (this.state.previousView === 'allCommunities') { + this.setState({ + view: 'allCommunities', + activeCommunity: null, + communityTopics: [], + }); + } else { + this._navigateToSplash(); + } + } else if (this.state.view === 'allCommunities') { + if (this.state.previousView === 'tagDetail') { + this.setState({ view: 'tagDetail' }); + } else { + this._navigateToSplash(); + } + } else if (this.state.view === 'tagDetail') { + this._navigateToSplash(); + } else if (this.state.view === 'search') { + this.setState({ view: 'splash', term: '', results: [] }); + } + } + + _navigateToSplash() { + this.setState({ + view: 'splash', + activeTag: null, + hotTopics: [], + tagCommunities: [], + activeCommunity: null, + communityTopics: [], + term: '', + results: [], + }); + } + + _goToAllCommunities() { + this.setState({ view: 'allCommunities', previousView: 'tagDetail' }); + } + + // ── Render ── + render() { const theme = this.context; - const resultCount = this.state.results.length; + + return ( + + {tabBarHeight => ( + + {this._renderContent(tabBarHeight)} + + + )} + + ); + } + + _renderContent(tabBarHeight) { + switch (this.state.view) { + case 'splash': + return this._renderSplashView(tabBarHeight); + case 'search': + return this._renderSearchView(tabBarHeight); + case 'tagDetail': + return this._renderTagDetailView(tabBarHeight); + case 'allCommunities': + return this._renderAllCommunitiesView(tabBarHeight); + case 'communityDetail': + return this._renderCommunityDetailView(tabBarHeight); + default: + return this._renderSplashView(tabBarHeight); + } + } + + _renderSplashView() { + return ( + + {this._renderSearchBox()} + this.onSelectTag(tag)} + /> + + ); + } + + _renderSearchView(tabBarHeight) { + const theme = this.context; const messageText = this.state.term.length === 1 ? i18n.t('discover_no_results_one_character') @@ -212,8 +572,10 @@ class DiscoverScreen extends React.Component { const emptyResult = ( - {this.state.term === '' ? ( - + {this.state.loading ? ( + + + ) : ( @@ -224,11 +586,11 @@ class DiscoverScreen extends React.Component { underlayColor={theme.background} onPress={() => { this.setState({ - selectedTag: '', + view: 'splash', term: '', page: 1, + results: [], }); - this.doSearch(''); }} > - {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); - } - }} - /> + + {this._renderSearchBox()} + String(item.id || item.featured_link)} + ListEmptyComponent={emptyResult} + ref={ref => (this.discoverList = ref)} + contentContainerStyle={{ paddingBottom: tabBarHeight }} + data={this.state.results} + refreshing={this.state.loading} + refreshControl={ + { + if (this.state.term) { + this.setState({ loading: true }); + this.debouncedSearch(this.state.term); } - renderItem={({ item }) => this._renderItem({ item })} - onEndReached={() => { - this._fetchNextPage(); - }} - extraData={this.state.selectionCount} - maxToRenderPerBatch={20} - /> - - - - )} - + }} + /> + } + renderItem={({ item }) => this._renderSiteItem({ item })} + onEndReached={() => this._fetchNextSearchPage()} + extraData={this.state.selectionCount} + maxToRenderPerBatch={20} + /> + ); } - _fetchNextPage() { - if (this.state.hasMoreResults) { - const newPageNumber = this.state.page + 1; + _renderTagDetailView(tabBarHeight) { + const theme = this.context; + const tagLabel = this._formatTagLabel(this.state.activeTag); - this.setState({ page: newPageNumber, loading: true }); - this.doSearch(this.state.selectedTag || '', { - append: true, - pageNumber: newPageNumber, - }); - } + const headerComponent = ( + + + {i18n.t('discover_communities_section')} + + this.onPressCommunity(community)} + /> + this._goToAllCommunities()} + > + + {i18n.t('discover_see_all_communities')} › + + + + {i18n.t('discover_topics_section')} + + + ); + + return ( + + this._navigateBack()} + /> + this.props.screenProps.openUrl(url)} + largerUI={this.state.largerUI} + contentContainerStyle={{ paddingBottom: tabBarHeight + 20 }} + listRef={ref => (this.hotTopicsList = ref)} + ListHeaderComponent={headerComponent} + onEndReached={() => this._fetchNextHotTopicsPage()} + onRefresh={() => { + this.setState({ hotTopicsLoading: true, hotTopicsPage: 1 }); + this.fetchHotTopics(this.state.activeTag); + }} + onExploreMore={() => this._navigateToSplash()} + /> + + ); } - _renderSearchBox() { + _renderAllCommunitiesView(tabBarHeight) { + const theme = this.context; + const tagLabel = this._formatTagLabel(this.state.activeTag); + const title = `${tagLabel} ${i18n.t('discover_communities')}`; + + const emptyComponent = this.state.tagCommunitiesLoading ? ( + + + + ) : null; + return ( - { - this.setState({ term, loading: true, selectedTag: false }); - this.debouncedSearch(term); - }} - /> + + this._navigateBack()} + /> + String(item.id || item.featured_link)} + ListEmptyComponent={emptyComponent} + contentContainerStyle={{ paddingBottom: tabBarHeight }} + data={this.state.tagCommunities} + renderItem={({ item }) => this._renderSiteItem({ item })} + extraData={this.state.selectionCount} + keyboardDismissMode="on-drag" + /> + ); } - _renderTags() { - const tagOptions = [ - '', - this.defaultSearch, - '#technology', - '#interests', - '#support', - '#media', - '#gaming', - '#open-source', - '#ai', - '#locale-intl', - 'order:latest_topic', - ]; - - if (!tagOptions.includes(this.state.term)) { + _renderCommunityDetailView(tabBarHeight) { + const community = this.state.activeCommunity; + if (!community) { return null; } + const headerComponent = ( + this.addSite(url)} + onRemoveFromSidebar={url => this.removeSite(url)} + onPreview={url => this.props.screenProps.openUrl(url)} + /> + ); + 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._navigateBack()} + /> + this.props.screenProps.openUrl(url)} + largerUI={this.state.largerUI} + contentContainerStyle={{ paddingBottom: tabBarHeight + 20 }} + ListHeaderComponent={headerComponent} + onEndReached={() => this._fetchNextCommunityTopicsPage()} + onRefresh={() => { + this.setState({ + communityTopicsLoading: true, + communityTopicsPage: 1, + }); + this._fetchCommunityHotTopics(community.featured_link); + }} + onExploreMore={() => this._navigateToSplash()} + /> + ); } - _renderTag(label, searchString) { - const theme = this.context; - const isCurrentTerm = searchString === this.state.selectedTag; + // ── Shared Render Helpers ── + _renderSearchBox() { return ( - { - this.setState({ - selectedTag: searchString, - loading: true, - page: 1, - }); - if (this.discoverList) { - this.discoverList.scrollToIndex({ - index: 0, - animated: true, + { + if (term.length > 0) { + this.setState({ term, loading: true, view: 'search' }); + this.debouncedSearch(term); + } else { + this.setState({ + term: '', + view: 'splash', + results: [], + loading: false, + page: 1, }); } - this.doSearch(searchString); }} - > - - {label} - - + /> ); } - _renderItem({ item }) { + _renderSiteItem({ item }) { return ( ); } + + _formatTagLabel(tag) { + if (!tag) { + return ''; + } + return tag + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + // ── 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', @@ -433,19 +867,25 @@ const styles = StyleSheet.create({ alignItems: 'center', maxHeight: 40, }, - tags: { - paddingLeft: 12, - paddingBottom: 12, - width: '100%', - flexDirection: 'row', - maxHeight: 50, + sectionLabel: { + fontSize: 13, + fontWeight: '600', + letterSpacing: 0.5, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 4, }, - tag: { - paddingVertical: 8, - paddingHorizontal: 8, - borderRadius: 6, - borderWidth: StyleSheet.hairlineWidth, - margin: 2, + seeAllButton: { + marginHorizontal: 16, + marginTop: 12, + marginBottom: 8, + paddingVertical: 14, + borderRadius: 10, + alignItems: 'center', + }, + seeAllButtonText: { + fontSize: 16, + fontWeight: '600', }, }); diff --git a/js/screens/DiscoverScreenComponents/CommunityCarousel.js b/js/screens/DiscoverScreenComponents/CommunityCarousel.js new file mode 100644 index 00000000..56892afe --- /dev/null +++ b/js/screens/DiscoverScreenComponents/CommunityCarousel.js @@ -0,0 +1,131 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { + ScrollView, + StyleSheet, + Text, + TouchableHighlight, + View, +} from 'react-native'; +import { ThemeContext } from '../../ThemeContext'; +import SiteLogo from '../CommonComponents/SiteLogo'; + +const CommunityCarousel = props => { + const theme = useContext(ThemeContext); + + if (props.loading) { + return ( + + {[0, 1, 2, 3].map(i => ( + + + + + ))} + + ); + } + + if (!props.communities || props.communities.length === 0) { + return null; + } + + return ( + + {props.communities.map(community => { + const iconUrl = community.discover_entry_logo_url; + const logoImage = + iconUrl && !iconUrl.endsWith('.webp') && !iconUrl.endsWith('.svg') + ? { uri: iconUrl } + : false; + + return ( + props.onPressCommunity(community)} + accessibilityRole="button" + accessibilityLabel={community.title} + > + + + + + + {community.title} + + + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + scrollView: { + paddingLeft: 16, + paddingVertical: 8, + }, + card: { + width: 100, + marginRight: 12, + borderRadius: 10, + paddingVertical: 8, + }, + cardContent: { + alignItems: 'center', + }, + logoContainer: { + width: 42, + height: 42, + alignItems: 'center', + justifyContent: 'center', + }, + cardTitle: { + fontSize: 12, + marginTop: 6, + textAlign: 'center', + }, + 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..19a5aa90 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/CommunityDetailView.js @@ -0,0 +1,250 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { + 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'; + +function hashCode(str) { + let hash = 0; + for (var i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return hash; +} + +const CommunityDetailView = props => { + const theme = useContext(ThemeContext); + + const { community, topicsCount, inLocalList } = props; + const iconUrl = community.discover_entry_logo_url; + const hasLogo = + iconUrl && !iconUrl.endsWith('.webp') && !iconUrl.endsWith('.svg'); + + 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'; + + function pickColor(str, text = false) { + const darkMode = theme.name === 'dark'; + let s = darkMode ? 30 : 50; + let l = darkMode ? 20 : 60; + if (text) { + s = s + 20; + l = l + 30; + } + return `hsl(${hashCode(str) % 360}, ${s}%, ${l}%)`; + } + + function renderLogo() { + if (hasLogo) { + return ( + + ); + } + + return ( + + + {community.title[0]} + + + ); + } + + return ( + + + {renderLogo()} + + {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, + }, + logo: { + width: 80, + height: 80, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + }, + logoInitial: { + fontSize: 48, + fontWeight: '700', + }, + 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, + textTransform: 'uppercase', + }, +}); + +export default CommunityDetailView; diff --git a/js/screens/DiscoverScreenComponents/DiscoverTopicList.js b/js/screens/DiscoverScreenComponents/DiscoverTopicList.js new file mode 100644 index 00000000..d4e4f925 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/DiscoverTopicList.js @@ -0,0 +1,272 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { + 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 ; + } + + 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.loading || props.topics.length === 0) { + return null; + } + + 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" + /> + + ); +}; + +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..ff029661 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/TagDetailHeader.js @@ -0,0 +1,73 @@ +/* @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..3deb3493 --- /dev/null +++ b/js/screens/DiscoverScreenComponents/TagSplash.js @@ -0,0 +1,101 @@ +/* @flow */ +'use strict'; + +import React, { useContext } from 'react'; +import { + ActivityIndicator, + ScrollView, + StyleSheet, + Text, + TouchableHighlight, + View, +} from 'react-native'; +import { ThemeContext } from '../../ThemeContext'; +import i18n from 'i18n-js'; + +const TagSplash = props => { + const theme = useContext(ThemeContext); + + if (props.loading) { + return ( + + + + ); + } + + return ( + + + + {i18n.t('discover_pick_tag')} + + + + {props.tags.map(tag => ( + props.onSelectTag(tag)} + > + + {tag} + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + paddingTop: 40, + paddingBottom: 40, + paddingHorizontal: 20, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + alignItems: 'center', + marginBottom: 30, + }, + prompt: { + fontSize: 20, + fontWeight: '600', + marginTop: 16, + }, + tagGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + }, + tagButton: { + paddingVertical: 12, + paddingHorizontal: 18, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + margin: 6, + }, + tagLabel: { + fontSize: 16, + fontWeight: '500', + }, +}); + +export default TagSplash; 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, }; From 3406c81fd8fe36a89affe8f1349cc491256ed64d Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Fri, 10 Apr 2026 18:32:07 -0300 Subject: [PATCH 02/14] DEV: lint --- js/screens/DiscoverScreen.js | 21 +++++++++++-------- .../CommunityDetailView.js | 2 +- .../TagDetailHeader.js | 12 +++++++---- .../DiscoverScreenComponents/TagSplash.js | 5 ++++- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/js/screens/DiscoverScreen.js b/js/screens/DiscoverScreen.js index 33a98c2c..dadb9f27 100644 --- a/js/screens/DiscoverScreen.js +++ b/js/screens/DiscoverScreen.js @@ -167,7 +167,10 @@ class DiscoverScreen extends React.Component { if (json.tags && json.tags.length > 0) { this.setState({ splashTags: json.tags, splashTagsLoading: false }); } else { - this.setState({ splashTags: FALLBACK_TAGS, splashTagsLoading: false }); + this.setState({ + splashTags: FALLBACK_TAGS, + splashTagsLoading: false, + }); } }) .catch(() => { @@ -309,8 +312,7 @@ class DiscoverScreen extends React.Component { return; } - const rawTopics = - (json.topic_list && json.topic_list.topics) || []; + const rawTopics = (json.topic_list && json.topic_list.topics) || []; const topics = rawTopics.map(topic => ({ ...topic, url: `${communityUrl}/t/${topic.slug}/${topic.id}`, @@ -337,10 +339,10 @@ class DiscoverScreen extends React.Component { communityTopicsPage: newPage, communityTopicsLoading: true, }); - this._fetchCommunityHotTopics( - this.state.activeCommunity.featured_link, - { append: true, page: newPage }, - ); + this._fetchCommunityHotTopics(this.state.activeCommunity.featured_link, { + append: true, + page: newPage, + }); } } @@ -660,7 +662,9 @@ class DiscoverScreen extends React.Component { underlayColor={theme.blueUnread} onPress={() => this._goToAllCommunities()} > - + {i18n.t('discover_see_all_communities')} › @@ -735,7 +739,6 @@ class DiscoverScreen extends React.Component { { const theme = useContext(ThemeContext); - const { community, topicsCount, inLocalList } = props; + const { community, inLocalList } = props; const iconUrl = community.discover_entry_logo_url; const hasLogo = iconUrl && !iconUrl.endsWith('.webp') && !iconUrl.endsWith('.svg'); diff --git a/js/screens/DiscoverScreenComponents/TagDetailHeader.js b/js/screens/DiscoverScreenComponents/TagDetailHeader.js index ff029661..d652c950 100644 --- a/js/screens/DiscoverScreenComponents/TagDetailHeader.js +++ b/js/screens/DiscoverScreenComponents/TagDetailHeader.js @@ -2,7 +2,13 @@ 'use strict'; import React, { useContext } from 'react'; -import { Platform, StyleSheet, Text, TouchableHighlight, View } from 'react-native'; +import { + Platform, + StyleSheet, + Text, + TouchableHighlight, + View, +} from 'react-native'; import FontAwesome5 from '@react-native-vector-icons/fontawesome5'; import { ThemeContext } from '../../ThemeContext'; @@ -34,9 +40,7 @@ const TagDetailHeader = props => { {props.title} - + ); }; diff --git a/js/screens/DiscoverScreenComponents/TagSplash.js b/js/screens/DiscoverScreenComponents/TagSplash.js index 3deb3493..05039bd0 100644 --- a/js/screens/DiscoverScreenComponents/TagSplash.js +++ b/js/screens/DiscoverScreenComponents/TagSplash.js @@ -35,6 +35,7 @@ const TagSplash = props => { {i18n.t('discover_pick_tag')} + {props.tags.map(tag => ( { underlayColor={theme.grayBackground} onPress={() => props.onSelectTag(tag)} > - + {tag} From 280c00e68f87019b5b8511ab77249934b3159e34 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Fri, 10 Apr 2026 18:52:41 -0300 Subject: [PATCH 03/14] DEV: reviewed a bit more --- js/screens/CommonComponents/SiteLogo.js | 82 +++++++++++-------- js/screens/DiscoverScreen.js | 78 +++++++++++------- .../CommunityCarousel.js | 9 +- .../CommunityDetailView.js | 78 +++--------------- .../DiscoverTopicList.js | 2 +- 5 files changed, 114 insertions(+), 135 deletions(-) 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 dadb9f27..5fa9b995 100644 --- a/js/screens/DiscoverScreen.js +++ b/js/screens/DiscoverScreen.js @@ -53,6 +53,16 @@ const toastConfig = { ), }; +const VIEWS = { + SPLASH: 'splash', + SEARCH: 'search', + TAG_DETAIL: 'tagDetail', + ALL_COMMUNITIES: 'allCommunities', + COMMUNITY_DETAIL: 'communityDetail', +}; + +const defaultView = VIEWS.SPLASH; + const FALLBACK_TAGS = [ 'ai', 'finance', @@ -79,8 +89,8 @@ class DiscoverScreen extends React.Component { this.state = { // View navigation - view: 'splash', // 'splash' | 'tagDetail' | 'allCommunities' | 'search' - previousView: 'splash', + view: defaultView, + previousView: defaultView, // Tag splash splashTags: [], @@ -127,7 +137,10 @@ class DiscoverScreen extends React.Component { this._unsubscribeTabPress = this.props.navigation.addListener( 'tabPress', () => { - if (this.props.navigation.isFocused() && this.state.view !== 'splash') { + if ( + this.props.navigation.isFocused() && + this.state.view !== VIEWS.SPLASH + ) { this._navigateToSplash(); } }, @@ -137,7 +150,10 @@ class DiscoverScreen extends React.Component { this._backHandler = BackHandler.addEventListener( 'hardwareBackPress', () => { - if (this.state.view !== 'splash' && this.state.view !== 'search') { + if ( + this.state.view !== VIEWS.SPLASH && + this.state.view !== VIEWS.SEARCH + ) { this._navigateBack(); return true; } @@ -187,7 +203,7 @@ class DiscoverScreen extends React.Component { hotTopicsHasMore: false, tagCommunities: [], tagCommunitiesLoading: true, - view: 'tagDetail', + view: VIEWS.TAG_DETAIL, }); this._fetchTagCommunities(tag); @@ -214,7 +230,10 @@ class DiscoverScreen extends React.Component { }); if (topics.length === 0) { - this.setState({ view: 'allCommunities', previousView: 'splash' }); + this.setState({ + view: VIEWS.ALL_COMMUNITIES, + previousView: VIEWS.SPLASH, + }); } }) .catch(e => { @@ -294,7 +313,7 @@ class DiscoverScreen extends React.Component { communityTopicsLoading: true, communityTopicsPage: 1, communityTopicsHasMore: false, - view: 'communityDetail', + view: VIEWS.COMMUNITY_DETAIL, previousView: this.state.view, }); @@ -472,38 +491,38 @@ class DiscoverScreen extends React.Component { // ── Navigation ── _navigateBack() { - if (this.state.view === 'communityDetail') { - if (this.state.previousView === 'tagDetail') { + if (this.state.view === VIEWS.COMMUNITY_DETAIL) { + if (this.state.previousView === VIEWS.TAG_DETAIL) { this.setState({ - view: 'tagDetail', + view: VIEWS.TAG_DETAIL, activeCommunity: null, communityTopics: [], }); - } else if (this.state.previousView === 'allCommunities') { + } else if (this.state.previousView === VIEWS.ALL_COMMUNITIES) { this.setState({ - view: 'allCommunities', + view: VIEWS.ALL_COMMUNITIES, activeCommunity: null, communityTopics: [], }); } else { this._navigateToSplash(); } - } else if (this.state.view === 'allCommunities') { - if (this.state.previousView === 'tagDetail') { - this.setState({ view: 'tagDetail' }); + } 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 === 'tagDetail') { + } else if (this.state.view === VIEWS.TAG_DETAIL) { this._navigateToSplash(); - } else if (this.state.view === 'search') { - this.setState({ view: 'splash', term: '', results: [] }); + } else if (this.state.view === VIEWS.SEARCH) { + this.setState({ view: VIEWS.SPLASH, term: '', results: [] }); } } _navigateToSplash() { this.setState({ - view: 'splash', + view: VIEWS.SPLASH, activeTag: null, hotTopics: [], tagCommunities: [], @@ -515,7 +534,10 @@ class DiscoverScreen extends React.Component { } _goToAllCommunities() { - this.setState({ view: 'allCommunities', previousView: 'tagDetail' }); + this.setState({ + view: VIEWS.ALL_COMMUNITIES, + previousView: VIEWS.TAG_DETAIL, + }); } // ── Render ── @@ -537,15 +559,15 @@ class DiscoverScreen extends React.Component { _renderContent(tabBarHeight) { switch (this.state.view) { - case 'splash': + case VIEWS.SPLASH: return this._renderSplashView(tabBarHeight); - case 'search': + case VIEWS.SEARCH: return this._renderSearchView(tabBarHeight); - case 'tagDetail': + case VIEWS.TAG_DETAIL: return this._renderTagDetailView(tabBarHeight); - case 'allCommunities': + case VIEWS.ALL_COMMUNITIES: return this._renderAllCommunitiesView(tabBarHeight); - case 'communityDetail': + case VIEWS.COMMUNITY_DETAIL: return this._renderCommunityDetailView(tabBarHeight); default: return this._renderSplashView(tabBarHeight); @@ -588,7 +610,7 @@ class DiscoverScreen extends React.Component { underlayColor={theme.background} onPress={() => { this.setState({ - view: 'splash', + view: VIEWS.SPLASH, term: '', page: 1, results: [], @@ -783,12 +805,12 @@ class DiscoverScreen extends React.Component { text={this.state.term} handleChangeText={term => { if (term.length > 0) { - this.setState({ term, loading: true, view: 'search' }); + this.setState({ term, loading: true, view: VIEWS.SEARCH }); this.debouncedSearch(term); } else { this.setState({ term: '', - view: 'splash', + view: VIEWS.SPLASH, results: [], loading: false, page: 1, diff --git a/js/screens/DiscoverScreenComponents/CommunityCarousel.js b/js/screens/DiscoverScreenComponents/CommunityCarousel.js index 56892afe..c5047c15 100644 --- a/js/screens/DiscoverScreenComponents/CommunityCarousel.js +++ b/js/screens/DiscoverScreenComponents/CommunityCarousel.js @@ -10,7 +10,7 @@ import { View, } from 'react-native'; import { ThemeContext } from '../../ThemeContext'; -import SiteLogo from '../CommonComponents/SiteLogo'; +import SiteLogo, { isValidLogoUrl } from '../CommonComponents/SiteLogo'; const CommunityCarousel = props => { const theme = useContext(ThemeContext); @@ -54,10 +54,9 @@ const CommunityCarousel = props => { > {props.communities.map(community => { const iconUrl = community.discover_entry_logo_url; - const logoImage = - iconUrl && !iconUrl.endsWith('.webp') && !iconUrl.endsWith('.svg') - ? { uri: iconUrl } - : false; + const logoImage = isValidLogoUrl(iconUrl) + ? { uri: iconUrl } + : false; return ( { const theme = useContext(ThemeContext); const { community, inLocalList } = props; const iconUrl = community.discover_entry_logo_url; - const hasLogo = - iconUrl && !iconUrl.endsWith('.webp') && !iconUrl.endsWith('.svg'); + const logoImage = isValidLogoUrl(iconUrl) ? { uri: iconUrl } : false; const link = community.featured_link || ''; const displayLink = link.replace(/^https?:\/\//, ''); @@ -38,48 +24,16 @@ const CommunityDetailView = props => { : i18n.t('add_to_home_screen'); const addButtonIcon = inLocalList ? 'minus' : 'plus'; - function pickColor(str, text = false) { - const darkMode = theme.name === 'dark'; - let s = darkMode ? 30 : 50; - let l = darkMode ? 20 : 60; - if (text) { - s = s + 20; - l = l + 30; - } - return `hsl(${hashCode(str) % 360}, ${s}%, ${l}%)`; - } - - function renderLogo() { - if (hasLogo) { - return ( - - ); - } - - return ( - - - {community.title[0]} - - - ); - } - return ( - {renderLogo()} + { function _renderCommunity(item) { if (!item.community_name) { - return ; + return null; } return ( From 205ad0a0bb5b47afa4db68750de92c918081cca2 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Tue, 14 Apr 2026 22:23:36 -0300 Subject: [PATCH 04/14] DEV: lint --- e2e/onboarding.test.js | 2 +- js/locale/fr.json | 11 ++++++++++- .../DiscoverScreenComponents/CommunityCarousel.js | 4 +--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/e2e/onboarding.test.js b/e2e/onboarding.test.js index bccc8ab1..597f7fde 100644 --- a/e2e/onboarding.test.js +++ b/e2e/onboarding.test.js @@ -42,7 +42,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/js/locale/fr.json b/js/locale/fr.json index 1d9bf389..f86acea3 100644 --- a/js/locale/fr.json +++ b/js/locale/fr.json @@ -90,5 +90,14 @@ "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" } diff --git a/js/screens/DiscoverScreenComponents/CommunityCarousel.js b/js/screens/DiscoverScreenComponents/CommunityCarousel.js index c5047c15..cdda223b 100644 --- a/js/screens/DiscoverScreenComponents/CommunityCarousel.js +++ b/js/screens/DiscoverScreenComponents/CommunityCarousel.js @@ -54,9 +54,7 @@ const CommunityCarousel = props => { > {props.communities.map(community => { const iconUrl = community.discover_entry_logo_url; - const logoImage = isValidLogoUrl(iconUrl) - ? { uri: iconUrl } - : false; + const logoImage = isValidLogoUrl(iconUrl) ? { uri: iconUrl } : false; return ( Date: Wed, 15 Apr 2026 10:33:34 -0400 Subject: [PATCH 05/14] changes --- js/locale/en.json | 6 +- js/screens/DiscoverScreen.js | 215 +++++++++++++++--- .../TagDetailHeader.js | 2 +- .../DiscoverScreenComponents/TagSplash.js | 168 ++++++++++++-- 4 files changed, 337 insertions(+), 54 deletions(-) diff --git a/js/locale/en.json b/js/locale/en.json index 1f0054c0..034bde1d 100644 --- a/js/locale/en.json +++ b/js/locale/en.json @@ -94,10 +94,12 @@ "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": "COMMUNITIES", - "discover_topics_section": "TOPICS", + "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", diff --git a/js/screens/DiscoverScreen.js b/js/screens/DiscoverScreen.js index 5fa9b995..f2f96f9f 100644 --- a/js/screens/DiscoverScreen.js +++ b/js/screens/DiscoverScreen.js @@ -19,6 +19,7 @@ import { } from 'react-native'; import DiscoverComponents from './DiscoverScreenComponents'; +import { PRIMARY_TAGS } from './DiscoverScreenComponents/TagSplash'; import { ThemeContext } from '../ThemeContext'; import Site from '../site'; import i18n from 'i18n-js'; @@ -114,6 +115,11 @@ class DiscoverScreen extends React.Component { 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: [], @@ -233,7 +239,11 @@ class DiscoverScreen extends React.Component { this.setState({ view: VIEWS.ALL_COMMUNITIES, previousView: VIEWS.SPLASH, + communitiesFilter: tag, + allCommunities: [], + allCommunitiesLoading: true, }); + this._fetchAllCommunities(tag); } }) .catch(e => { @@ -534,10 +544,89 @@ class DiscoverScreen extends React.Component { } _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.TAG_DETAIL, + 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) { + 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 = `${this.baseUrl}${encodeURIComponent(searchString)}&page=1`; + + fetch(url) + .then(res => res.json()) + .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 ── @@ -582,6 +671,8 @@ class DiscoverScreen extends React.Component { tags={this.state.splashTags} loading={this.state.splashTagsLoading} onSelectTag={tag => this.onSelectTag(tag)} + onSelectRecent={() => this._goToAllCommunitiesFromSplash('recent')} + onSeeAllCommunities={() => this._goToAllCommunitiesFromSplash()} /> ); @@ -664,12 +755,12 @@ class DiscoverScreen extends React.Component { _renderTagDetailView(tabBarHeight) { const theme = this.context; - const tagLabel = this._formatTagLabel(this.state.activeTag); + const activeTagLabel = this.state.activeTag; const headerComponent = ( - {i18n.t('discover_communities_section')} + {i18n.t('discover_communities_section', { tag: activeTagLabel })} - {i18n.t('discover_topics_section')} + {i18n.t('discover_topics_section', { tag: activeTagLabel })} ); return ( - this._navigateBack()} - /> + {this._renderSearchBox()} + {this._renderCommunitiesTagBar(this.state.activeTag, key => + this._selectFromTagDetail(key), + )} @@ -734,15 +823,15 @@ class DiscoverScreen extends React.Component { return ( - this._navigateBack()} - /> + {this._renderSearchBox()} + {this._renderCommunitiesTagBar(this.state.communitiesFilter, key => + this._selectCommunitiesFilter(key), + )} String(item.id || item.featured_link)} ListEmptyComponent={emptyComponent} contentContainerStyle={{ paddingBottom: tabBarHeight }} - data={this.state.tagCommunities} + data={this.state.allCommunities} renderItem={({ item }) => this._renderSiteItem({ item })} extraData={this.state.selectionCount} keyboardDismissMode="on-drag" @@ -751,6 +840,68 @@ class DiscoverScreen extends React.Component { ); } + _renderCommunitiesTagBar(activeKey, onSelect) { + const theme = this.context; + + const primaryTagSet = new Set(PRIMARY_TAGS.map(t => t.tag)); + const secondaryTags = (this.state.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} + + + ); + })} + + ); + } + _renderCommunityDetailView(tabBarHeight) { const community = this.state.activeCommunity; if (!community) { @@ -772,6 +923,7 @@ class DiscoverScreen extends React.Component { return ( + {this._renderSearchBox()} this._navigateBack()} @@ -832,16 +984,6 @@ class DiscoverScreen extends React.Component { ); } - _formatTagLabel(tag) { - if (!tag) { - return ''; - } - return tag - .split('-') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - } - // ── Pagination ── _fetchNextSearchPage() { @@ -899,6 +1041,27 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 4, + textTransform: 'uppercase', + }, + 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', }, seeAllButton: { marginHorizontal: 16, diff --git a/js/screens/DiscoverScreenComponents/TagDetailHeader.js b/js/screens/DiscoverScreenComponents/TagDetailHeader.js index d652c950..5dc31923 100644 --- a/js/screens/DiscoverScreenComponents/TagDetailHeader.js +++ b/js/screens/DiscoverScreenComponents/TagDetailHeader.js @@ -26,7 +26,7 @@ const TagDetailHeader = props => { accessibilityLabel="Back" > { const theme = useContext(ThemeContext); @@ -24,6 +37,41 @@ const TagSplash = props => { ); } + 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')} + - {props.tags.map(tag => ( - props.onSelectTag(tag)} - > - - {tag} - - - ))} + {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')} › + + ); }; @@ -65,8 +143,8 @@ const TagSplash = props => { const styles = StyleSheet.create({ container: { alignItems: 'center', - paddingTop: 40, - paddingBottom: 40, + paddingTop: 30, + paddingBottom: 30, paddingHorizontal: 20, }, loadingContainer: { @@ -76,29 +154,69 @@ const styles = StyleSheet.create({ }, header: { alignItems: 'center', - marginBottom: 30, + marginBottom: 10, }, prompt: { fontSize: 20, fontWeight: '600', marginTop: 16, }, + promptDescription: { + fontSize: 16, + marginTop: 10, + textAlign: 'center', + padding: 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: 12, - paddingHorizontal: 18, + 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; From 0f2422cf8589d1b3c19afb0e0b2908e5822eccd6 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Wed, 15 Apr 2026 12:23:26 -0300 Subject: [PATCH 06/14] Update carousel --- .../CommunityCarousel.js | 168 +++++++++++++----- 1 file changed, 124 insertions(+), 44 deletions(-) diff --git a/js/screens/DiscoverScreenComponents/CommunityCarousel.js b/js/screens/DiscoverScreenComponents/CommunityCarousel.js index cdda223b..9955fcd3 100644 --- a/js/screens/DiscoverScreenComponents/CommunityCarousel.js +++ b/js/screens/DiscoverScreenComponents/CommunityCarousel.js @@ -7,13 +7,39 @@ import { 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 ( @@ -21,21 +47,46 @@ const CommunityCarousel = props => { horizontal={true} showsHorizontalScrollIndicator={false} style={styles.scrollView} + contentContainerStyle={styles.contentContainer} > - {[0, 1, 2, 3].map(i => ( - - - + {[0, 1, 2, 3].map(columnIndex => ( + + {[0, 1].map(rowIndex => ( + + + + + + + ))} ))} @@ -49,55 +100,83 @@ const CommunityCarousel = props => { return ( - {props.communities.map(community => { - const iconUrl = community.discover_entry_logo_url; - const logoImage = isValidLogoUrl(iconUrl) ? { uri: iconUrl } : false; + {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} - > - - - - - props.onPressCommunity(community)} + accessibilityRole="button" + accessibilityLabel={community.title} > - {community.title} - - - - ); - })} - + + + + + + {community.title} + + + + ); + })} + + ))} ); }; const styles = StyleSheet.create({ scrollView: { - paddingLeft: 16, paddingVertical: 8, }, + contentContainer: { + paddingHorizontal: HORIZONTAL_PADDING, + }, + column: { + justifyContent: 'flex-start', + }, card: { - width: 100, - marginRight: 12, borderRadius: 10, paddingVertical: 8, }, cardContent: { alignItems: 'center', + minHeight: 86, }, logoContainer: { width: 42, @@ -109,6 +188,7 @@ const styles = StyleSheet.create({ fontSize: 12, marginTop: 6, textAlign: 'center', + width: '100%', }, placeholderLogo: { height: 42, From 61fce0eb09d973cf57bc26c9c2751fe0ec3f52e6 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Thu, 16 Apr 2026 10:06:50 -0300 Subject: [PATCH 07/14] DEV: small ui changes --- js/locale/en.json | 3 ++- js/locale/fr.json | 3 ++- .../DiscoverScreenComponents/TagSplash.js | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/js/locale/en.json b/js/locale/en.json index 034bde1d..e7e92390 100644 --- a/js/locale/en.json +++ b/js/locale/en.json @@ -104,5 +104,6 @@ "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 f86acea3..3744fe24 100644 --- a/js/locale/fr.json +++ b/js/locale/fr.json @@ -99,5 +99,6 @@ "discover_see_all_communities": "Voir toutes les communautés", "remove_from_home_screen": "Supprimer", "preview": "Aperçu", - "community_recent_topics": "Sujets populaires" + "community_recent_topics": "Sujets populaires", + "tag_label_programming_language": "programmation" } diff --git a/js/screens/DiscoverScreenComponents/TagSplash.js b/js/screens/DiscoverScreenComponents/TagSplash.js index 0c150ccc..ce43cd4a 100644 --- a/js/screens/DiscoverScreenComponents/TagSplash.js +++ b/js/screens/DiscoverScreenComponents/TagSplash.js @@ -4,6 +4,7 @@ import React, { useContext } from 'react'; import { ActivityIndicator, + Platform, ScrollView, StyleSheet, Text, @@ -13,6 +14,11 @@ import { import { ThemeContext } from '../../ThemeContext'; import i18n from 'i18n-js'; +// Some tags are too long to fit on a button, so we can override their labels here. The keys are the actual tag values, and the values are what gets displayed on the button. +const TAG_LABEL_OVERRIDES = { + 'programming-language': i18n.t('tag_label_programming_language'), +}; + // 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 = [ @@ -110,7 +116,7 @@ const TagSplash = props => { {secondaryTags.map(tag => renderTagButton({ key: tag, - label: tag, + label: TAG_LABEL_OVERRIDES[tag] || tag, onPress: () => props.onSelectTag(tag), secondary: true, }), @@ -143,7 +149,7 @@ const TagSplash = props => { const styles = StyleSheet.create({ container: { alignItems: 'center', - paddingTop: 30, + paddingTop: Platform.OS === 'android' ? 10 : 30, paddingBottom: 30, paddingHorizontal: 20, }, @@ -154,18 +160,18 @@ const styles = StyleSheet.create({ }, header: { alignItems: 'center', - marginBottom: 10, + marginBottom: Platform.OS === 'android' ? 4 : 10, }, prompt: { fontSize: 20, fontWeight: '600', - marginTop: 16, + marginTop: Platform.OS === 'android' ? 4 : 16, }, promptDescription: { fontSize: 16, - marginTop: 10, + marginTop: Platform.OS === 'android' ? 4 : 10, textAlign: 'center', - padding: 10, + padding: Platform.OS === 'android' ? 4 : 10, }, tagGrid: { flexDirection: 'row', From 07fac6d297adb46f72f8aa9925c822a2b1716b91 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Thu, 16 Apr 2026 10:11:03 -0300 Subject: [PATCH 08/14] dev: remove overrides --- js/screens/DiscoverScreenComponents/TagSplash.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/js/screens/DiscoverScreenComponents/TagSplash.js b/js/screens/DiscoverScreenComponents/TagSplash.js index ce43cd4a..616199dc 100644 --- a/js/screens/DiscoverScreenComponents/TagSplash.js +++ b/js/screens/DiscoverScreenComponents/TagSplash.js @@ -14,11 +14,6 @@ import { import { ThemeContext } from '../../ThemeContext'; import i18n from 'i18n-js'; -// Some tags are too long to fit on a button, so we can override their labels here. The keys are the actual tag values, and the values are what gets displayed on the button. -const TAG_LABEL_OVERRIDES = { - 'programming-language': i18n.t('tag_label_programming_language'), -}; - // 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 = [ @@ -116,7 +111,7 @@ const TagSplash = props => { {secondaryTags.map(tag => renderTagButton({ key: tag, - label: TAG_LABEL_OVERRIDES[tag] || tag, + label: tag, onPress: () => props.onSelectTag(tag), secondary: true, }), From 9033b2c1a18f5baefe654a751fc71041b0808949 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Tue, 21 Apr 2026 17:19:47 -0300 Subject: [PATCH 09/14] DEV: add tests and split discover into multiple files --- eslint.config.js | 8 + js/screens/DiscoverScreen.js | 548 ++++-------------- js/screens/DiscoverScreenComponents/api.js | 54 ++ .../DiscoverScreenComponents/constants.js | 61 ++ .../views/AllCommunitiesView.js | 54 ++ .../views/CommunityDetailView.js | 66 +++ .../views/SearchView.js | 109 ++++ .../views/SplashView.js | 34 ++ .../views/TagDetailView.js | 117 ++++ .../views/TagFilterBar.js | 97 ++++ .../__tests__/AllCommunitiesView.test.js | 73 +++ .../__tests__/CommunityDetailView.test.js | 103 ++++ .../views/__tests__/SearchView.test.js | 63 ++ .../views/__tests__/SplashView.test.js | 78 +++ .../views/__tests__/TagDetailView.test.js | 96 +++ .../views/__tests__/TagFilterBar.test.js | 77 +++ .../DiscoverScreenComponents/views/styles.js | 19 + 17 files changed, 1223 insertions(+), 434 deletions(-) create mode 100644 js/screens/DiscoverScreenComponents/api.js create mode 100644 js/screens/DiscoverScreenComponents/constants.js create mode 100644 js/screens/DiscoverScreenComponents/views/AllCommunitiesView.js create mode 100644 js/screens/DiscoverScreenComponents/views/CommunityDetailView.js create mode 100644 js/screens/DiscoverScreenComponents/views/SearchView.js create mode 100644 js/screens/DiscoverScreenComponents/views/SplashView.js create mode 100644 js/screens/DiscoverScreenComponents/views/TagDetailView.js create mode 100644 js/screens/DiscoverScreenComponents/views/TagFilterBar.js create mode 100644 js/screens/DiscoverScreenComponents/views/__tests__/AllCommunitiesView.test.js create mode 100644 js/screens/DiscoverScreenComponents/views/__tests__/CommunityDetailView.test.js create mode 100644 js/screens/DiscoverScreenComponents/views/__tests__/SearchView.test.js create mode 100644 js/screens/DiscoverScreenComponents/views/__tests__/SplashView.test.js create mode 100644 js/screens/DiscoverScreenComponents/views/__tests__/TagDetailView.test.js create mode 100644 js/screens/DiscoverScreenComponents/views/__tests__/TagFilterBar.test.js create mode 100644 js/screens/DiscoverScreenComponents/views/styles.js 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/js/screens/DiscoverScreen.js b/js/screens/DiscoverScreen.js index f2f96f9f..e9cdfa10 100644 --- a/js/screens/DiscoverScreen.js +++ b/js/screens/DiscoverScreen.js @@ -2,88 +2,29 @@ 'use strict'; import React from 'react'; -import { - ActivityIndicator, - Alert, - BackHandler, - 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 { PRIMARY_TAGS } from './DiscoverScreenComponents/TagSplash'; +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') { - Linking.openURL('App-Prefs:NOTIFICATIONS_ID'); - } - }} - contentContainerStyle={{ paddingHorizontal: 10 }} - text1Style={{ - fontSize: 17, - fontWeight: '400', - }} - text2Style={{ - fontSize: 17, - }} - /> - ), -}; - -const VIEWS = { - SPLASH: 'splash', - SEARCH: 'search', - TAG_DETAIL: 'tagDetail', - ALL_COMMUNITIES: 'allCommunities', - COMMUNITY_DETAIL: 'communityDetail', -}; - -const defaultView = VIEWS.SPLASH; - -const FALLBACK_TAGS = [ - 'ai', - 'finance', - 'apple', - 'automation', - 'media', - 'research', - 'smart-home', - 'linux', - 'open-source', - 'webdev', - 'health', - 'gaming', - 'audio', - 'programming-language', - 'devops', - 'crypto', - 'mapping', -]; - class DiscoverScreen extends React.Component { constructor(props) { super(props); @@ -131,7 +72,6 @@ class DiscoverScreen extends React.Component { }; this._siteManager = this.props.screenProps.siteManager; - this.baseUrl = `${Site.discoverUrl()}search.json?q=`; this.maxPageNumber = 10; this.debouncedSearch = debounce(this.doSearch, 750); @@ -181,10 +121,8 @@ class DiscoverScreen extends React.Component { // ── API Methods ── fetchSplashTags() { - const url = `${Site.discoverUrl()}discover/hot-topics-tags.json`; - - fetch(url) - .then(res => res.json()) + api + .fetchSplashTags() .then(json => { if (json.tags && json.tags.length > 0) { this.setState({ splashTags: json.tags, splashTagsLoading: false }); @@ -217,12 +155,8 @@ class DiscoverScreen extends React.Component { } _fetchHotTopicsForTag(tag) { - const url = `${Site.discoverUrl()}discover/hot-topics.json?tag=${encodeURIComponent( - tag, - )}&page=1`; - - fetch(url) - .then(res => res.json()) + api + .fetchHotTopics(tag, 1) .then(json => { if (tag !== this.state.activeTag) { return; // stale response @@ -257,12 +191,9 @@ class DiscoverScreen extends React.Component { fetchHotTopics(tag, opts = {}) { const page = opts.pageNumber || 1; - const url = `${Site.discoverUrl()}discover/hot-topics.json?tag=${encodeURIComponent( - tag, - )}&page=${page}`; - fetch(url) - .then(res => res.json()) + api + .fetchHotTopics(tag, page) .then(json => { if (tag !== this.state.activeTag) { return; @@ -291,11 +222,8 @@ class DiscoverScreen extends React.Component { } _fetchTagCommunities(tag) { - const searchString = `#discover #${tag} order:featured`; - const url = `${this.baseUrl}${encodeURIComponent(searchString)}&page=1`; - - fetch(url) - .then(res => res.json()) + api + .fetchTagCommunities(tag) .then(json => { if (tag !== this.state.activeTag) { return; @@ -332,10 +260,9 @@ class DiscoverScreen extends React.Component { _fetchCommunityHotTopics(communityUrl, opts = {}) { const page = opts.page || 0; - const url = `${communityUrl}/hot.json?page=${page}`; - fetch(url) - .then(res => res.json()) + api + .fetchCommunityHotTopics(communityUrl, page) .then(json => { if (communityUrl !== this.state.activeCommunity?.featured_link) { return; @@ -376,16 +303,8 @@ class DiscoverScreen extends React.Component { } doSearch(term, opts = {}) { - const defaultSearch = '#locale-en'; - const searchTerm = term === '' ? 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({ @@ -599,18 +518,8 @@ class DiscoverScreen extends React.Component { } _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 = `${this.baseUrl}${encodeURIComponent(searchString)}&page=1`; - - fetch(url) - .then(res => res.json()) + api + .fetchAllCommunities(filter) .then(json => { if (filter !== this.state.communitiesFilter) { return; // stale response @@ -649,7 +558,7 @@ class DiscoverScreen extends React.Component { _renderContent(tabBarHeight) { switch (this.state.view) { case VIEWS.SPLASH: - return this._renderSplashView(tabBarHeight); + return this._renderSplashView(); case VIEWS.SEARCH: return this._renderSearchView(tabBarHeight); case VIEWS.TAG_DETAIL: @@ -659,246 +568,94 @@ class DiscoverScreen extends React.Component { case VIEWS.COMMUNITY_DETAIL: return this._renderCommunityDetailView(tabBarHeight); default: - return this._renderSplashView(tabBarHeight); + return this._renderSplashView(); } } _renderSplashView() { return ( - - {this._renderSearchBox()} - this.onSelectTag(tag)} - onSelectRecent={() => this._goToAllCommunitiesFromSplash('recent')} - onSeeAllCommunities={() => this._goToAllCommunitiesFromSplash()} - /> - + this.onSelectTag(tag)} + onSelectRecent={() => this._goToAllCommunitiesFromSplash('recent')} + onSeeAllCommunities={() => this._goToAllCommunitiesFromSplash()} + renderSearchBox={() => this._renderSearchBox()} + /> ); } _renderSearchView(tabBarHeight) { - const theme = this.context; - const messageText = - this.state.term.length === 1 - ? i18n.t('discover_no_results_one_character') - : i18n.t('discover_no_results'); - - const emptyResult = ( - - {this.state.loading ? ( - - - - ) : ( - - - {messageText} - - { - this.setState({ - view: VIEWS.SPLASH, - term: '', - page: 1, - results: [], - }); - }} - > - - {i18n.t('discover_reset')} - - - - )} - - ); - return ( - - {this._renderSearchBox()} - String(item.id || item.featured_link)} - ListEmptyComponent={emptyResult} - ref={ref => (this.discoverList = ref)} - contentContainerStyle={{ paddingBottom: tabBarHeight }} - data={this.state.results} - refreshing={this.state.loading} - refreshControl={ - { - if (this.state.term) { - this.setState({ loading: true }); - this.debouncedSearch(this.state.term); - } - }} - /> + (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); } - renderItem={({ item }) => this._renderSiteItem({ item })} - onEndReached={() => this._fetchNextSearchPage()} - extraData={this.state.selectionCount} - maxToRenderPerBatch={20} - /> - + }} + onEndReached={() => this._fetchNextSearchPage()} + renderSearchBox={() => this._renderSearchBox()} + renderSiteItem={({ item }) => this._renderSiteItem({ item })} + /> ); } _renderTagDetailView(tabBarHeight) { - const theme = this.context; - const activeTagLabel = this.state.activeTag; - - const headerComponent = ( - - - {i18n.t('discover_communities_section', { tag: activeTagLabel })} - - this.onPressCommunity(community)} - /> - this._goToAllCommunities()} - > - - {i18n.t('discover_see_all_communities')} › - - - - {i18n.t('discover_topics_section', { tag: activeTagLabel })} - - - ); - return ( - - {this._renderSearchBox()} - {this._renderCommunitiesTagBar(this.state.activeTag, key => - this._selectFromTagDetail(key), - )} - this.props.screenProps.openUrl(url)} - largerUI={this.state.largerUI} - contentContainerStyle={{ paddingBottom: tabBarHeight + 20 }} - listRef={ref => (this.hotTopicsList = ref)} - ListHeaderComponent={headerComponent} - onEndReached={() => this._fetchNextHotTopicsPage()} - onRefresh={() => { - this.setState({ hotTopicsLoading: true, hotTopicsPage: 1 }); - this.fetchHotTopics(this.state.activeTag); - }} - onExploreMore={() => this._navigateToSplash()} - /> - + (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) { - const theme = this.context; - - const emptyComponent = this.state.allCommunitiesLoading ? ( - - - - ) : null; - - return ( - - {this._renderSearchBox()} - {this._renderCommunitiesTagBar(this.state.communitiesFilter, key => - this._selectCommunitiesFilter(key), - )} - String(item.id || item.featured_link)} - ListEmptyComponent={emptyComponent} - contentContainerStyle={{ paddingBottom: tabBarHeight }} - data={this.state.allCommunities} - renderItem={({ item }) => this._renderSiteItem({ item })} - extraData={this.state.selectionCount} - keyboardDismissMode="on-drag" - /> - - ); - } - - _renderCommunitiesTagBar(activeKey, onSelect) { - const theme = this.context; - - const primaryTagSet = new Set(PRIMARY_TAGS.map(t => t.tag)); - const secondaryTags = (this.state.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} - - - ); - })} - + this._selectCommunitiesFilter(key)} + renderSearchBox={() => this._renderSearchBox()} + renderSiteItem={({ item }) => this._renderSiteItem({ item })} + /> ); } @@ -908,45 +665,34 @@ class DiscoverScreen extends React.Component { return null; } - const headerComponent = ( - 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({ + communityTopicsLoading: true, + communityTopicsPage: 1, + }); + this._fetchCommunityHotTopics(community.featured_link); + }} + onExploreMore={() => this._navigateToSplash()} + renderSearchBox={() => this._renderSearchBox()} /> ); - - return ( - - {this._renderSearchBox()} - this._navigateBack()} - /> - this.props.screenProps.openUrl(url)} - largerUI={this.state.largerUI} - contentContainerStyle={{ paddingBottom: tabBarHeight + 20 }} - ListHeaderComponent={headerComponent} - onEndReached={() => this._fetchNextCommunityTopicsPage()} - onRefresh={() => { - this.setState({ - communityTopicsLoading: true, - communityTopicsPage: 1, - }); - this._fetchCommunityHotTopics(community.featured_link); - }} - onExploreMore={() => this._navigateToSplash()} - /> - - ); } // ── Shared Render Helpers ── @@ -1011,70 +757,4 @@ class DiscoverScreen extends React.Component { DiscoverScreen.contextType = ThemeContext; -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - 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, - }, - sectionLabel: { - fontSize: 13, - fontWeight: '600', - letterSpacing: 0.5, - paddingHorizontal: 16, - paddingTop: 16, - paddingBottom: 4, - textTransform: 'uppercase', - }, - 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', - }, - seeAllButton: { - marginHorizontal: 16, - marginTop: 12, - marginBottom: 8, - paddingVertical: 14, - borderRadius: 10, - alignItems: 'center', - }, - seeAllButtonText: { - fontSize: 16, - fontWeight: '600', - }, -}); - export default DiscoverScreen; 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/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; From daba59c79443e5ccc54426f88def8d9020a977b2 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Tue, 21 Apr 2026 17:24:48 -0300 Subject: [PATCH 10/14] DEV: add tests to the CI --- .github/workflows/jest-tests.yml | 30 ++++++++++++++++++++++++++++++ jest.config.js | 1 + package.json | 2 ++ 3 files changed, 33 insertions(+) create mode 100644 .github/workflows/jest-tests.yml diff --git a/.github/workflows/jest-tests.yml b/.github/workflows/jest-tests.yml new file mode 100644 index 00000000..34d908b1 --- /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 unit-test 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/package.json b/package.json index 8e5fbe5a..0b217771 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "prettier:fix": "prettier --write \"js/**/*.{js,json}\"", "eslint": "eslint .", "eslint:fix": "eslint . --fix", + "unit-test": "jest", + "unit-test:watch": "jest --watch", "e2e-build": "detox build --configuration ios.sim.debug", "test": "detox test --configuration ios.sim.debug", "test-ipad": "detox test --configuration ios.sim.debug -n 'iPad (10th generation)'" From 42a65c3e274c40f02e21d23e84296e893912b1ce Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Wed, 22 Apr 2026 09:26:51 -0300 Subject: [PATCH 11/14] DEV: lint --- e2e/onboarding.test.js | 5 ----- e2e/topiclist.test.js | 5 ----- 2 files changed, 10 deletions(-) diff --git a/e2e/onboarding.test.js b/e2e/onboarding.test.js index 597f7fde..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'; 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'; From fb4ae04c8b78e8c563e1ce43b6bd7d4ab34ef737 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Wed, 22 Apr 2026 09:28:02 -0300 Subject: [PATCH 12/14] DEV: rename tests --- .github/workflows/jest-tests.yml | 2 +- package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/jest-tests.yml b/.github/workflows/jest-tests.yml index 34d908b1..76835e21 100644 --- a/.github/workflows/jest-tests.yml +++ b/.github/workflows/jest-tests.yml @@ -27,4 +27,4 @@ jobs: run: yarn - name: Jest - run: yarn unit-test + run: yarn test:unit diff --git a/package.json b/package.json index 0b217771..685ada32 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "prettier:fix": "prettier --write \"js/**/*.{js,json}\"", "eslint": "eslint .", "eslint:fix": "eslint . --fix", - "unit-test": "jest", - "unit-test:watch": "jest --watch", "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": { From b09622c6ed4ee24afeca3e306781866a752c6236 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Thu, 23 Apr 2026 10:28:11 -0300 Subject: [PATCH 13/14] DEV trying to make topic list stable --- .../DiscoverScreenComponents/DiscoverTopicList.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/js/screens/DiscoverScreenComponents/DiscoverTopicList.js b/js/screens/DiscoverScreenComponents/DiscoverTopicList.js index a9338cd4..f88fb522 100644 --- a/js/screens/DiscoverScreenComponents/DiscoverTopicList.js +++ b/js/screens/DiscoverScreenComponents/DiscoverTopicList.js @@ -3,6 +3,7 @@ import React, { useContext } from 'react'; import { + ActivityIndicator, FlatList, Image, StyleSheet, @@ -132,10 +133,18 @@ const DiscoverTopicList = props => { } function _renderFooter() { - if (props.loading || props.topics.length === 0) { + if (props.topics.length === 0) { return null; } + if (props.loading) { + return ( + + + + ); + } + return ( { onScroll={props.onScroll} scrollEventThrottle={16} keyboardDismissMode="on-drag" + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} /> ); From d5dfde53d3700be8fd7d6015b01970c83c2a294b Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Thu, 23 Apr 2026 12:45:24 -0400 Subject: [PATCH 14/14] update metadata --- fastlane/Fastfile | 12 ++++++------ ios/Discourse.xcodeproj/project.pbxproj | 22 ++++++++-------------- ios/Discourse/Info.plist | 4 ++-- ios/ShareExtension/Info.plist | 4 ++-- 4 files changed, 18 insertions(+), 24 deletions(-) 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