diff --git a/.gitignore b/.gitignore index 6aa24ae..df7d03f 100644 --- a/.gitignore +++ b/.gitignore @@ -234,3 +234,6 @@ $RECYCLE.BIN/ *.lnk # End of https://www.toptal.com/developers/gitignore/api/macos,java,intellij,windows,visualstudiocode,react + +threads-*.json +google-services.json \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d0a70e2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "cSpell.words": [ + "kakao", + "likevanilla", + "Loggedin", + "Pressable", + "threadc" + ] +} \ No newline at end of file diff --git a/Jeonghyuk/threads/.gitignore b/Jeonghyuk/threads/.gitignore new file mode 100644 index 0000000..6372b8e --- /dev/null +++ b/Jeonghyuk/threads/.gitignore @@ -0,0 +1,44 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local +.env + +# typescript +*.tsbuildinfo + +app-example + +# generated native folders +/ios +/android diff --git a/Jeonghyuk/threads/.vscode/extensions.json b/Jeonghyuk/threads/.vscode/extensions.json new file mode 100644 index 0000000..b7ed837 --- /dev/null +++ b/Jeonghyuk/threads/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/Jeonghyuk/threads/.vscode/settings.json b/Jeonghyuk/threads/.vscode/settings.json new file mode 100644 index 0000000..e2798e4 --- /dev/null +++ b/Jeonghyuk/threads/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/Jeonghyuk/threads/README.md b/Jeonghyuk/threads/README.md new file mode 100644 index 0000000..48dd63f --- /dev/null +++ b/Jeonghyuk/threads/README.md @@ -0,0 +1,50 @@ +# Welcome to your Expo app ๐Ÿ‘‹ + +This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). + +## Get started + +1. Install dependencies + + ```bash + npm install + ``` + +2. Start the app + + ```bash + npx expo start + ``` + +In the output, you'll find options to open the app in a + +- [development build](https://docs.expo.dev/develop/development-builds/introduction/) +- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) +- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) +- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo + +You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). + +## Get a fresh project + +When you're ready, run: + +```bash +npm run reset-project +``` + +This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. + +## Learn more + +To learn more about developing your project with Expo, look at the following resources: + +- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). +- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. + +## Join the community + +Join our community of developers creating universal apps. + +- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. +- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. diff --git a/Jeonghyuk/threads/app.config.js b/Jeonghyuk/threads/app.config.js new file mode 100644 index 0000000..203e1a7 --- /dev/null +++ b/Jeonghyuk/threads/app.config.js @@ -0,0 +1,139 @@ +import "dotenv/config"; + +const fs = require("fs"); +const path = require("path"); + +const googleServicesPath = "./google-services.json"; +const hasGoogleServices = fs.existsSync( + path.resolve(__dirname, googleServicesPath), +); + +export default { + expo: { + name: "threads-clone", + slug: "threads-clone", + version: "1.0.0", + orientation: "portrait", + icon: "./assets/images/icon.png", + scheme: "threadc", + userInterfaceStyle: "automatic", + newArchEnabled: true, + ios: { + supportsTablet: true, + bundleIdentifier: "com.likevanilla.threads", + infoPlist: { + ITSAppUsesNonExemptEncryption: false, + }, + useAppleSignIn: true, + }, + android: { + package: "com.likevanilla.threads", + googleServicesFile: hasGoogleServices + ? googleServicesPath + : process.env.GOOGLE_SERVICES_JSON_PATH, + adaptiveIcon: { + foregroundImage: "./assets/images/android-icon-foreground.png", + backgroundColor: "#ffffff", + }, + edgeToEdgeEnabled: true, + permissions: [ + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.RECORD_AUDIO", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.ACCESS_MEDIA_LOCATION", + ], + }, + web: { + bundler: "metro", + output: "static", + favicon: "./assets/images/favicon.png", + }, + splash: { + image: "./assets/images/react-logo.png", + imageWidth: 200, + resizeMode: "contain", + backgroundColor: "#ffffff", + }, + updates: { + url: "https://u.expo.dev/a4f57aba-20f6-437b-85da-5b1217981be9", + }, + runtimeVersion: { + policy: "appVersion", + }, + plugins: [ + "expo-router", + [ + "expo-splash-screen", + { + image: "./assets/images/react-logo.png", + imageWidth: 200, + resizeMode: "contain", + backgroundColor: "#ffffff", + }, + ], + [ + "expo-location", + { + locationAlwaysAndWhenInUsePermission: + "Allow $(PRODUCT_NAME) to use your location.", + }, + ], + [ + "expo-image-picker", + { + photosPermission: + "The app accesses your photos to let you share them in your threads.", + cameraPermission: + "The app accesses your camera to let you share photos in your threads.", + }, + ], + [ + "expo-media-library", + { + photosPermission: "Allow $(PRODUCT_NAME) to access your photos.", + savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.", + isAccessMediaLocationEnabled: true, + }, + ], + "expo-secure-store", + "expo-font", + "expo-web-browser", + [ + "expo-build-properties", + { + android: { + extraMavenRepos: [ + "https://devrepo.kakao.com/nexus/content/groups/public/", + ], + }, + }, + ], + [ + "@react-native-kakao/core", + { + nativeAppKey: process.env.KAKAO_APP_KEY, + android: { + authCodeHandlerActivity: true, + }, + ios: { + handleKakaoOpenUrl: true, + }, + }, + ], + "expo-apple-authentication", + ], + experiments: { + typedRoutes: true, + }, + extra: { + nativeAppKey: process.env.KAKAO_APP_KEY, + router: {}, + eas: { + projectId: "a4f57aba-20f6-437b-85da-5b1217981be9", + }, + }, + owner: "likevanilla", + }, +}; diff --git a/Jeonghyuk/threads/app/(afterLogin)/_layout.tsx b/Jeonghyuk/threads/app/(afterLogin)/_layout.tsx new file mode 100644 index 0000000..e69de29 diff --git a/Jeonghyuk/threads/app/(tabs)/(home)/_layout.tsx b/Jeonghyuk/threads/app/(tabs)/(home)/_layout.tsx new file mode 100644 index 0000000..67d3cf1 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/(home)/_layout.tsx @@ -0,0 +1,196 @@ +import { + type MaterialTopTabNavigationEventMap, + type MaterialTopTabNavigationOptions, + createMaterialTopTabNavigator, +} from "@react-navigation/material-top-tabs"; +import { Slot, withLayoutContext } from "expo-router"; +import type { + ParamListBase, + TabNavigationState, +} from "@react-navigation/native"; +import { + Pressable, + View, + Image, + Text, + useColorScheme, + Appearance, +} from "react-native"; +import { useState } from "react"; +import { AuthContext } from "@/app/_layout"; +import { useContext } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import SideMenu from "@/components/SideMenu"; +import { BlurView } from "expo-blur"; +import { TouchableOpacity } from "react-native"; +import { router } from "expo-router"; +const { Navigator } = createMaterialTopTabNavigator(); + +export const MaterialTopTabs = withLayoutContext< + MaterialTopTabNavigationOptions, + typeof Navigator, + TabNavigationState, + MaterialTopTabNavigationEventMap +>(Navigator); + +export default function TabLayout() { + const colorScheme = useColorScheme(); + const insets = useSafeAreaInsets(); + const [isSideMenuOpen, setIsSideMenuOpen] = useState(false); + const { user } = useContext(AuthContext); + const isLoggedIn = !!user; + + return ( + + + {isLoggedIn && ( + { + setIsSideMenuOpen(true); + }} + > + + + )} + setIsSideMenuOpen(false)} + /> + + {!isLoggedIn && ( + { + console.log("loginButton onPress"); + router.navigate(`/login`); + }} + > + + ๋กœ๊ทธ์ธ!! + + + )} + + {isLoggedIn ? ( + + + + + ) : ( + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + containerLight: { + backgroundColor: "white", + }, + containerDark: { + backgroundColor: "#101010", + }, + header: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 16, + height: 50, + }, + headerLight: { + backgroundColor: "white", + }, + headerDark: { + backgroundColor: "#101010", + }, + menuButton: { + position: "absolute", + left: 16, + }, + headerLogo: { + width: 32, + height: 32, + }, + loginButton: { + padding: 8, + borderRadius: 4, + position: "absolute", + right: 16, + }, + loginButtonLight: { + backgroundColor: "black", + }, + loginButtonDark: { + backgroundColor: "white", + }, + loginButtonTextLight: { + color: "white", + }, + loginButtonTextDark: { + color: "black", + }, +}); diff --git a/Jeonghyuk/threads/app/(tabs)/(home)/following.tsx b/Jeonghyuk/threads/app/(tabs)/(home)/following.tsx new file mode 100644 index 0000000..53bd62f --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/(home)/following.tsx @@ -0,0 +1 @@ +export { default } from "."; diff --git a/Jeonghyuk/threads/app/(tabs)/(home)/index.tsx b/Jeonghyuk/threads/app/(tabs)/(home)/index.tsx new file mode 100644 index 0000000..d1ff8ff --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/(home)/index.tsx @@ -0,0 +1,108 @@ +import { + Text, + View, + TouchableOpacity, + StyleSheet, + useColorScheme, + ScrollView, +} from "react-native"; +import { useRouter } from "expo-router"; +import Post from "@/components/Post"; +export default function Index() { + const router = useRouter(); + const colorScheme = useColorScheme(); + + return ( + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + containerLight: { + backgroundColor: "white", + }, + containerDark: { + backgroundColor: "#101010", + }, + textLight: { + color: "black", + }, + textDark: { + color: "white", + }, +}); diff --git a/Jeonghyuk/threads/app/(tabs)/(post)/[username]/post/[postID].tsx b/Jeonghyuk/threads/app/(tabs)/(post)/[username]/post/[postID].tsx new file mode 100644 index 0000000..5190e33 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/(post)/[username]/post/[postID].tsx @@ -0,0 +1,184 @@ +import { + View, + Text, + StyleSheet, + useColorScheme, + ScrollView, + Image, + Pressable, +} from "react-native"; +import Post from "@/components/Post"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import SideMenu from "@/components/SideMenu"; +import { Ionicons } from "@expo/vector-icons"; +import { useState } from "react"; +import { useRouter } from "expo-router"; + +export default function PostScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const colorScheme = useColorScheme(); + const [isSideMenuOpen, setIsSideMenuOpen] = useState(false); + + return ( + + + {router.canGoBack() ? ( + { + router.back(); + }} + > + + + ) : ( + { + setIsSideMenuOpen(true); + }} + > + + + )} + + setIsSideMenuOpen(false)} + /> + + + + + + Replies + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + containerLight: { + backgroundColor: "white", + }, + containerDark: { + backgroundColor: "#101010", + }, + header: { + height: 50, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + }, + headerLight: { + backgroundColor: "white", + }, + headerDark: { + backgroundColor: "#101010", + }, + menuButton: { + position: "absolute", + left: 16, + }, + logo: { + width: 32, + height: 32, + }, + scrollView: { + flex: 1, + }, + repliesHeader: { + height: 50, + paddingLeft: 16, + borderBottomWidth: 1, + justifyContent: "center", + borderBottomColor: "#e0e0e0", + }, + repliesHeaderText: { + fontSize: 20, + fontWeight: "bold", + }, + repliesHeaderDark: { + color: "white", + }, + repliesHeaderLight: { + color: "#000", + }, +}); diff --git a/Jeonghyuk/threads/app/(tabs)/[username]/_layout.tsx b/Jeonghyuk/threads/app/(tabs)/[username]/_layout.tsx new file mode 100644 index 0000000..831ed1f --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/[username]/_layout.tsx @@ -0,0 +1,340 @@ +import { + type MaterialTopTabNavigationEventMap, + type MaterialTopTabNavigationOptions, + createMaterialTopTabNavigator, +} from "@react-navigation/material-top-tabs"; +import { withLayoutContext, useLocalSearchParams } from "expo-router"; +import type { + ParamListBase, + TabNavigationState, +} from "@react-navigation/native"; +import { + Pressable, + View, + Image, + Text, + useColorScheme, + TouchableOpacity, + Share, +} from "react-native"; +import { useState } from "react"; +import { AuthContext } from "@/app/_layout"; +import { useContext } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import SideMenu from "@/components/SideMenu"; +import EditProfileModal from "@/components/EditProfileModal"; +const { Navigator } = createMaterialTopTabNavigator(); + +export const MaterialTopTabs = withLayoutContext< + MaterialTopTabNavigationOptions, + typeof Navigator, + TabNavigationState, + MaterialTopTabNavigationEventMap +>(Navigator); + +export default function TabLayout() { + const colorScheme = useColorScheme(); + const insets = useSafeAreaInsets(); + const [isSideMenuOpen, setIsSideMenuOpen] = useState(false); + const { user } = useContext(AuthContext); + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + const isLoggedIn = !!user; + const { username } = useLocalSearchParams(); + const isOwnProfile = isLoggedIn && user?.id === username?.slice(1); + + const handleOpenEditModal = () => { + setIsEditModalVisible(true); + }; + + const handleCloseEditModal = () => setIsEditModalVisible(false); + + const handleShareProfile = async () => { + console.log("share profile"); + try { + await Share.share({ + message: `thread://@${username}`, + url: `thread://@${username}`, + }); + } catch (error) { + console.log(error); + } + }; + + return ( + + + {isLoggedIn && ( + { + setIsSideMenuOpen(true); + }} + > + + + )} + + setIsSideMenuOpen(false)} + /> + + + + + + {user?.name} + + + {user?.id} + + + {user?.description} + + + + {isOwnProfile ? ( + + + Edit profile + + + ) : ( + + + Follow + + + )} + + + Share profile + + + + + + {user && ( + + )} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 16, + height: 50, + }, + headerLight: { + backgroundColor: "white", + }, + headerDark: { + backgroundColor: "#101010", + }, + logo: { + width: 32, + height: 32, + }, + menuButton: { + position: "absolute", + left: 16, + }, + profile: { + padding: 16, + }, + profileHeader: { + flexDirection: "column", + alignItems: "flex-start", + }, + profileAvatar: { + position: "absolute", + right: 0, + width: 60, + height: 60, + borderRadius: 30, + }, + profileName: { + fontSize: 24, + fontWeight: "bold", + }, + profileNameLight: { + color: "black", + }, + profileNameDark: { + color: "white", + }, + profileTextDark: { + color: "white", + }, + profileTextLight: { + color: "black", + }, + containerLight: { + backgroundColor: "white", + }, + containerDark: { + backgroundColor: "#101010", + }, + profileActions: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 16, + marginBottom: 16, + }, + actionButton: { + flex: 1, + alignItems: "center", + justifyContent: "center", + borderRadius: 8, + paddingVertical: 8, + marginHorizontal: 4, + }, + actionButtonLight: { + backgroundColor: "white", + borderWidth: 1, + borderColor: "#333", + }, + actionButtonDark: { + backgroundColor: "#101010", + borderWidth: 1, + borderColor: "#ccc", + }, + actionButtonText: { + fontSize: 14, + fontWeight: "600", + }, + actionButtonTextLight: { + color: "#000", + }, + actionButtonTextDark: { + color: "#fff", + }, +}); diff --git a/Jeonghyuk/threads/app/(tabs)/[username]/index.tsx b/Jeonghyuk/threads/app/(tabs)/[username]/index.tsx new file mode 100644 index 0000000..0de6fd2 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/[username]/index.tsx @@ -0,0 +1,123 @@ +import { + Text, + View, + StyleSheet, + useColorScheme, + Pressable, + Image, +} from "react-native"; +import { usePathname } from "expo-router"; +import { useContext } from "react"; +import { AuthContext } from "@/app/_layout"; + +export default function Index() { + const colorScheme = useColorScheme(); + const pathname = usePathname(); + console.log(pathname); + const { user } = useContext(AuthContext); + + return ( + + {pathname === "/undefined" && ( + + + + What is new? + + + + Post + + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "white", + }, + containerDark: { + backgroundColor: "#101010", + }, + containerLight: { + backgroundColor: "white", + }, + profileAvatar: { + width: 40, + height: 40, + borderRadius: 20, + marginRight: 10, + }, + postInputContainer: { + flexDirection: "row", + alignItems: "center", + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#aaa", + }, + postButton: { + paddingVertical: 8, + paddingHorizontal: 18, + borderRadius: 22, + position: "absolute", + right: 0, + }, + postButtonLight: { + backgroundColor: "black", + }, + postButtonDark: { + backgroundColor: "white", + }, + postButtonText: { + fontSize: 16, + fontWeight: "800", + }, + postButtonTextLight: { + color: "white", + }, + postButtonTextDark: { + color: "black", + }, + postInputText: { + fontSize: 16, + fontWeight: "600", + }, + postInputTextLight: { + color: "black", + }, + postInputTextDark: { + color: "#aaa", + }, +}); diff --git a/Jeonghyuk/threads/app/(tabs)/[username]/replies.tsx b/Jeonghyuk/threads/app/(tabs)/[username]/replies.tsx new file mode 100644 index 0000000..f2bc946 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/[username]/replies.tsx @@ -0,0 +1 @@ +export { default } from "./"; diff --git a/Jeonghyuk/threads/app/(tabs)/[username]/reposts.tsx b/Jeonghyuk/threads/app/(tabs)/[username]/reposts.tsx new file mode 100644 index 0000000..f2bc946 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/[username]/reposts.tsx @@ -0,0 +1 @@ +export { default } from "./"; diff --git a/Jeonghyuk/threads/app/(tabs)/_layout.tsx b/Jeonghyuk/threads/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..6da1968 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/_layout.tsx @@ -0,0 +1,207 @@ +import { Ionicons } from "@expo/vector-icons"; +import { type BottomTabBarButtonProps } from "@react-navigation/bottom-tabs"; +import { Tabs, useRouter } from "expo-router"; +import { useContext, useRef, useState } from "react"; +import { + Animated, + Modal, + Pressable, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { AuthContext } from "../_layout"; + +const AnimatedTabBarButton = ({ + children, + onPress, + style, + ...restProps +}: BottomTabBarButtonProps) => { + const scaleValue = useRef(new Animated.Value(1)).current; + + const handlePressOut = () => { + Animated.sequence([ + Animated.spring(scaleValue, { + toValue: 1.2, + useNativeDriver: true, + speed: 200, + }), + Animated.spring(scaleValue, { + toValue: 1, + useNativeDriver: true, + speed: 200, + }), + ]).start(); + }; + + const { ref, ...propsWithoutRef } = restProps; + + return ( + + + {children} + + + ); +}; + +export default function TabLayout() { + const router = useRouter(); + const { user } = useContext(AuthContext); + const isLoggedIn = !!user; + const [isLoginModalOpen, setIsLoinModalOpen] = useState(false); + + const openLoginModal = () => { + setIsLoinModalOpen(true); + }; + + const closeLoginModal = () => { + setIsLoinModalOpen(false); + }; + + const toLoginPage = () => { + router.push("/login"); + }; + + return ( + <> + , + }} + > + null, + tabBarIcon: ({ focused }) => ( + + ), + }} + /> + null, + tabBarIcon: ({ focused }) => ( + + ), + }} + /> + { + e.preventDefault(); + if (isLoggedIn) { + router.navigate("/modal"); + } else { + openLoginModal(); + } + }, + }} + options={{ + tabBarLabel: () => null, + tabBarIcon: ({ focused }) => ( + + ), + }} + /> + { + if (!isLoggedIn) { + e.preventDefault(); + openLoginModal(); + } + }, + }} + options={{ + tabBarLabel: () => null, + tabBarIcon: ({ focused }) => ( + + ), + }} + /> + { + if (!isLoggedIn) { + e.preventDefault(); + openLoginModal(); + } + }, + }} + options={{ + tabBarLabel: () => null, + tabBarIcon: ({ focused }) => ( + + ), + }} + /> + + + + + + + Login Modal + + + + + + + + + ); +} diff --git a/Jeonghyuk/threads/app/(tabs)/activity/[tabs].tsx b/Jeonghyuk/threads/app/(tabs)/activity/[tabs].tsx new file mode 100644 index 0000000..53bd62f --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/activity/[tabs].tsx @@ -0,0 +1 @@ +export { default } from "."; diff --git a/Jeonghyuk/threads/app/(tabs)/activity/_layout.tsx b/Jeonghyuk/threads/app/(tabs)/activity/_layout.tsx new file mode 100644 index 0000000..9efb0a7 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/activity/_layout.tsx @@ -0,0 +1,3 @@ +import { Slot } from "expo-router"; + +export default Slot; diff --git a/Jeonghyuk/threads/app/(tabs)/activity/index.tsx b/Jeonghyuk/threads/app/(tabs)/activity/index.tsx new file mode 100644 index 0000000..d82ca80 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/activity/index.tsx @@ -0,0 +1,389 @@ +import { + Pressable, + Text, + TouchableOpacity, + View, + StyleSheet, + useColorScheme, + Image, + ScrollView, +} from "react-native"; +import { usePathname, useRouter } from "expo-router"; +import NotFound from "@/app/+not-found"; +import { Ionicons } from "@expo/vector-icons"; +import SideMenu from "@/components/SideMenu"; +import { useContext, useState } from "react"; +import { AuthContext } from "../../_layout"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import ActivityItem from "@/components/Activity"; + +export default function Index() { + const router = useRouter(); + const pathname = usePathname(); + const insets = useSafeAreaInsets(); + const { user } = useContext(AuthContext); + const colorScheme = useColorScheme(); + const isLoggedIn = !!user; + const [isSideMenuOpen, setIsSideMenuOpen] = useState(false); + + if ( + ![ + "/activity", + "/activity/follows", + "/activity/replies", + "/activity/mentions", + "/activity/quotes", + "/activity/reposts", + "/activity/verified", + ].includes(pathname) + ) { + return ; + } + + return ( + + + {isLoggedIn && ( + { + setIsSideMenuOpen(true); + }} + > + + + )} + + setIsSideMenuOpen(false)} + /> + + + + router.replace(`/activity`)} + > + + All + + + + + router.replace(`/activity/follows`)} + > + + Follows + + + + + router.replace(`/activity/replies`)} + > + + Replies + + + + + router.replace(`/activity/mentions`)} + > + + Mentions + + + + + router.replace(`/activity/quotes`)} + > + + Quotes + + + + + router.replace(`/activity/reposts`)} + > + + Reposts + + + + + router.replace(`/activity/verified`)} + > + + Verified + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + containerLight: { + backgroundColor: "white", + }, + containerDark: { + backgroundColor: "#101010", + }, + header: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + height: 50, + }, + headerLight: { + backgroundColor: "white", + }, + headerDark: { + backgroundColor: "#101010", + }, + menuButton: { + position: "absolute", + left: 16, + }, + tabButton: { + paddingVertical: 10, + paddingHorizontal: 15, + marginRight: 7, + borderRadius: 20, + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#aaa", + backgroundColor: "#101010", + }, + tabButtonLight: { + backgroundColor: "white", + }, + tabButtonDark: { + backgroundColor: "#101010", + }, + tabButtonActiveLight: { + backgroundColor: "#eee", + }, + tabButtonActiveDark: { + backgroundColor: "#202020", + }, + tabButtonText: { + fontSize: 16, + fontWeight: "900", + }, + tabButtonTextLight: { + color: "black", + }, + tabButtonTextDark: { + color: "white", + }, + tabBar: { + flexDirection: "row", + justifyContent: "space-between", + paddingHorizontal: 10, + }, + logo: { + width: 32, + height: 32, + }, +}); diff --git a/Jeonghyuk/threads/app/(tabs)/add.tsx b/Jeonghyuk/threads/app/(tabs)/add.tsx new file mode 100644 index 0000000..fac9960 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/add.tsx @@ -0,0 +1,15 @@ +import { Text, View } from "react-native"; + +export default function Index() { + return ( + + Edit app/add.tsx to edit this screen. + + ); +} diff --git a/Jeonghyuk/threads/app/(tabs)/search.tsx b/Jeonghyuk/threads/app/(tabs)/search.tsx new file mode 100644 index 0000000..1d84c97 --- /dev/null +++ b/Jeonghyuk/threads/app/(tabs)/search.tsx @@ -0,0 +1,169 @@ +import { Ionicons } from "@expo/vector-icons"; +import { + Pressable, + Text, + View, + StyleSheet, + TextInput, + useColorScheme, + Image, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useContext, useState } from "react"; +import { AuthContext } from "../_layout"; +import SideMenu from "@/components/SideMenu"; + +export default function Index() { + const insets = useSafeAreaInsets(); + const { user } = useContext(AuthContext); + const isLoggedIn = !!user; + const [isSideMenuOpen, setIsSideMenuOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const colorScheme = useColorScheme(); + + return ( + + + {isLoggedIn && ( + { + setIsSideMenuOpen(true); + }} + > + + + )} + + setIsSideMenuOpen(false)} + /> + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + logo: { + width: 32, + height: 32, + }, + containerLight: { + backgroundColor: "white", + }, + containerDark: { + backgroundColor: "#101010", + }, + header: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + height: 50, + }, + headerLight: { + backgroundColor: "white", + }, + headerDark: { + backgroundColor: "#101010", + }, + menuButton: { + position: "absolute", + left: 16, + }, + searchBarArea: { + flexDirection: "row", + paddingVertical: 15, + paddingHorizontal: 20, + }, + searchBarAreaLight: { + backgroundColor: "white", + }, + searchBarAreaDark: { + backgroundColor: "#202020", + }, + searchBar: { + width: "100%", + height: 50, + flexDirection: "row", + alignItems: "center", + borderWidth: 1, + borderColor: "gray", + borderRadius: 20, + paddingHorizontal: 30, + }, + searchBarLight: { + backgroundColor: "white", + }, + searchBarDark: { + backgroundColor: "black", + color: "white", + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#aaa", + }, + searchInput: { + marginLeft: 10, + }, + searchInputLight: { + color: "black", + }, + searchInputDark: { + color: "white", + }, +}); diff --git a/Jeonghyuk/threads/app/+not-found.tsx b/Jeonghyuk/threads/app/+not-found.tsx new file mode 100644 index 0000000..e75170c --- /dev/null +++ b/Jeonghyuk/threads/app/+not-found.tsx @@ -0,0 +1,9 @@ +import { Text, View } from "react-native"; + +export default function NotFound() { + return ( + + 404 Not Found + + ); +} diff --git a/Jeonghyuk/threads/app/_layout.tsx b/Jeonghyuk/threads/app/_layout.tsx new file mode 100644 index 0000000..69a7d08 --- /dev/null +++ b/Jeonghyuk/threads/app/_layout.tsx @@ -0,0 +1,318 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Stack, router } from "expo-router"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { + Alert, + View, + StyleSheet, + Image, + Animated, + Linking, +} from "react-native"; +import * as SecureStore from "expo-secure-store"; +import { StatusBar } from "expo-status-bar"; +import { Asset } from "expo-asset"; +import Constants from "expo-constants"; +import * as SplashScreen from "expo-splash-screen"; +import * as Notifications from "expo-notifications"; +import * as Device from "expo-device"; +import * as Updates from "expo-updates"; + +// First, set the handler that will cause the notification +// to show the alert +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldPlaySound: false, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +// Instruct SplashScreen not to hide yet, we want to do this manually +SplashScreen.preventAutoHideAsync().catch(() => { + /* reloading the app might trigger some race conditions, ignore them */ +}); + +export interface User { + id: string; + name: string; + profileImageUrl: string; + description: string; + link?: string; + showInstagramBadge?: boolean; + isPrivate?: boolean; +} + +export const AuthContext = createContext<{ + user: User | null; + login?: () => Promise; + logout?: () => Promise; + updateUser?: (user: User) => void; +}>({ + user: null, +}); + +function AnimatedAppLoader({ + children, + image, +}: { + children: React.ReactNode; + image: number; +}) { + const [user, setUser] = useState(null); + const [isSplashReady, setSplashReady] = useState(false); + + useEffect(() => { + async function prepare() { + await Asset.loadAsync(image); + setSplashReady(true); + } + prepare(); + }, [image]); + + const login = () => { + console.log("login"); + return fetch("/login", { + method: "POST", + body: JSON.stringify({ + username: "zerocho", + password: "1234", + }), + }) + .then((res) => { + console.log("res", res, res.status); + if (res.status >= 400) { + return Alert.alert("Error", "Invalid credentials"); + } + return res.json(); + }) + .then((data) => { + console.log("data", data); + setUser(data.user); + return Promise.all([ + SecureStore.setItemAsync("accessToken", data.accessToken), + SecureStore.setItemAsync("refreshToken", data.refreshToken), + AsyncStorage.setItem("user", JSON.stringify(data.user)), + ]); + }) + .catch(console.error); + }; + + const logout = () => { + setUser(null); + return Promise.all([ + SecureStore.deleteItemAsync("accessToken"), + SecureStore.deleteItemAsync("refreshToken"), + AsyncStorage.removeItem("user"), + ]); + }; + + const updateUser = (user: User | null) => { + setUser(user); + if (user) { + AsyncStorage.setItem("user", JSON.stringify(user)); + } else { + AsyncStorage.removeItem("user"); + } + }; + + if (!isSplashReady) { + return null; + } + + return ( + + {children} + + ); +} + +async function sendPushNotification(expoPushToken: string) { + const message = { + to: expoPushToken, + sound: "default", + title: "Original title", + body: "And here is the body", + data: { someData: "goes here" }, + }; + + await fetch("https://expo.host/--/api/v2/push/send", { + method: "POST", + headers: { + Accept: "application/json", + "Accept-encoding": "gzip, deflate", + "Concept-Type": "application/json", + }, + body: JSON.stringify(message), + }); +} + +function AnimatedSplashScreen({ + children, + image, +}: { + children: React.ReactNode; + image: number; +}) { + const [isAppReady, setAppReady] = useState(false); + const [isSplashAnimationComplete, setAnimationComplete] = useState(false); + const animation = useRef(new Animated.Value(1)).current; + const { updateUser } = useContext(AuthContext); + const [expoPushToken, setExpoPushToken] = useState(null); + const { currentlyRunning, isUpdateAvailable, isUpdatePending } = + Updates.useUpdates(); + console.log("currentlyRunning", currentlyRunning); + console.log("isUpdateAvailable", isUpdateAvailable); + console.log("isUpdatePending", isUpdatePending); + + useEffect(() => { + if (isAppReady) { + Animated.timing(animation, { + toValue: 0, + duration: 2000, + useNativeDriver: true, + }).start(() => setAnimationComplete(true)); + } + }, [isAppReady]); + + async function onFetchUpdateAsync() { + try { + if (!__DEV__) { + const update = await Updates.checkForUpdateAsync(); + + if (update.isAvailable) { + await Updates.fetchUpdateAsync(); + Alert.alert("Update available", "Please update your app", [ + { + text: "Update", + onPress: () => Updates.reloadAsync(), + }, + { text: "Cancel", style: "cancel" }, + ]); + } + } + } catch (error) { + console.error(error); + } + } + + const onImageLoaded = async () => { + try { + // ๋ฐ์ดํ„ฐ ์ค€๋น„ + await Promise.all([ + AsyncStorage.getItem("user").then((user) => { + updateUser?.(user ? JSON.parse(user) : null); + }), + // TODO: validating access token + onFetchUpdateAsync(), + ]); + await SplashScreen.hideAsync(); + const { status } = await Notifications.requestPermissionsAsync(); + if (status !== "granted") { + return Linking.openSettings(); + } + const token = await Notifications.getExpoPushTokenAsync({ + projectId: + Constants?.expoConfig?.extra?.eas?.projectId ?? + Constants?.easConfig?.projectId, + }); + console.log("token", token); + setExpoPushToken(token.data); + } catch (e) { + console.error(e); + } finally { + setAppReady(true); + } + }; + + useEffect(() => { + if (expoPushToken && Device.isDevice) { + sendPushNotification(expoPushToken); + } + }, [expoPushToken]); + + const rotateValue = animation.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }); + + return ( + + {isAppReady && children} + {!isSplashAnimationComplete && ( + + + + )} + + ); +} + +function useNotificationObserver() { + useEffect(() => { + let isMounted = true; + + function redirect(notification: Notifications.Notification) { + const url = notification.request.content.data?.url as string; + if (url && url.startsWith("threadc://")) { + Alert.alert("redirect to url", url); + router.push(url.replace("threadc://", "/") as Href); // threadc://@zerocho -> /@zerocho + // Linking.openURL(url); + } + } + + Notifications.getLastNotificationResponseAsync().then((response) => { + if (!isMounted || !response?.notification) { + return; + } + redirect(response?.notification); + }); + + const subscription = Notifications.addNotificationResponseReceivedListener( + (response) => { + redirect(response.notification); + }, + ); + + return () => { + isMounted = false; + subscription.remove(); + }; + }, []); +} + +export default function RootLayout() { + useNotificationObserver(); + return ( + + + ); +} diff --git a/Jeonghyuk/threads/app/home.tsx b/Jeonghyuk/threads/app/home.tsx new file mode 100644 index 0000000..93abeee --- /dev/null +++ b/Jeonghyuk/threads/app/home.tsx @@ -0,0 +1,5 @@ +import { Redirect } from "expo-router"; + +export default function Home() { + return ; +} diff --git a/Jeonghyuk/threads/app/login.tsx b/Jeonghyuk/threads/app/login.tsx new file mode 100644 index 0000000..1f4db36 --- /dev/null +++ b/Jeonghyuk/threads/app/login.tsx @@ -0,0 +1,114 @@ +import { Redirect, router } from "expo-router"; +import { + View, + Text, + Pressable, + StyleSheet, + Alert, + useColorScheme, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AuthContext } from "./_layout"; +import { useContext, useEffect } from "react"; +import { + getKeyHashAndroid, + initializeKakaoSDK, +} from "@react-native-kakao/core"; +import { login as kakaologin, me } from "@react-native-kakao/user"; +import * as AppleAuthentication from "expo-apple-authentication"; +import Constants from "expo-constants"; + +export default function Login() { + const colorScheme = useColorScheme(); + const insets = useSafeAreaInsets(); + const { user, login } = useContext(AuthContext); + const isLoggedIn = !!user; + + useEffect(() => { + initializeKakaoSDK(Constants.expoConfig?.extra?.kakaoAppKey as string); + }, []); + + const onAppleLogin = async () => { + try { + const credential = await AppleAuthentication.signInAsync({ + requestedScopes: [ + AppleAuthentication.AppleAuthenticationScope.FULL_NAME, + AppleAuthentication.AppleAuthenticationScope.EMAIL, + ], + }); + console.log(credential.user); + } catch (error) { + console.log(error); + } + }; + + const onKakaoLogin = async () => { + console.log(await getKeyHashAndroid()); + try { + const result = await kakaologin(); + console.log(result); + const user = await me(); + console.log(user); + } catch (error) { + console.log(error); + } + }; + + if (isLoggedIn) { + return ; + } + return ( + + router.back()}> + Back + + + Login + + + + Kakao Login + + + + Apple Login + + + ); +} + +const styles = StyleSheet.create({ + loginButton: { + backgroundColor: "blue", + padding: 10, + borderRadius: 5, + width: 100, + alignItems: "center", + }, + loginButtonText: { + color: "white", + }, + kakaoLoginButton: { + backgroundColor: "#FEE500", + }, + kakaoLoginButtonText: { + color: "black", + }, + appleLoginButton: { + backgroundColor: "black", + }, +}); diff --git a/Jeonghyuk/threads/app/modal.tsx b/Jeonghyuk/threads/app/modal.tsx new file mode 100644 index 0000000..3693049 --- /dev/null +++ b/Jeonghyuk/threads/app/modal.tsx @@ -0,0 +1,758 @@ +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + FlatList, + Image, + StyleSheet, + Modal as RNModal, + Pressable, + Linking, + Alert, + useColorScheme, +} from "react-native"; +import { useRouter } from "expo-router"; +import { Ionicons, FontAwesome } from "@expo/vector-icons"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import * as Location from "expo-location"; +import * as ImagePicker from "expo-image-picker"; +import * as MediaLibrary from "expo-media-library"; + +interface Thread { + id: string; + text: string; + hashtag?: string; + location?: [number, number]; + imageUris: string[]; +} + +export function ListFooter({ + canAddThread, + addThread, +}: { + canAddThread: boolean; + addThread: () => void; +}) { + return ( + + + + + + + + Add to thread + + + + + ); +} + +export default function Modal() { + const colorScheme = useColorScheme(); + const router = useRouter(); + const [threads, setThreads] = useState([ + { id: Date.now().toString(), text: "", imageUris: [] }, + ]); + const insets = useSafeAreaInsets(); + const [replyOption, setReplyOption] = useState("Anyone"); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [isPosting, setIsPosting] = useState(false); + + const replyOptions = ["Anyone", "Profiles you follow", "Mentioned only"]; + + const handleCancel = () => { + if (isPosting) return; + router.back(); + }; + + const handlePost = () => {}; + + const updateThreadText = (id: string, text: string) => { + setThreads((prevThreads) => + prevThreads.map((thread) => + thread.id === id ? { ...thread, text } : thread + ) + ); + }; + + const canAddThread = + (threads.at(-1)?.text.trim().length ?? 0) > 0 || + (threads.at(-1)?.imageUris.length ?? 0) > 0; + const canPost = threads.every( + (thread) => thread.text.trim().length > 0 || thread.imageUris.length > 0 + ); + + const removeThread = (id: string) => { + setThreads((prevThreads) => + prevThreads.filter((thread) => thread.id !== id) + ); + }; + + const pickImage = async (id: string) => { + let { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== "granted") { + Alert.alert( + "Photos permission not granted", + "Please grant photos permission to use this feature", + [ + { text: "Open settings", onPress: () => Linking.openSettings() }, + { + text: "Cancel", + }, + ] + ); + return; + } + let result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images", "livePhotos", "videos"], + allowsMultipleSelection: true, + selectionLimit: 5, + }); + console.log("image result", result); + if (!result.canceled) { + setThreads((prevThreads) => + prevThreads.map((thread) => + thread.id === id + ? { + ...thread, + imageUris: thread.imageUris.concat( + result.assets?.map((asset) => asset.uri) ?? [] + ), + } + : thread + ) + ); + } + }; + + const takePhoto = async (id: string) => { + let { status } = await ImagePicker.requestCameraPermissionsAsync(); + if (status !== "granted") { + Alert.alert( + "Camera permission not granted", + "Please grant camera permission to use this feature", + [ + { text: "Open settings", onPress: () => Linking.openSettings() }, + { + text: "Cancel", + }, + ] + ); + return; + } + let result = await ImagePicker.launchCameraAsync({ + mediaTypes: ["images", "livePhotos", "videos"], + allowsMultipleSelection: true, + selectionLimit: 5, + }); + console.log("camera result", result); + status = (await MediaLibrary.requestPermissionsAsync()).status; + if (status === "granted" && result.assets?.[0].uri) { + MediaLibrary.saveToLibraryAsync(result.assets[0].uri); + } + + if (!result.canceled) { + setThreads((prevThreads) => + prevThreads.map((thread) => + thread.id === id + ? { + ...thread, + imageUris: thread.imageUris.concat( + result.assets?.map((asset) => asset.uri) ?? [] + ), + } + : thread + ) + ); + } + }; + + const removeImageFromThread = (id: string, uriToRemove: string) => { + setThreads((prevThreads) => + prevThreads.map((thread) => + thread.id === id + ? { + ...thread, + imageUris: thread.imageUris.filter((uri) => uri !== uriToRemove), + } + : thread + ) + ); + }; + + const getMyLocation = async (id: string) => { + let { status } = await Location.requestForegroundPermissionsAsync(); + console.log("getMyLocation", status); + if (status !== "granted") { + Alert.alert( + "Location permission not granted", + "Please grant location permission to use this feature", + [ + { + text: "Open settings", + onPress: () => { + Linking.openSettings(); + }, + }, + { + text: "Cancel", + }, + ] + ); + return; + } + + const location = await Location.getCurrentPositionAsync({}); + + setThreads((prevThreads) => + prevThreads.map((thread) => + thread.id === id + ? { + ...thread, + location: [location.coords.latitude, location.coords.longitude], + } + : thread + ) + ); + }; + + const renderThreadItem = ({ + item, + index, + }: { + item: Thread; + index: number; + }) => ( + + + + + + + + + zerohch0 + + {index > 0 && ( + removeThread(item.id)} + style={styles.removeButton} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + )} + + updateThreadText(item.id, text)} + multiline + /> + {item.imageUris && item.imageUris.length > 0 && ( + ( + + + + !isPosting && removeImageFromThread(item.id, uri) + } + style={styles.removeImageButton} + > + + + + )} + keyExtractor={(uri, imgIndex) => + `${item.id}-img-${imgIndex}-${uri}` + } + horizontal + showsHorizontalScrollIndicator={false} + style={styles.imageFlatList} + /> + )} + {item.location && ( + + + {item.location[0]}, {item.location[1]} + + + )} + + !isPosting && pickImage(item.id)} + > + + + !isPosting && takePhoto(item.id)} + > + + + { + getMyLocation(item.id); + }} + > + + + + + + ); + + return ( + + + + + Cancel + + + + New thread + + + + + item.id} + renderItem={renderThreadItem} + ListFooterComponent={ + { + if (canAddThread) { + setThreads((prevThreads) => [ + ...prevThreads, + { id: Date.now().toString(), text: "", imageUris: [] }, + ]); + } + }} + /> + } + style={[ + styles.list, + colorScheme === "dark" ? styles.listDark : styles.listLight, + ]} + contentContainerStyle={{ + backgroundColor: colorScheme === "dark" ? "#101010" : "white", + }} + keyboardShouldPersistTaps="handled" + /> + + setIsDropdownVisible(false)} + > + setIsDropdownVisible(false)} + > + + {replyOptions.map((option) => ( + { + setReplyOption(option); + setIsDropdownVisible(false); + }} + > + + {option} + + + ))} + + + + + + setIsDropdownVisible(true)}> + + {replyOption} can reply & quote + + + + + Post + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#fff", + }, + containerLight: { + backgroundColor: "#fff", + }, + containerDark: { + backgroundColor: "#101010", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + }, + headerLight: { + backgroundColor: "#fff", + }, + headerDark: { + backgroundColor: "#101010", + }, + headerRightPlaceholder: { + width: 60, + }, + cancel: { + fontSize: 16, + }, + cancelLight: { + color: "#000", + }, + cancelDark: { + color: "#fff", + }, + disabledText: { + color: "#ccc", + }, + title: { + fontSize: 16, + fontWeight: "600", + }, + titleLight: { + color: "#000", + }, + titleDark: { + color: "#fff", + }, + list: { + flex: 1, + }, + listLight: { + backgroundColor: "white", + }, + listDark: { + backgroundColor: "#101010", + }, + threadContainer: { + flexDirection: "row", + paddingHorizontal: 20, + paddingTop: 12, + }, + avatarContainer: { + alignItems: "center", + marginRight: 12, + paddingTop: 2, + }, + avatar: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "#555", + }, + avatarSmall: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: "#555", + }, + threadLine: { + width: 1.5, + flexGrow: 1, + backgroundColor: "#aaa", + marginTop: 8, + }, + contentContainer: { + flex: 1, + paddingBottom: 6, + }, + userInfoContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 2, + }, + username: { + fontWeight: "600", + fontSize: 15, + }, + usernameLight: { + color: "#000", + }, + usernameDark: { + color: "#fff", + }, + input: { + fontSize: 15, + paddingTop: 4, + paddingBottom: 8, + minHeight: 24, + lineHeight: 20, + }, + inputLight: { + color: "#000", + }, + inputDark: { + color: "#fff", + }, + actionButtons: { + flexDirection: "row", + alignItems: "center", + }, + actionButton: { + marginRight: 15, + }, + imageFlatList: { + marginTop: 12, + marginBottom: 4, + }, + imagePreviewContainer: { + position: "relative", + marginRight: 8, + width: 100, + height: 100, + borderRadius: 8, + overflow: "hidden", + backgroundColor: "#f0f0f0", + }, + imagePreview: { + width: "100%", + height: "100%", + }, + removeImageButton: { + position: "absolute", + top: 4, + right: 4, + backgroundColor: "rgba(255, 255, 255, 0.8)", + borderRadius: 12, + padding: 2, + }, + footer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + paddingTop: 10, + position: "absolute", + bottom: 0, + left: 0, + right: 0, + }, + footerLight: { + backgroundColor: "white", + }, + footerDark: { + backgroundColor: "#101010", + }, + footerText: { + fontSize: 14, + }, + footerTextLight: { + color: "#8e8e93", + }, + footerTextDark: { + color: "#555", + }, + postButton: { + paddingVertical: 8, + paddingHorizontal: 18, + borderRadius: 18, + }, + postButtonLight: { + backgroundColor: "black", + }, + postButtonDark: { + backgroundColor: "white", + }, + postButtonDisabledLight: { + backgroundColor: "#ccc", + }, + postButtonDisabledDark: { + backgroundColor: "#555", + }, + postButtonText: { + fontSize: 15, + fontWeight: "600", + }, + postButtonTextLight: { + color: "white", + }, + postButtonTextDark: { + color: "black", + }, + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.4)", + justifyContent: "flex-end", + }, + dropdownContainer: { + width: 200, + borderRadius: 10, + marginHorizontal: 10, + overflow: "hidden", + }, + dropdownContainerLight: { + backgroundColor: "white", + }, + dropdownContainerDark: { + backgroundColor: "#101010", + }, + dropdownOption: { + paddingVertical: 15, + paddingHorizontal: 20, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#e5e5e5", + }, + selectedOption: {}, + dropdownOptionText: { + fontSize: 16, + }, + dropdownOptionTextLight: { + color: "#000", + }, + dropdownOptionTextDark: { + color: "#fff", + }, + selectedOptionText: { + fontWeight: "600", + color: "#007AFF", + }, + removeButton: { + padding: 4, + marginRight: -4, + marginLeft: 8, + }, + listFooter: { + paddingLeft: 26, + paddingTop: 10, + flexDirection: "row", + }, + listFooterAvatar: { + marginRight: 20, + paddingTop: 2, + }, + locationContainer: { + marginTop: 4, + marginBottom: 4, + }, + locationText: { + fontSize: 14, + color: "#8e8e93", + }, +}); diff --git a/Jeonghyuk/threads/assets/images/android-icon-background.png b/Jeonghyuk/threads/assets/images/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/Jeonghyuk/threads/assets/images/android-icon-background.png differ diff --git a/Jeonghyuk/threads/assets/images/android-icon-foreground.png b/Jeonghyuk/threads/assets/images/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/Jeonghyuk/threads/assets/images/android-icon-foreground.png differ diff --git a/Jeonghyuk/threads/assets/images/android-icon-monochrome.png b/Jeonghyuk/threads/assets/images/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/Jeonghyuk/threads/assets/images/android-icon-monochrome.png differ diff --git a/Jeonghyuk/threads/assets/images/avatar.png b/Jeonghyuk/threads/assets/images/avatar.png new file mode 100644 index 0000000..973660d Binary files /dev/null and b/Jeonghyuk/threads/assets/images/avatar.png differ diff --git a/Jeonghyuk/threads/assets/images/favicon.png b/Jeonghyuk/threads/assets/images/favicon.png new file mode 100644 index 0000000..408bd74 Binary files /dev/null and b/Jeonghyuk/threads/assets/images/favicon.png differ diff --git a/Jeonghyuk/threads/assets/images/icon.png b/Jeonghyuk/threads/assets/images/icon.png new file mode 100644 index 0000000..7165a53 Binary files /dev/null and b/Jeonghyuk/threads/assets/images/icon.png differ diff --git a/Jeonghyuk/threads/assets/images/partial-react-logo.png b/Jeonghyuk/threads/assets/images/partial-react-logo.png new file mode 100644 index 0000000..66fd957 Binary files /dev/null and b/Jeonghyuk/threads/assets/images/partial-react-logo.png differ diff --git a/Jeonghyuk/threads/assets/images/react-logo.png b/Jeonghyuk/threads/assets/images/react-logo.png new file mode 100644 index 0000000..9d72a9f Binary files /dev/null and b/Jeonghyuk/threads/assets/images/react-logo.png differ diff --git a/Jeonghyuk/threads/assets/images/react-logo@2x.png b/Jeonghyuk/threads/assets/images/react-logo@2x.png new file mode 100644 index 0000000..2229b13 Binary files /dev/null and b/Jeonghyuk/threads/assets/images/react-logo@2x.png differ diff --git a/Jeonghyuk/threads/assets/images/react-logo@3x.png b/Jeonghyuk/threads/assets/images/react-logo@3x.png new file mode 100644 index 0000000..a99b203 Binary files /dev/null and b/Jeonghyuk/threads/assets/images/react-logo@3x.png differ diff --git a/Jeonghyuk/threads/assets/images/splash-icon.png b/Jeonghyuk/threads/assets/images/splash-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/Jeonghyuk/threads/assets/images/splash-icon.png differ diff --git a/Jeonghyuk/threads/babel.config.js b/Jeonghyuk/threads/babel.config.js new file mode 100644 index 0000000..328201d --- /dev/null +++ b/Jeonghyuk/threads/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: ["react-native-reanimated/plugin"], // โœ… ๋ฐ˜๋“œ์‹œ ๋งˆ์ง€๋ง‰ + }; +}; diff --git a/Jeonghyuk/threads/components/Activity.tsx b/Jeonghyuk/threads/components/Activity.tsx new file mode 100644 index 0000000..06b0758 --- /dev/null +++ b/Jeonghyuk/threads/components/Activity.tsx @@ -0,0 +1,325 @@ +import { Ionicons } from "@expo/vector-icons"; +import { FontAwesome } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; +import { + View, + StyleSheet, + useColorScheme, + Pressable, + Image, + Text, +} from "react-native"; + +export interface ActivityItemProps { + id: string; + username: string; + otherCount?: number; + timeAgo: string; + content: string; + type: string; + link?: string; + reply?: string; + likes?: number; + actionButton?: React.ReactNode; + avatar: string; + postId?: string; +} + +export default function ActivityItem({ + id, + username, + otherCount, + timeAgo, + content, + type, + link, + reply, + likes, + actionButton, + avatar, + postId, +}: ActivityItemProps) { + const router = useRouter(); + const colorScheme = useColorScheme(); + + let iconColor = "#FF3B30"; + let iconName: any = "heart"; + let IconComponent: any = FontAwesome; + + if (type === "follow" || type === "followed") { + iconColor = "#FF9500"; + iconName = "person"; + IconComponent = Ionicons; + } else if (type === "mention") { + iconColor = "#FF3B30"; + iconName = "at"; + IconComponent = FontAwesome; + } else if (type === "reply") { + iconColor = "#007AFF"; + iconName = "reply"; + IconComponent = FontAwesome; + } else if (type === "quote") { + iconColor = "#007AFF"; + iconName = "quote-left"; + IconComponent = FontAwesome; + } else if (type === "repost") { + iconColor = "#007AFF"; + iconName = "retweet"; + IconComponent = FontAwesome; + } + + // ์•Œ๋ฆผ ํ•ญ๋ชฉ ํด๋ฆญ ์‹œ ์ด๋™ ๋กœ์ง + const handleItemPress = () => { + if (type === "follow" || type === "followed") { + // ํŒ”๋กœ์šฐ/ํŒ”๋กœ์›Œ ์•Œ๋ฆผ์€ ํ”„๋กœํ•„๋กœ ์ด๋™ + router.push(`/${username}`); + } else if (postId) { + // ๋‚˜๋จธ์ง€ ์•Œ๋ฆผ์€ ๊ฒŒ์‹œ๊ธ€๋กœ ์ด๋™ (postId๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ) + router.push(`/${username}/post/${postId}`); + } else { + // postId๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ (์˜ˆ: mention ๋“ฑ ํŠน์ • ํƒ€์ž…) ํ”„๋กœํ•„๋กœ ์ด๋™ํ•˜๊ฑฐ๋‚˜ ๋‹ค๋ฅธ ์ฒ˜๋ฆฌ + console.log( + `No postId for activity type: ${type}, navigating to profile` + ); + router.push(`/${username}`); + } + }; + + // ํ”„๋กœํ•„ ์‚ฌ์ง„ ํด๋ฆญ ์‹œ ์ด๋™ ๋กœ์ง + const handleAvatarPress = () => { + router.push(`/${username}`); + }; + + return ( + + {/* Avatar + Icon Container */} + + + + + + + + {/* Content Container */} + + + + {username} + + {otherCount && +{otherCount}๋ช…} + + {timeAgo} + + + + {type === "followed" ? ( + + Followed you + + ) : ( + + {content} + + )} + + {link && ( + + + {link} + + )} + + {reply && ( + + + {reply} + + + )} + + + {likes !== undefined && ( + + + {likes} + + )} + {actionButton && actionButton} + + + + ); +} + +const styles = StyleSheet.create({ + activityItemContainer: { + flexDirection: "row", + padding: 16, + borderBottomWidth: 1, + borderBottomColor: "#eee", + alignItems: "flex-start", + }, + avatarContainer: { + marginRight: 12, + position: "relative", + }, + avatar: { + width: 40, + height: 40, + borderRadius: 20, + }, + iconCircle: { + width: 20, + height: 20, + borderRadius: 10, + justifyContent: "center", + alignItems: "center", + position: "absolute", + bottom: -2, + right: -2, + borderWidth: 2, + borderColor: "#fff", + }, + activityContent: { + flex: 1, + }, + activityHeader: { + flexDirection: "row", + alignItems: "center", + marginBottom: 2, + }, + username: { + fontWeight: "bold", + fontSize: 14, + marginRight: 4, + }, + usernameDark: { + color: "white", + }, + usernameLight: { + color: "black", + }, + otherCount: { + fontSize: 14, + marginRight: 4, + }, + otherCountDark: { + color: "white", + }, + otherCountLight: { + color: "#888", + }, + timeAgo: { + fontSize: 14, + color: "#888", + }, + timeAgoDark: { + color: "white", + }, + timeAgoLight: { + color: "#888", + }, + activityText: { + fontSize: 14, + color: "#333", + lineHeight: 18, + }, + activityTextDark: { + color: "#ccc", + }, + activityTextLight: { + color: "#333", + }, + linkContainer: { + flexDirection: "row", + alignItems: "center", + marginTop: 4, + backgroundColor: "#f0f0f0", + padding: 8, + borderRadius: 8, + }, + linkText: { + marginLeft: 6, + fontSize: 13, + color: "#007AFF", + flexShrink: 1, + }, + replyContainer: { + marginTop: 8, + borderLeftWidth: 2, + borderLeftColor: "#ddd", + paddingLeft: 8, + }, + replyText: { + fontSize: 14, + color: "#555", + fontStyle: "italic", + }, + replyTextDark: { + color: "#ccc", + }, + replyTextLight: { + color: "#555", + }, + activityFooter: { + flexDirection: "row", + alignItems: "center", + marginTop: 8, + }, + likesContainer: { + flexDirection: "row", + alignItems: "center", + marginRight: 16, + }, + likesText: { + marginLeft: 4, + fontSize: 12, + color: "#FF3B30", + }, + followBackButton: { + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 6, + marginLeft: "auto", + }, + followButtonText: { + fontSize: 13, + fontWeight: "600", + }, +}); diff --git a/Jeonghyuk/threads/components/EditProfileModal.tsx b/Jeonghyuk/threads/components/EditProfileModal.tsx new file mode 100644 index 0000000..faf7c4d --- /dev/null +++ b/Jeonghyuk/threads/components/EditProfileModal.tsx @@ -0,0 +1,492 @@ +import React, { useState, useEffect, useContext } from "react"; +import { + Modal, + View, + Text, + StyleSheet, + TouchableOpacity, + TextInput, + Switch, + Image, + ActivityIndicator, + SafeAreaView, + Platform, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { AuthContext, type User } from "@/app/_layout"; + +interface EditProfileModalProps { + visible: boolean; + onClose: () => void; + initialProfileData: User; +} + +const EditProfileModal: React.FC = ({ + visible, + onClose, + initialProfileData, +}) => { + const { user, updateUser } = useContext(AuthContext); + const [name, setName] = useState(initialProfileData?.name || ""); + const [description, setDescription] = useState( + initialProfileData?.description || "" + ); + const [link, setLink] = useState(initialProfileData?.link || ""); + const [showInstagramBadge, setShowInstagramBadge] = useState( + initialProfileData?.showInstagramBadge || false + ); + const [isPrivate, setIsPrivate] = useState( + initialProfileData?.isPrivate || false + ); + const [isSaving, setIsSaving] = useState(false); + const [editingField, setEditingField] = useState<"bio" | "link" | null>(null); + const [tempValue, setTempValue] = useState(""); + + useEffect(() => { + if (visible && initialProfileData) { + setName(initialProfileData.name); + setDescription(initialProfileData.description); + setLink(initialProfileData.link || ""); + setShowInstagramBadge(initialProfileData.showInstagramBadge || false); + setIsPrivate(initialProfileData.isPrivate || false); + } else if (!visible) { + setEditingField(null); + setTempValue(""); + } + }, [visible, initialProfileData]); + + const handleSave = async () => { + if (!initialProfileData?.name) { + console.error("Cannot save profile without a username."); + return; + } + + setIsSaving(true); + try { + await updateUser?.({ + id: initialProfileData.id, + name: initialProfileData.name, + description, + link, + showInstagramBadge, + isPrivate, + profileImageUrl: initialProfileData.profileImageUrl, + }); + } catch (error) { + console.error("Failed to save profile:", error); + setIsSaving(false); + } + }; + + const handleCancel = () => { + if (!isSaving) { + if (editingField) { + setEditingField(null); + setTempValue(""); + } else { + if (initialProfileData) { + setName(initialProfileData.name); + setDescription(initialProfileData.description); + setLink(initialProfileData.link || ""); + setShowInstagramBadge(initialProfileData.showInstagramBadge || false); + setIsPrivate(initialProfileData.isPrivate || false); + } + onClose(); + } + } + }; + + const handleEditBio = () => { + setEditingField("bio"); + setTempValue(description); + }; + + const handleEditLink = () => { + setEditingField("link"); + setTempValue(link); + }; + + const handleConfirmEdit = () => { + if (editingField === "bio") { + setDescription(tempValue); + } else if (editingField === "link") { + setLink(tempValue); + } + setEditingField(null); + setTempValue(""); + }; + + const renderEditFieldView = () => { + const isBio = editingField === "bio"; + const title = isBio ? "Bio" : "Link"; + const placeholder = isBio ? "Write a bio" : "Add link"; + + return ( + + + + Cancel + + {title} + + + Done + + + + + + + + ); + }; + + const renderMainProfileView = () => ( + + + {isSaving ? ( + + ) : ( + + Cancel + + )} + Edit profile + {isSaving ? ( + + + + ) : ( + + + Done + + + )} + + + + + + Name + + + + {name} (@{initialProfileData?.id || "..."}) + + + + + + + + + Bio + + {description || "Write a bio..."} + + + + + + + + Link + + {link || "Add link..."} + + + + + + + + + + Show Instagram badge + + When turned on, the Threads badge on your Instagram profile will + also appear. + + + + + + + + + + Private profile + + If you switch to private, you won't be able to reply to others + unless they follow you. + + + + + + + + ); + + return ( + + + {editingField ? renderEditFieldView() : renderMainProfileView()} + + + ); +}; + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: "#f8f8f8", + }, + modalContainer: { + flex: 1, + backgroundColor: "#f8f8f8", + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 10, + borderBottomColor: "#e0e0e0", + borderBottomWidth: StyleSheet.hairlineWidth, + backgroundColor: "#f8f8f8", + height: 55, + }, + headerButton: { + padding: 8, + minWidth: 60, + alignItems: "center", + }, + headerButtonPlaceholder: { + minWidth: 60, + padding: 8, + }, + activityIndicatorContainer: { + minWidth: 60, + padding: 8, + alignItems: "center", + justifyContent: "center", + }, + headerButtonText: { + fontSize: 17, + color: "#000", + }, + headerTitle: { + fontSize: 17, + fontWeight: "600", + color: "#000", + }, + doneButton: { + fontWeight: "600", + color: "#000", + }, + contentContainer: { + flex: 1, + paddingTop: 10, + }, + editingContentContainer: { + flex: 1, + padding: 16, + }, + section: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#e0e0e0", + }, + nameSection: { + alignItems: "center", + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#e0e0e0", + paddingVertical: 16, + paddingHorizontal: 16, + }, + sectionText: { + flex: 1, + marginRight: 16, + }, + sectionTextFull: { + flex: 1, + marginRight: 8, + }, + label: { + fontSize: 16, + fontWeight: "500", + marginBottom: 4, + color: "#333", + }, + labelSwitch: { + fontSize: 16, + fontWeight: "500", + color: "#333", + marginBottom: 2, + }, + nameRow: { + flexDirection: "row", + alignItems: "center", + }, + lockIcon: { + marginRight: 6, + }, + valueText: { + fontSize: 16, + color: "#555", + flexShrink: 1, + }, + tappableText: { + fontSize: 16, + color: "#333", + lineHeight: 22, + paddingVertical: Platform.OS === "ios" ? 4 : 2, + }, + linkText: { + color: "#007AFF", + }, + placeholderText: { + color: "#ccc", + fontStyle: "italic", + }, + chevronIcon: { + marginLeft: "auto", + }, + textInputBioLarge: { + flex: 1, + fontSize: 16, + color: "#333", + textAlignVertical: "top", + padding: 8, + lineHeight: 22, + backgroundColor: "#fff", + borderRadius: 8, + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#ccc", + }, + textInputLinkLarge: { + fontSize: 16, + color: "#007AFF", + padding: 12, + backgroundColor: "#fff", + borderRadius: 8, + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#ccc", + maxHeight: 100, + }, + profilePic: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: "#e8e8e8", + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#d0d0d0", + }, + divider: { + height: 12, + backgroundColor: "#f8f8f8", + }, + dividerThin: { + height: StyleSheet.hairlineWidth, + backgroundColor: "#e0e0e0", + marginVertical: 8, + marginHorizontal: 16, + }, + switchSection: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 10, + paddingHorizontal: 16, + }, + switchTextContainer: { + flex: 1, + marginRight: 16, + }, + description: { + fontSize: 14, + color: "#666", + marginTop: 4, + lineHeight: 18, + }, + switch: { + transform: Platform.OS === "ios" ? [] : [{ scaleX: 1.1 }, { scaleY: 1.1 }], + }, +}); + +export default EditProfileModal; diff --git a/Jeonghyuk/threads/components/Post.tsx b/Jeonghyuk/threads/components/Post.tsx new file mode 100644 index 0000000..bf57bc1 --- /dev/null +++ b/Jeonghyuk/threads/components/Post.tsx @@ -0,0 +1,305 @@ +import { Feather, Ionicons } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; +import { + View, + StyleSheet, + Text, + TouchableOpacity, + Share, + useColorScheme, + Image, + Pressable, + Linking, + ScrollView, +} from "react-native"; +import * as WebBrowser from "expo-web-browser"; + +export interface Post { + id: string; + username: string; + displayName: string; + content: string; + timeAgo: string; + likes: number; + comments: number; + reposts: number; + isVerified?: boolean; + avatar?: string; + images?: string[]; + link?: string; + linkThumbnail?: string; + location?: [number, number]; +} + +export interface DetailedPost extends Post { + // Post์˜ ํ•„๋“œ๋“ค: id, username, displayName, content, timeAgo, likes, comments, reposts, isVerified?, avatar?, image? + isLiked?: boolean; // isLiked ์ถ”๊ฐ€ + shares?: number; // shares ์ถ”๊ฐ€ +} + +export default function Post({ item }: { item: Post }) { + const router = useRouter(); + const colorScheme = useColorScheme(); + // ๊ณต์œ  ๊ธฐ๋Šฅ ํ•ธ๋“ค๋Ÿฌ + const handleShare = async (username: string, postId: string) => { + const shareUrl = `thread://@${username}/post/${postId}`; + try { + await Share.share({ + message: shareUrl, + url: shareUrl, // iOS์—์„œ๋Š” url๋„ ํ•จ๊ป˜ ์ „๋‹ฌํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. + }); + } catch (error) { + console.error("Error sharing post:", error); + // ์‚ฌ์šฉ์ž์—๊ฒŒ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค. + } + }; + + // ๊ฒŒ์‹œ๊ธ€ ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ ์ˆ˜์ • + const handlePostPress = (post: Post) => { + console.log("postClick"); + // DetailedPost ํƒ€์ž…์— ๋งž๊ฒŒ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ (isLiked, shares๋Š” ์ƒ์„ธ ํ™”๋ฉด์—์„œ ๊ด€๋ฆฌ) + const detailedPost: DetailedPost = { + ...post, + // isLiked, shares ๋Š” PostScreen ์—์„œ ์ดˆ๊ธฐํ™”ํ•˜๊ฑฐ๋‚˜ API ์‘๋‹ต์œผ๋กœ ๋ฐ›์•„์™€์•ผ ํ•จ + // ์—ฌ๊ธฐ์„œ๋Š” ๊ธฐ๋ณธ๊ฐ’ ๋˜๋Š” undefined ๋กœ ์„ค์ • + isLiked: false, // ์˜ˆ์‹œ: ๊ธฐ๋ณธ๊ฐ’ false + shares: 0, // ์˜ˆ์‹œ: ๊ธฐ๋ณธ๊ฐ’ 0 + }; + router.push(`/@${post.username}/post/${post.id}`); + }; + + // ์‚ฌ์šฉ์ž ์ •๋ณด ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ (์•„๋ฐ”ํƒ€ ๋˜๋Š” ์ด๋ฆ„) + const handleUserPress = (post: Post) => { + router.push(`/@${post.username}`); + }; + + return ( + handlePostPress(item)} + activeOpacity={0.8} + > + + + handleUserPress(item)}> + {item.avatar ? ( + + ) : ( + + + + )} + + + handleUserPress(item)}> + + + {item.username} + + {item.isVerified && ( + + )} + + {item.timeAgo} + + + + + + + + + + + {item.content} + + + + {item.images && + item.images.length > 0 && + item.images.map((image) => ( + + ))} + + + {!item.images?.length && item.link && ( + WebBrowser.openBrowserAsync(item.link!)}> + + + )} + {item.location && item.location.length > 0 && ( + {item.location.join(", ")} + )} + + + + + + {item.likes > 0 && ( + {item.likes} + )} + + + + {item.comments > 0 && ( + {item.comments} + )} + + + + {item.reposts > 0 && ( + {item.reposts} + )} + + handleShare(item.username, item.id)} + > + + + + + ); +} + +const styles = StyleSheet.create({ + // ํฌ์ŠคํŠธ ์Šคํƒ€์ผ + postContainer: { + borderBottomWidth: 1, + borderBottomColor: "#eee", + padding: 12, + }, + postHeader: { + flexDirection: "row", + alignItems: "flex-start", + marginBottom: 8, + }, + userInfo: { + flex: 1, + flexDirection: "row", + alignItems: "flex-start", + }, + avatar: { + width: 40, + height: 40, + borderRadius: 20, + marginRight: 12, + justifyContent: "center", + alignItems: "center", + }, + usernameContainer: { + flex: 1, + }, + usernameRow: { + flexDirection: "row", + alignItems: "center", + }, + username: { + fontWeight: "600", + fontSize: 15, + marginRight: 4, + }, + usernameDark: { + color: "white", + }, + usernameLight: { + color: "#000", + }, + verifiedIcon: { + marginRight: 4, + }, + timeAgo: { + fontSize: 14, + marginLeft: 4, + }, + timeAgoDark: { + color: "#ccc", + }, + timeAgoLight: { + color: "#888", + }, + postContent: { + marginLeft: 52, + }, + postText: { + fontSize: 15, + lineHeight: 20, + marginBottom: 8, + }, + postTextDark: { + color: "white", + }, + postTextLight: { + color: "#000", + }, + postImages: { + flexDirection: "row", + gap: 8, + }, + postImage: { + width: 300, + height: 300, + borderRadius: 12, + marginTop: 8, + }, + postLink: { + width: "85%", + height: 200, + borderRadius: 12, + marginTop: 8, + }, + postActions: { + flexDirection: "row", + marginLeft: 52, + marginTop: 12, + }, + actionButton: { + flexDirection: "row", + alignItems: "center", + marginRight: 24, + }, + actionCount: { + marginLeft: 4, + fontSize: 14, + color: "#666", + }, +}); diff --git a/Jeonghyuk/threads/components/SideMenu.tsx b/Jeonghyuk/threads/components/SideMenu.tsx new file mode 100644 index 0000000..5dc318e --- /dev/null +++ b/Jeonghyuk/threads/components/SideMenu.tsx @@ -0,0 +1,376 @@ +import React, { useContext, useState } from "react"; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + SafeAreaView, + Modal, + useColorScheme, + Pressable, + Appearance, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; // For background blur effect +import { AuthContext } from "../app/_layout"; + +interface SideMenuProps { + isVisible: boolean; + onClose: () => void; +} + +const SideMenu: React.FC = ({ isVisible, onClose }) => { + const { logout } = useContext(AuthContext); + const colorScheme = useColorScheme(); + const [isAppearanceVisible, setIsAppearanceVisible] = useState(false); + + const showAppearance = () => { + setIsAppearanceVisible(true); + }; + + const closeAppearance = () => { + setIsAppearanceVisible(false); + }; + + const handleLogout = () => { + logout?.(); + onClose(); // Close the menu after logout + // Optionally navigate to login screen or home screen + // e.g., using router.replace('/login'); + }; + + // Use Modal for better presentation and handling outside clicks + return ( + <> + + + {/* Touchable overlay to close the menu when clicking outside */} + + + + {/* Header inside the menu if needed, or just items */} + + + + Appearance + + + + + + Insights + + + + + Settings + + + + + + + + Report a problem + + + + + Log out + + + + + + + + + + + + + + + + Appearance + + + + { + Appearance.setColorScheme("light"); + }} + > + + + { + Appearance.setColorScheme("dark"); + }} + > + + + { + Appearance.setColorScheme(undefined); + }} + > + + Auto + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + // The BlurView replaces the semi-transparent background + // backgroundColor: 'rgba(0, 0, 0, 0.3)', + position: "relative", + }, + touchableOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + // Ensure this overlay doesn't block interaction with the menu itself + // It's placed behind the menu container implicitly due to order or explicitly with zIndex if needed + }, + menuContainer: { + position: "absolute", + top: 60, // Position below the header + left: 10, + width: "75%", // Adjust width as needed + maxWidth: 320, + borderRadius: 15, // Rounded corners like the screenshot + paddingVertical: 5, // Reduced vertical padding + paddingHorizontal: 0, // Padding handled by items + shadowColor: "#000", + shadowOpacity: 0.15, + overflow: "hidden", // Ensure content respects border radius + }, + menuContainerLight: { + backgroundColor: "white", + shadowOffset: { width: 0, height: 4 }, + elevation: 8, + shadowRadius: 10, + }, + menuContainerDark: { + backgroundColor: "#101010", + borderWidth: 1, + borderColor: "#202020", + }, + menuContent: { + paddingVertical: 10, // Inner padding for the content block + paddingHorizontal: 15, // Inner horizontal padding + }, + menuItem: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 16, // Slightly increased vertical padding for items + paddingHorizontal: 5, // Horizontal padding within the content block + }, + menuText: { + fontSize: 16, + fontWeight: "500", + }, + menuTextLight: { + color: "black", + }, + menuTextDark: { + color: "white", + }, + logoutText: { + color: "#FF3B30", // iOS-like red color + }, + separator: { + height: StyleSheet.hairlineWidth, // Thinner separator + backgroundColor: "#D1D1D6", // Lighter separator color + marginVertical: 8, + marginHorizontal: -15, // Extend separator to edges if needed, adjust based on menuContent padding + }, + appearanceContainer: { + justifyContent: "center", + alignItems: "center", + borderRadius: 15, + position: "absolute", + top: 60, + left: 10, + padding: 20, + borderWidth: StyleSheet.hairlineWidth, + }, + appearanceContainerLight: { + backgroundColor: "white", + borderColor: "#ccc", + }, + appearanceContainerDark: { + backgroundColor: "#101010", + borderColor: "#333", + }, + appearanceHeader: { + flexDirection: "row", + alignItems: "center", + width: 200, + padding: 10, + paddingBottom: 30, + position: "relative", + justifyContent: "center", + }, + appearanceBackButton: { + position: "absolute", + top: 10, + left: 0, + }, + appearanceButton: { + padding: 10, + borderRadius: 10, + }, + appearanceButtonLight: { + backgroundColor: "white", + }, + appearanceButtonDark: { + backgroundColor: "#101010", + }, + appearanceText: { + fontSize: 16, + fontWeight: "500", + }, + appearanceTextLight: { + color: "black", + }, + appearanceTextDark: { + color: "white", + }, +}); + +export default SideMenu; diff --git a/Jeonghyuk/threads/eas.json b/Jeonghyuk/threads/eas.json new file mode 100644 index 0000000..7e9ec69 --- /dev/null +++ b/Jeonghyuk/threads/eas.json @@ -0,0 +1,24 @@ +{ + "cli": { + "version": ">= 16.28.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "channel": "development" + }, + "preview": { + "distribution": "internal", + "channel": "preview" + }, + "production": { + "autoIncrement": true, + "channel": "production" + } + }, + "submit": { + "production": {} + } +} diff --git a/Jeonghyuk/threads/eslint.config.js b/Jeonghyuk/threads/eslint.config.js new file mode 100644 index 0000000..5025da6 --- /dev/null +++ b/Jeonghyuk/threads/eslint.config.js @@ -0,0 +1,10 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config'); +const expoConfig = require('eslint-config-expo/flat'); + +module.exports = defineConfig([ + expoConfig, + { + ignores: ['dist/*'], + }, +]); diff --git a/Jeonghyuk/threads/index.ts b/Jeonghyuk/threads/index.ts new file mode 100644 index 0000000..456b0ee --- /dev/null +++ b/Jeonghyuk/threads/index.ts @@ -0,0 +1,40 @@ +import "expo-router/entry"; +import * as Device from "expo-device"; + +import { createServer, Response, Server } from "miragejs"; + +declare global { + interface Window { + server: Server; + } +} + +if (__DEV__ && typeof window !== "undefined" && !Device.isDevice) { + if (window.server) { + window.server.shutdown(); + } + + window.server = createServer({ + routes() { + this.post("/login", (schema, request) => { + const { username, password } = JSON.parse(request.requestBody); + + if (username === "zerocho" && password === "1234") { + return { + accessToken: "access-token", + refreshToken: "refresh-token", + user: { + id: "zerocho", + name: "LikeVanilla", + description: "programmer", + profileImageUrl: + "https://avartaras.githubusercontent.com/u/885857?v=4", + }, + }; + } else { + return new Response(401, {}, { message: "Invalid credentials" }); + } + }); + }, + }); +} diff --git a/Jeonghyuk/threads/modules/background-uploader/android/build.gradle b/Jeonghyuk/threads/modules/background-uploader/android/build.gradle new file mode 100644 index 0000000..d9d963b --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/android/build.gradle @@ -0,0 +1,43 @@ +apply plugin: 'com.android.library' + +group = 'com.likevanilla.backgroundUploader' +version = '0.7.6' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. +// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. +// Most of the time, you may like to manage the Android SDK versions yourself. +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 36) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 24) + targetSdkVersion safeExtGet("targetSdkVersion", 36) + } + } +} + +android { + namespace "com.likevanilla.backgroundUploader" + defaultConfig { + versionCode 1 + versionName "0.7.6" + } + lintOptions { + abortOnError false + } +} diff --git a/Jeonghyuk/threads/modules/background-uploader/android/src/main/AndroidManifest.xml b/Jeonghyuk/threads/modules/background-uploader/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bdae66c --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/Jeonghyuk/threads/modules/background-uploader/android/src/main/java/com/likevanilla/backgroundUploader/BackgroundUploaderModule.kt b/Jeonghyuk/threads/modules/background-uploader/android/src/main/java/com/likevanilla/backgroundUploader/BackgroundUploaderModule.kt new file mode 100644 index 0000000..3f66ddf --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/android/src/main/java/com/likevanilla/backgroundUploader/BackgroundUploaderModule.kt @@ -0,0 +1,50 @@ +package com.likevanilla.backgroundUploader + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.net.URL + +class BackgroundUploaderModule : Module() { + // Each module class must implement the definition function. The definition consists of components + // that describes the module's functionality and behavior. + // See https://docs.expo.dev/modules/module-api for more details about available components. + override fun definition() = ModuleDefinition { + // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. + // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. + // The module will be accessible from `requireNativeModule('BackgroundUploader')` in JavaScript. + Name("BackgroundUploader") + + // Defines constant property on the module. + Constant("PI") { + Math.PI + } + + // Defines event names that the module can send to JavaScript. + Events("onChange") + + // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. + Function("hello") { + "Hello world! ๐Ÿ‘‹" + } + + // Defines a JavaScript function that always returns a Promise and whose native code + // is by default dispatched on the different thread than the JavaScript runtime runs on. + AsyncFunction("setValueAsync") { value: String -> + // Send an event to JavaScript. + sendEvent("onChange", mapOf( + "value" to value + )) + } + + // Enables the module to be used as a native view. Definition components that are accepted as part of + // the view definition: Prop, Events. + View(BackgroundUploaderView::class) { + // Defines a setter for the `url` prop. + Prop("url") { view: BackgroundUploaderView, url: URL -> + view.webView.loadUrl(url.toString()) + } + // Defines an event that the view can send to JavaScript. + Events("onLoad") + } + } +} diff --git a/Jeonghyuk/threads/modules/background-uploader/android/src/main/java/com/likevanilla/backgroundUploader/BackgroundUploaderView.kt b/Jeonghyuk/threads/modules/background-uploader/android/src/main/java/com/likevanilla/backgroundUploader/BackgroundUploaderView.kt new file mode 100644 index 0000000..e5e387a --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/android/src/main/java/com/likevanilla/backgroundUploader/BackgroundUploaderView.kt @@ -0,0 +1,30 @@ +package com.likevanilla.backgroundUploader + +import android.content.Context +import android.webkit.WebView +import android.webkit.WebViewClient +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class BackgroundUploaderView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + // Creates and initializes an event dispatcher for the `onLoad` event. + // The name of the event is inferred from the value and needs to match the event name defined in the module. + private val onLoad by EventDispatcher() + + // Defines a WebView that will be used as the root subview. + internal val webView = WebView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + // Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript. + onLoad(mapOf("url" to url)) + } + } + } + + init { + // Adds the WebView to the view hierarchy. + addView(webView) + } +} diff --git a/Jeonghyuk/threads/modules/background-uploader/expo-module.config.json b/Jeonghyuk/threads/modules/background-uploader/expo-module.config.json new file mode 100644 index 0000000..7e3cf4f --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android", "web"], + "apple": { + "modules": ["BackgroundUploaderModule"] + }, + "android": { + "modules": ["com.likevanilla.backgroundUploader.BackgroundUploaderModule"] + } +} diff --git a/Jeonghyuk/threads/modules/background-uploader/index.ts b/Jeonghyuk/threads/modules/background-uploader/index.ts new file mode 100644 index 0000000..e65e10d --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/index.ts @@ -0,0 +1,5 @@ +// Reexport the native module. On web, it will be resolved to BackgroundUploaderModule.web.ts +// and on native platforms to BackgroundUploaderModule.ts +export { default } from './src/BackgroundUploaderModule'; +export { default as BackgroundUploaderView } from './src/BackgroundUploaderView'; +export * from './src/BackgroundUploader.types'; diff --git a/Jeonghyuk/threads/modules/background-uploader/ios/BackgroundUploader.podspec b/Jeonghyuk/threads/modules/background-uploader/ios/BackgroundUploader.podspec new file mode 100644 index 0000000..5717553 --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/ios/BackgroundUploader.podspec @@ -0,0 +1,23 @@ +Pod::Spec.new do |s| + s.name = 'BackgroundUploader' + s.version = '1.0.0' + s.summary = 'A sample project summary' + s.description = 'A sample project description' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platforms = { + :ios => '15.1', + :tvos => '15.1' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/Jeonghyuk/threads/modules/background-uploader/ios/BackgroundUploaderModule.swift b/Jeonghyuk/threads/modules/background-uploader/ios/BackgroundUploaderModule.swift new file mode 100644 index 0000000..91fa1ca --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/ios/BackgroundUploaderModule.swift @@ -0,0 +1,48 @@ +import ExpoModulesCore + +public class BackgroundUploaderModule: Module { + // Each module class must implement the definition function. The definition consists of components + // that describes the module's functionality and behavior. + // See https://docs.expo.dev/modules/module-api for more details about available components. + public func definition() -> ModuleDefinition { + // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. + // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. + // The module will be accessible from `requireNativeModule('BackgroundUploader')` in JavaScript. + Name("BackgroundUploader") + + // Defines constant property on the module. + Constant("PI") { + Double.pi + } + + // Defines event names that the module can send to JavaScript. + Events("onChange") + + // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. + Function("hello") { + return "Hello world! ๐Ÿ‘‹" + } + + // Defines a JavaScript function that always returns a Promise and whose native code + // is by default dispatched on the different thread than the JavaScript runtime runs on. + AsyncFunction("setValueAsync") { (value: String) in + // Send an event to JavaScript. + self.sendEvent("onChange", [ + "value": value + ]) + } + + // Enables the module to be used as a native view. Definition components that are accepted as part of the + // view definition: Prop, Events. + View(BackgroundUploaderView.self) { + // Defines a setter for the `url` prop. + Prop("url") { (view: BackgroundUploaderView, url: URL) in + if view.webView.url != url { + view.webView.load(URLRequest(url: url)) + } + } + + Events("onLoad") + } + } +} diff --git a/Jeonghyuk/threads/modules/background-uploader/ios/BackgroundUploaderView.swift b/Jeonghyuk/threads/modules/background-uploader/ios/BackgroundUploaderView.swift new file mode 100644 index 0000000..7037156 --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/ios/BackgroundUploaderView.swift @@ -0,0 +1,38 @@ +import ExpoModulesCore +import WebKit + +// This view will be used as a native component. Make sure to inherit from `ExpoView` +// to apply the proper styling (e.g. border radius and shadows). +class BackgroundUploaderView: ExpoView { + let webView = WKWebView() + let onLoad = EventDispatcher() + var delegate: WebViewDelegate? + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + clipsToBounds = true + delegate = WebViewDelegate { url in + self.onLoad(["url": url]) + } + webView.navigationDelegate = delegate + addSubview(webView) + } + + override func layoutSubviews() { + webView.frame = bounds + } +} + +class WebViewDelegate: NSObject, WKNavigationDelegate { + let onUrlChange: (String) -> Void + + init(onUrlChange: @escaping (String) -> Void) { + self.onUrlChange = onUrlChange + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) { + if let url = webView.url { + onUrlChange(url.absoluteString) + } + } +} diff --git a/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploader.types.ts b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploader.types.ts new file mode 100644 index 0000000..9b9137c --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploader.types.ts @@ -0,0 +1,19 @@ +import type { StyleProp, ViewStyle } from 'react-native'; + +export type OnLoadEventPayload = { + url: string; +}; + +export type BackgroundUploaderModuleEvents = { + onChange: (params: ChangeEventPayload) => void; +}; + +export type ChangeEventPayload = { + value: string; +}; + +export type BackgroundUploaderViewProps = { + url: string; + onLoad: (event: { nativeEvent: OnLoadEventPayload }) => void; + style?: StyleProp; +}; diff --git a/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderModule.ts b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderModule.ts new file mode 100644 index 0000000..47575c1 --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderModule.ts @@ -0,0 +1,12 @@ +import { NativeModule, requireNativeModule } from 'expo'; + +import { BackgroundUploaderModuleEvents } from './BackgroundUploader.types'; + +declare class BackgroundUploaderModule extends NativeModule { + PI: number; + hello(): string; + setValueAsync(value: string): Promise; +} + +// This call loads the native module object from the JSI. +export default requireNativeModule('BackgroundUploader'); diff --git a/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderModule.web.ts b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderModule.web.ts new file mode 100644 index 0000000..1970d0c --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderModule.web.ts @@ -0,0 +1,19 @@ +import { registerWebModule, NativeModule } from 'expo'; + +import { ChangeEventPayload } from './BackgroundUploader.types'; + +type BackgroundUploaderModuleEvents = { + onChange: (params: ChangeEventPayload) => void; +} + +class BackgroundUploaderModule extends NativeModule { + PI = Math.PI; + async setValueAsync(value: string): Promise { + this.emit('onChange', { value }); + } + hello() { + return 'Hello world! ๐Ÿ‘‹'; + } +}; + +export default registerWebModule(BackgroundUploaderModule, 'BackgroundUploaderModule'); diff --git a/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderView.tsx b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderView.tsx new file mode 100644 index 0000000..e7d8d19 --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderView.tsx @@ -0,0 +1,11 @@ +import { requireNativeView } from 'expo'; +import * as React from 'react'; + +import { BackgroundUploaderViewProps } from './BackgroundUploader.types'; + +const NativeView: React.ComponentType = + requireNativeView('BackgroundUploader'); + +export default function BackgroundUploaderView(props: BackgroundUploaderViewProps) { + return ; +} diff --git a/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderView.web.tsx b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderView.web.tsx new file mode 100644 index 0000000..0be876c --- /dev/null +++ b/Jeonghyuk/threads/modules/background-uploader/src/BackgroundUploaderView.web.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import { BackgroundUploaderViewProps } from './BackgroundUploader.types'; + +export default function BackgroundUploaderView(props: BackgroundUploaderViewProps) { + return ( +
+