From ef305b3c37250f85c226bc975ca2ade5926aa239 Mon Sep 17 00:00:00 2001 From: Dwayne Harris Date: Sat, 26 Oct 2019 18:57:59 -0400 Subject: [PATCH] WIP --- src/actions/groups.ts | 2 +- src/actions/posts.ts | 146 +++++++++++++++++++++++++++++ src/components/composer.tsx | 59 +++++++++++- src/components/pages/view-user.tsx | 14 ++- src/types/entities.ts | 28 +++++- src/types/store.ts | 4 + src/utils/normalization.ts | 42 ++++++++- 7 files changed, 281 insertions(+), 14 deletions(-) create mode 100644 src/actions/posts.ts diff --git a/src/actions/groups.ts b/src/actions/groups.ts index fa1a7b7..103b474 100644 --- a/src/actions/groups.ts +++ b/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' diff --git a/src/actions/posts.ts b/src/actions/posts.ts new file mode 100644 index 0000000..374f1fe --- /dev/null +++ b/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 => { + return async dispatch => { + const { visible, text, cover, data, parent } = options + dispatch(startRequest(RequestKey.CreatePost)) + + try { + const post = await apiFetch({ + 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({ + 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({ + 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({ + 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 + } +} diff --git a/src/components/composer.tsx b/src/components/composer.tsx index eb07190..c64607a 100644 --- a/src/components/composer.tsx +++ b/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(getInstallations) const installation = useSelector(getSelectedInstallation) const height = useSelector(getComposerHeight) const error = useSelector(getError) const config = useConfig() - const dispatch = useDispatch() + const dispatch = useDispatch() const ref = useRef(null) + const [limiters, setLimiters] = useState({}) 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, 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 } } diff --git a/src/components/pages/view-user.tsx b/src/components/pages/view-user.tsx index 31122b6..0715b54 100644 --- a/src/components/pages/view-user.tsx +++ b/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(() => { diff --git a/src/types/entities.ts b/src/types/entities.ts index dab273b..a2498a4 100644 --- a/src/types/entities.ts +++ b/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[] +} diff --git a/src/types/store.ts b/src/types/store.ts index ed9c2fd..146b5fa 100644 --- a/src/types/store.ts +++ b/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', diff --git a/src/utils/normalization.ts b/src/utils/normalization.ts index 5886f77..f7b9074 100644 --- a/src/utils/normalization.ts +++ b/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)), + }, + } + } } })