diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 00000000..c0246cac
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,17 @@
+module.exports = {
+ extends: ["universe/native"],
+ parserOptions: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ },
+ plugins: ["babel", "react"],
+ rules: {
+ "prettier/prettier": [
+ "error",
+ {
+ endOfLine: "auto",
+ printWidth: 120,
+ },
+ ],
+ },
+};
diff --git a/.gitignore b/.gitignore
index ef5b566c..982d0b45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,4 +62,6 @@ buck-out/
.expo/
web-build/
-# @end expo-cli
\ No newline at end of file
+# @end expo-cli
+
+out
\ No newline at end of file
diff --git a/App.js b/App.js
deleted file mode 100644
index 4d61827a..00000000
--- a/App.js
+++ /dev/null
@@ -1,196 +0,0 @@
-import React, { useState, useEffect, useRef } from "react"
-import { Appearance, Image, Linking, TouchableOpacity } from "react-native"
-import { SafeAreaProvider } from "react-native-safe-area-context"
-import { navigate, logoAssets } from "./navigation"
-import * as Font from "expo-font"
-import * as Device from "expo-device"
-import * as Notifications from "expo-notifications"
-import { Strings, Fonts } from "./utils/constants"
-import * as eva from "@eva-design/eva"
-import { ApplicationProvider, Icon, IconRegistry, Text } from "@ui-kitten/components"
-import { EvaIconsPack } from "@ui-kitten/eva-icons"
-import { DailyBread as bread } from "./theme"
-import { default as mapping } from "./mapping.json"
-import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native"
-import { createStackNavigator } from "@react-navigation/stack"
-import { ThemeContext } from "./theme-context"
-import { decode } from "html-entities"
-import Model from "./utils/model"
-import { APIKEY, MESSAGING_SENDER_ID, APP_ID, MEASUREMENT_ID, SERVICE_ACCOUNT_ID, TECH_PASSWORD } from "@env"
-import { Author, Home, Post, Section, Search } from "./components/screens"
-import { getMostCommonTagsFromRecentPosts } from "./utils/format"
-import { enableAnimationExperimental, onShare, registerForPushNotificationsAsync } from "./utils/action"
-
-Notifications.setNotificationHandler({
- handleNotification: async () => ({
- shouldShowAlert: true,
- shouldPlaySound: false,
- shouldSetBadge: false
- })
-})
-
-const firebaseConfig = {
- apiKey: APIKEY,
- authDomain: "daily-mobile-app-notifications.firebaseapp.com",
- databaseURL: "https://daily-mobile-app-notifications-default-rtdb.firebaseio.com",
- projectId: "daily-mobile-app-notifications",
- storageBucket: "daily-mobile-app-notifications.appspot.com",
- messagingSenderId: MESSAGING_SENDER_ID,
- appId: APP_ID,
- measurementId: MEASUREMENT_ID,
- serviceAccountId: SERVICE_ACCOUNT_ID
-}
-
-const Stack = createStackNavigator()
-enableAnimationExperimental()
-
-export default function App() {
- const [fontsLoaded, setFontsLoaded] = useState(false)
- const [expoPushToken, setExpoPushToken] = useState("")
- const [notification, setNotification] = useState(false)
- const notificationListener = useRef()
- const responseListener = useRef()
- const colorScheme = Appearance.getColorScheme()
- const [theme, setTheme] = useState(colorScheme)
- const [deviceType, setDeviceType] = useState(Device.DeviceType.PHONE)
- const [searchVisible, setSearchVisible] = useState(false)
- const [configValidated, setConfigValidated] = useState(false)
- const [tags, setTags] = useState([])
-
- const toggleTheme = () => {
- const next = theme === "light" ? "dark" : "light"
- setTheme(next)
- }
-
- const navigatorTheme = {
- light: DefaultTheme,
- dark: DarkTheme
- }
-
- const headerOptions = ({ navigation, route }) => {
- return {
- headerTitle: () => (
-
- ),
- headerRight: () => !searchVisible && (
- navigation.navigate(Strings.search, { tags })}>
-
-
- )
- };
- };
-
- const detailHeaderOptions = ({ navigation, route }) => {
- return {
- headerTitle: "",
- headerTransparent: true,
- headerTintColor: "white",
- headerBackTitleVisible: false,
- headerRight: () => (
- onShare(route.params.article.link, decode(route.params.article.title.rendered))}>
-
-
- )
- }
- }
-
- const sectionOptions = ({ route }) => ({
- headerTitle: () => {decode(route.params.category.name).replace("'", "\u{2019}")},
- headerTitleStyle: { fontFamily: "MinionProBold" },
- headerTintColor: bread[theme]["color-primary-500"]
- })
-
- const authorOptions = ({ route }) => ({
- headerTitle: () => {route.params.name},
- headerTitleStyle: { fontFamily: "MinionProBold" },
- headerTintColor: bread[theme]["color-primary-500"]
- })
-
- const searchHeaderOptions = {
- headerTintColor: bread[theme]["color-primary-500"]
- }
-
- useEffect(() => {
- // Loads fonts from static resource.
- Font.loadAsync(Fonts.minion).then(() => setFontsLoaded(true))
-
- /*if (firebase) {
- registerForPushNotificationsAsync().then(token => {
- setExpoPushToken(token)
- var matches = token?.match(/\[(.*?)\]/)
-
- if (matches) {
- var submatch = matches[1]
- signInWithEmailAndPassword(firebase.auth, "tech@stanforddaily.com", TECH_PASSWORD).then((userCredential) => {
- const tokenRef = ref(firebase.db, "ExpoPushTokens/" + submatch, userCredential)
- set(tokenRef, new Date().toISOString()).catch(error => console.log(error))
- }).catch(error => console.trace(error))
- }
- })
- }*/
-
- Device.getDeviceTypeAsync().then(type => setDeviceType(type))
-
- getMostCommonTagsFromRecentPosts(100, 10).then(tags => setTags(tags))
-
- // Handles any event in which appearance preferences change.
- Appearance.addChangeListener(listener => {
- setTheme(listener.colorScheme)
- // TODO: Add return function for removing listener when user opts out of automatic theme changes.
- })
-
- // This listener is fired whenever a notification is received while the app is foregrounded.
- notificationListener.current = Notifications.addNotificationReceivedListener(notification => {
- setNotification(notification)
- })
-
- // This listener is fired whenever a user taps on or interacts with a notification.
- responseListener.current = Notifications.addNotificationResponseReceivedListener(response => {
- // Works when app is foregrounded, backgrounded or killed.
- Model.posts().id(response.notification.request.trigger.payload.body.postID).embed().then(result => {
- navigate(Strings.post, { item: result })
- })
- })
-
- // FIXME: Listener for when app is opened from web browser.
- Linking.addEventListener("url", response => {
- if (response.url) {
- const url = response.url
- const slug = url.split("/").pop()
- if (slug?.length > 0) {
- Model.posts().slug(slug).embed().then(result => {
- navigate(Strings.post, { item: result })
- })
- }
- }
- })
-
- return () => {
- Notifications.removeNotificationSubscription(notificationListener.current)
- Notifications.removeNotificationSubscription(responseListener.current)
- }
- }, [])
-
-
- return fontsLoaded && (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/App.jsx b/App.jsx
new file mode 100644
index 00000000..9a48b685
--- /dev/null
+++ b/App.jsx
@@ -0,0 +1,267 @@
+import { TECH_PASSWORD, FIREBASE_PASSWORD } from "@env";
+import * as eva from "@eva-design/eva";
+import { DarkTheme, DefaultTheme, NavigationContainer } from "@react-navigation/native";
+import { createStackNavigator } from "@react-navigation/stack";
+import { ApplicationProvider, Icon, IconRegistry, Text } from "@ui-kitten/components";
+import { EvaIconsPack } from "@ui-kitten/eva-icons";
+import * as Device from "expo-device";
+import * as Font from "expo-font";
+import * as Notifications from "expo-notifications";
+import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
+import { ref, runTransaction } from "firebase/database";
+import { decode } from "html-entities";
+import React, { useEffect, useRef, useState } from "react";
+import { Appearance, Image, Linking, TouchableOpacity } from "react-native";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+
+import { Author, Home, Post, Search, Section } from "./components/screens";
+import { useFirebase } from "./hooks/useFirebase";
+import mapping from "./mapping.json";
+import { logoAssets, navigate } from "./navigation";
+import { DailyBread as bread } from "./theme";
+import { ThemeContext } from "./theme-context";
+import { enableAnimationExperimental, onShare, registerForPushNotificationsAsync } from "./utils/action";
+import { Fonts, Routing } from "./utils/constants";
+import { getMostCommonTagsFromRecentPosts } from "./utils/format";
+import Model from "./utils/model";
+
+Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldShowAlert: true,
+ shouldPlaySound: false,
+ shouldSetBadge: false,
+ }),
+});
+
+const Stack = createStackNavigator();
+enableAnimationExperimental();
+
+export default function App() {
+ const [fontsLoaded, setFontsLoaded] = useState(false);
+ const [expoPushToken, setExpoPushToken] = useState("");
+ const [notification, setNotification] = useState(false);
+ const notificationListener = useRef();
+ const responseListener = useRef();
+ const colorScheme = Appearance.getColorScheme();
+ const [theme, setTheme] = useState(colorScheme);
+ const [deviceType, setDeviceType] = useState(Device.DeviceType.PHONE);
+ const [tags, setTags] = useState([]);
+ const [sessionViews, setSessionViews] = useState({});
+
+ const toggleTheme = () => {
+ const next = theme === "light" ? "dark" : "light";
+ setTheme(next);
+ };
+
+ const navigatorTheme = {
+ light: DefaultTheme,
+ dark: DarkTheme,
+ };
+
+ const headerOptions = ({ navigation }) => ({
+ headerTitle: () => (
+
+ ),
+ headerRight: () => (
+ navigation.navigate(Routing.search, { tags })}>
+
+
+ ),
+ });
+
+ const detailHeaderOptions = ({ route }) => ({
+ headerTitle: "",
+ headerTransparent: true,
+ headerTintColor: "white",
+ headerBackTitleVisible: false,
+ headerRight: () => (
+ onShare(route.params.article.link, decode(route.params.article.title.rendered))}
+ >
+
+
+ ),
+ });
+
+ const sectionOptions = ({ route }) => ({
+ headerTitle: () => {decode(route.params.category.name).replace("'", "\u{2019}")},
+ headerTitleStyle: { fontFamily: "MinionProBold" },
+ headerTintColor: bread[theme]["color-primary-500"],
+ });
+
+ const authorOptions = ({ route }) => ({
+ headerTitle: () => {route.params.name},
+ headerTitleStyle: { fontFamily: "MinionProBold" },
+ headerTintColor: bread[theme]["color-primary-500"],
+ });
+
+ const searchHeaderOptions = {
+ headerTintColor: bread[theme]["color-primary-500"],
+ };
+
+ // const { app, database } = useFirebase(expoPushToken, FIREBASE_PASSWORD);
+
+ // Handles changes in the navigation state (as received from `NavigationContainer`) and logs them to a Firebase database.
+ const handleNavigationChange = async (state) => {
+ /*
+ if (!app || !state || !state.routes) return;
+ const auth = getAuth(app);
+
+ try {
+ await signInWithEmailAndPassword(auth, "tech@stanforddaily.com", TECH_PASSWORD);
+
+ const currentRoute = state.routes[state.index];
+ const currentView = currentRoute?.name;
+ const currentRouteParams = currentRoute?.params;
+
+ if (!currentView) return;
+
+ const datetime = new Date();
+ const year = datetime.getFullYear();
+ const month = String(datetime.getMonth() + 1).padStart(2, "0");
+ const day = String(datetime.getDate()).padStart(2, "0");
+
+ let currentViewPath = `Analytics/${year}/${month}/${day}/${currentView}`;
+ let viewIdentifier; // Used to track unique views for each screen in the sessions map.
+ let routeParamIdentifier;
+
+ // Leverage information about the current view to construct a unique identifier for use in Firebase.
+ switch (currentView) {
+ case Routing.post:
+ routeParamIdentifier = currentRouteParams?.article?.id; // Unique to the post being presented in the detail view.
+ break;
+ case Routing.section:
+ routeParamIdentifier = currentRouteParams?.category?.id; // Unique to the section being presented in the detail view.
+ break;
+ case Routing.author:
+ routeParamIdentifier = currentRouteParams?.id; // Unique to the author being presented in the detail view.
+ break;
+ default:
+ viewIdentifier = currentView;
+ }
+
+ if (routeParamIdentifier) {
+ // After the switch, if there is a `routeParamIdentifier`, it appends that to the `currentViewPath` string.
+ currentViewPath += `/${routeParamIdentifier}`;
+ viewIdentifier = `${currentView}/${routeParamIdentifier}`;
+ } else if (!viewIdentifier) {
+ // Otherwise, the default case must have been triggered, so it just uses the view identifier.
+ viewIdentifier = currentView;
+ }
+
+ const impressionsRef = ref(database, `${currentViewPath}/impressions`);
+ runTransaction(impressionsRef, (impressions) => {
+ return (impressions || 0) + 1;
+ });
+
+ if (!sessionViews[viewIdentifier]) {
+ const sessionsRef = ref(database, `${currentViewPath}/sessions`);
+ runTransaction(sessionsRef, (sessions) => {
+ return (sessions || 0) + 1;
+ });
+
+ // Update view information for future reference.
+ setSessionViews((prevSessionViews) => {
+ return { ...prevSessionViews, [viewIdentifier]: true };
+ });
+ }
+ } catch (error) {
+ console.trace(error);
+ }
+ */
+ };
+
+ // Triggered when the app is opened from a web browser. It extracts the slug from the URL and navigates to the corresponding post.
+ const handleOpenURL = async (event) => {
+ // FIXME: Listener for when app is opened from web browser.
+ const slug = event?.url?.split("/")?.pop();
+ if (slug?.length > 0) {
+ const result = await Model.posts().embed().slug(slug).embed();
+ navigate(Routing.post, { item: result });
+ }
+ };
+
+ useEffect(() => {
+ // Loads fonts from static resource.
+ Font.loadAsync(Fonts.minion).then(() => setFontsLoaded(true));
+
+ registerForPushNotificationsAsync().then((token) => {
+ const matches = token?.match(/\[(.*?)\]/);
+ if (matches) {
+ const submatch = matches[1];
+ setExpoPushToken(submatch);
+ }
+ });
+
+ Device.getDeviceTypeAsync().then((type) => setDeviceType(type));
+
+ getMostCommonTagsFromRecentPosts(100, 10).then((tags) => setTags(tags));
+
+ // Handles any event in which appearance preferences change.
+ const themeListener = Appearance.addChangeListener((listener) => {
+ if (theme !== listener.colorScheme) {
+ setTheme(listener.colorScheme);
+ }
+ });
+
+ // This listener is fired whenever a notification is received while the app is foregrounded.
+ notificationListener.current = Notifications.addNotificationReceivedListener((notification) => {
+ setNotification(notification);
+ });
+
+ // This listener is fired whenever a user taps on or interacts with a notification.
+ responseListener.current = Notifications.addNotificationResponseReceivedListener((response) => {
+ // Works when app is foregrounded, backgrounded or killed.
+ Model.posts()
+ .id(response.notification.request.trigger.payload.body.postID)
+ .embed()
+ .get()
+ .then((result) => {
+ navigate(Routing.post, { item: result });
+ });
+ });
+
+ // Perform initial URL check in case the app was closed when the user opened a URL.
+ Linking.getInitialURL().then((url) => {
+ if (url) {
+ handleOpenURL({ url });
+ }
+ });
+
+ Linking.addEventListener("url", handleOpenURL);
+
+ return () => {
+ themeListener.remove();
+ Notifications.removeNotificationSubscription(notificationListener.current);
+ Notifications.removeNotificationSubscription(responseListener.current);
+ // Linking.removeEventListener("url", handleOpenURL); Seemed to be causing crashes.
+ };
+ }, [theme]);
+
+ return (
+ fontsLoaded && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ );
+}
diff --git a/app.json b/app.json
index 8b81c858..fa1ad982 100644
--- a/app.json
+++ b/app.json
@@ -4,7 +4,7 @@
"description": "The Stanford Daily Mobile App",
"slug": "StanfordDaily",
"privacy": "public",
- "version": "2.3.1",
+ "version": "2.3.2",
"platforms": [
"ios",
"android"
@@ -28,14 +28,14 @@
],
"ios": {
"bundleIdentifier": "com.Stanford.Daily.App",
- "buildNumber": "28",
+ "buildNumber": "29",
"appStoreUrl": "https://itunes.apple.com/app/stanford-daily/id1341270063",
"supportsTablet": true
},
"android": {
"package": "com.Stanford.Daily.App",
"googleServicesFile": "./google-services.json",
- "versionCode": 28,
+ "versionCode": 29,
"playStoreUrl": "https://play.google.com/store/apps/details?id=com.Stanford.Daily.App",
"permissions": [
"ACCESS_COARSE_LOCATION",
@@ -46,7 +46,17 @@
"icon": "./assets/images/icon.png"
},
"plugins": [
- "expo-build-properties"
+ ["expo-build-properties",
+ {
+ "android": {
+ "compileSdkVersion": 31,
+ "targetSdkVersion": 31,
+ "buildToolsVersion": "31.0.0"
+ },
+ "ios": {
+ "deploymentTarget": "13.0"
+ }
+ }]
],
"extra": {
"eas": {
diff --git a/components/README.md b/components/README.md
new file mode 100644
index 00000000..f8e5059e
--- /dev/null
+++ b/components/README.md
@@ -0,0 +1,104 @@
+# Components
+
+## Carousel
+The `Carousel` component displays a horizontal scrollable list of articles, chiefly featured ones.
+Its content panels are `Card` components that display a large image and details like title, date and image below.
+
+**Component**:
+**Properties**
+
+| Name | Type | Description |
+| --- | --- | --- |
+| navigation | Object
| The navigation object from React Navigation. |
+| articles | Array
| An array of articles to be displayed in the carousel. |
+
+**Example**
+```js
+
+```
+
+## Mark
+`Mark` is large section signpost in all caps.
+
+This component displays the name of a section and provides navigation to a screen with more articles from that section.
+The `TouchableOpacity` makes the entire component tappable and changes its opacity as an indication of impression.
+
+The component's background color and text color may change depending on the `alternate` prop.
+If set to `true`, the background takes on the accent color typically used for the Humor section on the website.
+
+**Component**:
+**Properties**
+
+| Name | Type | Description |
+| --- | --- | --- |
+| navigation | Object
| Passed from the `Home` screen, which automatically receives the navigation object from React Navigation. |
+| alternate | boolean
| A flag indicating whether to use an accent color. Primarily intended for displaying Humor section. |
+| seed | Array
| A collection of articles from the `Home` screen that can be immediately displayed upon navigation to the detail view. |
+| category | Object
| The category object representing the section. It should have a `name` property like "News" or "The Grind." |
+
+## Polyptych
+Displays a set of articles in a paginated layout.
+
+`Polyptych` groups the articles into pages and displays each page as a row of cards.
+Each card represents an article and includes an image, the article's title, and the date it was published.
+The image is displayed at the top of the card, the title is displayed in the middle, and the date is displayed at the bottom.
+
+The number of cards in each row depends on the device type.
+If the device is a phone, there are two cards per row.
+If the device is a tablet or desktop, there are three cards per row.
+
+The user can navigate between pages by swiping left or right.
+The currently selected page is stored in the `selection` state variable but is not used for anything yet in particular.
+When a card is tapped, the component navigates to the `Post` screen and passes the corresponding article as a parameter.
+
+Canonically, a polyptych is an altarpiece of multiple panels that are joined by hinges. The name is a metaphor.
+
+**Properties**
+
+| Name | Type | Description |
+| --- | --- | --- |
+| articles | Array.<Object>
| The array of article objects to display. Expected to be non-null. |
+| navigation | Object
| The navigation object used for navigating between screens. |
+
+**Example**
+```js
+
+```
+
+## Wildcard
+Displays a `Card` preview for an article or some other related form of content.
+
+**Component**:
+**Properties**
+
+| Name | Type | Description |
+| --- | --- | --- |
+| navigation | Object
| The navigation object used for navigating between screens. |
+| verbose | boolean
| A flag indicating whether to include the excerpt in the card. |
+| title | string
| The headline. |
+| item | Object
| The article object. |
+| index | number
| The index of the article in a parent view. Currently unused in the implementation. |
+
+## Header
+`Header` is a sub-component of `Wildcard` that displays the article title, date, and image.
+
+**Component**:
+**Properties**
+
+| Name | Type | Description |
+| --- | --- | --- |
+| title | string
| The title of the article. |
+| verbose | boolean
| A flag indicating whether to include the excerpt in the card. |
+| date | string
| The publication date. |
+| uri | string
| The URI for the feature image of the article. |
+
+## Footer
+`Footer` is a sub-component of `Wildcard` that displays the article byline and section.
+
+**Component**:
+**Properties**
+
+| Name | Type | Description |
+| --- | --- | --- |
+| byline | string
| The author's name(s) in a displayable format. |
+| section | string
| The name of the section to which the article belongs. |
diff --git a/components/common/Byline.js b/components/common/Byline.js
deleted file mode 100644
index 725d80f6..00000000
--- a/components/common/Byline.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import React, { useEffect, useState } from "react"
-import { TouchableOpacity, StyleSheet, View } from "react-native"
-import { Button, Text, Icon, useTheme } from "@ui-kitten/components"
-import { Spacing } from "../../utils/constants"
-import { decode } from "html-entities"
-
-export default function Byline({ authors, section, sourceName, category, date, navigation }) {
- const entries = Object.entries(authors)
- const bylineFontSize = entries.length < 3 ? 16 : 14
- const [sameCategory, setSameCategory] = useState(false)
- const theme = useTheme()
-
-
- const Name = ({ detail }) => (
- navigation.navigate("Author", { name: detail[0], id: detail[1] })}>
- {detail[0]}
-
- )
-
- return (
-
-
-
- {entries[0][1] != 16735 && (By )}
- {entries.map((entry, index) => ).reduce((p, q, index) => [p, {index < entries.length - 1 ? ", " : " and "}, q])}
-
- {date}
-
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- byline: {
- flexDirection: "row",
- paddingTop: Spacing.medium,
- justifyContent: "space-between",
- alignItems: "center"
- }
-})
\ No newline at end of file
diff --git a/components/common/Byline.jsx b/components/common/Byline.jsx
new file mode 100644
index 00000000..5f328685
--- /dev/null
+++ b/components/common/Byline.jsx
@@ -0,0 +1,73 @@
+import { Button, Text } from "@ui-kitten/components";
+import { decode } from "html-entities";
+import React from "react";
+import { TouchableOpacity, StyleSheet, View } from "react-native";
+
+import { Spacing, Sections } from "../../utils/constants";
+
+const includesDesks = (category) => {
+ return category.desks && Object.keys(category.desks).length > 0;
+};
+
+export default function Byline({ authors, section, sourceName, category, date, navigation }) {
+ const entries = Object.entries(authors);
+ const bylineFontSize = entries.length < 3 ? 16 : 14;
+ const richCategory = Object.values(Sections).find((value) => value.id === category.id) ?? category;
+ const resolvedCategory = [3, 23, 25].includes(category.id) && includesDesks(richCategory) ? richCategory : category;
+
+ const Name = ({ detail }) => (
+ navigation.navigate("Author", { name: detail[0], id: detail[1] })}>
+
+ {detail[0]}
+
+
+ );
+
+ return (
+
+
+
+ {entries[0][1] !== 16735 && (
+
+ By{" "}
+
+ )}
+ {entries
+ .map((entry, index) => )
+ .reduce((p, q, index) => [
+ p,
+
+ {index < entries.length - 1 ? ", " : " and "}
+ ,
+ q,
+ ])}
+
+ {date}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ byline: {
+ flexDirection: "row",
+ paddingTop: Spacing.medium,
+ justifyContent: "space-between",
+ alignItems: "center",
+ },
+});
diff --git a/components/common/CachedImage.js b/components/common/CachedImage.jsx
similarity index 100%
rename from components/common/CachedImage.js
rename to components/common/CachedImage.jsx
diff --git a/components/common/Canvas.js b/components/common/Canvas.jsx
similarity index 65%
rename from components/common/Canvas.js
rename to components/common/Canvas.jsx
index 825b35af..4063da6a 100644
--- a/components/common/Canvas.js
+++ b/components/common/Canvas.jsx
@@ -1,20 +1,20 @@
-import React from "react"
-import { Dimensions, Image, PixelRatio, StyleSheet, Text, View } from "react-native"
+import React from "react";
+import { Dimensions, Image, PixelRatio, StyleSheet, Text, View } from "react-native";
// import Carousel from "react-native-snap-carousel"
-const { width, height } = Dimensions.get("window")
-const pixelRatio = PixelRatio.get()
+const { width, height } = Dimensions.get("window");
+const pixelRatio = PixelRatio.get();
export default function Canvas({ navigation, articles }) {
- return (
-
- {/*
+ {/* }
sliderWidth={width}
itemWidth={width}
layout="tinder"
/> */}
-
- )
+
+ );
}
diff --git a/components/common/Carousel.js b/components/common/Carousel.js
deleted file mode 100644
index 68d2400f..00000000
--- a/components/common/Carousel.js
+++ /dev/null
@@ -1,104 +0,0 @@
-import React, { useContext, useState } from "react"
-import { Card, Button, Layout, Text, useTheme } from "@ui-kitten/components"
-import { Dimensions, View, StyleSheet, PixelRatio, Platform } from "react-native"
-import { Image } from "expo-image"
-import PagerView from "react-native-pager-view"
-import moment from "moment"
-import _ from "lodash"
-import { decode } from "html-entities"
-import { itemize } from "../../utils/format"
-import { Spacing } from "../../utils/constants"
-import { ThemeContext } from "../../theme-context"
-import { DeviceType } from "expo-device"
-
-const { width } = Dimensions.get("window")
-const pixelRatio = PixelRatio.get()
-
-function Carousel(props) {
- const { navigation, articles } = props
- const { theme, deviceType } = useContext(ThemeContext)
- const carouselHeight = deviceType === DeviceType.PHONE ? 300 : 350
- const accentColor = useTheme()["color-primary-600"]
-
- // Seems like the card suddenly stopped rounding off the top corners of the image automatically.
- // Might have to do with the dynamic styling of the border below.
- const Header = ({ source }) => {
- return (
-
- )
- }
-
- const Footer = (props) => (
-
- {itemize(Object.keys(props.authors).map(name => name.toUpperCase()))}
-
-
- )
-
- return (
-
- {articles?.map((item, index) => (
-
- }
- footer={
- ))}
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- height: 300,
- paddingHorizontal: Spacing.medium,
- paddingVertical: 4,
- marginTop: Spacing.medium
- },
- tab: {
- height: 192,
- alignItems: "center",
- justifyContent: "center"
- },
- card: {
- flex: 1,
- margin: 2
- },
- headerTextContainer: {
- paddingHorizontal: 20,
- paddingVertical: 10
- },
- headerImage: {
- width: 300,
- height: 200
- },
- footerContainer: {
- flexDirection: "row",
- justifyContent: "flex-end",
- paddingHorizontal: 20,
- paddingVertical: 20
- },
- footerControl: {
- marginHorizontal: 4
- },
- footer: {
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "center",
- paddingHorizontal: 10,
- paddingVertical: 5
- }
-})
-
-export default React.memo(Carousel)
\ No newline at end of file
diff --git a/components/common/Carousel.jsx b/components/common/Carousel.jsx
new file mode 100644
index 00000000..19ccf1f1
--- /dev/null
+++ b/components/common/Carousel.jsx
@@ -0,0 +1,126 @@
+import { Button, Card, Text, useTheme } from "@ui-kitten/components";
+import { DeviceType } from "expo-device";
+import { Image } from "expo-image";
+import { decode } from "html-entities";
+import moment from "moment";
+import React, { useContext } from "react";
+import { Dimensions, PixelRatio, StyleSheet, View } from "react-native";
+import PagerView from "react-native-pager-view";
+
+import { ThemeContext } from "../../theme-context";
+import { Spacing } from "../../utils/constants";
+import { itemize } from "../../utils/format";
+
+const { width } = Dimensions.get("window");
+const pixelRatio = PixelRatio.get();
+
+/**
+ * The `Carousel` component displays a horizontal scrollable list of articles, chiefly featured ones.
+ * Its content panels are `Card` components that display a large image and details like title, date and image below.
+ *
+ * @component
+ * @property {Object} navigation - The navigation object from React Navigation.
+ * @property {Array} articles - An array of articles to be displayed in the carousel.
+ *
+ * @example
+ *
+ */
+function Carousel({ navigation, articles }) {
+ const { theme, deviceType } = useContext(ThemeContext);
+ const carouselHeight = deviceType === DeviceType.PHONE ? 300 : 350;
+ const accentColor = useTheme()["color-primary-600"];
+ const borderColor = theme === "light" ? "#E7EBF3" : "transparent";
+
+ const Header = ({ media }) => (
+
+ );
+
+ const Footer = ({ authors, section }) => (
+
+
+ {itemize(Object.keys(authors).map((name) => name.toUpperCase()))}
+
+
+
+ );
+
+ return (
+
+ {articles?.map((item, index) => (
+
+ }
+ footer={
+
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ height: 300,
+ paddingHorizontal: Spacing.medium,
+ paddingVertical: 4,
+ marginTop: Spacing.medium,
+ },
+ tab: {
+ height: 192,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ card: {
+ flex: 1,
+ margin: 2,
+ },
+ headerTextContainer: {
+ paddingHorizontal: 20,
+ paddingVertical: 10,
+ },
+ headerImage: {
+ width: 300,
+ height: 200,
+ },
+ footerContainer: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ paddingHorizontal: 20,
+ paddingVertical: 20,
+ },
+ footerControl: {
+ marginHorizontal: 4,
+ },
+ footer: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingHorizontal: 10,
+ paddingVertical: 5,
+ },
+});
+
+export default React.memo(Carousel);
diff --git a/components/common/Cloud.js b/components/common/Cloud.js
deleted file mode 100644
index 5cbde9a6..00000000
--- a/components/common/Cloud.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import React from "react"
-import { View, StyleSheet, Image, TouchableOpacity } from "react-native"
-
-export default function Cloud({ navigation, tags }) {
- return (
-
- yet to be implemented
-
- )
-}
\ No newline at end of file
diff --git a/components/common/Diptych.js b/components/common/Diptych.js
deleted file mode 100644
index 7d4aeae3..00000000
--- a/components/common/Diptych.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import React, { useContext, useState } from "react"
-import { Card, Text } from "@ui-kitten/components"
-import { Dimensions, Image, View, StyleSheet, PixelRatio } from "react-native"
-import PagerView from "react-native-pager-view"
-import moment from "moment"
-import _ from "lodash"
-import { decode } from "html-entities"
-import { Spacing } from "../../utils/constants"
-import { ThemeContext } from "../../theme-context"
-import * as Device from "expo-device"
-
-const { width, height } = Dimensions.get("window")
-const pixelRatio = PixelRatio.get()
-
-export default function Diptych(props) {
- const articles = props.articles.length % 2 === 0 ? props.articles : props.articles.slice(0, -1)
- const [selection, setSelection] = useState(0)
- const { deviceType } = useContext(ThemeContext)
- const groupSize = deviceType === Device.DeviceType.PHONE ? 2 : 3
-
- const Header = (props) => (
-
-
-
- )
-
- const Footer = (props) => (
-
- {moment(new Date(props.date)).fromNow().toUpperCase()}
-
- )
-
- return (
- setSelection(e.nativeEvent.position)} overdrag>
- {_.chunk(articles, groupSize).map((couplet, index) => (
-
- {couplet.map((item, _index) => (
- }
- footer={
- ))}
-
- ))}
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- height: 275, // Maybe use PixelRatio here?
- paddingHorizontal: Spacing.medium,
- paddingVertical: 4
- },
- tab: {
- height: 192,
- alignItems: "center",
- justifyContent: "center"
- },
- card: {
- flex: 1,
- height: 275, // Maybe use PixelRatio here?
- marginHorizontal: 5
- },
- headerTextContainer: {
- paddingHorizontal: 20,
- paddingVertical: 10
- },
- headerImage: {
- width: 300,
- height: 200
- },
- footerContainer: {
- flexDirection: "row",
- justifyContent: "flex-end",
- paddingHorizontal: 20,
- paddingVertical: 20
- },
- footerControl: {
- marginHorizontal: 4
- }
-})
\ No newline at end of file
diff --git a/components/common/HTML.js b/components/common/HTML.jsx
similarity index 100%
rename from components/common/HTML.js
rename to components/common/HTML.jsx
diff --git a/components/common/Mark.js b/components/common/Mark.js
deleted file mode 100644
index c8302c43..00000000
--- a/components/common/Mark.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from "react"
-import { StyleSheet, View, TouchableOpacity } from "react-native"
-import { Text, useTheme } from "@ui-kitten/components"
-
-export default function Mark({ navigation, alternate, seed, category }) {
- const theme = useTheme()
-
- return (
- navigation?.navigate("Section", { category: category, seed: seed })}
- style={[styles.container, { backgroundColor: theme[alternate ? "color-primary-600" : "background-color-basic-1"] }]}>
-
- {" \u276f"}
- {category.name.toUpperCase()}
- {navigation && {" \u276f"}}
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- display: "flex",
- padding: 5,
- width: "100%",
- height: 42,
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "center"
- }
-})
\ No newline at end of file
diff --git a/components/common/Mark.jsx b/components/common/Mark.jsx
new file mode 100644
index 00000000..7f21dcde
--- /dev/null
+++ b/components/common/Mark.jsx
@@ -0,0 +1,54 @@
+import { Text, useTheme } from "@ui-kitten/components";
+import React from "react";
+import { StyleSheet, TouchableOpacity, View } from "react-native";
+
+/**
+ * `Mark` is large section signpost in all caps.
+ *
+ * This component displays the name of a section and provides navigation to a screen with more articles from that section.
+ * The `TouchableOpacity` makes the entire component tappable and changes its opacity as an indication of impression.
+ *
+ * The component's background color and text color may change depending on the `alternate` prop.
+ * If set to `true`, the background takes on the accent color typically used for the Humor section on the website.
+ *
+ * @component
+ * @property {Object} navigation Passed from the `Home` screen, which automatically receives the navigation object from React Navigation.
+ * @property {boolean} alternate A flag indicating whether to use an accent color. Primarily intended for displaying Humor section.
+ * @property {Array} seed A collection of articles from the `Home` screen that can be immediately displayed upon navigation to the detail view.
+ * @property {Object} category The category object representing the section. It should have a `name` property like "News" or "The Grind."
+ * @exports Mark
+ */
+export default function Mark({ navigation, alternate, seed, category, desks }) {
+ const theme = useTheme();
+
+ return (
+ navigation?.navigate("Section", { category, desks, seed })}
+ style={{
+ ...styles.container,
+ backgroundColor: theme[alternate ? "color-primary-600" : "background-color-basic-1"],
+ }}
+ >
+
+ {" \u276f"}
+
+ {category.name.toUpperCase()}
+
+ {navigation && {" \u276f"}}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ display: "flex",
+ padding: 5,
+ width: "100%",
+ height: 42,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/components/common/Polyptych.jsx b/components/common/Polyptych.jsx
new file mode 100644
index 00000000..c1d16894
--- /dev/null
+++ b/components/common/Polyptych.jsx
@@ -0,0 +1,127 @@
+import { Card, Text } from "@ui-kitten/components";
+import * as Device from "expo-device";
+import { decode } from "html-entities";
+import _ from "lodash";
+import moment from "moment";
+import React, { useContext, useState } from "react";
+import { Dimensions, Image, View, StyleSheet, PixelRatio } from "react-native";
+import PagerView from "react-native-pager-view";
+
+import { ThemeContext } from "../../theme-context";
+import { Labels, Spacing } from "../../utils/constants";
+
+const { width } = Dimensions.get("window");
+const pixelRatio = PixelRatio.get();
+
+/**
+ * Displays a set of articles in a paginated layout.
+ *
+ * `Polyptych` groups the articles into pages and displays each page as a row of cards.
+ * Each card represents an article and includes an image, the article's title, and the date it was published.
+ * The image is displayed at the top of the card, the title is displayed in the middle, and the date is displayed at the bottom.
+ *
+ * The number of cards in each row depends on the device type.
+ * If the device is a phone, there are two cards per row.
+ * If the device is a tablet or desktop, there are three cards per row.
+ *
+ * The user can navigate between pages by swiping left or right.
+ * The currently selected page is stored in the `selection` state variable but is not used for anything yet in particular.
+ * When a card is tapped, the component navigates to the `Post` screen and passes the corresponding article as a parameter.
+ *
+ * Canonically, a polyptych is an altarpiece of multiple panels that are joined by hinges. The name is a metaphor.
+ *
+ * @component
+ * @property {Array