Dwayne Harris 5 years ago
parent
commit
ef305b3c37
  1. 2
      src/actions/groups.ts
  2. 146
      src/actions/posts.ts
  3. 59
      src/components/composer.tsx
  4. 14
      src/components/pages/view-user.tsx
  5. 28
      src/types/entities.ts
  6. 4
      src/types/store.ts
  7. 42
      src/utils/normalization.ts

2
src/actions/groups.ts

@ -1,7 +1,7 @@
import { Action } from 'redux'
import { apiFetch } from 'src/api'
import { setEntity, setEntities } from 'src/actions/entities'
import { setEntities } from 'src/actions/entities'
import { startRequest, finishRequest } from 'src/actions/requests'
import { objectToQuerystring } from 'src/utils'

146
src/actions/posts.ts

@ -0,0 +1,146 @@
import { Action } from 'redux'
import { apiFetch } from 'src/api'
import { setEntities } from 'src/actions/entities'
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 } from 'src/types'
export interface AppendPostsAction extends Action {
type: 'POSTS_APPEND_POSTS'
payload: {
items: string[]
continuation?: string
}
}
export interface ClearPostsAction extends Action {
type: 'POSTS_CLEAR_POSTS'
}
export type PostsActions = AppendPostsAction | ClearPostsAction
export const appendPosts = (posts: string[], continuation?: string): AppendPostsAction => ({
type: 'POSTS_APPEND_POSTS',
payload: {
items: posts,
continuation,
},
})
export const clearPosts = (): ClearPostsAction => ({
type: 'POSTS_CLEAR_POSTS',
})
interface CreatePostResponse {
id: string
}
interface CreatePostOptions {
visible: boolean
text?: string
cover?: string
data?: object
parent?: string
}
export const createPost = (options: CreatePostOptions): AppThunkAction<string> => {
return async dispatch => {
const { visible, text, cover, data, parent } = options
dispatch(startRequest(RequestKey.CreatePost))
try {
const post = await apiFetch<CreatePostResponse>({
path: `/api/post`,
method: 'post',
body: {
visible,
text,
cover,
data,
parent,
},
})
dispatch(finishRequest(RequestKey.CreatePost, true))
return post.id
} catch (err) {
dispatch(finishRequest(RequestKey.CreatePost, false))
throw err
}
}
}
export const fetchPost = (id: string): AppThunkAction => {
return async dispatch => {
dispatch(startRequest(RequestKey.FetchPost))
try {
const post = await apiFetch<Entity>({
path: `/api/post/${id}`
})
const posts = normalize([post], EntityType.Post)
dispatch(setEntities(posts.entities))
dispatch(finishRequest(RequestKey.FetchPost, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchPost, false))
throw err
}
}
}
interface TimelineResponse {
posts: Entity[]
continuation?: string
}
export const fetchTimeline = (continuation?: string): AppThunkAction => async dispatch => {
dispatch(startRequest(RequestKey.FetchTimeline))
try {
const response = await apiFetch<TimelineResponse>({
path: `/api/timeline?${objectToQuerystring({ continuation })}`,
})
const posts = normalize(response.posts, EntityType.Group)
dispatch(setEntities(posts.entities))
dispatch(appendPosts(posts.keys, response.continuation))
dispatch(finishRequest(RequestKey.FetchTimeline, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchTimeline, false))
throw err
}
}
interface UserPostsResponse {
user: User
posts: Post[]
continuation?: string
}
export const fetchUserPosts = (id: string, continuation?: string): AppThunkAction => async dispatch => {
dispatch(startRequest(RequestKey.FetchUserPosts))
try {
const response = await apiFetch<UserPostsResponse>({
path: `/api/user/${id}/posts?${objectToQuerystring({ continuation })}`,
})
const posts = normalize(response.posts.map(p => ({
...p,
user: response.user,
})), EntityType.Post)
dispatch(setEntities(posts.entities))
dispatch(appendPosts(posts.keys, response.continuation))
dispatch(finishRequest(RequestKey.FetchUserPosts, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchUserPosts, false))
throw err
}
}

59
src/components/composer.tsx

@ -1,22 +1,29 @@
import React, { FC, useEffect, useRef } from 'react'
import React, { FC, useState, useEffect, useRef } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import classNames from 'classnames'
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 } from 'src/actions/posts'
import { getInstallations, getSelectedInstallation, getError, getHeight as getComposerHeight } from 'src/selectors/composer'
import { AppState, Installation, ClassDictionary } from 'src/types'
import { AppState, Installation, ClassDictionary, AppThunkDispatch, NotificationType } from 'src/types'
import { IncomingMessageData, OutgoingMessageData } from 'src/types/communicator'
interface LimiterCollection {
[key: string]: number
}
const Composer: FC = () => {
const installations = useSelector<AppState, Installation[]>(getInstallations)
const installation = useSelector<AppState, Installation | undefined>(getSelectedInstallation)
const height = useSelector<AppState, number>(getComposerHeight)
const error = useSelector<AppState, string | undefined>(getError)
const config = useConfig()
const dispatch = useDispatch()
const dispatch = useDispatch<AppThunkDispatch>()
const ref = useRef<HTMLIFrameElement>(null)
const [limiters, setLimiters] = useState<LimiterCollection>({})
const composerUrl = installation ? installation.app.composerUrl : undefined
const showComposer = !!composerUrl && !error
@ -45,6 +52,21 @@ const Composer: FC = () => {
}
}
const withRateLimit = async (fn: (data: IncomingMessageData) => Promise<void>, data: IncomingMessageData, ms: number = 2000) => {
const last = limiters[data.name] || 0
if ((Date.now() - last) > ms) {
await fn(data)
limiters[data.name] = Date.now()
setLimiters(limiters)
} else {
postMessage({
name: data.name,
error: 'Rate limited.',
})
}
}
let data: IncomingMessageData | undefined
try {
@ -76,13 +98,42 @@ const Composer: FC = () => {
break
case 'setHeight':
const { height = 100 } = data.content
const { height = 0 } = data.content
dispatch(setComposerHeight(Math.max(Math.min(height, 400), 100)))
postMessage({
name: data.name,
})
break
case 'post':
withRateLimit(async ({ name, content }) => {
try {
const postId = await dispatch(createPost({
visible: content.visible,
text: content.text,
cover: content.cover,
data: content.data,
}))
postMessage({
name,
content: {
postId,
}
})
dispatch(showNotification(NotificationType.Success, `Posted!`))
} catch (err) {
postMessage({
name,
error: err,
})
dispatch(showNotification(NotificationType.Error, `Error posting: ${err.message}`))
}
}, data, 2000)
break
}
}

14
src/components/pages/view-user.tsx

@ -6,6 +6,7 @@ import { faUserPlus, faUserMinus, faBan } from '@fortawesome/free-solid-svg-icon
import { handleApiError } from 'src/api/errors'
import { fetchUser } from 'src/actions/users'
import { fetchUserPosts } from 'src/actions/posts'
import { getEntity } from 'src/selectors/entities'
import { getAuthenticatedUser } from 'src/selectors/authentication'
@ -30,11 +31,16 @@ const ViewUser: FC = () => {
const history = useHistory()
useEffect(() => {
try {
dispatch(fetchUser(id))
} catch (err) {
handleApiError(err, dispatch, history)
const init = async () => {
try {
await dispatch(fetchUser(id))
await dispatch(fetchUserPosts(id))
} catch (err) {
handleApiError(err, dispatch, history)
}
}
init()
}, [])
useDeepCompareEffect(() => {

28
src/types/entities.ts

@ -1,10 +1,11 @@
export enum EntityType {
User = 'users',
Group = 'groups',
Log = 'logs',
Invitation = 'invitations',
App = 'apps',
Group = 'groups',
Installation = 'installations',
Invitation = 'invitations',
Log = 'logs',
Post = 'posts',
User = 'users',
}
export enum GroupMembershipType {
@ -114,3 +115,22 @@ export interface EntityCollection {
export interface EntityStore {
[type: string]: EntityCollection
}
export type BasePost = Entity & {
text?: string
cover?: string
data?: object
visible: boolean
}
export type NormalizedPost = BasePost & {
user: string
parents?: string[]
children?: string[]
}
export type Post = BasePost & {
user: User
parents?: Post[]
children?: Post[]
}

4
src/types/store.ts

@ -14,6 +14,7 @@ export enum RequestKey {
CreateApp = 'create_app',
CreateGroup = 'create_group',
CreateInvitation = 'create_invitation',
CreatePost = 'create_post',
FetchAppAvailability = 'fetch_app_availability',
FetchApp = 'fetch_app',
FetchApps = 'fetch_apps',
@ -24,6 +25,9 @@ export enum RequestKey {
FetchGroups = 'fetch_groups',
FetchInstallations = 'fetch_installations',
FetchInvitations = 'fetch_invitations',
FetchPost = 'fetch_post',
FetchTimeline = 'fetch_timeline',
FetchUserPosts = 'fetch_user_posts',
FetchSelfApps = 'fetch_self_apps',
FetchUser = 'fetch_user',
FetchUserAvailability = 'fetch_user_availability',

42
src/utils/normalization.ts

@ -8,7 +8,8 @@ import {
NormalizedInvitation,
GroupLog,
NormalizedGroupLog,
App,
Post,
NormalizedPost,
Installation,
NormalizedInstallation,
} from '../types'
@ -103,6 +104,27 @@ export function normalize(entities: Entity[], type: EntityType): NormalizeResult
app: set(EntityType.App, newStore, installation.app),
})
})
break
case EntityType.Post:
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,
user: set(EntityType.User, newStore, {
...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)),
})
})
break
}
return {
@ -164,6 +186,24 @@ export function denormalize(keys: string[], type: EntityType, store: EntityStore
app: get(EntityType.App, store, installation.app),
}
}
case EntityType.Post: {
const post = get(type, store, key) as NormalizedPost
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