├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
5 |
6 |
7 |
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 | [](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 |
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 |
--------------------------------------------------------------------------------