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
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
......@@ -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>
......@@ -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";
......
......@@ -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;
});
};
......@@ -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;
});
};
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());
}
}
);
......@@ -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 && (
......
......@@ -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]);
......
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 {
} 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,8 +255,17 @@ 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>
......
......@@ -3,6 +3,7 @@ import { Store } from "pullstate";
/** @type {CF2021.GlobalInfoStorePayload} */
const globalInfoStoreInitial = {
connectionState: "connecting",
onlineUsers: 0,
streamUrl: null,
};
......
......@@ -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;
};
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
......
......@@ -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,
};
......@@ -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);
});
};
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 {
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;
};
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment