├── .github └── FUNDING.yml ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── meowler.iml ├── modules.xml ├── prettier.xml └── vcs.xml ├── .prettierignore ├── .prettierrc.json ├── App.tsx ├── LICENSE.md ├── README.md ├── Screens ├── AddAccount.tsx ├── Blocks │ ├── BlockedCommunities.tsx │ ├── BlockedUsers.tsx │ └── BlocksScreen.tsx ├── CommentWrite │ └── CommentWrite.tsx ├── Community │ ├── CommunityFeed.tsx │ ├── CommunityInfos.tsx │ └── CommunityScreen.tsx ├── DebugScreen.tsx ├── Feed │ ├── FeedScreen.tsx │ └── FloatingMenu.tsx ├── Follows │ ├── FollowedCommunity.tsx │ ├── FollowsList.tsx │ ├── FollowsScreen.tsx │ └── SavedFeed.tsx ├── HomeScreen.tsx ├── LoginScreen.tsx ├── Post │ ├── Comment.tsx │ ├── Comment │ │ ├── CommentIconRow.tsx │ │ └── CommentTitle.tsx │ ├── CommentsFlatlist.tsx │ ├── CommentsFloatingMenu.tsx │ └── PostScreen.tsx ├── PostWrite.tsx ├── Profile │ ├── Bio.tsx │ ├── Counters.tsx │ ├── OwnComments.tsx │ ├── OwnPosts.tsx │ ├── Profile.tsx │ ├── ProfileScreen.tsx │ └── UserRow.tsx ├── Search │ ├── ListComponents.tsx │ ├── SearchScreen.tsx │ └── SearchSettings.tsx ├── Settings │ ├── Behavior.tsx │ ├── Looks.tsx │ └── ProfileSettings.tsx ├── SettingsScreen.tsx ├── Unreads │ ├── Mentions.tsx │ ├── MessageWrite.tsx │ ├── Messages.tsx │ ├── Replies.tsx │ └── Unreads.tsx └── User │ ├── User.tsx │ └── UserScreen.tsx ├── ThemedComponents ├── Icon.tsx ├── Text.tsx ├── TextInput.tsx ├── TouchableOpacity.tsx └── index.ts ├── app.json ├── assets ├── adaptive-icon.png ├── favicon.png ├── icon.png ├── monochrome.png └── splash.png ├── asyncStorage.ts ├── babel.config.js ├── build.sh ├── commonStyles.ts ├── components ├── AccountPicker │ └── AccountPicker.tsx ├── CommunityIcon.tsx ├── DynamicHeader.tsx ├── FAB.tsx ├── MdRenderer.tsx ├── Pagination.tsx ├── Post │ ├── Embed.tsx │ ├── ExpandedPost.tsx │ ├── FeedPost.tsx │ ├── ImageViewer.tsx │ ├── Media.tsx │ ├── PostBadges.tsx │ ├── PostIconRow.tsx │ ├── PostTitle.tsx │ └── TinyPost.tsx ├── Prompt.tsx └── TinyComment.tsx ├── eas.json ├── package-lock.json ├── package.json ├── services └── apiService.ts ├── store ├── apiClient.ts ├── commentsStore.ts ├── communityStore.ts ├── dataClass.ts ├── debugStore.ts ├── mentionsStore.ts ├── postStore.ts ├── preferences.ts ├── profileStore.ts └── searchStore.ts ├── tsconfig.json └── utils ├── useKeyboard.ts └── utils.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ["https://www.buymeacoffee.com/nickdelirium"] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | # Temporary files created by Metro to check the health of the file watcher 17 | .metro-health-check* 18 | android/keystores/release.keystore 19 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 15 | 16 | 23 | 24 | 31 | 32 | 39 | 40 | 47 | 48 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/meowler.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | assets 2 | .expo 3 | node_modules -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "importOrderSeparation": true, 3 | "importOrderSortSpecifiers": true, 4 | "importOrder": [ 5 | "^react(-native)?$", 6 | "", 7 | "^@core/(.*)$", 8 | "^@server/(.*)$", 9 | "^@ui/(.*)$", 10 | "^[./]" 11 | ], 12 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 13 | } 14 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StatusBar, useColorScheme } from "react-native"; 3 | 4 | import { ActionSheetProvider } from "@expo/react-native-action-sheet"; 5 | import { NavigationContainer } from "@react-navigation/native"; 6 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; 7 | import { observer } from "mobx-react-lite"; 8 | import { SafeAreaProvider } from "react-native-safe-area-context"; 9 | 10 | import AddAccount from "./Screens/AddAccount"; 11 | import BlocksScreen from "./Screens/Blocks/BlocksScreen"; 12 | import CommentWrite from "./Screens/CommentWrite/CommentWrite"; 13 | import CommunityScreen from "./Screens/Community/CommunityScreen"; 14 | import DebugScreen from "./Screens/DebugScreen"; 15 | import HomeScreen from "./Screens/HomeScreen"; 16 | import LoginScreen from "./Screens/LoginScreen"; 17 | import PostScreen from "./Screens/Post/PostScreen"; 18 | import PostWrite from "./Screens/PostWrite"; 19 | import Behavior from "./Screens/Settings/Behavior"; 20 | import Looks from "./Screens/Settings/Looks"; 21 | import ProfileSettings from "./Screens/Settings/ProfileSettings"; 22 | import SettingsScreen from "./Screens/SettingsScreen"; 23 | import MessageWrite from "./Screens/Unreads/MessageWrite"; 24 | import UserScreen from "./Screens/User/UserScreen"; 25 | import { Icon } from "./ThemedComponents"; 26 | import { AppAmoledTheme, AppDarkTheme, AppTheme } from "./commonStyles"; 27 | import Prompt from "./components/Prompt"; 28 | import { ReportMode, apiClient } from "./store/apiClient"; 29 | import { Theme, preferences } from "./store/preferences"; 30 | 31 | const Stack = createNativeStackNavigator(); 32 | 33 | const App = observer(() => { 34 | const scheme = useColorScheme(); 35 | 36 | const systemTheme = scheme === "dark" ? AppDarkTheme : AppTheme; 37 | const isLightStatusBar = 38 | preferences.theme === Theme.System 39 | ? scheme !== "dark" 40 | : preferences.theme === Theme.Light; 41 | 42 | const schemeMap = { 43 | [Theme.System]: systemTheme, 44 | [Theme.Light]: AppTheme, 45 | [Theme.Dark]: AppDarkTheme, 46 | [Theme.Amoled]: AppAmoledTheme, 47 | }; 48 | 49 | const sendReport = (text: string) => { 50 | if (apiClient.reportMode === ReportMode.Post) { 51 | apiClient.api 52 | .createPostReport({ 53 | post_id: apiClient.reportedItemId, 54 | reason: text, 55 | }) 56 | .then(() => { 57 | closeReport(); 58 | }); 59 | } else { 60 | apiClient.api 61 | .createCommentReport({ 62 | comment_id: apiClient.reportedItemId, 63 | reason: text, 64 | }) 65 | .then(() => { 66 | closeReport(); 67 | }); 68 | } 69 | }; 70 | 71 | const closeReport = () => { 72 | apiClient.setShowPrompt(false); 73 | }; 74 | 75 | const reportMode = apiClient.reportMode; 76 | const promptActions = 77 | reportMode !== ReportMode.Off 78 | ? { 79 | onCancel: closeReport, 80 | onConfirm: sendReport, 81 | } 82 | : apiClient.promptActions; 83 | return ( 84 | 85 | {/* I don't really remember how it works */} 86 | 90 | 91 | 92 | 93 | 98 | 103 | 108 | 113 | 118 | , 121 | }} 122 | name="Community" 123 | component={CommunityScreen} 124 | /> 125 | 126 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | {apiClient.showPrompt ? ( 140 | 152 | ) : null} 153 | 154 | 155 | 156 | ); 157 | }); 158 | 159 | export default App; 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arctius for Lemmy 2 | 3 | #### (Previously: Fennec for Lemmy) 4 | 5 | Minimalistic and easy to use Lemmy client written for general public enjoyment, my personal experience and RN skills improvement. 6 | I hope that it will be easy and fun to use for everyone, including people with impaired vision abilities. 7 | 8 | # Installation 9 | 10 | **With [Obtainium](https://github.com/ImranR98/Obtainium/releases):** download [Obtainium](https://github.com/ImranR98/Obtainium/releases), then go to "Add App", type in "https://github.com/nick-delirium/lemmy-fennec/" to the first field and add the app. Voila! Now you will get all new releases automatically or notified about it if you will chose "track only" mode. 11 | 12 | **Manual download:** 13 | [Latest release](https://github.com/nick-delirium/lemmy-fennec/releases/latest) 14 | 15 | **Google play:** 16 | [Here](https://play.google.com/store/apps/details?id=com.nick.delirium.arctius) 17 | 18 | # Why React Native? 19 | 20 | Very easy to start with, easy to contribute and runs super well with new Hermes engine (almost no difference vs flutter in performance). This app outputs 120 fps and I'm looking into every possible option to optimize it when I'm adding something. 21 | 22 | # Want to help, found any bad code or bugs? 23 | 24 | Start a PR or issue, I'll be happy to see it. Same for feature requests. 25 | 26 | Lemmy community: [here](https://lemmy.world/c/arctius) 27 | 28 | todo board: [See status here](https://github.com/users/nick-delirium/projects/2) 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | # Special thanks and credits: 38 | 39 | - https://github.com/LemmyNet/lemmy-js-client - lemmy js client with types and all the good things (MIT) 40 | 41 | - https://github.com/gmsgowtham/react-native-marked - for MD rendering library (MIT) 42 | 43 | - https://github.com/jobtoday/react-native-image-viewing - image viewer (MIT) 44 | 45 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/nickdelirium) 46 | -------------------------------------------------------------------------------- /Screens/Blocks/BlockedCommunities.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlatList, Image, StyleSheet, View } from "react-native"; 3 | 4 | import { CommunityBlockView } from "lemmy-js-client"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 8 | import { apiClient } from "../../store/apiClient"; 9 | import { hostname } from "../Search/ListComponents"; 10 | 11 | function BlockedCommunities() { 12 | const blockedCommunities = apiClient.profileStore.blockedCommunities; 13 | 14 | const renderItem = ({ item }: { item: CommunityBlockView }) => ( 15 | 16 | ); 17 | return ( 18 | 19 | item.community.actor_id} 25 | ListEmptyComponent={Nothing so far...} 26 | /> 27 | 28 | ); 29 | } 30 | 31 | function RenderCommunity({ item }: { item: CommunityBlockView }) { 32 | const commName = `${item.community.name}@${hostname( 33 | item.community.actor_id 34 | )}`; 35 | 36 | const unblock = () => { 37 | apiClient.communityStore 38 | .blockCommunity(item.community.id, false) 39 | .then(() => { 40 | apiClient.profileStore.setBlocks( 41 | apiClient.profileStore.blockedPeople, 42 | apiClient.profileStore.blockedCommunities.filter( 43 | (c) => c.community.id !== item.community.id 44 | ) 45 | ); 46 | }); 47 | }; 48 | return ( 49 | 50 | 51 | 56 | 57 | {commName} 58 | 59 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | const styles = StyleSheet.create({ 72 | container: { flex: 1, padding: 8 }, 73 | communityIcon: { width: 28, height: 28, borderRadius: 28 }, 74 | iconContainer: { 75 | width: 28, 76 | height: 28, 77 | borderRadius: 28, 78 | backgroundColor: "#cecece", 79 | }, 80 | spacer: { flex: 1 }, 81 | touchableIcon: { padding: 4 }, 82 | row: { flexDirection: "row", alignItems: "center", gap: 8 }, 83 | title: { fontSize: 16 }, 84 | }); 85 | 86 | export default observer(BlockedCommunities); 87 | -------------------------------------------------------------------------------- /Screens/Blocks/BlockedUsers.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlatList, Image, StyleSheet, View } from "react-native"; 3 | 4 | import { PersonBlockView } from "lemmy-js-client"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 8 | import { apiClient } from "../../store/apiClient"; 9 | import { hostname } from "../Search/ListComponents"; 10 | 11 | function BlockedUsers() { 12 | const blockedUsers = apiClient.profileStore.blockedPeople; 13 | 14 | const renderItem = ({ item }: { item: PersonBlockView }) => ( 15 | 16 | ); 17 | return ( 18 | 19 | item.person.actor_id} 25 | ListEmptyComponent={Nothing so far...} 26 | /> 27 | 28 | ); 29 | } 30 | 31 | function RenderUser({ item }: { item: PersonBlockView }) { 32 | const userName = `${item.target.name}@${hostname(item.target.actor_id)}`; 33 | 34 | const unblock = () => { 35 | void apiClient.profileStore.blockPerson(item.target.id, false); 36 | }; 37 | return ( 38 | 39 | 40 | 45 | 46 | {userName} 47 | 48 | 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | const styles = StyleSheet.create({ 61 | container: { flex: 1, padding: 8 }, 62 | communityIcon: { width: 28, height: 28, borderRadius: 28 }, 63 | iconContainer: { 64 | width: 28, 65 | height: 28, 66 | borderRadius: 28, 67 | backgroundColor: "#cecece", 68 | }, 69 | spacer: { flex: 1 }, 70 | touchableIcon: { padding: 4 }, 71 | row: { flexDirection: "row", alignItems: "center", gap: 8 }, 72 | title: { fontSize: 16 }, 73 | }); 74 | 75 | export default observer(BlockedUsers); 76 | -------------------------------------------------------------------------------- /Screens/Blocks/BlocksScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; 4 | 5 | import BlockedCommunities from "./BlockedCommunities"; 6 | import BlockedUsers from "./BlockedUsers"; 7 | 8 | const Tab = createMaterialTopTabNavigator(); 9 | 10 | function BlocksScreen() { 11 | return ( 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export default BlocksScreen; 20 | -------------------------------------------------------------------------------- /Screens/Community/CommunityFeed.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlatList, StyleSheet, View } from "react-native"; 3 | 4 | import { PostView } from "lemmy-js-client"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 8 | import { commonStyles } from "../../commonStyles"; 9 | import FeedPost from "../../components/Post/FeedPost"; 10 | import TinyPost from "../../components/Post/TinyPost"; 11 | import { apiClient } from "../../store/apiClient"; 12 | import { preferences } from "../../store/preferences"; 13 | import FloatingMenu from "../Feed/FloatingMenu"; 14 | 15 | function CommunityFeed({ navigation }: { navigation: any }) { 16 | const { community } = apiClient.communityStore; 17 | const listRef = React.useRef>(null); 18 | 19 | React.useEffect(() => { 20 | if (navigation && listRef.current) { 21 | const scrollUp = () => 22 | listRef.current.scrollToOffset({ animated: true, offset: 0 }); 23 | 24 | navigation.getParent().setOptions({ 25 | headerRight: () => ( 26 | 31 | 32 | 33 | ), 34 | }); 35 | } 36 | }, [navigation, listRef.current]); 37 | 38 | const renderPost = React.useCallback( 39 | ({ item }) => { 40 | return preferences.compactPostLayout ? ( 41 | // @ts-ignore 42 | 43 | ) : ( 44 | // @ts-ignore 45 | 46 | ); 47 | }, 48 | [preferences.compactPostLayout] 49 | ); 50 | const extractor = React.useCallback((p) => p.post.id.toString(), []); 51 | const onEndReached = React.useCallback(() => { 52 | if (apiClient.postStore.posts.length === 0) return; 53 | void apiClient.postStore.nextPage(community.community.id); 54 | }, [community]); 55 | const onRefresh = React.useCallback(() => { 56 | apiClient.postStore.setCommPage(1); 57 | void apiClient.postStore.getPosts(community.community.id); 58 | }, [community]); 59 | 60 | const onPostScroll = React.useRef(({ changed }) => { 61 | if (changed.length > 0 && apiClient.loginDetails?.jwt) { 62 | changed.forEach((item) => { 63 | if (!item.isViewable && preferences.getReadOnScroll()) { 64 | void apiClient.postStore.markPostRead({ 65 | post_ids: [item.item.post.id], 66 | read: true, 67 | }); 68 | } 69 | }); 70 | } 71 | }).current; 72 | 73 | const createPost = () => { 74 | navigation.navigate("PostWrite", { 75 | communityName: community.community.name, 76 | communityId: community.community.id, 77 | }); 78 | }; 79 | 80 | // feedKey is a hack for autoscroll; force rerender on each feed update 81 | return ( 82 | 83 | 87 | No posts here so far... 88 | 89 | } 90 | style={{ flex: 1, width: "100%" }} 91 | renderItem={renderPost} 92 | data={apiClient.postStore.communityPosts} 93 | onRefresh={onRefresh} 94 | onEndReached={onEndReached} 95 | refreshing={apiClient.postStore.isLoading} 96 | onEndReachedThreshold={0.5} 97 | keyExtractor={extractor} 98 | fadingEdgeLength={1} 99 | onViewableItemsChanged={onPostScroll} 100 | /> 101 | 107 | New Post 108 | 109 | ) : null 110 | } 111 | /> 112 | 113 | ); 114 | } 115 | 116 | const ownStyles = StyleSheet.create({ 117 | emptyContainer: { 118 | padding: 12, 119 | flex: 1, 120 | }, 121 | empty: { 122 | fontSize: 16, 123 | }, 124 | }); 125 | 126 | export default observer(CommunityFeed); 127 | -------------------------------------------------------------------------------- /Screens/Community/CommunityInfos.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Image, Linking, ScrollView, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { Text, TouchableOpacity } from "../../ThemedComponents"; 8 | import MdRenderer from "../../components/MdRenderer"; 9 | import { apiClient } from "../../store/apiClient"; 10 | 11 | function CommunityInfos({ navigation, route }) { 12 | const { colors } = useTheme(); 13 | const { community } = apiClient.communityStore; 14 | React.useEffect(() => { 15 | if (community === null && !apiClient.communityStore.isLoading) { 16 | void apiClient.communityStore.getCommunity( 17 | apiClient.loginDetails, 18 | route.params.id, 19 | route.params.name 20 | ); 21 | } 22 | }, [community, navigation]); 23 | 24 | const onProfileUrlPress = async () => { 25 | try { 26 | await Linking.openURL(community.community.actor_id); 27 | } catch (e) { 28 | console.error(e); 29 | } 30 | }; 31 | 32 | const showFollow = Boolean(apiClient.loginDetails?.jwt); 33 | const isFollowing = community.subscribed === "Subscribed"; 34 | const followingStr = 35 | community.subscribed === "Pending" 36 | ? community.subscribed 37 | : isFollowing 38 | ? "Unsubscribe" 39 | : "Subscribe"; 40 | 41 | const follow = () => { 42 | if (community.subscribed === "Pending") return; 43 | void apiClient.communityStore.followCommunity( 44 | community.community.id, 45 | !isFollowing 46 | ); 47 | }; 48 | 49 | const createPost = () => { 50 | navigation.navigate("PostWrite", { 51 | communityName: community.community.name, 52 | communityId: community.community.id, 53 | }); 54 | }; 55 | 56 | const toggleBlockCommunity = () => { 57 | void apiClient.communityStore.blockCommunity( 58 | community.community.id, 59 | !community.blocked 60 | ); 61 | }; 62 | 63 | const canPost = 64 | !community?.community.posting_restricted_to_mods || 65 | apiClient.profileStore.moderatedCommunities.findIndex( 66 | (c) => c.community.id === community.community.id 67 | ) !== -1; 68 | 69 | return ( 70 | 71 | 72 | {community.community.icon ? ( 73 | 77 | ) : null} 78 | {/* title information and link */} 79 | 80 | {community.community.name} 81 | 82 | 83 | {community.community.nsfw ? ( 84 | 85 | NSFW 86 | 87 | ) : null} 88 | 89 | 90 | {community.community.actor_id} 91 | 92 | 93 | 94 | 95 | 96 | {/* counters */} 97 | 98 | {community.counts.posts} posts 99 | 100 | {community.counts.subscribers} subscribers 101 | 102 | 103 | {community.counts.users_active_day} daily users 104 | 105 | 106 | 107 | {showFollow ? ( 108 | <> 109 | 110 | {followingStr} 111 | 112 | {canPost ? ( 113 | 114 | New Post 115 | 116 | ) : null} 117 | 121 | {community.blocked ? "Unblock" : "Block"} 122 | 123 | 124 | ) : null} 125 | 126 | {community.community.description ? ( 127 | 131 | 132 | 133 | ) : null} 134 | 135 | ); 136 | } 137 | 138 | const styles = StyleSheet.create({ 139 | header: { flexDirection: "row", alignItems: "center", padding: 8, gap: 8 }, 140 | counts: { 141 | flexDirection: "row", 142 | alignItems: "center", 143 | padding: 8, 144 | justifyContent: "space-between", 145 | }, 146 | counter: { 147 | fontSize: 16, 148 | fontWeight: "500", 149 | }, 150 | communityIcon: { width: 56, height: 56, borderRadius: 48 }, 151 | title: { fontSize: 22, fontWeight: "bold", flex: 1 }, 152 | titleRow: { 153 | gap: 8, 154 | flexDirection: "row", 155 | alignItems: "center", 156 | flex: 1, 157 | }, 158 | wrapper: { flex: 1, width: "100%", paddingHorizontal: 6 }, 159 | buttons: { flexDirection: "row", gap: 8, flex: 1 }, 160 | badge: { 161 | backgroundColor: "red", 162 | borderRadius: 4, 163 | paddingVertical: 4, 164 | paddingHorizontal: 8, 165 | }, 166 | badgeText: { color: "white", fontWeight: "bold", fontSize: 12 }, 167 | }); 168 | 169 | export default observer(CommunityInfos); 170 | -------------------------------------------------------------------------------- /Screens/Community/CommunityScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ActivityIndicator } from "react-native"; 3 | 4 | import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; 5 | import { NativeStackScreenProps } from "@react-navigation/native-stack"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { apiClient } from "../../store/apiClient"; 9 | import { communityStore } from "../../store/communityStore"; 10 | import CommunityFeed from "./CommunityFeed"; 11 | import CommunityInfos from "./CommunityInfos"; 12 | 13 | const Tab = createMaterialTopTabNavigator(); 14 | 15 | function CommunityScreen({ 16 | navigation, 17 | route, 18 | }: NativeStackScreenProps) { 19 | const commId = route.params.id; 20 | const name = route.params.name; 21 | const { community } = apiClient.communityStore; 22 | 23 | const fetchedName = community?.community.name; 24 | const fetchedId = community?.community.id; 25 | 26 | React.useEffect(() => { 27 | const nameWithoutInst = 28 | name && name.includes("@") ? name.split("@")[0] : name; 29 | const getData = () => { 30 | if ( 31 | (fetchedId === commId || fetchedName === nameWithoutInst) && 32 | apiClient.postStore.communityPosts.length > 0 33 | ) { 34 | return; 35 | } else { 36 | if (commId || name) { 37 | apiClient.postStore.setCommPage(1); 38 | void apiClient.postStore.getPosts(commId, name); 39 | void apiClient.communityStore.getCommunity(commId, name); 40 | } 41 | } 42 | }; 43 | 44 | const unsubscribe = navigation.addListener("focus", () => { 45 | getData(); 46 | }); 47 | getData(); 48 | 49 | return unsubscribe; 50 | }, [ 51 | commId, 52 | name, 53 | apiClient.postStore.communityPosts.length, 54 | fetchedName, 55 | fetchedId, 56 | ]); 57 | 58 | React.useEffect(() => { 59 | if (communityStore.community !== null && apiClient.postStore) { 60 | navigation.setOptions({ 61 | title: `${communityStore.community.community.title} | ${apiClient.postStore.filters.sort}`, 62 | }); 63 | } 64 | }, [navigation, apiClient.postStore, communityStore.community]); 65 | 66 | if (apiClient.communityStore.isLoading || !community) 67 | return ; 68 | 69 | return ( 70 | 71 | 78 | 85 | 86 | ); 87 | } 88 | 89 | export default observer(CommunityScreen); 90 | -------------------------------------------------------------------------------- /Screens/DebugScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlatList, View } from "react-native"; 3 | 4 | import { observer } from "mobx-react-lite"; 5 | 6 | import { Text } from "../ThemedComponents"; 7 | import { debugStore } from "../store/debugStore"; 8 | 9 | function DebugScreen() { 10 | return ( 11 | 12 | All good so far!} 16 | renderItem={({ item }) => ( 17 | 22 | {item} 23 | 24 | )} 25 | /> 26 | 27 | ); 28 | } 29 | 30 | export default observer(DebugScreen); 31 | -------------------------------------------------------------------------------- /Screens/Follows/FollowedCommunity.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { useNavigation } from "@react-navigation/native"; 5 | import { Community as ICommunity } from "lemmy-js-client"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { Icon, TouchableOpacity } from "../../ThemedComponents"; 9 | import { apiClient } from "../../store/apiClient"; 10 | import { Community } from "../Search/ListComponents"; 11 | 12 | function FollowedCommunity({ item }: { item: ICommunity }) { 13 | const navigation = useNavigation(); 14 | const isFavorite = 15 | apiClient.communityStore.favoriteCommunities.findIndex( 16 | (i) => i.id === item.id 17 | ) !== -1; 18 | const onPress = () => { 19 | if (isFavorite) { 20 | apiClient.communityStore.removeFromFavorites(item); 21 | } else { 22 | apiClient.communityStore.addToFavorites(item); 23 | } 24 | }; 25 | return ( 26 | 27 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | const styles = StyleSheet.create({ 41 | row: { 42 | flexDirection: "row", 43 | alignItems: "center", 44 | gap: 4, 45 | paddingHorizontal: 6, 46 | paddingVertical: 3, 47 | }, 48 | }); 49 | 50 | export default observer(FollowedCommunity); 51 | -------------------------------------------------------------------------------- /Screens/Follows/FollowsList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ActivityIndicator, FlatList, StyleSheet, View } from "react-native"; 3 | 4 | import { observer } from "mobx-react-lite"; 5 | 6 | import { Text } from "../../ThemedComponents"; 7 | import { apiClient } from "../../store/apiClient"; 8 | import FollowedCommunity from "./FollowedCommunity"; 9 | 10 | function FollowsList() { 11 | const renderItem = ({ item }) => ; 12 | return ( 13 | 14 | {apiClient.communityStore.isLoading ? : null} 15 | 18 | Nothing here yet. 19 | 20 | Want a recommendation? Try searching "{getRandomPhrase()}". 21 | 22 | 23 | } 24 | onRefresh={apiClient.getGeneralData} 25 | data={[ 26 | ...apiClient.communityStore.favoriteCommunities, 27 | ...apiClient.communityStore.regularFollowedCommunities, 28 | ]} 29 | refreshing={apiClient.isLoading} 30 | renderItem={renderItem} 31 | keyExtractor={(item) => item.id.toString()} 32 | /> 33 | 34 | ); 35 | } 36 | 37 | const randomSublemmy = [ 38 | "asklemmy", 39 | "Technology", 40 | "Memes", 41 | "Gaming", 42 | "Chat", 43 | "Mildly Infuriating", 44 | "Lemmy Shitpost", 45 | "Showerthoughts", 46 | ]; 47 | 48 | const ownStyles = StyleSheet.create({ 49 | emptyContainer: { 50 | padding: 12, 51 | flex: 1, 52 | }, 53 | empty: { 54 | fontSize: 16, 55 | }, 56 | }); 57 | 58 | function getRandomPhrase() { 59 | return randomSublemmy[Math.floor(Math.random() * randomSublemmy.length)]; 60 | } 61 | 62 | export default observer(FollowsList); 63 | -------------------------------------------------------------------------------- /Screens/Follows/FollowsScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; 4 | 5 | import FollowsList from "./FollowsList"; 6 | import SavedFeed from "./SavedFeed"; 7 | 8 | const Tab = createMaterialTopTabNavigator(); 9 | 10 | function FollowsScreen() { 11 | return ( 12 | 13 | 14 | 21 | 22 | ); 23 | } 24 | 25 | export default FollowsScreen; 26 | -------------------------------------------------------------------------------- /Screens/Follows/SavedFeed.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlatList, View } from "react-native"; 3 | 4 | import { NativeStackScreenProps } from "@react-navigation/native-stack"; 5 | import { PostView } from "lemmy-js-client"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { commonStyles } from "../../commonStyles"; 9 | import Pagination from "../../components/Pagination"; 10 | import FeedPost from "../../components/Post/FeedPost"; 11 | import TinyPost from "../../components/Post/TinyPost"; 12 | import { apiClient } from "../../store/apiClient"; 13 | import { preferences } from "../../store/preferences"; 14 | 15 | function SavedFeed({ navigation }: NativeStackScreenProps) { 16 | const isFocused = navigation.isFocused(); 17 | const listRef = React.useRef>(null); 18 | 19 | React.useEffect(() => { 20 | const getPosts = () => { 21 | if (apiClient.api && apiClient.postStore.savedPosts.length === 0) { 22 | void apiClient.postStore.getSavedPosts(); 23 | } 24 | }; 25 | 26 | const unsubscribe = navigation.addListener("focus", () => { 27 | getPosts(); 28 | }); 29 | 30 | getPosts(); 31 | return unsubscribe; 32 | }, [apiClient.api, navigation, isFocused]); 33 | 34 | const renderPost = React.useCallback( 35 | ({ item }) => { 36 | return preferences.compactPostLayout ? ( 37 | 38 | ) : ( 39 | 40 | ); 41 | }, 42 | [preferences.compactPostLayout] 43 | ); 44 | const extractor = React.useCallback((p) => p.post.id.toString(), []); 45 | const onEndReached = React.useCallback(() => { 46 | if (apiClient.postStore.savedPosts.length === 0) return; 47 | void apiClient.postStore.changeSavedPage( 48 | apiClient.postStore.savedPostsPage + 1, 49 | !preferences.paginatedFeed 50 | ); 51 | }, [apiClient.postStore.savedPosts.length]); 52 | const onRefresh = React.useCallback(() => { 53 | apiClient.postStore.setSavedPostsPage(1); 54 | void apiClient.postStore.getSavedPosts(); 55 | }, []); 56 | 57 | // ref will be kept in memory in-between renders 58 | const onPostScroll = React.useRef(({ changed }) => { 59 | if (changed.length > 0 && apiClient.loginDetails?.jwt) { 60 | changed.forEach((item) => { 61 | if ( 62 | !item.item.read && 63 | !item.isViewable && 64 | preferences.getReadOnScroll() 65 | ) { 66 | void apiClient.postStore.markPostRead({ 67 | post_ids: [item.item.post.id], 68 | read: true, 69 | }); 70 | } 71 | }); 72 | } 73 | }).current; 74 | 75 | const nextPage = React.useCallback(() => { 76 | if (apiClient.postStore.savedPosts.length === 0) return; 77 | listRef.current.scrollToOffset({ animated: true, offset: 0 }); 78 | void apiClient.postStore.changeSavedPage( 79 | apiClient.postStore.savedPostsPage + 1 80 | ); 81 | }, []); 82 | const prevPage = React.useCallback(() => { 83 | if (apiClient.postStore.savedPosts.length === 0) return; 84 | listRef.current.scrollToOffset({ animated: true, offset: 0 }); 85 | void apiClient.postStore.changeSavedPage( 86 | apiClient.postStore.savedPostsPage - 1 87 | ); 88 | }, []); 89 | 90 | // feedKey is a hack for autoscroll 91 | return ( 92 | 93 | 116 | ) : undefined 117 | } 118 | /> 119 | 120 | ); 121 | } 122 | 123 | export default observer(SavedFeed); 124 | -------------------------------------------------------------------------------- /Screens/HomeScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Feather } from "@expo/vector-icons"; 4 | import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; 5 | import { getFocusedRouteNameFromRoute } from "@react-navigation/native"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { Icon } from "../ThemedComponents"; 9 | import { apiClient } from "../store/apiClient"; 10 | import Feed from "./Feed/FeedScreen"; 11 | import FollowsScreen from "./Follows/FollowsScreen"; 12 | import Profile from "./Profile/ProfileScreen"; 13 | import Search from "./Search/SearchScreen"; 14 | import Unreads from "./Unreads/Unreads"; 15 | 16 | const Tab = createBottomTabNavigator(); 17 | 18 | function HomeScreen() { 19 | const jwt = apiClient.loginDetails?.jwt; 20 | const isLoggedIn = apiClient.isLoggedIn; 21 | React.useEffect(() => { 22 | if (jwt) { 23 | void apiClient.mentionsStore.fetchUnreads(); 24 | } 25 | }, [jwt]); 26 | const unreadCount = apiClient.mentionsStore.unreadsCount; 27 | const displayedUnreads = unreadCount > 99 ? "99+" : unreadCount; 28 | 29 | return ( 30 | ({ 32 | tabBarIcon: ({ color, size }) => { 33 | switch (route.name) { 34 | case "Feed": 35 | return ; 36 | case "Profile": 37 | return ; 38 | case "Search": 39 | return ; 40 | case "Followed Communities": 41 | return ; 42 | case "Unreads": 43 | return ; 44 | default: 45 | return ; 46 | } 47 | }, 48 | tabBarShowLabel: false, 49 | tabBarAccessibilityLabel: `Tab bar route - ${route.name}`, 50 | })} 51 | initialRouteName={"Feed"} 52 | > 53 | 60 | {isLoggedIn ? ( 61 | <> 62 | 63 | 0 ? displayedUnreads : undefined, 68 | }} 69 | /> 70 | 71 | ) : null} 72 | 73 | 74 | 75 | ); 76 | } 77 | 78 | export default observer(HomeScreen); 79 | -------------------------------------------------------------------------------- /Screens/Post/Comment.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Platform, Share, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { setStringAsync } from "expo-clipboard"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { Text, TouchableOpacity } from "../../ThemedComponents"; 9 | import { commonColors } from "../../commonStyles"; 10 | import MdRenderer from "../../components/MdRenderer"; 11 | import { ReportMode, Score, apiClient } from "../../store/apiClient"; 12 | import { CommentNode } from "../../store/commentsStore"; 13 | import { preferences } from "../../store/preferences"; 14 | import CommentIconRow from "./Comment/CommentIconRow"; 15 | import CommentTitle from "./Comment/CommentTitle"; 16 | 17 | function Comment({ 18 | comment, 19 | hide, 20 | isExpanded, 21 | getAuthor, 22 | openCommenting, 23 | }: { 24 | comment: CommentNode; 25 | isExpanded: boolean; 26 | hide?: () => void; 27 | getAuthor?: (author: number) => void; 28 | openCommenting?: () => void; 29 | }) { 30 | const { colors } = useTheme(); 31 | 32 | const shareComment = React.useCallback(() => { 33 | void Share.share( 34 | { 35 | url: comment.comment.ap_id, 36 | message: Platform.OS === "ios" ? "" : comment.comment.ap_id, 37 | title: comment.comment.ap_id, 38 | }, 39 | { dialogTitle: comment.comment.ap_id } 40 | ); 41 | }, [comment.my_vote]); 42 | const upvoteComment = React.useCallback(() => { 43 | if (!apiClient.loginDetails?.jwt) return; 44 | void apiClient.commentsStore.rateComment( 45 | comment.comment.id, 46 | comment.my_vote === Score.Upvote ? Score.Neutral : Score.Upvote 47 | ); 48 | }, [comment.my_vote]); 49 | const downvoteComment = React.useCallback(() => { 50 | if (!apiClient.loginDetails?.jwt) return; 51 | void apiClient.commentsStore.rateComment( 52 | comment.comment.id, 53 | comment.my_vote === Score.Downvote ? Score.Neutral : Score.Downvote 54 | ); 55 | }, []); 56 | 57 | const replyToComment = React.useCallback(() => { 58 | if (!openCommenting || !apiClient.loginDetails?.jwt) return; 59 | apiClient.commentsStore.setReplyTo({ 60 | postId: comment.post.id, 61 | parent_id: comment.comment.id, 62 | title: comment.post.name, 63 | community: comment.community.name, 64 | published: comment.comment.published, 65 | author: comment.creator.name, 66 | content: comment.comment.content, 67 | language_id: comment.comment.language_id, 68 | }); 69 | openCommenting(); 70 | }, [openCommenting]); 71 | const editComment = React.useCallback(() => { 72 | if (!openCommenting || !apiClient.loginDetails?.jwt) return; 73 | apiClient.commentsStore.setReplyTo({ 74 | postId: comment.post.id, 75 | parent_id: comment.comment.id, 76 | title: comment.post.name, 77 | community: comment.community.name, 78 | published: comment.comment.published, 79 | author: comment.creator.name, 80 | content: comment.comment.content, 81 | language_id: comment.comment.language_id, 82 | isEdit: true, 83 | }); 84 | openCommenting(); 85 | }, [openCommenting]); 86 | 87 | const scoreColor = React.useMemo(() => { 88 | return comment.my_vote 89 | ? comment.my_vote === Score.Upvote 90 | ? preferences.voteColors.upvote 91 | : preferences.voteColors.downvote 92 | : undefined; 93 | }, [comment.my_vote]); 94 | 95 | const openReporting = () => { 96 | apiClient.setReportMode(ReportMode.Comment, comment.comment.id); 97 | apiClient.setShowPrompt(true); 98 | }; 99 | 100 | const onCopy = () => { 101 | void setStringAsync(comment.comment.content); 102 | }; 103 | 104 | const selfComment = 105 | apiClient.profileStore.localUser?.person.id === comment.creator.id; 106 | return ( 107 | 108 | 109 | 110 | {!isExpanded && preferences.collapseParentComment ? ( 111 | 112 | {comment.comment.content} 113 | 114 | ) : ( 115 | 116 | 117 | 118 | )} 119 | 120 | 136 | 137 | ); 138 | } 139 | 140 | const styles = StyleSheet.create({ 141 | container: { 142 | padding: 8, 143 | borderBottomWidth: 1, 144 | }, 145 | topRow: { 146 | flexDirection: "row", 147 | justifyContent: "space-between", 148 | }, 149 | infoPiece: { 150 | flexDirection: "row", 151 | gap: 8, 152 | alignItems: "center", 153 | }, 154 | date: { 155 | fontWeight: "300", 156 | fontSize: 12, 157 | }, 158 | author: { 159 | fontSize: 12, 160 | fontWeight: "500", 161 | color: "orange", 162 | }, 163 | bottomRow: { 164 | gap: 12, 165 | paddingTop: 4, 166 | paddingBottom: 4, 167 | }, 168 | row: { 169 | flexDirection: "row", 170 | gap: 6, 171 | alignItems: "center", 172 | }, 173 | op: { 174 | borderRadius: 4, 175 | paddingVertical: 2, 176 | paddingHorizontal: 4, 177 | backgroundColor: "red", 178 | }, 179 | opText: { 180 | fontSize: 10, 181 | fontWeight: "600", 182 | }, 183 | }); 184 | 185 | export default observer(Comment); 186 | -------------------------------------------------------------------------------- /Screens/Post/Comment/CommentTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { Icon, Text, TouchableOpacity } from "../../../ThemedComponents"; 5 | import { CommentNode } from "../../../store/commentsStore"; 6 | import { makeDateString } from "../../../utils/utils"; 7 | 8 | function CommentTitle({ 9 | comment, 10 | getAuthor, 11 | }: { 12 | comment: CommentNode; 13 | getAuthor?: (author: number) => void; 14 | }) { 15 | const commentDate = React.useMemo( 16 | () => makeDateString(comment.comment.published), 17 | [] 18 | ); 19 | return ( 20 | 21 | getAuthor(comment.creator.id)}> 22 | 23 | 24 | u/{comment.creator.display_name || comment.creator.name} 25 | 26 | {comment.post.creator_id === comment.creator.id ? ( 27 | 28 | OP 29 | 30 | ) : null} 31 | {comment.creator.bot_account ? ( 32 | 33 | BOT 34 | 35 | ) : null} 36 | {comment.creator.admin ? : null} 37 | 38 | 39 | {commentDate} 40 | 41 | ); 42 | } 43 | 44 | const styles = StyleSheet.create({ 45 | topRow: { 46 | flexDirection: "row", 47 | justifyContent: "space-between", 48 | }, 49 | date: { 50 | fontWeight: "300", 51 | fontSize: 12, 52 | }, 53 | author: { 54 | fontSize: 12, 55 | fontWeight: "500", 56 | color: "orange", 57 | }, 58 | row: { 59 | flexDirection: "row", 60 | gap: 6, 61 | alignItems: "center", 62 | }, 63 | op: { 64 | borderRadius: 4, 65 | paddingVertical: 2, 66 | paddingHorizontal: 4, 67 | backgroundColor: "red", 68 | }, 69 | opText: { 70 | fontSize: 10, 71 | fontWeight: "600", 72 | }, 73 | }); 74 | 75 | export default React.memo(CommentTitle); 76 | -------------------------------------------------------------------------------- /Screens/Post/CommentsFloatingMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ActivityIndicator, TouchableOpacity, View } from "react-native"; 3 | 4 | import { Theme, useTheme } from "@react-navigation/native"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { Icon, Text } from "../../ThemedComponents"; 8 | // I know its basically a copy paste of Feed FAB but I'm very lazy 9 | import { commonStyles } from "../../commonStyles"; 10 | import FAB from "../../components/FAB"; 11 | import { apiClient } from "../../store/apiClient"; 12 | import { CommentSortTypeMap } from "../../store/commentsStore"; 13 | 14 | function splitCamelCase(str: string) { 15 | return str.replace(/([a-z])([A-Z])/g, "$1 $2"); 16 | } 17 | 18 | const sortTypes = Object.values(CommentSortTypeMap).map((type) => ({ 19 | label: splitCamelCase(type), 20 | value: type, 21 | })); 22 | 23 | function CommentsFloatingMenu({ isLoading }: { isLoading: boolean }) { 24 | const { colors } = useTheme(); 25 | const [isSortOpen, setIsSortOpen] = React.useState(false); 26 | const [isOpen, setIsOpen] = React.useState(false); 27 | 28 | const switchToSort = () => { 29 | setIsSortOpen(true); 30 | setIsOpen(false); 31 | }; 32 | const openMenu = () => { 33 | setIsOpen(true); 34 | setIsSortOpen(false); 35 | }; 36 | const closeAll = () => { 37 | setIsOpen(false); 38 | setIsSortOpen(false); 39 | }; 40 | 41 | return ( 42 | 43 | {isSortOpen ? : null} 44 | {isOpen ? ( 45 | 46 | switchToSort()}> 47 | Change sorting type 48 | 49 | 50 | ) : null} 51 | {isLoading ? ( 52 | 55 | 56 | 57 | ) : ( 58 | (isOpen ? closeAll() : openMenu())}> 59 | 62 | 63 | 64 | 65 | )} 66 | 67 | ); 68 | } 69 | 70 | function SortMenu({ 71 | colors, 72 | closeSelf, 73 | }: { 74 | colors: Theme["colors"]; 75 | closeSelf: () => void; 76 | }) { 77 | const setSorting = ( 78 | sort: (typeof CommentSortTypeMap)[keyof typeof CommentSortTypeMap] 79 | ) => { 80 | void apiClient.commentsStore.setFilters({ sort: sort }); 81 | void apiClient.commentsStore.getComments( 82 | apiClient.postStore.singlePost.post.id 83 | ); 84 | closeSelf(); 85 | }; 86 | 87 | return ( 88 | 89 | {sortTypes.map((type) => ( 90 | setSorting(type.value)} 93 | > 94 | {type.label} 95 | 96 | ))} 97 | 98 | ); 99 | } 100 | 101 | export default observer(CommentsFloatingMenu); 102 | -------------------------------------------------------------------------------- /Screens/Post/PostScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ActivityIndicator, Animated, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { NativeStackScreenProps } from "@react-navigation/native-stack"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { Icon, TouchableOpacity } from "../../ThemedComponents"; 9 | import DynamicHeader from "../../components/DynamicHeader"; 10 | import ExpandedPost from "../../components/Post/ExpandedPost"; 11 | import { apiClient } from "../../store/apiClient"; 12 | import { preferences } from "../../store/preferences"; 13 | import CommentFlatList from "./CommentsFlatlist"; 14 | import CommentsFloatingMenu from "./CommentsFloatingMenu"; 15 | 16 | let lastOffset = 0; 17 | 18 | function PostScreen({ 19 | navigation, 20 | route, 21 | }: NativeStackScreenProps) { 22 | const scrollOffsetY = React.useRef(new Animated.Value(0)).current; 23 | 24 | const [showFab, setShowFab] = React.useState(true); 25 | const post = apiClient.postStore.singlePost; 26 | const openComment = route.params.openComment; 27 | const parentId = route.params.parentId; 28 | const { colors } = useTheme(); 29 | 30 | const refreshAll = () => { 31 | void apiClient.postStore.getSinglePost(route.params.post); 32 | void apiClient.commentsStore.getComments(post.post.id, parentId); 33 | }; 34 | 35 | React.useEffect(() => { 36 | if (route.params.post) { 37 | void apiClient.postStore.getSinglePost(route.params.post); 38 | } 39 | return () => { 40 | apiClient.postStore.setSinglePost(null); 41 | }; 42 | }, [route.params.post]); 43 | 44 | React.useEffect(() => { 45 | if (post) { 46 | if ( 47 | post.counts.comments > 0 48 | // apiClient.commentsStore.comments.length === 0 49 | ) { 50 | apiClient.commentsStore.setPage(1); 51 | void apiClient.commentsStore.getComments( 52 | post.post.id, 53 | parentId, 54 | Boolean(parentId) 55 | ); 56 | } 57 | } 58 | }, [post]); 59 | 60 | React.useEffect(() => { 61 | return () => { 62 | apiClient.commentsStore.setPage(1); 63 | apiClient.commentsStore.setComments([]); 64 | }; 65 | }, []); 66 | 67 | const onScroll = React.useCallback( 68 | (e: any) => { 69 | if (preferences.disableDynamicHeaders) return; 70 | const currentScrollY = e.nativeEvent.contentOffset.y; 71 | const deltaY = currentScrollY - lastOffset; 72 | const isGoingDown = currentScrollY > lastOffset; 73 | 74 | if (isGoingDown) { 75 | // @ts-ignore using internal value for dynamic animation 76 | scrollOffsetY.setValue(Math.min(scrollOffsetY._value + deltaY, 56)); 77 | } else { 78 | // @ts-ignore using internal value for dynamic animation 79 | scrollOffsetY.setValue(Math.max(scrollOffsetY._value + deltaY, 0)); 80 | } 81 | 82 | if (showFab !== !isGoingDown) setShowFab(!isGoingDown); 83 | 84 | lastOffset = currentScrollY; 85 | }, 86 | [showFab, scrollOffsetY, preferences.disableDynamicHeaders] 87 | ); 88 | 89 | if (!post) return ; 90 | 91 | const getAuthor = (id: number) => { 92 | navigation.navigate("User", { personId: id }); 93 | }; 94 | 95 | const openCommenting = () => { 96 | navigation.navigate("CommentWrite"); 97 | }; 98 | 99 | const onEndReached = () => { 100 | // I'm fairly sure that they return everything at the moment, no matter the limit/page. 101 | return console.log("endreached", apiClient.commentsStore.comments.length); 102 | // if ( 103 | // apiClient.commentsStore.comments.length >= 104 | // apiClient.postStore.singlePost.counts.comments - 1 105 | // ) { 106 | // return; 107 | // } else { 108 | // void apiClient.commentsStore.nextPage( 109 | // post.post.id, 110 | // apiClient.loginDetails 111 | // ); 112 | // } 113 | }; 114 | 115 | const showAllButton = Boolean(parentId); 116 | return ( 117 | 118 | navigation.goBack()} 126 | > 127 | 128 | 129 | } 130 | /> 131 | 132 | 140 | } 141 | refreshing={apiClient.commentsStore.isLoading} 142 | comments={apiClient.commentsStore.commentTree} 143 | colors={colors} 144 | onRefresh={refreshAll} 145 | onEndReached={onEndReached} 146 | openComment={openComment} 147 | openCommenting={openCommenting} 148 | navigation={navigation} 149 | onScroll={onScroll} 150 | scrollEventThrottle={8} 151 | level={1} 152 | footer={} 153 | /> 154 | {showFab ? ( 155 | 156 | ) : null} 157 | 158 | ); 159 | } 160 | 161 | export default observer(PostScreen); 162 | -------------------------------------------------------------------------------- /Screens/Profile/Bio.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { PersonView } from "lemmy-js-client"; 5 | 6 | import { Icon } from "../../ThemedComponents"; 7 | import MdRenderer from "../../components/MdRenderer"; 8 | 9 | function Bio({ profile }: { profile: PersonView }) { 10 | return profile.person.bio ? ( 11 | 12 | 13 | 14 | 15 | ) : null; 16 | } 17 | 18 | const styles = StyleSheet.create({ 19 | container: { 20 | padding: 12, 21 | gap: 12, 22 | }, 23 | row: { 24 | flexDirection: "row", 25 | alignItems: "center", 26 | gap: 8, 27 | }, 28 | longRow: { 29 | flexDirection: "row", 30 | alignItems: "flex-start", 31 | gap: 8, 32 | width: "100%", 33 | }, 34 | }); 35 | 36 | export default Bio; 37 | -------------------------------------------------------------------------------- /Screens/Profile/Counters.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { PersonView } from "lemmy-js-client"; 5 | 6 | import { Icon, Text as ThemedText } from "../../ThemedComponents"; 7 | 8 | function Counters({ profile }: { profile: PersonView }) { 9 | return ( 10 | 11 | 12 | 13 | Comments: {profile.counts.comment_count} 14 | Posts: {profile.counts.post_count} 15 | 16 | 17 | ); 18 | } 19 | 20 | const styles = StyleSheet.create({ 21 | row: { 22 | flexDirection: "row", 23 | alignItems: "center", 24 | gap: 8, 25 | }, 26 | }); 27 | 28 | export default Counters; 29 | -------------------------------------------------------------------------------- /Screens/Profile/OwnComments.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlatList, Share, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { CommentView } from "lemmy-js-client"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 9 | import { commonColors, commonStyles } from "../../commonStyles"; 10 | import Pagination from "../../components/Pagination"; 11 | // import FAB from "../../components/FAB"; 12 | import MiniComment from "../../components/TinyComment"; 13 | import { apiClient } from "../../store/apiClient"; 14 | import { preferences } from "../../store/preferences"; 15 | 16 | // TODO: FAB with sort type 17 | 18 | function OwnComments({ navigation }) { 19 | const { colors } = useTheme(); 20 | const comments = apiClient.profileStore.userProfile?.comments ?? []; 21 | 22 | const refresh = () => { 23 | apiClient.profileStore.setProfilePage(1); 24 | void apiClient.profileStore.getProfile({ 25 | person_id: apiClient.profileStore.userProfile.person_view.person.id, 26 | limit: 30, 27 | }); 28 | }; 29 | 30 | const getPost = (comment: CommentView) => { 31 | navigation.navigate("Post", { 32 | post: comment.post.id, 33 | openComment: 0, 34 | parentId: comment.comment.path.split(".")[1], 35 | }); 36 | }; 37 | 38 | const nextPage = () => { 39 | apiClient.profileStore.setProfilePage( 40 | apiClient.profileStore.profilePage + 1 41 | ); 42 | void apiClient.profileStore.getProfile({ 43 | person_id: apiClient.profileStore.userProfile.person_view.person.id, 44 | limit: 30, 45 | }); 46 | }; 47 | const prevPage = () => { 48 | if (apiClient.profileStore.profilePage === 1) return; 49 | apiClient.profileStore.setProfilePage( 50 | apiClient.profileStore.profilePage - 1 51 | ); 52 | void apiClient.profileStore.getProfile({ 53 | person_id: apiClient.profileStore.userProfile.person_view.person.id, 54 | limit: 30, 55 | }); 56 | }; 57 | 58 | const renderItem = ({ item }) => ; 59 | return ( 60 | 61 | ( 67 | 70 | )} 71 | ListEmptyComponent={ 72 | 73 | Nothing is here for now... 74 | 75 | } 76 | keyExtractor={(item) => item.comment.id.toString()} 77 | renderItem={renderItem} 78 | /> 79 | 85 | 86 | ); 87 | } 88 | 89 | function OwnComment({ 90 | item, 91 | getPost, 92 | }: { 93 | item: CommentView; 94 | getPost: (comment: CommentView) => void; 95 | }) { 96 | return ( 97 | 98 | 107 | 113 | 114 | getPost(item)}> 115 | 116 | 117 | 120 | Share.share({ 121 | url: item.comment.ap_id, 122 | message: item.comment.ap_id, 123 | }) 124 | } 125 | > 126 | 131 | 132 | 133 | 138 | 139 | {item.counts.upvotes} 140 | 141 | 142 | 143 | 148 | 149 | {item.counts.downvotes} 150 | 151 | 152 | 153 | 154 | ); 155 | } 156 | 157 | const styles = StyleSheet.create({ 158 | container: { 159 | flex: 1, 160 | paddingHorizontal: 12, 161 | paddingVertical: 4, 162 | }, 163 | comment: { 164 | paddingVertical: 8, 165 | }, 166 | empty: { 167 | fontSize: 16, 168 | }, 169 | row: { 170 | flexDirection: "row", 171 | alignItems: "center", 172 | gap: 6, 173 | }, 174 | topRow: { 175 | flexDirection: "row", 176 | alignItems: "center", 177 | gap: 6, 178 | }, 179 | }); 180 | 181 | export default observer(OwnComments); 182 | -------------------------------------------------------------------------------- /Screens/Profile/OwnPosts.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlatList, Share, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { PostView } from "lemmy-js-client"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 9 | import { commonColors, commonStyles } from "../../commonStyles"; 10 | import Pagination from "../../components/Pagination"; 11 | import MiniComment from "../../components/TinyComment"; 12 | import { apiClient } from "../../store/apiClient"; 13 | import { preferences } from "../../store/preferences"; 14 | 15 | // TODO: FAB with sort type 16 | 17 | function OwnPosts({ navigation }) { 18 | const { colors } = useTheme(); 19 | const posts = apiClient.profileStore.userProfile?.posts ?? []; 20 | 21 | const refresh = () => { 22 | apiClient.profileStore.setProfilePage(1); 23 | void apiClient.profileStore.getProfile({ 24 | person_id: apiClient.profileStore.userProfile.person_view.person.id, 25 | limit: 30, 26 | }); 27 | }; 28 | 29 | const getPost = (post: PostView) => { 30 | navigation.navigate("Post", { post: post.post.id }); 31 | }; 32 | 33 | const nextPage = () => { 34 | apiClient.profileStore.setProfilePage( 35 | apiClient.profileStore.profilePage + 1 36 | ); 37 | void apiClient.profileStore.getProfile({ 38 | person_id: apiClient.profileStore.userProfile.person_view.person.id, 39 | limit: 30, 40 | }); 41 | }; 42 | 43 | const prevPage = () => { 44 | if (apiClient.profileStore.profilePage === 1) return; 45 | apiClient.profileStore.setProfilePage( 46 | apiClient.profileStore.profilePage - 1 47 | ); 48 | void apiClient.profileStore.getProfile({ 49 | person_id: apiClient.profileStore.userProfile.person_view.person.id, 50 | limit: 30, 51 | }); 52 | }; 53 | 54 | const renderItem = ({ item }) => ; 55 | return ( 56 | 57 | ( 63 | 66 | )} 67 | ListEmptyComponent={ 68 | 69 | Nothing is here for now... 70 | 71 | } 72 | keyExtractor={(item) => item.post.id.toString()} 73 | renderItem={renderItem} 74 | /> 75 | 81 | 82 | ); 83 | } 84 | 85 | function OwnPost({ 86 | item, 87 | getPost, 88 | }: { 89 | item: PostView; 90 | getPost: (post: PostView) => void; 91 | }) { 92 | return ( 93 | 94 | 103 | 109 | 110 | getPost(item)}> 111 | 112 | 113 | 116 | Share.share({ 117 | url: item.post.ap_id, 118 | message: item.post.ap_id, 119 | }) 120 | } 121 | > 122 | 127 | 128 | 129 | 134 | 135 | {item.counts.upvotes} 136 | 137 | 138 | 139 | 144 | 145 | {item.counts.downvotes} 146 | 147 | 148 | 149 | 150 | ); 151 | } 152 | 153 | const styles = StyleSheet.create({ 154 | container: { 155 | flex: 1, 156 | paddingHorizontal: 12, 157 | paddingVertical: 4, 158 | }, 159 | comment: { 160 | paddingVertical: 8, 161 | }, 162 | empty: { 163 | fontSize: 16, 164 | }, 165 | row: { 166 | flexDirection: "row", 167 | alignItems: "center", 168 | gap: 6, 169 | }, 170 | topRow: { 171 | flexDirection: "row", 172 | alignItems: "center", 173 | gap: 6, 174 | }, 175 | }); 176 | 177 | export default observer(OwnPosts); 178 | -------------------------------------------------------------------------------- /Screens/Profile/Profile.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Image, 4 | Linking, 5 | RefreshControl, 6 | ScrollView, 7 | StyleSheet, 8 | View, 9 | } from "react-native"; 10 | 11 | import { useTheme } from "@react-navigation/native"; 12 | import { NativeStackScreenProps } from "@react-navigation/native-stack"; 13 | import { observer } from "mobx-react-lite"; 14 | 15 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 16 | import { asyncStorageHandler } from "../../asyncStorage"; 17 | import AccountPicker from "../../components/AccountPicker/AccountPicker"; 18 | import { apiClient } from "../../store/apiClient"; 19 | import Bio from "./Bio"; 20 | import Counters from "./Counters"; 21 | import UserRow from "./UserRow"; 22 | 23 | // even though its actually inside tab, main nav context is a stack right now 24 | function Profile({ navigation }: NativeStackScreenProps) { 25 | const { localUser: profile } = apiClient.profileStore; 26 | const { colors } = useTheme(); 27 | 28 | React.useEffect(() => { 29 | const unsubscribe = navigation.addListener("focus", () => { 30 | if (!profile) { 31 | reload(); 32 | } else { 33 | if ( 34 | apiClient.profileStore.userProfile?.person_view.person.id !== 35 | profile?.person.id 36 | ) { 37 | void apiClient.profileStore.getProfile({ 38 | person_id: profile.local_user.person_id, 39 | sort: "New", 40 | page: 1, 41 | limit: 30, 42 | }); 43 | } 44 | } 45 | }); 46 | 47 | return unsubscribe; 48 | }, [profile, navigation]); 49 | 50 | const reload = () => { 51 | void apiClient.getGeneralData(); 52 | }; 53 | 54 | const logoutHandler = () => { 55 | const accounts = apiClient.accounts.filter((a) => { 56 | return JSON.parse(a.auth).jwt !== apiClient.loginDetails.jwt; 57 | }); 58 | apiClient.setAccounts(accounts); 59 | asyncStorageHandler.logout(); 60 | apiClient.profileStore.setLocalUser(null); 61 | apiClient.setLoginDetails(null); 62 | navigation.replace("Home"); 63 | }; 64 | 65 | const hasBanner = Boolean(profile?.person?.banner); 66 | return ( 67 | 74 | } 75 | > 76 | {profile ? ( 77 | 78 | {hasBanner ? ( 79 | 87 | ) : null} 88 | 89 | 93 | 94 | 95 | 96 | 97 | ) : null} 98 | 99 | navigation.navigate("Settings")} 102 | style={styles.row} 103 | > 104 | 105 | Settings 106 | 107 | 111 | navigation.navigate("Community", { name: "arctius@lemmy.world" }) 112 | } 113 | > 114 | 115 | 122 | https://lemmy.world/c/arctius 123 | 124 | 125 | 129 | Linking.openURL("https://github.com/nick-delirium/lemmy-fennec") 130 | } 131 | > 132 | 133 | 140 | Got feedback, issues or suggestions? 141 | 142 | 143 | 144 | {apiClient.isLoggedIn ? ( 145 | <> 146 | 151 | 152 | 159 | Logout 160 | 161 | 162 | 163 | 164 | ) : ( 165 | 166 | navigation.replace("Login")}> 167 | Login 168 | 169 | 170 | )} 171 | 172 | 173 | ); 174 | } 175 | 176 | // todo: settings, posts, comments, profile editing 177 | 178 | const styles = StyleSheet.create({ 179 | container: { 180 | padding: 12, 181 | gap: 8, 182 | }, 183 | row: { 184 | flexDirection: "row", 185 | alignItems: "center", 186 | gap: 8, 187 | }, 188 | }); 189 | 190 | export default observer(Profile); 191 | -------------------------------------------------------------------------------- /Screens/Profile/ProfileScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; 4 | 5 | import OwnComments from "./OwnComments"; 6 | import OwnPosts from "./OwnPosts"; 7 | import Profile from "./Profile"; 8 | 9 | const Tab = createMaterialTopTabNavigator(); 10 | 11 | function ProfileScreen() { 12 | return ( 13 | 14 | 21 | 28 | 35 | 36 | ); 37 | } 38 | 39 | export default ProfileScreen; 40 | -------------------------------------------------------------------------------- /Screens/Profile/UserRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Image, Linking, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { Person } from "lemmy-js-client"; 6 | 7 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 8 | import { makeDateString } from "../../utils/utils"; 9 | 10 | function UserRow({ 11 | person, 12 | hasBanner, 13 | }: { 14 | person: Person; 15 | hasBanner?: boolean; 16 | }) { 17 | const { colors } = useTheme(); 18 | const onProfileUrlPress = async () => { 19 | try { 20 | await Linking.openURL(person.actor_id); 21 | } catch (e) { 22 | console.error(e); 23 | } 24 | }; 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | {person.display_name || `@${person.name}`} 32 | 33 | {person.actor_id} 34 | 35 | 36 | 37 | 38 | 39 | Joined {makeDateString(person.published)} 40 | 41 | 42 | {person.avatar ? ( 43 | 55 | ) : null} 56 | 57 | ); 58 | } 59 | 60 | export default UserRow; 61 | -------------------------------------------------------------------------------- /Screens/Search/ListComponents.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Dimensions, Image, StyleSheet, View } from "react-native"; 3 | 4 | import { 5 | CommunityView, 6 | Community as ICommunity, 7 | PersonView, 8 | PostView, 9 | } from "lemmy-js-client"; 10 | 11 | import { Text, TouchableOpacity } from "../../ThemedComponents"; 12 | import { apiClient } from "../../store/apiClient"; 13 | import { makeDateString } from "../../utils/utils"; 14 | 15 | export function hostname(url: string): string { 16 | const matches = url.match(/^https?:\/\/([^\/?#]+)(?:[\/?#]|$)/i); 17 | 18 | return matches ? matches[1] : ""; 19 | } 20 | 21 | function isCommunityView( 22 | item: ICommunity | CommunityView 23 | ): item is CommunityView { 24 | return (item as CommunityView).community !== undefined; 25 | } 26 | 27 | export function Community({ 28 | sublemmy, 29 | navigation, 30 | }: { 31 | sublemmy: CommunityView | ICommunity; 32 | navigation: any; 33 | }) { 34 | const isView = isCommunityView(sublemmy); 35 | // @ts-ignore 36 | let commonItemInterface: ICommunity = isView 37 | ? // @ts-ignore 38 | sublemmy.community 39 | : sublemmy; 40 | const getCommunity = () => { 41 | apiClient.postStore.setCommunityPosts([]); 42 | apiClient.communityStore.setCommunity(null); 43 | navigation.navigate("Community", { id: commonItemInterface.id }); 44 | }; 45 | 46 | const name = commonItemInterface.local 47 | ? commonItemInterface.name 48 | : `${commonItemInterface.name}@${hostname(commonItemInterface.actor_id)}`; 49 | 50 | return ( 51 | 52 | 53 | {commonItemInterface.icon ? ( 54 | 58 | ) : ( 59 | 60 | )} 61 | 62 | {name} 63 | {isView ? ( 64 | 65 | {(sublemmy as unknown as CommunityView).counts?.subscribers} 66 | {" subscribers"} 67 | 68 | ) : null} 69 | 70 | 71 | 72 | ); 73 | } 74 | 75 | export function User({ 76 | user, 77 | navigation, 78 | }: { 79 | user: PersonView; 80 | navigation: any; 81 | }) { 82 | return ( 83 | 86 | navigation.navigate("User", { personId: user.person.id }) 87 | } 88 | > 89 | 90 | {user.person.avatar ? ( 91 | 95 | ) : null} 96 | {user.person.name} 97 | {user.counts.post_score + user.counts.comment_score} score 98 | {user.counts.comment_count} comments 99 | 100 | 101 | ); 102 | } 103 | 104 | export function Post({ 105 | post, 106 | navigation, 107 | }: { 108 | post: PostView; 109 | navigation: any; 110 | }) { 111 | return ( 112 | navigation.navigate("Post", { post: post.post.id })} 115 | > 116 | 117 | {post.post.thumbnail_url ? ( 118 | 122 | ) : null} 123 | 124 | 125 | {post.post.name} 126 | 127 | 128 | {post.creator.name} 129 | in {post.community.name} 130 | {makeDateString(post.post.published)} 131 | 132 | 133 | 134 | 135 | ); 136 | } 137 | 138 | const styles = StyleSheet.create({ 139 | communityIcon: { 140 | width: 28, 141 | height: 28, 142 | borderRadius: 28, 143 | backgroundColor: "#cecece", 144 | }, 145 | communityName: { 146 | color: "violet", 147 | fontSize: 16, 148 | maxWidth: Dimensions.get("window").width - 75, 149 | flexWrap: "wrap", 150 | }, 151 | postIcon: { width: 72, height: 72 }, 152 | community: { 153 | flexDirection: "row", 154 | alignItems: "center", 155 | gap: 8, 156 | padding: 4, 157 | width: "100%", 158 | flex: 1, 159 | }, 160 | }); 161 | -------------------------------------------------------------------------------- /Screens/Search/SearchSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { Picker } from "@react-native-picker/picker"; 5 | import { useTheme } from "@react-navigation/native"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { Text } from "../../ThemedComponents"; 9 | import { apiClient } from "../../store/apiClient"; 10 | import { ListingTypeMap } from "../../store/postStore"; 11 | import { SearchTypeMap } from "../../store/searchStore"; 12 | 13 | function SearchSettings() { 14 | const { colors } = useTheme(); 15 | 16 | return ( 17 | 18 | 19 | Search type: 20 | 32 | apiClient.searchStore.setSearchType(itemValue) 33 | } 34 | > 35 | {Object.values(SearchTypeMap).map((type) => ( 36 | 37 | ))} 38 | 39 | 40 | 41 | Listing type: 42 | 54 | apiClient.searchStore.setListingType(itemValue) 55 | } 56 | > 57 | {Object.values(ListingTypeMap).map((type) => ( 58 | 59 | ))} 60 | 61 | 62 | 63 | ); 64 | } 65 | 66 | const styles = StyleSheet.create({ 67 | flex: { flex: 1 }, 68 | searchPage: { flex: 1, padding: 6 }, 69 | searchControls: { 70 | borderBottomWidth: 1, 71 | flexDirection: "row", 72 | alignItems: "flex-start", 73 | gap: 8, 74 | }, 75 | inputRow: { 76 | paddingHorizontal: 6, 77 | paddingVertical: 12, 78 | flexDirection: "row", 79 | gap: 6, 80 | }, 81 | additionalButtonStyle: { justifyContent: "center" }, 82 | }); 83 | 84 | export default observer(SearchSettings); 85 | -------------------------------------------------------------------------------- /Screens/Settings/Behavior.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | ActivityIndicator, 4 | Dimensions, 5 | ScrollView, 6 | StyleSheet, 7 | View, 8 | } from "react-native"; 9 | 10 | import { useTheme } from "@react-navigation/native"; 11 | import { observer } from "mobx-react-lite"; 12 | 13 | import { Text, TextInput, TouchableOpacity } from "../../ThemedComponents"; 14 | import { apiClient } from "../../store/apiClient"; 15 | import { preferences } from "../../store/preferences"; 16 | import { Toggler } from "./Looks"; 17 | 18 | function Behavior() { 19 | const [ignoredInst, setIgnoredInst] = React.useState( 20 | preferences.ignoredInstances.join(", ") 21 | ); 22 | const { localUser: profile } = apiClient.profileStore; 23 | const { colors } = useTheme(); 24 | 25 | const toggleReadPosts = () => { 26 | if (!apiClient.loginDetails?.jwt) return; 27 | void apiClient.profileStore.updateSettings({ 28 | show_read_posts: !profile.local_user.show_read_posts, 29 | }); 30 | }; 31 | const toggleNSFW = () => { 32 | if (!apiClient.loginDetails?.jwt) return; 33 | void apiClient.profileStore.updateSettings({ 34 | show_nsfw: !profile.local_user.show_nsfw, 35 | }); 36 | }; 37 | const toggleBlurNsfw = () => { 38 | preferences.setBlurNsfw(!preferences.unblurNsfw); 39 | }; 40 | 41 | return ( 42 | 43 | 48 | 53 | preferences.setReadOnScroll(!preferences.readOnScroll) 54 | } 55 | /> 56 | 57 | 61 | preferences.setLowTrafficMode(!preferences.lowTrafficMode) 62 | } 63 | /> 64 | 71 | App will not load media unless you open it. 72 | 73 | 74 | {apiClient.isLoggedIn ? ( 75 | <> 76 | Account Settings 77 | 83 | 89 | 90 | ) : null} 91 | 92 | 93 | Ignored instances 94 | 95 | 106 | 112 | Instances or trigger words separated by comma. 113 | {"\n"} 114 | If substring will be matched in post's ap_id, it will be filtered out. 115 | {"\n"} 116 | Example ap_id: https://lemmy.world/c/communityname 117 | 118 | 121 | preferences.setIgnoredInstances(ignoredInst.split(", ")) 122 | } 123 | > 124 | Save Ignored Instances 125 | 126 | 127 | {apiClient.profileStore.isLoading ? ( 128 | 143 | 144 | 145 | ) : null} 146 | 147 | ); 148 | } 149 | 150 | const styles = StyleSheet.create({ 151 | container: { 152 | gap: 12, 153 | padding: 12, 154 | }, 155 | row: { 156 | flexDirection: "row", 157 | alignItems: "center", 158 | gap: 8, 159 | }, 160 | longRow: { 161 | flexDirection: "row", 162 | alignItems: "center", 163 | width: "100%", 164 | justifyContent: "space-between", 165 | }, 166 | title: { 167 | fontSize: 16, 168 | fontWeight: "bold", 169 | textAlign: "center", 170 | }, 171 | }); 172 | 173 | export default observer(Behavior); 174 | -------------------------------------------------------------------------------- /Screens/Settings/Looks.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ScrollView, StyleSheet, Switch, View } from "react-native"; 3 | 4 | import { Picker } from "@react-native-picker/picker"; 5 | import { useTheme } from "@react-navigation/native"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { Text } from "../../ThemedComponents"; 9 | import { apiClient } from "../../store/apiClient"; 10 | import { Theme, ThemeMap, preferences } from "../../store/preferences"; 11 | 12 | function Looks() { 13 | const toggleLeftHanded = () => { 14 | preferences.setLeftHanded(!preferences.leftHanded); 15 | }; 16 | const toggleVotingButtons = () => { 17 | preferences.setSwapVotingButtons(!preferences.swapVotingButtons); 18 | }; 19 | return ( 20 | 21 | 26 | 31 | 35 | preferences.setCollapseParentComment( 36 | !preferences.collapseParentComment 37 | ) 38 | } 39 | /> 40 | 44 | preferences.setPostLayout(!preferences.compactPostLayout) 45 | } 46 | /> 47 | preferences.setHapticsOff(!preferences.hapticsOff)} 51 | /> 52 | 56 | preferences.setPaginatedFeed(!preferences.paginatedFeed) 57 | } 58 | /> 59 | 63 | preferences.setDisableDynamicHeaders( 64 | !preferences.disableDynamicHeaders 65 | ) 66 | } 67 | /> 68 | 69 | preferences.setAltUpvote(!preferences.altUpvote)} 73 | /> 74 | 81 | Requires restart. 82 | 83 | 84 | ); 85 | } 86 | 87 | export function Toggler({ 88 | useLogin, 89 | label, 90 | value, 91 | onValueChange, 92 | }: { 93 | useLogin?: boolean; 94 | label: string; 95 | value: boolean; 96 | onValueChange: (value: boolean) => void; 97 | }) { 98 | if (useLogin && !apiClient.isLoggedIn) return null; 99 | return ( 100 | 101 | {label} 102 | 108 | 109 | ); 110 | } 111 | 112 | const ThemePicker = observer(() => { 113 | const { colors } = useTheme(); 114 | return ( 115 | 116 | Theme 117 | preferences.setTheme(itemValue)} 130 | > 131 | {[Theme.System, Theme.Light, Theme.Dark, Theme.Amoled].map((type) => ( 132 | 133 | ))} 134 | 135 | 136 | ); 137 | }); 138 | 139 | const styles = StyleSheet.create({ 140 | container: { 141 | gap: 12, 142 | padding: 12, 143 | }, 144 | row: { 145 | flexDirection: "row", 146 | alignItems: "center", 147 | gap: 8, 148 | }, 149 | longRow: { 150 | flexDirection: "row", 151 | alignItems: "center", 152 | width: "100%", 153 | justifyContent: "space-between", 154 | }, 155 | title: { 156 | fontSize: 16, 157 | fontWeight: "bold", 158 | textAlign: "center", 159 | }, 160 | }); 161 | 162 | export default observer(Looks); 163 | -------------------------------------------------------------------------------- /Screens/Settings/ProfileSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ActivityIndicator, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { Text, TextInput, TouchableOpacity } from "../../ThemedComponents"; 8 | import { apiClient } from "../../store/apiClient"; 9 | import { ButtonsRow } from "../CommentWrite/CommentWrite"; 10 | 11 | function ProfileSettings() { 12 | const { colors } = useTheme(); 13 | const [displayName, setDisplayName] = React.useState( 14 | apiClient.profileStore.localUser?.person.display_name || "" 15 | ); 16 | const [bio, setBio] = React.useState( 17 | apiClient.profileStore.localUser?.person.bio || "" 18 | ); 19 | const [email, setEmail] = React.useState( 20 | apiClient.profileStore.localUser?.local_user.email || "" 21 | ); 22 | 23 | const onSave = () => { 24 | if (apiClient.profileStore.isLoading) return; 25 | void apiClient.profileStore.updateSettings({ 26 | bio, 27 | display_name: displayName, 28 | email, 29 | }); 30 | }; 31 | 32 | return ( 33 | 34 | Display Name 35 | 44 | Bio 45 | 46 | 56 | 57 | 58 | Email 59 | 68 | 69 | 70 | 71 | {apiClient.profileStore.isLoading ? ( 72 | 73 | ) : ( 74 | Save 75 | )} 76 | 77 | 78 | ); 79 | } 80 | 81 | const styles = StyleSheet.create({ 82 | container: { 83 | flex: 1, 84 | padding: 12, 85 | gap: 8, 86 | }, 87 | }); 88 | 89 | export default observer(ProfileSettings); 90 | -------------------------------------------------------------------------------- /Screens/SettingsScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { observer } from "mobx-react-lite"; 5 | 6 | import { Icon, Text, TouchableOpacity } from "../ThemedComponents"; 7 | import { apiClient } from "../store/apiClient"; 8 | 9 | function Settings({ navigation }) { 10 | return ( 11 | 12 | navigation.navigate("Looks")} 15 | style={styles.row} 16 | > 17 | 18 | Look and feel 19 | 20 | navigation.navigate("Behavior")} 23 | style={styles.row} 24 | > 25 | 26 | Behavior 27 | 28 | {apiClient.isLoggedIn ? ( 29 | <> 30 | navigation.navigate("ProfileSettings")} 33 | style={styles.row} 34 | > 35 | 36 | Profile settings 37 | 38 | navigation.navigate("Blocks")} 42 | > 43 | 44 | Blocks 45 | 46 | 47 | ) : null} 48 | navigation.navigate("Debug")} 52 | > 53 | 54 | Show network debug log 55 | 56 | 57 | Make sure to report issues and suggest your ideas in our community. 58 | 59 | 60 | This app is an Open Source software released under GNU AGPLv3 license, 61 | {"\n"} 62 | it does not store, process or send your personal information. 63 | 64 | 65 | ); 66 | } 67 | 68 | const styles = StyleSheet.create({ 69 | row: { 70 | flexDirection: "row", 71 | alignItems: "center", 72 | gap: 8, 73 | }, 74 | text: { 75 | fontSize: 16, 76 | }, 77 | }); 78 | 79 | export default observer(Settings); 80 | -------------------------------------------------------------------------------- /Screens/Unreads/MessageWrite.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { 8 | Icon, 9 | Text, 10 | TextInput, 11 | TouchableOpacity, 12 | } from "../../ThemedComponents"; 13 | import { apiClient } from "../../store/apiClient"; 14 | import { MessageBody } from "./Messages"; 15 | 16 | function MessageWrite({ navigation, route }) { 17 | const [text, setText] = React.useState(""); 18 | const [isSubmitting, setIsSubmitting] = React.useState(false); 19 | const { colors } = useTheme(); 20 | const { 21 | isFromLocalUser, 22 | toLocalUser, 23 | item, 24 | messageDate, 25 | borderColor, 26 | recipient, 27 | messageId, 28 | newMessage, 29 | } = route.params; 30 | 31 | React.useEffect(() => { 32 | if (isFromLocalUser) { 33 | navigation.setOptions({ 34 | headerTitle: "Edit Message", 35 | }); 36 | setText(item.private_message?.content); 37 | } 38 | }, [navigation, route.params?.isFromLocalUser]); 39 | 40 | const submit = () => { 41 | if (text.length === 0) return; 42 | setIsSubmitting(true); 43 | const promise = isFromLocalUser 44 | ? apiClient.api.editPrivateMessage({ 45 | private_message_id: messageId, 46 | content: text, 47 | }) 48 | : apiClient.api.createPrivateMessage({ 49 | content: text, 50 | recipient_id: recipient, 51 | }); 52 | promise.then(() => { 53 | setIsSubmitting(false); 54 | void apiClient.mentionsStore.getMessages(); 55 | navigation.goBack(); 56 | }); 57 | }; 58 | 59 | return ( 60 | 61 | 62 | 66 | 67 | 75 | 76 | 77 | 78 | 84 | 85 | setText(text)} 90 | autoCapitalize={"sentences"} 91 | autoCorrect={true} 92 | onSubmitEditing={submit} 93 | placeholderTextColor={colors.border} 94 | keyboardType="default" 95 | multiline 96 | accessibilityLabel={"Message text input"} 97 | /> 98 | 99 | 100 | ); 101 | } 102 | 103 | export function ButtonsRow({ setText, text, submit, isLoading }) { 104 | const { colors } = useTheme(); 105 | return ( 106 | 113 | setText(text + "**___**")} simple> 114 | 115 | B 116 | 117 | 118 | setText(text + "*___*")} simple> 119 | 120 | 121 | setText(text + "[text](url)")} simple> 122 | 123 | 124 | setText(text + "> ")} simple> 125 | 130 | 131 | setText(text + "~~___~~")} simple> 132 | 133 | S 134 | 135 | 136 | 138 | setText(text + ":::spoiler SpoilerName\n" + "___\n" + ":::") 139 | } 140 | simple 141 | > 142 | 147 | 148 | 149 | 150 | 155 | {isLoading ? ( 156 | 157 | ) : ( 158 | 159 | )} 160 | 161 | 162 | ); 163 | } 164 | 165 | const styles = StyleSheet.create({ 166 | inputRow: { 167 | paddingHorizontal: 6, 168 | paddingBottom: 12, 169 | paddingTop: 4, 170 | flexDirection: "row", 171 | }, 172 | additionalButtonStyle: { justifyContent: "center" }, 173 | iconsRow: { 174 | flexDirection: "row", 175 | gap: 16, 176 | alignItems: "center", 177 | paddingHorizontal: 8, 178 | paddingVertical: 8, 179 | borderTopWidth: 1, 180 | }, 181 | bold: { fontSize: 16, fontWeight: "bold" }, 182 | strike: { textDecorationLine: "line-through", fontSize: 16 }, 183 | }); 184 | 185 | export default observer(MessageWrite); 186 | -------------------------------------------------------------------------------- /Screens/Unreads/Messages.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlatList, StyleSheet, View } from "react-native"; 3 | 4 | import { useNavigation, useTheme } from "@react-navigation/native"; 5 | import { PrivateMessageView } from "lemmy-js-client"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 9 | import { commonColors } from "../../commonStyles"; 10 | import { apiClient } from "../../store/apiClient"; 11 | import { makeDateString } from "../../utils/utils"; 12 | 13 | function Messages({ navigation }) { 14 | const { colors } = useTheme(); 15 | 16 | React.useEffect(() => { 17 | const unsub = navigation.addListener("focus", () => { 18 | if (apiClient.mentionsStore.messages.length !== 0) return; 19 | void apiClient.mentionsStore.getMessages(); 20 | }); 21 | 22 | return () => { 23 | unsub(); 24 | }; 25 | }, [apiClient.mentionsStore.messages.length]); 26 | 27 | const renderItem = ({ item }) => ; 28 | return ( 29 | apiClient.mentionsStore.getMessages()} 33 | refreshing={apiClient.mentionsStore.isLoading} 34 | ItemSeparatorComponent={() => ( 35 | 38 | )} 39 | ListEmptyComponent={ 40 | 41 | 42 | No messages yet. Send a message to someone! (from web interface, 43 | since this area is still in progress) 44 | 45 | 46 | } 47 | /> 48 | ); 49 | } 50 | 51 | function Message({ item }: { item: PrivateMessageView }) { 52 | const navigation = useNavigation(); 53 | 54 | const isFromLocalUser = 55 | item.creator.id === apiClient.profileStore.localUser.person.id; 56 | const toLocalUser = 57 | item.recipient.id === apiClient.profileStore.localUser.person.id; 58 | const borderColor = isFromLocalUser ? commonColors.author : "#cecece"; 59 | 60 | const messageDate = makeDateString( 61 | item.private_message.updated || item.private_message.published 62 | ); 63 | const toWriting = () => { 64 | // @ts-ignore 65 | navigation.navigate("MessageWrite", { 66 | isFromLocalUser, 67 | toLocalUser, 68 | item, 69 | messageDate, 70 | borderColor, 71 | recipient: item.creator.id, 72 | messageId: item.private_message.id, 73 | }); 74 | }; 75 | 76 | const removeMessage = () => { 77 | apiClient.api 78 | .deletePrivateMessage({ 79 | private_message_id: item.private_message.id, 80 | deleted: true, 81 | }) 82 | .then(() => { 83 | void apiClient.mentionsStore.getMessages(); 84 | }); 85 | }; 86 | 87 | const markRead = () => null; 88 | return ( 89 | 95 | 102 | 103 | 104 | {isFromLocalUser ? ( 105 | 106 | 107 | 108 | ) : null} 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | ); 118 | } 119 | 120 | export function MessageBody({ 121 | isFromLocalUser, 122 | toLocalUser, 123 | item, 124 | messageDate, 125 | borderColor, 126 | newMessage, 127 | }: { 128 | isFromLocalUser: boolean; 129 | toLocalUser: boolean; 130 | item: PrivateMessageView; 131 | messageDate: string; 132 | borderColor: string; 133 | newMessage?: boolean; 134 | }) { 135 | const navigation = useNavigation(); 136 | const openUser = () => { 137 | if (newMessage) return; 138 | // @ts-ignore 139 | navigation.navigate("User", { 140 | personId: item.creator.id, 141 | }); 142 | }; 143 | return ( 144 | <> 145 | 146 | {isFromLocalUser ? null : ( 147 | <> 148 | From: 149 | 150 | {item.creator.name} 151 | 152 | 153 | )} 154 | {toLocalUser ? null : ( 155 | <> 156 | To: 157 | 158 | 159 | {item.recipient.name} 160 | 161 | 162 | 163 | )} 164 | {messageDate} 165 | 166 | 167 | {item.private_message.content || "Empty message"} 168 | 169 | 170 | ); 171 | } 172 | 173 | const styles = StyleSheet.create({ 174 | emptyContainer: { 175 | flex: 1, 176 | padding: 12, 177 | }, 178 | empty: { 179 | fontSize: 16, 180 | }, 181 | title: { 182 | flexDirection: "row", 183 | fontWeight: "500", 184 | gap: 6, 185 | }, 186 | message: { 187 | padding: 12, 188 | }, 189 | messageContent: { 190 | borderLeftWidth: 1, 191 | paddingLeft: 6, 192 | }, 193 | }); 194 | 195 | export default observer(Messages); 196 | -------------------------------------------------------------------------------- /Screens/Unreads/Unreads.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; 4 | import { observer } from "mobx-react-lite"; 5 | 6 | import { Text } from "../../ThemedComponents"; 7 | import { apiClient } from "../../store/apiClient"; 8 | import Mentions from "./Mentions"; 9 | import Messages from "./Messages"; 10 | import Replies from "./Replies"; 11 | 12 | const Tab = createMaterialTopTabNavigator(); 13 | 14 | const buildShortStr = (counter: number) => (counter > 99 ? "99+" : counter); 15 | 16 | function Unreads() { 17 | const unreadReplies = buildShortStr(apiClient.mentionsStore.unreads.replies); 18 | const unreadMessages = buildShortStr( 19 | apiClient.mentionsStore.unreads.messages 20 | ); 21 | const unreadMentions = buildShortStr( 22 | apiClient.mentionsStore.unreads.mentions 23 | ); 24 | return ( 25 | 26 | 29 | apiClient.mentionsStore.unreads.replies > 0 ? ( 30 | {unreadReplies} 31 | ) : null, 32 | }} 33 | name={"Replies"} 34 | component={Replies} 35 | /> 36 | 39 | apiClient.mentionsStore.unreads.messages > 0 ? ( 40 | {unreadMessages} 41 | ) : null, 42 | }} 43 | name={"Messages"} 44 | component={Messages} 45 | /> 46 | 49 | apiClient.mentionsStore.unreads.mentions > 0 ? ( 50 | {unreadMentions} 51 | ) : null, 52 | }} 53 | name={"Mentions"} 54 | component={Mentions} 55 | /> 56 | 57 | ); 58 | } 59 | 60 | export default observer(Unreads); 61 | -------------------------------------------------------------------------------- /Screens/User/User.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Image, StyleSheet, View } from "react-native"; 3 | 4 | import { useNavigation } from "@react-navigation/native"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { Text, TouchableOpacity } from "../../ThemedComponents"; 8 | import { apiClient } from "../../store/apiClient"; 9 | import { makeDateString } from "../../utils/utils"; 10 | import Bio from "../Profile/Bio"; 11 | import Counters from "../Profile/Counters"; 12 | import UserRow from "../Profile/UserRow"; 13 | 14 | function User() { 15 | const navigation = useNavigation(); 16 | const loadedProfile = apiClient.profileStore.userProfile; 17 | const profile = loadedProfile?.person_view; 18 | const isBlocked = 19 | apiClient.profileStore.blockedPeople.findIndex( 20 | (p) => p.target.id === profile.person.id 21 | ) !== -1; 22 | 23 | const onMessagePress = () => { 24 | // @ts-ignore why do I need this... 25 | navigation.navigate("MessageWrite", { 26 | isFromLocalUser: true, 27 | toLocalUser: false, 28 | item: { 29 | private_message: { 30 | content: "New message...", 31 | }, 32 | recipient: { 33 | name: profile?.person?.name, 34 | }, 35 | }, 36 | messageDate: makeDateString(new Date().getTime()), 37 | recipient: profile?.person?.id, 38 | messageId: null, 39 | newMessage: true, 40 | }); 41 | }; 42 | 43 | const onBlock = () => { 44 | void apiClient.profileStore.blockPerson(profile.person.id, !isBlocked); 45 | }; 46 | 47 | const hasBanner = Boolean(profile?.person?.banner); 48 | 49 | return ( 50 | 51 | {hasBanner ? ( 52 | 60 | ) : null} 61 | 62 | 63 | 64 | {apiClient.loginDetails.jwt ? ( 65 | <> 66 | 67 | Message User 68 | 69 | 74 | {isBlocked ? "Unblock User" : "Block User"} 75 | 76 | 77 | ) : null} 78 | 79 | ); 80 | } 81 | 82 | const styles = StyleSheet.create({ 83 | container: { 84 | padding: 12, 85 | gap: 12, 86 | }, 87 | row: { 88 | flexDirection: "row", 89 | alignItems: "center", 90 | gap: 8, 91 | }, 92 | }); 93 | 94 | export default observer(User); 95 | -------------------------------------------------------------------------------- /Screens/User/UserScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ActivityIndicator, View } from "react-native"; 3 | 4 | import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; 5 | import { NativeStackScreenProps } from "@react-navigation/native-stack"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { apiClient } from "../../store/apiClient"; 9 | import OwnComments from "../Profile/OwnComments"; 10 | import OwnPosts from "../Profile/OwnPosts"; 11 | import User from "./User"; 12 | 13 | const Tab = createMaterialTopTabNavigator(); 14 | 15 | function UserScreen({ 16 | route, 17 | navigation, 18 | }: NativeStackScreenProps) { 19 | const loadedProfile = apiClient.profileStore.userProfile; 20 | const person = loadedProfile?.person_view?.person; 21 | 22 | React.useEffect(() => { 23 | const title = 24 | apiClient.profileStore.isLoading || !person 25 | ? "Loading User..." 26 | : `@${person.name}`; 27 | navigation.setOptions({ 28 | title: title, 29 | }); 30 | }, [person, navigation, apiClient.profileStore.isLoading]); 31 | 32 | React.useEffect(() => { 33 | const paramsPresent = route.params?.personId || route.params?.username; 34 | if (paramsPresent) { 35 | void apiClient.profileStore.getProfile({ 36 | person_id: route.params.personId, 37 | username: route.params.username, 38 | }); 39 | } 40 | }, [route.params?.personId, route.params?.username]); 41 | 42 | if (!person || apiClient.profileStore.isLoading) { 43 | return ( 44 | 45 | 46 | 47 | ); 48 | } 49 | return ( 50 | 51 | 58 | 65 | 72 | 73 | ); 74 | } 75 | 76 | export default observer(UserScreen); 77 | -------------------------------------------------------------------------------- /ThemedComponents/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ViewStyle } from "react-native"; 3 | 4 | import { Feather } from "@expo/vector-icons"; 5 | import { useTheme } from "@react-navigation/native"; 6 | 7 | interface Props { 8 | name: keyof typeof Feather.glyphMap; 9 | style?: ViewStyle; 10 | size: number; 11 | color?: string; 12 | accessibilityLabel?: string; 13 | } 14 | 15 | export default function Icon({ 16 | accessibilityLabel, 17 | name, 18 | size, 19 | color, 20 | style, 21 | }: Props) { 22 | const { colors } = useTheme(); 23 | 24 | return ( 25 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /ThemedComponents/Text.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Text, TextStyle } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { AccessibilityRole } from "react-native/Libraries/Components/View/ViewAccessibility"; 6 | 7 | interface Props { 8 | style?: TextStyle; 9 | customColor?: string; 10 | lines?: number; 11 | children: React.ReactNode; 12 | selectable?: boolean; 13 | accessibilityHint?: string; 14 | accessibilityLabel?: string; 15 | accessibilityRole?: AccessibilityRole; 16 | onPress?: () => void; 17 | } 18 | 19 | export default function ThemedText({ 20 | style, 21 | children, 22 | lines, 23 | customColor, 24 | selectable, 25 | accessibilityHint, 26 | accessibilityLabel, 27 | accessibilityRole, 28 | onPress, 29 | }: Props) { 30 | const { colors } = useTheme(); 31 | 32 | return ( 33 | 44 | {children} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /ThemedComponents/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, TextInput, TextStyle } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | 6 | interface Props { 7 | style?: TextStyle; 8 | [key: string]: any; 9 | } 10 | 11 | function ThemedTextInput(props: Props, ref: any) { 12 | const { colors } = useTheme(); 13 | 14 | return ( 15 | 26 | ); 27 | } 28 | 29 | const ownStyle = StyleSheet.create({ 30 | input: { 31 | width: "100%", 32 | padding: 8, 33 | borderRadius: 6, 34 | borderWidth: 1, 35 | }, 36 | }); 37 | 38 | export default React.forwardRef(ThemedTextInput); 39 | -------------------------------------------------------------------------------- /ThemedComponents/TouchableOpacity.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Pressable, StyleSheet, ViewStyle } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { ImpactFeedbackStyle, impactAsync } from "expo-haptics"; 6 | import { observer } from "mobx-react-lite"; 7 | 8 | import { preferences } from "../store/preferences"; 9 | 10 | interface Props { 11 | isOutlined?: boolean; 12 | style?: ViewStyle; 13 | children: React.ReactNode; 14 | isSecondary?: boolean; 15 | simple?: boolean; 16 | still?: boolean; 17 | feedback?: boolean; 18 | [key: string]: any; 19 | } 20 | 21 | interface PressProps extends Props { 22 | onPressCb: () => void; 23 | } 24 | 25 | interface LongPressProps extends Props { 26 | onLongPress: () => void; 27 | } 28 | 29 | function ThemedTouchableOpacity(props: PressProps | LongPressProps) { 30 | const { colors } = useTheme(); 31 | 32 | const onPress = () => { 33 | if (!preferences.hapticsOff && props.feedback) { 34 | void impactAsync(ImpactFeedbackStyle.Light); 35 | } 36 | props.onPressCb(); 37 | }; 38 | 39 | const onLongPress = () => { 40 | if (!preferences.hapticsOff && props.feedback) { 41 | void impactAsync(ImpactFeedbackStyle.Light); 42 | } 43 | props.onLongPress(); 44 | }; 45 | 46 | return ( 47 | 52 | props.simple 53 | ? { 54 | opacity: pressed && !props.still ? 0.5 : 1, 55 | ...props.style, 56 | } 57 | : { 58 | ...styleSheet.button, 59 | borderColor: props.isOutlined ? colors.primary : "", 60 | borderWidth: props.isOutlined ? 1 : 0, 61 | backgroundColor: props.isOutlined 62 | ? "" 63 | : props.isSecondary 64 | ? colors.border 65 | : colors.primary, 66 | opacity: pressed && !props.still ? 0.5 : 1, 67 | ...props.style, 68 | } 69 | } 70 | > 71 | {props.children} 72 | 73 | ); 74 | } 75 | 76 | const styleSheet = StyleSheet.create({ 77 | button: { 78 | borderRadius: 6, 79 | alignItems: "center", 80 | padding: 8, 81 | }, 82 | }); 83 | 84 | export default observer(ThemedTouchableOpacity); 85 | -------------------------------------------------------------------------------- /ThemedComponents/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Text } from "./Text"; 2 | export { default as TextInput } from "./TextInput"; 3 | export { default as Icon } from "./Icon"; 4 | export { default as TouchableOpacity } from "./TouchableOpacity"; 5 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Arctius", 4 | "slug": "fennec", 5 | "version": "0.2.4", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "automatic", 9 | "plugins": [ 10 | [ 11 | "expo-media-library", 12 | { 13 | "photosPermission": "Allow $(PRODUCT_NAME) to access your photos.", 14 | "savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.", 15 | "isAccessMediaLocationEnabled": false 16 | } 17 | ], 18 | [ 19 | "expo-image-picker", 20 | { 21 | "photosPermission": "Allow $(PRODUCT_NAME) to access your photos." 22 | } 23 | ], 24 | "expo-secure-store" 25 | ], 26 | "splash": { 27 | "image": "./assets/splash.png", 28 | "resizeMode": "contain", 29 | "backgroundColor": "#2b4777" 30 | }, 31 | "assetBundlePatterns": [ 32 | "**/*" 33 | ], 34 | "ios": { 35 | "supportsTablet": true, 36 | "config": { 37 | "usesNonExemptEncryption": false 38 | }, 39 | "infoPlist": { 40 | "NSPhotoLibraryUsageDescription": "Allow $(PRODUCT_NAME) to access your photos.", 41 | "NSPhotoLibraryAddUsageDescription": "Allow $(PRODUCT_NAME) to save photos." 42 | } 43 | }, 44 | "android": { 45 | "jsEngine": "hermes", 46 | "icon": "./assets/icon.png", 47 | "splash": { 48 | "image": "./assets/splash.png", 49 | "resizeMode": "contain", 50 | "backgroundColor": "#2b4777" 51 | }, 52 | "adaptiveIcon": { 53 | "foregroundImage": "./assets/adaptive-icon.png", 54 | "backgroundColor": "#2b4777", 55 | "monochromeImage": "./assets/monochrome.png" 56 | }, 57 | "package": "com.nick.delirium.arctius", 58 | "blockedPermissions": [ 59 | "android.permission.USE_BIOMETRIC", 60 | "android.permission.RECORD_AUDIO" 61 | ], 62 | "versionCode": 19 63 | }, 64 | "web": { 65 | "favicon": "./assets/favicon.png" 66 | }, 67 | "extra": { 68 | "eas": { 69 | "projectId": "0be79b75-f653-4f4b-a90e-cedb0b58b414" 70 | } 71 | }, 72 | "owner": "nick.delirium" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-delirium/lemmy-fennec/55c843ee43a85c91eb512a009dbad28a46dfaeb8/assets/adaptive-icon.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-delirium/lemmy-fennec/55c843ee43a85c91eb512a009dbad28a46dfaeb8/assets/favicon.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-delirium/lemmy-fennec/55c843ee43a85c91eb512a009dbad28a46dfaeb8/assets/icon.png -------------------------------------------------------------------------------- /assets/monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-delirium/lemmy-fennec/55c843ee43a85c91eb512a009dbad28a46dfaeb8/assets/monochrome.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nick-delirium/lemmy-fennec/55c843ee43a85c91eb512a009dbad28a46dfaeb8/assets/splash.png -------------------------------------------------------------------------------- /asyncStorage.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import * as SecureStore from "expo-secure-store"; 3 | 4 | export const dataKeys = { 5 | instance: "fennec-instance", 6 | login: "fennec-login", 7 | username: "fennec-username", 8 | postsLimit: "fennec-settings-posts-limit", 9 | filters: "fennec-filters", 10 | commentFilters: "fennec-comment-filters", 11 | readScroll: "fennec-read-scroll", 12 | blurNsfw: "fennec-blur-nsfw", 13 | leftHanded: "fennec-left-handed", 14 | collapseParent: "fennec-collapse-parent", 15 | compactPostLayout: "fennec-compact-post-layout", 16 | theme: "fennec-theme", 17 | hapticsOff: "fennec-haptics-off", 18 | favCommunities: "fennec-fav-communities", 19 | paginatedFeed: "fennec-paginated-feed", 20 | ignoredInstances: "fennec-ignored-instances", 21 | votingButtons: "fennec-voting-buttons", 22 | accounts: "fennec-accounts", 23 | lowTraffic: "fennec-low-traffic", 24 | dynamicHeaders: "fennec-dynamic-headers", 25 | altUpvote: "fennec-alt-upvote", 26 | } as const; 27 | 28 | type Keys = keyof typeof dataKeys; 29 | type DataValues = (typeof dataKeys)[Keys]; 30 | 31 | class AsyncStoragehandler { 32 | readData = async (key: DataValues): Promise => { 33 | try { 34 | const value = await AsyncStorage.getItem(key); 35 | if (value !== null) { 36 | return value; 37 | } else return null; 38 | } catch (e) { 39 | console.error("regular data read:", e); 40 | } 41 | }; 42 | 43 | setData = async (key: DataValues, value: string) => { 44 | try { 45 | await AsyncStorage.setItem(key, value); 46 | } catch (e) { 47 | console.error("regular data write:", e); 48 | } 49 | }; 50 | 51 | setSecureData = async (key, value) => { 52 | try { 53 | await SecureStore.setItemAsync(key, value); 54 | } catch (e) { 55 | console.error("secure data write:", e); 56 | } 57 | }; 58 | 59 | readSecureData = async (key: string): Promise => { 60 | try { 61 | const value = await SecureStore.getItemAsync(key); 62 | if (value !== null) { 63 | return value; 64 | } else return null; 65 | } catch (e) { 66 | console.error("secure data read:", e); 67 | } 68 | }; 69 | 70 | purge() { 71 | void AsyncStorage.clear(); 72 | } 73 | 74 | logout() { 75 | void SecureStore.deleteItemAsync(dataKeys.login); 76 | void AsyncStorage.removeItem(dataKeys.username); 77 | } 78 | } 79 | 80 | export const asyncStorageHandler = new AsyncStoragehandler(); 81 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | env: { 6 | production: { 7 | plugins: ["transform-remove-console"], 8 | }, 9 | }, 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | eas build --platform android 2 | eas build --profile preview --platform android -------------------------------------------------------------------------------- /commonStyles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from "react-native"; 2 | 3 | import { DarkTheme, DefaultTheme } from "@react-navigation/native"; 4 | 5 | export const AppTheme = { 6 | dark: false, 7 | colors: { 8 | ...DefaultTheme.colors, 9 | primary: "#2142AB", //57bcd9 10 | border: "#637ac4", 11 | background: "#effaf6", 12 | }, 13 | }; 14 | export const AppDarkTheme = { 15 | dark: true, 16 | colors: { 17 | ...DarkTheme.colors, 18 | primary: "#57bcd9", // 2142AB 19 | border: "#637ac4", 20 | background: "#1c1c1c", 21 | card: "#2c2c2c", 22 | }, 23 | }; 24 | export const AppAmoledTheme = { 25 | dark: true, 26 | colors: { 27 | ...DarkTheme.colors, 28 | primary: "#57bcd9", // 2142AB 29 | border: "#637ac4", 30 | background: "#000000", 31 | }, 32 | }; 33 | 34 | export const mdTheme = { 35 | light: { 36 | code: "#f6f8fa", 37 | link: "#58a6ff", 38 | text: "#333333", 39 | border: "#d0d7de", 40 | ...AppTheme.colors, 41 | }, 42 | dark: { 43 | code: "#161b22", 44 | link: "#58a6ff", 45 | text: "#eeeeee", 46 | border: "#30363d", 47 | ...AppDarkTheme.colors, 48 | }, 49 | } as const; 50 | 51 | export const commonStyles = StyleSheet.create({ 52 | container: { 53 | flex: 1, 54 | alignItems: "center", 55 | justifyContent: "center", 56 | }, 57 | hrefInput: { 58 | width: "60%", 59 | padding: 8, 60 | borderRadius: 6, 61 | borderWidth: 1, 62 | borderColor: "#AADDEC", 63 | }, 64 | button: { 65 | borderRadius: 6, 66 | width: "30%", 67 | alignItems: "center", 68 | backgroundColor: "#AADDEC", 69 | padding: 8, 70 | }, 71 | title: { 72 | fontSize: 16, 73 | fontWeight: "500", 74 | }, 75 | text: { 76 | fontSize: 15, 77 | }, 78 | iconsRow: { 79 | gap: 12, 80 | alignItems: "center", 81 | paddingTop: 8, 82 | paddingBottom: 8, 83 | }, 84 | fabButton: { 85 | padding: 12, 86 | maxWidth: 46, 87 | alignItems: "center", 88 | borderRadius: 24, 89 | }, 90 | fabMenu: { 91 | flexDirection: "column", 92 | gap: 16, 93 | padding: 12, 94 | borderRadius: 6, 95 | minWidth: 130, 96 | }, 97 | touchableIcon: { 98 | padding: 3, 99 | }, 100 | }); 101 | 102 | export const commonColors = { 103 | author: "orange", 104 | community: "violet", 105 | upvote: "red", 106 | upvoteAlt: "blue", 107 | downvote: "blue", 108 | downvoteAlt: "red", 109 | } as const; 110 | -------------------------------------------------------------------------------- /components/AccountPicker/AccountPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Image, StyleSheet, ToastAndroid, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { useNavigation } from "@react-navigation/native"; 6 | import { LoginResponse } from "lemmy-js-client"; 7 | import { observer } from "mobx-react-lite"; 8 | 9 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 10 | import { asyncStorageHandler, dataKeys } from "../../asyncStorage"; 11 | import { Account as IAccount, apiClient } from "../../store/apiClient"; 12 | 13 | function AddNew() { 14 | const navigation = useNavigation(); 15 | 16 | const openAddAccount = () => { 17 | // @ts-ignore 18 | navigation.navigate("AddAccount"); 19 | }; 20 | return ( 21 | 22 | 23 | 24 | Add New Account 25 | 26 | 27 | ); 28 | } 29 | 30 | const Account = observer( 31 | ({ account, isActive }: { account: IAccount; isActive?: boolean }) => { 32 | const { colors } = useTheme(); 33 | 34 | return ( 35 | 41 | 49 | {account.avatar ? ( 50 | 55 | ) : null} 56 | 57 | 58 | {account.login} 59 | 60 | 61 | 62 | {account.instance.replace(/https?:\/\//i, "")} 63 | 64 | 65 | ); 66 | } 67 | ); 68 | 69 | function AccountPicker() { 70 | const { colors } = useTheme(); 71 | const [isOpen, setIsOpen] = React.useState(false); 72 | const accounts = apiClient.accounts; 73 | 74 | React.useEffect(() => { 75 | return () => { 76 | setIsOpen(false); 77 | }; 78 | }, []); 79 | 80 | const changeAccount = async (account) => { 81 | const instance = account.instance; 82 | const username = account.login; 83 | 84 | try { 85 | await Promise.all([ 86 | asyncStorageHandler.setSecureData(dataKeys.login, account.auth), 87 | asyncStorageHandler.setData(dataKeys.instance, instance), 88 | asyncStorageHandler.setData(dataKeys.username, username), 89 | ]); 90 | apiClient 91 | .createLoggedClient(account.auth, instance, username) 92 | .then(() => { 93 | void apiClient.postStore.getPosts({ 94 | jwt: account.auth, 95 | } as LoginResponse); 96 | }); 97 | } catch (e) { 98 | ToastAndroid.showWithGravity( 99 | "Error changing account", 100 | ToastAndroid.SHORT, 101 | ToastAndroid.CENTER 102 | ); 103 | } 104 | }; 105 | 106 | return ( 107 | 108 | 115 | setIsOpen(!isOpen)} 119 | > 120 | 121 | Change Account 122 | 123 | {isOpen ? ( 124 | <> 125 | {accounts.map((account) => ( 126 | changeAccount(account)} 130 | > 131 | 137 | 138 | ))} 139 | 140 | 141 | ) : null} 142 | 143 | 144 | ); 145 | } 146 | 147 | const styles = StyleSheet.create({ 148 | entry: { 149 | flexDirection: "row", 150 | alignItems: "center", 151 | gap: 8, 152 | padding: 8, 153 | flex: 1, 154 | }, 155 | pickerContainer: { 156 | // padding: 8, 157 | flexDirection: "column", 158 | gap: 8, 159 | borderRadius: 6, 160 | borderWidth: 1, 161 | width: "90%", 162 | }, 163 | pickerCenterer: { 164 | marginTop: 32, 165 | flex: 1, 166 | justifyContent: "center", 167 | alignItems: "center", 168 | }, 169 | }); 170 | 171 | export default observer(AccountPicker); 172 | -------------------------------------------------------------------------------- /components/CommunityIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Image, StyleSheet, View } from "react-native"; 3 | 4 | function CommunityIcon({ 5 | communityPic, 6 | communityName, 7 | }: { 8 | communityPic?: string; 9 | communityName: string; 10 | }) { 11 | return ( 12 | 13 | 18 | 19 | ); 20 | } 21 | 22 | const styles = StyleSheet.create({ 23 | communityIcon: { width: 28, height: 28, borderRadius: 28 }, 24 | iconContainer: { 25 | width: 28, 26 | height: 28, 27 | borderRadius: 28, 28 | backgroundColor: "#cecece", 29 | }, 30 | }); 31 | 32 | export default CommunityIcon; 33 | -------------------------------------------------------------------------------- /components/DynamicHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Animated, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | 6 | import { Text } from "../ThemedComponents"; 7 | 8 | const MAX_HEIGHT = 56; 9 | const MIN_HEIGHT = 0; 10 | const scrollDistance = MAX_HEIGHT - MIN_HEIGHT; 11 | 12 | interface IProps { 13 | animHeaderValue: Animated.Value; 14 | title: string; 15 | rightAction?: React.ReactNode; 16 | leftAction?: React.ReactNode; 17 | } 18 | 19 | const DynamicHeader = ({ 20 | animHeaderValue, 21 | title, 22 | rightAction, 23 | leftAction, 24 | }: IProps) => { 25 | const { colors } = useTheme(); 26 | const animatedHeaderHeight = animHeaderValue.interpolate({ 27 | inputRange: [0, scrollDistance], 28 | outputRange: [MAX_HEIGHT, MIN_HEIGHT], 29 | extrapolate: "clamp", 30 | }); 31 | const animateHeaderBackgroundColor = animHeaderValue.interpolate({ 32 | inputRange: [0, scrollDistance], 33 | outputRange: [colors.card, colors.background], 34 | extrapolate: "clamp", 35 | }); 36 | return ( 37 | 46 | {leftAction} 47 | {title} 48 | 49 | {rightAction} 50 | 51 | ); 52 | }; 53 | 54 | const styles = StyleSheet.create({ 55 | header: { 56 | flexDirection: "row", 57 | justifyContent: "flex-start", 58 | alignItems: "center", 59 | width: "100%", 60 | position: "absolute", 61 | top: 0, 62 | left: 0, 63 | zIndex: 100, 64 | elevation: 100, 65 | }, 66 | headerText: { 67 | fontSize: 20, 68 | fontWeight: "500", 69 | paddingHorizontal: 16, 70 | }, 71 | }); 72 | 73 | export default DynamicHeader; 74 | -------------------------------------------------------------------------------- /components/FAB.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { observer } from "mobx-react-lite"; 5 | 6 | import { preferences } from "../store/preferences"; 7 | 8 | function FAB({ 9 | children, 10 | elevated, 11 | }: { 12 | children: React.ReactNode; 13 | elevated?: boolean; 14 | }) { 15 | const position = preferences.leftHanded 16 | ? styles.leftButton 17 | : styles.rightButton; 18 | const verticalPosition = elevated ? styles.elevated : {}; 19 | return ( 20 | 21 | {children} 22 | 23 | ); 24 | } 25 | 26 | const styles = StyleSheet.create({ 27 | container: { 28 | position: "absolute", 29 | bottom: 21, 30 | gap: 12, 31 | }, 32 | elevated: { bottom: 64 }, // when input added 33 | leftButton: { left: 16, alignItems: "flex-start" }, 34 | rightButton: { right: 16, alignItems: "flex-end" }, 35 | }); 36 | 37 | export default observer(FAB); 38 | -------------------------------------------------------------------------------- /components/MdRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React, { isValidElement } from "react"; 2 | import { Linking, TextStyle, useColorScheme } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { useNavigation } from "@react-navigation/native"; 6 | import Markdown, { Renderer } from "react-native-marked"; 7 | 8 | import { Text, TouchableOpacity } from "../ThemedComponents"; 9 | import { mdTheme } from "../commonStyles"; 10 | import { apiClient } from "../store/apiClient"; 11 | 12 | const hasChildren = (element: React.ReactNode) => 13 | isValidElement(element) && Boolean(element.props.children); 14 | 15 | const ReactChildrenText = (children): string => { 16 | if (hasChildren(children)) return ReactChildrenText(children.props.children); 17 | return children; 18 | }; 19 | 20 | function extractInstance(inputString) { 21 | const match = inputString.split("@"); 22 | return `@${match[match.length - 1]}`; 23 | } 24 | 25 | class CustomRenderer extends Renderer { 26 | constructor(private readonly navigation) { 27 | super(); 28 | } 29 | 30 | handleLinkPress(href: string, text: string) { 31 | // getting the domain 32 | const hrefDomain = href.split("/")[2]; 33 | 34 | const isLocalLink = apiClient.getCurrentInstance().includes(hrefDomain); 35 | // regexp to match "(@/!)text@instance.com" 36 | const isForeign = /^(@,!)?.+@.+\..+$/.test(text); 37 | 38 | if (isLocalLink) { 39 | // https://lemmyinst.any/post/123 40 | if (href.includes("/post/")) { 41 | const parts = href.split("/"); 42 | const postId = parts[parts.length - 1]; 43 | return this.navigation.navigate("Post", { post: postId }); 44 | } 45 | // https://lemmyinst.any/c/commname 46 | if (href.includes("/c/")) { 47 | const parts = href.split("/"); 48 | const communityName = parts[parts.length - 1]; 49 | const name = isForeign 50 | ? `${communityName}${extractInstance(text)}` 51 | : communityName; 52 | return this.navigation.navigate("Community", { name }); 53 | } 54 | // https://lemmyinst.any/u/username 55 | if (href.includes("/u/")) { 56 | const parts = href.split("/"); 57 | const username = parts[parts.length - 1]; 58 | const name = isForeign 59 | ? `${username}${extractInstance(text)}` 60 | : username; 61 | return this.navigation.navigate("User", { username: name }); 62 | } 63 | } 64 | // any other link that hasn't been handled or is not local to the instance 65 | return Linking.openURL(href) 66 | .then(() => console.log("URL opened")) 67 | .catch((e) => { 68 | console.warn("URL can't be opened", e); 69 | }); 70 | } 71 | 72 | link( 73 | children: string | React.ReactNode[], 74 | href: string, 75 | styles?: TextStyle 76 | ): React.ReactNode { 77 | const text = ReactChildrenText(children[0]); 78 | return ( 79 | this.handleLinkPress(href, text)} 85 | style={styles} 86 | > 87 | {children} 88 | 89 | ); 90 | } 91 | 92 | linkImage(href: string, imageUrl: string, alt?: string, style?: any) { 93 | if (imageUrl.endsWith(".svg")) { 94 | return ( 95 | Linking.openURL(href)} 99 | > 100 | {alt} 101 | 102 | ); 103 | } else { 104 | return super.linkImage(href, imageUrl, alt, style); 105 | } 106 | } 107 | } 108 | 109 | function MdRenderer({ value }) { 110 | const navigation = useNavigation(); 111 | const RendererEx = new CustomRenderer(navigation); 112 | 113 | const sch = useColorScheme(); 114 | const { colors } = useTheme(); 115 | const theme = sch === "dark" ? mdTheme.dark : mdTheme.light; 116 | 117 | const themeWithBg = { ...theme, backgroundColor: colors.background }; 118 | return ( 119 | 129 | ); 130 | } 131 | 132 | export default React.memo(MdRenderer); 133 | -------------------------------------------------------------------------------- /components/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | 6 | import { Text, TouchableOpacity } from "../ThemedComponents"; 7 | 8 | interface Props { 9 | prevPage: () => void; 10 | nextPage: () => void; 11 | itemsLength: number; 12 | page: number; 13 | isLoading?: boolean; 14 | } 15 | 16 | function Pagination({ 17 | prevPage, 18 | nextPage, 19 | itemsLength, 20 | page, 21 | isLoading, 22 | }: Props) { 23 | const { colors } = useTheme(); 24 | return ( 25 | 26 | {page > 1 && !isLoading ? ( 27 | 28 | Previous page 29 | 30 | ) : ( 31 | null} 34 | > 35 | Previous page 36 | 37 | )} 38 | {itemsLength > 0 && !isLoading ? ( 39 | 40 | Next page 41 | 42 | ) : ( 43 | null} 46 | > 47 | Next page 48 | 49 | )} 50 | 51 | ); 52 | } 53 | 54 | const styles = StyleSheet.create({ 55 | row: { 56 | flexDirection: "row", 57 | alignItems: "center", 58 | gap: 6, 59 | }, 60 | paddedRow: { 61 | flexDirection: "row", 62 | alignItems: "center", 63 | gap: 8, 64 | paddingVertical: 6, 65 | paddingHorizontal: 8, 66 | }, 67 | }); 68 | 69 | export default Pagination; 70 | -------------------------------------------------------------------------------- /components/Post/Embed.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Linking, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | 6 | import { Text, TouchableOpacity } from "../../ThemedComponents"; 7 | 8 | interface Props { 9 | embed_description?: string; 10 | embed_title?: string; 11 | embed_video_url?: string | null; 12 | url?: string; 13 | } 14 | 15 | function Embed({ 16 | embed_description, 17 | embed_title, 18 | embed_video_url, 19 | url, 20 | }: Props) { 21 | const { colors } = useTheme(); 22 | 23 | const openUrl = () => { 24 | void Linking.openURL(url); 25 | }; 26 | return ( 27 | 28 | 29 | {embed_title ? {embed_title} : null} 30 | {embed_description ? {embed_description} : null} 31 | {url ? ( 32 | 33 | {url} 34 | 35 | ) : null} 36 | 37 | 38 | ); 39 | } 40 | 41 | const styles = StyleSheet.create({ 42 | container: { 43 | borderWidth: 1, 44 | borderRadius: 6, 45 | padding: 6, 46 | }, 47 | title: { fontWeight: "500", fontSize: 16 }, 48 | link: { opacity: 0.7, marginLeft: "auto" }, 49 | }); 50 | 51 | export default Embed; 52 | -------------------------------------------------------------------------------- /components/Post/ExpandedPost.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { NativeStackScreenProps } from "@react-navigation/native-stack"; 6 | import { PostView } from "lemmy-js-client"; 7 | import { observer } from "mobx-react-lite"; 8 | 9 | import { Text, TouchableOpacity } from "../../ThemedComponents"; 10 | import Embed from "../../components/Post/Embed"; 11 | import { apiClient } from "../../store/apiClient"; 12 | import { makeDateString } from "../../utils/utils"; 13 | import MdRenderer from "../MdRenderer"; 14 | import Media from "./Media"; 15 | import PostBadges from "./PostBadges"; 16 | import PostIconRow from "./PostIconRow"; 17 | import PostTitle from "./PostTitle"; 18 | 19 | // !!!TODO!!! 20 | // 1. split stuff into components 21 | // 2. research performance 22 | // 3. see how I can force max lines on markdown 23 | 24 | function Post({ 25 | post, 26 | navigation, 27 | useCommunity, 28 | showAllButton, 29 | }: { 30 | post: PostView; 31 | useCommunity?: boolean; 32 | showAllButton?: boolean; 33 | navigation?: NativeStackScreenProps["navigation"]; 34 | }) { 35 | const { colors } = useTheme(); 36 | 37 | // flags to mark the post 38 | const isNsfw = post.post.nsfw || post.community.nsfw; 39 | const isPic = post.post.url 40 | ? /\.(jpeg|jpg|gif|png|webp)$/.test(post.post.url) 41 | : false; 42 | // const isLocal = post.post.local; // do I even need it? 43 | 44 | const maxLines = undefined; 45 | const safeDescription = post.post.body ? post.post.body : ""; 46 | const dateStr = makeDateString(post.post.published); 47 | 48 | const markRead = () => { 49 | if (apiClient.loginDetails?.jwt) { 50 | void apiClient.postStore.markPostRead( 51 | { 52 | post_ids: [post.post.id], 53 | read: true, 54 | }, 55 | useCommunity 56 | ); 57 | } 58 | }; 59 | React.useEffect(() => { 60 | markRead(); 61 | }, []); 62 | 63 | const getCommunity = () => { 64 | apiClient.postStore.setCommunityPosts([]); 65 | apiClient.communityStore.setCommunity(null); 66 | navigation.navigate("Community", { id: post.community.id }); 67 | }; 68 | 69 | const getAuthor = () => { 70 | navigation.navigate("User", { personId: post.creator.id }); 71 | }; 72 | 73 | const customReadColor = post.read ? "#ababab" : colors.text; 74 | 75 | const openCommenting = () => { 76 | if (post.post.locked) return; 77 | apiClient.commentsStore.setReplyTo({ 78 | postId: post.post.id, 79 | parent_id: undefined, 80 | title: post.post.name, 81 | community: post.community.name, 82 | published: post.post.published, 83 | author: post.creator.name, 84 | content: post.post.body || post.post.url, 85 | language_id: post.post.language_id, 86 | }); 87 | navigation.navigate("CommentWrite"); 88 | }; 89 | 90 | return ( 91 | <> 92 | 93 | 99 | 100 | 105 | {post.post.name} 106 | 107 | 108 | 109 | 110 | {isPic ? ( 111 | 112 | ) : null} 113 | {post.post.url || post.post.embed_title ? ( 114 | 119 | ) : null} 120 | 121 | 122 | 128 | 129 | {showAllButton ? ( 130 | { 132 | navigation.setParams({ post: post.post.id, parentId: undefined }); 133 | void apiClient.commentsStore.getComments(post.post.id); 134 | }} 135 | simple 136 | > 137 | 145 | Show all {post.counts.comments} comments 146 | 147 | 148 | ) : null} 149 | 150 | ); 151 | } 152 | 153 | // todo add saving 154 | 155 | const styles = StyleSheet.create({ 156 | container: { 157 | padding: 8, 158 | borderBottomWidth: 1, 159 | marginTop: 56, 160 | }, 161 | previewButton: { 162 | width: "100%", 163 | alignItems: "center", 164 | padding: 12, 165 | }, 166 | postName: { 167 | fontSize: 17, 168 | fontWeight: "500", 169 | flex: 1, 170 | marginTop: 4, 171 | marginBottom: 8, 172 | }, 173 | postImg: { width: "100%", height: 340 }, 174 | }); 175 | 176 | export default observer(Post); 177 | -------------------------------------------------------------------------------- /components/Post/FeedPost.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Dimensions, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { NativeStackScreenProps } from "@react-navigation/native-stack"; 6 | import { PostView } from "lemmy-js-client"; 7 | import { observer } from "mobx-react-lite"; 8 | 9 | import { Text, TouchableOpacity } from "../../ThemedComponents"; 10 | import { apiClient } from "../../store/apiClient"; 11 | import { makeDateString } from "../../utils/utils"; 12 | import MdRenderer from "../MdRenderer"; 13 | import Embed from "./Embed"; 14 | import Media from "./Media"; 15 | import PostBadges from "./PostBadges"; 16 | import PostIconRow from "./PostIconRow"; 17 | import PostTitle from "./PostTitle"; 18 | 19 | // !!!TODO!!! 20 | // 1. split stuff into components 21 | // 2. research performance 22 | // 3. see how I can force max lines on markdown 23 | function Post({ 24 | post, 25 | navigation, 26 | useCommunity, 27 | }: { 28 | post: PostView; 29 | useCommunity?: boolean; 30 | navigation?: NativeStackScreenProps["navigation"]; 31 | }) { 32 | const { colors } = useTheme(); 33 | 34 | // flags to mark the post 35 | const isNsfw = post.post.nsfw || post.community.nsfw; 36 | const isPic = post.post.url 37 | ? /\.(jpeg|jpg|gif|png|webp)$/.test(post.post.url) 38 | : false; 39 | // const isLocal = post.post.local; // do I even need it? 40 | 41 | const maxLines = 3; 42 | const safeDescription = post.post.body ? post.post.body.slice(0, 500) : ""; 43 | const dateStr = makeDateString(post.post.published); 44 | 45 | const markRead = () => { 46 | if (apiClient.loginDetails?.jwt) { 47 | void apiClient.postStore.markPostRead( 48 | { 49 | post_ids: [post.post.id], 50 | read: true, 51 | }, 52 | useCommunity 53 | ); 54 | } 55 | }; 56 | 57 | const getCommunity = () => { 58 | apiClient.postStore.setCommunityPosts([]); 59 | apiClient.communityStore.setCommunity(null); 60 | navigation.navigate("Community", { id: post.community.id }); 61 | }; 62 | 63 | const getAuthor = () => { 64 | navigation.navigate("User", { personId: post.creator.id }); 65 | }; 66 | 67 | const getComments = () => { 68 | apiClient.postStore.setSinglePost(post); 69 | navigation.navigate("Post", { post: post.post.id, openComment: 0 }); 70 | }; 71 | 72 | const customReadColor = post.read ? "#ababab" : colors.text; 73 | return ( 74 | 75 | 81 | { 85 | apiClient.postStore.setSinglePost(post); 86 | navigation.navigate("Post", { post: post.post.id }); 87 | }} 88 | > 89 | 94 | {post.post.name} 95 | 96 | 97 | 98 | { 102 | apiClient.postStore.setSinglePost(post); 103 | navigation.navigate("Post", { post: post.post.id }); 104 | }} 105 | > 106 | {isPic ? ( 107 | 108 | ) : ( 109 | 110 | {post.post.url || post.post.embed_title ? ( 111 | 116 | ) : null} 117 | 118 | 119 | )} 120 | 121 | 127 | 128 | ); 129 | } 130 | 131 | // todo add saving 132 | 133 | const styles = StyleSheet.create({ 134 | container: { 135 | padding: 8, 136 | width: Dimensions.get("window").width, 137 | borderBottomWidth: 1, 138 | }, 139 | postName: { 140 | fontSize: 17, 141 | fontWeight: "500", 142 | marginTop: 4, 143 | marginBottom: 8, 144 | }, 145 | }); 146 | 147 | export default observer(Post); 148 | -------------------------------------------------------------------------------- /components/Post/ImageViewer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, ToastAndroid, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { setImageAsync } from "expo-clipboard"; 6 | import { documentDirectory, downloadAsync } from "expo-file-system"; 7 | import { 8 | addAssetsToAlbumAsync, 9 | createAlbumAsync, 10 | createAssetAsync, 11 | getAlbumAsync, 12 | usePermissions, 13 | } from "expo-media-library"; 14 | import ImageView from "react-native-image-viewing"; 15 | 16 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 17 | 18 | interface Props { 19 | url: string; 20 | name: string; 21 | visible: boolean; 22 | setIsVisible: React.Dispatch>; 23 | shareImage: () => void; 24 | } 25 | 26 | function ImageViewer({ url, name, visible, setIsVisible, shareImage }: Props) { 27 | const [permissionResponse, askForPermission] = usePermissions({ 28 | writeOnly: true, 29 | }); 30 | const { colors } = useTheme(); 31 | const safeName = name.length > 45 ? name.slice(0, 40) + "..." : name; 32 | 33 | const handleDownload = async () => { 34 | let fileUri = documentDirectory + `${safeName.replace(" ", "-")}.jpg`; 35 | try { 36 | const res = await downloadAsync(url, fileUri); 37 | await saveFile(res.uri); 38 | ToastAndroid.showWithGravity( 39 | "Image saved", 40 | ToastAndroid.SHORT, 41 | ToastAndroid.CENTER 42 | ); 43 | } catch (err) { 44 | ToastAndroid.showWithGravity( 45 | "Couldn't save image", 46 | ToastAndroid.SHORT, 47 | ToastAndroid.CENTER 48 | ); 49 | console.log("FS Err: ", err); 50 | } 51 | }; 52 | 53 | const copyImage = () => { 54 | fetch(url) 55 | .then((r) => r.blob()) 56 | .then((blob) => { 57 | const reader = new FileReader(); 58 | reader.readAsDataURL(blob); 59 | reader.onloadend = () => { 60 | const base64data = reader.result.toString().split(",")[1]; 61 | setImageAsync(base64data.toString()).catch((err) => 62 | console.log("Copy err: ", err) 63 | ); 64 | }; 65 | }); 66 | }; 67 | 68 | const saveFile = async (fileUri) => { 69 | const shouldAsk = 70 | permissionResponse?.granted === false && 71 | permissionResponse?.canAskAgain === true; 72 | const saveToAlbum = async () => { 73 | try { 74 | const asset = await createAssetAsync(fileUri); 75 | const album = await getAlbumAsync("Arctius"); 76 | if (album === null) { 77 | await createAlbumAsync("Arctius", asset, false); 78 | } else { 79 | await addAssetsToAlbumAsync([asset], album, false); 80 | } 81 | } catch (err) { 82 | console.log("Save err: ", err); 83 | } 84 | }; 85 | if (shouldAsk) { 86 | askForPermission().then(async (res) => { 87 | if (res.status === "granted") { 88 | void saveToAlbum(); 89 | } else if (res.status === "denied") { 90 | alert("please allow permissions to download"); 91 | } 92 | }); 93 | } else { 94 | void saveToAlbum(); 95 | } 96 | }; 97 | return ( 98 | setIsVisible(false)} 103 | FooterComponent={() => ( 104 | 105 | 106 | {safeName} 107 | 108 | 109 | 110 | 115 | 116 | 117 | 122 | 123 | 124 | 129 | 130 | 131 | 132 | )} 133 | /> 134 | ); 135 | } 136 | 137 | const styles = StyleSheet.create({ 138 | imgHeader: { 139 | padding: 12, 140 | alignItems: "center", 141 | flexDirection: "row", 142 | justifyContent: "space-between", 143 | gap: 16, 144 | }, 145 | }); 146 | 147 | export default ImageViewer; 148 | -------------------------------------------------------------------------------- /components/Post/Media.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Image, Share, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 8 | import { preferences } from "../../store/preferences"; 9 | import ImageViewer from "./ImageViewer"; 10 | 11 | interface Props { 12 | url: string; 13 | name: string; 14 | isNsfw: boolean; 15 | small?: boolean; 16 | } 17 | 18 | function Media({ url, name, isNsfw, small }: Props) { 19 | const [visible, setIsVisible] = React.useState(false); 20 | const { colors } = useTheme(); 21 | const shareImage = () => { 22 | void Share.share({ 23 | url: url, 24 | message: url, 25 | title: "Share post image", 26 | }); 27 | }; 28 | 29 | const imgStyle = React.useMemo(() => { 30 | return small ? styles.postSmallImg : styles.postImg; 31 | }, [small]); 32 | const containerStyle = React.useMemo(() => { 33 | return small ? styles.noImageSmall : styles.noImage; 34 | }, [small]); 35 | 36 | return ( 37 | <> 38 | 45 | setIsVisible(true)} simple> 46 | {preferences.lowTrafficMode ? ( 47 | 48 | 49 | {!small ? ( 50 | <> 51 | Low data mode enabled 52 | Tap to view image 53 | 54 | ) : null} 55 | 56 | ) : ( 57 | {"Image 66 | )} 67 | 68 | 69 | ); 70 | } 71 | 72 | const styles = StyleSheet.create({ 73 | postImg: { width: "100%", height: 340 }, 74 | noImage: { 75 | width: "100%", 76 | height: 340, 77 | flexDirection: "column", 78 | gap: 8, 79 | alignItems: "center", 80 | justifyContent: "center", 81 | }, 82 | noImageSmall: { 83 | width: 80, 84 | height: 80, 85 | borderRadius: 8, 86 | flexDirection: "column", 87 | gap: 8, 88 | alignItems: "center", 89 | justifyContent: "center", 90 | }, 91 | text: { 92 | fontSize: 16, 93 | opacity: 0.8, 94 | }, 95 | postSmallImg: { width: 80, height: 80, borderRadius: 8 }, 96 | }); 97 | 98 | export default observer(Media); 99 | -------------------------------------------------------------------------------- /components/Post/PostBadges.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { PostView } from "lemmy-js-client"; 5 | import { observer } from "mobx-react-lite"; 6 | 7 | import { Icon, Text } from "../../ThemedComponents"; 8 | 9 | function PostBadges({ post, isNsfw }: { post: PostView; isNsfw?: boolean }) { 10 | return ( 11 | 12 | {post.post.featured_community || post.post.featured_local ? ( 13 | 14 | 15 | 16 | Featured 17 | 18 | 19 | ) : null} 20 | {isNsfw ? ( 21 | 22 | NSFW 23 | 24 | ) : null} 25 | {post.post.locked ? ( 26 | 27 | 28 | 29 | Locked 30 | 31 | 32 | ) : null} 33 | {post.saved ? ( 34 | 35 | 36 | 37 | Saved 38 | 39 | 40 | ) : null} 41 | 42 | ); 43 | } 44 | 45 | const styles = StyleSheet.create({ 46 | badges: { 47 | flexDirection: "row", 48 | gap: 8, 49 | alignItems: "flex-end", 50 | flex: 1, 51 | marginVertical: 4, 52 | }, 53 | badgeText: { fontSize: 12, fontWeight: "500" }, 54 | badge: { flexDirection: "row", gap: 6, alignItems: "center" }, 55 | }); 56 | 57 | export default observer(PostBadges); 58 | -------------------------------------------------------------------------------- /components/Post/PostTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Image, StyleSheet, View } from "react-native"; 3 | 4 | import { PostView } from "lemmy-js-client"; 5 | 6 | import { hostname } from "../../Screens/Search/ListComponents"; 7 | import { Text, TouchableOpacity } from "../../ThemedComponents"; 8 | 9 | interface Props { 10 | post: PostView; 11 | getCommunity: () => void; 12 | getAuthor: () => void; 13 | customReadColor?: string; 14 | dateStr: string; 15 | } 16 | 17 | function PostTitle({ 18 | post, 19 | getCommunity, 20 | getAuthor, 21 | customReadColor, 22 | dateStr, 23 | }: Props) { 24 | const isLocal = post.community.local; 25 | const communityName = isLocal 26 | ? `c/${post.community.name}` 27 | : `c/${post.community.name}@${hostname(post.community.actor_id)}`; 28 | 29 | const safeCommunityName = 30 | communityName.length > 50 31 | ? communityName.slice(0, 50) + "..." 32 | : communityName; 33 | 34 | const authorDisplayName = `u/${ 35 | post.creator.display_name || post.creator.name 36 | }`; 37 | const safeAuthorName = 38 | authorDisplayName.length > 50 39 | ? authorDisplayName.slice(0, 50) + "..." 40 | : authorDisplayName; 41 | 42 | return ( 43 | 44 | 45 | 46 | 51 | 52 | 53 | 54 | 55 | 60 | {safeCommunityName} 61 | 62 | 63 | 64 | 65 | {safeAuthorName} 66 | 67 | 68 | 69 | 70 | {dateStr} 71 | 72 | 73 | ); 74 | } 75 | 76 | const styles = StyleSheet.create({ 77 | container: { 78 | padding: 8, 79 | borderBottomWidth: 1, 80 | }, 81 | topRow: { 82 | flexDirection: "row", 83 | alignItems: "center", 84 | gap: 6, 85 | }, 86 | communityIconContainer: { 87 | backgroundColor: "#f6f6f6", 88 | borderRadius: 28, 89 | width: 28, 90 | height: 28, 91 | }, 92 | communityIcon: { width: 28, height: 28, borderRadius: 28 }, 93 | authorName: { 94 | fontSize: 13, 95 | fontWeight: "500", 96 | color: "orange", 97 | marginTop: 2, 98 | }, 99 | date: { 100 | alignSelf: "flex-start", 101 | marginLeft: "auto", 102 | }, 103 | communityName: { 104 | fontSize: 13, 105 | fontWeight: "500", 106 | color: "violet", 107 | }, 108 | smolText: { fontSize: 12 }, 109 | }); 110 | 111 | export default PostTitle; 112 | -------------------------------------------------------------------------------- /components/Post/TinyPost.tsx: -------------------------------------------------------------------------------- 1 | // still empty, thinking about making mini version of a post component for mini feed where everything has set height 2 | import React from "react"; 3 | import { Dimensions, Image, Share, StyleSheet, View } from "react-native"; 4 | 5 | import { useTheme } from "@react-navigation/native"; 6 | import { NativeStackScreenProps } from "@react-navigation/native-stack"; 7 | import { PostView } from "lemmy-js-client"; 8 | import { observer } from "mobx-react-lite"; 9 | 10 | import { Icon, Text, TouchableOpacity } from "../../ThemedComponents"; 11 | import { apiClient } from "../../store/apiClient"; 12 | import { makeDateString } from "../../utils/utils"; 13 | import Media from "./Media"; 14 | import PostIconRow from "./PostIconRow"; 15 | import PostTitle from "./PostTitle"; 16 | 17 | function TinyPost({ 18 | post, 19 | navigation, 20 | useCommunity, 21 | }: { 22 | post: PostView; 23 | useCommunity?: boolean; 24 | navigation?: NativeStackScreenProps["navigation"]; 25 | }) { 26 | const { colors } = useTheme(); 27 | 28 | const isNsfw = post.post.nsfw || post.community.nsfw; 29 | const isPic = post.post.url 30 | ? /\.(jpeg|jpg|gif|png|webp)$/.test(post.post.url) 31 | : false; 32 | const dateStr = makeDateString(post.post.published); 33 | 34 | const markRead = () => { 35 | if (apiClient.loginDetails?.jwt) { 36 | void apiClient.postStore.markPostRead( 37 | { 38 | post_ids: [post.post.id], 39 | read: true, 40 | }, 41 | useCommunity 42 | ); 43 | } 44 | }; 45 | 46 | const getCommunity = () => { 47 | apiClient.postStore.setCommunityPosts([]); 48 | apiClient.communityStore.setCommunity(null); 49 | navigation.navigate("Community", { id: post.community.id }); 50 | }; 51 | 52 | const getAuthor = () => { 53 | navigation.navigate("User", { personId: post.creator.id }); 54 | }; 55 | 56 | const getComments = () => { 57 | apiClient.postStore.setSinglePost(post); 58 | navigation.navigate("Post", { post: post.post.id, openComment: 0 }); 59 | }; 60 | 61 | const customReadColor = post.read ? "#ababab" : colors.text; 62 | 63 | return ( 64 | 65 | 71 | 72 | { 75 | apiClient.postStore.setSinglePost(post); 76 | navigation.navigate("Post", { post: post.post.id }); 77 | }} 78 | > 79 | {isPic ? ( 80 | 86 | ) : ( 87 | 88 | 94 | 95 | )} 96 | 97 | 98 | { 102 | apiClient.postStore.setSinglePost(post); 103 | navigation.navigate("Post", { post: post.post.id }); 104 | }} 105 | > 106 | 111 | {post.post.name} 112 | 113 | 114 | 115 | {isNsfw ? NSFW : null} 116 | 117 | {post.post.body} 118 | 119 | 120 | 121 | 122 | 128 | 129 | ); 130 | } 131 | 132 | const styles = StyleSheet.create({ 133 | container: { 134 | padding: 8, 135 | borderBottomWidth: 1, 136 | width: Dimensions.get("window").width, 137 | }, 138 | postName: { 139 | fontSize: 17, 140 | fontWeight: "500", 141 | marginTop: 4, 142 | marginBottom: 8, 143 | }, 144 | postImg: { width: 80, height: 80, borderRadius: 8 }, 145 | imageLike: { 146 | width: 80, 147 | height: 80, 148 | borderRadius: 8, 149 | justifyContent: "center", 150 | alignItems: "center", 151 | }, 152 | row: { 153 | paddingVertical: 8, 154 | flexDirection: "row", 155 | alignItems: "flex-start", 156 | gap: 16, 157 | }, 158 | text: { 159 | opacity: 0.8, 160 | }, 161 | }); 162 | 163 | export default observer(TinyPost); 164 | -------------------------------------------------------------------------------- /components/Prompt.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ActivityIndicator, StyleSheet, View } from "react-native"; 3 | 4 | import { useTheme } from "@react-navigation/native"; 5 | 6 | import { Text, TextInput, TouchableOpacity } from "../ThemedComponents"; 7 | import { ReportMode } from "../store/apiClient"; 8 | 9 | function Prompt({ title, text, placeholder, onSubmit, onCancel, reportMode }) { 10 | const [isLoading, setIsLoading] = React.useState(false); 11 | const [value, setValue] = React.useState(""); 12 | const { colors } = useTheme(); 13 | 14 | React.useEffect(() => { 15 | setIsLoading(false); 16 | setValue(""); 17 | }, []); 18 | 19 | const submit = () => { 20 | setIsLoading(true); 21 | onSubmit(value); 22 | }; 23 | 24 | return ( 25 | 26 | {[ReportMode.Post, ReportMode.Comment].includes(reportMode) ? ( 27 | <> 28 | {title} 29 | {text} 30 | setValue(text)} 35 | autoCapitalize="none" 36 | autoCorrect 37 | accessibilityLabel={"Prompt text"} 38 | /> 39 | 40 | ) : ( 41 | <> 42 | Mod action 43 | setValue(text)} 48 | autoCapitalize="none" 49 | autoCorrect 50 | accessibilityLabel={"Prompt text"} 51 | /> 52 | 53 | )} 54 | 55 | 56 | Cancel 57 | 58 | 59 | {isLoading ? ( 60 | 61 | ) : ( 62 | Submit 63 | )} 64 | 65 | 66 | 67 | ); 68 | } 69 | 70 | const styles = StyleSheet.create({ 71 | container: { 72 | padding: 16, 73 | borderRadius: 8, 74 | width: "75%", 75 | position: "absolute", 76 | top: "40%", 77 | left: "12%", 78 | }, 79 | title: { 80 | fontSize: 20, 81 | fontWeight: "bold", 82 | marginBottom: 8, 83 | }, 84 | text: { 85 | fontSize: 16, 86 | marginBottom: 8, 87 | }, 88 | input: { 89 | padding: 8, 90 | borderRadius: 4, 91 | marginBottom: 8, 92 | }, 93 | buttons: { 94 | flexDirection: "row", 95 | justifyContent: "flex-end", 96 | }, 97 | button: { 98 | padding: 8, 99 | borderRadius: 4, 100 | marginLeft: 8, 101 | }, 102 | buttonText: { 103 | fontSize: 16, 104 | }, 105 | }); 106 | 107 | export default Prompt; 108 | -------------------------------------------------------------------------------- /components/TinyComment.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | 4 | import { Text } from "../ThemedComponents"; 5 | import { commonColors, commonStyles } from "../commonStyles"; 6 | import { makeDateString } from "../utils/utils"; 7 | import CommunityIcon from "./CommunityIcon"; 8 | import MdRenderer from "./MdRenderer"; 9 | 10 | function MiniComment({ 11 | published, 12 | author, 13 | community, 14 | title, 15 | communityPic, 16 | content, 17 | isSelf, 18 | useMd, 19 | }: { 20 | published: string; 21 | author: string; 22 | community: string; 23 | communityPic?: string; 24 | title: string; 25 | content: string; 26 | isSelf?: boolean; 27 | useMd?: boolean; 28 | }) { 29 | const dateStr = makeDateString(published); 30 | return ( 31 | 32 | 33 | 34 | 35 | {community} 36 | {isSelf ? null : ( 37 | {author} 38 | )} 39 | 40 | {dateStr} 41 | 42 | 43 | 44 | {title} 45 | 46 | {content ? ( 47 | useMd ? ( 48 | 49 | ) : ( 50 | 51 | {content} 52 | 53 | ) 54 | ) : null} 55 | 56 | 57 | ); 58 | } 59 | 60 | const styles = StyleSheet.create({ 61 | comment: { 62 | paddingVertical: 8, 63 | }, 64 | topRow: { 65 | flexDirection: "row", 66 | alignItems: "center", 67 | gap: 6, 68 | }, 69 | }); 70 | 71 | export default MiniComment; 72 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 3.17.1" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal" 9 | }, 10 | "preview": { 11 | "distribution": "internal", 12 | "android": { 13 | "buildType": "apk", 14 | "gradleCommand": ":app:assembleRelease" 15 | } 16 | }, 17 | "production": {} 18 | }, 19 | "submit": { 20 | "production": {} 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arctius", 3 | "version": "0.2.4", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web", 10 | "pretty": "prettier --write ." 11 | }, 12 | "dependencies": { 13 | "@expo/react-native-action-sheet": "^4.0.1", 14 | "@react-native-async-storage/async-storage": "1.21.0", 15 | "@react-native-picker/picker": "2.6.1", 16 | "@react-navigation/bottom-tabs": "^6.5.7", 17 | "@react-navigation/material-top-tabs": "^6.6.3", 18 | "@react-navigation/native": "^6.1.6", 19 | "@react-navigation/native-stack": "^6.9.12", 20 | "@types/react": "~18.2.14", 21 | "buffer": "^6.0.3", 22 | "expo": "^50.0.17", 23 | "expo-clipboard": "~5.0.1", 24 | "expo-dev-client": "~3.3.11", 25 | "expo-file-system": "~16.0.9", 26 | "expo-haptics": "~12.8.1", 27 | "expo-image-picker": "~14.7.1", 28 | "expo-media-library": "~15.9.2", 29 | "expo-secure-store": "~12.8.1", 30 | "expo-sharing": "~11.10.0", 31 | "expo-status-bar": "~1.11.1", 32 | "lemmy-js-client": "^0.19.4-alpha.18", 33 | "mobx": "^6.10.0", 34 | "mobx-react-lite": "^4.0.3", 35 | "prettier": "^2.8.8", 36 | "react": "18.2.0", 37 | "react-native": "0.73.6", 38 | "react-native-image-viewing": "^0.2.2", 39 | "react-native-marked": "^5.0.8", 40 | "react-native-pager-view": "6.2.3", 41 | "react-native-safe-area-context": "4.8.2", 42 | "react-native-screens": "~3.29.0", 43 | "react-native-svg": "14.1.0", 44 | "react-native-tab-view": "^3.5.2", 45 | "typescript": "^5.1.3" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.20.0", 49 | "expo-asset": "^9.0.2", 50 | "@trivago/prettier-plugin-sort-imports": "^4.2.0", 51 | "babel-plugin-transform-remove-console": "^6.9.4" 52 | }, 53 | "private": true 54 | } 55 | -------------------------------------------------------------------------------- /services/apiService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BanFromCommunity, 3 | BlockCommunity, 4 | BlockPerson, 5 | CreateComment, 6 | CreateCommentLike, 7 | CreateCommentReport, 8 | CreatePost, 9 | CreatePostLike, 10 | CreatePostReport, 11 | CreatePrivateMessage, 12 | DeletePost, 13 | DeletePrivateMessage, 14 | EditComment, 15 | EditPost, 16 | EditPrivateMessage, 17 | FeaturePost, 18 | FollowCommunity, 19 | GetComments, 20 | GetCommunity, 21 | GetPersonDetails, 22 | GetPersonMentions, 23 | GetPost, 24 | GetPosts, 25 | GetPostsResponse, 26 | GetPrivateMessages, 27 | GetReplies, 28 | LemmyHttp, 29 | ListCommunities, 30 | LockPost, 31 | Login, 32 | LoginResponse, 33 | MarkCommentReplyAsRead, 34 | MarkPostAsRead, 35 | RemovePost, 36 | SavePost, 37 | SaveUserSettings, 38 | Search, 39 | UploadImage, 40 | } from "lemmy-js-client"; 41 | 42 | // !!!TODO!!! 43 | // split this crap into multiple services once app will be more or less close to MVP 44 | 45 | export default class ApiService { 46 | constructor(private readonly client: LemmyHttp) {} 47 | 48 | getPosts(filters: GetPosts): Promise { 49 | return this.client.getPosts(filters); 50 | } 51 | 52 | getSinglePost(form: GetPost) { 53 | return this.client.getPost(form); 54 | } 55 | 56 | async login(form: Login): Promise { 57 | const r = await this.client.login(form); 58 | this.client.setHeaders({ Authorization: `Bearer ${r.jwt}` }); 59 | return r; 60 | } 61 | 62 | getProfile(form: GetPersonDetails) { 63 | return this.client.getPersonDetails(form); 64 | } 65 | 66 | getComments(form: GetComments) { 67 | return this.client.getComments(form); 68 | } 69 | 70 | savePost(form: SavePost) { 71 | return this.client.savePost(form); 72 | } 73 | 74 | ratePost(form: CreatePostLike) { 75 | return this.client.likePost(form); 76 | } 77 | 78 | rateComment(form: CreateCommentLike) { 79 | return this.client.likeComment(form); 80 | } 81 | 82 | getGeneralData() { 83 | return this.client.getSite(); 84 | } 85 | 86 | markPostRead(form: MarkPostAsRead) { 87 | return this.client.markPostAsRead(form); 88 | } 89 | 90 | saveUserSettings(form: SaveUserSettings) { 91 | return this.client.saveUserSettings(form); 92 | } 93 | 94 | search(form: Search) { 95 | return this.client.search(form); 96 | } 97 | 98 | fetchCommunity(form: GetCommunity) { 99 | return this.client.getCommunity(form); 100 | } 101 | 102 | followCommunity(form: FollowCommunity) { 103 | return this.client.followCommunity(form); 104 | } 105 | 106 | getCommunities(form: ListCommunities) { 107 | return this.client.listCommunities(form); 108 | } 109 | 110 | getUnreads() { 111 | return this.client.getUnreadCount(); 112 | } 113 | 114 | getReplies(form: GetReplies) { 115 | return this.client.getReplies(form); 116 | } 117 | 118 | getMentions(form: GetPersonMentions) { 119 | return this.client.getPersonMentions(form); 120 | } 121 | 122 | getMessages(form: GetPrivateMessages) { 123 | return this.client.getPrivateMessages(form); 124 | } 125 | 126 | markReplyRead(form: MarkCommentReplyAsRead) { 127 | return this.client.markCommentReplyAsRead(form); 128 | } 129 | 130 | markAllRead() { 131 | return this.client.markAllAsRead(); 132 | } 133 | 134 | createComment(form: CreateComment) { 135 | return this.client.createComment(form); 136 | } 137 | 138 | createPost(form: CreatePost) { 139 | return this.client.createPost(form); 140 | } 141 | 142 | deletePost(form: DeletePost) { 143 | return this.client.deletePost(form); 144 | } 145 | 146 | createPrivateMessage(form: CreatePrivateMessage) { 147 | return this.client.createPrivateMessage(form); 148 | } 149 | 150 | deletePrivateMessage(form: DeletePrivateMessage) { 151 | return this.client.deletePrivateMessage(form); 152 | } 153 | 154 | editPrivateMessage(form: EditPrivateMessage) { 155 | return this.client.editPrivateMessage(form); 156 | } 157 | 158 | blockCommunity(form: BlockCommunity) { 159 | return this.client.blockCommunity(form); 160 | } 161 | 162 | blockPerson(form: BlockPerson) { 163 | return this.client.blockPerson(form); 164 | } 165 | 166 | createCommentReport(form: CreateCommentReport) { 167 | return this.client.createCommentReport(form); 168 | } 169 | 170 | createPostReport(form: CreatePostReport) { 171 | return this.client.createPostReport(form); 172 | } 173 | 174 | editPost(form: EditPost) { 175 | return this.client.editPost(form); 176 | } 177 | 178 | editComment(form: EditComment) { 179 | return this.client.editComment(form); 180 | } 181 | 182 | uploadImage(form: UploadImage) { 183 | return this.client.uploadImage(form); 184 | } 185 | 186 | /* MOD ACTIONS */ 187 | 188 | removePost(form: RemovePost) { 189 | return this.client.removePost(form); 190 | } 191 | 192 | lockPost(form: LockPost) { 193 | return this.client.lockPost(form); 194 | } 195 | 196 | banCommunityUser(form: BanFromCommunity) { 197 | return this.client.banFromCommunity(form); 198 | } 199 | 200 | featurePost(form: FeaturePost) { 201 | return this.client.featurePost(form); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /store/communityStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BlockCommunityResponse, 3 | Community, 4 | CommunityResponse, 5 | CommunityView, 6 | GetCommunityResponse, 7 | LoginResponse, 8 | } from "lemmy-js-client"; 9 | import { action, makeObservable, observable } from "mobx"; 10 | 11 | import { asyncStorageHandler, dataKeys } from "../asyncStorage"; 12 | import DataClass from "./dataClass"; 13 | 14 | class CommunityStore extends DataClass { 15 | communityId = 0; 16 | community: CommunityView | null = null; 17 | followedCommunities: Community[] = []; 18 | favoriteCommunities: Community[] = []; 19 | 20 | constructor() { 21 | super(); 22 | makeObservable(this, { 23 | communityId: observable, 24 | isLoading: observable, 25 | community: observable, 26 | followedCommunities: observable, 27 | favoriteCommunities: observable, 28 | setFavoriteCommunities: action, 29 | setCommunityId: action, 30 | setCommunity: action, 31 | setIsLoading: action, 32 | setFollowedCommunities: action, 33 | }); 34 | 35 | asyncStorageHandler.readData(dataKeys.favCommunities).then((value) => { 36 | if (value) { 37 | let comms: CommunityView[] | Community[] = JSON.parse(value); 38 | // @ts-ignore 39 | if (comms[0]?.community?.id) { 40 | comms = (comms as CommunityView[]).map((c) => c.community); 41 | } 42 | this.setFavoriteCommunities(comms as Community[]); 43 | } 44 | }); 45 | } 46 | 47 | setFavoriteCommunities(communities: Community[]) { 48 | communities.forEach((c) => { 49 | c.description = ""; 50 | }); 51 | this.favoriteCommunities = communities; 52 | void asyncStorageHandler.setData( 53 | dataKeys.favCommunities, 54 | JSON.stringify(communities) 55 | ); 56 | } 57 | 58 | get regularFollowedCommunities() { 59 | return this.followedCommunities.filter( 60 | (c) => this.favoriteCommunities.findIndex((f) => f.id === c.id) === -1 61 | ); 62 | } 63 | 64 | addToFavorites(community: Community) { 65 | const communities = [...this.favoriteCommunities]; 66 | communities.push(community); 67 | this.setFavoriteCommunities(communities); 68 | } 69 | 70 | removeFromFavorites(community: Community) { 71 | const communities = [...this.favoriteCommunities]; 72 | const index = communities.findIndex((c) => c.id === community.id); 73 | if (index > -1) { 74 | communities.splice(index, 1); 75 | } 76 | this.setFavoriteCommunities(communities); 77 | } 78 | 79 | setFollowedCommunities(communities: Community[]) { 80 | this.followedCommunities = communities; 81 | } 82 | 83 | setCommunityId(id: number) { 84 | this.communityId = id; 85 | } 86 | 87 | setCommunity(community: CommunityView) { 88 | this.community = community; 89 | } 90 | 91 | async getCommunity(id?: number, name?: string) { 92 | await this.fetchData( 93 | () => this.api.fetchCommunity({ id, name }), 94 | (data) => this.setCommunity(data.community_view), 95 | (error) => console.log(error) 96 | ); 97 | } 98 | 99 | async followCommunity(id: number, follow: boolean) { 100 | await this.fetchData( 101 | () => this.api.followCommunity({ community_id: id, follow }), 102 | (data) => this.setCommunity(data.community_view), 103 | (error) => console.log(error), 104 | true, 105 | "follow community" 106 | ); 107 | } 108 | 109 | async blockCommunity(id: number, block: boolean) { 110 | await this.fetchData( 111 | () => this.api.blockCommunity({ community_id: id, block }), 112 | (data) => this.setCommunity(data.community_view), 113 | (error) => console.log(error), 114 | true, 115 | "block community" 116 | ); 117 | } 118 | } 119 | 120 | export const communityStore = new CommunityStore(); 121 | -------------------------------------------------------------------------------- /store/dataClass.ts: -------------------------------------------------------------------------------- 1 | import { ToastAndroid } from "react-native"; 2 | 3 | import ApiService from "../services/apiService"; 4 | import { debugStore } from "./debugStore"; 5 | 6 | export default class DataClass { 7 | public api: ApiService; 8 | public isLoading: boolean = false; 9 | 10 | setIsLoading(state: boolean) { 11 | this.isLoading = state; 12 | } 13 | 14 | setClient(client: ApiService) { 15 | this.api = client; 16 | } 17 | 18 | async fetchData( 19 | fetcher: () => Promise, 20 | onSuccess: (data: T) => void, 21 | onError: (error: any) => void, 22 | isUnimportant?: boolean, 23 | label?: string 24 | ) { 25 | if (this.isLoading) return; 26 | console.log(new Date().getTime(), "fetching", label); 27 | if (!isUnimportant) this.setIsLoading(true); 28 | try { 29 | const data = await fetcher(); 30 | onSuccess(data); 31 | } catch (e) { 32 | const errStr = `Network${ 33 | typeof e === "string" ? "error: " + e : "error" 34 | }`; 35 | ToastAndroid.show(errStr, ToastAndroid.SHORT); 36 | debugStore.addError( 37 | typeof e === "string" 38 | ? `${label} ${e}` 39 | : `${label} --- ${e.name}: ${e.message}; ${e.stack}` 40 | ); 41 | onError(e); 42 | } finally { 43 | if (!isUnimportant) this.setIsLoading(false); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /store/debugStore.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | 3 | class DebugStore { 4 | errors: string[] = []; 5 | warnings: string[] = []; 6 | debugLog: string[] = []; 7 | 8 | constructor() { 9 | makeAutoObservable(this); 10 | } 11 | 12 | addError(error: string) { 13 | if (this.errors.length > 200) this.errors.shift(); 14 | this.errors.push(error); 15 | } 16 | } 17 | 18 | export const debugStore = new DebugStore(); 19 | -------------------------------------------------------------------------------- /store/mentionsStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommentReplyResponse, 3 | CommentReplyView, 4 | GetPersonMentionsResponse, 5 | GetRepliesResponse, 6 | GetUnreadCountResponse, 7 | PersonMentionView, 8 | PrivateMessageView, 9 | PrivateMessagesResponse, 10 | } from "lemmy-js-client"; 11 | import { action, makeObservable, observable } from "mobx"; 12 | 13 | import DataClass from "./dataClass"; 14 | 15 | interface Unreads { 16 | replies: number; 17 | mentions: number; 18 | messages: number; 19 | } 20 | 21 | class MentionsStore extends DataClass { 22 | unreadsCount = 0; 23 | unreads: Unreads = { 24 | replies: 0, 25 | mentions: 0, 26 | messages: 0, 27 | }; 28 | page = 1; 29 | mentionsPage = 1; 30 | messagesPage = 1; 31 | replies: CommentReplyView[] = []; 32 | mentions: PersonMentionView[] = []; 33 | messages: PrivateMessageView[] = []; 34 | 35 | constructor() { 36 | super(); 37 | makeObservable(this, { 38 | unreadsCount: observable, 39 | replies: observable, 40 | page: observable, 41 | unreads: observable, 42 | mentions: observable, 43 | messages: observable, 44 | messagesPage: observable, 45 | mentionsPage: observable, 46 | setMentionsPage: action, 47 | setMessagesPage: action, 48 | setMessages: action, 49 | setUnreads: action, 50 | setMentions: action, 51 | setUnreadsCount: action, 52 | setReplies: action, 53 | setPage: action, 54 | isLoading: observable, 55 | setIsLoading: action, 56 | }); 57 | } 58 | 59 | setUnreads(unreads: Unreads) { 60 | this.unreads = unreads; 61 | } 62 | 63 | setMessages(messages: PrivateMessageView[]) { 64 | this.messages = messages; 65 | } 66 | 67 | setMessagesPage(page: number) { 68 | this.messagesPage = page; 69 | } 70 | 71 | setPage(page: number) { 72 | this.page = page; 73 | } 74 | 75 | setMentionsPage(page: number) { 76 | this.mentionsPage = page; 77 | } 78 | 79 | setReplies(replies: CommentReplyView[]) { 80 | this.replies = replies; 81 | } 82 | 83 | setMentions(mentions: PersonMentionView[]) { 84 | this.mentions = mentions; 85 | } 86 | 87 | setUnreadsCount(count: number) { 88 | this.unreadsCount = count; 89 | } 90 | 91 | async fetchUnreads() { 92 | await this.fetchData( 93 | () => this.api.getUnreads(), 94 | ({ mentions, replies, private_messages }) => { 95 | this.setUnreadsCount(mentions + replies + private_messages); 96 | this.setUnreads({ mentions, replies, messages: private_messages }); 97 | }, 98 | (error) => console.log(error), 99 | false, 100 | "fetch unreads count" 101 | ); 102 | } 103 | 104 | async getReplies() { 105 | await this.fetchData( 106 | () => 107 | this.api.getReplies({ 108 | sort: "New", 109 | limit: 30, 110 | page: this.page, 111 | }), 112 | ({ replies }) => this.setReplies(replies), 113 | (error) => console.log(error), 114 | false, 115 | "fetch comment replies" 116 | ); 117 | } 118 | 119 | async getMessages() { 120 | await this.fetchData( 121 | () => 122 | this.api.getMessages({ 123 | limit: 30, 124 | page: this.page, 125 | unread_only: false, 126 | }), 127 | ({ private_messages }) => { 128 | this.setMessages(private_messages); 129 | }, 130 | (error) => console.log(error), 131 | false, 132 | "fetch private messages" 133 | ); 134 | } 135 | 136 | async getMentions() { 137 | await this.fetchData( 138 | () => 139 | this.api.getMentions({ 140 | sort: "New", 141 | limit: 30, 142 | page: this.mentionsPage, 143 | }), 144 | ({ mentions }) => { 145 | this.setMentions(mentions); 146 | }, 147 | (error) => console.log(error), 148 | false, 149 | "fetch user mentions" 150 | ); 151 | } 152 | 153 | async markReplyRead(replyId: number) { 154 | await this.fetchData( 155 | () => 156 | this.api.markReplyRead({ 157 | comment_reply_id: replyId, 158 | read: true, 159 | }), 160 | () => { 161 | this.setReplies( 162 | this.replies.map((r) => 163 | r.comment_reply.id === replyId ? { ...r, read: true } : r 164 | ) 165 | ); 166 | }, 167 | (error) => console.log(error), 168 | false, 169 | "mark reply read" 170 | ); 171 | } 172 | 173 | async markAllRepliesRead() { 174 | await this.fetchData( 175 | () => this.api.markAllRead(), 176 | ({ replies }) => this.setReplies(replies), 177 | (error) => console.log(error), 178 | false, 179 | "mark all replies read" 180 | ); 181 | } 182 | } 183 | 184 | export const mentionsStore = new MentionsStore(); 185 | -------------------------------------------------------------------------------- /store/profileStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BlockPersonResponse, 3 | CommunityBlockView, 4 | CommunityModeratorView, 5 | GetPersonDetails, 6 | GetPersonDetailsResponse, 7 | LocalUserView, 8 | LoginResponse, 9 | PersonBlockView, 10 | SaveUserSettings, 11 | SortType, 12 | } from "lemmy-js-client"; 13 | import { action, makeObservable, observable } from "mobx"; 14 | 15 | import DataClass from "./dataClass"; 16 | 17 | class ProfileStore extends DataClass { 18 | public userProfile: GetPersonDetailsResponse | null = null; 19 | public localUser: LocalUserView | null = null; 20 | public moderatedCommunities: CommunityModeratorView[] = []; 21 | public username: string | null = null; 22 | public blockedPeople: PersonBlockView[] = []; 23 | public blockedCommunities: CommunityBlockView[] = []; 24 | public profilePage = 1; 25 | public profileSort: SortType = "New"; 26 | 27 | constructor() { 28 | super(); 29 | makeObservable(this, { 30 | userProfile: observable.deep, 31 | isLoading: observable, 32 | username: observable, 33 | blockedPeople: observable, 34 | blockedCommunities: observable, 35 | localUser: observable, 36 | profileSort: observable, 37 | profilePage: observable, 38 | moderatedCommunities: observable, 39 | setProfile: action, 40 | setModeratedCommunities: action, 41 | setUsername: action, 42 | setClient: action, 43 | setIsLoading: action, 44 | setLocalUser: action, 45 | setProfilePage: action, 46 | setProfileSort: action, 47 | setBlocks: action, 48 | }); 49 | } 50 | 51 | setModeratedCommunities(communities: CommunityModeratorView[]) { 52 | this.moderatedCommunities = communities; 53 | } 54 | 55 | setBlocks(people: PersonBlockView[], communities: CommunityBlockView[]) { 56 | this.blockedPeople = people; 57 | this.blockedCommunities = communities; 58 | } 59 | 60 | setProfilePage(page: number) { 61 | this.profilePage = page; 62 | } 63 | 64 | setProfileSort(sort: SortType) { 65 | this.profileSort = sort; 66 | } 67 | 68 | setLocalUser(localUser: LocalUserView) { 69 | this.localUser = localUser; 70 | } 71 | 72 | async getProfile(form: GetPersonDetails) { 73 | await this.fetchData( 74 | () => 75 | this.api.getProfile({ 76 | ...form, 77 | page: this.profilePage, 78 | sort: this.profileSort, 79 | }), 80 | (profile) => this.setProfile(profile), 81 | (e) => console.error(e), 82 | false, 83 | "get profile _" + form.person_id 84 | ); 85 | } 86 | 87 | setProfile(profile: GetPersonDetailsResponse) { 88 | this.userProfile = profile; 89 | } 90 | 91 | setUsername(username: string) { 92 | this.username = username; 93 | } 94 | 95 | async updateSettings(form: SaveUserSettings) { 96 | const currentSettings = this.localUser.local_user; 97 | const newSettings = { ...currentSettings, ...form }; 98 | await this.fetchData( 99 | // had to improvise because of client bug 100 | () => 101 | this.api.saveUserSettings({ 102 | show_nsfw: form.show_nsfw || this.localUser.local_user.show_nsfw, 103 | show_read_posts: 104 | form.show_read_posts || this.localUser.local_user.show_read_posts, 105 | default_listing_type: this.localUser.local_user.default_listing_type, 106 | default_sort_type: this.localUser.local_user.default_sort_type, 107 | theme: this.localUser.local_user.theme, 108 | interface_language: this.localUser.local_user.interface_language, 109 | discussion_languages: [], 110 | avatar: this.localUser.person.avatar, 111 | banner: this.localUser.person.banner, 112 | display_name: form.display_name || this.localUser.person.display_name, 113 | show_avatars: this.localUser.local_user.show_avatars, 114 | bot_account: false, 115 | show_bot_accounts: this.localUser.local_user.show_bot_accounts, 116 | show_scores: this.localUser.local_user.show_scores, 117 | email: form.email || this.localUser.local_user.email, 118 | bio: form.bio || this.localUser.person.bio, 119 | send_notifications_to_email: 120 | this.localUser.local_user.send_notifications_to_email, 121 | matrix_user_id: this.localUser.person.matrix_user_id, 122 | }), 123 | () => { 124 | const currentUser = this.localUser; 125 | this.setLocalUser({ ...currentUser, local_user: newSettings }); 126 | }, 127 | (e) => console.error(e) 128 | ); 129 | } 130 | 131 | async blockPerson(id: number, blocked: boolean) { 132 | await this.fetchData( 133 | () => 134 | this.api.blockPerson({ 135 | person_id: id, 136 | block: blocked, 137 | }), 138 | ({ person_view }) => { 139 | if (!blocked) { 140 | this.setBlocks( 141 | this.blockedPeople.filter((p) => p.target.id !== id), 142 | this.blockedCommunities 143 | ); 144 | } 145 | if (this.userProfile.person_view.person.id === id) { 146 | this.setProfile({ 147 | ...this.userProfile, 148 | person_view: { 149 | ...this.userProfile.person_view, 150 | ...person_view, 151 | }, 152 | }); 153 | this.setBlocks( 154 | // @ts-ignore 155 | [...this.blockedPeople, { person: {}, target: person_view.person }], 156 | this.blockedCommunities 157 | ); 158 | } 159 | }, 160 | (e) => console.error(e), 161 | true, 162 | "block person _" + id 163 | ); 164 | } 165 | } 166 | 167 | export const profileStore = new ProfileStore(); 168 | -------------------------------------------------------------------------------- /store/searchStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ListingType, 3 | LoginResponse, 4 | SearchResponse, 5 | SearchType, 6 | } from "lemmy-js-client"; 7 | import { action, makeObservable, observable } from "mobx"; 8 | 9 | import DataClass from "./dataClass"; 10 | import { ListingTypeMap } from "./postStore"; 11 | 12 | export const SearchTypeMap = { 13 | All: "All", 14 | Comments: "Comments", 15 | Posts: "Posts", 16 | Communities: "Communities", 17 | Users: "Users", 18 | Url: "Url", 19 | } as const; 20 | 21 | class SearchStore extends DataClass { 22 | public searchQuery: string = ""; 23 | public page: number = 1; 24 | public limit: number = 12; 25 | public type: SearchType = SearchTypeMap.All; 26 | public listingType: ListingType = ListingTypeMap.All; 27 | 28 | constructor() { 29 | super(); 30 | makeObservable(this, { 31 | searchQuery: observable, 32 | page: observable, 33 | listingType: observable, 34 | type: observable, 35 | isLoading: observable, 36 | limit: observable, 37 | setListingType: action, 38 | setSearchType: action, 39 | setPage: action, 40 | setIsLoading: action, 41 | setSearchQuery: action, 42 | }); 43 | } 44 | 45 | setLimit(limit: number) { 46 | this.limit = limit; 47 | } 48 | 49 | setListingType(type: ListingType) { 50 | this.listingType = type; 51 | } 52 | 53 | setSearchType(type: SearchType) { 54 | this.type = type; 55 | } 56 | 57 | setPage(page: number) { 58 | this.page = page; 59 | } 60 | 61 | setSearchQuery(query: string) { 62 | this.searchQuery = query; 63 | } 64 | 65 | async fetchSearch(): Promise { 66 | let results = null; 67 | await this.fetchData( 68 | () => 69 | this.api.search({ 70 | q: this.searchQuery, 71 | limit: this.limit, 72 | page: this.page, 73 | listing_type: this.listingType, 74 | sort: "TopAll", 75 | type_: this.type, 76 | }), 77 | (data) => { 78 | results = data; 79 | }, 80 | (e) => console.error(e) 81 | ); 82 | return results; 83 | } 84 | } 85 | 86 | export const searchStore = new SearchStore(); 87 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | -------------------------------------------------------------------------------- /utils/useKeyboard.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Keyboard, KeyboardEvent } from "react-native"; 3 | 4 | const useKeyboard = () => { 5 | const [keyboardHeight, setKeyboardHeight] = useState(0); 6 | 7 | useEffect(() => { 8 | function onKeyboardDidShow(e: KeyboardEvent) { 9 | // Remove type here if not using TypeScript 10 | setKeyboardHeight(e.endCoordinates.height); 11 | } 12 | 13 | function onKeyboardDidHide() { 14 | setKeyboardHeight(0); 15 | } 16 | 17 | const showSubscription = Keyboard.addListener( 18 | "keyboardDidShow", 19 | onKeyboardDidShow 20 | ); 21 | const hideSubscription = Keyboard.addListener( 22 | "keyboardDidHide", 23 | onKeyboardDidHide 24 | ); 25 | return () => { 26 | showSubscription.remove(); 27 | hideSubscription.remove(); 28 | }; 29 | }, []); 30 | 31 | return keyboardHeight; 32 | }; 33 | 34 | export default useKeyboard; 35 | -------------------------------------------------------------------------------- /utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { NativeModules, Platform } from "react-native"; 2 | 3 | const recentDateOptions = { 4 | month: "short", 5 | day: "numeric", 6 | hour: "2-digit", 7 | minute: "2-digit", 8 | } as const; 9 | const oldDateOptions = { 10 | month: "long", 11 | day: "2-digit", 12 | year: "numeric", 13 | } as const; 14 | 15 | const deviceLanguage = 16 | Platform.OS === "ios" 17 | ? NativeModules.SettingsManager.settings.AppleLocale || 18 | NativeModules.SettingsManager.settings.AppleLanguages[0] // iOS 13 is special 19 | : NativeModules.I18nManager.localeIdentifier; 20 | 21 | export const makeDateString = (timestamp: number | string) => { 22 | const dateObj = new Date(timestamp); 23 | const isSameYear = dateObj.getFullYear() === new Date().getFullYear(); 24 | 25 | return dateObj.toLocaleDateString( 26 | deviceLanguage.replace("_", "-"), 27 | isSameYear ? recentDateOptions : oldDateOptions 28 | ); 29 | }; 30 | 31 | export const shortenNumbers = (num: number) => { 32 | if (num < 1000) return num; 33 | if (num < 1000000) return `${(num / 1000).toFixed(1)}K`; 34 | return `${(num / 1000000).toFixed(1)}M`; 35 | }; 36 | 37 | export function debounce(func: any, delay: number) { 38 | let timeout: NodeJS.Timeout; 39 | return function (...args: any[]) { 40 | const context = this; 41 | clearTimeout(timeout); 42 | timeout = setTimeout(() => { 43 | func.apply(context, args); 44 | }, delay); 45 | }; 46 | } 47 | --------------------------------------------------------------------------------