├── 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 | Close -------------------------------------------------------------------------------- /assets/icons/bookmark.svg: -------------------------------------------------------------------------------- 1 | Bookmark -------------------------------------------------------------------------------- /assets/icons/square.svg: -------------------------------------------------------------------------------- 1 | Square -------------------------------------------------------------------------------- /assets/icons/paper-plane.svg: -------------------------------------------------------------------------------- 1 | Paper Plane -------------------------------------------------------------------------------- /assets/icons/checkbox.svg: -------------------------------------------------------------------------------- 1 | Checkbox -------------------------------------------------------------------------------- /assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | Search -------------------------------------------------------------------------------- /assets/icons/mail.svg: -------------------------------------------------------------------------------- 1 | Mail -------------------------------------------------------------------------------- /assets/icons/lock-open.svg: -------------------------------------------------------------------------------- 1 | Lock Open -------------------------------------------------------------------------------- /assets/icons/heart.svg: -------------------------------------------------------------------------------- 1 | Heart -------------------------------------------------------------------------------- /assets/icons/lock-closed.svg: -------------------------------------------------------------------------------- 1 | Lock Closed -------------------------------------------------------------------------------- /assets/icons/planet.svg: -------------------------------------------------------------------------------- 1 | Planet -------------------------------------------------------------------------------- /assets/icons/ellipsis.svg: -------------------------------------------------------------------------------- 1 | Ellipsis Horizontal -------------------------------------------------------------------------------- /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 | Home -------------------------------------------------------------------------------- /assets/icons/person.svg: -------------------------------------------------------------------------------- 1 | Person -------------------------------------------------------------------------------- /assets/icons/create.svg: -------------------------------------------------------------------------------- 1 | Create -------------------------------------------------------------------------------- /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 | Camera -------------------------------------------------------------------------------- /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 | 37 | 38 | 40 | 41 | 42 | { props.children } 43 | 44 | 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 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 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 | 20 | 22 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 58 | 62 | 69 | 70 | 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 | 20 | 22 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 58 | 62 | 66 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /assets/logo/logo-standalone.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 58 | 63 | 70 | 71 | 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 | 17 | 19 | 25 | 26 | 45 | 47 | 48 | 50 | image/svg+xml 51 | 53 | 54 | 55 | 56 | 57 | 61 | 65 | 70 | 77 | 78 | resin 85 | 86 | 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 | A screenshot of the timeline 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 | A screenshot of the login interface 37 | A screenshot of the followers feed 41 | A screenshot of the comments page 45 | A screenshot of the moderation menu on a post 49 | A screenshot of the discover page 53 | A screenshot of the search interface 57 | 58 | A screenshot of the profile page 62 | A screenshot of the notifications page 66 | A screenshot of the settings page 70 | A screenshot of the list of direct message conversations 74 | A screenshot of a conversation over direct messaging 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 | The QR code of the link to the v1.0-beta build of Resin 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 | 148 | 149 | 150 | 153 | 154 | { 155 | conversation.accounts 156 | .slice(0, 3) // Take first 3 accounts only 157 | .map(account => account.acct) 158 | .join(", ") 159 | } 160 | 161 | 162 | 163 | 164 | { 165 | conversation.accounts.map(account => 166 | 167 | 170 | 171 | 172 | @{ account.acct } 173 | 174 | 175 | { account.display_name } 176 | 177 | 178 | 179 | ) 180 | } 181 | 182 | 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 | 199 | 200 | 201 | 202 | 203 | { props.profile.acct == props.data.account.acct 204 | ? <> 205 | 210 | 211 | : <> 212 | 217 | 222 | 223 | } 224 | 225 | 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 | --------------------------------------------------------------------------------