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

feat: detect seen state, hook up posts API, listen on WS for post changes

parent 38aa9d14
Branches
No related tags found
No related merge requests found
Showing
with 335 additions and 471 deletions
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/posts
......@@ -11332,6 +11332,11 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
},
"react-intersection-observer": {
"version": "8.31.0",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-8.31.0.tgz",
"integrity": "sha512-XraIC/tkrD9JtrmVA7ypEN1QIpKc52mXBH1u/bz/aicRLo8QQEJQAMUTb8mz4B6dqpPwyzgjrr7Ljv/2ACDtqw=="
},
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
......
......
......@@ -14,6 +14,7 @@
"react": "^16.13.1",
"react-device-detect": "^1.13.1",
"react-dom": "^16.13.1",
"react-intersection-observer": "^8.31.0",
"react-modal": "^3.12.1",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.3",
......@@ -54,7 +55,7 @@
"^@?\\w"
],
[
"^(api|actions|config|hooks|components|containers|pages|utils|stores|keycloak)(/.*|$)"
"^(api|actions|config|hooks|components|containers|pages|utils|stores|keycloak|ws)(/.*|$)"
],
[
"^(test-utils)(/.*|$)"
......
......
......@@ -5,6 +5,7 @@ import * as Sentry from "@sentry/react";
import { loadGroupMappings } from "actions/misc";
import { loadProgram } from "actions/program";
import { loadPosts } from "actions/posts";
import Footer from "components/Footer";
import Navbar from "components/Navbar";
import Home from "pages/Home";
......@@ -58,6 +59,7 @@ const LoadingComponent = (
const BaseApp = () => {
loadGroupMappings.read();
loadProgram.read();
loadPosts.read();
return (
<Router>
......
......
import keyBy from "lodash/keyBy";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api";
import { PostStore } from "stores";
import { updateWindowPosts } from "utils";
import { filterPosts, parseRawPost, postsTypeMappingRev } from "utils";
export const like = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "none") {
return errorResult();
export const loadPosts = createAsyncAction(
async () => {
try {
const resp = await fetch("/posts");
const data = await resp.json();
return successResult(data.data);
} catch (err) {
return errorResult([], err.toString());
}
return false;
},
{
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.likes += 1;
state.items[result.payload.id].ranking.score += 1;
state.items[result.payload.id].ranking.myVote = "like";
const posts = result.payload.map(parseRawPost);
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
PostStore.update((state) => {
const filteredPosts = filterPosts(state.filters, posts);
state.items = keyBy(posts, property("id"));
state.itemCount = state.items.length;
state.window = {
items: filteredPosts.map(property("id")),
itemCount: filteredPosts.length,
page: 1,
perPage: 5,
};
});
}
},
}
);
export const removeLike = createAsyncAction(
export const like = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
try {
await fetch(`/posts/${post.id}/like`, { method: "PATCH" });
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "like") {
return errorResult();
} catch (err) {
return errorResult([], err.toString());
}
return false;
},
{
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.likes -= 1;
state.items[result.payload.id].ranking.score -= 1;
state.items[result.payload.id].ranking.myVote = "none";
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
state.items[result.payload.id].ranking.myVote = "like";
});
}
},
......@@ -70,57 +66,18 @@ export const dislike = createAsyncAction(
* @param {CF2021.Post} post
*/
async (post) => {
try {
await fetch(`/posts/${post.id}/dislike`, { method: "PATCH" });
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "none") {
return errorResult();
} catch (err) {
return errorResult([], err.toString());
}
return false;
},
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.dislikes += 1;
state.items[result.payload.id].ranking.score -= 1;
state.items[result.payload.id].ranking.myVote = "dislike";
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
});
}
},
}
);
export const removeDislike = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "dislike") {
return errorResult();
}
return false;
},
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.dislikes -= 1;
state.items[result.payload.id].ranking.score += 1;
state.items[result.payload.id].ranking.myVote = "none";
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
state.items[result.payload.id].ranking.myVote = "dislike";
});
}
},
......@@ -130,80 +87,34 @@ export const removeDislike = createAsyncAction(
/**
* Add new discussion post.
*/
export const addPost = createAsyncAction(
async ({ content }) => {
/** @type {CF2021.DiscussionPost} */
const payload = {
id: "999",
datetime: new Date(),
author: {
name: "John Doe",
group: "KS Pardubický kraj",
},
type: "post",
export const addPost = createAsyncAction(async ({ content }) => {
try {
const body = JSON.stringify({
content,
ranking: {
score: 0,
likes: 0,
dislikes: 0,
myVote: "none",
},
historyLog: [],
archived: false,
hidden: false,
seen: true,
};
return successResult(payload);
},
{
postActionHook: ({ result }) => {
PostStore.update((state) => {
state.items[result.payload.id] = result.payload;
updateWindowPosts(state);
type: postsTypeMappingRev["post"],
});
},
await fetch(`/posts`, { method: "POST", body });
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
);
});
/**
* Add new proposal.
*/
export const addProposal = createAsyncAction(
async ({ content }) => {
/** @type {CF2021.ProposalPost} */
const payload = {
id: "999",
datetime: new Date(),
author: {
name: "John Doe",
group: "KS Pardubický kraj",
},
type: "procedure-proposal",
state: "pending",
export const addProposal = createAsyncAction(async ({ content }) => {
try {
const body = JSON.stringify({
content,
ranking: {
score: 0,
likes: 0,
dislikes: 0,
myVote: "none",
},
historyLog: [],
archived: false,
hidden: false,
seen: true,
};
return successResult(payload);
},
{
postActionHook: ({ result }) => {
PostStore.update((state) => {
state.items[result.payload.id] = result.payload;
updateWindowPosts(state);
type: postsTypeMappingRev["procedure-proposal"],
});
},
await fetch(`/posts`, { method: "POST", body });
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
);
});
/**
* Hide existing post.
......
......
import React from "react";
import classNames from "classnames";
const Thumbs = ({ likes, dislikes, onLike, onDislike, myVote }) => {
return (
<div>
<div className="space-x-2 text-sm flex items-center">
<button
className={classNames("text-blue-300 flex items-center space-x-1", {
"cursor-pointer": myVote === "none" || myVote === "like",
"cursor-default": myVote === "dislike",
})}
disabled={myVote === "dislike"}
className="text-blue-300 flex items-center space-x-1 cursor-pointer"
onClick={onLike}
>
<span className="font-bold">{likes}</span>
<i className="ico--thumbs-up"></i>
</button>
<button
className={classNames("text-red-600 flex items-center space-x-1", {
"cursor-pointer": myVote === "none" || myVote === "dislike",
"cursor-default": myVote === "like",
})}
disabled={myVote === "like"}
className="text-red-600 flex items-center space-x-1 cursor-pointer"
onClick={onDislike}
>
<i className="ico--thumbs-down transform -scale-x-1"></i>
......
......
import React from "react";
import React, { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import classNames from "classnames";
import { format } from "date-fns";
......@@ -16,9 +17,24 @@ const Announcement = ({
displayActions = false,
onDelete,
onEdit,
onSeen,
}) => {
const { ref, inView } = useInView({
threshold: 1,
trackVisibility: true,
delay: 1500,
skip: seen,
triggerOnce: true,
});
useEffect(() => {
if (!seen && inView && onSeen) {
onSeen();
}
});
const wrapperClassName = classNames(
"bg-opacity-50 border-l-2 px-4 py-2 lg:px-8 lg:py-3",
"bg-opacity-50 border-l-2 px-4 py-2 lg:px-8 lg:py-3 transition duration-500",
{
"bg-grey-50": !!seen,
"bg-yellow-100": !seen,
......@@ -60,7 +76,7 @@ const Announcement = ({
].includes(type);
return (
<div className={wrapperClassName}>
<div className={wrapperClassName} ref={ref}>
<div className="flex items-center justify-between mb-2">
<div className="space-x-2 flex items-center">
<div className="font-bold text-sm">{format(datetime, "H:mm")}</div>
......
......
......@@ -9,6 +9,7 @@ const AnnouncementList = ({
displayActions,
onDelete,
onEdit,
onSeen,
}) => {
const buildHandler = (responderFn) => (post) => (evt) => {
evt.preventDefault();
......@@ -18,6 +19,10 @@ const AnnouncementList = ({
const onAnnouncementEdit = buildHandler(onEdit);
const onAnnouncementDelete = buildHandler(onDelete);
const onAnnouncementSeen = (announcement) => () => {
onSeen(announcement);
};
return (
<div className={classNames("space-y-px", className)}>
{items.map((item) => (
......@@ -31,6 +36,7 @@ const AnnouncementList = ({
displayActions={displayActions}
onEdit={onAnnouncementEdit(item)}
onDelete={onAnnouncementDelete(item)}
onSeen={onAnnouncementSeen(item)}
/>
))}
</div>
......
......
import React from "react";
import React, { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import classNames from "classnames";
import { format } from "date-fns";
......@@ -13,10 +14,10 @@ const Post = ({
type,
ranking,
content,
modified,
seen,
archived,
state,
historyLog,
dimIfArchived = true,
displayActions = false,
onLike,
......@@ -26,13 +27,27 @@ const Post = ({
onAnnounceProcedureProposal,
onAcceptProcedureProposal,
onRejectProcedureProposal,
onSeen,
}) => {
const { ref, inView } = useInView({
threshold: 0.9,
trackVisibility: true,
delay: 1500,
skip: seen,
triggerOnce: true,
});
useEffect(() => {
if (!seen && inView && onSeen) {
onSeen();
}
});
const wrapperClassName = classNames(
"flex items-start p-4 lg:p-2 lg:py-3 lg:-mx-2",
"flex items-start p-4 lg:p-2 lg:py-3 lg:-mx-2 transition duration-500",
{
"bg-yellow-100 bg-opacity-50": !seen,
"opacity-25 hover:opacity-100 transition-opacity duration-200":
dimIfArchived && !!archived,
"opacity-25 hover:opacity-100": dimIfArchived && !!archived,
},
className
);
......@@ -103,12 +118,6 @@ const Post = ({
);
}
const isModified =
(historyLog || []).filter(
(logRecord) =>
logRecord.attribute === "content" && logRecord.originator === "chairman"
).length > 0;
const showAnnounceAction =
type === "procedure-proposal" && state === "pending";
const showAcceptAction =
......@@ -119,10 +128,10 @@ const Post = ({
const showHideAction = !archived;
return (
<div className={wrapperClassName}>
<div className={wrapperClassName} ref={ref}>
<img
src="http://placeimg.com/100/100/people"
className="w-8 h-8 lg:w-14 lg:h-14 rounded mr-3"
src={`https://a.pirati.cz/piratar/${author.username}.jpg`}
className="w-8 h-8 lg:w-14 lg:h-14 rounded mr-3 object-cover"
alt={author.name}
/>
<div className="flex-1">
......@@ -135,7 +144,7 @@ const Post = ({
<span className="text-grey-200 text-sm">{author.group}</span>
<span className="text-grey-200 ml-1 text-sm">
@ {format(datetime, "H:mm")}
{isModified && (
{modified && (
<span className="text-grey-200 text-xs ml-2 underline">
(Upraveno přesdedajícím)
</span>
......
......
......@@ -13,6 +13,7 @@ const PostList = ({
onAnnounceProcedureProposal,
onAcceptProcedureProposal,
onRejectProcedureProposal,
onSeen,
dimArchived,
}) => {
const buildHandler = (responderFn) => (post) => (evt) => {
......@@ -30,6 +31,10 @@ const PostList = ({
const onPostAcceptProcedureProposal = buildHandler(onAcceptProcedureProposal);
const onPostRejectProcedureProposal = buildHandler(onRejectProcedureProposal);
const onPostSeen = (post) => () => {
onSeen(post);
};
return (
<div className={classNames("space-y-px", className)}>
{items
......@@ -44,6 +49,7 @@ const PostList = ({
content={item.content}
ranking={item.ranking}
historyLog={item.historyLog}
modified={item.modified}
seen={item.seen}
archived={item.archived}
displayActions={true}
......@@ -55,6 +61,7 @@ const PostList = ({
onAnnounceProcedureProposal={onPostAnnounceProcedureProposal(item)}
onAcceptProcedureProposal={onPostAcceptProcedureProposal(item)}
onRejectProcedureProposal={onPostRejectProcedureProposal(item)}
onSeen={onPostSeen(item)}
/>
))}
</div>
......
......
......@@ -9,6 +9,7 @@ import AnnouncementList from "components/annoucements/AnnouncementList";
import ModalConfirm from "components/modals/ModalConfirm";
import { useItemActionConfirm } from "hooks";
import { AnnouncementStore } from "stores";
import findIndex from "lodash/findIndex";
const AnnoucementsContainer = () => {
const [itemToEdit, setItemToEdit] = useState(null);
......@@ -36,6 +37,17 @@ const AnnoucementsContainer = () => {
setItemToEdit(null);
}, [setItemToEdit]);
/**
* Mark down user saw this post already.
* @param {CF2021.Announcement} post
*/
const markSeen = (announcement) => {
AnnouncementStore.update((state) => {
const idx = findIndex(state.items, announcement);
state.items[idx].seen = true;
});
};
return (
<>
<AnnouncementList
......@@ -43,6 +55,7 @@ const AnnoucementsContainer = () => {
displayActions={true}
onDelete={setItemToDelete}
onEdit={setItemToEdit}
onSeen={markSeen}
/>
<ModalConfirm
isOpen={!!itemToDelete}
......
......
......@@ -8,8 +8,6 @@ import {
hide,
like,
rejectProposal,
removeDislike,
removeLike,
} from "actions/posts";
import { ban } from "actions/users";
import ModalConfirm from "components/modals/ModalConfirm";
......@@ -57,37 +55,21 @@ const PostsContainer = ({ className }) => {
);
/**
*
* @param {CF2021.Post} post
*/
const onLike = (post) => {
if (post.ranking.myVote === "none") {
return like.run(post);
}
if (post.ranking.myVote === "like") {
return removeLike.run(post);
}
};
/**
*
* Ban a post's author.
* @param {CF2021.Post} post
*/
const onDislike = (post) => {
if (post.ranking.myVote === "none") {
return dislike.run(post);
}
if (post.ranking.myVote === "dislike") {
return removeDislike.run(post);
}
const onBanUser = (post) => {
setUserToBan(post.author);
};
/**
* Ban a post's author.
* Mark down user saw this post already.
* @param {CF2021.Post} post
*/
const onBanUser = (post) => {
setUserToBan(post.author);
const markSeen = (post) => {
PostStore.update((state) => {
state.items[post.id].seen = true;
});
};
const sliceStart = (window.page - 1) * window.perPage;
......@@ -99,8 +81,9 @@ const PostsContainer = ({ className }) => {
items={window.items
.slice(sliceStart, sliceEnd)
.map((postId) => items[postId])}
onLike={onLike}
onDislike={onDislike}
onLike={like.run}
onDislike={dislike.run}
onSeen={markSeen}
className={className}
dimArchived={!showingArchivedOnly}
displayActions={true}
......
......
......@@ -2,11 +2,13 @@ import React from "react";
import ReactDOM from "react-dom";
import ReactModal from "react-modal";
import { connect } from "./ws/connection";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
const root = document.getElementById("root");
const render = () => {
ReactDOM.render(
<React.StrictMode>
<App />
......@@ -14,6 +16,9 @@ ReactDOM.render(
root
);
ReactModal.setAppElement(root);
};
connect().then(render);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
......
......
import keyBy from "lodash/keyBy";
import memoize from "lodash/memoize";
import property from "lodash/property";
import { Store } from "pullstate";
import { filterPosts } from "utils";
/** @type {CF2021.AuthStorePayload} */
const authStoreInitial = {
isAuthenticated: false,
......@@ -77,261 +73,22 @@ const announcementStoreInitial = {
export const AnnouncementStore = new Store(announcementStoreInitial);
const allPosts = [
{
id: "1",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "cf",
},
ranking: {
likes: 0,
dislikes: 0,
score: 0,
myVote: "none",
},
seen: false,
archived: false,
type: "post",
},
{
id: "2",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "cf",
},
ranking: {
likes: 1,
dislikes: 0,
score: 1,
myVote: "none",
},
seen: false,
archived: false,
type: "post",
},
{
id: "3",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "cf",
},
ranking: {
likes: 5,
dislikes: 5,
score: 0,
myVote: "none",
},
seen: true,
archived: false,
type: "post",
},
{
id: "4",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "KS Pardubický kraj",
},
ranking: {
likes: 0,
dislikes: 10,
score: -10,
myVote: "none",
},
seen: true,
archived: false,
type: "post",
},
{
id: "5",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "KS Pardubický kraj",
},
ranking: {
likes: 1,
dislikes: 1,
score: 0,
myVote: "none",
},
seen: true,
archived: false,
type: "post",
},
{
id: "6",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "KS Pardubický kraj",
},
ranking: {
likes: 5,
dislikes: 3,
score: 2,
myVote: "none",
},
seen: true,
archived: true,
type: "post",
},
{
id: "7",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "KS Pardubický kraj",
},
ranking: {
likes: 5,
dislikes: 8,
score: -3,
myVote: "none",
},
seen: true,
archived: true,
type: "procedure-proposal",
state: "pending",
},
{
id: "8",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "KS Pardubický kraj",
},
ranking: {
likes: 2,
dislikes: 1,
score: 1,
myVote: "like",
},
seen: true,
archived: false,
type: "procedure-proposal",
state: "announced",
historyLog: [
{
attribute: "content",
datetime: new Date(),
newValue: "Lemme know",
originator: "chairman",
},
],
},
{
id: "9",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "KS Pardubický kraj",
},
ranking: {
likes: 5,
dislikes: 0,
score: 5,
myVote: "dislike",
},
seen: true,
archived: false,
type: "procedure-proposal",
state: "accepted",
},
{
id: "10",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "KS Pardubický kraj",
},
ranking: {
likes: 5,
dislikes: 8,
score: -3,
myVote: "none",
},
seen: true,
archived: false,
type: "procedure-proposal",
state: "rejected",
},
{
id: "11",
content:
"Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.",
datetime: new Date(),
author: {
id: 1,
name: "John Doe",
group: "KS Pardubický kraj",
},
ranking: {
likes: 10,
dislikes: 1,
score: 9,
myVote: "none",
},
seen: true,
archived: true,
type: "procedure-proposal",
state: "rejected-by-chairman",
},
];
const initialPostFilters = {
flags: "all",
sort: "byDate",
type: "all",
};
const filteredPosts = filterPosts(initialPostFilters, allPosts);
/** @type {CF2021.PostStorePayload} */
const postStoreInitial = {
items: keyBy(allPosts, property("id")),
itemCount: allPosts.length,
items: {},
itemCount: 0,
window: {
items: filteredPosts.map(property("id")),
itemCount: filteredPosts.length,
items: [],
itemCount: 0,
page: 1,
perPage: 5,
},
filters: initialPostFilters,
filters: {
flags: "all",
sort: "byDate",
type: "all",
},
};
export const PostStore = new Store(postStoreInitial);
......
......
import filter from "lodash/filter";
import property from "lodash/property";
import values from "lodash/values";
import pick from "lodash/pick";
/**
* Filter & sort collection of posts.
......@@ -43,3 +44,58 @@ export const updateWindowPosts = (state) => {
property("id")
);
};
export const postsMyVoteMapping = {
0: "none",
1: "like",
[-1]: "dislike",
};
export const postsTypeMapping = {
0: "post",
1: "procedure-proposal",
};
export const postsTypeMappingRev = {
post: 0,
"procedure-proposal": 1,
};
export const postsStateMapping = {
0: "pending",
1: "announced",
2: "accepted",
3: "rejected",
4: "rejected-by-chairman",
};
/**
* Parse single post from the API.
*
* @param {any} rawPost
* @returns {CF2021.Post}
*/
export const parseRawPost = (rawPost) => {
const post = {
...pick(rawPost, ["id", "content", "author"]),
datetime: new Date(rawPost.datetime),
historyLog: rawPost.history_log,
ranking: {
dislikes: rawPost.ranking.dislikes,
likes: rawPost.ranking.likes,
score: rawPost.ranking.score,
myVote: postsMyVoteMapping[rawPost.ranking.my_vote],
},
type: postsTypeMapping[rawPost.type],
modified: Boolean(rawPost.is_changed),
archived: Boolean(rawPost.is_archived),
hidden: false,
seen: false,
};
if (post.type === "procedure-proposal") {
post.state = postsStateMapping[post.state];
}
return post;
};
import { handleRanking } from "./handlers";
const handlerMap = {
ranked: handleRanking,
};
const messageRouter = (event) => {
console.debug("[ws] New message", event.data);
try {
const data = JSON.parse(event.data);
if (!data.event) {
return console.error("[ws] Missing `event` field");
}
const handlerFn = handlerMap[data.event];
if (!handlerFn) {
console.warn(`[ws] Can't handle event '${data.event}'`);
}
handlerFn(data.payload);
} catch (err) {
console.error("[ws] Could not parse message as JSON.");
}
};
export const connect = () => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(process.env.REACT_APP_WS_BASE_URL);
let keepAlive;
console.log("[ws] Connecting ...");
ws.onopen = () => {
console.log("[ws] Connected.");
resolve(ws);
keepAlive = setInterval(() => {
ws.send("KEEPALIVE");
console.debug("[ws] Sending keepalive.");
}, 30 * 1000);
};
ws.onmessage = messageRouter;
ws.onclose = (event) => {
console.log(
"[ws] Socket is closed. Reconnect will be attempted in 1 second.",
event.reason
);
clearInterval(keepAlive);
setTimeout(connect, 1000);
};
ws.onerror = (err) => {
console.error(
"[ws] Socket encountered error: ",
err.message,
"Closing socket"
);
ws.close();
reject(err);
};
});
};
export * from "./posts";
import { PostStore } from "stores";
import { parseRawPost, updateWindowPosts } from "utils";
export const handleRanking = (payload) => {
PostStore.update((state) => {
state.items[payload.id].ranking.likes = payload["ranking_likes"];
state.items[payload.id].ranking.dislikes = payload["ranking_dislikes"];
state.items[payload.id].ranking.score =
state.items[payload.id].ranking.likes -
state.items[payload.id].ranking.dislikes;
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
});
};
export const handleChanged = (payload) => {
PostStore.update((state) => {
state.items[payload.id].content = payload.content;
state.items[payload.id].modified = true;
});
};
export const handleCreated = (payload) => {
PostStore.update((state) => {
state.items[payload.id] = parseRawPost(payload);
updateWindowPosts(state);
});
};
......@@ -66,11 +66,12 @@ declare namespace CF2021 {
export type PostType = "post" | "procedure-proposal";
export interface AbstractPost {
id: string;
id: number;
datetime: Date;
author: {
id: number;
name: string;
username: string;
group: string;
};
type: PostType;
......@@ -87,6 +88,7 @@ declare namespace CF2021 {
datetime: Date;
originator: "self" | "chairman";
}[];
modified: boolean;
archived: boolean;
hidden: boolean;
seen: boolean;
......
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment