From b90ce7ebaf410d3b0025a586657c2888f5e6a248 Mon Sep 17 00:00:00 2001 From: xaralis <filip.varecha@fragaria.cz> Date: Wed, 23 Dec 2020 11:51:00 +0100 Subject: [PATCH] feat: delete post, ban and reactions --- .env | 1 + public/index.html | 15 ++++++ src/App.jsx | 4 ++ src/actions/announcements.js | 16 ++++++ src/actions/posts.js | 35 +++++++++---- src/actions/users.js | 65 ++++++++++++++++++++++-- src/containers/AnnoucementsContainer.jsx | 11 +--- src/containers/PostsContainer.jsx | 11 +--- src/containers/StatsCard.jsx | 43 ++++++++++++++++ src/pages/Home.jsx | 21 ++++++-- src/stores.js | 1 + src/utils.js | 36 ++++++++++++- src/ws/connection.js | 21 ++++++++ src/ws/handlers/index.js | 5 ++ src/ws/handlers/posts.js | 9 ++++ src/ws/handlers/users.js | 17 +++++++ typings/cf2021.d.ts | 6 +++ 17 files changed, 279 insertions(+), 38 deletions(-) create mode 100644 src/containers/StatsCard.jsx create mode 100644 src/ws/handlers/users.js diff --git a/.env b/.env index fd45135..daccf27 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ REACT_APP_STYLEGUIDE_URL=http://localhost:3001 REACT_APP_API_BASE_URL=https://cf2021.pirati.cz/api REACT_APP_WS_BASE_URL=wss://cf2021.pirati.cz/ws +REACT_APP_MATOMO_ID=135 diff --git a/public/index.html b/public/index.html index 9469ad4..3fae09c 100644 --- a/public/index.html +++ b/public/index.html @@ -49,4 +49,19 @@ To create a production bundle, use `npm run build` or `yarn build`. --> </body> + <!-- Matomo --> + <script type="text/javascript"> + var _paq = window._paq = window._paq || []; + /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ + _paq.push(['trackPageView']); + _paq.push(['enableLinkTracking']); + (function() { + var u="//matomo.pirati.cz/"; + _paq.push(['setTrackerUrl', u+'matomo.php']); + _paq.push(['setSiteId', '%REACT_APP_MATOMO_ID%']); + var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); + })(); + </script> + <!-- End Matomo Code --> </html> diff --git a/src/App.jsx b/src/App.jsx index 6a9b209..bff1d19 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import { KeycloakProvider } from "@react-keycloak/web"; import * as Sentry from "@sentry/react"; import { loadConfig } from "actions/global-info"; +import { loadMe } from "actions/users"; import { initializeWSChannel } from "actions/ws"; import Footer from "components/Footer"; import Navbar from "components/Navbar"; @@ -50,6 +51,9 @@ const onKeycloakEvent = (event) => { }; }); + // Once base user details has been stored, load me details from API. + loadMe.run(); + PostStore.update((state) => { // Only display proposals verified by chairman to other users. state.filters.showPendingProposals = role === "chairman"; diff --git a/src/actions/announcements.js b/src/actions/announcements.js index cb99ea5..647d5a6 100644 --- a/src/actions/announcements.js +++ b/src/actions/announcements.js @@ -6,7 +6,9 @@ import { fetch } from "api"; import { AnnouncementStore } from "stores"; import { announcementTypeMappingRev, + createSeenWriter, parseRawAnnouncement, + seenAnnouncementsLSKey, syncAnnoucementItemIds, } from "utils"; @@ -104,3 +106,17 @@ export const updateAnnouncementContent = createAsyncAction( } } ); + +const { markSeen: storeSeen } = createSeenWriter(seenAnnouncementsLSKey); + +/** + * Mark down user saw this post already. + * @param {CF2021.Post} post + */ +export const markSeen = (post) => { + storeSeen(post.id); + + AnnouncementStore.update((state) => { + state.items[post.id].seen = true; + }); +}; diff --git a/src/actions/posts.js b/src/actions/posts.js index 2e6d123..3f97710 100644 --- a/src/actions/posts.js +++ b/src/actions/posts.js @@ -5,10 +5,12 @@ import { createAsyncAction, errorResult, successResult } from "pullstate"; import { fetch } from "api"; import { PostStore } from "stores"; import { + createSeenWriter, filterPosts, parseRawPost, postsStateMappingRev, postsTypeMappingRev, + seenPostsLSKey, } from "utils"; export const loadPosts = createAsyncAction( @@ -135,16 +137,15 @@ export const hide = createAsyncAction( * @param {CF2021.Post} post */ async (post) => { - return successResult(post); - }, - { - postActionHook: ({ result }) => { - if (!result.error) { - PostStore.update((state) => { - state.items[result.payload.id].hidden = true; - }); - } - }, + try { + await fetch(`/posts/${post.id}`, { + method: "DELETE", + expectedStatus: 204, + }); + return successResult(); + } catch (err) { + return errorResult([], err.toString()); + } } ); @@ -312,3 +313,17 @@ export const rejectProposalByChairman = createAsyncAction( }, } ); + +const { markSeen: storeSeen } = createSeenWriter(seenPostsLSKey); + +/** + * Mark down user saw this post already. + * @param {CF2021.Post} post + */ +export const markSeen = (post) => { + storeSeen(post.id); + + PostStore.update((state) => { + state.items[post.id].seen = true; + }); +}; diff --git a/src/actions/users.js b/src/actions/users.js index 7e3d1ae..7fe04d8 100644 --- a/src/actions/users.js +++ b/src/actions/users.js @@ -1,10 +1,69 @@ -import { createAsyncAction, successResult } from "pullstate"; +import { createAsyncAction, errorResult, successResult } from "pullstate"; + +import { fetch } from "api"; +import { AuthStore } from "stores"; + +export const loadMe = createAsyncAction( + /** + * @param {number} userId + */ + async (user) => { + try { + const response = await fetch(`/users/me`, { + method: "GET", + expectedStatus: 200, + }); + const data = await response.json(); + return successResult(data); + } catch (err) { + return errorResult([], err.toString()); + } + }, + { + postActionHook: ({ result }) => { + if (!result.error) { + AuthStore.update((state) => { + if (state.user) { + state.user.id = result.payload.id; + state.user.group = result.payload.group; + state.user.isBanned = result.payload.is_banned; + } + }); + } + }, + } +); export const ban = createAsyncAction( /** * @param {number} userId */ - async (userId) => { - return successResult(userId); + async (user) => { + try { + await fetch(`/users/${user.id}/ban`, { + method: "PATCH", + expectedStatus: 204, + }); + return successResult(user); + } catch (err) { + return errorResult([], err.toString()); + } + } +); + +export const removeBan = createAsyncAction( + /** + * @param {number} userId + */ + async (user) => { + try { + await fetch(`/users/${user.id}/unban`, { + method: "PATCH", + expectedStatus: 204, + }); + return successResult(user); + } catch (err) { + return errorResult([], err.toString()); + } } ); diff --git a/src/containers/AnnoucementsContainer.jsx b/src/containers/AnnoucementsContainer.jsx index cd8da9c..048e946 100644 --- a/src/containers/AnnoucementsContainer.jsx +++ b/src/containers/AnnoucementsContainer.jsx @@ -3,6 +3,7 @@ import React, { useCallback, useState } from "react"; import { deleteAnnouncement, loadAnnouncements, + markSeen, updateAnnouncementContent, } from "actions/announcements"; import AnnouncementEditModal from "components/annoucements/AnnouncementEditModal"; @@ -63,16 +64,6 @@ const AnnoucementsContainer = () => { setItemToEdit(null); }, [setItemToEdit]); - /** - * Mark down user saw this announcement already. - * @param {CF2021.Announcement} announcement - */ - const markSeen = (announcement) => { - AnnouncementStore.update((state) => { - state.items[announcement.id].seen = true; - }); - }; - return ( <> {loadResult && loadResult.error && ( diff --git a/src/containers/PostsContainer.jsx b/src/containers/PostsContainer.jsx index 907f7de..486eafe 100644 --- a/src/containers/PostsContainer.jsx +++ b/src/containers/PostsContainer.jsx @@ -10,6 +10,7 @@ import { hide, like, loadPosts, + markSeen, rejectProposal, rejectProposalByChairman, } from "actions/posts"; @@ -134,16 +135,6 @@ const PostsContainer = ({ className }) => { setUserToBan(post.author); }; - /** - * Mark down user saw this post already. - * @param {CF2021.Post} post - */ - const markSeen = (post) => { - PostStore.update((state) => { - state.items[post.id].seen = true; - }); - }; - const sliceStart = (window.page - 1) * window.perPage; const sliceEnd = window.page * window.perPage; const windowItems = window.items.map((postId) => items[postId]); diff --git a/src/containers/StatsCard.jsx b/src/containers/StatsCard.jsx new file mode 100644 index 0000000..5b74d89 --- /dev/null +++ b/src/containers/StatsCard.jsx @@ -0,0 +1,43 @@ +import React from "react"; +import classNames from "classnames"; + +import { Card, CardBody } from "components/cards"; +import { GlobalInfoStore } from "stores"; + +const StatsCard = () => { + const { connectionState, onlineUsers } = GlobalInfoStore.useState(); + + const connectionIndicator = ( + <div + className={classNames("inline-block rounded-full w-4 h-4 mr-2", { + "bg-green-400": connectionState === "connected", + "bg-red-600": connectionState === "offline", + "bg-yellow-200": connectionState === "connecting", + })} + /> + ); + + return ( + <Card> + <CardBody className="leading-normal"> + <div className="flex justify-between"> + <span>Stav vašeho připojení</span> + <div className="flex items-center"> + {connectionIndicator} + <strong> + {connectionState === "connected" && "on-line"} + {connectionState === "offline" && "off-line"} + {connectionState === "connecting" && "připojování"} + </strong> + </div> + </div> + <div className="flex justify-between"> + <span>Počet on-line účastníků</span> + <strong>{onlineUsers}</strong> + </div> + </CardBody> + </Card> + ); +}; + +export default StatsCard; diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 4d3dc7e..a623482 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -9,6 +9,7 @@ import { } from "actions/program"; import Button from "components/Button"; import { DropdownMenu, DropdownMenuItem } from "components/dropdown-menu"; +import ErrorMessage from "components/ErrorMessage"; import ModalConfirm from "components/modals/ModalConfirm"; import ProgramEntryEditModal from "components/program/ProgramEntryEditModal"; import AddAnnouncementForm from "containers/AddAnnouncementForm"; @@ -16,6 +17,7 @@ import AddPostForm from "containers/AddPostForm"; import AnnouncementsContainer from "containers/AnnoucementsContainer"; import PostFilters from "containers/PostFilters"; import PostsContainer from "containers/PostsContainer"; +import StatsCard from "containers/StatsCard"; import { useActionConfirm } from "hooks"; import { AuthStore, GlobalInfoStore, ProgramStore } from "stores"; @@ -215,6 +217,10 @@ const Home = () => { )} </section> + <section className="cf2021__stats"> + <StatsCard /> + </section> + <section className="cf2021__notifications"> <div className="lg:card lg:elevation-10"> <div className="lg:card__body pb-2 lg:py-6"> @@ -249,9 +255,18 @@ const Home = () => { </div> <PostsContainer className="container-padding--zero lg:container-padding--auto" /> - {programEntry.discussionOpened && isAuthenticated && ( - <AddPostForm className="my-8 space-y-4" /> - )} + {programEntry.discussionOpened && + isAuthenticated && + !user.isBanned && <AddPostForm className="my-8 space-y-4" />} + + {programEntry.discussionOpened && + isAuthenticated && + user.isBanned && ( + <ErrorMessage className="mt-8"> + Jejda! Nemůžeš přidávat příspěvky, protože máš ban. Vyčkej než + ti ho předsedající odebere. + </ErrorMessage> + )} </section> </article> <ProgramEntryEditModal diff --git a/src/stores.js b/src/stores.js index c7db090..a82900a 100644 --- a/src/stores.js +++ b/src/stores.js @@ -3,6 +3,7 @@ import { Store } from "pullstate"; /** @type {CF2021.GlobalInfoStorePayload} */ const globalInfoStoreInitial = { + connectionState: "connecting", onlineUsers: 0, streamUrl: null, }; diff --git a/src/utils.js b/src/utils.js index 2a80f37..cc62eea 100644 --- a/src/utils.js +++ b/src/utils.js @@ -3,9 +3,13 @@ import filter from "lodash/filter"; import pick from "lodash/pick"; import property from "lodash/property"; import values from "lodash/values"; +import WaitQueue from "wait-queue"; import { markdownConverter } from "markdown"; +export const seenPostsLSKey = "cf2021_seen_posts"; +export const seenAnnouncementsLSKey = "cf2021_seen_announcements"; + /** * Filter & sort collection of posts. * @param {CF2021.PostStoreFilters} filters @@ -136,7 +140,7 @@ export const parseRawPost = (rawPost) => { modified: Boolean(rawPost.is_changed), archived: Boolean(rawPost.is_archived), hidden: false, - seen: false, + seen: isSeen(seenPostsLSKey, rawPost.id), }; if (post.type === "procedure-proposal") { @@ -161,8 +165,36 @@ export const parseRawAnnouncement = (rawAnnouncement) => { new Date() ), type: announcementTypeMapping[rawAnnouncement.type], - seen: false, + seen: isSeen(seenAnnouncementsLSKey, rawAnnouncement.id), }; return announcement; }; + +export const createSeenWriter = (localStorageKey) => { + const queue = new WaitQueue(); + + const seenWriterWorker = async () => { + const id = await queue.shift(); + const seen = new Set( + (localStorage.getItem(localStorageKey) || "").split(",") + ); + + seen.add(id); + + localStorage.setItem(localStorageKey, Array.from(seen).join(",")); + + setTimeout(seenWriterWorker); + }; + + seenWriterWorker(); + + return { + markSeen: (id) => queue.push(id), + }; +}; + +export const isSeen = (localStorageKey, id) => { + const val = localStorage.getItem(localStorageKey) || ""; + return val.indexOf(id) !== -1; +}; diff --git a/src/ws/connection.js b/src/ws/connection.js index df922b2..17f9c2e 100644 --- a/src/ws/connection.js +++ b/src/ws/connection.js @@ -1,5 +1,8 @@ +import has from "lodash/has"; import WaitQueue from "wait-queue"; +import { GlobalInfoStore } from "stores"; + import { handlers } from "./handlers"; function Worker() { @@ -17,6 +20,13 @@ function Worker() { try { const data = JSON.parse(event.data); + // Special message, TODO: fix + if (has(data, "online_users")) { + return GlobalInfoStore.update((state) => { + state.onlineUsers = data["online_users"]; + }); + } + if (!data.event) { return console.error("[ws][worker] Missing `event` field"); } @@ -46,12 +56,20 @@ export const connect = ({ onConnect }) => { return new Promise((resolve, reject) => { const worker = Worker(); + GlobalInfoStore.update((state) => { + state.connectionState = "connecting"; + }); const ws = new WebSocket(process.env.REACT_APP_WS_BASE_URL); + let keepAliveInterval; console.log("[ws] Connecting ..."); ws.onopen = () => { + GlobalInfoStore.update((state) => { + state.connectionState = "connected"; + }); console.log("[ws] Connected."); + ws.send("CONNECT"); keepAliveInterval = setInterval(() => { ws.send("KEEPALIVE"); @@ -70,6 +88,9 @@ export const connect = ({ onConnect }) => { ws.onmessage = worker.queue.push.bind(worker.queue); ws.onclose = (event) => { + GlobalInfoStore.update((state) => { + state.connectionState = "offline"; + }); console.log( "[ws] Socket is closed. Reconnect will be attempted in 1 second.", event.reason diff --git a/src/ws/handlers/index.js b/src/ws/handlers/index.js index b10730e..fb4f300 100644 --- a/src/ws/handlers/index.js +++ b/src/ws/handlers/index.js @@ -6,9 +6,11 @@ import { import { handlePostChanged, handlePostCreated, + handlePostDeleted, handlePostRanked, } from "./posts"; import { handleProgramEntryChanged } from "./program"; +import { handleUserBanned, handleUserUnbanned } from "./users"; export const handlers = { announcement_changed: handleAnnouncementChanged, @@ -17,5 +19,8 @@ export const handlers = { post_ranked: handlePostRanked, post_changed: handlePostChanged, post_created: handlePostCreated, + post_deleted: handlePostDeleted, program_entry_changed: handleProgramEntryChanged, + user_banned: handleUserBanned, + user_unbanned: handleUserUnbanned, }; diff --git a/src/ws/handlers/posts.js b/src/ws/handlers/posts.js index 1eb6dc7..d2bb0b3 100644 --- a/src/ws/handlers/posts.js +++ b/src/ws/handlers/posts.js @@ -38,6 +38,8 @@ export const handlePostChanged = (payload) => { if (has(payload, "is_archived")) { state.items[payload.id].archived = payload.is_archived; } + + updateWindowPosts(state); } }); }; @@ -49,3 +51,10 @@ export const handlePostCreated = (payload) => { updateWindowPosts(state); }); }; + +export const handlePostDeleted = (payload) => { + PostStore.update((state) => { + delete state.items[payload.id]; + updateWindowPosts(state); + }); +}; diff --git a/src/ws/handlers/users.js b/src/ws/handlers/users.js new file mode 100644 index 0000000..bf9243f --- /dev/null +++ b/src/ws/handlers/users.js @@ -0,0 +1,17 @@ +import { AuthStore } from "stores"; + +export const handleUserBanned = (payload) => { + AuthStore.update((state) => { + if (state.user && state.user.id && payload.id === state.user.id) { + state.user.isBanned = true; + } + }); +}; + +export const handleUserUnbanned = (payload) => { + AuthStore.update((state) => { + if (state.user && state.user.id && payload.id === state.user.id) { + state.user.isBanned = false; + } + }); +}; diff --git a/typings/cf2021.d.ts b/typings/cf2021.d.ts index 33bb981..5b31734 100644 --- a/typings/cf2021.d.ts +++ b/typings/cf2021.d.ts @@ -1,5 +1,6 @@ declare namespace CF2021 { export interface GlobalInfoStorePayload { + connectionState: "connected" | "offline" | "connecting"; onlineUsers: number; streamUrl?: string; } @@ -40,6 +41,11 @@ declare namespace CF2021 { username: string; role: "regp" | "member" | "chairman"; accessToken: string; + + // These are optional as they're loaded separately. + id?: number; + isBanned?: boolean; + group?: string; }; } -- GitLab