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 { objectToQuerystring } from 'src/utils'
import { normalize } from 'src/utils/normalization' 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 { interface CreatePostResponse {
id: string id: string
@ -17,13 +17,14 @@ interface CreatePostOptions {
visible: boolean visible: boolean
text?: string text?: string
cover?: string cover?: string
attachments: Attachment[]
data?: object data?: object
parent?: string parent?: string
} }
export const createPost = (options: CreatePostOptions): AppThunkAction<string> => { export const createPost = (options: CreatePostOptions): AppThunkAction<string> => {
return async dispatch => { return async dispatch => {
const { visible, text, cover, data, parent } = options
const { visible, text, cover, attachments, data, parent } = options
dispatch(startRequest(RequestKey.CreatePost)) dispatch(startRequest(RequestKey.CreatePost))
try { try {
@ -34,6 +35,7 @@ export const createPost = (options: CreatePostOptions): AppThunkAction<string> =
visible, visible,
text, text,
cover, cover,
attachments,
data, data,
parent, 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 => { export const fetchPost = (id: string): AppThunkAction => {
return async dispatch => { return async dispatch => {
dispatch(startRequest(RequestKey.FetchPost)) dispatch(startRequest(RequestKey.FetchPost))
try { try {
const post = await apiFetch<Entity>({
const response = await apiFetch<FetchPostResponse>({
path: `/api/post/${id}` 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(setEntities(posts.entities))
dispatch(finishRequest(RequestKey.FetchPost, true)) dispatch(finishRequest(RequestKey.FetchPost, true))
} catch (err) { } catch (err) {
dispatch(finishRequest(RequestKey.FetchPost, false)) 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 Self from './pages/self'
import ViewApp from './pages/view-app' import ViewApp from './pages/view-app'
import ViewGroup from './pages/view-group' import ViewGroup from './pages/view-group'
import ViewPost from './pages/view-post'
import ViewUser from './pages/view-user' import ViewUser from './pages/view-user'
import '../styles/app.scss' import '../styles/app.scss'
@ -101,6 +102,9 @@ const App: FC = () => {
<Route path="/u/:id"> <Route path="/u/:id">
<ViewUser /> <ViewUser />
</Route> </Route>
<Route path="/p/:id">
<ViewPost />
</Route>
<Route path="/login"> <Route path="/login">
<Login /> <Login />
</Route> </Route>

27
src/components/composer.tsx

@ -6,16 +6,21 @@ import { getOrigin } from 'src/utils'
import { useConfig, useDeepCompareEffect } from 'src/hooks' import { useConfig, useDeepCompareEffect } from 'src/hooks'
import { fetchInstallations, setSelectedInstallation, setHeight as setComposerHeight, setError as setComposerError } from 'src/actions/composer' import { fetchInstallations, setSelectedInstallation, setHeight as setComposerHeight, setError as setComposerError } from 'src/actions/composer'
import { showNotification } from 'src/actions/notifications' 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 { 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' import { IncomingMessageData, OutgoingMessageData } from 'src/types/communicator'
interface LimiterCollection { interface LimiterCollection {
[key: string]: number [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 installations = useSelector<AppState, Installation[]>(getInstallations)
const installation = useSelector<AppState, Installation | undefined>(getSelectedInstallation) const installation = useSelector<AppState, Installation | undefined>(getSelectedInstallation)
const height = useSelector<AppState, number>(getComposerHeight) const height = useSelector<AppState, number>(getComposerHeight)
@ -96,6 +101,13 @@ const Composer: FC = () => {
content: { content: {
installationId: installation.id, installationId: installation.id,
settings: installation.settings, 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({ postMessage({
name: data.name, name: data.name,
content: {},
}) })
break break
@ -116,7 +129,9 @@ const Composer: FC = () => {
visible: content.visible, visible: content.visible,
text: content.text, text: content.text,
cover: content.cover, cover: content.cover,
attachments: content.attachments,
data: content.data, data: content.data,
parent: parent ? parent.id : undefined,
})) }))
postMessage({ postMessage({
@ -127,7 +142,7 @@ const Composer: FC = () => {
}) })
dispatch(showNotification(NotificationType.Success, `Posted!`)) dispatch(showNotification(NotificationType.Success, `Posted!`))
dispatch(fetchTimeline())
if (onPost) onPost()
} catch (err) { } catch (err) {
postMessage({ postMessage({
name, name,
@ -147,7 +162,7 @@ const Composer: FC = () => {
return () => { return () => {
window.removeEventListener('message', listener, false) window.removeEventListener('message', listener, false)
} }
}, [installation, error])
}, [installation, parent, error])
const handleClick = (id: string) => { const handleClick = (id: string) => {
if (installation && installation.id === id) { if (installation && installation.id === id) {
@ -162,7 +177,7 @@ const Composer: FC = () => {
<div className="container composer-container"> <div className="container composer-container">
<div className={classNames(composerClasses)}> <div className={classNames(composerClasses)}>
{showComposer && {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>} {error && <span className="has-text-danger">Composer Error: {error}</span>}
{(!showComposer && !error) && <span>Choose an App.</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 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 { getAuthenticated } from 'src/selectors/authentication'
import { setTitle } from 'src/utils' import { setTitle } from 'src/utils'
import { AppState } from 'src/types'
import { AppState, AppThunkDispatch } from 'src/types'
import PageHeader from 'src/components/page-header' import PageHeader from 'src/components/page-header'
import Composer from 'src/components/composer' import Composer from 'src/components/composer'
@ -11,11 +12,16 @@ import Timeline from 'src/components/timeline'
const Home: FC = () => { const Home: FC = () => {
const authenticated = useSelector<AppState, boolean>(getAuthenticated) const authenticated = useSelector<AppState, boolean>(getAuthenticated)
const dispatch = useDispatch<AppThunkDispatch>()
useEffect(() => { useEffect(() => {
setTitle('Home') setTitle('Home')
}) })
const handlePost = () => {
dispatch(fetchTimeline())
}
return ( return (
<div> <div>
<PageHeader title="Home" /> <PageHeader title="Home" />
@ -23,7 +29,7 @@ const Home: FC = () => {
<div className="main-content"> <div className="main-content">
{authenticated && {authenticated &&
<div> <div>
<Composer />
<Composer onPost={handlePost} />
<Timeline /> <Timeline />
</div> </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' import PostComponent from 'src/components/post'
interface Props { interface Props {
posts: Post[] 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 export default PostList

89
src/components/post.tsx

@ -1,10 +1,11 @@
import React, { FC } from 'react' import React, { FC } from 'react'
import { useDispatch } from 'react-redux'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import moment from 'moment' import moment from 'moment'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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' import User from 'src/components/user'
@ -12,29 +13,73 @@ interface Props {
post: Post 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> </div>
</div>
)
)
}
export default PostComponent export default PostComponent

14
src/selectors/posts.ts

@ -1,16 +1,16 @@
import { denormalize } from 'src/utils/normalization' import { denormalize } from 'src/utils/normalization'
import { AppState, Post, EntityType, EntityListKey } from 'src/types' 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 [] if (!entityList) return []
return denormalize(entityList.entities, EntityType.Post, state.entities) as Post[] 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; margin: 10px;
} }
@ -176,6 +176,14 @@ div.post p {
padding: 20px; padding: 20px;
} }
div.post > div.cover {
cursor: pointer;
font-size: 1.1rem;
font-weight: bold;
height: 100px;
text-align: center;
}
div.post-info { div.post-info {
border-top: solid 1px $grey-lighter; border-top: solid 1px $grey-lighter;
display: flex; display: flex;
@ -186,3 +194,13 @@ div.post-info {
div.post-info > div { div.post-info > div {
padding: 10px; 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 [type: string]: EntityCollection
} }
export interface Attachment {
url: string
text?: string
cover?: string
}
export type BasePost = Entity & { export type BasePost = Entity & {
text?: string text?: string
cover?: string cover?: string
attachments?: Attachment[]
data?: object data?: object
visible: boolean visible: boolean
revealed: boolean
} }
export type NormalizedPost = BasePost & { export type NormalizedPost = BasePost & {
user: string user: string
parents?: string[]
children?: string[]
} }
export type Post = BasePost & { export type Post = BasePost & {
user: User 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 => { keys = entities.map(entity => {
const post = entity as Post const post = entity as Post
const user = post.user const user = post.user
const parents = post.parents ? post.parents : []
const children = post.children ? post.children : []
return set(type, newStore, { return set(type, newStore, {
...post, ...post,
@ -119,8 +117,6 @@ export function normalize(entities: Entity[], type: EntityType): NormalizeResult
...user, ...user,
group: set(EntityType.Group, newStore, user.group), 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 if (!post) return
const user = get(EntityType.User, store, post.user) as NormalizedUser const user = get(EntityType.User, store, post.user) as NormalizedUser
const parents = post.parents ? post.parents : []
const children = post.children ? post.children : []
return { return {
...post, ...post,
user: { user: {
...user, ...user,
group: get(EntityType.Group, store, user.group), 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