├── .eslintignore ├── .yarnrc.yml ├── amplify ├── backend │ ├── storage │ │ └── s39381675c │ │ │ ├── storage-params.json │ │ │ ├── parameters.json │ │ │ └── s3-cloudformation-template.json │ ├── function │ │ └── SignalClonePostConfirmation │ │ │ ├── parameters.json │ │ │ ├── src │ │ │ ├── event.json │ │ │ ├── package.json │ │ │ ├── package-lock.json │ │ │ ├── custom.js │ │ │ └── index.js │ │ │ ├── amplify.state │ │ │ ├── function-parameters.json │ │ │ └── SignalClonePostConfirmation-cloudformation-template.json │ ├── tags.json │ ├── api │ │ └── SignalClone │ │ │ ├── transform.conf.json │ │ │ ├── parameters.json │ │ │ ├── resolvers │ │ │ └── README.md │ │ │ ├── schema.graphql │ │ │ └── stacks │ │ │ └── CustomResources.json │ ├── backend-config.json │ └── auth │ │ └── SignalClone │ │ ├── parameters.json │ │ ├── auth-trigger-cloudformation-template.json │ │ └── SignalClone-cloudformation-template.yml ├── .config │ └── project-config.json ├── team-provider-info.json └── cli.json ├── components ├── Message │ ├── index.ts │ └── Message.tsx ├── UserItem │ ├── index.ts │ ├── styles.ts │ └── UserItem.tsx ├── ChatRoomItem │ ├── index.ts │ ├── styles.ts │ └── ChatRoomItem.tsx ├── MessageInput │ ├── index.ts │ └── MessageInput.tsx ├── MessageReply │ ├── index.ts │ └── MessageReply.tsx ├── AudioPlayer │ ├── index.tsx │ └── AudioPlayer.tsx ├── StyledText.tsx ├── __tests__ │ └── StyledText-test.js ├── NewGroupButton │ └── index.tsx ├── Themed.tsx └── EditScreenInfo.tsx ├── screens ├── GroupInfoScreen │ ├── index.ts │ └── GroupInfoScreen.tsx ├── TabTwoScreen.tsx ├── NotFoundScreen.tsx ├── HomeScreen.tsx ├── Settings.tsx ├── ChatRoomScreen.tsx └── UsersScreen.tsx ├── assets ├── images │ ├── icon.png │ ├── favicon.png │ ├── splash.png │ └── adaptive-icon.png ├── fonts │ └── SpaceMono-Regular.ttf └── dummy-data │ ├── Users.ts │ ├── Chats.ts │ └── ChatRooms.ts ├── src └── models │ ├── schema.d.ts │ ├── index.js │ ├── index.d.ts │ └── schema.js ├── tsconfig.json ├── babel.config.js ├── metro.config.js ├── constants ├── Layout.ts └── Colors.ts ├── .expo-shared └── assets.json ├── .vscode └── settings.json ├── hooks ├── useColorScheme.ts └── useCachedResources.ts ├── types.tsx ├── .gitignore ├── navigation ├── LinkingConfiguration.ts ├── ChatRoomHeader.tsx └── index.tsx ├── app.json ├── LICENSE ├── package.json ├── utils └── crypto.js └── App.tsx /.eslintignore: -------------------------------------------------------------------------------- 1 | src/models -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /amplify/backend/storage/s39381675c/storage-params.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /components/Message/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Message'; -------------------------------------------------------------------------------- /components/UserItem/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './UserItem'; -------------------------------------------------------------------------------- /components/ChatRoomItem/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ChatRoomItem'; -------------------------------------------------------------------------------- /components/MessageInput/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './MessageInput'; -------------------------------------------------------------------------------- /components/MessageReply/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './MessageReply'; -------------------------------------------------------------------------------- /components/AudioPlayer/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./AudioPlayer"; 2 | -------------------------------------------------------------------------------- /screens/GroupInfoScreen/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './GroupInfoScreen'; -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VadimNotJustDev/SignalClone/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VadimNotJustDev/SignalClone/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VadimNotJustDev/SignalClone/HEAD/assets/images/splash.png -------------------------------------------------------------------------------- /src/models/schema.d.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from '@aws-amplify/datastore'; 2 | 3 | export declare const schema: Schema; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VadimNotJustDev/SignalClone/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VadimNotJustDev/SignalClone/HEAD/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /amplify/backend/function/SignalClonePostConfirmation/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "modules": "custom", 3 | "resourceName": "SignalClonePostConfirmation" 4 | } -------------------------------------------------------------------------------- /amplify/backend/function/SignalClonePostConfirmation/src/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "request": { 3 | "userPoolId": "testID", 4 | "userName": "testUser" 5 | }, 6 | "response": {} 7 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: ['react-native-reanimated/plugin'], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /amplify/backend/tags.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Key": "user:Stack", 4 | "Value": "{project-env}" 5 | }, 6 | { 7 | "Key": "user:Application", 8 | "Value": "{project-name}" 9 | } 10 | ] -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | const blacklist = require("metro-config/src/defaults/blacklist"); 2 | 3 | module.exports = { 4 | resolver: { 5 | blacklistRE: blacklist([/#current-cloud-backend\/.*/]), 6 | }, 7 | }; -------------------------------------------------------------------------------- /amplify/backend/function/SignalClonePostConfirmation/amplify.state: -------------------------------------------------------------------------------- 1 | { 2 | "pluginId": "amplify-nodejs-function-runtime-provider", 3 | "functionRuntime": "nodejs", 4 | "useLegacyBuild": true, 5 | "defaultEditorFile": "src/index.js" 6 | } -------------------------------------------------------------------------------- /components/StyledText.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Text, TextProps } from './Themed'; 4 | 5 | export function MonoText(props: TextProps) { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /amplify/backend/api/SignalClone/transform.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": 5, 3 | "ElasticsearchWarning": true, 4 | "ResolverConfig": { 5 | "project": { 6 | "ConflictHandler": "AUTOMERGE", 7 | "ConflictDetection": "VERSION" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /constants/Layout.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions } from 'react-native'; 2 | 3 | const width = Dimensions.get('window').width; 4 | const height = Dimensions.get('window').height; 5 | 6 | export default { 7 | window: { 8 | width, 9 | height, 10 | }, 11 | isSmallDevice: width < 375, 12 | }; 13 | -------------------------------------------------------------------------------- /amplify/backend/api/SignalClone/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "AppSyncApiName": "SignalClone", 3 | "DynamoDBBillingMode": "PAY_PER_REQUEST", 4 | "DynamoDBEnableServerSideEncryption": false, 5 | "AuthCognitoUserPoolId": { 6 | "Fn::GetAtt": [ 7 | "authSignalClone", 8 | "Outputs.UserPoolId" 9 | ] 10 | } 11 | } -------------------------------------------------------------------------------- /amplify/backend/function/SignalClonePostConfirmation/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SignalClonePostConfirmation", 3 | "version": "2.0.0", 4 | "description": "Lambda function generated by Amplify", 5 | "main": "index.js", 6 | "license": "Apache-2.0", 7 | "dependencies": { 8 | "axios": "latest" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /amplify/backend/api/SignalClone/resolvers/README.md: -------------------------------------------------------------------------------- 1 | Any resolvers that you add in this directory will override the ones automatically generated by Amplify CLI and will be directly copied to the cloud. 2 | For more information, visit [https://docs.amplify.aws/cli/graphql-transformer/resolvers](https://docs.amplify.aws/cli/graphql-transformer/resolvers) -------------------------------------------------------------------------------- /.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true, 3 | "af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true, 4 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 5 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 6 | } 7 | -------------------------------------------------------------------------------- /components/__tests__/StyledText-test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import { MonoText } from '../StyledText'; 5 | 6 | it(`renders correctly`, () => { 7 | const tree = renderer.create(Snapshot test!).toJSON(); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "amplify/.config": true, 4 | "amplify/**/*-parameters.json": true, 5 | "amplify/**/amplify.state": true, 6 | "amplify/**/transform.conf.json": true, 7 | "amplify/#current-cloud-backend": true, 8 | "amplify/backend/amplify-meta.json": true, 9 | "amplify/backend/awscloudformation": true 10 | } 11 | } -------------------------------------------------------------------------------- /src/models/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { initSchema } from '@aws-amplify/datastore'; 3 | import { schema } from './schema'; 4 | 5 | const MessageStatus = { 6 | "SENT": "SENT", 7 | "DELIVERED": "DELIVERED", 8 | "READ": "READ" 9 | }; 10 | 11 | const { Message, ChatRoom, ChatRoomUser, User } = initSchema(schema); 12 | 13 | export { 14 | Message, 15 | ChatRoom, 16 | ChatRoomUser, 17 | User, 18 | MessageStatus 19 | }; -------------------------------------------------------------------------------- /amplify/.config/project-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "providers": [ 3 | "awscloudformation" 4 | ], 5 | "projectName": "SignalClone", 6 | "version": "3.1", 7 | "frontend": "javascript", 8 | "javascript": { 9 | "framework": "react-native", 10 | "config": { 11 | "SourceDir": "src", 12 | "DistributionDir": "/", 13 | "BuildCommand": "npm run-script build", 14 | "StartCommand": "npm run-script start" 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | import { ColorSchemeName, useColorScheme as _useColorScheme } from 'react-native'; 2 | 3 | // The useColorScheme value is always either light or dark, but the built-in 4 | // type suggests that it can be null. This will not happen in practice, so this 5 | // makes it a bit easier to work with. 6 | export default function useColorScheme(): NonNullable { 7 | return _useColorScheme() as NonNullable; 8 | } 9 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | const tintColorLight = '#2f95dc'; 2 | const tintColorDark = '#fff'; 3 | 4 | export default { 5 | light: { 6 | text: '#000', 7 | background: '#fff', 8 | tint: tintColorLight, 9 | tabIconDefault: '#ccc', 10 | tabIconSelected: tintColorLight, 11 | }, 12 | dark: { 13 | text: '#fff', 14 | background: '#000', 15 | tint: tintColorDark, 16 | tabIconDefault: '#ccc', 17 | tabIconSelected: tintColorDark, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /types.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about using TypeScript with React Navigation: 3 | * https://reactnavigation.org/docs/typescript/ 4 | */ 5 | 6 | export type RootStackParamList = { 7 | Root: undefined; 8 | NotFound: undefined; 9 | }; 10 | 11 | export type BottomTabParamList = { 12 | TabOne: undefined; 13 | TabTwo: undefined; 14 | }; 15 | 16 | export type TabOneParamList = { 17 | TabOneScreen: undefined; 18 | }; 19 | 20 | export type TabTwoParamList = { 21 | TabTwoScreen: undefined; 22 | }; 23 | -------------------------------------------------------------------------------- /components/NewGroupButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesome } from "@expo/vector-icons"; 2 | import React from "react"; 3 | import { Pressable, View, Text } from "react-native"; 4 | 5 | const NewGroupButton = ({ onPress }) => { 6 | return ( 7 | 8 | 9 | 10 | New group 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default NewGroupButton; 17 | -------------------------------------------------------------------------------- /amplify/backend/function/SignalClonePostConfirmation/function-parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "trigger": true, 3 | "modules": [ 4 | "custom" 5 | ], 6 | "parentResource": "SignalClone", 7 | "functionName": "SignalClonePostConfirmation", 8 | "resourceName": "SignalClonePostConfirmation", 9 | "parentStack": "auth", 10 | "triggerEnvs": [], 11 | "triggerDir": "/snapshot/node_modules/amplify-category-auth/provider-utils/awscloudformation/triggers/PostConfirmation", 12 | "triggerTemplate": "PostConfirmation.json.ejs", 13 | "triggerEventPath": "PostConfirmation.event.json", 14 | "roleName": "SignalClonePostConfirmation", 15 | "skipEdit": true, 16 | "enableCors": false 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | npm-debug.* 4 | *.jks 5 | *.p8 6 | *.p12 7 | *.key 8 | *.mobileprovision 9 | *.orig.* 10 | web-build/ 11 | 12 | # macOS 13 | .DS_Store 14 | 15 | #amplify-do-not-edit-begin 16 | amplify/\#current-cloud-backend 17 | amplify/.config/local-* 18 | amplify/logs 19 | amplify/mock-data 20 | amplify/backend/amplify-meta.json 21 | amplify/backend/awscloudformation 22 | amplify/backend/.temp 23 | build/ 24 | dist/ 25 | node_modules/ 26 | aws-exports.js 27 | awsconfiguration.json 28 | amplifyconfiguration.json 29 | amplifyconfiguration.dart 30 | amplify-build-config.json 31 | amplify-gradle-config.json 32 | amplifytools.xcconfig 33 | .secret-* 34 | #amplify-do-not-edit-end 35 | -------------------------------------------------------------------------------- /navigation/LinkingConfiguration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about deep linking with React Navigation 3 | * https://reactnavigation.org/docs/deep-linking 4 | * https://reactnavigation.org/docs/configuring-links 5 | */ 6 | 7 | import * as Linking from 'expo-linking'; 8 | 9 | export default { 10 | prefixes: [Linking.makeUrl('/')], 11 | config: { 12 | screens: { 13 | Root: { 14 | screens: { 15 | TabOne: { 16 | screens: { 17 | TabOneScreen: 'one', 18 | }, 19 | }, 20 | TabTwo: { 21 | screens: { 22 | TabTwoScreen: 'two', 23 | }, 24 | }, 25 | }, 26 | }, 27 | NotFound: '*', 28 | }, 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /amplify/backend/function/SignalClonePostConfirmation/src/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SignalClonePostConfirmation", 3 | "version": "2.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "axios": { 8 | "version": "0.23.0", 9 | "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", 10 | "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", 11 | "requires": { 12 | "follow-redirects": "^1.14.4" 13 | } 14 | }, 15 | "follow-redirects": { 16 | "version": "1.14.4", 17 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", 18 | "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "SignalClone", 4 | "slug": "SignalClone", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/images/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 | "android": { 25 | "adaptiveIcon": { 26 | "foregroundImage": "./assets/images/adaptive-icon.png", 27 | "backgroundColor": "#ffffff" 28 | } 29 | }, 30 | "web": { 31 | "favicon": "./assets/images/favicon.png" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /screens/TabTwoScreen.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | 4 | import EditScreenInfo from '../components/EditScreenInfo'; 5 | import { Text, View } from '../components/Themed'; 6 | 7 | export default function TabTwoScreen() { 8 | return ( 9 | 10 | Tab Two 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | const styles = StyleSheet.create({ 18 | container: { 19 | flex: 1, 20 | alignItems: 'center', 21 | justifyContent: 'center', 22 | }, 23 | title: { 24 | fontSize: 20, 25 | fontWeight: 'bold', 26 | }, 27 | separator: { 28 | marginVertical: 30, 29 | height: 1, 30 | width: '80%', 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /components/UserItem/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | const styles = StyleSheet.create({ 4 | container: { 5 | flexDirection: 'row', 6 | padding: 10, 7 | }, 8 | image: { 9 | height: 50, 10 | width: 50, 11 | borderRadius: 30, 12 | marginRight: 10, 13 | }, 14 | badgeContainer: { 15 | backgroundColor: '#3777f0', 16 | width: 20, 17 | height: 20, 18 | borderRadius: 10, 19 | borderWidth: 1, 20 | borderColor: 'white', 21 | justifyContent: 'center', 22 | alignItems: 'center', 23 | position: 'absolute', 24 | left: 45, 25 | top: 10, 26 | }, 27 | badgeText: { 28 | color: 'white', 29 | fontSize: 12 30 | }, 31 | rightContainer: { 32 | flex: 1, 33 | justifyContent: 'center', 34 | }, 35 | row: { 36 | flexDirection: 'row', 37 | justifyContent: 'space-between', 38 | }, 39 | name: { 40 | fontWeight: 'bold', 41 | fontSize: 18, 42 | marginBottom: 3, 43 | }, 44 | text: { 45 | color: 'grey', 46 | } 47 | }); 48 | 49 | export default styles; -------------------------------------------------------------------------------- /components/ChatRoomItem/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | const styles = StyleSheet.create({ 4 | container: { 5 | flexDirection: 'row', 6 | padding: 10, 7 | }, 8 | image: { 9 | height: 50, 10 | width: 50, 11 | borderRadius: 30, 12 | marginRight: 10, 13 | }, 14 | badgeContainer: { 15 | backgroundColor: '#3777f0', 16 | width: 20, 17 | height: 20, 18 | borderRadius: 10, 19 | borderWidth: 1, 20 | borderColor: 'white', 21 | justifyContent: 'center', 22 | alignItems: 'center', 23 | position: 'absolute', 24 | left: 45, 25 | top: 10, 26 | }, 27 | badgeText: { 28 | color: 'white', 29 | fontSize: 12 30 | }, 31 | rightContainer: { 32 | flex: 1, 33 | justifyContent: 'center', 34 | }, 35 | row: { 36 | flexDirection: 'row', 37 | justifyContent: 'space-between', 38 | }, 39 | name: { 40 | fontWeight: 'bold', 41 | fontSize: 18, 42 | marginBottom: 3, 43 | }, 44 | text: { 45 | color: 'grey', 46 | } 47 | }); 48 | 49 | export default styles; -------------------------------------------------------------------------------- /components/UserItem/UserItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Text, Image, View, Pressable } from "react-native"; 3 | import { useNavigation } from "@react-navigation/core"; 4 | import styles from "./styles"; 5 | import { Feather } from "@expo/vector-icons"; 6 | 7 | export default function UserItem({ 8 | user, 9 | onPress, 10 | onLongPress, 11 | isSelected, 12 | isAdmin = false, 13 | }) { 14 | // null | false | true 15 | return ( 16 | 21 | 22 | 23 | 24 | {user.name} 25 | {isAdmin && admin} 26 | 27 | 28 | {isSelected !== undefined && ( 29 | 34 | )} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /hooks/useCachedResources.ts: -------------------------------------------------------------------------------- 1 | import { Ionicons } from '@expo/vector-icons'; 2 | import * as Font from 'expo-font'; 3 | import * as SplashScreen from 'expo-splash-screen'; 4 | import * as React from 'react'; 5 | 6 | export default function useCachedResources() { 7 | const [isLoadingComplete, setLoadingComplete] = React.useState(false); 8 | 9 | // Load any resources or data that we need prior to rendering the app 10 | React.useEffect(() => { 11 | async function loadResourcesAndDataAsync() { 12 | try { 13 | SplashScreen.preventAutoHideAsync(); 14 | 15 | // Load fonts 16 | await Font.loadAsync({ 17 | ...Ionicons.font, 18 | 'space-mono': require('../assets/fonts/SpaceMono-Regular.ttf'), 19 | }); 20 | } catch (e) { 21 | // We might want to provide this error information to an error reporting service 22 | console.warn(e); 23 | } finally { 24 | setLoadingComplete(true); 25 | SplashScreen.hideAsync(); 26 | } 27 | } 28 | 29 | loadResourcesAndDataAsync(); 30 | }, []); 31 | 32 | return isLoadingComplete; 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Vadim Savin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /screens/NotFoundScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StackScreenProps } from '@react-navigation/stack'; 2 | import * as React from 'react'; 3 | import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; 4 | 5 | import { RootStackParamList } from '../types'; 6 | 7 | export default function NotFoundScreen({ 8 | navigation, 9 | }: StackScreenProps) { 10 | return ( 11 | 12 | This screen doesn't exist. 13 | navigation.replace('Root')} style={styles.link}> 14 | Go to home screen! 15 | 16 | 17 | ); 18 | } 19 | 20 | const styles = StyleSheet.create({ 21 | container: { 22 | flex: 1, 23 | backgroundColor: '#fff', 24 | alignItems: 'center', 25 | justifyContent: 'center', 26 | padding: 20, 27 | }, 28 | title: { 29 | fontSize: 20, 30 | fontWeight: 'bold', 31 | }, 32 | link: { 33 | marginTop: 15, 34 | paddingVertical: 15, 35 | }, 36 | linkText: { 37 | fontSize: 14, 38 | color: '#2e78b7', 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /amplify/backend/function/SignalClonePostConfirmation/src/custom.js: -------------------------------------------------------------------------------- 1 | const aws = require("aws-sdk"); 2 | const ddb = new aws.DynamoDB(); 3 | 4 | const tableName = process.env.USERTABLE; 5 | 6 | exports.handler = async (event) => { 7 | // event event.request.userAttributes.(sub, email) 8 | // insert code to be executed by your lambda trigger 9 | 10 | if (!event?.request?.userAttributes?.sub){ 11 | console.log("No sub provided"); 12 | return; 13 | } 14 | 15 | const now = new Date(); 16 | const timestamp = now.getTime(); 17 | 18 | const userItem = { 19 | __typename: { S: 'User' }, 20 | _lastChangedAt: { N: timestamp.toString() }, 21 | _version: { N: "1" }, 22 | createdAt: { S: now.toISOString() }, 23 | updatedAt: { S: now.toISOString() }, 24 | id: { S: event.request.userAttributes.sub }, 25 | name: { S: event.request.userAttributes.email }, 26 | } 27 | 28 | const params = { 29 | Item: userItem, 30 | TableName: tableName 31 | }; 32 | 33 | // save a new user to DynamoDB 34 | try { 35 | await ddb.putItem(params).promise(); 36 | console.log("success"); 37 | } catch (e) { 38 | console.log(e) 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /amplify/team-provider-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "staging": { 3 | "awscloudformation": { 4 | "AuthRoleName": "amplify-signalclone-staging-142023-authRole", 5 | "UnauthRoleArn": "arn:aws:iam::704219588443:role/amplify-signalclone-staging-142023-unauthRole", 6 | "AuthRoleArn": "arn:aws:iam::704219588443:role/amplify-signalclone-staging-142023-authRole", 7 | "Region": "eu-west-1", 8 | "DeploymentBucketName": "amplify-signalclone-staging-142023-deployment", 9 | "UnauthRoleName": "amplify-signalclone-staging-142023-unauthRole", 10 | "StackName": "amplify-signalclone-staging-142023", 11 | "StackId": "arn:aws:cloudformation:eu-west-1:704219588443:stack/amplify-signalclone-staging-142023/c22c1eb0-01c1-11ec-a710-0a765228daef", 12 | "AmplifyAppId": "d28kwkid6yb4zl" 13 | }, 14 | "categories": { 15 | "auth": { 16 | "SignalClone": {} 17 | }, 18 | "function": { 19 | "SignalClonePostConfirmation": { 20 | "deploymentBucketName": "amplify-signalclone-staging-142023-deployment", 21 | "s3Key": "amplify-builds/SignalClonePostConfirmation-6f504a5254326c567a71-build.zip" 22 | } 23 | } 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /amplify/backend/function/SignalClonePostConfirmation/src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 3 | * 4 | * This CloudFormation Trigger creates a handler which awaits the other handlers 5 | * specified in the `MODULES` env var, located at `./${MODULE}`. 6 | */ 7 | 8 | /** 9 | * The names of modules to load are stored as a comma-delimited string in the 10 | * `MODULES` env var. 11 | */ 12 | const moduleNames = process.env.MODULES.split(','); 13 | /** 14 | * The array of imported modules. 15 | */ 16 | const modules = moduleNames.map(name => require(`./${name}`)); 17 | 18 | /** 19 | * This async handler iterates over the given modules and awaits them. 20 | * 21 | * @see https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html#nodejs-handler-async 22 | * 23 | * @param {object} event 24 | * 25 | * The event that triggered this Lambda. 26 | * 27 | * @returns 28 | * 29 | * The handler response. 30 | */ 31 | exports.handler = async event => { 32 | /** 33 | * Instead of naively iterating over all handlers, run them concurrently with 34 | * `await Promise.all(...)`. This would otherwise just be determined by the 35 | * order of names in the `MODULES` var. 36 | */ 37 | await Promise.all(modules.map(module => module.handler(event))); 38 | return event; 39 | }; 40 | -------------------------------------------------------------------------------- /screens/HomeScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | 3 | import { View, StyleSheet, FlatList } from "react-native"; 4 | import { Auth, DataStore } from "aws-amplify"; 5 | import { ChatRoom, ChatRoomUser } from "../src/models"; 6 | import ChatRoomItem from "../components/ChatRoomItem"; 7 | 8 | export default function TabOneScreen() { 9 | const [chatRooms, setChatRooms] = useState([]); 10 | 11 | useEffect(() => { 12 | const fetchChatRooms = async () => { 13 | const userData = await Auth.currentAuthenticatedUser(); 14 | 15 | const chatRooms = (await DataStore.query(ChatRoomUser)) 16 | .filter( 17 | (chatRoomUser) => chatRoomUser.user.id === userData.attributes.sub 18 | ) 19 | .map((chatRoomUser) => chatRoomUser.chatroom); 20 | 21 | setChatRooms(chatRooms); 22 | }; 23 | fetchChatRooms(); 24 | }, []); 25 | 26 | 27 | 28 | return ( 29 | 30 | } 33 | showsVerticalScrollIndicator={false} 34 | /> 35 | 36 | ); 37 | } 38 | 39 | const styles = StyleSheet.create({ 40 | page: { 41 | backgroundColor: "white", 42 | flex: 1, 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /amplify/cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "graphqltransformer": { 4 | "addmissingownerfields": true, 5 | "improvepluralization": true, 6 | "validatetypenamereservedwords": true, 7 | "useexperimentalpipelinedtransformer": false, 8 | "enableiterativegsiupdates": true, 9 | "secondarykeyasgsi": true, 10 | "skipoverridemutationinputtypes": true 11 | }, 12 | "frontend-ios": { 13 | "enablexcodeintegration": true 14 | }, 15 | "auth": { 16 | "enablecaseinsensitivity": true, 17 | "useinclusiveterminology": true, 18 | "breakcirculardependency": true 19 | }, 20 | "codegen": { 21 | "useappsyncmodelgenplugin": true, 22 | "usedocsgeneratorplugin": true, 23 | "usetypesgeneratorplugin": true, 24 | "cleangeneratedmodelsdirectory": true, 25 | "retaincasestyle": true, 26 | "addtimestampfields": true, 27 | "handlelistnullabilitytransparently": true, 28 | "emitauthprovider": true, 29 | "generateindexrules": true, 30 | "enabledartnullsafety": true 31 | }, 32 | "appsync": { 33 | "generategraphqlpermissions": true 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /amplify/backend/storage/s39381675c/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "bucketName": "signalcloneeb35d96961524b3f953ede84b4f5c284", 3 | "authPolicyName": "s3_amplify_9381675c", 4 | "unauthPolicyName": "s3_amplify_9381675c", 5 | "authRoleName": { 6 | "Ref": "AuthRoleName" 7 | }, 8 | "unauthRoleName": { 9 | "Ref": "UnauthRoleName" 10 | }, 11 | "selectedGuestPermissions": [ 12 | "s3:GetObject", 13 | "s3:ListBucket" 14 | ], 15 | "selectedAuthenticatedPermissions": [ 16 | "s3:PutObject", 17 | "s3:GetObject", 18 | "s3:ListBucket" 19 | ], 20 | "s3PermissionsAuthenticatedPublic": "s3:PutObject,s3:GetObject", 21 | "s3PublicPolicy": "Public_policy_97ebde3a", 22 | "s3PermissionsAuthenticatedUploads": "s3:PutObject", 23 | "s3UploadsPolicy": "Uploads_policy_97ebde3a", 24 | "s3PermissionsAuthenticatedProtected": "s3:PutObject,s3:GetObject", 25 | "s3ProtectedPolicy": "Protected_policy_97ebde3a", 26 | "s3PermissionsAuthenticatedPrivate": "s3:PutObject,s3:GetObject", 27 | "s3PrivatePolicy": "Private_policy_97ebde3a", 28 | "AuthenticatedAllowList": "ALLOW", 29 | "s3ReadPolicy": "read_policy_97ebde3a", 30 | "s3PermissionsGuestPublic": "DISALLOW", 31 | "s3PermissionsGuestUploads": "DISALLOW", 32 | "GuestAllowList": "DISALLOW", 33 | "triggerFunction": "NONE" 34 | } -------------------------------------------------------------------------------- /assets/dummy-data/Users.ts: -------------------------------------------------------------------------------- 1 | export default [{ 2 | id: 'u1', 3 | name: 'Vadim', 4 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 5 | status: "Hello there, how are you" 6 | }, { 7 | id: 'u2', 8 | name: 'Elon Musk', 9 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/elon.png', 10 | }, { 11 | id: 'u3', 12 | name: 'Jeff', 13 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/jeff.jpeg', 14 | }, { 15 | id: 'u4', 16 | name: 'Zuck', 17 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/zuck.jpeg', 18 | }, { 19 | id: 'u5', 20 | name: 'Graham', 21 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/graham.jpg', 22 | }, { 23 | id: 'u6', 24 | name: 'Biahaze', 25 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/biahaze.jpg', 26 | }, { 27 | id: 'u7', 28 | name: 'Sus?', 29 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/1.jpg', 30 | }, { 31 | id: 'u8', 32 | name: 'Daniel', 33 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/2.jpg', 34 | }, { 35 | id: 'u9', 36 | name: 'Carlos', 37 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/3.jpg', 38 | }, { 39 | id: 'u10', 40 | name: 'Angelina Jolie', 41 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/4.jpg', 42 | }] 43 | -------------------------------------------------------------------------------- /components/Themed.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about Light and Dark modes: 3 | * https://docs.expo.io/guides/color-schemes/ 4 | */ 5 | 6 | import * as React from 'react'; 7 | import { Text as DefaultText, View as DefaultView } from 'react-native'; 8 | 9 | import Colors from '../constants/Colors'; 10 | import useColorScheme from '../hooks/useColorScheme'; 11 | 12 | export function useThemeColor( 13 | props: { light?: string; dark?: string }, 14 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 15 | ) { 16 | const theme = useColorScheme(); 17 | const colorFromProps = props[theme]; 18 | 19 | if (colorFromProps) { 20 | return colorFromProps; 21 | } else { 22 | return Colors[theme][colorName]; 23 | } 24 | } 25 | 26 | type ThemeProps = { 27 | lightColor?: string; 28 | darkColor?: string; 29 | }; 30 | 31 | export type TextProps = ThemeProps & DefaultText['props']; 32 | export type ViewProps = ThemeProps & DefaultView['props']; 33 | 34 | export function Text(props: TextProps) { 35 | const { style, lightColor, darkColor, ...otherProps } = props; 36 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); 37 | 38 | return ; 39 | } 40 | 41 | export function View(props: ViewProps) { 42 | const { style, lightColor, darkColor, ...otherProps } = props; 43 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); 44 | 45 | return ; 46 | } 47 | -------------------------------------------------------------------------------- /amplify/backend/api/SignalClone/schema.graphql: -------------------------------------------------------------------------------- 1 | enum MessageStatus { 2 | SENT 3 | DELIVERED 4 | READ 5 | } 6 | 7 | type Message @model @auth(rules: [{allow: public}]) @key(name: "byUser", fields: ["userID"]) @key(name: "byChatRoom", fields: ["chatroomID"]) { 8 | id: ID! 9 | content: String 10 | userID: ID 11 | chatroomID: ID 12 | image: String 13 | audio: String 14 | status: MessageStatus 15 | replyToMessageID: ID 16 | forUserId: ID 17 | } 18 | 19 | type ChatRoom @model @auth(rules: [{allow: public}]) { 20 | id: ID! 21 | newMessages: Int 22 | LastMessage: Message @connection 23 | Messages: [Message] @connection(keyName: "byChatRoom", fields: ["id"]) 24 | ChatRoomUsers: [ChatRoomUser] @connection(keyName: "byChatRoom", fields: ["id"]) 25 | Admin: User @connection 26 | name: String 27 | imageUri: String 28 | } 29 | 30 | type User @model @auth(rules: [{allow: public}]) { 31 | id: ID! 32 | name: String! 33 | imageUri: String 34 | status: String 35 | Messages: [Message] @connection(keyName: "byUser", fields: ["id"]) 36 | chatrooms: [ChatRoomUser] @connection(keyName: "byUser", fields: ["id"]) 37 | lastOnlineAt: AWSTimestamp 38 | publicKey: String 39 | } 40 | 41 | type ChatRoomUser @model(queries: null) @key(name: "byChatRoom", fields: ["chatroomID", "userID"]) @key(name: "byUser", fields: ["userID", "chatroomID"]) @auth(rules: [{allow: public}, {allow: public}]) { 42 | id: ID! 43 | chatroomID: ID! 44 | userID: ID! 45 | chatroom: ChatRoom! @connection(fields: ["chatroomID"]) 46 | user: User! @connection(fields: ["userID"]) 47 | } 48 | -------------------------------------------------------------------------------- /assets/dummy-data/Chats.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | id: '1', 3 | users: [{ 4 | id: 'u1', 5 | name: 'Vadim', 6 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 7 | }, { 8 | id: 'u2', 9 | name: 'Elon Musk', 10 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/elon.png', 11 | }], 12 | messages: [{ 13 | id: 'm1', 14 | content: 'How are you, Elon!', 15 | createdAt: '2020-10-10T12:48:00.000Z', 16 | user: { 17 | id: 'u1', 18 | name: 'Vadim', 19 | }, 20 | }, { 21 | id: 'm2', 22 | content: 'I am good, good', 23 | createdAt: '2020-10-03T14:49:00.000Z', 24 | user: { 25 | id: 'u2', 26 | name: 'Elon Musk', 27 | }, 28 | }, { 29 | id: 'm3', 30 | content: 'What about you?', 31 | createdAt: '2020-10-03T14:49:40.000Z', 32 | user: { 33 | id: 'u2', 34 | name: 'Elon Musk', 35 | }, 36 | }, { 37 | id: 'm4', 38 | content: 'Good as well, preparing for the stream now.', 39 | createdAt: '2020-10-03T14:50:00.000Z', 40 | user: { 41 | id: 'u1', 42 | name: 'Vadim', 43 | }, 44 | }, { 45 | id: 'm5', 46 | content: 'How is SpaceX doing?', 47 | createdAt: '2020-10-03T14:51:00.000Z', 48 | user: { 49 | id: 'u1', 50 | name: 'Vadim', 51 | }, 52 | }, { 53 | id: 'm6', 54 | content: 'going to the Moooooon', 55 | createdAt: '2020-10-03T14:49:00.000Z', 56 | user: { 57 | id: 'u2', 58 | name: 'Elon Musk', 59 | }, 60 | }, { 61 | id: 'm7', 62 | content: 'btw, SpaceX is interested in buying notJust.dev!', 63 | createdAt: '2020-10-03T14:53:00.000Z', 64 | user: { 65 | id: 'u2', 66 | name: 'Elon Musk', 67 | }, 68 | }] 69 | } 70 | 71 | -------------------------------------------------------------------------------- /amplify/backend/api/SignalClone/stacks/CustomResources.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "An auto-generated nested stack.", 4 | "Metadata": {}, 5 | "Parameters": { 6 | "AppSyncApiId": { 7 | "Type": "String", 8 | "Description": "The id of the AppSync API associated with this project." 9 | }, 10 | "AppSyncApiName": { 11 | "Type": "String", 12 | "Description": "The name of the AppSync API", 13 | "Default": "AppSyncSimpleTransform" 14 | }, 15 | "env": { 16 | "Type": "String", 17 | "Description": "The environment name. e.g. Dev, Test, or Production", 18 | "Default": "NONE" 19 | }, 20 | "S3DeploymentBucket": { 21 | "Type": "String", 22 | "Description": "The S3 bucket containing all deployment assets for the project." 23 | }, 24 | "S3DeploymentRootKey": { 25 | "Type": "String", 26 | "Description": "An S3 key relative to the S3DeploymentBucket that points to the root\nof the deployment directory." 27 | } 28 | }, 29 | "Resources": { 30 | "EmptyResource": { 31 | "Type": "Custom::EmptyResource", 32 | "Condition": "AlwaysFalse" 33 | } 34 | }, 35 | "Conditions": { 36 | "HasEnvironmentParameter": { 37 | "Fn::Not": [ 38 | { 39 | "Fn::Equals": [ 40 | { 41 | "Ref": "env" 42 | }, 43 | "NONE" 44 | ] 45 | } 46 | ] 47 | }, 48 | "AlwaysFalse": { 49 | "Fn::Equals": ["true", "false"] 50 | } 51 | }, 52 | "Outputs": { 53 | "EmptyOutput": { 54 | "Description": "An empty output. You may delete this if you have at least one resource above.", 55 | "Value": "" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /amplify/backend/backend-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "SignalClone": { 4 | "service": "Cognito", 5 | "providerPlugin": "awscloudformation", 6 | "dependsOn": [ 7 | { 8 | "category": "function", 9 | "resourceName": "SignalClonePostConfirmation", 10 | "triggerProvider": "Cognito", 11 | "attributes": [ 12 | "Arn", 13 | "Name" 14 | ] 15 | } 16 | ], 17 | "customAuth": false, 18 | "frontendAuthConfig": { 19 | "loginMechanism": [], 20 | "signupAttributes": [ 21 | "EMAIL" 22 | ], 23 | "passwordProtectionSettings": { 24 | "passwordPolicyMinLength": 8, 25 | "passwordPolicyCharacters": [] 26 | }, 27 | "mfaConfiguration": "OFF", 28 | "mfaTypes": [ 29 | "SMS" 30 | ] 31 | } 32 | } 33 | }, 34 | "api": { 35 | "SignalClone": { 36 | "service": "AppSync", 37 | "providerPlugin": "awscloudformation", 38 | "output": { 39 | "authConfig": { 40 | "defaultAuthentication": { 41 | "authenticationType": "API_KEY", 42 | "apiKeyConfig": { 43 | "apiKeyExpirationDays": 30, 44 | "description": "api key description" 45 | } 46 | }, 47 | "additionalAuthenticationProviders": [ 48 | { 49 | "authenticationType": "AWS_IAM" 50 | }, 51 | { 52 | "authenticationType": "AMAZON_COGNITO_USER_POOLS", 53 | "userPoolConfig": { 54 | "userPoolId": "authSignalClone" 55 | } 56 | } 57 | ] 58 | } 59 | } 60 | } 61 | }, 62 | "function": { 63 | "SignalClonePostConfirmation": { 64 | "build": true, 65 | "providerPlugin": "awscloudformation", 66 | "service": "Lambda" 67 | } 68 | }, 69 | "storage": { 70 | "s39381675c": { 71 | "service": "S3", 72 | "providerPlugin": "awscloudformation" 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /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 | "test": "jest --watchAll" 10 | }, 11 | "jest": { 12 | "preset": "jest-expo" 13 | }, 14 | "dependencies": { 15 | "@expo/react-native-action-sheet": "^3.11.0", 16 | "@expo/vector-icons": "^12.0.0", 17 | "@react-native-async-storage/async-storage": "~1.15.0", 18 | "@react-native-community/masked-view": "0.1.10", 19 | "@react-native-community/netinfo": "^6.0.0", 20 | "@react-navigation/bottom-tabs": "5.11.2", 21 | "@react-navigation/native": "~5.8.10", 22 | "@react-navigation/stack": "~5.12.8", 23 | "@stablelib/base64": "^1.0.1", 24 | "@stablelib/utf8": "^1.0.1", 25 | "aws-amplify": "^4.2.4", 26 | "aws-amplify-react-native": "^5.0.3", 27 | "expo": "~42.0.1", 28 | "expo-asset": "~8.3.2", 29 | "expo-av": "~9.2.3", 30 | "expo-constants": "~11.0.1", 31 | "expo-font": "~9.2.1", 32 | "expo-image-picker": "~10.2.2", 33 | "expo-linking": "~2.3.1", 34 | "expo-random": "~11.2.0", 35 | "expo-splash-screen": "~0.11.2", 36 | "expo-status-bar": "~1.0.4", 37 | "expo-web-browser": "~9.2.0", 38 | "moment": "^2.29.1", 39 | "react": "16.13.1", 40 | "react-dom": "16.13.1", 41 | "react-native": "https://github.com/expo/react-native/archive/sdk-42.0.0.tar.gz", 42 | "react-native-emoji-selector": "^0.2.0", 43 | "react-native-gesture-handler": "~1.10.2", 44 | "react-native-reanimated": "~2.2.0", 45 | "react-native-safe-area-context": "3.2.0", 46 | "react-native-screens": "~3.4.0", 47 | "react-native-web": "~0.13.12", 48 | "tweetnacl": "^0.14.5", 49 | "uuid": "^3.4.0" 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.9.0", 53 | "@expo/metro-config": "^0.1.84", 54 | "@types/react": "~16.9.35", 55 | "@types/react-native": "~0.63.2", 56 | "jest-expo": "~41.0.0-beta.0", 57 | "typescript": "~4.0.0" 58 | }, 59 | "private": true 60 | } 61 | -------------------------------------------------------------------------------- /screens/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { Auth, DataStore } from "aws-amplify"; 2 | import React from "react"; 3 | import { View, Text, Pressable, Alert } from "react-native"; 4 | import { generateKeyPair } from "../utils/crypto"; 5 | import AsyncStorage from "@react-native-async-storage/async-storage"; 6 | import { User as UserModel } from "../src/models"; 7 | 8 | export const PRIVATE_KEY = "PRIVATE_KEY"; 9 | 10 | const Settings = () => { 11 | const logOut = async () => { 12 | await DataStore.clear(); 13 | Auth.signOut(); 14 | }; 15 | 16 | const updateKeyPair = async () => { 17 | // generate private/public key 18 | const { publicKey, secretKey } = generateKeyPair(); 19 | console.log(publicKey, secretKey); 20 | 21 | // save private key to Async storage 22 | await AsyncStorage.setItem(PRIVATE_KEY, secretKey.toString()); 23 | console.log("secret key was saved"); 24 | 25 | // save public key to UserModel in Datastore 26 | const userData = await Auth.currentAuthenticatedUser(); 27 | const dbUser = await DataStore.query(UserModel, userData.attributes.sub); 28 | 29 | if (!dbUser) { 30 | Alert.alert("User not found!"); 31 | return; 32 | } 33 | 34 | await DataStore.save( 35 | UserModel.copyOf(dbUser, (updated) => { 36 | updated.publicKey = publicKey.toString(); 37 | }) 38 | ); 39 | 40 | console.log(dbUser); 41 | 42 | Alert.alert("Successfully updated the keypair."); 43 | }; 44 | 45 | return ( 46 | 47 | Setting 48 | 49 | 59 | Update keypair 60 | 61 | 62 | 72 | Logout 73 | 74 | 75 | ); 76 | }; 77 | 78 | export default Settings; 79 | -------------------------------------------------------------------------------- /components/EditScreenInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as WebBrowser from 'expo-web-browser'; 2 | import React from 'react'; 3 | import { StyleSheet, TouchableOpacity } from 'react-native'; 4 | 5 | import Colors from '../constants/Colors'; 6 | import { MonoText } from './StyledText'; 7 | import { Text, View } from './Themed'; 8 | 9 | export default function EditScreenInfo({ path }: { path: string }) { 10 | return ( 11 | 12 | 13 | 17 | Open up the code for this screen: 18 | 19 | 20 | 24 | {path} 25 | 26 | 27 | 31 | Change any of the text, save the file, and your app will automatically update. 32 | 33 | 34 | 35 | 36 | 37 | 38 | Tap here if your app doesn't automatically update after making changes 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | function handleHelpPress() { 47 | WebBrowser.openBrowserAsync( 48 | 'https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet' 49 | ); 50 | } 51 | 52 | const styles = StyleSheet.create({ 53 | getStartedContainer: { 54 | alignItems: 'center', 55 | marginHorizontal: 50, 56 | }, 57 | homeScreenFilename: { 58 | marginVertical: 7, 59 | }, 60 | codeHighlightContainer: { 61 | borderRadius: 3, 62 | paddingHorizontal: 4, 63 | }, 64 | getStartedText: { 65 | fontSize: 17, 66 | lineHeight: 24, 67 | textAlign: 'center', 68 | }, 69 | helpContainer: { 70 | marginTop: 15, 71 | marginHorizontal: 20, 72 | alignItems: 'center', 73 | }, 74 | helpLink: { 75 | paddingVertical: 15, 76 | }, 77 | helpLinkText: { 78 | textAlign: 'center', 79 | }, 80 | }); 81 | -------------------------------------------------------------------------------- /utils/crypto.js: -------------------------------------------------------------------------------- 1 | import { getRandomBytes } from "expo-random"; 2 | import { box, setPRNG } from "tweetnacl"; 3 | import { decode as decodeUTF8, encode as encodeUTF8 } from "@stablelib/utf8"; 4 | import { 5 | decode as decodeBase64, 6 | encode as encodeBase64, 7 | } from "@stablelib/base64"; 8 | import AsyncStorage from "@react-native-async-storage/async-storage"; 9 | 10 | export const PRIVATE_KEY = "PRIVATE_KEY"; 11 | 12 | setPRNG((x, n) => { 13 | const randomBytes = getRandomBytes(n); 14 | for (let i = 0; i < n; i++) { 15 | x[i] = randomBytes[i]; 16 | } 17 | }); 18 | 19 | const newNonce = () => getRandomBytes(box.nonceLength); 20 | export const generateKeyPair = () => box.keyPair(); 21 | 22 | export const encrypt = (secretOrSharedKey, json, key) => { 23 | const nonce = newNonce(); 24 | const messageUint8 = encodeUTF8(JSON.stringify(json)); 25 | const encrypted = key 26 | ? box(messageUint8, nonce, key, secretOrSharedKey) 27 | : box.after(messageUint8, nonce, secretOrSharedKey); 28 | 29 | const fullMessage = new Uint8Array(nonce.length + encrypted.length); 30 | fullMessage.set(nonce); 31 | fullMessage.set(encrypted, nonce.length); 32 | 33 | const base64FullMessage = encodeBase64(fullMessage); 34 | return base64FullMessage; 35 | }; 36 | 37 | export const decrypt = (secretOrSharedKey, messageWithNonce, key) => { 38 | const messageWithNonceAsUint8Array = decodeBase64(messageWithNonce); 39 | const nonce = messageWithNonceAsUint8Array.slice(0, box.nonceLength); 40 | const message = messageWithNonceAsUint8Array.slice( 41 | box.nonceLength, 42 | messageWithNonce.length 43 | ); 44 | 45 | const decrypted = key 46 | ? box.open(message, nonce, key, secretOrSharedKey) 47 | : box.open.after(message, nonce, secretOrSharedKey); 48 | 49 | if (!decrypted) { 50 | throw new Error("Could not decrypt message"); 51 | } 52 | 53 | const base64DecryptedMessage = decodeUTF8(decrypted); 54 | return JSON.parse(base64DecryptedMessage); 55 | }; 56 | 57 | export const stringToUint8Array = (content) => 58 | Uint8Array.from(content.split(",").map((str) => parseInt(str))); 59 | 60 | export const getMySecretKey = async () => { 61 | const keyString = await AsyncStorage.getItem(PRIVATE_KEY); 62 | if (!keyString) { 63 | Alert.alert( 64 | "You haven't set your keypair yet", 65 | "Go to settings, and generate a new keypair", 66 | [ 67 | { 68 | text: "Open setting", 69 | onPress: () => navigation.navigate("Settings"), 70 | }, 71 | ] 72 | ); 73 | return; 74 | } 75 | 76 | return stringToUint8Array(keyString); 77 | }; 78 | -------------------------------------------------------------------------------- /amplify/backend/auth/SignalClone/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "identityPoolName": "testAuthIdentityPool", 3 | "allowUnauthenticatedIdentities": false, 4 | "resourceNameTruncated": "signal29db2920", 5 | "userPoolName": "SignalClone", 6 | "autoVerifiedAttributes": [ 7 | "email" 8 | ], 9 | "mfaConfiguration": "OFF", 10 | "mfaTypes": [ 11 | "SMS Text Message" 12 | ], 13 | "smsAuthenticationMessage": "Your authentication code is {####}", 14 | "smsVerificationMessage": "Your verification code is {####}", 15 | "emailVerificationSubject": "Forgot password code: {####}", 16 | "emailVerificationMessage": "Forgot password code: {####}", 17 | "defaultPasswordPolicy": false, 18 | "passwordPolicyMinLength": 8, 19 | "passwordPolicyCharacters": [], 20 | "requiredAttributes": [ 21 | "email" 22 | ], 23 | "aliasAttributes": [], 24 | "userpoolClientGenerateSecret": false, 25 | "userpoolClientRefreshTokenValidity": 30, 26 | "userpoolClientWriteAttributes": [], 27 | "userpoolClientReadAttributes": [], 28 | "userpoolClientLambdaRole": "Signal29db2920_userpoolclient_lambda_role", 29 | "userpoolClientSetAttributes": false, 30 | "sharedId": "29db2920", 31 | "resourceName": "SignalClone", 32 | "authSelections": "identityPoolAndUserPool", 33 | "authRoleArn": { 34 | "Fn::GetAtt": [ 35 | "AuthRole", 36 | "Arn" 37 | ] 38 | }, 39 | "unauthRoleArn": { 40 | "Fn::GetAtt": [ 41 | "UnauthRole", 42 | "Arn" 43 | ] 44 | }, 45 | "serviceName": "Cognito", 46 | "usernameAttributes": [ 47 | "email" 48 | ], 49 | "useDefault": "manual", 50 | "userPoolGroups": false, 51 | "userPoolGroupList": [], 52 | "adminQueries": false, 53 | "thirdPartyAuth": false, 54 | "authProviders": [], 55 | "usernameCaseSensitive": false, 56 | "dependsOn": [ 57 | { 58 | "category": "function", 59 | "resourceName": "SignalClonePostConfirmation", 60 | "triggerProvider": "Cognito", 61 | "attributes": [ 62 | "Arn", 63 | "Name" 64 | ] 65 | } 66 | ], 67 | "triggers": "{\n \"PostConfirmation\": [\n \"custom\"\n ]\n}", 68 | "hostedUI": false, 69 | "parentStack": { 70 | "Ref": "AWS::StackId" 71 | }, 72 | "authTriggerConnections": "[\n {\n \"triggerType\": \"PostConfirmation\",\n \"lambdaFunctionName\": \"SignalClonePostConfirmation\"\n }\n]", 73 | "breakCircularDependency": true, 74 | "permissions": [] 75 | } -------------------------------------------------------------------------------- /components/ChatRoomItem/ChatRoomItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Text, Image, View, Pressable, ActivityIndicator } from "react-native"; 3 | import { useNavigation } from "@react-navigation/core"; 4 | import { DataStore } from "@aws-amplify/datastore"; 5 | import { ChatRoomUser, User, Message } from "../../src/models"; 6 | import styles from "./styles"; 7 | import Auth from "@aws-amplify/auth"; 8 | import moment from "moment"; 9 | 10 | export default function ChatRoomItem({ chatRoom }) { 11 | // const [users, setUsers] = useState([]); // all users in this chatroom 12 | const [user, setUser] = useState(null); // the display user 13 | const [lastMessage, setLastMessage] = useState(); 14 | const [isLoading, setIsLoading] = useState(true); 15 | 16 | const navigation = useNavigation(); 17 | 18 | useEffect(() => { 19 | const fetchUsers = async () => { 20 | const fetchedUsers = (await DataStore.query(ChatRoomUser)) 21 | .filter((chatRoomUser) => chatRoomUser.chatroom.id === chatRoom.id) 22 | .map((chatRoomUser) => chatRoomUser.user); 23 | 24 | // setUsers(fetchedUsers); 25 | 26 | const authUser = await Auth.currentAuthenticatedUser(); 27 | setUser( 28 | fetchedUsers.find((user) => user.id !== authUser.attributes.sub) || null 29 | ); 30 | setIsLoading(false); 31 | }; 32 | fetchUsers(); 33 | }, []); 34 | 35 | useEffect(() => { 36 | if (!chatRoom.chatRoomLastMessageId) { 37 | return; 38 | } 39 | DataStore.query(Message, chatRoom.chatRoomLastMessageId).then( 40 | setLastMessage 41 | ); 42 | }, []); 43 | 44 | const onPress = () => { 45 | navigation.navigate("ChatRoom", { id: chatRoom.id }); 46 | }; 47 | 48 | if (isLoading) { 49 | return ; 50 | } 51 | 52 | const time = moment(lastMessage?.createdAt).from(moment()); 53 | 54 | return ( 55 | 56 | 60 | 61 | {!!chatRoom.newMessages && ( 62 | 63 | {chatRoom.newMessages} 64 | 65 | )} 66 | 67 | 68 | 69 | {chatRoom.name || user?.name} 70 | {time} 71 | 72 | 73 | {lastMessage?.content} 74 | 75 | 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/models/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ModelInit, MutableModel, PersistentModelConstructor } from "@aws-amplify/datastore"; 2 | 3 | export enum MessageStatus { 4 | SENT = "SENT", 5 | DELIVERED = "DELIVERED", 6 | READ = "READ" 7 | } 8 | 9 | 10 | 11 | type MessageMetaData = { 12 | readOnlyFields: 'createdAt' | 'updatedAt'; 13 | } 14 | 15 | type ChatRoomMetaData = { 16 | readOnlyFields: 'createdAt' | 'updatedAt'; 17 | } 18 | 19 | type ChatRoomUserMetaData = { 20 | readOnlyFields: 'createdAt' | 'updatedAt'; 21 | } 22 | 23 | type UserMetaData = { 24 | readOnlyFields: 'createdAt' | 'updatedAt'; 25 | } 26 | 27 | export declare class Message { 28 | readonly id: string; 29 | readonly content?: string; 30 | readonly userID?: string; 31 | readonly chatroomID?: string; 32 | readonly image?: string; 33 | readonly audio?: string; 34 | readonly status?: MessageStatus | keyof typeof MessageStatus; 35 | readonly replyToMessageID?: string; 36 | readonly forUserId?: string; 37 | readonly createdAt?: string; 38 | readonly updatedAt?: string; 39 | constructor(init: ModelInit); 40 | static copyOf(source: Message, mutator: (draft: MutableModel) => MutableModel | void): Message; 41 | } 42 | 43 | export declare class ChatRoom { 44 | readonly id: string; 45 | readonly newMessages?: number; 46 | readonly LastMessage?: Message; 47 | readonly Messages?: (Message | null)[]; 48 | readonly ChatRoomUsers?: (ChatRoomUser | null)[]; 49 | readonly Admin?: User; 50 | readonly name?: string; 51 | readonly imageUri?: string; 52 | readonly createdAt?: string; 53 | readonly updatedAt?: string; 54 | constructor(init: ModelInit); 55 | static copyOf(source: ChatRoom, mutator: (draft: MutableModel) => MutableModel | void): ChatRoom; 56 | } 57 | 58 | export declare class ChatRoomUser { 59 | readonly id: string; 60 | readonly chatroom: ChatRoom; 61 | readonly user: User; 62 | readonly createdAt?: string; 63 | readonly updatedAt?: string; 64 | constructor(init: ModelInit); 65 | static copyOf(source: ChatRoomUser, mutator: (draft: MutableModel) => MutableModel | void): ChatRoomUser; 66 | } 67 | 68 | export declare class User { 69 | readonly id: string; 70 | readonly name: string; 71 | readonly imageUri?: string; 72 | readonly status?: string; 73 | readonly Messages?: (Message | null)[]; 74 | readonly chatrooms?: (ChatRoomUser | null)[]; 75 | readonly lastOnlineAt?: number; 76 | readonly publicKey?: string; 77 | readonly createdAt?: string; 78 | readonly updatedAt?: string; 79 | constructor(init: ModelInit); 80 | static copyOf(source: User, mutator: (draft: MutableModel) => MutableModel | void): User; 81 | } -------------------------------------------------------------------------------- /components/AudioPlayer/AudioPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { Feather } from "@expo/vector-icons"; 2 | import { Audio, AVPlaybackStatus } from "expo-av"; 3 | import React, { useEffect, useState } from "react"; 4 | import { View, Text, Pressable, StyleSheet } from "react-native"; 5 | 6 | const AudioPlayer = ({ soundURI }) => { 7 | const [sound, setSound] = useState(null); 8 | const [paused, setPause] = useState(true); 9 | const [audioProgress, setAudioProgress] = useState(0); 10 | const [audioDuration, setAudioDuration] = useState(0); 11 | 12 | useEffect(() => { 13 | loadSound(); 14 | () => { 15 | // unload sound 16 | if (sound) { 17 | sound.unloadAsync(); 18 | } 19 | }; 20 | }, [soundURI]); 21 | 22 | const loadSound = async () => { 23 | if (!soundURI) { 24 | return; 25 | } 26 | 27 | const { sound } = await Audio.Sound.createAsync( 28 | { uri: soundURI }, 29 | {}, 30 | onPlaybackStatusUpdate 31 | ); 32 | setSound(sound); 33 | }; 34 | 35 | // Audio 36 | const onPlaybackStatusUpdate = (status: AVPlaybackStatus) => { 37 | if (!status.isLoaded) { 38 | return; 39 | } 40 | setAudioProgress(status.positionMillis / (status.durationMillis || 1)); 41 | setPause(!status.isPlaying); 42 | setAudioDuration(status.durationMillis || 0); 43 | }; 44 | 45 | const playPauseSound = async () => { 46 | if (!sound) { 47 | return; 48 | } 49 | if (paused) { 50 | await sound.playFromPositionAsync(0); 51 | } else { 52 | await sound.pauseAsync(); 53 | } 54 | }; 55 | 56 | const getDuration = () => { 57 | const minutes = Math.floor(audioDuration / (60 * 1000)); 58 | const seconds = Math.floor((audioDuration % (60 * 1000)) / 1000); 59 | 60 | return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; 61 | }; 62 | 63 | return ( 64 | 65 | 66 | 67 | 68 | 69 | 70 | 73 | 74 | 75 | {getDuration()} 76 | 77 | ); 78 | }; 79 | 80 | const styles = StyleSheet.create({ 81 | sendAudioContainer: { 82 | marginVertical: 10, 83 | padding: 10, 84 | flexDirection: "row", 85 | justifyContent: "space-between", 86 | alignItems: "center", 87 | alignSelf: "stretch", 88 | borderWidth: 1, 89 | borderColor: "lightgray", 90 | borderRadius: 10, 91 | backgroundColor: "white", 92 | }, 93 | 94 | audiProgressBG: { 95 | height: 3, 96 | flex: 1, 97 | backgroundColor: "lightgray", 98 | borderRadius: 5, 99 | margin: 10, 100 | }, 101 | audioProgressFG: { 102 | width: 10, 103 | height: 10, 104 | borderRadius: 10, 105 | backgroundColor: "#3777f0", 106 | 107 | position: "absolute", 108 | top: -3, 109 | }, 110 | }); 111 | 112 | export default AudioPlayer; 113 | -------------------------------------------------------------------------------- /screens/ChatRoomScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { 3 | Text, 4 | View, 5 | StyleSheet, 6 | FlatList, 7 | SafeAreaView, 8 | ActivityIndicator, 9 | } from "react-native"; 10 | import { useRoute, useNavigation } from "@react-navigation/core"; 11 | import { DataStore } from "@aws-amplify/datastore"; 12 | import { ChatRoom, Message as MessageModel } from "../src/models"; 13 | import Message from "../components/Message"; 14 | import MessageInput from "../components/MessageInput"; 15 | import { Auth, SortDirection } from "aws-amplify"; 16 | 17 | export default function ChatRoomScreen() { 18 | const [messages, setMessages] = useState([]); 19 | const [messageReplyTo, setMessageReplyTo] = useState( 20 | null 21 | ); 22 | const [chatRoom, setChatRoom] = useState(null); 23 | 24 | const route = useRoute(); 25 | const navigation = useNavigation(); 26 | 27 | useEffect(() => { 28 | fetchChatRoom(); 29 | }, []); 30 | 31 | useEffect(() => { 32 | fetchMessages(); 33 | }, [chatRoom]); 34 | 35 | useEffect(() => { 36 | const subscription = DataStore.observe(MessageModel).subscribe((msg) => { 37 | // console.log(msg.model, msg.opType, msg.element); 38 | if (msg.model === MessageModel && msg.opType === "INSERT") { 39 | setMessages((existingMessage) => [msg.element, ...existingMessage]); 40 | } 41 | }); 42 | 43 | return () => subscription.unsubscribe(); 44 | }, []); 45 | 46 | const fetchChatRoom = async () => { 47 | if (!route.params?.id) { 48 | console.warn("No chatroom id provided"); 49 | return; 50 | } 51 | const chatRoom = await DataStore.query(ChatRoom, route.params.id); 52 | if (!chatRoom) { 53 | console.error("Couldn't find a chat room with this id"); 54 | } else { 55 | setChatRoom(chatRoom); 56 | } 57 | }; 58 | 59 | const fetchMessages = async () => { 60 | if (!chatRoom) { 61 | return; 62 | } 63 | const authUser = await Auth.currentAuthenticatedUser(); 64 | const myId = authUser.attributes.sub; 65 | 66 | const fetchedMessages = await DataStore.query( 67 | MessageModel, 68 | (message) => message.chatroomID("eq", chatRoom?.id).forUserId("eq", myId), 69 | { 70 | sort: (message) => message.createdAt(SortDirection.DESCENDING), 71 | } 72 | ); 73 | // console.log(fetchedMessages); 74 | setMessages(fetchedMessages); 75 | }; 76 | 77 | if (!chatRoom) { 78 | return ; 79 | } 80 | 81 | return ( 82 | 83 | ( 86 | setMessageReplyTo(item)} 89 | /> 90 | )} 91 | inverted 92 | /> 93 | setMessageReplyTo(null)} 97 | /> 98 | 99 | ); 100 | } 101 | 102 | const styles = StyleSheet.create({ 103 | page: { 104 | backgroundColor: "white", 105 | flex: 1, 106 | }, 107 | }); 108 | -------------------------------------------------------------------------------- /navigation/ChatRoomHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | View, 4 | Image, 5 | Text, 6 | useWindowDimensions, 7 | Pressable, 8 | } from "react-native"; 9 | import { Feather } from "@expo/vector-icons"; 10 | import { Auth, DataStore } from "aws-amplify"; 11 | import { ChatRoom, ChatRoomUser, User } from "../src/models"; 12 | import moment from "moment"; 13 | import { useNavigation } from "@react-navigation/core"; 14 | 15 | const ChatRoomHeader = ({ id, children }) => { 16 | const { width } = useWindowDimensions(); 17 | const [user, setUser] = useState(null); 18 | const [allUsers, setAllUsers] = useState([]); 19 | const [chatRoom, setChatRoom] = useState(undefined); 20 | 21 | const navigation = useNavigation(); 22 | 23 | const fetchUsers = async () => { 24 | const fetchedUsers = (await DataStore.query(ChatRoomUser)) 25 | .filter((chatRoomUser) => chatRoomUser.chatroom.id === id) 26 | .map((chatRoomUser) => chatRoomUser.user); 27 | 28 | setAllUsers(fetchedUsers); 29 | 30 | const authUser = await Auth.currentAuthenticatedUser(); 31 | setUser( 32 | fetchedUsers.find((user) => user.id !== authUser.attributes.sub) || null 33 | ); 34 | }; 35 | 36 | const fetchChatRoom = async () => { 37 | DataStore.query(ChatRoom, id).then(setChatRoom); 38 | }; 39 | 40 | useEffect(() => { 41 | if (!id) { 42 | return; 43 | } 44 | 45 | fetchUsers(); 46 | fetchChatRoom(); 47 | }, []); 48 | 49 | const getLastOnlineText = () => { 50 | if (!user?.lastOnlineAt) { 51 | return null; 52 | } 53 | 54 | // if lastOnlineAt is less than 5 minutes ago, show him as ONLINE 55 | const lastOnlineDiffMS = moment().diff(moment(user.lastOnlineAt)); 56 | if (lastOnlineDiffMS < 5 * 60 * 1000) { 57 | // less than 5 minutes 58 | return "online"; 59 | } else { 60 | return `Last seen online ${moment(user.lastOnlineAt).fromNow()}`; 61 | } 62 | }; 63 | 64 | const getUsernames = () => { 65 | return allUsers.map((user) => user.name).join(", "); 66 | }; 67 | 68 | const openInfo = () => { 69 | // redirect to info page 70 | navigation.navigate("GroupInfoScreen", { id }); 71 | }; 72 | 73 | const isGroup = allUsers.length > 2; 74 | 75 | return ( 76 | 86 | 92 | 93 | 94 | 95 | {chatRoom?.name || user?.name} 96 | 97 | 98 | {isGroup ? getUsernames() : getLastOnlineText()} 99 | 100 | 101 | 102 | 108 | 114 | 115 | ); 116 | }; 117 | 118 | export default ChatRoomHeader; 119 | -------------------------------------------------------------------------------- /screens/GroupInfoScreen/GroupInfoScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useRoute } from "@react-navigation/native"; 2 | import { DataStore, Auth } from "aws-amplify"; 3 | import React, { useEffect, useState } from "react"; 4 | import { View, Text, StyleSheet, Alert } from "react-native"; 5 | import { FlatList } from "react-native-gesture-handler"; 6 | import UserItem from "../../components/UserItem"; 7 | import { ChatRoom, User, ChatRoomUser } from "../../src/models"; 8 | 9 | const GroupInfoScreen = () => { 10 | const [chatRoom, setChatRoom] = useState(null); 11 | const [allUsers, setAllUsers] = useState([]); 12 | const route = useRoute(); 13 | 14 | useEffect(() => { 15 | fetchChatRoom(); 16 | fetchUsers(); 17 | }, []); 18 | 19 | const fetchChatRoom = async () => { 20 | if (!route.params?.id) { 21 | console.warn("No chatroom id provided"); 22 | return; 23 | } 24 | const chatRoom = await DataStore.query(ChatRoom, route.params.id); 25 | if (!chatRoom) { 26 | console.error("Couldn't find a chat room with this id"); 27 | } else { 28 | setChatRoom(chatRoom); 29 | } 30 | }; 31 | 32 | const fetchUsers = async () => { 33 | const fetchedUsers = (await DataStore.query(ChatRoomUser)) 34 | .filter((chatRoomUser) => chatRoomUser.chatroom.id === route.params?.id) 35 | .map((chatRoomUser) => chatRoomUser.user); 36 | 37 | setAllUsers(fetchedUsers); 38 | }; 39 | 40 | const confirmDelete = async (user) => { 41 | // check if Auth user is admin of this group 42 | const authData = await Auth.currentAuthenticatedUser(); 43 | if (chatRoom?.Admin?.id !== authData.attributes.sub) { 44 | Alert.alert("You are not the admin of this group"); 45 | return; 46 | } 47 | 48 | if (user.id === chatRoom?.Admin?.id) { 49 | Alert.alert("You are the admin, you cannot delete yourself"); 50 | return; 51 | } 52 | 53 | Alert.alert( 54 | "Confirm delete", 55 | `Are you sure you want to delete ${user.name} from the group`, 56 | [ 57 | { 58 | text: "Delete", 59 | onPress: () => deleteUser(user), 60 | style: "destructive", 61 | }, 62 | { 63 | text: "Cancel", 64 | }, 65 | ] 66 | ); 67 | }; 68 | 69 | const deleteUser = async (user) => { 70 | const chatRoomUsersToDelete = await ( 71 | await DataStore.query(ChatRoomUser) 72 | ).filter( 73 | (cru) => cru.chatroom.id === chatRoom.id && cru.user.id === user.id 74 | ); 75 | 76 | console.log(chatRoomUsersToDelete); 77 | 78 | if (chatRoomUsersToDelete.length > 0) { 79 | await DataStore.delete(chatRoomUsersToDelete[0]); 80 | 81 | setAllUsers(allUsers.filter((u) => u.id !== user.id)); 82 | } 83 | }; 84 | 85 | return ( 86 | 87 | {chatRoom?.name} 88 | 89 | Users ({allUsers.length}) 90 | ( 93 | confirmDelete(item)} 97 | /> 98 | )} 99 | /> 100 | 101 | ); 102 | }; 103 | 104 | const styles = StyleSheet.create({ 105 | root: { 106 | backgroundColor: "white", 107 | padding: 10, 108 | flex: 1, 109 | }, 110 | title: { 111 | fontSize: 18, 112 | fontWeight: "bold", 113 | paddingVertical: 10, 114 | }, 115 | }); 116 | 117 | export default GroupInfoScreen; 118 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import "react-native-gesture-handler"; 2 | import { StatusBar } from "expo-status-bar"; 3 | import React, { useEffect, useState } from "react"; 4 | import { SafeAreaProvider } from "react-native-safe-area-context"; 5 | import Amplify, { DataStore, Hub, Auth } from "aws-amplify"; 6 | import { withAuthenticator } from "aws-amplify-react-native"; 7 | import config from "./src/aws-exports"; 8 | import { Message, User } from "./src/models"; 9 | import { ActionSheetProvider } from "@expo/react-native-action-sheet"; 10 | 11 | import useCachedResources from "./hooks/useCachedResources"; 12 | import useColorScheme from "./hooks/useColorScheme"; 13 | import Navigation from "./navigation"; 14 | import moment from "moment"; 15 | 16 | import { box } from "tweetnacl"; 17 | import { generateKeyPair, encrypt, decrypt } from "./utils/crypto"; 18 | 19 | Amplify.configure(config); 20 | 21 | const obj = { hello: "world" }; 22 | const pairA = generateKeyPair(); 23 | const pairB = generateKeyPair(); 24 | 25 | const sharedA = box.before(pairB.publicKey, pairA.secretKey); 26 | const encrypted = encrypt(sharedA, obj); 27 | 28 | const sharedB = box.before(pairA.publicKey, pairB.secretKey); 29 | const decrypted = decrypt(sharedB, encrypted); 30 | console.log(obj, encrypted, decrypted); 31 | 32 | function App() { 33 | const isLoadingComplete = useCachedResources(); 34 | const colorScheme = useColorScheme(); 35 | 36 | const [user, setUser] = useState(null); 37 | 38 | useEffect(() => { 39 | const listener = Hub.listen("datastore", async (hubData) => { 40 | const { event, data } = hubData.payload; 41 | if ( 42 | event === "outboxMutationProcessed" && 43 | data.model === Message && 44 | !["DELIVERED", "READ"].includes(data.element.status) 45 | ) { 46 | // set the message status to delivered 47 | DataStore.save( 48 | Message.copyOf(data.element, (updated) => { 49 | updated.status = "DELIVERED"; 50 | }) 51 | ); 52 | } 53 | }); 54 | 55 | // Remove listener 56 | return () => listener(); 57 | }, []); 58 | 59 | useEffect(() => { 60 | if (!user) { 61 | return; 62 | } 63 | 64 | const subscription = DataStore.observe(User, user.id).subscribe((msg) => { 65 | if (msg.model === User && msg.opType === "UPDATE") { 66 | setUser(msg.element); 67 | } 68 | }); 69 | 70 | return () => subscription.unsubscribe(); 71 | }, [user?.id]); 72 | 73 | useEffect(() => { 74 | fetchUser(); 75 | }, []); 76 | 77 | useEffect(() => { 78 | const interval = setInterval(async () => { 79 | await updateLastOnline(); 80 | }, 1 * 60 * 1000); 81 | return () => clearInterval(interval); 82 | }, [user]); 83 | 84 | const fetchUser = async () => { 85 | const userData = await Auth.currentAuthenticatedUser(); 86 | const user = await DataStore.query(User, userData.attributes.sub); 87 | if (user) { 88 | setUser(user); 89 | } 90 | }; 91 | 92 | const updateLastOnline = async () => { 93 | if (!user) { 94 | return; 95 | } 96 | 97 | const response = await DataStore.save( 98 | User.copyOf(user, (updated) => { 99 | updated.lastOnlineAt = +new Date(); 100 | }) 101 | ); 102 | setUser(response); 103 | }; 104 | 105 | if (!isLoadingComplete) { 106 | return null; 107 | } else { 108 | return ( 109 | 110 | 111 | 112 | 113 | 114 | 115 | ); 116 | } 117 | } 118 | 119 | export default withAuthenticator(App); 120 | -------------------------------------------------------------------------------- /components/MessageReply/MessageReply.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { 3 | View, 4 | Text, 5 | StyleSheet, 6 | ActivityIndicator, 7 | Pressable, 8 | } from "react-native"; 9 | import { DataStore } from "@aws-amplify/datastore"; 10 | import { User } from "../../src/models"; 11 | import { Auth, Storage } from "aws-amplify"; 12 | import { S3Image } from "aws-amplify-react-native"; 13 | import { useWindowDimensions } from "react-native"; 14 | import { Ionicons } from "@expo/vector-icons"; 15 | import AudioPlayer from "../AudioPlayer"; 16 | import { Message as MessageModel } from "../../src/models"; 17 | 18 | const blue = "#3777f0"; 19 | const grey = "lightgrey"; 20 | 21 | const MessageReply = (props) => { 22 | const { message: propMessage } = props; 23 | 24 | const [message, setMessage] = useState(propMessage); 25 | 26 | const [user, setUser] = useState(); 27 | const [isMe, setIsMe] = useState(null); 28 | const [soundURI, setSoundURI] = useState(null); 29 | 30 | const { width } = useWindowDimensions(); 31 | 32 | useEffect(() => { 33 | DataStore.query(User, message.userID).then(setUser); 34 | }, []); 35 | 36 | useEffect(() => { 37 | setMessage(propMessage); 38 | }, [propMessage]); 39 | 40 | useEffect(() => { 41 | if (message.audio) { 42 | Storage.get(message.audio).then(setSoundURI); 43 | } 44 | }, [message]); 45 | 46 | useEffect(() => { 47 | const checkIfMe = async () => { 48 | if (!user) { 49 | return; 50 | } 51 | const authUser = await Auth.currentAuthenticatedUser(); 52 | setIsMe(user.id === authUser.attributes.sub); 53 | }; 54 | checkIfMe(); 55 | }, [user]); 56 | 57 | if (!user) { 58 | return ; 59 | } 60 | 61 | return ( 62 | 69 | 70 | {message.image && ( 71 | 72 | 77 | 78 | )} 79 | {soundURI && } 80 | {!!message.content && ( 81 | 82 | {message.content} 83 | 84 | )} 85 | 86 | {isMe && !!message.status && message.status !== "SENT" && ( 87 | 95 | )} 96 | 97 | 98 | ); 99 | }; 100 | 101 | const styles = StyleSheet.create({ 102 | container: { 103 | padding: 10, 104 | margin: 10, 105 | borderRadius: 10, 106 | maxWidth: "75%", 107 | }, 108 | row: { 109 | flexDirection: "row", 110 | alignItems: "flex-end", 111 | }, 112 | messageReply: { 113 | backgroundColor: "gray", 114 | padding: 5, 115 | borderRadius: 5, 116 | }, 117 | leftContainer: { 118 | backgroundColor: blue, 119 | marginLeft: 10, 120 | marginRight: "auto", 121 | }, 122 | rightContainer: { 123 | backgroundColor: grey, 124 | marginLeft: "auto", 125 | marginRight: 10, 126 | alignItems: "flex-end", 127 | }, 128 | }); 129 | 130 | export default MessageReply; 131 | -------------------------------------------------------------------------------- /navigation/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * If you are not familiar with React Navigation, check out the "Fundamentals" guide: 3 | * https://reactnavigation.org/docs/getting-started 4 | * 5 | */ 6 | import { 7 | NavigationContainer, 8 | DefaultTheme, 9 | DarkTheme, 10 | useNavigation, 11 | } from "@react-navigation/native"; 12 | import { createStackNavigator } from "@react-navigation/stack"; 13 | import * as React from "react"; 14 | import { 15 | ColorSchemeName, 16 | View, 17 | Text, 18 | Image, 19 | useWindowDimensions, 20 | Pressable, 21 | } from "react-native"; 22 | import { Feather } from "@expo/vector-icons"; 23 | 24 | import NotFoundScreen from "../screens/NotFoundScreen"; 25 | import { RootStackParamList } from "../types"; 26 | import LinkingConfiguration from "./LinkingConfiguration"; 27 | 28 | import ChatRoomScreen from "../screens/ChatRoomScreen"; 29 | import HomeScreen from "../screens/HomeScreen"; 30 | import UsersScreen from "../screens/UsersScreen"; 31 | import SettingsScreen from "../screens/Settings"; 32 | 33 | import ChatRoomHeader from "./ChatRoomHeader"; 34 | import GroupInfoScreen from "../screens/GroupInfoScreen"; 35 | 36 | export default function Navigation({ 37 | colorScheme, 38 | }: { 39 | colorScheme: ColorSchemeName; 40 | }) { 41 | return ( 42 | 46 | 47 | 48 | ); 49 | } 50 | 51 | // A root stack navigator is often used for displaying modals on top of all other content 52 | // Read more here: https://reactnavigation.org/docs/modal 53 | const Stack = createStackNavigator(); 54 | 55 | function RootNavigator() { 56 | return ( 57 | 58 | 63 | ({ 67 | headerTitle: () => , 68 | headerBackTitleVisible: false, 69 | })} 70 | /> 71 | 72 | 79 | 80 | 81 | 86 | 87 | ); 88 | } 89 | 90 | const HomeHeader = (props) => { 91 | const { width } = useWindowDimensions(); 92 | const navigation = useNavigation(); 93 | 94 | return ( 95 | 104 | 110 | 118 | Signal 119 | 120 | navigation.navigate("Settings")}> 121 | 127 | 128 | navigation.navigate("UsersScreen")}> 129 | 135 | 136 | 137 | ); 138 | }; 139 | -------------------------------------------------------------------------------- /assets/dummy-data/ChatRooms.ts: -------------------------------------------------------------------------------- 1 | export default [{ 2 | id: '1', 3 | users: [{ 4 | id: 'u1', 5 | name: 'Vadim', 6 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 7 | }, { 8 | id: 'u2', 9 | name: 'Elon Musk', 10 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/elon.png', 11 | }], 12 | lastMessage: { 13 | id: 'm1', 14 | content: 'btw, SpaceX is interested in buying notJust.dev!', 15 | createdAt: '2020-10-03T14:48:00.000Z', 16 | }, 17 | newMessages: 4, 18 | }, { 19 | id: '231231231231231', 20 | users: [{ 21 | id: 'u1', 22 | name: 'Vadim', 23 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 24 | }, { 25 | id: 'u3', 26 | name: 'Jeff', 27 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/jeff.jpeg', 28 | }], 29 | lastMessage: { 30 | id: 'm2', 31 | content: 'Why did you reject our offer?', 32 | createdAt: '2020-10-02T15:40:00.000Z', 33 | } 34 | }, { 35 | id: '3', 36 | users: [{ 37 | id: 'u1', 38 | name: 'Vadim', 39 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 40 | }, { 41 | id: 'u4', 42 | name: 'Zuck', 43 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/zuck.jpeg', 44 | }], 45 | lastMessage: { 46 | id: 'm3', 47 | content: 'Is signal really better than my Whatsapp?', 48 | createdAt: '2020-10-02T14:48:00.000Z', 49 | } 50 | }, { 51 | id: '4', 52 | users: [{ 53 | id: 'u1', 54 | name: 'Vadim', 55 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 56 | }, { 57 | id: 'u5', 58 | name: 'Graham', 59 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/graham.jpg', 60 | }], 61 | lastMessage: { 62 | id: 'm4', 63 | content: 'Destroy the like button!', 64 | createdAt: '2020-09-29T14:48:00.000Z', 65 | } 66 | }, { 67 | id: '5', 68 | users: [{ 69 | id: 'u1', 70 | name: 'Vadim', 71 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 72 | }, { 73 | id: 'u6', 74 | name: 'Biahaze', 75 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/biahaze.jpg', 76 | }], 77 | lastMessage: { 78 | id: 'm5', 79 | content: 'I would be happy', 80 | createdAt: '2020-09-30T14:48:00.000Z', 81 | } 82 | }, { 83 | id: '6', 84 | users: [{ 85 | id: 'u1', 86 | name: 'Vadim', 87 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 88 | }, { 89 | id: 'u7', 90 | name: 'Sus?', 91 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/1.jpg', 92 | }], 93 | lastMessage: { 94 | id: 'm6', 95 | content: 'Who sus?', 96 | createdAt: '2020-10-02T15:40:00.000Z', 97 | } 98 | }, { 99 | id: '7', 100 | users: [{ 101 | id: 'u1', 102 | name: 'Vadim', 103 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 104 | }, { 105 | id: 'u8', 106 | name: 'Daniel', 107 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/2.jpg', 108 | }], 109 | lastMessage: { 110 | id: 'm7', 111 | content: 'How are you doing?', 112 | createdAt: '2020-10-02T15:40:00.000Z', 113 | } 114 | }, { 115 | id: '8', 116 | users: [{ 117 | id: 'u1', 118 | name: 'Vadim', 119 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 120 | }, { 121 | id: 'u9', 122 | name: 'Carlos', 123 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/3.jpg', 124 | }], 125 | lastMessage: { 126 | id: 'm8', 127 | content: 'Hola hola coca cola?', 128 | createdAt: '2020-09-27T15:40:00.000Z', 129 | } 130 | }, { 131 | id: '9', 132 | users: [{ 133 | id: 'u1', 134 | name: 'Vadim', 135 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/vadim.jpg', 136 | }, { 137 | id: 'u10', 138 | name: 'Angelina Jolie', 139 | imageUri: 'https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/4.jpg', 140 | }], 141 | lastMessage: { 142 | id: 'm9', 143 | content: 'Meet me at the same place', 144 | createdAt: '2020-09-25T15:40:00.000Z', 145 | }, 146 | }] 147 | -------------------------------------------------------------------------------- /screens/UsersScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | 3 | import { 4 | View, 5 | StyleSheet, 6 | FlatList, 7 | Text, 8 | Pressable, 9 | SafeAreaView, 10 | Alert, 11 | } from "react-native"; 12 | import UserItem from "../components/UserItem"; 13 | import NewGroupButton from "../components/NewGroupButton"; 14 | import { useNavigation } from "@react-navigation/native"; 15 | import { Auth, DataStore } from "aws-amplify"; 16 | import { ChatRoom, User, ChatRoomUser } from "../src/models"; 17 | 18 | export default function UsersScreen() { 19 | const [users, setUsers] = useState([]); 20 | const [selectedUsers, setSelectedUsers] = useState([]); 21 | const [isNewGroup, setIsNewGroup] = useState(false); 22 | 23 | const navigation = useNavigation(); 24 | 25 | useEffect(() => { 26 | DataStore.query(User).then(setUsers); 27 | }, []); 28 | 29 | // useEffect(() => { 30 | // // query users 31 | // const fetchUsers = async () => { 32 | // const fetchedUsers = await DataStore.query(User); 33 | // setUsers(fetchedUsers); 34 | // }; 35 | // fetchUsers(); 36 | // }, []) 37 | 38 | const addUserToChatRoom = async (user, chatroom) => { 39 | DataStore.save(new ChatRoomUser({ user, chatroom })); 40 | }; 41 | 42 | const createChatRoom = async (users) => { 43 | // TODO if there is already a chat room between these 2 users 44 | // then redirect to the existing chat room 45 | // otherwise, create a new chatroom with these users. 46 | 47 | // connect authenticated user with the chat room 48 | const authUser = await Auth.currentAuthenticatedUser(); 49 | const dbUser = await DataStore.query(User, authUser.attributes.sub); 50 | if (!dbUser) { 51 | Alert.alert("There was an error creating the group"); 52 | return; 53 | } 54 | // Create a chat room 55 | const newChatRoomData = { 56 | newMessages: 0, 57 | Admin: dbUser, 58 | }; 59 | if (users.length > 1) { 60 | newChatRoomData.name = "New group 2"; 61 | newChatRoomData.imageUri = 62 | "https://notjustdev-dummy.s3.us-east-2.amazonaws.com/avatars/group.jpeg"; 63 | } 64 | const newChatRoom = await DataStore.save(new ChatRoom(newChatRoomData)); 65 | 66 | if (dbUser) { 67 | await addUserToChatRoom(dbUser, newChatRoom); 68 | } 69 | 70 | // connect users user with the chat room 71 | await Promise.all( 72 | users.map((user) => addUserToChatRoom(user, newChatRoom)) 73 | ); 74 | 75 | navigation.navigate("ChatRoom", { id: newChatRoom.id }); 76 | }; 77 | 78 | const isUserSelected = (user) => { 79 | return selectedUsers.some((selectedUser) => selectedUser.id === user.id); 80 | }; 81 | 82 | const onUserPress = async (user) => { 83 | if (isNewGroup) { 84 | if (isUserSelected(user)) { 85 | // remove it from selected 86 | setSelectedUsers( 87 | selectedUsers.filter((selectedUser) => selectedUser.id !== user.id) 88 | ); 89 | } else { 90 | setSelectedUsers([...selectedUsers, user]); 91 | } 92 | } else { 93 | await createChatRoom([user]); 94 | } 95 | }; 96 | 97 | const saveGroup = async () => { 98 | await createChatRoom(selectedUsers); 99 | }; 100 | 101 | return ( 102 | 103 | ( 106 | onUserPress(item)} 109 | isSelected={isNewGroup ? isUserSelected(item) : undefined} 110 | /> 111 | )} 112 | showsVerticalScrollIndicator={false} 113 | ListHeaderComponent={() => ( 114 | setIsNewGroup(!isNewGroup)} /> 115 | )} 116 | /> 117 | 118 | {isNewGroup && ( 119 | 120 | 121 | Save group ({selectedUsers.length}) 122 | 123 | 124 | )} 125 | 126 | ); 127 | } 128 | 129 | const styles = StyleSheet.create({ 130 | page: { 131 | backgroundColor: "white", 132 | flex: 1, 133 | }, 134 | button: { 135 | backgroundColor: "#3777f0", 136 | marginHorizontal: 10, 137 | padding: 10, 138 | alignItems: "center", 139 | borderRadius: 10, 140 | }, 141 | buttonText: { 142 | color: "white", 143 | fontWeight: "bold", 144 | }, 145 | }); 146 | -------------------------------------------------------------------------------- /amplify/backend/auth/SignalClone/auth-trigger-cloudformation-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "Description": "Custom Resource stack for Auth Trigger created using Amplify CLI", 3 | "AWSTemplateFormatVersion": "2010-09-09", 4 | "Parameters": { 5 | "env": { 6 | "Type": "String" 7 | }, 8 | "userpoolId": { 9 | "Type": "String" 10 | }, 11 | "userpoolArn": { 12 | "Type": "String" 13 | }, 14 | "functionSignalClonePostConfirmationName": { 15 | "Type": "String" 16 | }, 17 | "functionSignalClonePostConfirmationArn": { 18 | "Type": "String" 19 | } 20 | }, 21 | "Conditions": { 22 | "ShouldNotCreateEnvResources": { 23 | "Fn::Equals": [ 24 | { 25 | "Ref": "env" 26 | }, 27 | "NONE" 28 | ] 29 | } 30 | }, 31 | "Resources": { 32 | "UserPoolPostConfirmationLambdaInvokePermission": { 33 | "Type": "AWS::Lambda::Permission", 34 | "Properties": { 35 | "Action": "lambda:InvokeFunction", 36 | "FunctionName": { 37 | "Ref": "functionSignalClonePostConfirmationName" 38 | }, 39 | "Principal": "cognito-idp.amazonaws.com", 40 | "SourceArn": { 41 | "Ref": "userpoolArn" 42 | } 43 | } 44 | }, 45 | "authTriggerFnServiceRole08093B67": { 46 | "Type": "AWS::IAM::Role", 47 | "Properties": { 48 | "AssumeRolePolicyDocument": { 49 | "Statement": [ 50 | { 51 | "Action": "sts:AssumeRole", 52 | "Effect": "Allow", 53 | "Principal": { 54 | "Service": "lambda.amazonaws.com" 55 | } 56 | } 57 | ], 58 | "Version": "2012-10-17" 59 | }, 60 | "ManagedPolicyArns": [ 61 | { 62 | "Fn::Join": [ 63 | "", 64 | [ 65 | "arn:", 66 | { 67 | "Ref": "AWS::Partition" 68 | }, 69 | ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 70 | ] 71 | ] 72 | } 73 | ] 74 | } 75 | }, 76 | "authTriggerFnServiceRoleDefaultPolicyEC9285A8": { 77 | "Type": "AWS::IAM::Policy", 78 | "Properties": { 79 | "PolicyDocument": { 80 | "Statement": [ 81 | { 82 | "Action": [ 83 | "cognito-idp:DescribeUserPoolClient", 84 | "cognito-idp:UpdateUserPool" 85 | ], 86 | "Effect": "Allow", 87 | "Resource": "*" 88 | } 89 | ], 90 | "Version": "2012-10-17" 91 | }, 92 | "PolicyName": "authTriggerFnServiceRoleDefaultPolicyEC9285A8", 93 | "Roles": [ 94 | { 95 | "Ref": "authTriggerFnServiceRole08093B67" 96 | } 97 | ] 98 | } 99 | }, 100 | "authTriggerFn7FCFA449": { 101 | "Type": "AWS::Lambda::Function", 102 | "Properties": { 103 | "Code": { 104 | "ZipFile": "const response = require('cfn-response');\nconst aws = require('aws-sdk');\n\nexports.handler = async function (event, context) {\n try {\n const userPoolId = event.ResourceProperties.userpoolId;\n const lambdaConfig = event.ResourceProperties.lambdaConfig;\n const config = {};\n lambdaConfig.forEach(lambda => (config[`${lambda.triggerType}`] = lambda.lambdaFunctionArn));\n if (event.RequestType == 'Delete') {\n const authParams = { UserPoolId: userPoolId, LambdaConfig: {} };\n const cognitoclient = new aws.CognitoIdentityServiceProvider();\n try {\n const result = await cognitoclient.updateUserPool(authParams).promise();\n console.log('delete response data ' + JSON.stringify(result));\n await response.send(event, context, response.SUCCESS, {});\n } catch (err) {\n console.log(err.stack);\n await response.send(event, context, response.FAILED, { err });\n }\n }\n if (event.RequestType == 'Update' || event.RequestType == 'Create') {\n const authParams = { UserPoolId: userPoolId, LambdaConfig: config };\n console.log(authParams);\n const cognitoclient = new aws.CognitoIdentityServiceProvider();\n try {\n const result = await cognitoclient.updateUserPool(authParams).promise();\n console.log('createOrUpdate response data ' + JSON.stringify(result));\n await response.send(event, context, response.SUCCESS, { result });\n } catch (err) {\n console.log(err.stack);\n await response.send(event, context, response.FAILED, { err });\n }\n }\n } catch (err) {\n console.log(err.stack);\n await response.send(event, context, response.FAILED, { err });\n }\n};\n" 105 | }, 106 | "Handler": "index.handler", 107 | "Role": { 108 | "Fn::GetAtt": [ 109 | "authTriggerFnServiceRole08093B67", 110 | "Arn" 111 | ] 112 | }, 113 | "Runtime": "nodejs12.x" 114 | }, 115 | "DependsOn": [ 116 | "authTriggerFnServiceRoleDefaultPolicyEC9285A8", 117 | "authTriggerFnServiceRole08093B67" 118 | ] 119 | }, 120 | "CustomAuthTriggerResource": { 121 | "Type": "Custom::CustomAuthTriggerResourceOutputs", 122 | "Properties": { 123 | "ServiceToken": { 124 | "Fn::GetAtt": [ 125 | "authTriggerFn7FCFA449", 126 | "Arn" 127 | ] 128 | }, 129 | "userpoolId": { 130 | "Ref": "userpoolId" 131 | }, 132 | "lambdaConfig": [ 133 | { 134 | "triggerType": "PostConfirmation", 135 | "lambdaFunctionName": "SignalClonePostConfirmation", 136 | "lambdaFunctionArn": { 137 | "Ref": "functionSignalClonePostConfirmationArn" 138 | } 139 | } 140 | ] 141 | }, 142 | "UpdateReplacePolicy": "Delete", 143 | "DeletionPolicy": "Delete" 144 | } 145 | } 146 | } -------------------------------------------------------------------------------- /amplify/backend/function/SignalClonePostConfirmation/SignalClonePostConfirmation-cloudformation-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "Lambda resource stack creation using Amplify CLI", 4 | "Parameters": { 5 | "GROUP": { 6 | "Type": "String", 7 | "Default": "" 8 | }, 9 | "modules": { 10 | "Type": "String", 11 | "Default": "", 12 | "Description": "Comma-delimited list of modules to be executed by a lambda trigger. Sent to resource as an env variable." 13 | }, 14 | "resourceName": { 15 | "Type": "String", 16 | "Default": "" 17 | }, 18 | "trigger": { 19 | "Type": "String", 20 | "Default": "true" 21 | }, 22 | "functionName": { 23 | "Type": "String", 24 | "Default": "" 25 | }, 26 | "roleName": { 27 | "Type": "String", 28 | "Default": "" 29 | }, 30 | "parentResource": { 31 | "Type": "String", 32 | "Default": "" 33 | }, 34 | "parentStack": { 35 | "Type": "String", 36 | "Default": "" 37 | }, 38 | "env": { 39 | "Type": "String" 40 | }, 41 | "deploymentBucketName": { 42 | "Type": "String" 43 | }, 44 | "s3Key": { 45 | "Type": "String" 46 | } 47 | }, 48 | "Conditions": { 49 | "ShouldNotCreateEnvResources": { 50 | "Fn::Equals": [ 51 | { 52 | "Ref": "env" 53 | }, 54 | "NONE" 55 | ] 56 | } 57 | }, 58 | "Resources": { 59 | "LambdaFunction": { 60 | "Type": "AWS::Lambda::Function", 61 | "Metadata": { 62 | "aws:asset:path": "./src", 63 | "aws:asset:property": "Code" 64 | }, 65 | "Properties": { 66 | "Handler": "index.handler", 67 | "FunctionName": { 68 | "Fn::If": [ 69 | "ShouldNotCreateEnvResources", 70 | "SignalClonePostConfirmation", 71 | { 72 | "Fn::Join": [ 73 | "", 74 | [ 75 | "SignalClonePostConfirmation", 76 | "-", 77 | { 78 | "Ref": "env" 79 | } 80 | ] 81 | ] 82 | } 83 | ] 84 | }, 85 | "Environment": { 86 | "Variables": { 87 | "ENV": { 88 | "Ref": "env" 89 | }, 90 | "MODULES": { 91 | "Ref": "modules" 92 | }, 93 | "REGION": { 94 | "Ref": "AWS::Region" 95 | }, 96 | "GROUP": { 97 | "Ref": "GROUP" 98 | } 99 | } 100 | }, 101 | "Role": { 102 | "Fn::GetAtt": [ 103 | "LambdaExecutionRole", 104 | "Arn" 105 | ] 106 | }, 107 | "Runtime": "nodejs14.x", 108 | "Timeout": 25, 109 | "Code": { 110 | "S3Bucket": { 111 | "Ref": "deploymentBucketName" 112 | }, 113 | "S3Key": { 114 | "Ref": "s3Key" 115 | } 116 | } 117 | } 118 | }, 119 | "LambdaExecutionRole": { 120 | "Type": "AWS::IAM::Role", 121 | "Properties": { 122 | "RoleName": { 123 | "Fn::If": [ 124 | "ShouldNotCreateEnvResources", 125 | "SignalClonePostConfirmation", 126 | { 127 | "Fn::Join": [ 128 | "", 129 | [ 130 | "SignalClonePostConfirmation", 131 | "-", 132 | { 133 | "Ref": "env" 134 | } 135 | ] 136 | ] 137 | } 138 | ] 139 | }, 140 | "AssumeRolePolicyDocument": { 141 | "Version": "2012-10-17", 142 | "Statement": [ 143 | { 144 | "Effect": "Allow", 145 | "Principal": { 146 | "Service": [ 147 | "lambda.amazonaws.com" 148 | ] 149 | }, 150 | "Action": [ 151 | "sts:AssumeRole" 152 | ] 153 | } 154 | ] 155 | } 156 | } 157 | }, 158 | "lambdaexecutionpolicy": { 159 | "DependsOn": [ 160 | "LambdaExecutionRole" 161 | ], 162 | "Type": "AWS::IAM::Policy", 163 | "Properties": { 164 | "PolicyName": "lambda-execution-policy", 165 | "Roles": [ 166 | { 167 | "Ref": "LambdaExecutionRole" 168 | } 169 | ], 170 | "PolicyDocument": { 171 | "Version": "2012-10-17", 172 | "Statement": [ 173 | { 174 | "Effect": "Allow", 175 | "Action": [ 176 | "logs:CreateLogGroup", 177 | "logs:CreateLogStream", 178 | "logs:PutLogEvents" 179 | ], 180 | "Resource": { 181 | "Fn::Sub": [ 182 | "arn:aws:logs:${region}:${account}:log-group:/aws/lambda/${lambda}:log-stream:*", 183 | { 184 | "region": { 185 | "Ref": "AWS::Region" 186 | }, 187 | "account": { 188 | "Ref": "AWS::AccountId" 189 | }, 190 | "lambda": { 191 | "Ref": "LambdaFunction" 192 | } 193 | } 194 | ] 195 | } 196 | } 197 | ] 198 | } 199 | } 200 | } 201 | }, 202 | "Outputs": { 203 | "Name": { 204 | "Value": { 205 | "Ref": "LambdaFunction" 206 | } 207 | }, 208 | "Arn": { 209 | "Value": { 210 | "Fn::GetAtt": [ 211 | "LambdaFunction", 212 | "Arn" 213 | ] 214 | } 215 | }, 216 | "LambdaExecutionRole": { 217 | "Value": { 218 | "Ref": "LambdaExecutionRole" 219 | } 220 | }, 221 | "Region": { 222 | "Value": { 223 | "Ref": "AWS::Region" 224 | } 225 | } 226 | } 227 | } -------------------------------------------------------------------------------- /components/Message/Message.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { 3 | View, 4 | Text, 5 | StyleSheet, 6 | ActivityIndicator, 7 | Pressable, 8 | Alert, 9 | } from "react-native"; 10 | import { DataStore } from "@aws-amplify/datastore"; 11 | import { User } from "../../src/models"; 12 | import { Auth, Storage } from "aws-amplify"; 13 | import { S3Image } from "aws-amplify-react-native"; 14 | import { useWindowDimensions } from "react-native"; 15 | import { Ionicons } from "@expo/vector-icons"; 16 | import { useActionSheet } from "@expo/react-native-action-sheet"; 17 | import AudioPlayer from "../AudioPlayer"; 18 | import { Message as MessageModel } from "../../src/models"; 19 | import MessageReply from "../MessageReply"; 20 | import { box } from "tweetnacl"; 21 | import { 22 | decrypt, 23 | getMySecretKey, 24 | stringToUint8Array, 25 | } from "../../utils/crypto"; 26 | 27 | const blue = "#3777f0"; 28 | const grey = "lightgrey"; 29 | 30 | const Message = (props) => { 31 | const { setAsMessageReply, message: propMessage } = props; 32 | 33 | const [message, setMessage] = useState(propMessage); 34 | const [decryptedContent, setDecryptedContent] = useState(""); 35 | const [repliedTo, setRepliedTo] = useState( 36 | undefined 37 | ); 38 | const [user, setUser] = useState(); 39 | const [isMe, setIsMe] = useState(null); 40 | const [soundURI, setSoundURI] = useState(null); 41 | const [isDeleted, setIsDeleted] = useState(false); 42 | 43 | const { width } = useWindowDimensions(); 44 | const { showActionSheetWithOptions } = useActionSheet(); 45 | 46 | useEffect(() => { 47 | DataStore.query(User, message.userID).then(setUser); 48 | }, []); 49 | 50 | useEffect(() => { 51 | setMessage(propMessage); 52 | }, [propMessage]); 53 | 54 | useEffect(() => { 55 | if (message?.replyToMessageID) { 56 | DataStore.query(MessageModel, message.replyToMessageID).then( 57 | setRepliedTo 58 | ); 59 | } 60 | }, [message]); 61 | 62 | useEffect(() => { 63 | const subscription = DataStore.observe(MessageModel, message.id).subscribe( 64 | (msg) => { 65 | if (msg.model === MessageModel) { 66 | if (msg.opType === "UPDATE") { 67 | setMessage((message) => ({ ...message, ...msg.element })); 68 | } else if (msg.opType === "DELETE") { 69 | setIsDeleted(true); 70 | } 71 | } 72 | } 73 | ); 74 | 75 | return () => subscription.unsubscribe(); 76 | }, []); 77 | 78 | useEffect(() => { 79 | setAsRead(); 80 | }, [isMe, message]); 81 | 82 | useEffect(() => { 83 | if (message.audio) { 84 | Storage.get(message.audio).then(setSoundURI); 85 | } 86 | }, [message]); 87 | 88 | useEffect(() => { 89 | const checkIfMe = async () => { 90 | if (!user) { 91 | return; 92 | } 93 | const authUser = await Auth.currentAuthenticatedUser(); 94 | setIsMe(user.id === authUser.attributes.sub); 95 | }; 96 | checkIfMe(); 97 | }, [user]); 98 | 99 | useEffect(() => { 100 | if (!message?.content || !user?.publicKey) { 101 | return; 102 | } 103 | 104 | const decryptMessage = async () => { 105 | const myKey = await getMySecretKey(); 106 | if (!myKey) { 107 | return; 108 | } 109 | // decrypt message.content 110 | const sharedKey = box.before(stringToUint8Array(user.publicKey), myKey); 111 | console.log("sharedKey", sharedKey); 112 | const decrypted = decrypt(sharedKey, message.content); 113 | console.log("decrypted", decrypted); 114 | setDecryptedContent(decrypted.message); 115 | }; 116 | 117 | decryptMessage(); 118 | }, [message, user]); 119 | 120 | const setAsRead = async () => { 121 | if (isMe === false && message.status !== "READ") { 122 | await DataStore.save( 123 | MessageModel.copyOf(message, (updated) => { 124 | updated.status = "READ"; 125 | }) 126 | ); 127 | } 128 | }; 129 | 130 | const deleteMessage = async () => { 131 | await DataStore.delete(message); 132 | }; 133 | 134 | const confirmDelete = () => { 135 | Alert.alert( 136 | "Confirm delete", 137 | "Are you sure you want to delete the message?", 138 | [ 139 | { 140 | text: "Delete", 141 | onPress: deleteMessage, 142 | style: "destructive", 143 | }, 144 | { 145 | text: "Cancel", 146 | }, 147 | ] 148 | ); 149 | }; 150 | 151 | const onActionPress = (index) => { 152 | if (index === 0) { 153 | setAsMessageReply(); 154 | } else if (index === 1) { 155 | if (isMe) { 156 | confirmDelete(); 157 | } else { 158 | Alert.alert("Can't perform action", "This is not your message"); 159 | } 160 | } 161 | }; 162 | 163 | const openActionMenu = () => { 164 | const options = ["Reply", "Delete", "Cancel"]; 165 | const destructiveButtonIndex = 1; 166 | const cancelButtonIndex = 2; 167 | showActionSheetWithOptions( 168 | { 169 | options, 170 | destructiveButtonIndex, 171 | cancelButtonIndex, 172 | }, 173 | onActionPress 174 | ); 175 | }; 176 | 177 | if (!user) { 178 | return ; 179 | } 180 | 181 | return ( 182 | 190 | {repliedTo && } 191 | 192 | {message.image && ( 193 | 194 | 199 | 200 | )} 201 | {soundURI && } 202 | {!!decryptedContent && ( 203 | 204 | {isDeleted ? "message deleted" : decryptedContent} 205 | 206 | )} 207 | 208 | {isMe && !!message.status && message.status !== "SENT" && ( 209 | 217 | )} 218 | 219 | 220 | ); 221 | }; 222 | 223 | const styles = StyleSheet.create({ 224 | container: { 225 | padding: 10, 226 | margin: 10, 227 | borderRadius: 10, 228 | maxWidth: "75%", 229 | }, 230 | row: { 231 | flexDirection: "row", 232 | alignItems: "flex-end", 233 | }, 234 | messageReply: { 235 | backgroundColor: "gray", 236 | padding: 5, 237 | borderRadius: 5, 238 | }, 239 | leftContainer: { 240 | backgroundColor: blue, 241 | marginLeft: 10, 242 | marginRight: "auto", 243 | }, 244 | rightContainer: { 245 | backgroundColor: grey, 246 | marginLeft: "auto", 247 | marginRight: 10, 248 | alignItems: "flex-end", 249 | }, 250 | }); 251 | 252 | export default Message; 253 | -------------------------------------------------------------------------------- /components/MessageInput/MessageInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { 3 | View, 4 | Text, 5 | StyleSheet, 6 | TextInput, 7 | Pressable, 8 | KeyboardAvoidingView, 9 | Platform, 10 | Image, 11 | Alert, 12 | } from "react-native"; 13 | import { 14 | SimpleLineIcons, 15 | Feather, 16 | MaterialCommunityIcons, 17 | AntDesign, 18 | Ionicons, 19 | } from "@expo/vector-icons"; 20 | import { DataStore } from "@aws-amplify/datastore"; 21 | import { ChatRoom, Message } from "../../src/models"; 22 | import { Auth, Storage } from "aws-amplify"; 23 | import EmojiSelector from "react-native-emoji-selector"; 24 | import * as ImagePicker from "expo-image-picker"; 25 | import { v4 as uuidv4 } from "uuid"; 26 | import { Audio, AVPlaybackStatus } from "expo-av"; 27 | import AudioPlayer from "../AudioPlayer"; 28 | import MessageComponent from "../Message"; 29 | import { ChatRoomUser } from "../../src/models"; 30 | import { useNavigation } from "@react-navigation/core"; 31 | import { box } from "tweetnacl"; 32 | import { 33 | encrypt, 34 | getMySecretKey, 35 | stringToUint8Array, 36 | } from "../../utils/crypto"; 37 | 38 | const MessageInput = ({ chatRoom, messageReplyTo, removeMessageReplyTo }) => { 39 | const [message, setMessage] = useState(""); 40 | const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); 41 | const [image, setImage] = useState(null); 42 | const [progress, setProgress] = useState(0); 43 | const [recording, setRecording] = useState(null); 44 | const [soundURI, setSoundURI] = useState(null); 45 | 46 | const navigation = useNavigation(); 47 | 48 | useEffect(() => { 49 | (async () => { 50 | if (Platform.OS !== "web") { 51 | const libraryResponse = 52 | await ImagePicker.requestMediaLibraryPermissionsAsync(); 53 | const photoResponse = await ImagePicker.requestCameraPermissionsAsync(); 54 | await Audio.requestPermissionsAsync(); 55 | 56 | if ( 57 | libraryResponse.status !== "granted" || 58 | photoResponse.status !== "granted" 59 | ) { 60 | alert("Sorry, we need camera roll permissions to make this work!"); 61 | } 62 | } 63 | })(); 64 | }, []); 65 | 66 | const sendMessageToUser = async (user, fromUserId) => { 67 | // send message 68 | const ourSecretKey = await getMySecretKey(); 69 | if (!ourSecretKey) { 70 | return; 71 | } 72 | 73 | if (!user.publicKey) { 74 | Alert.alert( 75 | "The user haven't set his keypair yet", 76 | "Until the user generates the keypair, you cannot securely send him messages" 77 | ); 78 | return; 79 | } 80 | 81 | console.log("private key", ourSecretKey); 82 | 83 | const sharedKey = box.before( 84 | stringToUint8Array(user.publicKey), 85 | ourSecretKey 86 | ); 87 | console.log("shared key", sharedKey); 88 | 89 | const encryptedMessage = encrypt(sharedKey, { message }); 90 | console.log("encrypted message", encryptedMessage); 91 | 92 | const newMessage = await DataStore.save( 93 | new Message({ 94 | content: encryptedMessage, // <- this messages should be encrypted 95 | userID: fromUserId, 96 | forUserId: user.id, 97 | chatroomID: chatRoom.id, 98 | replyToMessageID: messageReplyTo?.id, 99 | }) 100 | ); 101 | 102 | // updateLastMessage(newMessage); 103 | }; 104 | 105 | const sendMessage = async () => { 106 | // get all the users of this chatroom 107 | const authUser = await Auth.currentAuthenticatedUser(); 108 | 109 | const users = (await DataStore.query(ChatRoomUser)) 110 | .filter((cru) => cru.chatroom.id === chatRoom.id) 111 | .map((cru) => cru.user); 112 | 113 | console.log("users", users); 114 | 115 | // for each user, encrypt the `content` with his public key, and save it as a new message 116 | await Promise.all( 117 | users.map((user) => sendMessageToUser(user, authUser.attributes.sub)) 118 | ); 119 | 120 | resetFields(); 121 | }; 122 | 123 | const updateLastMessage = async (newMessage) => { 124 | DataStore.save( 125 | ChatRoom.copyOf(chatRoom, (updatedChatRoom) => { 126 | updatedChatRoom.LastMessage = newMessage; 127 | }) 128 | ); 129 | }; 130 | 131 | const onPlusClicked = () => { 132 | console.warn("On plus clicked"); 133 | }; 134 | 135 | const onPress = () => { 136 | if (image) { 137 | sendImage(); 138 | } else if (soundURI) { 139 | sendAudio(); 140 | } else if (message) { 141 | sendMessage(); 142 | } else { 143 | onPlusClicked(); 144 | } 145 | }; 146 | 147 | const resetFields = () => { 148 | setMessage(""); 149 | setIsEmojiPickerOpen(false); 150 | setImage(null); 151 | setProgress(0); 152 | setSoundURI(null); 153 | removeMessageReplyTo(); 154 | }; 155 | 156 | // Image picker 157 | const pickImage = async () => { 158 | const result = await ImagePicker.launchImageLibraryAsync({ 159 | mediaTypes: ImagePicker.MediaTypeOptions.Images, 160 | allowsEditing: true, 161 | aspect: [4, 3], 162 | quality: 0.5, 163 | }); 164 | 165 | if (!result.cancelled) { 166 | setImage(result.uri); 167 | } 168 | }; 169 | 170 | const takePhoto = async () => { 171 | const result = await ImagePicker.launchCameraAsync({ 172 | mediaTypes: ImagePicker.MediaTypeOptions.Images, 173 | aspect: [4, 3], 174 | }); 175 | 176 | if (!result.cancelled) { 177 | setImage(result.uri); 178 | } 179 | }; 180 | 181 | const progressCallback = (progress) => { 182 | setProgress(progress.loaded / progress.total); 183 | }; 184 | 185 | const sendImage = async () => { 186 | if (!image) { 187 | return; 188 | } 189 | const blob = await getBlob(image); 190 | const { key } = await Storage.put(`${uuidv4()}.png`, blob, { 191 | progressCallback, 192 | }); 193 | 194 | // send message 195 | const user = await Auth.currentAuthenticatedUser(); 196 | const newMessage = await DataStore.save( 197 | new Message({ 198 | content: message, 199 | image: key, 200 | userID: user.attributes.sub, 201 | chatroomID: chatRoom.id, 202 | replyToMessageID: messageReplyTo?.id, 203 | }) 204 | ); 205 | 206 | updateLastMessage(newMessage); 207 | 208 | resetFields(); 209 | }; 210 | 211 | const getBlob = async (uri: string) => { 212 | const respone = await fetch(uri); 213 | const blob = await respone.blob(); 214 | return blob; 215 | }; 216 | 217 | async function startRecording() { 218 | try { 219 | await Audio.setAudioModeAsync({ 220 | allowsRecordingIOS: true, 221 | playsInSilentModeIOS: true, 222 | }); 223 | 224 | console.log("Starting recording.."); 225 | const { recording } = await Audio.Recording.createAsync( 226 | Audio.RECORDING_OPTIONS_PRESET_HIGH_QUALITY 227 | ); 228 | setRecording(recording); 229 | console.log("Recording started"); 230 | } catch (err) { 231 | console.error("Failed to start recording", err); 232 | } 233 | } 234 | 235 | async function stopRecording() { 236 | console.log("Stopping recording.."); 237 | if (!recording) { 238 | return; 239 | } 240 | 241 | setRecording(null); 242 | await recording.stopAndUnloadAsync(); 243 | await Audio.setAudioModeAsync({ 244 | allowsRecordingIOS: false, 245 | }); 246 | 247 | const uri = recording.getURI(); 248 | console.log("Recording stopped and stored at", uri); 249 | if (!uri) { 250 | return; 251 | } 252 | setSoundURI(uri); 253 | } 254 | 255 | const sendAudio = async () => { 256 | if (!soundURI) { 257 | return; 258 | } 259 | const uriParts = soundURI.split("."); 260 | const extenstion = uriParts[uriParts.length - 1]; 261 | const blob = await getBlob(soundURI); 262 | const { key } = await Storage.put(`${uuidv4()}.${extenstion}`, blob, { 263 | progressCallback, 264 | }); 265 | 266 | // send message 267 | const user = await Auth.currentAuthenticatedUser(); 268 | const newMessage = await DataStore.save( 269 | new Message({ 270 | content: message, 271 | audio: key, 272 | userID: user.attributes.sub, 273 | chatroomID: chatRoom.id, 274 | status: "SENT", 275 | replyToMessageID: messageReplyTo?.id, 276 | }) 277 | ); 278 | 279 | updateLastMessage(newMessage); 280 | 281 | resetFields(); 282 | }; 283 | 284 | return ( 285 | 290 | {messageReplyTo && ( 291 | 300 | 301 | Reply to: 302 | 303 | 304 | removeMessageReplyTo()}> 305 | 311 | 312 | 313 | )} 314 | 315 | {image && ( 316 | 317 | 321 | 322 | 329 | 337 | 338 | 339 | setImage(null)}> 340 | 346 | 347 | 348 | )} 349 | 350 | {soundURI && } 351 | 352 | 353 | 354 | 356 | setIsEmojiPickerOpen((currentValue) => !currentValue) 357 | } 358 | > 359 | 365 | 366 | 367 | 373 | 374 | 375 | 381 | 382 | 383 | 384 | 390 | 391 | 392 | 393 | 399 | 400 | 401 | 402 | 403 | {message || image || soundURI ? ( 404 | 405 | ) : ( 406 | 407 | )} 408 | 409 | 410 | 411 | {isEmojiPickerOpen && ( 412 | 414 | setMessage((currentMessage) => currentMessage + emoji) 415 | } 416 | columns={8} 417 | /> 418 | )} 419 | 420 | ); 421 | }; 422 | 423 | const styles = StyleSheet.create({ 424 | root: { 425 | padding: 10, 426 | }, 427 | row: { 428 | flexDirection: "row", 429 | }, 430 | inputContainer: { 431 | backgroundColor: "#f2f2f2", 432 | flex: 1, 433 | marginRight: 10, 434 | borderRadius: 25, 435 | borderWidth: 1, 436 | borderColor: "#dedede", 437 | alignItems: "center", 438 | flexDirection: "row", 439 | padding: 5, 440 | }, 441 | input: { 442 | flex: 1, 443 | marginHorizontal: 5, 444 | }, 445 | icon: { 446 | marginHorizontal: 5, 447 | }, 448 | buttonContainer: { 449 | width: 40, 450 | height: 40, 451 | backgroundColor: "#3777f0", 452 | borderRadius: 25, 453 | justifyContent: "center", 454 | alignItems: "center", 455 | }, 456 | buttonText: { 457 | color: "white", 458 | fontSize: 35, 459 | }, 460 | 461 | sendImageContainer: { 462 | flexDirection: "row", 463 | marginVertical: 10, 464 | alignSelf: "stretch", 465 | justifyContent: "space-between", 466 | borderWidth: 1, 467 | borderColor: "lightgray", 468 | borderRadius: 10, 469 | }, 470 | }); 471 | 472 | export default MessageInput; 473 | -------------------------------------------------------------------------------- /amplify/backend/auth/SignalClone/SignalClone-cloudformation-template.yml: -------------------------------------------------------------------------------- 1 | 2 | AWSTemplateFormatVersion: 2010-09-09 3 | 4 | Parameters: 5 | env: 6 | Type: String 7 | authRoleArn: 8 | Type: String 9 | unauthRoleArn: 10 | Type: String 11 | 12 | 13 | 14 | 15 | functionSignalClonePostConfirmationArn: 16 | Type: String 17 | Default: functionSignalClonePostConfirmationArn 18 | 19 | functionSignalClonePostConfirmationName: 20 | Type: String 21 | Default: functionSignalClonePostConfirmationName 22 | 23 | 24 | 25 | 26 | 27 | identityPoolName: 28 | Type: String 29 | 30 | 31 | 32 | allowUnauthenticatedIdentities: 33 | Type: String 34 | 35 | resourceNameTruncated: 36 | Type: String 37 | 38 | 39 | userPoolName: 40 | Type: String 41 | 42 | 43 | 44 | autoVerifiedAttributes: 45 | Type: CommaDelimitedList 46 | 47 | mfaConfiguration: 48 | Type: String 49 | 50 | 51 | 52 | mfaTypes: 53 | Type: CommaDelimitedList 54 | 55 | smsAuthenticationMessage: 56 | Type: String 57 | 58 | 59 | smsVerificationMessage: 60 | Type: String 61 | 62 | 63 | emailVerificationSubject: 64 | Type: String 65 | 66 | 67 | emailVerificationMessage: 68 | Type: String 69 | 70 | 71 | 72 | defaultPasswordPolicy: 73 | Type: String 74 | 75 | 76 | passwordPolicyMinLength: 77 | Type: Number 78 | 79 | 80 | passwordPolicyCharacters: 81 | Type: CommaDelimitedList 82 | 83 | 84 | requiredAttributes: 85 | Type: CommaDelimitedList 86 | 87 | 88 | aliasAttributes: 89 | Type: CommaDelimitedList 90 | 91 | 92 | userpoolClientGenerateSecret: 93 | Type: String 94 | 95 | 96 | userpoolClientRefreshTokenValidity: 97 | Type: Number 98 | 99 | 100 | userpoolClientWriteAttributes: 101 | Type: CommaDelimitedList 102 | 103 | 104 | userpoolClientReadAttributes: 105 | Type: CommaDelimitedList 106 | 107 | userpoolClientLambdaRole: 108 | Type: String 109 | 110 | 111 | 112 | userpoolClientSetAttributes: 113 | Type: String 114 | 115 | sharedId: 116 | Type: String 117 | 118 | 119 | resourceName: 120 | Type: String 121 | 122 | 123 | authSelections: 124 | Type: String 125 | 126 | 127 | 128 | 129 | serviceName: 130 | Type: String 131 | 132 | 133 | 134 | usernameAttributes: 135 | Type: CommaDelimitedList 136 | 137 | useDefault: 138 | Type: String 139 | 140 | 141 | 142 | userPoolGroups: 143 | Type: String 144 | 145 | 146 | userPoolGroupList: 147 | Type: CommaDelimitedList 148 | 149 | 150 | adminQueries: 151 | Type: String 152 | 153 | 154 | thirdPartyAuth: 155 | Type: String 156 | 157 | 158 | authProviders: 159 | Type: CommaDelimitedList 160 | 161 | 162 | usernameCaseSensitive: 163 | Type: String 164 | 165 | 166 | dependsOn: 167 | Type: CommaDelimitedList 168 | 169 | triggers: 170 | Type: String 171 | 172 | 173 | 174 | hostedUI: 175 | Type: String 176 | 177 | 178 | 179 | parentStack: 180 | Type: String 181 | 182 | authTriggerConnections: 183 | Type: String 184 | 185 | 186 | 187 | breakCircularDependency: 188 | Type: String 189 | 190 | 191 | permissions: 192 | Type: CommaDelimitedList 193 | 194 | Conditions: 195 | ShouldNotCreateEnvResources: !Equals [ !Ref env, NONE ] 196 | 197 | ShouldOutputAppClientSecrets: !Equals [!Ref userpoolClientGenerateSecret, true ] 198 | 199 | 200 | Resources: 201 | 202 | 203 | # BEGIN SNS ROLE RESOURCE 204 | SNSRole: 205 | # Created to allow the UserPool SMS Config to publish via the Simple Notification Service during MFA Process 206 | Type: AWS::IAM::Role 207 | Properties: 208 | RoleName: !If [ShouldNotCreateEnvResources, 'signal29db2920_sns-role', !Join ['',[ 'sns', '29db2920', !Select [3, !Split ['-', !Ref 'AWS::StackName']], '-', !Ref env]]] 209 | AssumeRolePolicyDocument: 210 | Version: "2012-10-17" 211 | Statement: 212 | - Sid: "" 213 | Effect: "Allow" 214 | Principal: 215 | Service: "cognito-idp.amazonaws.com" 216 | Action: 217 | - "sts:AssumeRole" 218 | Condition: 219 | StringEquals: 220 | sts:ExternalId: signal29db2920_role_external_id 221 | Policies: 222 | - 223 | PolicyName: signal29db2920-sns-policy 224 | PolicyDocument: 225 | Version: "2012-10-17" 226 | Statement: 227 | - 228 | Effect: "Allow" 229 | Action: 230 | - "sns:Publish" 231 | Resource: "*" 232 | # BEGIN USER POOL RESOURCES 233 | UserPool: 234 | # Created upon user selection 235 | # Depends on SNS Role for Arn if MFA is enabled 236 | Type: AWS::Cognito::UserPool 237 | UpdateReplacePolicy: Retain 238 | Properties: 239 | UserPoolName: !If [ShouldNotCreateEnvResources, !Ref userPoolName, !Join ['',[!Ref userPoolName, '-', !Ref env]]] 240 | 241 | 242 | UsernameConfiguration: 243 | CaseSensitive: false 244 | 245 | Schema: 246 | 247 | - 248 | Name: email 249 | Required: true 250 | Mutable: true 251 | 252 | 253 | 254 | 255 | AutoVerifiedAttributes: 256 | 257 | - email 258 | 259 | 260 | 261 | EmailVerificationMessage: !Ref emailVerificationMessage 262 | EmailVerificationSubject: !Ref emailVerificationSubject 263 | 264 | Policies: 265 | PasswordPolicy: 266 | MinimumLength: !Ref passwordPolicyMinLength 267 | RequireLowercase: false 268 | RequireNumbers: false 269 | RequireSymbols: false 270 | RequireUppercase: false 271 | 272 | UsernameAttributes: !Ref usernameAttributes 273 | 274 | 275 | MfaConfiguration: !Ref mfaConfiguration 276 | SmsVerificationMessage: !Ref smsVerificationMessage 277 | SmsAuthenticationMessage: !Ref smsAuthenticationMessage 278 | SmsConfiguration: 279 | SnsCallerArn: !GetAtt SNSRole.Arn 280 | ExternalId: signal29db2920_role_external_id 281 | 282 | 283 | UserPoolClientWeb: 284 | # Created provide application access to user pool 285 | # Depends on UserPool for ID reference 286 | Type: "AWS::Cognito::UserPoolClient" 287 | Properties: 288 | ClientName: signal29db2920_app_clientWeb 289 | 290 | RefreshTokenValidity: !Ref userpoolClientRefreshTokenValidity 291 | UserPoolId: !Ref UserPool 292 | DependsOn: UserPool 293 | UserPoolClient: 294 | # Created provide application access to user pool 295 | # Depends on UserPool for ID reference 296 | Type: "AWS::Cognito::UserPoolClient" 297 | Properties: 298 | ClientName: signal29db2920_app_client 299 | 300 | GenerateSecret: !Ref userpoolClientGenerateSecret 301 | RefreshTokenValidity: !Ref userpoolClientRefreshTokenValidity 302 | UserPoolId: !Ref UserPool 303 | DependsOn: UserPool 304 | # BEGIN USER POOL LAMBDA RESOURCES 305 | UserPoolClientRole: 306 | # Created to execute Lambda which gets userpool app client config values 307 | Type: 'AWS::IAM::Role' 308 | Properties: 309 | RoleName: !If [ShouldNotCreateEnvResources, !Ref userpoolClientLambdaRole, !Join ['',['upClientLambdaRole', '29db2920', !Select [3, !Split ['-', !Ref 'AWS::StackName']], '-', !Ref env]]] 310 | AssumeRolePolicyDocument: 311 | Version: '2012-10-17' 312 | Statement: 313 | - Effect: Allow 314 | Principal: 315 | Service: 316 | - lambda.amazonaws.com 317 | Action: 318 | - 'sts:AssumeRole' 319 | DependsOn: UserPoolClient 320 | UserPoolClientLambda: 321 | # Lambda which gets userpool app client config values 322 | # Depends on UserPool for id 323 | # Depends on UserPoolClientRole for role ARN 324 | Type: 'AWS::Lambda::Function' 325 | Properties: 326 | Code: 327 | ZipFile: !Join 328 | - |+ 329 | - - 'const response = require(''cfn-response'');' 330 | - 'const aws = require(''aws-sdk'');' 331 | - 'const identity = new aws.CognitoIdentityServiceProvider();' 332 | - 'exports.handler = (event, context, callback) => {' 333 | - ' if (event.RequestType == ''Delete'') { ' 334 | - ' response.send(event, context, response.SUCCESS, {})' 335 | - ' }' 336 | - ' if (event.RequestType == ''Update'' || event.RequestType == ''Create'') {' 337 | - ' const params = {' 338 | - ' ClientId: event.ResourceProperties.clientId,' 339 | - ' UserPoolId: event.ResourceProperties.userpoolId' 340 | - ' };' 341 | - ' identity.describeUserPoolClient(params).promise()' 342 | - ' .then((res) => {' 343 | - ' response.send(event, context, response.SUCCESS, {''appSecret'': res.UserPoolClient.ClientSecret});' 344 | - ' })' 345 | - ' .catch((err) => {' 346 | - ' response.send(event, context, response.FAILED, {err});' 347 | - ' });' 348 | - ' }' 349 | - '};' 350 | Handler: index.handler 351 | Runtime: nodejs12.x 352 | Timeout: 300 353 | Role: !GetAtt 354 | - UserPoolClientRole 355 | - Arn 356 | DependsOn: UserPoolClientRole 357 | UserPoolClientLambdaPolicy: 358 | # Sets userpool policy for the role that executes the Userpool Client Lambda 359 | # Depends on UserPool for Arn 360 | # Marked as depending on UserPoolClientRole for easier to understand CFN sequencing 361 | Type: 'AWS::IAM::Policy' 362 | Properties: 363 | PolicyName: signal29db2920_userpoolclient_lambda_iam_policy 364 | Roles: 365 | - !Ref UserPoolClientRole 366 | PolicyDocument: 367 | Version: '2012-10-17' 368 | Statement: 369 | - Effect: Allow 370 | Action: 371 | - 'cognito-idp:DescribeUserPoolClient' 372 | Resource: !GetAtt UserPool.Arn 373 | DependsOn: UserPoolClientLambda 374 | UserPoolClientLogPolicy: 375 | # Sets log policy for the role that executes the Userpool Client Lambda 376 | # Depends on UserPool for Arn 377 | # Marked as depending on UserPoolClientLambdaPolicy for easier to understand CFN sequencing 378 | Type: 'AWS::IAM::Policy' 379 | Properties: 380 | PolicyName: signal29db2920_userpoolclient_lambda_log_policy 381 | Roles: 382 | - !Ref UserPoolClientRole 383 | PolicyDocument: 384 | Version: 2012-10-17 385 | Statement: 386 | - Effect: Allow 387 | Action: 388 | - 'logs:CreateLogGroup' 389 | - 'logs:CreateLogStream' 390 | - 'logs:PutLogEvents' 391 | Resource: !Sub 392 | - arn:aws:logs:${region}:${account}:log-group:/aws/lambda/${lambda}:log-stream:* 393 | - { region: !Ref "AWS::Region", account: !Ref "AWS::AccountId", lambda: !Ref UserPoolClientLambda} 394 | DependsOn: UserPoolClientLambdaPolicy 395 | UserPoolClientInputs: 396 | # Values passed to Userpool client Lambda 397 | # Depends on UserPool for Id 398 | # Depends on UserPoolClient for Id 399 | # Marked as depending on UserPoolClientLambdaPolicy for easier to understand CFN sequencing 400 | Type: 'Custom::LambdaCallout' 401 | Properties: 402 | ServiceToken: !GetAtt UserPoolClientLambda.Arn 403 | clientId: !Ref UserPoolClient 404 | userpoolId: !Ref UserPool 405 | DependsOn: UserPoolClientLogPolicy 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | # BEGIN IDENTITY POOL RESOURCES 414 | 415 | 416 | IdentityPool: 417 | # Always created 418 | Type: AWS::Cognito::IdentityPool 419 | Properties: 420 | IdentityPoolName: !If [ShouldNotCreateEnvResources, 'testAuthIdentityPool', !Join ['',['testAuthIdentityPool', '__', !Ref env]]] 421 | 422 | CognitoIdentityProviders: 423 | - ClientId: !Ref UserPoolClient 424 | ProviderName: !Sub 425 | - cognito-idp.${region}.amazonaws.com/${client} 426 | - { region: !Ref "AWS::Region", client: !Ref UserPool} 427 | - ClientId: !Ref UserPoolClientWeb 428 | ProviderName: !Sub 429 | - cognito-idp.${region}.amazonaws.com/${client} 430 | - { region: !Ref "AWS::Region", client: !Ref UserPool} 431 | 432 | AllowUnauthenticatedIdentities: !Ref allowUnauthenticatedIdentities 433 | 434 | 435 | DependsOn: UserPoolClientInputs 436 | 437 | 438 | IdentityPoolRoleMap: 439 | # Created to map Auth and Unauth roles to the identity pool 440 | # Depends on Identity Pool for ID ref 441 | Type: AWS::Cognito::IdentityPoolRoleAttachment 442 | Properties: 443 | IdentityPoolId: !Ref IdentityPool 444 | Roles: 445 | unauthenticated: !Ref unauthRoleArn 446 | authenticated: !Ref authRoleArn 447 | DependsOn: IdentityPool 448 | 449 | 450 | Outputs : 451 | 452 | IdentityPoolId: 453 | Value: !Ref 'IdentityPool' 454 | Description: Id for the identity pool 455 | IdentityPoolName: 456 | Value: !GetAtt IdentityPool.Name 457 | 458 | 459 | 460 | 461 | UserPoolId: 462 | Value: !Ref 'UserPool' 463 | Description: Id for the user pool 464 | UserPoolArn: 465 | Value: !GetAtt UserPool.Arn 466 | Description: Arn for the user pool 467 | UserPoolName: 468 | Value: !Ref userPoolName 469 | AppClientIDWeb: 470 | Value: !Ref 'UserPoolClientWeb' 471 | Description: The user pool app client id for web 472 | AppClientID: 473 | Value: !Ref 'UserPoolClient' 474 | Description: The user pool app client id 475 | AppClientSecret: 476 | Value: !GetAtt UserPoolClientInputs.appSecret 477 | Condition: ShouldOutputAppClientSecrets 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | -------------------------------------------------------------------------------- /amplify/backend/storage/s39381675c/s3-cloudformation-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "S3 resource stack creation using Amplify CLI", 4 | "Parameters": { 5 | "bucketName": { 6 | "Type": "String" 7 | }, 8 | "authPolicyName": { 9 | "Type": "String" 10 | }, 11 | "unauthPolicyName": { 12 | "Type": "String" 13 | }, 14 | "authRoleName": { 15 | "Type": "String" 16 | }, 17 | "unauthRoleName": { 18 | "Type": "String" 19 | }, 20 | "s3PublicPolicy": { 21 | "Type": "String", 22 | "Default" : "NONE" 23 | }, 24 | "s3PrivatePolicy": { 25 | "Type": "String", 26 | "Default" : "NONE" 27 | }, 28 | "s3ProtectedPolicy": { 29 | "Type": "String", 30 | "Default" : "NONE" 31 | }, 32 | "s3UploadsPolicy": { 33 | "Type": "String", 34 | "Default" : "NONE" 35 | }, 36 | "s3ReadPolicy": { 37 | "Type": "String", 38 | "Default" : "NONE" 39 | }, 40 | "s3PermissionsAuthenticatedPublic": { 41 | "Type": "String", 42 | "Default" : "DISALLOW" 43 | }, 44 | "s3PermissionsAuthenticatedProtected": { 45 | "Type": "String", 46 | "Default" : "DISALLOW" 47 | }, 48 | "s3PermissionsAuthenticatedPrivate": { 49 | "Type": "String", 50 | "Default" : "DISALLOW" 51 | }, 52 | "s3PermissionsAuthenticatedUploads": { 53 | "Type": "String", 54 | "Default" : "DISALLOW" 55 | }, 56 | "s3PermissionsGuestPublic": { 57 | "Type": "String", 58 | "Default" : "DISALLOW" 59 | }, 60 | "s3PermissionsGuestUploads": { 61 | "Type": "String", 62 | "Default" : "DISALLOW" }, 63 | "AuthenticatedAllowList": { 64 | "Type": "String", 65 | "Default" : "DISALLOW" 66 | }, 67 | "GuestAllowList": { 68 | "Type": "String", 69 | "Default" : "DISALLOW" 70 | }, 71 | "selectedGuestPermissions": { 72 | "Type": "CommaDelimitedList", 73 | "Default" : "NONE" 74 | }, 75 | "selectedAuthenticatedPermissions": { 76 | "Type": "CommaDelimitedList", 77 | "Default" : "NONE" 78 | }, 79 | "env": { 80 | "Type": "String" 81 | }, 82 | "triggerFunction": { 83 | "Type": "String" 84 | } 85 | 86 | 87 | }, 88 | "Conditions": { 89 | "ShouldNotCreateEnvResources": { 90 | "Fn::Equals": [ 91 | { 92 | "Ref": "env" 93 | }, 94 | "NONE" 95 | ] 96 | }, 97 | "CreateAuthPublic": { 98 | "Fn::Not" : [{ 99 | "Fn::Equals" : [ 100 | {"Ref" : "s3PermissionsAuthenticatedPublic"}, 101 | "DISALLOW" 102 | ] 103 | }] 104 | }, 105 | "CreateAuthProtected": { 106 | "Fn::Not" : [{ 107 | "Fn::Equals" : [ 108 | {"Ref" : "s3PermissionsAuthenticatedProtected"}, 109 | "DISALLOW" 110 | ] 111 | }] 112 | }, 113 | "CreateAuthPrivate": { 114 | "Fn::Not" : [{ 115 | "Fn::Equals" : [ 116 | {"Ref" : "s3PermissionsAuthenticatedPrivate"}, 117 | "DISALLOW" 118 | ] 119 | }] 120 | }, 121 | "CreateAuthUploads": { 122 | "Fn::Not" : [{ 123 | "Fn::Equals" : [ 124 | {"Ref" : "s3PermissionsAuthenticatedUploads"}, 125 | "DISALLOW" 126 | ] 127 | }] 128 | }, 129 | "CreateGuestPublic": { 130 | "Fn::Not" : [{ 131 | "Fn::Equals" : [ 132 | {"Ref" : "s3PermissionsGuestPublic"}, 133 | "DISALLOW" 134 | ] 135 | }] 136 | }, 137 | "CreateGuestUploads": { 138 | "Fn::Not" : [{ 139 | "Fn::Equals" : [ 140 | {"Ref" : "s3PermissionsGuestUploads"}, 141 | "DISALLOW" 142 | ] 143 | }] 144 | }, 145 | "AuthReadAndList": { 146 | "Fn::Not" : [{ 147 | "Fn::Equals" : [ 148 | {"Ref" : "AuthenticatedAllowList"}, 149 | "DISALLOW" 150 | ] 151 | }] 152 | }, 153 | "GuestReadAndList": { 154 | "Fn::Not" : [{ 155 | "Fn::Equals" : [ 156 | {"Ref" : "GuestAllowList"}, 157 | "DISALLOW" 158 | ] 159 | }] 160 | } 161 | }, 162 | "Resources": { 163 | "S3Bucket": { 164 | "Type": "AWS::S3::Bucket", 165 | 166 | "DeletionPolicy" : "Retain", 167 | "Properties": { 168 | "BucketName": { 169 | "Fn::If": [ 170 | "ShouldNotCreateEnvResources", 171 | { 172 | "Ref": "bucketName" 173 | }, 174 | { 175 | "Fn::Join": [ 176 | "", 177 | [ 178 | { 179 | "Ref": "bucketName" 180 | }, 181 | { 182 | "Fn::Select": [ 183 | 3, 184 | { 185 | "Fn::Split": [ 186 | "-", 187 | { 188 | "Ref": "AWS::StackName" 189 | } 190 | ] 191 | } 192 | ] 193 | }, 194 | "-", 195 | { 196 | "Ref": "env" 197 | } 198 | ] 199 | ] 200 | } 201 | ] 202 | }, 203 | 204 | "CorsConfiguration": { 205 | "CorsRules": [ 206 | { 207 | "AllowedHeaders": [ 208 | "*" 209 | ], 210 | "AllowedMethods": [ 211 | "GET", 212 | "HEAD", 213 | "PUT", 214 | "POST", 215 | "DELETE" 216 | ], 217 | "AllowedOrigins": [ 218 | "*" 219 | ], 220 | "ExposedHeaders": [ 221 | "x-amz-server-side-encryption", 222 | "x-amz-request-id", 223 | "x-amz-id-2", 224 | "ETag" 225 | ], 226 | "Id": "S3CORSRuleId1", 227 | "MaxAge": "3000" 228 | } 229 | ] 230 | } 231 | } 232 | }, 233 | 234 | 235 | "S3AuthPublicPolicy": { 236 | "DependsOn": [ 237 | "S3Bucket" 238 | ], 239 | "Condition": "CreateAuthPublic", 240 | "Type": "AWS::IAM::Policy", 241 | "Properties": { 242 | "PolicyName": { 243 | "Ref": "s3PublicPolicy" 244 | }, 245 | "Roles": [ 246 | { 247 | "Ref": "authRoleName" 248 | } 249 | ], 250 | "PolicyDocument": { 251 | "Version": "2012-10-17", 252 | "Statement": [ 253 | { 254 | "Effect": "Allow", 255 | "Action": { 256 | "Fn::Split" : [ "," , { 257 | "Ref": "s3PermissionsAuthenticatedPublic" 258 | } ] 259 | }, 260 | "Resource": [ 261 | { 262 | "Fn::Join": [ 263 | "", 264 | [ 265 | "arn:aws:s3:::", 266 | { 267 | "Ref": "S3Bucket" 268 | }, 269 | "/public/*" 270 | ] 271 | ] 272 | } 273 | ] 274 | } 275 | ] 276 | } 277 | } 278 | }, 279 | "S3AuthProtectedPolicy": { 280 | "DependsOn": [ 281 | "S3Bucket" 282 | ], 283 | "Condition": "CreateAuthProtected", 284 | "Type": "AWS::IAM::Policy", 285 | "Properties": { 286 | "PolicyName": { 287 | "Ref": "s3ProtectedPolicy" 288 | }, 289 | "Roles": [ 290 | { 291 | "Ref": "authRoleName" 292 | } 293 | ], 294 | "PolicyDocument": { 295 | "Version": "2012-10-17", 296 | "Statement": [ 297 | { 298 | "Effect": "Allow", 299 | "Action": { 300 | "Fn::Split" : [ "," , { 301 | "Ref": "s3PermissionsAuthenticatedProtected" 302 | } ] 303 | }, 304 | "Resource": [ 305 | { 306 | "Fn::Join": [ 307 | "", 308 | [ 309 | "arn:aws:s3:::", 310 | { 311 | "Ref": "S3Bucket" 312 | }, 313 | "/protected/${cognito-identity.amazonaws.com:sub}/*" 314 | ] 315 | ] 316 | } 317 | ] 318 | } 319 | ] 320 | } 321 | } 322 | }, 323 | "S3AuthPrivatePolicy": { 324 | "DependsOn": [ 325 | "S3Bucket" 326 | ], 327 | "Condition": "CreateAuthPrivate", 328 | "Type": "AWS::IAM::Policy", 329 | "Properties": { 330 | "PolicyName": { 331 | "Ref": "s3PrivatePolicy" 332 | }, 333 | "Roles": [ 334 | { 335 | "Ref": "authRoleName" 336 | } 337 | ], 338 | "PolicyDocument": { 339 | "Version": "2012-10-17", 340 | "Statement": [ 341 | { 342 | "Effect": "Allow", 343 | "Action": { 344 | "Fn::Split" : [ "," , { 345 | "Ref": "s3PermissionsAuthenticatedPrivate" 346 | } ] 347 | }, 348 | "Resource": [ 349 | { 350 | "Fn::Join": [ 351 | "", 352 | [ 353 | "arn:aws:s3:::", 354 | { 355 | "Ref": "S3Bucket" 356 | }, 357 | "/private/${cognito-identity.amazonaws.com:sub}/*" 358 | ] 359 | ] 360 | } 361 | ] 362 | } 363 | ] 364 | } 365 | } 366 | }, 367 | "S3AuthUploadPolicy": { 368 | "DependsOn": [ 369 | "S3Bucket" 370 | ], 371 | "Condition": "CreateAuthUploads", 372 | "Type": "AWS::IAM::Policy", 373 | "Properties": { 374 | "PolicyName": { 375 | "Ref": "s3UploadsPolicy" 376 | }, 377 | "Roles": [ 378 | { 379 | "Ref": "authRoleName" 380 | } 381 | ], 382 | "PolicyDocument": { 383 | "Version": "2012-10-17", 384 | "Statement": [ 385 | { 386 | "Effect": "Allow", 387 | "Action": { 388 | "Fn::Split" : [ "," , { 389 | "Ref": "s3PermissionsAuthenticatedUploads" 390 | } ] 391 | }, 392 | "Resource": [ 393 | { 394 | "Fn::Join": [ 395 | "", 396 | [ 397 | "arn:aws:s3:::", 398 | { 399 | "Ref": "S3Bucket" 400 | }, 401 | "/uploads/*" 402 | ] 403 | ] 404 | } 405 | ] 406 | } 407 | ] 408 | } 409 | } 410 | }, 411 | "S3AuthReadPolicy": { 412 | "DependsOn": [ 413 | "S3Bucket" 414 | ], 415 | "Condition": "AuthReadAndList", 416 | "Type": "AWS::IAM::Policy", 417 | "Properties": { 418 | "PolicyName": { 419 | "Ref": "s3ReadPolicy" 420 | }, 421 | "Roles": [ 422 | { 423 | "Ref": "authRoleName" 424 | } 425 | ], 426 | "PolicyDocument": { 427 | "Version": "2012-10-17", 428 | "Statement": [ 429 | { 430 | "Effect": "Allow", 431 | "Action": [ 432 | "s3:GetObject" 433 | ], 434 | "Resource": [ 435 | { 436 | "Fn::Join": [ 437 | "", 438 | [ 439 | "arn:aws:s3:::", 440 | { 441 | "Ref": "S3Bucket" 442 | }, 443 | "/protected/*" 444 | ] 445 | ] 446 | } 447 | ] 448 | }, 449 | { 450 | "Effect": "Allow", 451 | "Action": [ 452 | "s3:ListBucket" 453 | ], 454 | "Resource": [ 455 | { 456 | "Fn::Join": [ 457 | "", 458 | [ 459 | "arn:aws:s3:::", 460 | { 461 | "Ref": "S3Bucket" 462 | } 463 | ] 464 | ] 465 | } 466 | ], 467 | "Condition": { 468 | "StringLike": { 469 | "s3:prefix": [ 470 | "public/", 471 | "public/*", 472 | "protected/", 473 | "protected/*", 474 | "private/${cognito-identity.amazonaws.com:sub}/", 475 | "private/${cognito-identity.amazonaws.com:sub}/*" 476 | ] 477 | } 478 | } 479 | } 480 | ] 481 | } 482 | } 483 | }, 484 | "S3GuestPublicPolicy": { 485 | "DependsOn": [ 486 | "S3Bucket" 487 | ], 488 | "Condition": "CreateGuestPublic", 489 | "Type": "AWS::IAM::Policy", 490 | "Properties": { 491 | "PolicyName": { 492 | "Ref": "s3PublicPolicy" 493 | }, 494 | "Roles": [ 495 | { 496 | "Ref": "unauthRoleName" 497 | } 498 | ], 499 | "PolicyDocument": { 500 | "Version": "2012-10-17", 501 | "Statement": [ 502 | { 503 | "Effect": "Allow", 504 | "Action": { 505 | "Fn::Split" : [ "," , { 506 | "Ref": "s3PermissionsGuestPublic" 507 | } ] 508 | }, 509 | "Resource": [ 510 | { 511 | "Fn::Join": [ 512 | "", 513 | [ 514 | "arn:aws:s3:::", 515 | { 516 | "Ref": "S3Bucket" 517 | }, 518 | "/public/*" 519 | ] 520 | ] 521 | } 522 | ] 523 | } 524 | ] 525 | } 526 | } 527 | }, 528 | "S3GuestUploadPolicy": { 529 | "DependsOn": [ 530 | "S3Bucket" 531 | ], 532 | "Condition": "CreateGuestUploads", 533 | "Type": "AWS::IAM::Policy", 534 | "Properties": { 535 | "PolicyName": { 536 | "Ref": "s3UploadsPolicy" 537 | }, 538 | "Roles": [ 539 | { 540 | "Ref": "unauthRoleName" 541 | } 542 | ], 543 | "PolicyDocument": { 544 | "Version": "2012-10-17", 545 | "Statement": [ 546 | { 547 | "Effect": "Allow", 548 | "Action": { 549 | "Fn::Split" : [ "," , { 550 | "Ref": "s3PermissionsGuestUploads" 551 | } ] 552 | }, 553 | "Resource": [ 554 | { 555 | "Fn::Join": [ 556 | "", 557 | [ 558 | "arn:aws:s3:::", 559 | { 560 | "Ref": "S3Bucket" 561 | }, 562 | "/uploads/*" 563 | ] 564 | ] 565 | } 566 | ] 567 | } 568 | ] 569 | } 570 | } 571 | }, 572 | "S3GuestReadPolicy": { 573 | "DependsOn": [ 574 | "S3Bucket" 575 | ], 576 | "Condition": "GuestReadAndList", 577 | "Type": "AWS::IAM::Policy", 578 | "Properties": { 579 | "PolicyName": { 580 | "Ref": "s3ReadPolicy" 581 | }, 582 | "Roles": [ 583 | { 584 | "Ref": "unauthRoleName" 585 | } 586 | ], 587 | "PolicyDocument": { 588 | "Version": "2012-10-17", 589 | "Statement": [ 590 | { 591 | "Effect": "Allow", 592 | "Action": [ 593 | "s3:GetObject" 594 | ], 595 | "Resource": [ 596 | { 597 | "Fn::Join": [ 598 | "", 599 | [ 600 | "arn:aws:s3:::", 601 | { 602 | "Ref": "S3Bucket" 603 | }, 604 | "/protected/*" 605 | ] 606 | ] 607 | } 608 | ] 609 | }, 610 | { 611 | "Effect": "Allow", 612 | "Action": [ 613 | "s3:ListBucket" 614 | ], 615 | "Resource": [ 616 | { 617 | "Fn::Join": [ 618 | "", 619 | [ 620 | "arn:aws:s3:::", 621 | { 622 | "Ref": "S3Bucket" 623 | } 624 | ] 625 | ] 626 | } 627 | ], 628 | "Condition": { 629 | "StringLike": { 630 | "s3:prefix": [ 631 | "public/", 632 | "public/*", 633 | "protected/", 634 | "protected/*" 635 | ] 636 | } 637 | } 638 | } 639 | ] 640 | } 641 | } 642 | } 643 | }, 644 | "Outputs": { 645 | "BucketName": { 646 | "Value": { 647 | "Ref": "S3Bucket" 648 | }, 649 | "Description": "Bucket name for the S3 bucket" 650 | }, 651 | "Region": { 652 | "Value": { 653 | "Ref": "AWS::Region" 654 | } 655 | } 656 | } 657 | } 658 | -------------------------------------------------------------------------------- /src/models/schema.js: -------------------------------------------------------------------------------- 1 | export const schema = { 2 | "models": { 3 | "Message": { 4 | "name": "Message", 5 | "fields": { 6 | "id": { 7 | "name": "id", 8 | "isArray": false, 9 | "type": "ID", 10 | "isRequired": true, 11 | "attributes": [] 12 | }, 13 | "content": { 14 | "name": "content", 15 | "isArray": false, 16 | "type": "String", 17 | "isRequired": false, 18 | "attributes": [] 19 | }, 20 | "userID": { 21 | "name": "userID", 22 | "isArray": false, 23 | "type": "ID", 24 | "isRequired": false, 25 | "attributes": [] 26 | }, 27 | "chatroomID": { 28 | "name": "chatroomID", 29 | "isArray": false, 30 | "type": "ID", 31 | "isRequired": false, 32 | "attributes": [] 33 | }, 34 | "image": { 35 | "name": "image", 36 | "isArray": false, 37 | "type": "String", 38 | "isRequired": false, 39 | "attributes": [] 40 | }, 41 | "audio": { 42 | "name": "audio", 43 | "isArray": false, 44 | "type": "String", 45 | "isRequired": false, 46 | "attributes": [] 47 | }, 48 | "status": { 49 | "name": "status", 50 | "isArray": false, 51 | "type": { 52 | "enum": "MessageStatus" 53 | }, 54 | "isRequired": false, 55 | "attributes": [] 56 | }, 57 | "replyToMessageID": { 58 | "name": "replyToMessageID", 59 | "isArray": false, 60 | "type": "ID", 61 | "isRequired": false, 62 | "attributes": [] 63 | }, 64 | "forUserId": { 65 | "name": "forUserId", 66 | "isArray": false, 67 | "type": "ID", 68 | "isRequired": false, 69 | "attributes": [] 70 | }, 71 | "createdAt": { 72 | "name": "createdAt", 73 | "isArray": false, 74 | "type": "AWSDateTime", 75 | "isRequired": false, 76 | "attributes": [], 77 | "isReadOnly": true 78 | }, 79 | "updatedAt": { 80 | "name": "updatedAt", 81 | "isArray": false, 82 | "type": "AWSDateTime", 83 | "isRequired": false, 84 | "attributes": [], 85 | "isReadOnly": true 86 | } 87 | }, 88 | "syncable": true, 89 | "pluralName": "Messages", 90 | "attributes": [ 91 | { 92 | "type": "model", 93 | "properties": {} 94 | }, 95 | { 96 | "type": "key", 97 | "properties": { 98 | "name": "byUser", 99 | "fields": [ 100 | "userID" 101 | ] 102 | } 103 | }, 104 | { 105 | "type": "key", 106 | "properties": { 107 | "name": "byChatRoom", 108 | "fields": [ 109 | "chatroomID" 110 | ] 111 | } 112 | }, 113 | { 114 | "type": "auth", 115 | "properties": { 116 | "rules": [ 117 | { 118 | "allow": "public", 119 | "operations": [ 120 | "create", 121 | "update", 122 | "delete", 123 | "read" 124 | ] 125 | } 126 | ] 127 | } 128 | } 129 | ] 130 | }, 131 | "ChatRoom": { 132 | "name": "ChatRoom", 133 | "fields": { 134 | "id": { 135 | "name": "id", 136 | "isArray": false, 137 | "type": "ID", 138 | "isRequired": true, 139 | "attributes": [] 140 | }, 141 | "newMessages": { 142 | "name": "newMessages", 143 | "isArray": false, 144 | "type": "Int", 145 | "isRequired": false, 146 | "attributes": [] 147 | }, 148 | "LastMessage": { 149 | "name": "LastMessage", 150 | "isArray": false, 151 | "type": { 152 | "model": "Message" 153 | }, 154 | "isRequired": false, 155 | "attributes": [], 156 | "association": { 157 | "connectionType": "BELONGS_TO", 158 | "targetName": "chatRoomLastMessageId" 159 | } 160 | }, 161 | "Messages": { 162 | "name": "Messages", 163 | "isArray": true, 164 | "type": { 165 | "model": "Message" 166 | }, 167 | "isRequired": false, 168 | "attributes": [], 169 | "isArrayNullable": true, 170 | "association": { 171 | "connectionType": "HAS_MANY", 172 | "associatedWith": "chatroomID" 173 | } 174 | }, 175 | "ChatRoomUsers": { 176 | "name": "ChatRoomUsers", 177 | "isArray": true, 178 | "type": { 179 | "model": "ChatRoomUser" 180 | }, 181 | "isRequired": false, 182 | "attributes": [], 183 | "isArrayNullable": true, 184 | "association": { 185 | "connectionType": "HAS_MANY", 186 | "associatedWith": "chatroom" 187 | } 188 | }, 189 | "Admin": { 190 | "name": "Admin", 191 | "isArray": false, 192 | "type": { 193 | "model": "User" 194 | }, 195 | "isRequired": false, 196 | "attributes": [], 197 | "association": { 198 | "connectionType": "BELONGS_TO", 199 | "targetName": "chatRoomAdminId" 200 | } 201 | }, 202 | "name": { 203 | "name": "name", 204 | "isArray": false, 205 | "type": "String", 206 | "isRequired": false, 207 | "attributes": [] 208 | }, 209 | "imageUri": { 210 | "name": "imageUri", 211 | "isArray": false, 212 | "type": "String", 213 | "isRequired": false, 214 | "attributes": [] 215 | }, 216 | "createdAt": { 217 | "name": "createdAt", 218 | "isArray": false, 219 | "type": "AWSDateTime", 220 | "isRequired": false, 221 | "attributes": [], 222 | "isReadOnly": true 223 | }, 224 | "updatedAt": { 225 | "name": "updatedAt", 226 | "isArray": false, 227 | "type": "AWSDateTime", 228 | "isRequired": false, 229 | "attributes": [], 230 | "isReadOnly": true 231 | } 232 | }, 233 | "syncable": true, 234 | "pluralName": "ChatRooms", 235 | "attributes": [ 236 | { 237 | "type": "model", 238 | "properties": {} 239 | }, 240 | { 241 | "type": "auth", 242 | "properties": { 243 | "rules": [ 244 | { 245 | "allow": "public", 246 | "operations": [ 247 | "create", 248 | "update", 249 | "delete", 250 | "read" 251 | ] 252 | } 253 | ] 254 | } 255 | } 256 | ] 257 | }, 258 | "ChatRoomUser": { 259 | "name": "ChatRoomUser", 260 | "fields": { 261 | "id": { 262 | "name": "id", 263 | "isArray": false, 264 | "type": "ID", 265 | "isRequired": true, 266 | "attributes": [] 267 | }, 268 | "chatroom": { 269 | "name": "chatroom", 270 | "isArray": false, 271 | "type": { 272 | "model": "ChatRoom" 273 | }, 274 | "isRequired": true, 275 | "attributes": [], 276 | "association": { 277 | "connectionType": "BELONGS_TO", 278 | "targetName": "chatroomID" 279 | } 280 | }, 281 | "user": { 282 | "name": "user", 283 | "isArray": false, 284 | "type": { 285 | "model": "User" 286 | }, 287 | "isRequired": true, 288 | "attributes": [], 289 | "association": { 290 | "connectionType": "BELONGS_TO", 291 | "targetName": "userID" 292 | } 293 | }, 294 | "createdAt": { 295 | "name": "createdAt", 296 | "isArray": false, 297 | "type": "AWSDateTime", 298 | "isRequired": false, 299 | "attributes": [], 300 | "isReadOnly": true 301 | }, 302 | "updatedAt": { 303 | "name": "updatedAt", 304 | "isArray": false, 305 | "type": "AWSDateTime", 306 | "isRequired": false, 307 | "attributes": [], 308 | "isReadOnly": true 309 | } 310 | }, 311 | "syncable": true, 312 | "pluralName": "ChatRoomUsers", 313 | "attributes": [ 314 | { 315 | "type": "model", 316 | "properties": { 317 | "queries": null 318 | } 319 | }, 320 | { 321 | "type": "key", 322 | "properties": { 323 | "name": "byChatRoom", 324 | "fields": [ 325 | "chatroomID", 326 | "userID" 327 | ] 328 | } 329 | }, 330 | { 331 | "type": "key", 332 | "properties": { 333 | "name": "byUser", 334 | "fields": [ 335 | "userID", 336 | "chatroomID" 337 | ] 338 | } 339 | }, 340 | { 341 | "type": "auth", 342 | "properties": { 343 | "rules": [ 344 | { 345 | "allow": "public", 346 | "operations": [ 347 | "create", 348 | "update", 349 | "delete", 350 | "read" 351 | ] 352 | }, 353 | { 354 | "allow": "public", 355 | "operations": [ 356 | "create", 357 | "update", 358 | "delete", 359 | "read" 360 | ] 361 | } 362 | ] 363 | } 364 | } 365 | ] 366 | }, 367 | "User": { 368 | "name": "User", 369 | "fields": { 370 | "id": { 371 | "name": "id", 372 | "isArray": false, 373 | "type": "ID", 374 | "isRequired": true, 375 | "attributes": [] 376 | }, 377 | "name": { 378 | "name": "name", 379 | "isArray": false, 380 | "type": "String", 381 | "isRequired": true, 382 | "attributes": [] 383 | }, 384 | "imageUri": { 385 | "name": "imageUri", 386 | "isArray": false, 387 | "type": "String", 388 | "isRequired": false, 389 | "attributes": [] 390 | }, 391 | "status": { 392 | "name": "status", 393 | "isArray": false, 394 | "type": "String", 395 | "isRequired": false, 396 | "attributes": [] 397 | }, 398 | "Messages": { 399 | "name": "Messages", 400 | "isArray": true, 401 | "type": { 402 | "model": "Message" 403 | }, 404 | "isRequired": false, 405 | "attributes": [], 406 | "isArrayNullable": true, 407 | "association": { 408 | "connectionType": "HAS_MANY", 409 | "associatedWith": "userID" 410 | } 411 | }, 412 | "chatrooms": { 413 | "name": "chatrooms", 414 | "isArray": true, 415 | "type": { 416 | "model": "ChatRoomUser" 417 | }, 418 | "isRequired": false, 419 | "attributes": [], 420 | "isArrayNullable": true, 421 | "association": { 422 | "connectionType": "HAS_MANY", 423 | "associatedWith": "user" 424 | } 425 | }, 426 | "lastOnlineAt": { 427 | "name": "lastOnlineAt", 428 | "isArray": false, 429 | "type": "AWSTimestamp", 430 | "isRequired": false, 431 | "attributes": [] 432 | }, 433 | "publicKey": { 434 | "name": "publicKey", 435 | "isArray": false, 436 | "type": "String", 437 | "isRequired": false, 438 | "attributes": [] 439 | }, 440 | "createdAt": { 441 | "name": "createdAt", 442 | "isArray": false, 443 | "type": "AWSDateTime", 444 | "isRequired": false, 445 | "attributes": [], 446 | "isReadOnly": true 447 | }, 448 | "updatedAt": { 449 | "name": "updatedAt", 450 | "isArray": false, 451 | "type": "AWSDateTime", 452 | "isRequired": false, 453 | "attributes": [], 454 | "isReadOnly": true 455 | } 456 | }, 457 | "syncable": true, 458 | "pluralName": "Users", 459 | "attributes": [ 460 | { 461 | "type": "model", 462 | "properties": {} 463 | }, 464 | { 465 | "type": "auth", 466 | "properties": { 467 | "rules": [ 468 | { 469 | "allow": "public", 470 | "operations": [ 471 | "create", 472 | "update", 473 | "delete", 474 | "read" 475 | ] 476 | } 477 | ] 478 | } 479 | } 480 | ] 481 | } 482 | }, 483 | "enums": { 484 | "MessageStatus": { 485 | "name": "MessageStatus", 486 | "values": [ 487 | "SENT", 488 | "DELIVERED", 489 | "READ" 490 | ] 491 | } 492 | }, 493 | "nonModels": {}, 494 | "version": "b79e86a6e122fd2d5af4a03d6f673ce3" 495 | }; --------------------------------------------------------------------------------