Dwayne Harris 5 years ago
parent
commit
2e9c8beae9
  1. 41
      src/actions/posts.ts
  2. 4
      src/components/app.tsx
  3. 27
      src/components/composer.tsx
  4. 12
      src/components/pages/home.tsx
  5. 90
      src/components/pages/view-post.tsx
  6. 30
      src/components/post-list.tsx
  7. 89
      src/components/post.tsx
  8. 14
      src/selectors/posts.ts
  9. 20
      src/styles/app.scss
  10. 12
      src/types/entities.ts
  11. 8
      src/utils/normalization.ts

41
src/actions/posts.ts

@ -7,7 +7,7 @@ import { startRequest, finishRequest } from 'src/actions/requests'
import { objectToQuerystring } from 'src/utils'
import { normalize } from 'src/utils/normalization'
import { AppThunkAction, Entity, RequestKey, EntityType, User, Post, EntityListKey } from 'src/types'
import { AppThunkAction, Entity, RequestKey, EntityType, User, Post, Attachment, EntityListKey } from 'src/types'
interface CreatePostResponse {
id: string
@ -17,13 +17,14 @@ interface CreatePostOptions {
visible: boolean
text?: string
cover?: string
attachments: Attachment[]
data?: object
parent?: string
}
export const createPost = (options: CreatePostOptions): AppThunkAction<string> => {
return async dispatch => {
const { visible, text, cover, data, parent } = options
const { visible, text, cover, attachments, data, parent } = options
dispatch(startRequest(RequestKey.CreatePost))
try {
@ -34,6 +35,7 @@ export const createPost = (options: CreatePostOptions): AppThunkAction<string> =
visible,
text,
cover,
attachments,
data,
parent,
},
@ -48,18 +50,49 @@ export const createPost = (options: CreatePostOptions): AppThunkAction<string> =
}
}
interface FetchPostResponse {
post: Post,
parents: Post[],
children: Post[],
users: User[],
}
export const fetchPost = (id: string): AppThunkAction => {
return async dispatch => {
dispatch(startRequest(RequestKey.FetchPost))
try {
const post = await apiFetch<Entity>({
const response = await apiFetch<FetchPostResponse>({
path: `/api/post/${id}`
})
const posts = normalize([post], EntityType.Post)
const parents = normalize(response.parents.map(p => ({
...p,
user: response.users.find(u => u.id === p.userId),
userId: undefined,
})), EntityType.Post)
dispatch(setEntities(parents.entities))
dispatch(listSet(`post:${id}:parents`, parents.keys))
const children = normalize(response.children.map(p => ({
...p,
user: response.users.find(u => u.id === p.userId),
userId: undefined,
})), EntityType.Post)
dispatch(setEntities(children.entities))
dispatch(listSet(`post:${id}:children`, children.keys))
const post: Entity = {
...response.post,
user: response.users.find(u => u.id === response.post.userId),
userId: undefined,
}
const posts = normalize([post], EntityType.Post)
dispatch(setEntities(posts.entities))
dispatch(finishRequest(RequestKey.FetchPost, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchPost, false))

4
src/components/app.tsx

@ -32,6 +32,7 @@ import RegisterGroup from './pages/register-group'
import Self from './pages/self'
import ViewApp from './pages/view-app'
import ViewGroup from './pages/view-group'
import ViewPost from './pages/view-post'
import ViewUser from './pages/view-user'
import '../styles/app.scss'
@ -101,6 +102,9 @@ const App: FC = () => {
<Route path="/u/:id">
<ViewUser />
</Route>
<Route path="/p/:id">
<ViewPost />
</Route>
<Route path="/login">
<Login />
</Route>

27
src/components/composer.tsx

@ -6,16 +6,21 @@ import { getOrigin } from 'src/utils'
import { useConfig, useDeepCompareEffect } from 'src/hooks'
import { fetchInstallations, setSelectedInstallation, setHeight as setComposerHeight, setError as setComposerError } from 'src/actions/composer'
import { showNotification } from 'src/actions/notifications'
import { createPost, fetchTimeline } from 'src/actions/posts'
import { createPost } from 'src/actions/posts'
import { getInstallations, getSelectedInstallation, getError, getHeight as getComposerHeight } from 'src/selectors/composer'
import { AppState, Installation, ClassDictionary, AppThunkDispatch, NotificationType } from 'src/types'
import { AppState, Installation, ClassDictionary, AppThunkDispatch, NotificationType, Post } from 'src/types'
import { IncomingMessageData, OutgoingMessageData } from 'src/types/communicator'
interface LimiterCollection {
[key: string]: number
}
const Composer: FC = () => {
interface Props {
parent?: Post
onPost?: () => void
}
const Composer: FC<Props> = ({ parent, onPost }) => {
const installations = useSelector<AppState, Installation[]>(getInstallations)
const installation = useSelector<AppState, Installation | undefined>(getSelectedInstallation)
const height = useSelector<AppState, number>(getComposerHeight)
@ -96,6 +101,13 @@ const Composer: FC = () => {
content: {
installationId: installation.id,
settings: installation.settings,
parent: parent ? {
text: parent.text,
cover: parent.cover,
attachments: parent.attachments,
data: parent.data,
created: parent.created,
} : undefined,
},
})
@ -106,6 +118,7 @@ const Composer: FC = () => {
postMessage({
name: data.name,
content: {},
})
break
@ -116,7 +129,9 @@ const Composer: FC = () => {
visible: content.visible,
text: content.text,
cover: content.cover,
attachments: content.attachments,
data: content.data,
parent: parent ? parent.id : undefined,
}))
postMessage({
@ -127,7 +142,7 @@ const Composer: FC = () => {
})
dispatch(showNotification(NotificationType.Success, `Posted!`))
dispatch(fetchTimeline())
if (onPost) onPost()
} catch (err) {
postMessage({
name,
@ -147,7 +162,7 @@ const Composer: FC = () => {
return () => {
window.removeEventListener('message', listener, false)
}
}, [installation, error])
}, [installation, parent, error])
const handleClick = (id: string) => {
if (installation && installation.id === id) {
@ -162,7 +177,7 @@ const Composer: FC = () => {
<div className="container composer-container">
<div className={classNames(composerClasses)}>
{showComposer &&
<iframe ref={ref} src={composerUrl} scrolling="no" style={{ height, width: '100%', overflow: 'hidden' }} />
<iframe ref={ref} src={composerUrl} scrolling="yes" style={{ height, width: '100%', overflow: 'hidden' }} />
}
{error && <span className="has-text-danger">Composer Error: {error}</span>}
{(!showComposer && !error) && <span>Choose an App.</span>}

12
src/components/pages/home.tsx

@ -1,9 +1,10 @@
import React, { FC, useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useSelector, useDispatch } from 'react-redux'
import { fetchTimeline } from 'src/actions/posts'
import { getAuthenticated } from 'src/selectors/authentication'
import { setTitle } from 'src/utils'
import { AppState } from 'src/types'
import { AppState, AppThunkDispatch } from 'src/types'
import PageHeader from 'src/components/page-header'
import Composer from 'src/components/composer'
@ -11,11 +12,16 @@ import Timeline from 'src/components/timeline'
const Home: FC = () => {
const authenticated = useSelector<AppState, boolean>(getAuthenticated)
const dispatch = useDispatch<AppThunkDispatch>()
useEffect(() => {
setTitle('Home')
})
const handlePost = () => {
dispatch(fetchTimeline())
}
return (
<div>
<PageHeader title="Home" />
@ -23,7 +29,7 @@ const Home: FC = () => {
<div className="main-content">
{authenticated &&
<div>
<Composer />
<Composer onPost={handlePost} />
<Timeline />
</div>
}

90
src/components/pages/view-post.tsx

@ -0,0 +1,90 @@
import React, { FC, useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { useParams, useHistory } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowsAltV } from '@fortawesome/free-solid-svg-icons'
import { handleApiError } from 'src/api/errors'
import { fetchPost } from 'src/actions/posts'
import { getAuthenticated, getChecked } from 'src/selectors/authentication'
import { getEntity } from 'src/selectors/entities'
import { getPostParents, getPostChildren } from 'src/selectors/posts'
import { setTitle } from 'src/utils'
import { AppState, AppThunkDispatch, EntityType, Post } from 'src/types'
import PageHeader from 'src/components/page-header'
import Loading from 'src/components/pages/loading'
import PostComponent from 'src/components/post'
import PostList from 'src/components/post-list'
import Composer from 'src/components/composer'
interface Params {
id: string
}
const ViewPost: FC = () => {
const { id } = useParams<Params>()
const post = useSelector<AppState, Post | undefined>(state => getEntity<Post>(state, EntityType.Post, id))
const parents = useSelector<AppState, Post[]>(state => getPostParents(state, id))
const replies = useSelector<AppState, Post[]>(state => getPostChildren(state, id))
const checked = useSelector<AppState, boolean>(getChecked)
const authenticated = useSelector<AppState, boolean>(getAuthenticated)
const dispatch = useDispatch<AppThunkDispatch>()
const history = useHistory()
const fetch = async () => {
try {
await dispatch(fetchPost(id))
} catch (err) {
handleApiError(err, dispatch, history)
}
}
useEffect(() => {
if (checked) fetch()
}, [checked])
useEffect(() => {
if (post) setTitle('Post')
}, [post])
if (!post) return <Loading />
return (
<div>
<PageHeader title="Post" />
<div className="main-content">
{parents.length > 0 &&
<div>
<PostList posts={parents} collapseText="Show Older Posts" />
<div className="has-text-centered">
<FontAwesomeIcon icon={faArrowsAltV} />
</div>
</div>
}
<PostComponent post={post} />
{authenticated &&
<div>
<br />
<h1 className="title is-size-5 is-primary">Reply</h1>
<Composer parent={post} onPost={fetch} />
</div>
}
{replies.length > 0 &&
<div>
<br />
<h1 className="title is-size-5">Replies</h1>
<PostList posts={replies} />
</div>
}
</div>
</div>
)
}
export default ViewPost

30
src/components/post-list.tsx

@ -1,17 +1,31 @@
import React, { FC } from 'react'
import { Post } from 'src/types'
import React, { FC, useState } from 'react'
import classNames from 'classnames'
import { Post, ClassDictionary } from 'src/types'
import PostComponent from 'src/components/post'
interface Props {
posts: Post[]
collapseText?: string
}
const PostList: FC<Props> = ({ posts }) => (
<div className="post-list">
{posts.map(post => <PostComponent key={post.id} post={post} />)}
</div>
)
const PostList: FC<Props> = ({ posts, collapseText }) => {
const [isCollapsed, setIsCollapsed] = useState(!!collapseText)
const classes: ClassDictionary = {
'post-list': true,
'post-list-collapsed': isCollapsed,
}
return (
<div className={classNames(classes)}>
{isCollapsed &&
<button className="button is-primary is-fullwidth" onClick={() => setIsCollapsed(false)}>{collapseText}</button>
}
{!isCollapsed && posts.map(post => <PostComponent key={post.id} post={post} />)}
</div>
)
}
export default PostList

89
src/components/post.tsx

@ -1,10 +1,11 @@
import React, { FC } from 'react'
import { useDispatch } from 'react-redux'
import { Link } from 'react-router-dom'
import moment from 'moment'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClock } from '@fortawesome/free-solid-svg-icons'
import { Post } from 'src/types'
import { faClock, faReplyAll, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'
import { setEntity } from 'src/actions/entities'
import { Post, EntityType } from 'src/types'
import User from 'src/components/user'
@ -12,29 +13,73 @@ interface Props {
post: Post
}
const PostComponent: FC<Props> = ({ post }) => (
<div className="post">
<p className="is-size-5">{post.text}</p>
const PostComponent: FC<Props> = ({ post }) => {
const dispatch = useDispatch()
const showCover = !!post.cover && !post.revealed
<div className="post-info">
<div>
<User user={post.user} />
</div>
const handleShowPost = () => {
dispatch(setEntity(EntityType.Post, {
...post,
revealed: true,
}))
}
<div>
Awards
</div>
return (
<div className="post">
{showCover &&
<div className="cover" onClick={() => handleShowPost()}>{post.cover}</div>
}
{!showCover &&
<div>
{post.text && <p className="is-size-5">{post.text}</p>}
{post.attachments && post.attachments.length > 0 &&
<div className="attachments">
{post.attachments.map(attachment => (
<div key={attachment.url} className="attachment">
<img src={attachment.url} />
{attachment.text && <p className="caption">{attachment.text}</p>}
</div>
))}
</div>
}
</div>
}
<div>
<span className="icon">
<FontAwesomeIcon icon={faClock} />
</span>
<Link to={`/p/${post.id}`} className="has-text-primary">
{moment(post.created).format('MMMM Do, h:mm A')}
</Link>
<div className="post-info">
<div>
<User user={post.user} />
</div>
{!!post.cover && post.cover.length > 0 &&
<div>
<span className="icon">
<FontAwesomeIcon icon={faExclamationCircle} />
</span>
</div>
}
<div>
<Link to={`/p/${post.id}`}>
<span className="icon">
<FontAwesomeIcon icon={faReplyAll} />
</span>
{post.replies}
</Link>
</div>
<div>
<span className="icon">
<FontAwesomeIcon icon={faClock} />
</span>
<Link to={`/p/${post.id}`} className="has-text-primary">
{moment(post.created).format('MMMM Do, h:mm A')}
</Link>
</div>
</div>
</div>
</div>
)
)
}
export default PostComponent

14
src/selectors/posts.ts

@ -1,16 +1,16 @@
import { denormalize } from 'src/utils/normalization'
import { AppState, Post, EntityType, EntityListKey } from 'src/types'
export const getTimeline = (state: AppState) => {
const entityList = state.lists[EntityListKey.Timeline]
const getPostsFromList = (state: AppState, name: string) => {
const entityList = state.lists[name]
if (!entityList) return []
return denormalize(entityList.entities, EntityType.Post, state.entities) as Post[]
}
export const getUserPosts = (state: AppState, id: string) => {
const entityList = state.lists[`posts:${id}`]
if (!entityList) return []
export const getTimeline = (state: AppState) => getPostsFromList(state, EntityListKey.Timeline)
export const getUserPosts = (state: AppState, id: string) => getPostsFromList(state, `user:${id}:posts`)
export const getPostParents = (state: AppState, id: string) => getPostsFromList(state, `post:${id}:parents`)
export const getPostChildren = (state: AppState, id: string) => getPostsFromList(state, `post:${id}:children`)
return denormalize(entityList.entities, EntityType.Post, state.entities) as Post[]
}
export const getPost = (state: AppState, id: string) => denormalize([id], EntityType.Post, state.entities)[0] as Post

20
src/styles/app.scss

@ -163,7 +163,7 @@ div.user {
}
}
div.posts-list {
div.post-list {
margin: 10px;
}
@ -176,6 +176,14 @@ div.post p {
padding: 20px;
}
div.post > div.cover {
cursor: pointer;
font-size: 1.1rem;
font-weight: bold;
height: 100px;
text-align: center;
}
div.post-info {
border-top: solid 1px $grey-lighter;
display: flex;
@ -186,3 +194,13 @@ div.post-info {
div.post-info > div {
padding: 10px;
}
div.attachment {
padding: 10px;
p.caption {
color: $grey;
font-size: 1rem;
margin-top: -15px;
}
}

12
src/types/entities.ts

@ -124,21 +124,25 @@ export interface EntityStore {
[type: string]: EntityCollection
}
export interface Attachment {
url: string
text?: string
cover?: string
}
export type BasePost = Entity & {
text?: string
cover?: string
attachments?: Attachment[]
data?: object
visible: boolean
revealed: boolean
}
export type NormalizedPost = BasePost & {
user: string
parents?: string[]
children?: string[]
}
export type Post = BasePost & {
user: User
parents?: Post[]
children?: Post[]
}

8
src/utils/normalization.ts

@ -110,8 +110,6 @@ export function normalize(entities: Entity[], type: EntityType): NormalizeResult
keys = entities.map(entity => {
const post = entity as Post
const user = post.user
const parents = post.parents ? post.parents : []
const children = post.children ? post.children : []
return set(type, newStore, {
...post,
@ -119,8 +117,6 @@ export function normalize(entities: Entity[], type: EntityType): NormalizeResult
...user,
group: set(EntityType.Group, newStore, user.group),
}),
parents: parents.map(p => set(EntityType.Post, newStore, p)),
children: children.map(p => set(EntityType.Post, newStore, p)),
})
})
@ -191,16 +187,12 @@ export function denormalize(keys: string[], type: EntityType, store: EntityStore
if (!post) return
const user = get(EntityType.User, store, post.user) as NormalizedUser
const parents = post.parents ? post.parents : []
const children = post.children ? post.children : []
return {
...post,
user: {
...user,
group: get(EntityType.Group, store, user.group),
parents: parents.map(p => get(EntityType.Post, store, p)),
children: children.map(p => get(EntityType.Post, store, p)),
},
}
}

Loading…
Cancel
Save