├── assets
├── icon.png
├── splash.png
├── favicon.png
├── hashtag.png
├── build-qr-code.png
├── screenshots
│ ├── feed.png
│ ├── login.png
│ ├── profile.png
│ ├── search.png
│ ├── comments.png
│ ├── discover.png
│ ├── settings.png
│ ├── conversation.png
│ ├── moderation.png
│ ├── notifications.png
│ └── direct-messages.png
├── logo
│ ├── logo-wordmark.png
│ ├── logo-standalone.png
│ ├── logo-splash.svg
│ ├── logo-icon.svg
│ ├── logo-standalone.svg
│ └── logo-wordmark.svg
├── icons
│ ├── boost-grey-64px.png
│ ├── feed-black-64px.png
│ ├── feed-grey-64px.png
│ ├── heart-grey-64px.png
│ ├── mail-black-64px.png
│ ├── mail-grey-64px.png
│ ├── bookmark-grey-64px.png
│ ├── boost-black-64px.png
│ ├── camera-black-64px.png
│ ├── camera-grey-64px.png
│ ├── close-black-64px.png
│ ├── create-black-64px.png
│ ├── hashtag-black-64px.png
│ ├── hashtag-grey-64px.png
│ ├── heart-black-64px.png
│ ├── person-black-64px.png
│ ├── person-grey-64px.png
│ ├── planet-black-64px.png
│ ├── planet-grey-64px.png
│ ├── search-black-64px.png
│ ├── search-grey-64px.png
│ ├── square-black-64px.png
│ ├── bookmark-black-64px.png
│ ├── checkbox-black-64px.png
│ ├── ellipsis-black-64px.png
│ ├── lock-open-black-64px.png
│ ├── lock-open-grey-64px.png
│ ├── lock-closed-black-64px.png
│ ├── lock-closed-grey-64px.png
│ ├── paper-plane-black-64px.png
│ ├── paper-plane-grey-64px.png
│ ├── close.svg
│ ├── bookmark.svg
│ ├── square.svg
│ ├── paper-plane.svg
│ ├── checkbox.svg
│ ├── search.svg
│ ├── mail.svg
│ ├── lock-open.svg
│ ├── heart.svg
│ ├── lock-closed.svg
│ ├── planet.svg
│ ├── ellipsis.svg
│ ├── feed.svg
│ ├── person.svg
│ ├── create.svg
│ ├── camera.svg
│ ├── boost.svg
│ └── hashtag.svg
└── images
│ └── checkmark-circle.png
├── .gitignore
├── src
├── interface
│ ├── interactions.js
│ └── rendering.js
├── components
│ ├── pages
│ │ ├── view-post.js
│ │ ├── feed
│ │ │ └── older-posts.js
│ │ ├── user-list.js
│ │ ├── discover
│ │ │ ├── view-hashtag.js
│ │ │ └── search.js
│ │ ├── feed.js
│ │ ├── discover.js
│ │ ├── publish.js
│ │ ├── direct.js
│ │ ├── authenticate.js
│ │ ├── profile
│ │ │ └── settings.js
│ │ ├── direct
│ │ │ └── conversation.js
│ │ ├── profile.js
│ │ └── view-comments.js
│ ├── posts
│ │ ├── timeline-view.js
│ │ ├── post-action-bar.js
│ │ ├── paged-grid.js
│ │ ├── grid-view.js
│ │ └── post.js
│ ├── context-menu.js
│ ├── moderate-menu.js
│ └── icons.js
├── App.js
└── requests.js
├── babel.config.js
├── app.json
├── package.json
├── CODE_OF_CONDUCT.md
└── README.md
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/splash.png
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/favicon.png
--------------------------------------------------------------------------------
/assets/hashtag.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/hashtag.png
--------------------------------------------------------------------------------
/assets/build-qr-code.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/build-qr-code.png
--------------------------------------------------------------------------------
/assets/screenshots/feed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/feed.png
--------------------------------------------------------------------------------
/assets/logo/logo-wordmark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/logo/logo-wordmark.png
--------------------------------------------------------------------------------
/assets/screenshots/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/login.png
--------------------------------------------------------------------------------
/assets/screenshots/profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/profile.png
--------------------------------------------------------------------------------
/assets/screenshots/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/search.png
--------------------------------------------------------------------------------
/assets/icons/boost-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/boost-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/feed-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/feed-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/feed-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/feed-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/heart-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/heart-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/mail-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/mail-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/mail-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/mail-grey-64px.png
--------------------------------------------------------------------------------
/assets/logo/logo-standalone.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/logo/logo-standalone.png
--------------------------------------------------------------------------------
/assets/screenshots/comments.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/comments.png
--------------------------------------------------------------------------------
/assets/screenshots/discover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/discover.png
--------------------------------------------------------------------------------
/assets/screenshots/settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/settings.png
--------------------------------------------------------------------------------
/assets/icons/bookmark-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/bookmark-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/boost-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/boost-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/camera-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/camera-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/camera-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/camera-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/close-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/close-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/create-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/create-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/hashtag-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/hashtag-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/hashtag-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/hashtag-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/heart-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/heart-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/person-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/person-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/person-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/person-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/planet-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/planet-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/planet-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/planet-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/search-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/search-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/search-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/search-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/square-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/square-black-64px.png
--------------------------------------------------------------------------------
/assets/images/checkmark-circle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/images/checkmark-circle.png
--------------------------------------------------------------------------------
/assets/screenshots/conversation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/conversation.png
--------------------------------------------------------------------------------
/assets/screenshots/moderation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/moderation.png
--------------------------------------------------------------------------------
/assets/icons/bookmark-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/bookmark-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/checkbox-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/checkbox-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/ellipsis-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/ellipsis-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/lock-open-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/lock-open-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/lock-open-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/lock-open-grey-64px.png
--------------------------------------------------------------------------------
/assets/screenshots/notifications.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/notifications.png
--------------------------------------------------------------------------------
/assets/icons/lock-closed-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/lock-closed-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/lock-closed-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/lock-closed-grey-64px.png
--------------------------------------------------------------------------------
/assets/icons/paper-plane-black-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/paper-plane-black-64px.png
--------------------------------------------------------------------------------
/assets/icons/paper-plane-grey-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/icons/paper-plane-grey-64px.png
--------------------------------------------------------------------------------
/assets/screenshots/direct-messages.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/natjms/resin/HEAD/assets/screenshots/direct-messages.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/**/*
2 | .expo/*
3 | .expo-shared/*
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 | # Vim
17 | *.sw[klmnop]
18 |
--------------------------------------------------------------------------------
/src/interface/interactions.js:
--------------------------------------------------------------------------------
1 | export function activeOrNot(condition, pack) {
2 | return condition ? pack.active : pack.inactive;
3 | }
4 |
5 | export function updateTabBuilder(nav) {
6 | return (tab, params) => nav.navigate(tab, params)
7 | }
--------------------------------------------------------------------------------
/assets/icons/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/bookmark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/square.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/paper-plane.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/checkbox.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/mail.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/lock-open.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/heart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/lock-closed.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/planet.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/ellipsis.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | plugins: [
6 | [
7 | require.resolve('babel-plugin-module-resolver'),
8 | {
9 | root: ["./"],
10 | alias: {
11 | "assets": "./assets",
12 | "src": "./src"
13 | }
14 | }
15 | ],
16 | "react-native-reanimated/plugin"
17 | ]
18 | };
19 | };
20 |
--------------------------------------------------------------------------------
/assets/icons/feed.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/person.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/create.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/pages/view-post.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { ScrollView } from "react-native";
3 |
4 | import { PostByData } from "src/components/posts/post";
5 |
6 | const ViewPost = ({ navigation, route }) => (
7 |
8 | navigation.goBack()
12 | }
13 | afterModerate = {
14 | () => navigation.goBack()
15 | }
16 | data = { route.params.post } />
17 |
18 | )
19 |
20 | export default ViewPost;
21 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "entryPoint": "./src/App.js",
4 | "name": "Resin",
5 | "slug": "Resin",
6 | "scheme": "resin",
7 | "version": "1.0.0",
8 | "orientation": "portrait",
9 | "icon": "./assets/icon.png",
10 | "splash": {
11 | "image": "./assets/splash.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#ffffff"
14 | },
15 | "updates": {
16 | "fallbackToCacheTimeout": 0
17 | },
18 | "assetBundlePatterns": [
19 | "**/*"
20 | ],
21 | "ios": {
22 | "supportsTablet": true
23 | },
24 | "web": {
25 | "favicon": "./assets/favicon.png"
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/assets/icons/camera.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/boost.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/hashtag.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/posts/timeline-view.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { View } from "react-native";
3 |
4 | import { PostByData } from "src/components/posts/post";
5 |
6 | const TimelineView = (props) => {
7 | // Count the number of posts that have already loaded
8 | const [postsLoaded, setPostsLoaded] = useState(0);
9 |
10 | // Ensure only posts with media get in the timeline
11 | const posts = props.posts.filter(
12 | p => p.media_attachments != null
13 | && p.media_attachments.length > 0
14 | );
15 |
16 | useEffect(() => {
17 | // When all the posts have been loaded, call onTimelineLoaded
18 | // if it's been defined
19 | if (postsLoaded == posts.length) {
20 | if (props.onTimelineLoaded != null) {
21 | props.onTimelineLoaded();
22 | }
23 | }
24 | }, [postsLoaded]);
25 |
26 | const _handlePostLoaded = () => {
27 | setPostsLoaded(postsLoaded + 1);
28 | }
29 |
30 | return (
31 |
32 | { props.posts.map((post, i) => {
33 | return (
34 |
35 |
39 |
40 | );
41 | }) }
42 |
43 | );
44 | };
45 |
46 | export default TimelineView;
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "node_modules/expo/AppEntry.js",
3 | "scripts": {
4 | "start": "expo start",
5 | "android": "expo start --android",
6 | "ios": "expo start --ios",
7 | "web": "expo start --web",
8 | "eject": "expo eject"
9 | },
10 | "dependencies": {
11 | "@expo/ngrok": "^4.1.0",
12 | "@react-native-async-storage/async-storage": "~1.17.3",
13 | "@react-native-masked-view/masked-view": "0.2.6",
14 | "@react-navigation/bottom-tabs": "^6.3.1",
15 | "@react-navigation/core": "^6.2.1",
16 | "@react-navigation/native": "^6.0.10",
17 | "@react-navigation/stack": "^6.2.1",
18 | "expo": "^45.0.0",
19 | "expo-image-picker": "~13.1.1",
20 | "expo-linking": "~3.1.0",
21 | "expo-status-bar": "~1.3.0",
22 | "expo-web-browser": "~10.2.0",
23 | "mime": "^2.5.2",
24 | "react": "^17.0.2",
25 | "react-dom": "17.0.2",
26 | "react-native": "0.68.2",
27 | "react-native-gesture-handler": "~2.2.1",
28 | "react-native-pager-view": "5.4.15",
29 | "react-native-popup-menu": "^0.15.10",
30 | "react-native-reanimated": "~2.8.0",
31 | "react-native-render-html": "^5.1.1",
32 | "react-native-safe-area-context": "4.2.4",
33 | "react-native-screens": "~3.11.1",
34 | "react-native-tab-view": "^2.16.0",
35 | "react-native-web": "0.17.7",
36 | "react-navigation-stack": "^2.8.2"
37 | },
38 | "devDependencies": {
39 | "@babel/core": "^7.12.9",
40 | "babel-plugin-module-resolver": "^4.0.0",
41 | "babel-preset-expo": "~9.1.0"
42 | },
43 | "private": true
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/posts/post-action-bar.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import {
3 | Image,
4 | Text,
5 | View,
6 | Dimensions,
7 | TouchableOpacity
8 | } from "react-native";
9 |
10 | const PostAction = (props) => {
11 | return (
12 |
14 |
15 |
19 |
20 |
21 | )
22 | }
23 |
24 | const PostActionBar = (props) => {
25 | return (
26 |
27 |
31 |
32 |
36 |
37 |
41 |
42 | )
43 | }
44 |
45 | const SCREEN_WIDTH = Dimensions.get("window").width;
46 | const styles = {
47 | flexContainer: {
48 | flexDirection: "row",
49 | padding: SCREEN_WIDTH / 40
50 | },
51 | }
52 |
53 | export default PostActionBar;
54 |
--------------------------------------------------------------------------------
/src/components/context-menu.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Dimensions, View, Image } from "react-native";
3 | import {
4 | Menu,
5 | MenuOptions,
6 | MenuOption,
7 | MenuTrigger,
8 | renderers
9 | } from "react-native-popup-menu";
10 |
11 | import Icon from "src/components/icons.js";
12 |
13 | const { SlideInMenu } = renderers;
14 |
15 | const SCREEN_WIDTH = Dimensions.get("window").width;
16 |
17 | const ContextMenu = (props) => {
18 | const optionsStyles = {
19 | optionWrapper: { // The wrapper around a single option
20 | paddingLeft: SCREEN_WIDTH / 15,
21 | paddingTop: SCREEN_WIDTH / 30,
22 | paddingBottom: SCREEN_WIDTH / 30
23 | },
24 | optionsWrapper: { // The wrapper around all options
25 | marginTop: SCREEN_WIDTH / 20,
26 | marginBottom: SCREEN_WIDTH / 20,
27 | },
28 | optionsContainer: { // The Animated.View
29 | borderTopLeftRadius: 10,
30 | borderTopRightRadius: 10
31 | }
32 | };
33 |
34 | return (
35 |
36 |
45 |
46 | );
47 | }
48 |
49 | export { ContextMenu as default };
50 |
--------------------------------------------------------------------------------
/src/components/moderate-menu.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Dimensions, View, Image } from "react-native";
3 | import {
4 | Menu,
5 | MenuOptions,
6 | MenuOption,
7 | MenuTrigger,
8 | renderers
9 | } from "react-native-popup-menu";
10 |
11 | const { SlideInMenu } = renderers;
12 | import Icon from "src/components/icons.js";
13 |
14 | const SCREEN_WIDTH = Dimensions.get("window").width;
15 |
16 | const ModerateMenu = (props) => {
17 | const optionsStyles = {
18 | optionWrapper: { // The wrapper around a single option
19 | paddingLeft: SCREEN_WIDTH / 15,
20 | paddingTop: SCREEN_WIDTH / 30,
21 | paddingBottom: SCREEN_WIDTH / 30
22 | },
23 | optionsWrapper: { // The wrapper around all options
24 | marginTop: SCREEN_WIDTH / 20,
25 | marginBottom: SCREEN_WIDTH / 20,
26 | },
27 | optionsContainer: { // The Animated.View
28 | borderTopLeftRadius: 10,
29 | borderTopRightRadius: 10
30 | }
31 | };
32 |
33 | return (
34 |
35 |
46 |
47 | );
48 | }
49 |
50 | export { ModerateMenu as default };
51 |
--------------------------------------------------------------------------------
/src/components/posts/paged-grid.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { View, TouchableOpacity, Text } from "react-native";
3 |
4 | import GridView from "src/components/posts/grid-view";
5 |
6 | const TEST_IMAGE = "https://cache.desktopnexus.com/thumbseg/2255/2255124-bigthumbnail.jpg";
7 | const TEST_POSTS = [
8 | {
9 | id: 1,
10 | media_attachments: [
11 | {preview_url: TEST_IMAGE}
12 | ]
13 | },
14 | {
15 | id: 2,
16 | media_attachments: [
17 | {preview_url: TEST_IMAGE}
18 | ]
19 | },
20 | {
21 | id: 3,
22 | media_attachments: [
23 | {preview_url: TEST_IMAGE}
24 | ]
25 | },
26 | {
27 | id: 4,
28 | media_attachments: [
29 | {preview_url: TEST_IMAGE}
30 | ]
31 | }
32 | ];
33 |
34 | const PagedGridJSX = (props) => {
35 | return (
36 |
37 | {
42 | props.navigation.navigate("ViewPost", {
43 | id: id,
44 | originTab: props.navigation.getParam("originTab")
45 | });
46 | }
47 | } />
48 |
49 |
51 |
52 | Show more?
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
60 | const styles = {
61 | buttonContainer: {
62 | justifyContent: "center",
63 | alignItems: "center"
64 | },
65 | buttonMore: {
66 | borderWidth: 1,
67 | borderColor: "#888",
68 | borderRadius: 5,
69 | padding: 10,
70 | margin: 20
71 | }
72 | }
73 |
74 | export default PagedGridJSX;
75 |
--------------------------------------------------------------------------------
/src/components/pages/feed/older-posts.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { ScrollView } from "react-native";
3 | import AsyncStorage from "@react-native-async-storage/async-storage";
4 |
5 | import * as requests from "src/requests";
6 | import PagedGrid from "src/components/posts/paged-grid";
7 |
8 | // The number of posts to fetch at a time
9 | const POST_FETCH_LIMIT = 18;
10 |
11 | const OlderPosts = (props) => {
12 | const [ state, setState ] = useState({
13 | loaded: false,
14 | });
15 |
16 | useEffect(() => {
17 | let instance, accessToken, latestId;
18 | AsyncStorage
19 | .multiGet([
20 | "@user_instance",
21 | "@user_token",
22 | "@user_latestPostId"
23 | ])
24 | .then(([instancePair, tokenPair, latestPair]) => {
25 | instance = instancePair[1];
26 | accessToken = JSON.parse(tokenPair[1]).access_token;
27 | latestId = JSON.parse(latestPair[1]);
28 |
29 | return requests.fetchHomeTimeline(instance, accessToken, {
30 | max_id: latestId,
31 | limit: POST_FETCH_LIMIT,
32 | })
33 | })
34 | .then(posts => {
35 | setState({...state,
36 | posts: posts,
37 | instance: instance,
38 | accessToken: accessToken,
39 | loaded: true,
40 | });
41 | })
42 | }, []);
43 |
44 | const _handleShowMore = async () => {
45 | const newPosts = await requests.fetchHomeTimeline(
46 | state.instance,
47 | state.accessToken,
48 | {
49 | max_id: state.posts[state.posts.length - 1].id,
50 | limit: POST_FETCH_LIMIT,
51 | }
52 | );
53 |
54 | setState({...state,
55 | posts: state.posts.concat(newPosts),
56 | });
57 | };
58 |
59 | return (
60 |
61 | { state.loaded
62 | ? <>
63 |
67 | >
68 | : <>>
69 | }
70 |
71 | );
72 | };
73 |
74 | export default OlderPosts;
75 |
--------------------------------------------------------------------------------
/assets/logo/logo-splash.svg:
--------------------------------------------------------------------------------
1 |
2 |
71 |
--------------------------------------------------------------------------------
/src/components/posts/grid-view.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | View,
4 | Dimensions,
5 | Image,
6 | TouchableOpacity,
7 | } from "react-native";
8 |
9 | function partition(arr, size) {
10 | let newArray = [];
11 | for (let i = 0; i < arr.length; i += size) {
12 | const part = arr.slice(i, i + 3);
13 | newArray.push(part);
14 | }
15 |
16 | return newArray
17 | }
18 |
19 | const GridPost = (props) => {
20 | return (
21 | {
23 | props.navigation.navigate("ViewPost", {
24 | post: props.data,
25 | });
26 | }}>
27 |
32 |
33 | )
34 | }
35 |
36 | const GridView = (props) => {
37 | // Ensure only posts with media get into the grid
38 | const postsWithMedia = props.posts.filter(
39 | p => p.media_attachments != null
40 | && p.media_attachments.length > 0
41 | );
42 |
43 | let rows = partition(postsWithMedia, 3);
44 | return (
45 |
46 | {
47 | rows.map((row, i) => {
48 | return (
49 |
51 | {
52 | row.map((post) => {
53 | return (
54 |
55 |
58 |
59 | );
60 | })
61 | }
62 |
63 | )
64 | })
65 | }
66 |
67 | );
68 | };
69 |
70 | const screen_width = Dimensions.get("window").width
71 | const styles = {
72 | gridRow: {
73 | padding: 0,
74 | margin: 0,
75 | flexDirection: "row"
76 | },
77 | gridImage: {
78 | width: screen_width / 3,
79 | height: screen_width / 3
80 | },
81 | };
82 |
83 | export default GridView;
84 |
--------------------------------------------------------------------------------
/assets/logo/logo-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
75 |
--------------------------------------------------------------------------------
/assets/logo/logo-standalone.svg:
--------------------------------------------------------------------------------
1 |
2 |
72 |
--------------------------------------------------------------------------------
/src/interface/rendering.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View, StatusBar } from "react-native";
3 |
4 | export function StatusBarSpace(props) {
5 | return ;
11 | };
12 |
13 | export function withoutHTML(string) {
14 | return string.replace(/<[^>]*>/ig, "");
15 | }
16 |
17 | export function withLeadingAcct(acct, html) {
18 | // Insert a bolded acct at the beginning of an HTML string so that it can
19 | // be rendered the way Instagram renders post captions
20 | return `${acct} ` + html;
21 | }
22 |
23 | export function pluralize(n, singular, plural) {
24 | if (n == 1) {
25 | return singular;
26 | } else {
27 | return plural;
28 | }
29 | }
30 |
31 | export function getAutoHeight(w1, h1, w2) {
32 | /*
33 | Given the original dimensions and the new width, calculate what would
34 | otherwise be the "auto" height of the image.
35 |
36 | Just so that nobody has to ever work out this algebra again:
37 |
38 | Let {w1, h1} = the width and height of the static image,
39 | w2 = the new width,
40 | h2 = the "auto" height of the scaled image of width w2:
41 |
42 | w1/h1 = w2/h2
43 | h2 * w1/h1 = w2
44 | h2 = w2 / w1/h1
45 | h2 = w2 * h1/w1
46 | */
47 | return w2 * (h1 / w1)
48 | }
49 |
50 | export function timeToAge(time1, time2) {
51 | /*
52 | Output a friendly string to describe the age of a post, where `time1` and
53 | `time2` are in milliseconds
54 | */
55 |
56 | const between = (n, lower, upper) => n >= lower && n < upper;
57 |
58 | const diff = time1 - time2;
59 |
60 | if (diff < 60000) {
61 | return "Seconds ago"
62 | } else if (between(diff, 60000, 3600000)) {
63 | const nMin = Math.floor(diff / 60000);
64 | return nMin + " " + pluralize(nMin, "minute", "minutes") + " ago";
65 | } else if (between(diff, 3600000, 86400000)) {
66 | const nHours = Math.floor(diff / 3600000);
67 | return nHours + " " + pluralize(nHours, "hour", "hours") + " ago";
68 | } else if (between(diff, 86400000, 2629800000)) {
69 | const nDays = Math.floor(diff / 86400000);
70 | return nDays + " " + pluralize(nDays, "day", "days") + " ago";
71 | } else if (between(diff, 2629800000, 31557600000)) {
72 | const nMonths = Math.floor(diff / 2629800000);
73 | return nMonths + " " + pluralize(nMonths, "month", "months") + " ago";
74 | } else {
75 | const nYears = Math.floor(diff / 31557600000);
76 | return nYears + " " + pluralize(nYears, "year", "years") + " ago";
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/assets/logo/logo-wordmark.svg:
--------------------------------------------------------------------------------
1 |
2 |
87 |
--------------------------------------------------------------------------------
/src/components/pages/user-list.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | ScrollView,
4 | SafeAreaView,
5 | View,
6 | Image,
7 | Text,
8 | Dimensions,
9 | TouchableOpacity,
10 | } from "react-native";
11 |
12 | import ModerateMenu from "src/components/moderate-menu.js";
13 |
14 | const UserList = ({ navigation, route}) => {
15 | const data = route.params.data;
16 | const context = route.params.context;
17 |
18 | return (
19 |
20 | {
21 | context ?
22 |
23 | { context }:
24 |
25 | : <>>
26 | }
27 | {
28 | data.map(item =>
29 |
32 | {
36 | navigation.push("ViewProfile", { profile: item });
37 | }
38 | }>
39 |
40 |
43 |
44 |
45 | @{ item.acct }
46 |
47 |
48 | { item.display_name }
49 |
50 |
51 |
52 |
53 |
56 |
57 | )
58 | }
59 |
60 | );
61 | };
62 |
63 | const SCREEN_WIDTH = Dimensions.get("window").width;
64 |
65 | const styles = {
66 | context: {
67 | fontSize: 18,
68 | //color: "#888",
69 | padding: 10,
70 | },
71 | flexContainer: {
72 | flex: 1,
73 | flexDirection: "row",
74 | alignItems: "center",
75 | },
76 | itemContainer: { padding: 10 },
77 | accountButton: {
78 | flexGrow: 1,
79 | },
80 | bottomBorder: {
81 | borderBottomWidth: 1,
82 | borderBottomColor: "#888",
83 | },
84 | avatar: {
85 | width: SCREEN_WIDTH / 8,
86 | height: SCREEN_WIDTH / 8,
87 | borderRadius: SCREEN_WIDTH / 16,
88 | marginRight: 10,
89 | },
90 | acct: {
91 | fontWeight: "bold",
92 | },
93 | moderateMenu: {
94 | marginLeft: "auto",
95 | marginRight: 10,
96 | },
97 | ellipsis: {
98 | width: 20,
99 | height: 20,
100 | },
101 | };
102 |
103 | export { UserList as default };
104 |
--------------------------------------------------------------------------------
/src/components/icons.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Image, StyleSheet } from "react-native";
3 |
4 | /* React doesn't allow you to `require` images dynamically because they need
5 | * to be known ahead of time. As such, we require all the icons we'll need in
6 | * this map. If a new icon is added, then it must be added to this array
7 | */
8 | const images = {
9 | ellipsis: {
10 | black: require("assets/icons/ellipsis-black-64px.png"),
11 | },
12 | feed: {
13 | black: require("assets/icons/feed-black-64px.png"),
14 | grey: require("assets/icons/feed-grey-64px.png"),
15 | },
16 | search: {
17 | black: require("assets/icons/search-black-64px.png"),
18 | grey: require("assets/icons/search-grey-64px.png"),
19 | },
20 | camera: {
21 | black: require("assets/icons/camera-black-64px.png"),
22 | grey: require("assets/icons/camera-grey-64px.png"),
23 | },
24 | mail: {
25 | black: require("assets/icons/mail-black-64px.png"),
26 | grey: require("assets/icons/mail-grey-64px.png"),
27 | },
28 | person: {
29 | black: require("assets/icons/person-black-64px.png"),
30 | grey: require("assets/icons/person-grey-64px.png"),
31 | },
32 | hashtag: {
33 | black: require("assets/icons/hashtag-black-64px.png"),
34 | grey: require("assets/icons/hashtag-grey-64px.png"),
35 | },
36 | planet: {
37 | black: require("assets/icons/planet-black-64px.png"),
38 | grey: require("assets/icons/planet-grey-64px.png"),
39 | },
40 | square: {
41 | black: require("assets/icons/square-black-64px.png"),
42 | },
43 | checkbox: {
44 | black: require("assets/icons/checkbox-black-64px.png"),
45 | },
46 | "lock-closed": {
47 | black: require("assets/icons/lock-closed-black-64px.png"),
48 | grey: require("assets/icons/lock-closed-grey-64px.png"),
49 | },
50 | "lock-open": {
51 | black: require("assets/icons/lock-open-black-64px.png"),
52 | grey: require("assets/icons/lock-open-grey-64px.png"),
53 | },
54 | heart: {
55 | black: require("assets/icons/heart-black-64px.png"),
56 | grey: require("assets/icons/heart-grey-64px.png"),
57 | },
58 | bookmark: {
59 | black: require("assets/icons/bookmark-black-64px.png"),
60 | grey: require("assets/icons/bookmark-grey-64px.png"),
61 | },
62 | boost: {
63 | black: require("assets/icons/boost-black-64px.png"),
64 | grey: require("assets/icons/boost-grey-64px.png"),
65 | },
66 | create: {
67 | black: require("assets/icons/create-black-64px.png"),
68 | },
69 | close: {
70 | black: require("assets/icons/close-black-64px.png"),
71 | },
72 | };
73 |
74 | const Icon = ({name, size, focused = true}) => {
75 | if (images[name] === undefined) {
76 | console.error(`Icon "${name}" is not recognized`);
77 | return <>>;
78 | }
79 |
80 | // Warn the programmer if their chosen icon colour hasn't been rendered
81 | if (focused && images[name].black === undefined) {
82 | console.error(`There exists no focused version of icon "${name}"`);
83 | return <>>;
84 | }
85 |
86 | if (!focused && images[name].grey === undefined) {
87 | console.error(`There exists no unfocused version of icon "${name}"`);
88 | return <>>;
89 | }
90 |
91 | const styles = StyleSheet.create({
92 | image: {
93 | width: size,
94 | height: size,
95 | },
96 | });
97 |
98 | return ;
101 | };
102 |
103 | export default Icon;
104 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributor Covenant Code of Conduct
3 |
4 | ## Our Pledge
5 |
6 | In the interest of fostering an open and welcoming environment, we as
7 | contributors and maintainers pledge to make participation in our project and
8 | our community a harassment-free experience for everyone, regardless of age, body
9 | size, disability, ethnicity, sex characteristics, gender identity and expression,
10 | level of experience, education, socio-economic status, nationality, personal
11 | appearance, race, religion, or sexual identity and orientation.
12 |
13 | ## Our Standards
14 |
15 | Examples of behavior that contributes to creating a positive environment
16 | include:
17 |
18 | * Using welcoming and inclusive language
19 | * Being respectful of differing viewpoints and experiences
20 | * Gracefully accepting constructive criticism
21 | * Focusing on what is best for the community
22 | * Showing empathy towards other community members
23 |
24 | Examples of unacceptable behavior by participants include:
25 |
26 | * The use of sexualized language or imagery and unwelcome sexual attention or
27 | advances
28 | * Trolling, insulting/derogatory comments, and personal or political attacks
29 | * Public or private harassment
30 | * Publishing others' private information, such as a physical or electronic
31 | address, without explicit permission
32 | * Other conduct which could reasonably be considered inappropriate in a
33 | professional setting
34 |
35 | ## Our Responsibilities
36 |
37 | Project maintainers are responsible for clarifying the standards of acceptable
38 | behavior and are expected to take appropriate and fair corrective action in
39 | response to any instances of unacceptable behavior.
40 |
41 | Project maintainers have the right and responsibility to remove, edit, or
42 | reject comments, commits, code, wiki edits, issues, and other contributions
43 | that are not aligned to this Code of Conduct, or to ban temporarily or
44 | permanently any contributor for other behaviors that they deem inappropriate,
45 | threatening, offensive, or harmful.
46 |
47 | ## Scope
48 |
49 | This Code of Conduct applies within all project spaces, and it also applies when
50 | an individual is representing the project or its community in public spaces.
51 | Examples of representing a project or community include using an official
52 | project e-mail address, posting via an official social media account, or acting
53 | as an appointed representative at an online or offline event. Representation of
54 | a project may be further defined and clarified by project maintainers.
55 |
56 | ## Enforcement
57 |
58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
59 | reported by contacting the project team at
60 | "[the github account of this repo's owner]@njms.ca". All complaints will be
61 | reviewed and investigated and will result in a response that is deemed
62 | necessary and appropriate to the circumstances. The project team is obligated to
63 | maintain confidentiality with regard to the reporter of an incident. Further
64 | details of specific enforcement policies may be posted separately.
65 |
66 | Project maintainers who do not follow or enforce the Code of Conduct in good
67 | faith may face temporary or permanent repercussions as determined by other
68 | members of the project's leadership.
69 |
70 | ## Attribution
71 |
72 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
73 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
74 |
75 | [homepage]: https://www.contributor-covenant.org
76 |
77 | For answers to common questions about this code of conduct, see
78 | https://www.contributor-covenant.org/faq
79 |
80 |
--------------------------------------------------------------------------------
/src/components/pages/discover/view-hashtag.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { ScrollView, View, Image, Dimensions, Text } from "react-native";
3 | import PagedGrid from "src/components/posts/paged-grid";
4 |
5 | import * as requests from "src/requests";
6 | import AsyncStorage from "@react-native-async-storage/async-storage";
7 |
8 | const ViewHashtag = ({ navigation, route }) => {
9 | const FETCH_LIMIT = 18;
10 | let [state, setState] = useState({
11 | tag: route.params.tag,
12 | posts: [],
13 | offset: 0,
14 | followed: false,
15 | loaded: false,
16 | });
17 |
18 | useEffect(() => {
19 | let instance, accessToken;
20 | AsyncStorage
21 | .multiGet([
22 | "@user_instance",
23 | "@user_token",
24 | ])
25 | .then(([instancePair, tokenPair]) => {
26 | instance = instancePair[1];
27 | accessToken = JSON.parse(tokenPair[1]).access_token;
28 |
29 | return requests.fetchHashtagTimeline(
30 | instance,
31 | state.tag.name,
32 | accessToken,
33 | {
34 | only_media: true,
35 | limit: FETCH_LIMIT,
36 | }
37 | );
38 | })
39 | .then(posts => {
40 | setState({...state,
41 | posts,
42 | offset: state.offset + FETCH_LIMIT,
43 | instance,
44 | accessToken,
45 | loaded: true,
46 | });
47 | });
48 | }, []);
49 |
50 |
51 | const _handleShowMore = async () => {
52 | const newPosts = await requests.fetchHashtagTimeline(
53 | state.instance,
54 | state.tag.name,
55 | state.accessToken,
56 | {
57 | only_media: true,
58 | limit: FETCH_LIMIT,
59 | max_id: state.offset,
60 | }
61 | );
62 |
63 | setState({...state,
64 | posts: state.posts.concat(newPosts),
65 | offset: state.offset + FETCH_LIMIT,
66 | });
67 | };
68 |
69 | // A hashtag's history describes how actively it's being used. There's
70 | // one element in the history array for every set interval of time.
71 | // state.tag.history may be undefined, and its length might be 0.
72 | let latest = null;
73 | if (state.tag.history && state.tag.history.length > 0) {
74 | latest = state.tag.history[0];
75 | }
76 |
77 | return (
78 |
79 |
80 |
81 |
82 | 0
86 | ? {
87 | uri: state
88 | .posts[0]
89 | .media_attachments[0]
90 | .preview_url
91 | }
92 | : require("assets/hashtag.png")
93 | }/>
94 |
95 |
96 |
97 | #{ state.tag.name }
98 |
99 | <>
100 | { latest
101 | ?
102 | { latest.uses }
103 | posts from
104 | { latest.accounts }
105 | people today
106 |
107 | :<>>
108 | }
109 | >
110 |
111 |
112 | <>
113 | { state.loaded && state.posts.length > 0
114 | ? state.posts.length > 0
115 | ?
119 | :
120 | Nothing to show
121 |
122 | : <>>
123 | }
124 | >
125 |
126 |
127 | );
128 | };
129 |
130 | const screen_width = Dimensions.get("window").width;
131 | const styles = {
132 | headerContainer: {
133 | flexDirection: "row",
134 | alignItems: "center",
135 | padding: 15,
136 | },
137 | image: {
138 | width: screen_width / 3,
139 | height: screen_width / 3,
140 | borderWidth: 1,
141 | borderColor: "#888",
142 | borderRadius: screen_width / 6,
143 | marginRight: 20,
144 | },
145 | hashtag: {
146 | fontWeight: "bold",
147 | fontSize: 20
148 | },
149 | nothing: {
150 | color: "#666",
151 | textAlign: "center",
152 | paddingTop: 20,
153 | },
154 | strong: {
155 | fontWeight: "bold",
156 | },
157 | }
158 |
159 | export default ViewHashtag;
160 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import 'react-native-gesture-handler';
2 | import React from "react";
3 |
4 | import { LogBox } from "react-native";
5 |
6 | import { createStackNavigator } from "@react-navigation/stack";
7 | import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
8 | import { NavigationContainer } from "@react-navigation/native";
9 | import { MenuProvider } from "react-native-popup-menu";
10 |
11 | import { registerRootComponent } from 'expo';
12 | import * as Linking from "expo-linking";
13 |
14 | import Icon from "src/components/icons.js";
15 | import ViewPost from "src/components/pages/view-post";
16 | import ViewComments from "src/components/pages/view-comments.js";
17 |
18 | import Authenticate from "src/components/pages/authenticate";
19 | import Feed from "src/components/pages/feed";
20 | import Publish from "src/components/pages/publish";
21 | import OlderPosts from "src/components/pages/feed/older-posts";
22 | import Profile, { ViewProfile } from "src/components/pages/profile";
23 | import Discover from 'src/components/pages/discover';
24 | import Search from 'src/components/pages/discover/search';
25 | import ViewHashtag from 'src/components/pages/discover/view-hashtag';
26 | import Direct from "src/components/pages/direct";
27 | import Conversation, { Compose } from "src/components/pages/direct/conversation";
28 | import UserList from "src/components/pages/user-list.js";
29 | import Settings from "src/components/pages/profile/settings.js";
30 |
31 | LogBox.ignoreLogs([
32 | "[react-native-gesture-handler] Seems like you\'re using an old API with gesture components, check out new Gestures system!",
33 | ]);
34 |
35 | const prefix = Linking.makeUrl("/");
36 | const Tab = createBottomTabNavigator();
37 |
38 | const MainNavigator = () => {
39 | // Tabbed navigator for Feed, Discover, Publish, Direct and Profile
40 |
41 | const bottomTabIcon = name => {
42 | return ({ size, focused }) =>
43 |
47 | };
48 |
49 | const screenOptions = {
50 | all: {
51 | // Options that apply to every screen in the navigator
52 | headerShown: false,
53 | tabBarShowLabel: false,
54 | tabBarStyle: {
55 | height: 60,
56 | },
57 | },
58 | Feed: {
59 | tabBarAccessibilityLabel: "Feed",
60 | tabBarIcon: bottomTabIcon("feed"),
61 | },
62 | Discover: {
63 | tabBarAccessibilityLabel: "Discover",
64 | tabBarIcon: bottomTabIcon("search"),
65 | },
66 | Publish: {
67 | tabBarAccessibilityLabel: "Publish",
68 | tabBarIcon: bottomTabIcon("camera"),
69 | },
70 | Direct: {
71 | tabBarAccessibilityLabel: "Direct messages",
72 | tabBarIcon: bottomTabIcon("mail"),
73 | },
74 | Profile: {
75 | tabBarAccessibilityLabel: "Profile",
76 | tabBarIcon: bottomTabIcon("person"),
77 | },
78 | };
79 |
80 | return (
81 |
84 |
86 |
88 |
90 |
92 |
94 |
95 | );
96 | };
97 |
98 | const Stack = createStackNavigator();
99 |
100 | const App = (props) => {
101 | const providerStyles = {
102 | backdrop: {
103 | backgroundColor: "black",
104 | opacity: 0.5
105 | },
106 | };
107 |
108 | // This allows for the OAuth redirect
109 | const linking = {
110 | prefixes: [prefix],
111 | config: {
112 | screens: {
113 | Authenticate: "authenticate",
114 | },
115 | },
116 | };
117 |
118 | const screenOptions = {
119 | headerTitle: "",
120 | };
121 |
122 | return
123 |
124 |
127 |
131 |
135 |
136 |
137 |
138 |
139 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 | ;
149 | };
150 |
151 | export default registerRootComponent(App);
152 |
--------------------------------------------------------------------------------
/src/components/pages/feed.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { ScrollView, Dimensions, View, Image, Text } from "react-native";
3 |
4 | import TimelineView from "src/components/posts/timeline-view";
5 | import { TouchableWithoutFeedback } from "react-native-gesture-handler";
6 |
7 | import AsyncStorage from "@react-native-async-storage/async-storage";
8 |
9 | import * as requests from "src/requests";
10 | import { StatusBarSpace } from "src/interface/rendering";
11 |
12 | const Feed = (props) => {
13 | const [state, setState] = useState({
14 | loaded: false,
15 | postsRendered: false,
16 | });
17 |
18 | useEffect(() => {
19 | let accessToken, instance, posts;
20 |
21 | AsyncStorage
22 | .multiGet([
23 | "@user_token",
24 | "@user_instance",
25 | "@user_latestPostId",
26 | ])
27 | .then(([tokenPair, instancePair, latestPair]) => {
28 | accessToken = JSON.parse(tokenPair[1]).access_token;
29 | instance = instancePair[1];
30 | // NOTE: `latest` is just a number, but the Pixelfed API will
31 | // not accept query params like ?min_id="123" so it must be
32 | // parsed
33 | const latest = JSON.parse(latestPair[1]);
34 | const params = { limit: 20 };
35 |
36 | if (latest) {
37 | // @user_latestPostId will be null the first time the feed
38 | // is opened, so there's no need to specify it here.
39 | params["min_id"] = latest;
40 | }
41 |
42 | return requests.fetchHomeTimeline(
43 | instance,
44 | accessToken,
45 | params
46 | )
47 | })
48 | .then(retrievedPosts => {
49 | posts = retrievedPosts;
50 | if(posts.length > 0) {
51 | const latestId = posts[0].id;
52 | return AsyncStorage.setItem(
53 | "@user_latestPostId",
54 | JSON.stringify(latestId)
55 | );
56 | }
57 | })
58 | .then(() =>
59 | setState({...state,
60 | posts,
61 | loaded: true,
62 | })
63 | );
64 | }, []);
65 |
66 | const _handleTimelineLoaded = () => {
67 | setState({...state,
68 | postsRendered: true,
69 | });
70 | };
71 |
72 | let endOfTimelineMessage = <>>;
73 | if (state.postsRendered) {
74 | // Only render the timeline interruption if all of the posts have been
75 | // rendered in the feed.
76 | endOfTimelineMessage = <>
77 |
82 |
83 |
85 |
86 |
87 | You're all caught up.
88 |
89 | Wow, it sure is a lovely day outside 🌳
90 |
91 | props.navigation.navigate("OlderPosts")
95 | }>
96 | See older posts
97 |
98 |
99 |
100 | >;
101 | }
102 |
103 | return (
104 | <>
105 | { state.loaded
106 | ?
107 |
111 | { endOfTimelineMessage }
112 |
113 | : <>>
114 | }
115 | >
116 | );
117 | };
118 |
119 | const screen_width = Dimensions.get("window").width;
120 | const screen_height = Dimensions.get("window").height;
121 | const styles = {
122 | interruption: {
123 | container: {
124 | /* HACK: The space between the top of the screen and the bottom
125 | * tabs bar is about `screen_height - 100. See issue #7359 on the
126 | * react-navigation github repository. There is supposed to be
127 | * a way make a ScrollView's height at least the available viewport
128 | * using flexGrow, which we need as a container to use
129 | * justifyContent, but that doesn't seem to work here for some
130 | * reason. It'll be slightly off-center but the user never should
131 | * be able to accidentally scroll on this page.
132 | */
133 | height: screen_height - 100,
134 | justifyContent: "center",
135 | },
136 | topBorder: {
137 | borderTopWidth: 1,
138 | borderTopColor: "#CCC",
139 | },
140 | inner: {
141 | marginTop: 10,
142 | marginBottom: 10,
143 |
144 | justifyContent: "center",
145 | alignItems: "center",
146 | },
147 | header: {
148 | fontSize: 21
149 | },
150 | button: {
151 | borderWidth: 1,
152 | borderColor: "#888",
153 | borderRadius: 5,
154 |
155 | margin: 30,
156 | padding: 5
157 | },
158 | },
159 | };
160 |
161 | export default Feed;
162 |
--------------------------------------------------------------------------------
/src/components/pages/discover.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import {
3 | ScrollView,
4 | TouchableOpacity,
5 | View,
6 | TextInput,
7 | Text,
8 | Dimensions
9 | } from "react-native";
10 |
11 | import { TabView, TabBar, SceneMap } from "react-native-tab-view";
12 |
13 | import AsyncStorage from "@react-native-async-storage/async-storage";
14 |
15 | import * as requests from "src/requests";
16 |
17 | import Icon from "src/components/icons.js";
18 | import PagedGrid from "src/components/posts/paged-grid";
19 | import { TouchableWithoutFeedback } from "react-native-gesture-handler";
20 |
21 |
22 | const Discover = (props) => {
23 | const POST_FETCH_PARAMS = {
24 | only_media: true,
25 | limit: 18,
26 | };
27 |
28 | const [ state, setState ] = useState({
29 | loaded: false,
30 | });
31 | const [ index, setIndex ] = useState(0);
32 | const [ routes ] = useState([
33 | {
34 | key: "local",
35 | icon: "feed",
36 | },
37 | {
38 | key: "federated",
39 | icon: "planet",
40 | },
41 | ]);
42 |
43 | useEffect(() => {
44 | let instance, accessToken;
45 | AsyncStorage.
46 | multiGet([
47 | "@user_instance",
48 | "@user_token",
49 | ])
50 | .then(([instancePair, tokenPair]) => {
51 | instance = instancePair[1];
52 | accessToken = JSON.parse(tokenPair[1]).access_token;
53 |
54 | return Promise.all([
55 | requests.fetchPublicTimeline(
56 | instance,
57 | accessToken,
58 | { ...POST_FETCH_PARAMS, local: true, }
59 | ),
60 | requests.fetchPublicTimeline(
61 | instance,
62 | accessToken,
63 | { ...POST_FETCH_PARAMS, remote: true, }
64 | )
65 | ]);
66 | })
67 | .then(([localPosts, federatedPosts]) => {
68 | setState({...state,
69 | localPosts,
70 | federatedPosts,
71 | instance,
72 | accessToken,
73 | loaded: true,
74 | });
75 | })
76 | }, []);
77 |
78 | const _handleLocalTabUpdate = async () => {
79 | const newPosts = await requests.fetchPublicTimeline(
80 | state.instance,
81 | state.accessToken,
82 | {
83 | ...POST_FETCH_PARAMS,
84 | local: true,
85 | max_id: state.localPosts[state.localPosts.length - 1].id
86 | }
87 | );
88 |
89 | setState({...state,
90 | localPosts: state.localPosts.concat(newPosts),
91 | });
92 | };
93 |
94 | const _handleFederatedTabUpdate = async () => {
95 | const lastId = state.federatedPosts[state.federatedPosts.length - 1].id
96 | const newPosts = await requests.fetchPublicTimeline(
97 | state.instance,
98 | state.accessToken,
99 | {
100 | ...POST_FETCH_PARAMS,
101 | remote: true,
102 | max_id: lastId,
103 | }
104 | );
105 |
106 | setState({...state,
107 | federatedPosts: state.federatedPosts.concat(newPosts),
108 | });
109 | };
110 |
111 | const LocalTimeline = () => (
112 |
117 | );
118 |
119 | const FederatedTimeline = () => (
120 |
125 | );
126 |
127 | const renderScene = SceneMap({
128 | local: LocalTimeline,
129 | federated: FederatedTimeline,
130 | });
131 |
132 | const renderTabBar = (props) => (
133 |
140 | );
141 |
142 | const renderIcon = ({ route, focused }) => (
143 |
147 | );
148 |
149 | return (
150 | <>
151 | { state.loaded
152 | ?
153 |
154 | props.navigation.navigate("Search")
159 | }/>
160 |
162 |
163 |
164 |
165 |
171 |
172 | : <>>
173 | }
174 | >
175 | );
176 | };
177 |
178 | const SCREEN_WIDTH = Dimensions.get("window").width;
179 | const styles = {
180 | form: {
181 | container: {
182 | flexDirection: "row",
183 | justifyContent: "center",
184 | backgroundColor: "white",
185 | padding: 20,
186 | },
187 |
188 | input: {
189 | flexGrow: 1,
190 | padding: 10,
191 | fontSize: 17,
192 | color: "#888"
193 | },
194 |
195 | submit: {
196 | padding: 20,
197 | }
198 | },
199 |
200 | searchBar: {
201 | padding: 10,
202 | fontSize: 17,
203 | color: "#888",
204 | borderBottomWidth: 1,
205 | borderBottomColor: "#CCC",
206 | },
207 |
208 | tabBar: {
209 | indicator: { backgroundColor: "black" },
210 | tab: { backgroundColor: "white" },
211 | },
212 | };
213 |
214 | export default Discover;
215 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
5 |
6 | Resin is the Pixelfed client you won't get addicted to. The primary goal of this
7 | project is to create a social-media interface that has no dark patterns and
8 | otherwise goes out of its way to prevent the user from getting addicted to it.
9 | In other words, this app practices
10 | [ethical anti-design](https://njms.ca/posts/ethical-anti-design.html). The way
11 | it does this is by excluding dark patterns like infinite scrolling and
12 | putting obstacles in place to minimize the amount of time the person spends
13 | taking unhealthy actions that are otherwise unavoidable.
14 |
15 | ## Goal
16 |
17 | The goal of this project is to create a model for what social media could look
18 | like were it not designed to be addictive. The Fediverse, not being dependent
19 | on ad revenue, shouldn't need to capitalize on people's attention. Still, many
20 | of the apps we use to interact with the Fediverse use the same dark patterns
21 | developed by companies that do. While these dark patterns may seem like industry
22 | standards, we have no need to follow them. This project seeks to demonstrate
23 | the different ways to go about doing that.
24 |
25 | ## Current Objectives
26 | * ~~Beta release~~
27 | * Full 1.0 release
28 | * [Sabbaticals](https://github.com/natjms/resin/issues/19)
29 | * Support for Mastodon instances
30 | * Support for multiple accounts
31 |
32 | ## Screenshots
33 |
37 |
41 |
45 |
49 |
53 |
57 |
58 |
62 |
66 |
70 |
74 |
78 |
79 | ## Building
80 |
81 | This project is written in React Native and built using Expo. Here are the
82 | steps to build and run it locally:
83 |
84 | ```
85 | $ npm install -g expo-cli # You'll need this to work with Expo projects
86 | $ git clone https://github.com/natjms/resin # Clone the repository
87 | $ npm install # Install the dependencies
88 | $ expo start # start the development server
89 | ```
90 |
91 | ## Contributing
92 |
93 | ### Bug testing
94 | As Resin enters it's beta phase, we're looking for help from bug testers!
95 | Android folks can test it out without running a development server by
96 | downloading the
97 | [Expo Go](https://play.google.com/store/apps/details?id=host.exp.exponent)
98 | app and scanning the following QR code on your phone:
99 |
100 |
104 |
105 | When you run into a bug, hop on over to the [issues page](https://github.com/natjms/resin/issues) and create a new issue. Make sure you fill out as much of the template as possible to help us determine how to best approach fixing the problem. If you're not comfortable with GitHub, you're welcome to [contact the project maintainer](https://social.njms.ca/nat), however bug tracking through GitHub Issues is greatly preferred and there's no guarantee we'll see your message if you try to send it through another channel.
106 |
107 | #### A note on current limitations
108 | Note that there are a number of issues with this app related to Pixelfed API endpoints that haven't been exposed yet. These are problems that cannot be fixed on our end, but are currently being worked on by the Pixelfed team. Until these endpoints are exposed, a number of features, including the likes of bookmarks, direct messages and updating your profile won't work and using these interfaces may have some unintended side effects. For more information and for a full list of these limitations, check [this list of issues labeled wontfix](https://github.com/natjms/resin/issues?q=is%3Aopen+is%3Aissue+label%3Awontfix).
109 |
110 | #### A note for iOS folks
111 | One of Resin's original goals was to create a Pixelfed client that could be
112 | used on both Android and iOS--something that's somewhat lacking among
113 | Fediverse clients. Unfortunately, due to our lacking access to Apple products,
114 | Resin hasn't formally been tested on iOS. Further, the Expo Go client no longer
115 | allows for iOS users to open projects via QR code in response to Apple's new
116 | security guidelines, thus complicating our beta distribution strategy. So,
117 | Resin's iOS support has been temporarily delayed until we can solve testing and
118 | distribution issues.
119 |
120 | If you're savvy with Apple's developer tools, you can probably still build it
121 | yourself if you were hoping to get an early look. If you manage to do so, we'd
122 | love to hear from you.
123 |
124 | ### Contributing code
125 |
126 | We're always welcoming new contributors! To start contributing code, feel free
127 | to check out the issues tab. Easy issues are labeled
128 | [good first issue](https://github.com/natjms/resin/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22),
129 | but don't let that limit you if you feel confident with React Native and
130 | JavaScript. If you see an issue hasn't been assigned to anyone, that means no
131 | one's working on it. Drop a reply saying you'd be interested in helping out and
132 | it's all yours.
133 |
134 | ### Other ways to help out
135 |
136 | Resin isn't officially set up to handle translations yet, but that'll be coming
137 | in the near future. There are many ways to help out with a project like this
138 | besides contributing code, from bug testing, to writing and more. Even then, a
139 | lot of the work is more on the philosophical side, in deconstructing the
140 | interfaces of apps like Instagram to best determine how to make Resin as
141 | nonaddictive as possible without making it painful to use.
142 |
143 | If you're not sure where to get started, feel free to contact
144 | [the project maintainer](https://social.njms.ca/nat) who would be more than
145 | happy to hear from you.
146 |
--------------------------------------------------------------------------------
/src/requests.js:
--------------------------------------------------------------------------------
1 | import AsyncStorage from "@react-native-async-storage/async-storage";
2 |
3 | export function objectToForm(obj) {
4 | let form = new FormData();
5 |
6 | Object.keys(obj).forEach(key =>
7 | form.append(key, obj[key])
8 | );
9 |
10 | return form;
11 | }
12 |
13 | export async function postForm(url, data = false, token = false, contentType = false) {
14 | // Send a POST request with data formatted with FormData returning JSON
15 | let headers = {};
16 |
17 | if (token) headers["Authorization"] = `Bearer ${token}`;
18 | if (contentType) headers["Content-Type"] = contentType;
19 |
20 | const resp = await fetch(url, {
21 | method: "POST",
22 | body: data ? objectToForm(data) : undefined,
23 | headers,
24 | });
25 |
26 | return resp;
27 | }
28 |
29 | export async function post(url, token = false) {
30 | const resp = await fetch(url, {
31 | method: "POST",
32 | headers: token
33 | ? { "Authorization": `Bearer ${token}`, }
34 | : {},
35 | });
36 |
37 | return resp;
38 | }
39 |
40 | export async function get(url, token , data = false) {
41 | let completeURL;
42 | if (data) {
43 | let params = new URLSearchParams(data);
44 | completeURL = `${url}?${params.toString()}`;
45 | } else {
46 | completeURL = url;
47 | }
48 |
49 | const resp = await fetch(completeURL, {
50 | method: "GET",
51 | headers: token
52 | ? { "Authorization": `Bearer ${token}`, }
53 | : {},
54 | });
55 |
56 | return resp;
57 | }
58 |
59 | export async function _delete(url, token) {
60 | const resp = await fetch(url, {
61 | method: "DELETE",
62 | headers: token
63 | ? { "Authorization": `Bearer ${token}`, }
64 | : {},
65 | });
66 |
67 | return resp;
68 | }
69 |
70 | export async function verifyCredentials(domain, token) {
71 | const resp = await get(
72 | `https://${domain}/api/v1/accounts/verify_credentials`,
73 | token
74 | );
75 | return resp.json();
76 | }
77 |
78 | export async function fetchProfile(domain, id, token) {
79 | const resp = await get(`https://${domain}/api/v1/accounts/${id}`, token);
80 | return resp.json();
81 | }
82 |
83 | export async function fetchAccountStatuses(domain, id, token) {
84 | const resp = await get(`https://${domain}/api/v1/accounts/${id}/statuses`, token);
85 | return resp.json();
86 | }
87 |
88 | export async function muteAccount(domain, id, token, params = false) {
89 | const resp = await postForm(`https://${domain}/api/v1/accounts/${id}/mute`, params, token);
90 | return resp.json();
91 | }
92 |
93 | export async function unmuteAccount(domain, id, token) {
94 | const resp = await post(`https://${domain}/api/v1/accounts/${id}/unmute`, token);
95 | return resp.json();
96 | }
97 |
98 | export async function blockAccount(domain, id, token) {
99 | const resp = await post(`https://${domain}/api/v1/accounts/${id}/block`, token);
100 | return resp.json();
101 | }
102 |
103 | export async function unblockAccount(domain, id, token) {
104 | const resp = await post(`https://${domain}/api/v1/accounts/${id}/unblock`, token);
105 | return resp.json();
106 | }
107 |
108 | export async function publishMediaAttachment(domain, token, params) {
109 | const resp = await postForm(`https://${domain}/api/v1/media`, params, token, "multipart/form-data");
110 | return resp.json();
111 | }
112 |
113 | export async function publishStatus(domain, token, params) {
114 | const resp = await postForm(`https://${domain}/api/v1/statuses`, params, token);
115 | return resp.json();
116 | }
117 |
118 | export async function deleteStatus(domain, id, token) {
119 | const resp = await _delete(`https://${domain}/api/v1/statuses/${id}`, token);
120 | return resp.json();
121 | }
122 |
123 | export async function fetchStatusContext(domain, id, token) {
124 | const resp = await get(`https://${domain}/api/v1/statuses/${id}/context`, token);
125 | return resp.json();
126 | }
127 |
128 | export async function favouriteStatus(domain, id, token) {
129 | const resp = await post(`https://${domain}/api/v1/statuses/${id}/favourite`, token);
130 | return resp.json();
131 | }
132 |
133 | export async function unfavouriteStatus(domain, id, token) {
134 | const resp = await post(`https://${domain}/api/v1/statuses/${id}/unfavourite`, token);
135 | return resp.json();
136 | }
137 |
138 | export async function reblogStatus(domain, id, token) {
139 | const resp = await post(`https://${domain}/api/v1/statuses/${id}/reblog`, token);
140 | return resp.json();
141 | }
142 |
143 | export async function unreblogStatus(domain, id, token) {
144 | const resp = await post(`https://${domain}/api/v1/statuses/${id}/unreblog`, token);
145 | return resp.json();
146 | }
147 |
148 | export async function bookmarkStatus(domain, id, token) {
149 | const resp = await post(`https://${domain}/api/v1/statuses/${id}/bookmark`, token);
150 | return resp.json();
151 | }
152 |
153 | export async function unbookmarkStatus(domain, id, token) {
154 | const resp = await post(`https://${domain}/api/v1/statuses/${id}/unbookmark`, token);
155 | return resp.json();
156 | }
157 |
158 | export async function fetchFollowing(domain, id, token) {
159 | const resp = await get(`https://${domain}/api/v1/accounts/${id}/following`, token);
160 | return resp.json();
161 | }
162 |
163 | export async function fetchFollowers(domain, id, token) {
164 | const resp = await get(`https://${domain}/api/v1/accounts/${id}/followers`, token);
165 | return resp.json();
166 | }
167 |
168 | export async function followAccount(domain, id, token) {
169 | const resp = await post(`https://${domain}/api/v1/accounts/${id}/follow`, token);
170 | return resp.json();
171 | }
172 |
173 | export async function unfollowAccount(domain, id, token) {
174 | const resp = await post(`https://${domain}/api/v1/accounts/${id}/unfollow`, token);
175 | return resp.json();
176 | }
177 |
178 | export async function fetchHomeTimeline(domain, token, params = false) {
179 | const resp = await get(
180 | `https://${domain}/api/v1/timelines/home`,
181 | token,
182 | params
183 | );
184 | return resp.json();
185 | }
186 |
187 | export async function fetchPublicTimeline(domain, token, params = false) {
188 | const resp = await get(
189 | `https://${domain}/api/v1/timelines/public`,
190 | token,
191 | params
192 | );
193 | return resp.json();
194 | }
195 |
196 | export async function fetchHashtagTimeline(domain, hashtag, token, params = false) {
197 | const resp = await get(
198 | `https://${domain}/api/v1/timelines/tag/${hashtag}`,
199 | token,
200 | params
201 | );
202 | return resp.json();
203 | }
204 |
205 | export async function fetchConversations(domain, token, params = false) {
206 | const resp = await get(
207 | `https://${domain}/api/v1/conversations`,
208 | token,
209 | params
210 | );
211 | return resp.json();
212 | }
213 |
214 | export async function fetchSearchResults(domain, token, params) {
215 | const resp = await get(
216 | `https://${domain}/api/v2/search`,
217 | token,
218 | params
219 | );
220 | return resp.json();
221 | }
222 |
--------------------------------------------------------------------------------
/src/components/pages/publish.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import {
3 | ScrollView,
4 | Dimensions,
5 | View,
6 | Image,
7 | Text,
8 | TextInput,
9 | } from "react-native";
10 |
11 | import { getAutoHeight } from "src/interface/rendering";
12 | import { TouchableOpacity } from "react-native-gesture-handler";
13 |
14 | import mime from "mime";
15 | import * as ImagePicker from 'expo-image-picker';
16 | import AsyncStorage from "@react-native-async-storage/async-storage";
17 | import * as requests from "src/requests";
18 | import Icon from "src/components/icons.js";
19 |
20 | const Publish = ({ navigation }) => {
21 | const [ state, setState ] = useState({
22 | loaded: false,
23 | });
24 |
25 | useEffect(() => {
26 | let instance, accessToken;
27 |
28 | AsyncStorage
29 | .multiGet([
30 | "@user_instance",
31 | "@user_token",
32 | ])
33 | .then(([ instancePair, tokenPair ]) => {
34 | instance = instancePair[1];
35 | accessToken = JSON.parse(tokenPair[1]).access_token;
36 |
37 | return ImagePicker.requestMediaLibraryPermissionsAsync();
38 | })
39 | .then(permissionResult => {
40 | console.warn(permissionResult);
41 | if (permissionResult.granted) {
42 | return ImagePicker.launchImageLibraryAsync({
43 | allowsEditing: true,
44 | });
45 | } else {
46 | throw "Permission not granted";
47 | }
48 | })
49 | .then((imageData) => {
50 | if (!imageData.cancelled) {
51 | return imageData;
52 | } else {
53 | throw "Image picker closed";
54 | }
55 | })
56 | .then(({ uri, type, width, height }) => {
57 | const name = uri.split("/").slice(-1)[0];
58 |
59 | setState({...state,
60 | loaded: true,
61 | instance,
62 | accessToken,
63 | visibility: "public",
64 | image: {
65 | data: {
66 | uri,
67 | type: mime.getType(uri),
68 | name,
69 | },
70 | width: SCREEN_WIDTH,
71 | height: getAutoHeight(width, height, SCREEN_WIDTH),
72 | },
73 | });
74 | })
75 | .catch(e => {
76 | console.warn(e);
77 | navigation.goBack();
78 | });
79 | }, []);
80 |
81 | const _handlePublish = async () => {
82 | const mediaAttachment = await requests.publishMediaAttachment(
83 | state.instance,
84 | state.accessToken,
85 | { file: state.image.data }
86 | );
87 |
88 | console.warn(mediaAttachment);
89 | if(mediaAttachment.type == "unknown") return;
90 |
91 | const params = {
92 | status: state.caption,
93 | "media_ids[]": mediaAttachment.id,
94 | visibility: state.visibility,
95 | };
96 |
97 | const newStatus = await requests.publishStatus(
98 | state.instance,
99 | state.accessToken,
100 | params
101 | );
102 |
103 | console.warn(newStatus);
104 | navigation.navigate("Feed");
105 | };
106 |
107 | const Selector = (props) => {
108 | const color = props.active == props.visibility ? "black" : "#666";
109 |
110 | return (
111 | setState({ ...state, visibility: props.visibility })
115 | }>
116 |
117 |
121 |
122 |
123 | { props.message }
124 |
125 |
126 |
127 | );
128 | };
129 |
130 | return state.loaded
131 | ?
132 |
133 |
142 |
143 |
144 | setState({ ...state, caption })
151 | }
152 | style = { [ styles.form.input, { height: 100, } ] } />
153 |
154 | Visibility
155 |
160 |
165 |
170 |
171 |
174 |
175 | Publish
176 |
177 |
178 |
179 |
180 | : <>>;
181 | };
182 |
183 | const SCREEN_WIDTH = Dimensions.get("window").width;
184 | const SCREEN_HEIGHT = Dimensions.get("window").height;
185 | const styles = {
186 | preview: {
187 | container: {
188 | paddingTop: 10,
189 | },
190 | image: {
191 | marginLeft: "auto",
192 | marginRight: "auto",
193 | height: SCREEN_HEIGHT / 3,
194 | },
195 | },
196 |
197 | form: {
198 | container: {
199 | padding: 10,
200 | },
201 | input: {
202 | borderBottomWidth: 1,
203 | borderBottomColor: "#888",
204 | textAlignVertical: "top",
205 | padding: 10,
206 | },
207 | label: {
208 | marginTop: 20,
209 | fontSize: 15,
210 | color: "#666",
211 | },
212 | option: {
213 | button: {},
214 | inner: {
215 | marginTop: 10,
216 | flexDirection: "row",
217 | alignItems: "center",
218 | },
219 | },
220 | button: {
221 | container: {
222 | width: SCREEN_WIDTH * (3/4),
223 | marginTop: 30,
224 | marginLeft: "auto",
225 | marginRight: "auto",
226 | padding: 20,
227 | borderWidth: 1,
228 | borderColor: "#666",
229 | borderRadius: 10,
230 | },
231 | label: {
232 | textAlign: "center",
233 | },
234 | },
235 | },
236 | };
237 |
238 | export { Publish as default };
239 |
--------------------------------------------------------------------------------
/src/components/pages/direct.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import {
3 | ScrollView,
4 | View,
5 | Text,
6 | TouchableOpacity,
7 | Image,
8 | FlatList,
9 | TextInput,
10 | Dimensions,
11 | } from "react-native";
12 |
13 | import AsyncStorage from "@react-native-async-storage/async-storage";
14 | import * as requests from "src/requests";
15 | import Icon from "src/components/icons.js";
16 | import ModerateMenu from "src/components/moderate-menu.js";
17 |
18 | const TEST_IMAGE_1 = "https://cache.desktopnexus.com/thumbseg/2255/2255124-bigthumbnail.jpg";
19 | const TEST_IMAGE_2 = "https://natureproducts.net/Forest_Products/Cutflowers/Musella_cut.jpg";
20 | const TEST_ACCOUNT_1 = { id: 1, acct: "njms", display_name: "Nat🔆", avatar: TEST_IMAGE_1 };
21 | const TEST_ACCOUNT_2 = { id: 2, acct: "someone", display_name: "Some person", avatar: TEST_IMAGE_2 };
22 |
23 | const TEST_STATUS = {
24 | id: 1,
25 | account: TEST_ACCOUNT_1,
26 | content: "This is a direct message",
27 | };
28 |
29 | function filterConversations(convs, query) {
30 | return convs.filter(conv => {
31 | const accts = conv.accounts.map(account => account.acct).join();
32 | const names = conv.accounts.map(account => account.display_name).join();
33 |
34 | return accts.includes(query) || names.includes(query)
35 | });
36 | }
37 |
38 | const Direct = ({ navigation }) => {
39 | const FETCH_LIMIT = 1;
40 |
41 | const [state, setState] = useState({
42 | loaded: false,
43 | query: "",
44 | fetchOffset: 0,
45 | });
46 |
47 | useEffect(() => {
48 | let instance, accessToken;
49 | AsyncStorage
50 | .multiGet([
51 | "@user_instance",
52 | "@user_token",
53 | ])
54 | .then(([instancePair, tokenPair]) => {
55 | instance = instancePair[1];
56 | accessToken = JSON.parse(tokenPair[1]).access_token;
57 |
58 | return requests.fetchConversations(
59 | instance,
60 | accessToken,
61 | { limit: FETCH_LIMIT, }
62 | );
63 | })
64 | .then(conversations => {
65 | setState({...state,
66 | loaded: true,
67 | conversations,
68 | fetchOffset: FETCH_LIMIT,
69 | instance,
70 | accessToken,
71 | });
72 | });
73 | }, []);
74 |
75 | const _handleShowMore = async () => {
76 | const results = await requests.fetchConversations(
77 | state.instance,
78 | state.accessToken,
79 | {
80 | max_id: state.conversations[state.conversations.length - 1],
81 | limit: FETCH_LIMIT,
82 | }
83 | );
84 |
85 | setState({...state,
86 | conversations: state.conversations.concat(results),
87 | fetchOffset: state.fetchOffset + FETCH_LIMIT,
88 | });
89 | };
90 |
91 | const onPressConversationFactory = (conv) => {
92 | return () => {
93 | navigation.navigate("Conversation", {
94 | conversation: conv,
95 | });
96 | }
97 | };
98 |
99 | const renderConversation = (item) => {
100 | const boldIfUnread = item.unread ? styles.bold : {};
101 |
102 | return (
103 |
106 |
111 |
112 |
115 |
116 |
117 |
118 | { item.accounts.map(account => account.acct).join(", ") }
119 |
120 |
121 | {
122 | // Prefix message with acct
123 | [
124 | item.accounts.length > 1 ?
125 | item.last_status.account.acct + ": "
126 | : "",
127 | item.last_status.content,
128 | ].join("")
129 | }
130 |
131 |
132 |
133 |
134 |
136 |
137 |
138 | );
139 | };
140 |
141 | return (
142 | <>
143 | { state.loaded
144 | ?
145 |
146 | {
152 | setState({...state,
153 | query: value,
154 | });
155 | }
156 | }/>
157 | { navigation.navigate("Compose") } }>
160 |
161 |
162 |
163 | <>
164 | {
165 | filterConversations(
166 | state.conversations,
167 | state.query
168 | ).map(renderConversation)
169 | }
170 | >
171 | <>
172 | { state.conversations.length == state.fetchOffset
173 | ?
174 |
176 |
177 | Show more?
178 |
179 |
180 |
181 | : <>>
182 | }
183 | >
184 |
185 | : <>>
186 | }
187 | >
188 | );
189 | };
190 |
191 | const SCREEN_WIDTH = Dimensions.get("window").width;
192 | const styles = {
193 | row: {
194 | flexDirection: "row",
195 | alignItems: "center",
196 | },
197 | form: {
198 | container: {
199 | marginLeft: 20,
200 | marginTop: 20,
201 | marginBottom: 20,
202 | },
203 | searchBar: {
204 | padding: 10,
205 | width: SCREEN_WIDTH * 3 / 4,
206 | borderWidth: 1,
207 | borderRadius: 5,
208 | borderColor: "#888",
209 | },
210 | compose: {
211 | marginLeft: "auto",
212 | marginRight: "auto",
213 | },
214 | },
215 | conv: {
216 | container: {
217 | paddingBottom: 20,
218 | paddingLeft: 10,
219 | paddingRight: 20,
220 | },
221 | containerButton: { flexGrow: 1 },
222 | avatar: {
223 | image: {
224 | width: 40,
225 | height: 40,
226 | borderRadius: 20,
227 | }
228 | },
229 | body: {
230 | marginLeft: 10,
231 | },
232 | context: {
233 | marginLeft: "auto",
234 | }
235 | },
236 | menu: {
237 | trigger: {
238 | width: 20,
239 | height: 20,
240 | },
241 | },
242 | showMore: {
243 | container: {
244 | justifyContent: "center",
245 | alignItems: "center"
246 | },
247 | button: {
248 | borderWidth: 1,
249 | borderColor: "#888",
250 | borderRadius: 5,
251 | padding: 10,
252 | margin: 20
253 | },
254 | },
255 | bold: { fontWeight: "bold" },
256 | };
257 |
258 | export { Direct as default };
259 |
--------------------------------------------------------------------------------
/src/components/pages/authenticate.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import {
3 | SafeAreaView,
4 | View,
5 | TextInput,
6 | TouchableOpacity,
7 | Text,
8 | Image,
9 | Dimensions,
10 | } from "react-native";
11 |
12 | import AsyncStorage from "@react-native-async-storage/async-storage";
13 | import * as Linking from "expo-linking";
14 | import * as WebBrowser from "expo-web-browser";
15 | import Constants from "expo-constants";
16 |
17 | import * as requests from "src/requests";
18 |
19 | const Authenticate = ({navigation}) => {
20 | const REDIRECT_URI = Linking.makeUrl("authenticate");
21 | const [state, setState] = useState({
22 | instance: "",
23 | renderLogin: false,
24 | });
25 |
26 | const init = async () => {
27 | const [instancePair, tokenJSONPair, profileJSONPair, appJSONPair] =
28 | await AsyncStorage.multiGet([
29 | "@user_instance",
30 | "@user_token",
31 | "@user_profile",
32 | ]);
33 |
34 | const instance = instancePair[1];
35 | const tokenJSON = tokenJSONPair[1];
36 | const profileJSON = profileJSONPair[1];
37 |
38 | if (profileJSON == null) {
39 | // The user hasn't logged in yet.
40 | setState({ ...state, renderLogin: true, });
41 | return;
42 | }
43 |
44 | const accessToken = JSON.parse(tokenJSON).access_token;
45 |
46 | // Check to see if the credentials are still valid
47 | const verifiedUser = await requests.verifyCredentials(
48 | instance,
49 | accessToken
50 | ).catch(e => {
51 | /* The Pixelfed API returns an HTML page when your access token gets
52 | * revoked instead of the JSON error object. Since this causes a lot
53 | * of problems, we're going to assume that if the response is HTML,
54 | * then the user needs to log in again. See issue #27.
55 | */
56 | if (e instanceof SyntaxError) {
57 | // Generate faux error API response
58 | return { "error": true };
59 | }
60 | });
61 |
62 | if(verifiedUser.error) {
63 | // `error` will be undefined if the token is valid
64 | // Purge the user's data
65 | await AsyncStorage.multiRemove([
66 | "@user_instance",
67 | "@user_token",
68 | "@user_profile",
69 | ]);
70 |
71 | setState({...state,
72 | renderLogin: true,
73 | });
74 | return;
75 | }
76 |
77 | // requests.verifyCredentials returns the latest version of the
78 | // profile on success, so take this opportunity to update it
79 | const newProfile = verifiedUser;
80 | await AsyncStorage.setItem(
81 | "@user_profile",
82 | JSON.stringify(newProfile)
83 | );
84 |
85 | // Since nothing went wrong, navigate to the feed.
86 | navigation.replace("Main");
87 | };
88 |
89 | const _handleUrl = async ({ url }) => {
90 | // When the app is foregrounded after authorizing the app from their
91 | // instance's website...
92 | if (Constants.platform.ios) {
93 | WebBrowser.dismissBrowser();
94 | } else {
95 | Linking.removeEventListener("url", _handleUrl)
96 | }
97 |
98 | const { path, queryParams } = Linking.parse(url);
99 |
100 | const instance = await AsyncStorage.getItem("@user_instance");
101 | const api = `https://${instance}`;
102 | const app = JSON.parse(
103 | await AsyncStorage.getItem("@app_object")
104 | );
105 |
106 | // Fetch the access token
107 | const tokenRequestBody = {
108 | client_id: app.client_id,
109 | client_secret: app.client_secret,
110 | redirect_uri: REDIRECT_URI,
111 | grant_type: "authorization_code",
112 | code: queryParams.code,
113 | scope: "read write follow push",
114 | };
115 |
116 | const token = await requests
117 | .postForm(`${api}/oauth/token`, tokenRequestBody)
118 | .then(resp => resp.json());
119 |
120 | // Store the token
121 | await AsyncStorage.setItem("@user_token", JSON.stringify(token));
122 |
123 | const profile = await requests.get(
124 | `${api}/api/v1/accounts/verify_credentials`,
125 | token.access_token
126 | ).then(resp => resp.json());
127 |
128 | await AsyncStorage.setItem("@user_profile", JSON.stringify(profile));
129 |
130 | navigation.replace("Main");
131 | };
132 |
133 | useEffect(() => {
134 | // Register the listener for the app getting foregrounded
135 | // This is for when the user has navigated back from their web browser
136 | // having approved the app
137 | Linking.addEventListener("url", _handleUrl);
138 |
139 | // Start initialization sequence
140 | init();
141 | }, []);
142 |
143 | const _login = async () => {
144 | const url = `https://${state.instance}`;
145 |
146 | let appJSON = await AsyncStorage.getItem("@app_object");
147 | let app;
148 |
149 | // Ensure the app has been created
150 | if (appJSON == null) {
151 | // Register app: https://docs.joinmastodon.org/methods/apps/#create-an-application
152 | app = await requests.postForm(`${url}/api/v1/apps`, {
153 | client_name: "Resin",
154 | redirect_uris: REDIRECT_URI,
155 | scopes: "read write follow push",
156 | website: "https://github.com/natjms/resin",
157 | }).then(resp => resp.json());
158 |
159 | await AsyncStorage
160 | .setItem("@app_object", JSON.stringify(app))
161 | } else {
162 | // The app has already been registered
163 | app = JSON.parse(appJSON);
164 | }
165 |
166 | // Store the domain name of the instance for use in
167 | // the _handleUrl callback
168 | // NOTE: state.instance is not accessible from _handleUrl; this
169 | // probably has something to do with the fact that the app loses
170 | // focus when WebBrowser.openAuthSessionAsync gets called.
171 | await AsyncStorage.setItem("@user_instance", state.instance);
172 |
173 | // Get the user to authorize the app
174 | await WebBrowser.openAuthSessionAsync(
175 | `${url}/oauth/authorize`
176 | + `?client_id=${app.client_id}`
177 | + `&scope=read+write+follow+push`
178 | + `&redirect_uri=${REDIRECT_URI}`
179 | + `&response_type=code`
180 | );
181 | };
182 |
183 | return (
184 |
185 | {
186 | state.renderLogin
187 | ?
188 |
189 |
193 |
194 | Instance domain name
195 | setState({ ...state, instance: value })
201 | }/>
202 |
203 |
206 | Login
207 |
208 |
209 | : <>>
210 | }
211 |
212 | );
213 | };
214 |
215 | const SCREEN_WIDTH = Dimensions.get("window").width;
216 | const SCREEN_HEIGHT = Dimensions.get("window").height;
217 | const styles = {
218 | container: {
219 | justifyContent: "center",
220 | alignItems: "center",
221 | height: SCREEN_HEIGHT,
222 | },
223 | innerContainer: {
224 | width: SCREEN_WIDTH / 1.5,
225 | },
226 | logo: {
227 | container: {
228 | alignItems: "center",
229 | marginBottom: 30,
230 | },
231 | image: {
232 | width: 100,
233 | height: 100,
234 | }
235 | },
236 | label: {
237 | fontWeight: "bold",
238 | color: "#888",
239 | },
240 | input: {
241 | padding: 10,
242 | borderBottomWidth: 1,
243 | borderBottomColor: "#888",
244 | marginBottom: 10,
245 | },
246 | login: {
247 | button: {
248 | borderWidth: 1,
249 | borderColor: "#888",
250 | borderRadius: 5,
251 | padding: 15,
252 | },
253 | label: {
254 | textAlign: "center",
255 | },
256 | },
257 | };
258 |
259 | export { Authenticate as default };
260 |
--------------------------------------------------------------------------------
/src/components/pages/profile/settings.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 |
3 | import {
4 | ScrollView,
5 | SafeAreaView,
6 | View,
7 | TextInput,
8 | Text,
9 | Image,
10 | TouchableOpacity,
11 | Dimensions,
12 | } from "react-native";
13 | import mime from "mime";
14 |
15 | import AsyncStorage from "@react-native-async-storage/async-storage";
16 | import * as requests from "src/requests";
17 | import Icon from "src/components/icons.js";
18 |
19 | import * as ImagePicker from 'expo-image-picker';
20 |
21 | import { withoutHTML } from "src/interface/rendering";
22 |
23 | const Settings = (props) => {
24 | const [state, setState] = useState({
25 | loaded: false,
26 | });
27 |
28 | const _handleLogout = async () => {
29 | await requests.postForm(
30 | `https://${state.instance}/oauth/revoke`,
31 | {
32 | client_id: state.appObject.client_id,
33 | client_secret: state.appObject.client_secret,
34 | token: state.accessToken,
35 | }
36 | );
37 |
38 | await AsyncStorage.multiRemove([
39 | "@user_profile",
40 | "@user_instance",
41 | "@user_token",
42 | ]);
43 |
44 | props.navigation.navigate("Authenticate");
45 | };
46 |
47 | useEffect(() => {
48 | AsyncStorage
49 | .multiGet([
50 | "@user_profile",
51 | "@user_instance",
52 | "@user_token",
53 | "@app_object",
54 | ])
55 | .then(([profilePair, instancePair, tokenPair, appPair]) =>
56 | [
57 | JSON.parse(profilePair[1]),
58 | instancePair[1],
59 | JSON.parse(tokenPair[1]),
60 | JSON.parse(appPair[1]),
61 | ]
62 | )
63 | .then(([profile, instance, token, appObject]) => {
64 | let newProfile = profile;
65 | newProfile.fields = newProfile.fields == null
66 | ? []
67 | : newProfile.fields;
68 |
69 | setState({...state,
70 | profile: profile,
71 | instance: instance,
72 | appObject: appObject,
73 | accessToken: token.access_token,
74 |
75 | // Malleable props that will actually go towards updating
76 | // the profile credentials
77 | locked: profile.locked,
78 | newAvatar: {
79 | uri: profile.avatar,
80 | },
81 | display_name: profile.display_name,
82 | note: profile.note,
83 |
84 | loaded: true,
85 | })
86 | });
87 | }, []);
88 |
89 | const _handleChangeProfilePhoto = async () => {
90 | await ImagePicker.requestMediaLibraryPermissionsAsync()
91 |
92 | const { uri } = await ImagePicker.launchImageLibraryAsync({
93 | allowsEditing: true,
94 | aspect: [1, 1],
95 | });
96 |
97 | const name = uri.split("/").slice(-1)[0];
98 |
99 | setState({...state,
100 | newAvatar: {
101 | uri,
102 | type: mime.getType(uri),
103 | name,
104 | },
105 | });
106 | };
107 |
108 | const _handleSaveProfile = async () => {
109 | let params = {
110 | display_name: state.display_name,
111 | note: state.note,
112 | locked: state.locked,
113 | };
114 |
115 | // In other words, if a picture has been selected...
116 | if (state.newAvatar.name) {
117 | params.avatar = state.newAvatar;
118 | }
119 |
120 | const newProfile = await fetch(
121 | `https://${state.instance}/api/v1/accounts/update_credentials`,
122 | {
123 | method: "PATCH",
124 | body: requests.objectToForm(params),
125 | headers: { "Authorization": `Bearer ${state.accessToken}`, }
126 | }
127 | ).then(resp => resp.json());
128 |
129 | await AsyncStorage.setItem("@user_profile", JSON.stringify(newProfile));
130 |
131 | props.navigation.navigate("Profile");
132 | };
133 |
134 | return (
135 | <>
136 | { state.loaded
137 | ?
138 |
139 |
142 |
143 |
144 | Change profile photo
145 |
146 |
147 |
148 |
149 | Display name
150 | {
156 | setState({...state,
157 | display_name: value,
158 | });
159 | }
160 | }/>
161 |
162 | Bio
163 | {
176 | setState({...state,
177 | note: value
178 | });
179 | }
180 | }/>
181 |
182 | (
185 | setState({...state,
186 | locked: !state.locked
187 | })
188 | )
189 | }>
190 |
191 | <>
192 | { !state.locked
193 | ?
194 | :
195 | }
196 | >
197 |
198 | Manually approve follow requests?
199 |
200 |
201 |
202 |
203 |
206 | Save Profile
207 |
208 |
211 |
214 | Log out
215 |
216 |
217 |
218 |
219 | : <>>
220 | }
221 | >
222 | );
223 | };
224 |
225 | const SCREEN_WIDTH = Dimensions.get("window").width;
226 | const styles = {
227 | label: {
228 | paddingTop: 10,
229 | fontWeight: "bold",
230 | color: "#888",
231 | },
232 | bar: {
233 | borderBottomWidth: 1,
234 | borderBottomColor: "#888",
235 | padding: 10,
236 | },
237 | avatar: {
238 | container: {
239 | paddingTop: 10,
240 | paddingBottom: 10,
241 | flex: 1,
242 | alignItems: "center",
243 | },
244 | image: {
245 | width: SCREEN_WIDTH / 5,
246 | height: SCREEN_WIDTH / 5,
247 | borderRadius: SCREEN_WIDTH / 10,
248 | marginBottom: 10,
249 | },
250 | change: {
251 | fontSize: 18,
252 | color: "#888",
253 | },
254 | },
255 | input: {
256 | container: {
257 | padding: 10,
258 | },
259 | },
260 | check: {
261 | container: {
262 | flexDirection: "row",
263 | alignItems: "center",
264 | padding: 10,
265 | },
266 | label: {
267 | paddingLeft: 10,
268 | },
269 | },
270 | button: {
271 | container: {
272 | width: SCREEN_WIDTH / 1.2,
273 | padding: 15,
274 | marginTop: 10,
275 | marginBottom: 5,
276 | marginLeft: "auto",
277 | marginRight: "auto",
278 | borderWidth: 1,
279 | borderColor: "#888",
280 | borderRadius: 5,
281 | },
282 | text: { textAlign: "center" },
283 | warning: {
284 | fontWeight: "bold",
285 | textDecorationLine: "underline",
286 | },
287 | },
288 | };
289 |
290 | export default Settings;
291 |
--------------------------------------------------------------------------------
/src/components/pages/direct/conversation.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import {
3 | View,
4 | SafeAreaView,
5 | Text,
6 | Image,
7 | TextInput,
8 | ScrollView,
9 | Dimensions,
10 | TouchableOpacity,
11 | } from "react-native";
12 |
13 | import AsyncStorage from "@react-native-async-storage/async-storage";
14 | import Icon from "src/components/icons.js";
15 |
16 | import {
17 | Menu,
18 | MenuOptions,
19 | MenuOption,
20 | MenuTrigger,
21 | renderers
22 | } from "react-native-popup-menu";
23 |
24 | const { SlideInMenu } = renderers;
25 |
26 | import { timeToAge, StatusBarSpace } from "src/interface/rendering";
27 |
28 | const TEST_IMAGE_1 = "https://cache.desktopnexus.com/thumbseg/2255/2255124-bigthumbnail.jpg";
29 | const TEST_IMAGE_2 = "https://natureproducts.net/Forest_Products/Cutflowers/Musella_cut.jpg";
30 | const TEST_ACCOUNT_1 = { id: 1, acct: "someone", display_name: "Someone", avatar: TEST_IMAGE_1 };
31 | const TEST_ACCOUNT_2 = { id: 2, acct: "someone_else", display_name: "Another person", avatar: TEST_IMAGE_2 };
32 |
33 | const TEST_STATUS = {
34 | account: TEST_ACCOUNT_1,
35 | content: "This is a direct message",
36 | created_at: 1596745156000,
37 | };
38 |
39 | const TEST_MESSAGES = [
40 | { ...TEST_STATUS, id: 1 },
41 | { ...TEST_STATUS, id: 2, account: TEST_ACCOUNT_2 },
42 | { ...TEST_STATUS, id: 3 },
43 | { ...TEST_STATUS, id: 4, account: { acct: "njms" } },
44 | { ...TEST_STATUS, id: 5 },
45 | ];
46 |
47 | const ConversationContainer = (props) => (
48 |
49 |
50 |
51 | { props.renderBackBar() }
52 |
53 |
54 | { props.children }
55 |
56 |
57 | {
64 | props.setState({...props.state,
65 | newMessage: value,
66 | });
67 | }
68 | }/>
69 |
72 |
73 |
74 |
75 |
76 | );
77 |
78 | const Compose = ({ navigation }) => {
79 | const [state, setState] = useState({
80 | accts: [],
81 | newMessage: "",
82 | });
83 | const renderBackBar = () => (
84 | {
89 | setState({...state,
90 | accts: value.split(",").map(acct => acct.trim())
91 | });
92 | }
93 | }/>
94 | );
95 |
96 | return {
103 | // Create the conversation, navigate to conversation.js
104 | }
105 | }/>;
106 | };
107 |
108 | const Conversation = ({ navigation }) => {
109 | const conversation = route.params.conversation
110 | const [state, setState] = useState({
111 | loaded: false,
112 | newMessage: "",
113 | });
114 |
115 | useEffect(() => {
116 | // Get the context of last_status, then profile from AsyncStorage
117 | AsyncStorage.getItem("@user_profile").then((profile) => {
118 | setState({...state,
119 | loaded: true,
120 | profile: JSON.parse(profile),
121 | messages: TEST_MESSAGES,
122 | });
123 | });
124 | }, []);
125 |
126 | const accountListOptionsStyles = {
127 | optionWrapper: { // The wrapper around a single option
128 | flexDirection: "row",
129 | alignItems: "center",
130 |
131 | paddingLeft: SCREEN_WIDTH / 15,
132 | paddingTop: SCREEN_WIDTH / 30,
133 | paddingBottom: SCREEN_WIDTH / 30
134 | },
135 | optionsWrapper: { // The wrapper around all options
136 | marginTop: SCREEN_WIDTH / 20,
137 | marginBottom: SCREEN_WIDTH / 20,
138 | },
139 | optionsContainer: { // The Animated.View
140 | borderTopLeftRadius: 10,
141 | borderTopRightRadius: 10
142 | }
143 | };
144 |
145 | const renderBackBar = () => (
146 |
147 |
183 |
184 | );
185 |
186 | const renderMessage = (item) => {
187 | const yours = state.profile.acct == item.account.acct;
188 | return
189 | { !yours
190 | ?
191 | { item.account.acct }
192 |
193 | : <>>
194 | }
195 |
196 | { !yours
197 | ?
200 | : <>>
201 | }
202 |
211 |
212 |
213 | { item.content + "\n" }
214 |
215 |
216 | { timeToAge(item.created_at) }
217 |
218 |
219 |
220 |
221 | ;
222 | };
223 |
224 | return (
225 |
230 | { state.loaded
231 | ? state.messages.map(renderMessage)
232 | : <>>
233 | }
234 |
235 | );
236 | };
237 |
238 | const SCREEN_WIDTH = Dimensions.get("window").width;
239 | const styles = {
240 | row: {
241 | flexDirection: "row",
242 | alignItems: "center",
243 | },
244 | backBar: {
245 | accountList: {
246 | avatar: {
247 | width: 40,
248 | height: 40,
249 | borderRadius: 20,
250 | marginRight: 10,
251 | },
252 | },
253 | container: {
254 | marginLeft: 20,
255 | paddingTop: 10,
256 | paddingBottom: 10,
257 | },
258 | avatar: {
259 | width: 40,
260 | height: 40,
261 | borderRadius: 20,
262 | marginRight: 10,
263 | },
264 | },
265 | message: {
266 | container: {
267 | paddingTop: 5,
268 | paddingBottom: 10,
269 | paddingLeft: 10,
270 | paddingRight: 10,
271 | flexDirection: "row",
272 | },
273 | acct: {
274 | paddingLeft: 60,
275 | fontSize: 12,
276 | color: "#888",
277 | },
278 | avatar: {
279 | width: 40,
280 | height: 40,
281 | borderRadius: 20,
282 | marginRight: 10,
283 | },
284 | bubble: {
285 | width: SCREEN_WIDTH * 3/4,
286 | borderWidth: 1,
287 | borderColor: "#888",
288 | borderRadius: 10,
289 | padding: 10,
290 | },
291 | yourBubble: {
292 | backgroundColor: "#CCC",
293 | marginLeft: "auto",
294 | },
295 | yourText: {
296 | //color: "white",
297 | textAlign: "right",
298 | },
299 | age: {
300 | fontSize: 10,
301 | color: "#888",
302 | },
303 | },
304 | send: {
305 | container: {
306 | marginTop: 10,
307 | marginBottom: 10,
308 | marginLeft: 10,
309 | },
310 | button: {
311 | marginLeft: 10,
312 | marginRight: 10,
313 | }
314 | },
315 | bold: { fontWeight: "bold", },
316 | input: {
317 | padding: 10,
318 | borderWidth: 1,
319 | borderColor: "#888",
320 | borderRadius: 5,
321 | flexGrow: 1,
322 | },
323 | };
324 |
325 | export { Conversation as default, Compose, };
326 |
--------------------------------------------------------------------------------
/src/components/pages/discover/search.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import {
3 | ScrollView,
4 | View,
5 | TextInput,
6 | Text,
7 | Dimensions,
8 | Image,
9 | } from "react-native";
10 | import { TabView, TabBar, SceneMap } from "react-native-tab-view";
11 | import AsyncStorage from "@react-native-async-storage/async-storage";
12 |
13 | import * as requests from "src/requests";
14 | import { StatusBarSpace } from "src/interface/rendering";
15 | import Icon from "src/components/icons.js";
16 |
17 | import { TouchableOpacity } from "react-native-gesture-handler";
18 |
19 | function navCallbackFactory(navigation, route) {
20 | return params => {
21 | navigation.navigate(route, params);
22 | }
23 | }
24 |
25 | const Search = ({navigation}) => {
26 | // The number of additional items to fetch each time
27 | const FETCH_LIMIT = 5;
28 |
29 | let [state, setState] = useState({
30 | query: "",
31 | loaded: false,
32 | accountOffset: 0,
33 | hashtagOffset: 0,
34 | });
35 |
36 | useEffect(() => {
37 | let instance, accessToken;
38 | AsyncStorage
39 | .multiGet([
40 | "@user_instance",
41 | "@user_token",
42 | ])
43 | .then(([instancePair, tokenPair]) => {
44 | instance = instancePair[1];
45 | accessToken = JSON.parse(tokenPair[1]).access_token;
46 |
47 | setState({...state,
48 | instance,
49 | accessToken,
50 | loaded: true,
51 | });
52 | });
53 | }, []);
54 |
55 | const _handleSearch = async () => {
56 | const results = await requests.fetchSearchResults(
57 | state.instance,
58 | state.accessToken,
59 | {
60 | q: state.query,
61 | limit: FETCH_LIMIT,
62 | }
63 | );
64 |
65 | setState({...state,
66 | results,
67 | accountOffset: FETCH_LIMIT,
68 | hashtagOffset: FETCH_LIMIT,
69 | });
70 | };
71 |
72 | const _handleShowMoreAccounts = async () => {
73 | const { accounts } = await requests.fetchSearchResults(
74 | state.instance,
75 | state.accessToken,
76 | {
77 | q: state.query,
78 | type: "accounts",
79 | offset: state.accountOffset,
80 | limit: FETCH_LIMIT,
81 | }
82 | );
83 |
84 | setState({...state,
85 | results: {...state.results,
86 | accounts: state.results.accounts.concat(accounts),
87 | },
88 | accountOffset: state.accountOffset + FETCH_LIMIT,
89 | });
90 | };
91 |
92 | const _handleShowMoreHashtags = async () => {
93 | const { hashtags } = await requests.fetchSearchResults(
94 | state.instance,
95 | state.accessToken,
96 | {
97 | q: state.query,
98 | type: "hashtags",
99 | offset: state.hashtagOffset,
100 | limit: FETCH_LIMIT,
101 | }
102 | );
103 |
104 | setState({...state,
105 | results: {...state.results,
106 | hashtags: state.results.hashtags.concat(hashtags),
107 | },
108 | hashtagOffset: state.hashtagOffset + FETCH_LIMIT,
109 | });
110 | };
111 |
112 | const [ index, setIndex ] = useState(0);
113 | const [ routes ] = useState([
114 | {
115 | key: "accounts",
116 | icon: "profile",
117 | },
118 | {
119 | key: "hashtags",
120 | icon: "hashtag",
121 | },
122 | ]);
123 |
124 | const AccountRenderer = () => (
125 |
130 | );
131 |
132 | const HashtagRenderer = () => (
133 |
138 | );
139 |
140 | const renderScene = SceneMap({
141 | accounts: AccountRenderer,
142 | hashtags: HashtagRenderer,
143 | });
144 |
145 | const renderTabBar = (props) => (
146 |
153 | );
154 |
155 | const renderIcon = ({ route, focused }) => (
156 |
160 | );
161 |
162 | return (
163 | <>
164 | { state.loaded
165 | ?
166 |
167 | setState({ ...state, query: q })
174 | }
175 | onBlur = {
176 | () => {
177 | if (state.query == "") {
178 | navigation.navigate("Discover");
179 | }
180 | }
181 | }
182 | value = { state.query } />
183 |
186 |
187 |
188 |
189 | { state.results
190 | ?
196 | : <>>
197 | }
198 |
199 | : <>>
200 | }
201 | >
202 | );
203 | };
204 |
205 | const SearchItem = (props) => {
206 | return (
207 | props.callback(props.navParams) }>
209 |
210 |
213 |
214 | { props.children }
215 |
216 |
217 |
218 | );
219 | };
220 |
221 | // Display message noting when no results turned up. This component wraps
222 | // AccountList and HashtagList.
223 | const SearchListContainer = ({ results, children }) => results.length == 0
224 | ?
225 | No results!
226 |
227 | : children;
228 |
229 | const AccountList = (props) => {
230 | return (
231 |
232 |
233 | <>
234 | {
235 | props.results.map(item => {
236 | return (
237 |
242 |
243 | { item.acct }
244 |
245 |
246 | { item.display_name }
247 |
248 |
249 | );
250 | })
251 | }
252 | >
253 | <>
254 | { props.results.length == props.offset
255 | ?
256 |
258 |
259 | Show more?
260 |
261 |
262 |
263 | : <>>
264 | }
265 | >
266 |
267 |
268 | );
269 | };
270 |
271 | const HashtagList = (props) => {
272 | return (
273 |
274 |
275 | <>
276 | {
277 | props.results.map((item, i) => {
278 | return (
279 |
284 |
285 | #{ item.name }
286 |
287 |
288 | );
289 | })
290 | }
291 | >
292 | <>
293 | { props.results.length == props.offset
294 | ?
295 |
297 |
298 | Show more?
299 |
300 |
301 |
302 | :<>>
303 | }
304 | >
305 |
306 |
307 | );
308 | }
309 |
310 | const SCREEN_WIDTH = Dimensions.get("window").width;
311 | const SCREEN_HEIGHT = Dimensions.get("window").height;
312 | const styles = {
313 | form: {
314 | container: {
315 | flexDirection: "row",
316 | justifyContent: "center",
317 | backgroundColor: "white",
318 | padding: 20,
319 | },
320 |
321 | input: {
322 | flexGrow: 1,
323 | padding: 10,
324 | fontSize: 17,
325 | color: "#888"
326 | },
327 |
328 | submit: {
329 | padding: 20,
330 | }
331 | },
332 |
333 | label: {
334 | padding: 10,
335 | fontSize: 15,
336 | },
337 |
338 | searchList: { padding: 0 },
339 |
340 | searchResultContainer: {
341 | display: "flex",
342 | flexDirection: "row",
343 | padding: 5,
344 | paddingLeft: 20,
345 | },
346 |
347 | noResultsContainer: {
348 | paddingTop: SCREEN_HEIGHT / 4,
349 | alignItems: "center",
350 | },
351 |
352 | queried: {
353 | display: "flex",
354 | justifyContent: "center",
355 | },
356 |
357 | username: { fontWeight: "bold" },
358 | displayName: { color: "#888" },
359 |
360 | thumbnail: {
361 | width: 50,
362 | height: 50,
363 | borderRadius: 25,
364 | marginRight: 10,
365 | },
366 |
367 | showMore: {
368 | container: {
369 | justifyContent: "center",
370 | alignItems: "center"
371 | },
372 |
373 | button: {
374 | borderWidth: 1,
375 | borderColor: "#888",
376 | borderRadius: 5,
377 | padding: 10,
378 | margin: 20
379 | },
380 | },
381 |
382 | tabBar: {
383 | indicator: { backgroundColor: "black" },
384 | tab: { backgroundColor: "white" },
385 | },
386 | }
387 |
388 | export default Search;
389 |
--------------------------------------------------------------------------------
/src/components/posts/post.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import {
3 | Image,
4 | View,
5 | Text,
6 | Dimensions,
7 | TouchableOpacity,
8 | ScrollView,
9 | } from "react-native";
10 |
11 | import {
12 | pluralize,
13 | timeToAge,
14 | getAutoHeight,
15 | } from "src/interface/rendering";
16 |
17 | import HTML from "react-native-render-html";
18 |
19 | import AsyncStorage from "@react-native-async-storage/async-storage";
20 | import * as requests from "src/requests";
21 | import { withLeadingAcct } from "src/interface/rendering";
22 |
23 | import PostActionBar from "src/components/posts/post-action-bar";
24 |
25 | import { MenuOption } from "react-native-popup-menu";
26 | import ContextMenu from "src/components/context-menu.js";
27 |
28 | const SCREEN_WIDTH = Dimensions.get("window").width;
29 |
30 | function getDimensionsPromises(uris) {
31 | return uris.map(attachment => new Promise(resolve => {
32 | Image.getSize(attachment.url, (width, height) => {
33 | const autoHeight = getAutoHeight(width, height, SCREEN_WIDTH)
34 |
35 | resolve([SCREEN_WIDTH, autoHeight]);
36 | });
37 | }));
38 | }
39 |
40 | function handleFavouriteFactory(state, setState) {
41 | return async () => {
42 | const newStatus = await requests.favouriteStatus(
43 | state.instance,
44 | state.data.id,
45 | state.accessToken
46 | );
47 |
48 | setState({...state,
49 | data: newStatus,
50 | });
51 | };
52 | }
53 |
54 | const PostImage = (props) => {
55 | return
63 | };
64 |
65 | export const RawPost = (props) => {
66 | const repliesCount = props.data.replies_count;
67 |
68 | let commentsText;
69 | if (repliesCount == 0 || repliesCount == undefined) {
70 | commentsText = "View comments";
71 | } else {
72 | commentsText = "View "
73 | + repliesCount
74 | + pluralize(repliesCount, " comment", " comments");
75 | }
76 |
77 | const _handleProfileButton = () => {
78 | props.navigation.navigate("ViewProfile", {
79 | profile: props.data.account,
80 | });
81 | };
82 |
83 | useEffect(() => {
84 | if (props.onRendered != null) {
85 | props.onRendered();
86 | }
87 | }, []);
88 |
89 | return (
90 |
91 |
92 |
93 |
96 |
97 |
98 |
99 | { props.data.account.acct }
100 |
101 |
102 |
105 | { props.own
106 | ? <>
107 |
110 | >
111 | : <>
112 |
115 |
118 |
121 | >
122 | }
123 |
124 |
125 | {
126 | props.data.media_attachments.length > 1 ?
127 |
133 | {
134 | props.data.media_attachments
135 | .map((attachment, i) => {
136 | return ();
141 | })
142 | }
143 |
144 | :
148 | }
149 |
156 |
157 |
165 | props.navigation.navigate("ViewComments", {
168 | postData: props.data
169 | })
170 | }>
171 |
172 | { commentsText }
173 |
174 |
175 |
176 |
177 | { timeToAge(Date.now(), (new Date(props.data.created_at)).getTime()) }
178 |
179 |
180 |
181 | );
182 | }
183 |
184 | export const PostByData = (props) => {
185 | /*
186 | * Renders a post where the data is supplied directly to the element through
187 | * its properties, as it is in a timeline.
188 | */
189 |
190 | let [state, setState] = useState({
191 | loaded: false,
192 | deleted: false,
193 | data: props.data,
194 | dimensions: []
195 | });
196 |
197 | useEffect(() => {
198 | let instance, accessToken, own;
199 | AsyncStorage
200 | .multiGet([
201 | "@user_instance",
202 | "@user_profile",
203 | "@user_token",
204 | ])
205 | .then(([instancePair, profilePair, tokenPair]) => {
206 | instance = instancePair[1];
207 | accessToken = JSON.parse(tokenPair[1]).access_token;
208 | own = state.data.account.id == JSON.parse(profilePair[1]).id;
209 | })
210 | .then(() =>
211 | Promise.all(
212 | getDimensionsPromises(props.data.media_attachments)
213 | )
214 | )
215 | .then(dimensions => {
216 | setState({...state,
217 | dimensions,
218 | instance,
219 | accessToken,
220 | own,
221 | loaded: true
222 | });
223 | });
224 | }, []);
225 |
226 | useEffect(() => {
227 | // This is run after the state has been updated
228 | props.onPostLoaded();
229 | }, [state])
230 |
231 | const _handleFavourite = async () => {
232 | let newStatus;
233 |
234 | if (!state.data.favourited) {
235 | newStatus = await requests.favouriteStatus(
236 | state.instance,
237 | state.data.id,
238 | state.accessToken
239 | );
240 | } else {
241 | newStatus = await requests.unfavouriteStatus(
242 | state.instance,
243 | state.data.id,
244 | state.accessToken
245 | );
246 | }
247 |
248 | setState({...state,
249 | data: newStatus,
250 | });
251 | };
252 |
253 | const _handleReblog = async () => {
254 | let newStatus;
255 |
256 | if (!state.data.reblogged) {
257 | newStatus = await requests.reblogStatus(
258 | state.instance,
259 | state.data.id,
260 | state.accessToken
261 | );
262 | } else {
263 | newStatus = await requests.unreblogStatus(
264 | state.instance,
265 | state.data.id,
266 | state.accessToken
267 | );
268 | }
269 |
270 | setState({...state,
271 | data: newStatus,
272 | });
273 | };
274 |
275 | const _handleBookmark = async () => {
276 | let newStatus;
277 |
278 | if (!state.data.bookmarked) {
279 | newStatus = await requests.bookmarkStatus(
280 | state.instance,
281 | state.data.id,
282 | state.accessToken
283 | );
284 | } else {
285 | newStatus = await requests.unbookmarkStatus(
286 | state.instance,
287 | state.data.id,
288 | state.accessToken
289 | );
290 | }
291 |
292 | setState({...state,
293 | data: newStatus,
294 | });
295 | };
296 |
297 | const _handleDelete = async () => {
298 | await requests.deleteStatus(
299 | state.instance,
300 | state.data.id,
301 | state.accessToken
302 | );
303 |
304 | if (props.afterDelete) {
305 | // Useful for when we need to navigate away from ViewPost
306 | props.afterDelete();
307 | } else {
308 | setState({...state,
309 | deleted: true,
310 | });
311 | }
312 | };
313 |
314 | const _handleHide = async () => {
315 | await requests.muteAccount(
316 | state.instance,
317 | state.data.account.id,
318 | state.accessToken,
319 |
320 | // Thus, only "mute" statuses
321 | { notifications: false, }
322 | );
323 |
324 | if (props.afterModerate) {
325 | props.afterModerate();
326 | }
327 | };
328 |
329 | const _handleMute = async () => {
330 | await requests.muteAccount(
331 | state.instance,
332 | state.data.account.id,
333 | state.accessToken,
334 | );
335 |
336 | if (props.afterModerate) {
337 | props.afterModerate();
338 | }
339 | };
340 |
341 | const _handleBlock = async () => {
342 | await requests.blockAccount(
343 | state.instance,
344 | state.data.account.id,
345 | state.accessToken,
346 | );
347 |
348 | if (props.afterModerate) {
349 | props.afterModerate();
350 | }
351 | };
352 |
353 | return (
354 |
355 | { state.loaded && !state.deleted ?
356 |
369 | : <>> }
370 |
371 | );
372 | }
373 |
374 | const styles = {
375 | postHeader: {
376 | display: "flex",
377 | flexDirection: "row",
378 | alignItems: "center",
379 | marginTop: SCREEN_WIDTH / 28,
380 | marginBottom: SCREEN_WIDTH / 28,
381 | marginLeft: SCREEN_WIDTH / 36,
382 | marginRight: SCREEN_WIDTH / 36
383 | },
384 | postHeaderName: {
385 | fontSize: 16,
386 | fontWeight: "bold",
387 | color: "#000",
388 | marginTop: -2
389 | },
390 | menu: {
391 | marginLeft: "auto",
392 | marginRight: SCREEN_WIDTH / 30
393 | },
394 | pfp: {
395 | width: SCREEN_WIDTH / 10,
396 | height: SCREEN_WIDTH / 10,
397 | marginRight: SCREEN_WIDTH / 28,
398 | borderRadius: 50
399 | },
400 | photo: {
401 | flex: 1,
402 | },
403 | carousel: {
404 | width: SCREEN_WIDTH,
405 | height: SCREEN_WIDTH,
406 | },
407 | carouselContainer: {
408 | display: "flex",
409 | alignItems: "center"
410 | },
411 | caption: {
412 | padding: SCREEN_WIDTH / 24,
413 | },
414 | comments: {
415 | paddingTop: SCREEN_WIDTH / 50,
416 | color: "#666",
417 | },
418 | captionDate: {
419 | fontSize: 12,
420 | color: "#666",
421 | paddingTop: 10
422 | },
423 | strong: {
424 | fontWeight: 'bold',
425 | }
426 | };
427 |
428 | const optionsStyles = {
429 | optionWrapper: { // The wrapper around a single option
430 | paddingLeft: SCREEN_WIDTH / 15,
431 | paddingTop: SCREEN_WIDTH / 30,
432 | paddingBottom: SCREEN_WIDTH / 30
433 | },
434 | optionsWrapper: { // The wrapper around all options
435 | marginTop: SCREEN_WIDTH / 20,
436 | marginBottom: SCREEN_WIDTH / 20,
437 | },
438 | optionsContainer: { // The Animated.View
439 | borderTopLeftRadius: 10,
440 | borderTopRightRadius: 10
441 | }
442 | }
443 |
--------------------------------------------------------------------------------
/src/components/pages/profile.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import {
3 | View,
4 | Dimensions,
5 | Image,
6 | Text,
7 | TouchableOpacity,
8 | FlatList,
9 | ScrollView,
10 | } from "react-native";
11 |
12 | import * as Linking from "expo-linking";
13 | import AsyncStorage from "@react-native-async-storage/async-storage";
14 |
15 | import { activeOrNot } from "src/interface/interactions";
16 | import HTML from "react-native-render-html";
17 | import {
18 | withLeadingAcct,
19 | withoutHTML,
20 | pluralize,
21 | StatusBarSpace,
22 | } from "src/interface/rendering";
23 | import * as requests from "src/requests";
24 |
25 | import GridView from "src/components/posts/grid-view";
26 |
27 | import { MenuOption } from "react-native-popup-menu";
28 | import ContextMenu from "src/components/context-menu.js";
29 |
30 | function getMutuals(yourFollowing, theirFollowers) {
31 | // Where yours and theirs are arrays of followers, as returned by the API
32 | // Returns a list of people you are following that are following some other
33 | // account
34 |
35 | const getAcct = ({acct}) => acct;
36 | const theirsAsAccts = new Set(
37 | theirFollowers.map(({acct}) => acct)
38 | );
39 |
40 | return yourFollowing.filter(x =>
41 | theirsAsAccts.has(x.acct)
42 | );
43 | }
44 |
45 | const HTMLLink = ({link}) => {
46 | let url = link.match(/https?:\/\/\w+\.\w+/);
47 |
48 | if (url) {
49 | return (
50 | {
54 | Linking.openURL(url[0]);
55 | }
56 | }>
57 | { withoutHTML(link) }
58 |
59 | );
60 | } else {
61 | return ( { withoutHTML(link) } );
62 | }
63 | }
64 |
65 | const ViewProfile = ({ navigation, route }) => {
66 | // As rendered when opened from somewhere other than the tab bar
67 | const [state, setState] = useState({
68 | loaded: false,
69 | profile: route.params.profile,
70 | });
71 |
72 | useEffect(() => {
73 | let ownProfile, instance, accessToken, domain;
74 | AsyncStorage
75 | .multiGet(["@user_profile", "@user_instance", "@user_token"])
76 | .then(([ ownProfilePair, ownDomainPair, tokenPair ]) => {
77 | ownProfile = JSON.parse(ownProfilePair[1]);
78 | instance = ownDomainPair[1];
79 | accessToken = JSON.parse(tokenPair[1]).access_token;
80 |
81 | return Promise.all([
82 | requests.fetchFollowing(
83 | instance,
84 | ownProfile.id,
85 | accessToken
86 | ),
87 | requests.fetchFollowers(
88 | instance,
89 | state.profile.id,
90 | accessToken
91 | ),
92 | requests.fetchAccountStatuses(
93 | instance,
94 | state.profile.id,
95 | accessToken
96 | )
97 | ]);
98 | })
99 | .then(([ ownFollowing, theirFollowers, posts ]) => {
100 | setState({...state,
101 | listedUsers: getMutuals(ownFollowing, theirFollowers),
102 | posts: posts,
103 | instance,
104 | ownProfile,
105 | accessToken,
106 | followed: ownFollowing.some(x => x.id == state.profile.id),
107 | loaded: true,
108 | });
109 | });
110 | }, []);
111 |
112 | const _handleFollow = async () => {
113 | if (!state.followed) {
114 | await requests.followAccount(
115 | state.instance,
116 | state.profile.id,
117 | state.accessToken
118 | );
119 | } else {
120 | await requests.unfollowAccount(
121 | state.instance,
122 | state.profile.id,
123 | state.accessToken
124 | );
125 | }
126 |
127 | setState({...state,
128 | followed: !state.followed,
129 | });
130 | };
131 |
132 | const _handleHide = async () => {
133 | await requests.muteAccount(
134 | state.instance,
135 | state.profile.id,
136 | state.accessToken,
137 |
138 | // Thus, only "mute" statuses
139 | { notifications: false, }
140 | );
141 |
142 | navigation.goBack();
143 | };
144 |
145 | const _handleMute = async () => {
146 | await requests.muteAccount(
147 | state.instance,
148 | state.profile.id,
149 | state.accessToken,
150 | );
151 |
152 | navigation.goBack();
153 | };
154 |
155 | const _handleBlock = async () => {
156 | await requests.blockAccount(
157 | state.instance,
158 | state.profile.id,
159 | state.accessToken,
160 | );
161 |
162 | navigation.goBack();
163 | };
164 |
165 | return (
166 | <>
167 | { state.loaded
168 | ?
169 |
179 |
180 | : <>>
181 | }
182 | >
183 | );
184 | }
185 |
186 | const Profile = ({ navigation }) => {
187 | const [state, setState] = useState({
188 | loaded: false,
189 | });
190 |
191 | const init = async () => {
192 | const [
193 | profilePair,
194 | instancePair,
195 | tokenPair
196 | ] = await AsyncStorage.multiGet([
197 | "@user_profile",
198 | "@user_instance",
199 | "@user_token",
200 | ]);
201 |
202 | const profile = JSON.parse(profilePair[1]);
203 | const instance = instancePair[1];
204 | const accessToken = JSON.parse(tokenPair[1]).access_token;
205 |
206 | const latestProfile =
207 | await requests.fetchProfile(instance, profile.id, accessToken);
208 | const posts =
209 | await requests.fetchAccountStatuses(instance, profile.id, accessToken);
210 | const followers =
211 | await requests.fetchFollowers(instance, profile.id, accessToken);
212 |
213 | const latestProfileString = JSON.stringify(latestProfile);
214 |
215 | // Update the profile in AsyncStorage if it's changed
216 | if(latestProfileString != JSON.stringify(profile)) {
217 | await AsyncStorage.setItem(
218 | "@user_profile",
219 | latestProfileString
220 | );
221 | }
222 |
223 | setState({...state,
224 | profile: latestProfile,
225 | posts: posts,
226 | listedUsers: followers,
227 | loaded: true,
228 | });
229 | };
230 |
231 | useEffect(() => { init(); }, []);
232 |
233 | return (
234 | <>
235 |
236 | { state.loaded
237 | ?
238 |
244 |
245 | : <>>
246 | }
247 | >
248 | )
249 | };
250 |
251 | const RawProfile = (props) => {
252 | let profileButton;
253 |
254 | /* Some profiles won't have a note, and react-native-render-html will
255 | * issue a warning if it isn't passed any content. So, if there's no
256 | * note with the account (or if it's an empty string, possibly), the
257 | * element shouldn't be rendered at all.
258 | */
259 | let noteIfPresent = <>>;
260 | if (props.profile.note != null && props.profile.note.length != "") {
261 | noteIfPresent = (
262 |
265 | );
266 | }
267 |
268 | if (props.own) {
269 | profileButton = (
270 | {
273 | props.navigation.navigate("Settings");
274 | }
275 | }>
276 |
277 | Settings
278 |
279 |
280 | );
281 | } else {
282 | profileButton = (
283 |
284 |
288 |
292 | { props.followed
293 | ? "Unfollow"
294 | : "Follow"
295 | }
296 |
297 |
298 |
299 | )
300 | }
301 |
302 | return (
303 |
304 |
305 |
306 |
309 |
310 |
312 | { props.profile.display_name}
313 |
314 |
315 | @{ props.profile.acct }
316 |
317 |
318 | {
319 | !props.own
320 | ?
324 |
327 |
330 |
333 |
334 | : <>>
335 | }
336 |
337 |
338 | { props.profile.statuses_count } posts •
339 | {
341 | const context = props.own ?
342 | "People following you"
343 | : "Your mutual followers with " + props.profile.display_name;
344 | props.navigation.navigate("UserList", {
345 | context: context,
346 | data: props.listedUsers,
347 | });
348 | }
349 | }>
350 | {
351 | props.own ?
352 | <>View followers>
353 | : <>
354 | {
355 | props.listedUsers.length
356 | + pluralize(
357 | props.listedUsers.length,
358 | " mutual",
359 | " mutuals"
360 | )
361 | }
362 | >
363 | }
364 |
365 |
366 |
367 | { noteIfPresent }
368 |
369 | { props.profile.fields
370 | ? props.profile.fields.map((field, index) => (
371 |
374 |
375 |
378 | { field.name }
379 |
380 |
381 |
382 |
383 |
384 |
385 | ))
386 | : <>>
387 | }
388 |
389 | {profileButton}
390 |
391 |
392 |
395 |
396 | );
397 | };
398 |
399 | const SCREEN_WIDTH = Dimensions.get("window").width;
400 |
401 | const styles = {
402 | jumbotron: {
403 | padding: SCREEN_WIDTH / 20,
404 | },
405 | profileHeader: {
406 | flexDirection: "row",
407 | alignItems: "center",
408 | marginBottom: SCREEN_WIDTH / 20,
409 | },
410 | displayName: {
411 | fontSize: 24
412 | },
413 | avatar: {
414 | width: SCREEN_WIDTH / 5,
415 | height: SCREEN_WIDTH / 5,
416 |
417 | borderRadius: SCREEN_WIDTH / 10,
418 | marginRight: SCREEN_WIDTH / 20,
419 | },
420 | profileHeaderIcon: {
421 | width: SCREEN_WIDTH / 12,
422 | height: SCREEN_WIDTH / 12,
423 | },
424 | profileContextContainer: {
425 | marginLeft: "auto",
426 | marginRight: SCREEN_WIDTH / 15,
427 | },
428 | accountStats: {
429 | fontSize: 14,
430 | fontWeight: "bold"
431 | },
432 | note: {
433 | fontSize: 16,
434 | marginTop: 10,
435 | },
436 | fields: {
437 | container: { marginTop: 20, },
438 | row: {
439 | padding: 10,
440 | flexDirection: "row",
441 | },
442 | cell: {
443 | name: {
444 | width: SCREEN_WIDTH / 3,
445 | },
446 | value: {
447 | width: (SCREEN_WIDTH / 3) * 2,
448 | },
449 | }
450 | },
451 | anchor: {
452 | color: "#888",
453 | textDecorationLine: "underline"
454 | },
455 | button: {
456 | container: {
457 | borderWidth: 1,
458 | borderColor: "#888",
459 | borderRadius: 5,
460 |
461 | padding: 10,
462 | marginTop: 10
463 | },
464 | dark: { backgroundColor: "black", },
465 | text: { textAlign: "center" },
466 | darkText: { color: "white", },
467 | },
468 | strong: {
469 | fontWeight: "bold",
470 | },
471 | };
472 |
473 | export { ViewProfile, Profile as default };
474 |
--------------------------------------------------------------------------------
/src/components/pages/view-comments.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import {
3 | Dimensions,
4 | View,
5 | SafeAreaView,
6 | Image,
7 | TextInput,
8 | Text
9 | } from "react-native";
10 | import { ScrollView } from "react-native-gesture-handler";
11 | import AsyncStorage from "@react-native-async-storage/async-storage";
12 |
13 | import HTML from "react-native-render-html";
14 | import {
15 | withLeadingAcct,
16 | timeToAge,
17 | StatusBarSpace
18 | } from "src/interface/rendering";
19 | import Icon from "src/components/icons.js";
20 | import { activeOrNot } from "src/interface/interactions";
21 |
22 | import TimelineView from "src/components/posts/timeline-view";
23 | import { TouchableOpacity } from "react-native-gesture-handler";
24 |
25 | import * as requests from "src/requests";
26 |
27 | import {
28 | Menu,
29 | MenuOptions,
30 | MenuOption,
31 | MenuTrigger,
32 | renderers
33 | } from "react-native-popup-menu";
34 |
35 | const { SlideInMenu } = renderers;
36 |
37 | function chunkWhile(arr, fun) {
38 | /*
39 | * Chunk a list into partitions while fun returns something truthy
40 | * > chunkWhile([1,1,1,2,2], (a, b) => a == b)
41 | * [[1,1,1], [2,2]]
42 | */
43 |
44 | let parts;
45 |
46 | if (arr == []) {
47 | return []
48 | } else {
49 | parts = [[arr[0]]];
50 | }
51 |
52 | let tail = arr.slice(1);
53 |
54 | if (tail == []) {
55 | return parts;
56 | }
57 |
58 | for (let i = 0; i < tail.length; i++) {
59 | let lastPart = parts[parts.length - 1];
60 | if (fun(tail[i], lastPart[lastPart.length - 1])) {
61 | // If fun returns something truthy, push tail[i] to the end of the
62 | // partition at the end of the new array.
63 | parts[parts.length - 1].push(tail[i])
64 | } else {
65 | // Create a new partition starting with tail[i]
66 | parts.push([tail[i]])
67 | }
68 | }
69 |
70 | return parts;
71 | }
72 |
73 | function threadify(descendants) {
74 | /*
75 | * Take a list of descendants and sort them into a 2D matrix.
76 | * The first item is the direct descendant of parentID post and the rest
77 | * are all the descendants of the direct descendant in order of id, the
78 | * way Instagram displays conversations in comments.
79 | * i.e. [[first level comment, ...descendants]]
80 | */
81 | if (descendants.length == 0) {
82 | return [];
83 | }
84 |
85 | // Sort comments in order of increasing reply id
86 | const comments = descendants.sort((first, second) => {
87 | return first.in_reply_to_id - second.in_reply_to_id;
88 | });
89 |
90 | // Return partitions of comments based on their reply id
91 | const byReply = chunkWhile(comments, (a, b) => {
92 | return a.in_reply_to_id == b.in_reply_to_id;
93 | });
94 |
95 | // Start with just the first level comments.
96 | // All these elements should be in singleton arrays so they can be
97 | // appended to.
98 | let sorted = byReply[0].map(x => [x]);
99 |
100 | let sub = byReply.slice(1); // All sub-comments
101 |
102 | // Repeat the procedure until sub is empty (i.e all comments have been
103 | // sorted)
104 | while (sub.length > 0) {
105 | sorted.forEach((thread, threadIndex) => {
106 | for (let i = 0; i < thread.length; i++) {
107 | const id = thread[i].id;
108 |
109 | // Search for comment groups with that id
110 | for(let subIndex = 0; subIndex < sub.length; subIndex++) {
111 | // All items in each partition should have the same reply id
112 | if(id == sub[subIndex][0].in_reply_to_id) {
113 | // Move the newly found thread contents to thread in
114 | // sorted
115 | sorted[threadIndex] = sorted[threadIndex].concat(sub[subIndex]);
116 | sub.splice(subIndex, 1);
117 | }
118 | }
119 | }
120 | });
121 | }
122 |
123 | return sorted;
124 | }
125 |
126 | const Comment = (props) => {
127 | const menuOptionsStyles = {
128 | optionWrapper: { // The wrapper around a single option
129 | paddingLeft: SCREEN_WIDTH / 15,
130 | paddingTop: SCREEN_WIDTH / 30,
131 | paddingBottom: SCREEN_WIDTH / 30
132 | },
133 | optionsWrapper: { // The wrapper around all options
134 | marginTop: SCREEN_WIDTH / 20,
135 | marginBottom: SCREEN_WIDTH / 20,
136 | },
137 | optionsContainer: { // The Animated.View
138 | borderTopLeftRadius: 10,
139 | borderTopRightRadius: 10
140 | }
141 | };
142 |
143 | const packs = {
144 | favourited: {
145 | active: "heart",
146 | inactive: "heart-outline",
147 | }
148 | };
149 |
150 | return (
151 |
152 |
155 |
156 |
157 |
165 |
166 |
167 |
168 |
169 | {
170 | timeToAge(
171 | Date.now(),
172 | (new Date(proIconcreated_at)).getTime()
173 | )
174 | }
175 |
176 |
177 |
184 |
185 |
186 | Reply
187 |
188 |
189 |
190 |
192 |
196 |
197 |
198 |
226 |
227 |
228 |
229 |
230 | );
231 | }
232 |
233 | const ViewComments = (props) => {
234 | let [state, setState] = useState({
235 | loaded: false,
236 | reply: "",
237 | });
238 |
239 | const postData = props.route.params.postData;
240 |
241 | useEffect(() => {
242 | let profile, instance, accessToken;
243 | AsyncStorage
244 | .multiGet([
245 | "@user_profile",
246 | "@user_instance",
247 | "@user_token",
248 | ]).then(([profilePair, instancePair, tokenPair]) => {
249 | profile = JSON.parse(profilePair[1]);
250 | instance = instancePair[1];
251 | accessToken = JSON.parse(tokenPair[1]).access_token;
252 |
253 | return requests
254 | .fetchStatusContext(instance, postData.id, accessToken)
255 | })
256 | .then(context => {
257 | setState({...state,
258 | descendants: threadify(context.descendants),
259 | profile,
260 | instance,
261 | accessToken,
262 | inReplyTo: {
263 | acct: postData.account.acct,
264 | id: postData.id,
265 | },
266 | loaded: true,
267 | });
268 | });
269 | }, []);
270 |
271 | const _fetchNewThreads = async () => {
272 | // Fetch an updated context to rerender the page
273 | const { descendants } = await requests.fetchStatusContext(
274 | state.instance,
275 | postData.id,
276 | state.accessToken,
277 | );
278 |
279 | return threadify(descendants);
280 | }
281 |
282 | const _hideStatus = id => {
283 | /*
284 | * Instead of waiting for the server to register that a status
285 | * shouldn't be retrieved next time the context is fetched, it's more
286 | * efficient to just remove it on the client side.
287 | *
288 | * Returns a new collection of threads without the comment with the
289 | * given id
290 | */
291 |
292 | return state.descendants.map(thread =>
293 | thread.filter(comment => comment.id != id)
294 | ).filter(thread => thread.length > 0);
295 | };
296 |
297 | const onReplyFactory = (acct, id) => {
298 | return () => {
299 | setState({...state,
300 | inReplyTo: {
301 | acct,
302 | id,
303 | },
304 | });
305 | }
306 | };
307 |
308 | const onFavouriteFactory = (data) => {
309 | return async () => {
310 | if(!data.favourited) {
311 | await requests.favouriteStatus(
312 | state.instance,
313 | data.id,
314 | state.accessToken
315 | )
316 | } else {
317 | await requests.unfavouriteStatus(
318 | state.instance,
319 | data.id,
320 | state.accessToken
321 | )
322 | }
323 |
324 | setState({...state,
325 | descendants: await _fetchNewThreads(),
326 | });
327 | }
328 | }
329 |
330 | // Returns a function that returns a callback for a context menu option
331 | // It's not every day you get to use third order functions
332 | const _onModerateFactory = request => id => async () => {
333 | await request(
334 | state.instance,
335 | id,
336 | state.accessToken,
337 | );
338 |
339 | setState({...state,
340 | descendants: _hideStatus(id),
341 | });
342 | };
343 |
344 | const onDeleteFactory = _onModerateFactory(requests.deleteStatus);
345 | const onMuteFactory = _onModerateFactory(requests.muteAccount);
346 | const onBlockFactory = _onModerateFactory(requests.blockAccount);
347 |
348 | const _handleCancelSubReply = () => {
349 | setState({...state,
350 | inReplyTo: {
351 | acct: postData.account.acct,
352 | id: postData.id,
353 | },
354 | });
355 | };
356 |
357 | const _handleSubmitReply = async () => {
358 | if(state.reply.length > 0) {
359 | await requests.publishStatus(
360 | state.instance,
361 | state.accessToken,
362 | {
363 | status: state.reply,
364 | in_reply_to_id: state.inReplyTo.id,
365 | }
366 | );
367 |
368 | setState({...state,
369 | // Reset the comment form
370 | inReplyTo: {
371 | acct: postData.account.acct,
372 | id: postData.id,
373 | },
374 | reply: "",
375 |
376 | // Retrieve updated context
377 | descendants: await _fetchNewThreads(),
378 | });
379 | }
380 | };
381 |
382 | const PartialComment = (props) => (
383 |
391 | );
392 |
393 | return (
394 | <>
395 | { state.loaded ?
396 |
397 |
398 | { state.loaded
399 | ?
400 |
401 |
403 |
404 |
405 | { state.descendants.length != 0
406 | ? state.descendants.map((thread, i) => {
407 | const comment = thread[0];
408 | const subs = thread.slice(1);
409 | return (
410 |
411 |
413 | {
414 | subs.map((sub, j) => {
415 | return (
416 |
419 |
421 |
422 | )
423 | })
424 | }
425 |
426 | );
427 | })
428 | :
429 |
430 | No comments
431 |
432 |
433 | }
434 |
435 |
436 | : <>>
437 | }
438 |
439 |
440 | <>
441 | { state.inReplyTo.id != postData.id
442 | ?
443 |
444 |
445 |
446 | Replying to
447 |
448 | { state.inReplyTo.acct }
449 | ...
450 |
451 |
452 |
453 | : <>>
454 | }
455 | >
456 |
457 |
460 | setState({...state, reply: c }) }/>
466 |
467 |
468 |
469 |
470 |
471 |
472 |
473 |
474 | : <>>
475 | }
476 | >
477 | );
478 | }
479 |
480 | const SCREEN_WIDTH = Dimensions.get("window").width;
481 |
482 | const styles = {
483 | bold: {
484 | fontWeight: "bold",
485 | },
486 | container: {
487 | flexDirection: "row",
488 | flexShrink: 1,
489 | marginTop: 10,
490 | marginBottom: 10,
491 | marginRight: 20,
492 | },
493 | avatar: {
494 | marginLeft: 20,
495 | marginRight: 20,
496 | width: 50,
497 | height: 50,
498 | borderRadius: 25,
499 | },
500 | contentContainer: {
501 | flexShrink: 1
502 | },
503 | parentPost: {
504 | borderBottomWidth: 1,
505 | borderBottomColor: "#CCC",
506 | marginBottom: 10,
507 | },
508 | sub: {
509 | marginLeft: SCREEN_WIDTH / 8,
510 | },
511 | commentActions: {
512 | flexDirection: "row",
513 | alignItems: "center",
514 | },
515 | actionText: {
516 | fontSize: 13,
517 | color: "#666",
518 | paddingRight: 10,
519 | },
520 | heart: {
521 | width: 15,
522 | height: 15,
523 | },
524 |
525 | form: {
526 | container: {
527 | backgroundColor: "white",
528 |
529 | borderTopWidth: 1,
530 | borderTopColor: "#CCC",
531 | },
532 | inReplyTo: {
533 | container: {
534 | padding: 10,
535 | flexDirection: "row",
536 | alignItems: "center",
537 | },
538 | message: {
539 | color: "#666",
540 | },
541 | },
542 | },
543 |
544 | commentForm: {
545 | flexDirection: "row",
546 | alignItems: "center",
547 |
548 | paddingTop: 10,
549 | paddingBottom: 10,
550 | },
551 | commentInput: {
552 | borderWidth: 0,
553 | padding: 10,
554 | flexGrow: 3,
555 | marginRight: 20,
556 | },
557 | submitContainer: {
558 | marginLeft: "auto",
559 | marginRight: 20,
560 | },
561 | commentSubmit: {
562 | width: 30,
563 | height: 30,
564 | },
565 | emptyMessage: {
566 | container: {
567 | paddingTop: 30,
568 | paddingBottom: 30,
569 | },
570 | text: {
571 | textAlign: "center",
572 | color: "#666",
573 | },
574 | },
575 | };
576 |
577 | export default ViewComments;
578 |
--------------------------------------------------------------------------------