Skip to content
Snippets Groups Projects
Commit b90ce7eb authored by xaralis's avatar xaralis
Browse files

feat: delete post, ban and reactions

parent 0c930d33
No related branches found
No related tags found
No related merge requests found
Pipeline #1876 passed
REACT_APP_STYLEGUIDE_URL=http://localhost:3001 REACT_APP_STYLEGUIDE_URL=http://localhost:3001
REACT_APP_API_BASE_URL=https://cf2021.pirati.cz/api REACT_APP_API_BASE_URL=https://cf2021.pirati.cz/api
REACT_APP_WS_BASE_URL=wss://cf2021.pirati.cz/ws REACT_APP_WS_BASE_URL=wss://cf2021.pirati.cz/ws
REACT_APP_MATOMO_ID=135
...@@ -49,4 +49,19 @@ ...@@ -49,4 +49,19 @@
To create a production bundle, use `npm run build` or `yarn build`. To create a production bundle, use `npm run build` or `yarn build`.
--> -->
</body> </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> </html>
...@@ -4,6 +4,7 @@ import { KeycloakProvider } from "@react-keycloak/web"; ...@@ -4,6 +4,7 @@ import { KeycloakProvider } from "@react-keycloak/web";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { loadConfig } from "actions/global-info"; import { loadConfig } from "actions/global-info";
import { loadMe } from "actions/users";
import { initializeWSChannel } from "actions/ws"; import { initializeWSChannel } from "actions/ws";
import Footer from "components/Footer"; import Footer from "components/Footer";
import Navbar from "components/Navbar"; import Navbar from "components/Navbar";
...@@ -50,6 +51,9 @@ const onKeycloakEvent = (event) => { ...@@ -50,6 +51,9 @@ const onKeycloakEvent = (event) => {
}; };
}); });
// Once base user details has been stored, load me details from API.
loadMe.run();
PostStore.update((state) => { PostStore.update((state) => {
// Only display proposals verified by chairman to other users. // Only display proposals verified by chairman to other users.
state.filters.showPendingProposals = role === "chairman"; state.filters.showPendingProposals = role === "chairman";
......
...@@ -6,7 +6,9 @@ import { fetch } from "api"; ...@@ -6,7 +6,9 @@ import { fetch } from "api";
import { AnnouncementStore } from "stores"; import { AnnouncementStore } from "stores";
import { import {
announcementTypeMappingRev, announcementTypeMappingRev,
createSeenWriter,
parseRawAnnouncement, parseRawAnnouncement,
seenAnnouncementsLSKey,
syncAnnoucementItemIds, syncAnnoucementItemIds,
} from "utils"; } from "utils";
...@@ -104,3 +106,17 @@ export const updateAnnouncementContent = createAsyncAction( ...@@ -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;
});
};
...@@ -5,10 +5,12 @@ import { createAsyncAction, errorResult, successResult } from "pullstate"; ...@@ -5,10 +5,12 @@ import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api"; import { fetch } from "api";
import { PostStore } from "stores"; import { PostStore } from "stores";
import { import {
createSeenWriter,
filterPosts, filterPosts,
parseRawPost, parseRawPost,
postsStateMappingRev, postsStateMappingRev,
postsTypeMappingRev, postsTypeMappingRev,
seenPostsLSKey,
} from "utils"; } from "utils";
export const loadPosts = createAsyncAction( export const loadPosts = createAsyncAction(
...@@ -135,16 +137,15 @@ export const hide = createAsyncAction( ...@@ -135,16 +137,15 @@ export const hide = createAsyncAction(
* @param {CF2021.Post} post * @param {CF2021.Post} post
*/ */
async (post) => { async (post) => {
return successResult(post); try {
}, await fetch(`/posts/${post.id}`, {
{ method: "DELETE",
postActionHook: ({ result }) => { expectedStatus: 204,
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].hidden = true;
}); });
return successResult();
} catch (err) {
return errorResult([], err.toString());
} }
},
} }
); );
...@@ -312,3 +313,17 @@ export const rejectProposalByChairman = createAsyncAction( ...@@ -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;
});
};
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( export const ban = createAsyncAction(
/** /**
* @param {number} userId * @param {number} userId
*/ */
async (userId) => { async (user) => {
return successResult(userId); 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());
}
} }
); );
...@@ -3,6 +3,7 @@ import React, { useCallback, useState } from "react"; ...@@ -3,6 +3,7 @@ import React, { useCallback, useState } from "react";
import { import {
deleteAnnouncement, deleteAnnouncement,
loadAnnouncements, loadAnnouncements,
markSeen,
updateAnnouncementContent, updateAnnouncementContent,
} from "actions/announcements"; } from "actions/announcements";
import AnnouncementEditModal from "components/annoucements/AnnouncementEditModal"; import AnnouncementEditModal from "components/annoucements/AnnouncementEditModal";
...@@ -63,16 +64,6 @@ const AnnoucementsContainer = () => { ...@@ -63,16 +64,6 @@ const AnnoucementsContainer = () => {
setItemToEdit(null); setItemToEdit(null);
}, [setItemToEdit]); }, [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 ( return (
<> <>
{loadResult && loadResult.error && ( {loadResult && loadResult.error && (
......
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
hide, hide,
like, like,
loadPosts, loadPosts,
markSeen,
rejectProposal, rejectProposal,
rejectProposalByChairman, rejectProposalByChairman,
} from "actions/posts"; } from "actions/posts";
...@@ -134,16 +135,6 @@ const PostsContainer = ({ className }) => { ...@@ -134,16 +135,6 @@ const PostsContainer = ({ className }) => {
setUserToBan(post.author); 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 sliceStart = (window.page - 1) * window.perPage;
const sliceEnd = window.page * window.perPage; const sliceEnd = window.page * window.perPage;
const windowItems = window.items.map((postId) => items[postId]); const windowItems = window.items.map((postId) => items[postId]);
......
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;
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
} from "actions/program"; } from "actions/program";
import Button from "components/Button"; import Button from "components/Button";
import { DropdownMenu, DropdownMenuItem } from "components/dropdown-menu"; import { DropdownMenu, DropdownMenuItem } from "components/dropdown-menu";
import ErrorMessage from "components/ErrorMessage";
import ModalConfirm from "components/modals/ModalConfirm"; import ModalConfirm from "components/modals/ModalConfirm";
import ProgramEntryEditModal from "components/program/ProgramEntryEditModal"; import ProgramEntryEditModal from "components/program/ProgramEntryEditModal";
import AddAnnouncementForm from "containers/AddAnnouncementForm"; import AddAnnouncementForm from "containers/AddAnnouncementForm";
...@@ -16,6 +17,7 @@ import AddPostForm from "containers/AddPostForm"; ...@@ -16,6 +17,7 @@ import AddPostForm from "containers/AddPostForm";
import AnnouncementsContainer from "containers/AnnoucementsContainer"; import AnnouncementsContainer from "containers/AnnoucementsContainer";
import PostFilters from "containers/PostFilters"; import PostFilters from "containers/PostFilters";
import PostsContainer from "containers/PostsContainer"; import PostsContainer from "containers/PostsContainer";
import StatsCard from "containers/StatsCard";
import { useActionConfirm } from "hooks"; import { useActionConfirm } from "hooks";
import { AuthStore, GlobalInfoStore, ProgramStore } from "stores"; import { AuthStore, GlobalInfoStore, ProgramStore } from "stores";
...@@ -215,6 +217,10 @@ const Home = () => { ...@@ -215,6 +217,10 @@ const Home = () => {
)} )}
</section> </section>
<section className="cf2021__stats">
<StatsCard />
</section>
<section className="cf2021__notifications"> <section className="cf2021__notifications">
<div className="lg:card lg:elevation-10"> <div className="lg:card lg:elevation-10">
<div className="lg:card__body pb-2 lg:py-6"> <div className="lg:card__body pb-2 lg:py-6">
...@@ -249,8 +255,17 @@ const Home = () => { ...@@ -249,8 +255,17 @@ const Home = () => {
</div> </div>
<PostsContainer className="container-padding--zero lg:container-padding--auto" /> <PostsContainer className="container-padding--zero lg:container-padding--auto" />
{programEntry.discussionOpened && isAuthenticated && ( {programEntry.discussionOpened &&
<AddPostForm className="my-8 space-y-4" /> 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> </section>
</article> </article>
......
...@@ -3,6 +3,7 @@ import { Store } from "pullstate"; ...@@ -3,6 +3,7 @@ import { Store } from "pullstate";
/** @type {CF2021.GlobalInfoStorePayload} */ /** @type {CF2021.GlobalInfoStorePayload} */
const globalInfoStoreInitial = { const globalInfoStoreInitial = {
connectionState: "connecting",
onlineUsers: 0, onlineUsers: 0,
streamUrl: null, streamUrl: null,
}; };
......
...@@ -3,9 +3,13 @@ import filter from "lodash/filter"; ...@@ -3,9 +3,13 @@ import filter from "lodash/filter";
import pick from "lodash/pick"; import pick from "lodash/pick";
import property from "lodash/property"; import property from "lodash/property";
import values from "lodash/values"; import values from "lodash/values";
import WaitQueue from "wait-queue";
import { markdownConverter } from "markdown"; import { markdownConverter } from "markdown";
export const seenPostsLSKey = "cf2021_seen_posts";
export const seenAnnouncementsLSKey = "cf2021_seen_announcements";
/** /**
* Filter & sort collection of posts. * Filter & sort collection of posts.
* @param {CF2021.PostStoreFilters} filters * @param {CF2021.PostStoreFilters} filters
...@@ -136,7 +140,7 @@ export const parseRawPost = (rawPost) => { ...@@ -136,7 +140,7 @@ export const parseRawPost = (rawPost) => {
modified: Boolean(rawPost.is_changed), modified: Boolean(rawPost.is_changed),
archived: Boolean(rawPost.is_archived), archived: Boolean(rawPost.is_archived),
hidden: false, hidden: false,
seen: false, seen: isSeen(seenPostsLSKey, rawPost.id),
}; };
if (post.type === "procedure-proposal") { if (post.type === "procedure-proposal") {
...@@ -161,8 +165,36 @@ export const parseRawAnnouncement = (rawAnnouncement) => { ...@@ -161,8 +165,36 @@ export const parseRawAnnouncement = (rawAnnouncement) => {
new Date() new Date()
), ),
type: announcementTypeMapping[rawAnnouncement.type], type: announcementTypeMapping[rawAnnouncement.type],
seen: false, seen: isSeen(seenAnnouncementsLSKey, rawAnnouncement.id),
}; };
return announcement; 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;
};
import has from "lodash/has";
import WaitQueue from "wait-queue"; import WaitQueue from "wait-queue";
import { GlobalInfoStore } from "stores";
import { handlers } from "./handlers"; import { handlers } from "./handlers";
function Worker() { function Worker() {
...@@ -17,6 +20,13 @@ function Worker() { ...@@ -17,6 +20,13 @@ function Worker() {
try { try {
const data = JSON.parse(event.data); 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) { if (!data.event) {
return console.error("[ws][worker] Missing `event` field"); return console.error("[ws][worker] Missing `event` field");
} }
...@@ -46,12 +56,20 @@ export const connect = ({ onConnect }) => { ...@@ -46,12 +56,20 @@ export const connect = ({ onConnect }) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const worker = Worker(); const worker = Worker();
GlobalInfoStore.update((state) => {
state.connectionState = "connecting";
});
const ws = new WebSocket(process.env.REACT_APP_WS_BASE_URL); const ws = new WebSocket(process.env.REACT_APP_WS_BASE_URL);
let keepAliveInterval; let keepAliveInterval;
console.log("[ws] Connecting ..."); console.log("[ws] Connecting ...");
ws.onopen = () => { ws.onopen = () => {
GlobalInfoStore.update((state) => {
state.connectionState = "connected";
});
console.log("[ws] Connected."); console.log("[ws] Connected.");
ws.send("CONNECT");
keepAliveInterval = setInterval(() => { keepAliveInterval = setInterval(() => {
ws.send("KEEPALIVE"); ws.send("KEEPALIVE");
...@@ -70,6 +88,9 @@ export const connect = ({ onConnect }) => { ...@@ -70,6 +88,9 @@ export const connect = ({ onConnect }) => {
ws.onmessage = worker.queue.push.bind(worker.queue); ws.onmessage = worker.queue.push.bind(worker.queue);
ws.onclose = (event) => { ws.onclose = (event) => {
GlobalInfoStore.update((state) => {
state.connectionState = "offline";
});
console.log( console.log(
"[ws] Socket is closed. Reconnect will be attempted in 1 second.", "[ws] Socket is closed. Reconnect will be attempted in 1 second.",
event.reason event.reason
......
...@@ -6,9 +6,11 @@ import { ...@@ -6,9 +6,11 @@ import {
import { import {
handlePostChanged, handlePostChanged,
handlePostCreated, handlePostCreated,
handlePostDeleted,
handlePostRanked, handlePostRanked,
} from "./posts"; } from "./posts";
import { handleProgramEntryChanged } from "./program"; import { handleProgramEntryChanged } from "./program";
import { handleUserBanned, handleUserUnbanned } from "./users";
export const handlers = { export const handlers = {
announcement_changed: handleAnnouncementChanged, announcement_changed: handleAnnouncementChanged,
...@@ -17,5 +19,8 @@ export const handlers = { ...@@ -17,5 +19,8 @@ export const handlers = {
post_ranked: handlePostRanked, post_ranked: handlePostRanked,
post_changed: handlePostChanged, post_changed: handlePostChanged,
post_created: handlePostCreated, post_created: handlePostCreated,
post_deleted: handlePostDeleted,
program_entry_changed: handleProgramEntryChanged, program_entry_changed: handleProgramEntryChanged,
user_banned: handleUserBanned,
user_unbanned: handleUserUnbanned,
}; };
...@@ -38,6 +38,8 @@ export const handlePostChanged = (payload) => { ...@@ -38,6 +38,8 @@ export const handlePostChanged = (payload) => {
if (has(payload, "is_archived")) { if (has(payload, "is_archived")) {
state.items[payload.id].archived = payload.is_archived; state.items[payload.id].archived = payload.is_archived;
} }
updateWindowPosts(state);
} }
}); });
}; };
...@@ -49,3 +51,10 @@ export const handlePostCreated = (payload) => { ...@@ -49,3 +51,10 @@ export const handlePostCreated = (payload) => {
updateWindowPosts(state); updateWindowPosts(state);
}); });
}; };
export const handlePostDeleted = (payload) => {
PostStore.update((state) => {
delete state.items[payload.id];
updateWindowPosts(state);
});
};
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;
}
});
};
declare namespace CF2021 { declare namespace CF2021 {
export interface GlobalInfoStorePayload { export interface GlobalInfoStorePayload {
connectionState: "connected" | "offline" | "connecting";
onlineUsers: number; onlineUsers: number;
streamUrl?: string; streamUrl?: string;
} }
...@@ -40,6 +41,11 @@ declare namespace CF2021 { ...@@ -40,6 +41,11 @@ declare namespace CF2021 {
username: string; username: string;
role: "regp" | "member" | "chairman"; role: "regp" | "member" | "chairman";
accessToken: string; accessToken: string;
// These are optional as they're loaded separately.
id?: number;
isBanned?: boolean;
group?: string;
}; };
} }
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment