From 1eb1d71a3efbb321974f9d98f3df40135f33d195 Mon Sep 17 00:00:00 2001 From: Dessalines Date: Thu, 28 Mar 2019 12:32:08 -0700 Subject: [PATCH] Adding comment voting - Extracting out some components. - Fixing an issue with window refreshing websockets. --- .../2019-02-27-170003_create_community/up.sql | 2 + .../2019-03-03-163336_create_post/up.sql | 1 - .../2019-03-05-233828_create_comment/up.sql | 4 +- server/src/actions/comment.rs | 128 +++++++++++-- server/src/actions/post.rs | 6 + server/src/lib.rs | 1 + server/src/schema.rs | 2 + server/src/websocket_server/server.rs | 149 ++++++++++++--- ui/src/components/post.tsx | 176 ++++++++++++------ ui/src/interfaces.ts | 18 +- ui/src/services/WebSocketService.ts | 16 +- 11 files changed, 401 insertions(+), 102 deletions(-) diff --git a/server/migrations/2019-02-27-170003_create_community/up.sql b/server/migrations/2019-02-27-170003_create_community/up.sql index 1ee2e51..651a943 100644 --- a/server/migrations/2019-02-27-170003_create_community/up.sql +++ b/server/migrations/2019-02-27-170003_create_community/up.sql @@ -18,3 +18,5 @@ create table community_follower ( fedi_user_id text not null, published timestamp not null default now() ); + +insert into community (name) values ('main'); diff --git a/server/migrations/2019-03-03-163336_create_post/up.sql b/server/migrations/2019-03-03-163336_create_post/up.sql index f22192f..14294c8 100644 --- a/server/migrations/2019-03-03-163336_create_post/up.sql +++ b/server/migrations/2019-03-03-163336_create_post/up.sql @@ -16,4 +16,3 @@ create table post_like ( score smallint not null, -- -1, or 1 for dislike, like, no row for no opinion published timestamp not null default now() ); - diff --git a/server/migrations/2019-03-05-233828_create_comment/up.sql b/server/migrations/2019-03-05-233828_create_comment/up.sql index 63fc758..c80f8d1 100644 --- a/server/migrations/2019-03-05-233828_create_comment/up.sql +++ b/server/migrations/2019-03-05-233828_create_comment/up.sql @@ -11,7 +11,9 @@ create table comment ( create table comment_like ( id serial primary key, comment_id int references comment on update cascade on delete cascade not null, + post_id int references post on update cascade on delete cascade not null, fedi_user_id text not null, score smallint not null, -- -1, or 1 for dislike, like, no row for no opinion - published timestamp not null default now() + published timestamp not null default now(), + unique(comment_id, fedi_user_id) ); diff --git a/server/src/actions/comment.rs b/server/src/actions/comment.rs index 93e808a..7f2dace 100644 --- a/server/src/actions/comment.rs +++ b/server/src/actions/comment.rs @@ -36,12 +36,13 @@ pub struct CommentForm { pub updated: Option } -#[derive(Identifiable, Queryable, Associations, PartialEq, Debug)] +#[derive(Identifiable, Queryable, Associations, PartialEq, Debug, Clone)] #[belongs_to(Comment)] #[table_name = "comment_like"] pub struct CommentLike { pub id: i32, pub comment_id: i32, + pub post_id: i32, pub fedi_user_id: String, pub score: i16, pub published: chrono::NaiveDateTime, @@ -51,6 +52,7 @@ pub struct CommentLike { #[table_name="comment_like"] pub struct CommentLikeForm { pub comment_id: i32, + pub post_id: i32, pub fedi_user_id: String, pub score: i16 } @@ -70,9 +72,9 @@ impl Crud for Comment { fn create(conn: &PgConnection, comment_form: &CommentForm) -> Result { use schema::comment::dsl::*; - insert_into(comment) - .values(comment_form) - .get_result::(conn) + insert_into(comment) + .values(comment_form) + .get_result::(conn) } fn update(conn: &PgConnection, comment_id: i32, comment_form: &CommentForm) -> Result { @@ -84,6 +86,13 @@ impl Crud for Comment { } impl Likeable for CommentLike { + fn read(conn: &PgConnection, comment_id_from: i32) -> Result, Error> { + use schema::comment_like::dsl::*; + comment_like + .filter(comment_id.eq(comment_id_from)) + .load::(conn) + } + fn like(conn: &PgConnection, comment_like_form: &CommentLikeForm) -> Result { use schema::comment_like::dsl::*; insert_into(comment_like) @@ -93,21 +102,116 @@ impl Likeable for CommentLike { fn remove(conn: &PgConnection, comment_like_form: &CommentLikeForm) -> Result { use schema::comment_like::dsl::*; diesel::delete(comment_like - .filter(comment_id.eq(comment_like_form.comment_id)) - .filter(fedi_user_id.eq(&comment_like_form.fedi_user_id))) + .filter(comment_id.eq(comment_like_form.comment_id)) + .filter(fedi_user_id.eq(&comment_like_form.fedi_user_id))) .execute(conn) } } -impl Comment { - pub fn from_post(conn: &PgConnection, post: &Post) -> Result, Error> { - use schema::community::dsl::*; - Comment::belonging_to(post) - .order_by(comment::published.desc()) +impl CommentLike { + pub fn from_post(conn: &PgConnection, post_id_from: i32) -> Result, Error> { + use schema::comment_like::dsl::*; + comment_like + .filter(post_id.eq(post_id_from)) .load::(conn) } } + + +impl Comment { + fn from_post(conn: &PgConnection, post_id_from: i32) -> Result, Error> { + use schema::comment::dsl::*; + comment + .filter(post_id.eq(post_id_from)) + .order_by(published.desc()) + .load::(conn) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CommentView { + pub id: i32, + pub content: String, + pub attributed_to: String, + pub post_id: i32, + pub parent_id: Option, + pub published: chrono::NaiveDateTime, + pub updated: Option, + pub score: i32, + pub upvotes: i32, + pub downvotes: i32, + pub my_vote: Option +} + +impl CommentView { + fn from_comment(comment: &Comment, likes: &Vec, fedi_user_id: &Option) -> Self { + let mut upvotes: i32 = 0; + let mut downvotes: i32 = 0; + let mut my_vote: Option = Some(0); + + for like in likes.iter() { + if like.score == 1 { + upvotes += 1 + } else if like.score == -1 { + downvotes += 1; + } + + if let Some(user) = fedi_user_id { + if like.fedi_user_id == *user { + my_vote = Some(like.score); + } + } + + } + + let score: i32 = upvotes - downvotes; + + CommentView { + id: comment.id, + content: comment.content.to_owned(), + parent_id: comment.parent_id, + post_id: comment.post_id, + attributed_to: comment.attributed_to.to_owned(), + published: comment.published, + updated: None, + upvotes: upvotes, + score: score, + downvotes: downvotes, + my_vote: my_vote + } + } + + pub fn from_new_comment(comment: &Comment) -> Self { + Self::from_comment(comment, &Vec::new(), &None) + } + + pub fn read(conn: &PgConnection, comment_id: i32, fedi_user_id: &Option) -> Self { + let comment = Comment::read(&conn, comment_id).unwrap(); + let likes = CommentLike::read(&conn, comment_id).unwrap(); + Self::from_comment(&comment, &likes, fedi_user_id) + } + + pub fn from_post(conn: &PgConnection, post_id: i32, fedi_user_id: &Option) -> Vec { + let comments = Comment::from_post(&conn, post_id).unwrap(); + let post_comment_likes = CommentLike::from_post(&conn, post_id).unwrap(); + + let mut views = Vec::new(); + for comment in comments.iter() { + let comment_likes: Vec = post_comment_likes + .iter() + .filter(|like| comment.id == like.comment_id) + .cloned() + .collect(); + let comment_view = CommentView::from_comment(&comment, &comment_likes, fedi_user_id); + views.push(comment_view); + }; + + views + } +} + + #[cfg(test)] mod tests { use establish_connection; @@ -169,6 +273,7 @@ mod tests { let comment_like_form = CommentLikeForm { comment_id: inserted_comment.id, + post_id: inserted_post.id, fedi_user_id: "test".into(), score: 1 }; @@ -178,6 +283,7 @@ mod tests { let expected_comment_like = CommentLike { id: inserted_comment_like.id, comment_id: inserted_comment.id, + post_id: inserted_post.id, fedi_user_id: "test".into(), published: inserted_comment_like.published, score: 1 diff --git a/server/src/actions/post.rs b/server/src/actions/post.rs index 71846df..fff87df 100644 --- a/server/src/actions/post.rs +++ b/server/src/actions/post.rs @@ -77,6 +77,12 @@ impl Crud for Post { } impl Likeable for PostLike { + fn read(conn: &PgConnection, post_id_from: i32) -> Result, Error> { + use schema::post_like::dsl::*; + post_like + .filter(post_id.eq(post_id_from)) + .load::(conn) + } fn like(conn: &PgConnection, post_like_form: &PostLikeForm) -> Result { use schema::post_like::dsl::*; insert_into(post_like) diff --git a/server/src/lib.rs b/server/src/lib.rs index fcc9c2c..0d81d50 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -43,6 +43,7 @@ pub trait Joinable { } pub trait Likeable { + fn read(conn: &PgConnection, id: i32) -> Result, Error> where Self: Sized; fn like(conn: &PgConnection, form: &T) -> Result where Self: Sized; fn remove(conn: &PgConnection, form: &T) -> Result where Self: Sized; } diff --git a/server/src/schema.rs b/server/src/schema.rs index 28c4e8c..93add9b 100644 --- a/server/src/schema.rs +++ b/server/src/schema.rs @@ -14,6 +14,7 @@ table! { comment_like (id) { id -> Int4, comment_id -> Int4, + post_id -> Int4, fedi_user_id -> Text, score -> Int2, published -> Timestamp, @@ -85,6 +86,7 @@ table! { joinable!(comment -> post (post_id)); joinable!(comment_like -> comment (comment_id)); +joinable!(comment_like -> post (post_id)); joinable!(community_follower -> community (community_id)); joinable!(community_user -> community (community_id)); joinable!(post -> community (community_id)); diff --git a/server/src/websocket_server/server.rs b/server/src/websocket_server/server.rs index d257f4c..78a71ec 100644 --- a/server/src/websocket_server/server.rs +++ b/server/src/websocket_server/server.rs @@ -9,8 +9,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Result, Value}; use bcrypt::{verify}; use std::str::FromStr; +use std::{thread, time}; -use {Crud, Joinable, establish_connection}; +use {Crud, Joinable, Likeable, establish_connection}; use actions::community::*; use actions::user::*; use actions::post::*; @@ -19,7 +20,7 @@ use actions::comment::*; #[derive(EnumString,ToString,Debug)] pub enum UserOperation { - Login, Register, Logout, CreateCommunity, ListCommunities, CreatePost, GetPost, GetCommunity, CreateComment, Join, Edit, Reply, Vote, Delete, NextPage, Sticky + Login, Register, Logout, CreateCommunity, ListCommunities, CreatePost, GetPost, GetCommunity, CreateComment, CreateCommentLike, Join, Edit, Reply, Vote, Delete, NextPage, Sticky } @@ -151,14 +152,15 @@ pub struct CreatePostResponse { #[derive(Serialize, Deserialize)] pub struct GetPost { - id: i32 + id: i32, + auth: Option } #[derive(Serialize, Deserialize)] pub struct GetPostResponse { op: String, post: Post, - comments: Vec + comments: Vec } #[derive(Serialize, Deserialize)] @@ -183,7 +185,22 @@ pub struct CreateComment { #[derive(Serialize, Deserialize)] pub struct CreateCommentResponse { op: String, - comment: Comment + comment: CommentView +} + + +#[derive(Serialize, Deserialize)] +pub struct CreateCommentLike { + comment_id: i32, + post_id: i32, + score: i16, + auth: String +} + +#[derive(Serialize, Deserialize)] +pub struct CreateCommentLikeResponse { + op: String, + comment: CommentView } /// `ChatServer` manages chat rooms and responsible for coordinating chat @@ -343,6 +360,10 @@ impl Handler for ChatServer { let create_comment: CreateComment = serde_json::from_str(&data.to_string()).unwrap(); create_comment.perform(self, msg.id) }, + UserOperation::CreateCommentLike => { + let create_comment_like: CreateCommentLike = serde_json::from_str(&data.to_string()).unwrap(); + create_comment_like.perform(self, msg.id) + }, _ => { let e = ErrorMessage { op: "Unknown".to_string(), @@ -576,6 +597,22 @@ impl Perform for GetPost { let conn = establish_connection(); + println!("{:?}", self.auth); + + let fedi_user_id: Option = match &self.auth { + Some(auth) => { + match Claims::decode(&auth) { + Ok(claims) => { + let user_id = claims.claims.id; + let iss = claims.claims.iss; + Some(format!("{}/{}", iss, user_id)) + } + Err(e) => None + } + } + None => None + }; + let post = match Post::read(&conn, self.id) { Ok(post) => post, Err(e) => { @@ -583,37 +620,21 @@ impl Perform for GetPost { } }; - - // let mut rooms = Vec::new(); - // remove session from all rooms for (n, sessions) in &mut chat.rooms { - // if sessions.remove(&addr) { - // // rooms.push(*n); - // } sessions.remove(&addr); } - // // send message to other users - // for room in rooms { - // self.send_room_message(&room, "Someone disconnected", 0); - // } if chat.rooms.get_mut(&self.id).is_none() { chat.rooms.insert(self.id, HashSet::new()); } - // TODO send a Joined response - - - - // chat.send_room_message(addr,) - // self.send_room_message(&name, "Someone connected", id); chat.rooms.get_mut(&self.id).unwrap().insert(addr); - let comments = Comment::from_post(&conn, &post).unwrap(); + let comments = CommentView::from_post(&conn, post.id, &fedi_user_id); - println!("{:?}", chat.rooms.keys()); - println!("{:?}", chat.rooms.get(&5i32).unwrap()); + // println!("{:?}", chat.rooms.keys()); + // println!("{:?}", chat.rooms.get(&5i32).unwrap()); // Return the jwt serde_json::to_string( @@ -688,24 +709,98 @@ impl Perform for CreateComment { } }; + // TODO You like your own comment by default + + // Simulate a comment view to get back blank score, no need to fetch anything + let comment_view = CommentView::from_new_comment(&inserted_comment); + let comment_out = serde_json::to_string( &CreateCommentResponse { op: self.op_type().to_string(), - comment: inserted_comment + comment: comment_view } ) .unwrap(); chat.send_room_message(self.post_id, &comment_out, addr); - println!("{:?}", chat.rooms.keys()); - println!("{:?}", chat.rooms.get(&5i32).unwrap()); + // println!("{:?}", chat.rooms.keys()); + // println!("{:?}", chat.rooms.get(&5i32).unwrap()); comment_out } } +impl Perform for CreateCommentLike { + fn op_type(&self) -> UserOperation { + UserOperation::CreateCommentLike + } + + fn perform(&self, chat: &mut ChatServer, addr: usize) -> String { + + let conn = establish_connection(); + + let claims = match Claims::decode(&self.auth) { + Ok(claims) => claims.claims, + Err(e) => { + return self.error("Not logged in."); + } + }; + + let user_id = claims.id; + let iss = claims.iss; + let fedi_user_id = format!("{}/{}", iss, user_id); + + let like_form = CommentLikeForm { + comment_id: self.comment_id, + post_id: self.post_id, + fedi_user_id: fedi_user_id.to_owned(), + score: self.score + }; + + // Remove any likes first + CommentLike::remove(&conn, &like_form).unwrap(); + + // Only add the like if the score isnt 0 + if &like_form.score != &0 { + let inserted_like = match CommentLike::like(&conn, &like_form) { + Ok(like) => like, + Err(e) => { + return self.error("Couldn't like comment."); + } + }; + } + + // Have to refetch the comment to get the current state + // thread::sleep(time::Duration::from_secs(1)); + let liked_comment = CommentView::read(&conn, self.comment_id, &Some(fedi_user_id)); + + let mut liked_comment_sent = liked_comment.clone(); + liked_comment_sent.my_vote = None; + + let like_out = serde_json::to_string( + &CreateCommentLikeResponse { + op: self.op_type().to_string(), + comment: liked_comment + } + ) + .unwrap(); + + let like_sent_out = serde_json::to_string( + &CreateCommentLikeResponse { + op: self.op_type().to_string(), + comment: liked_comment_sent + } + ) + .unwrap(); + + chat.send_room_message(self.post_id, &like_sent_out, addr); + + like_out + } +} + // impl Handler for ChatServer { diff --git a/ui/src/components/post.tsx b/ui/src/components/post.tsx index 867e1a4..2a780cf 100644 --- a/ui/src/components/post.tsx +++ b/ui/src/components/post.tsx @@ -1,7 +1,7 @@ import { Component, linkEvent } from 'inferno'; import { Subscription } from "rxjs"; import { retryWhen, delay, take } from 'rxjs/operators'; -import { UserOperation, Community, Post as PostI, PostResponse, Comment, CommentForm as CommentFormI, CommentResponse } from '../interfaces'; +import { UserOperation, Community, Post as PostI, PostResponse, Comment, CommentForm as CommentFormI, CommentResponse, CommentLikeForm, CreateCommentLikeResponse } from '../interfaces'; import { WebSocketService, UserService } from '../services'; import { msgOp } from '../utils'; import { MomentTime } from './moment-time'; @@ -9,7 +9,6 @@ import { MomentTime } from './moment-time'; interface CommentNodeI { comment: Comment; children?: Array; - showReply?: boolean; }; interface State { @@ -78,7 +77,7 @@ export class Post extends Component { ?
{this.state.post.name} {(new URL(this.state.post.url)).hostname} -
+ :
{this.state.post.name}
; return (
@@ -141,7 +140,6 @@ export class Post extends Component { ); } - parseMessage(msg: any) { console.log(msg); let op: UserOperation = msgOp(msg); @@ -157,6 +155,16 @@ export class Post extends Component { let res: CommentResponse = msg; this.state.comments.unshift(res.comment); this.setState(this.state); + } else if (op == UserOperation.CreateCommentLike) { + let res: CreateCommentLikeResponse = msg; + let found: Comment = this.state.comments.find(c => c.id === res.comment.id); + found.score = res.comment.score; + found.upvotes = res.comment.upvotes; + found.downvotes = res.comment.downvotes; + if (res.comment.my_vote !== null) + found.my_vote = res.comment.my_vote; + console.log(res.comment.my_vote); + this.setState(this.state); } } @@ -174,75 +182,128 @@ export class CommentNodes extends Component {this.props.nodes.map(node => -
-
-
-
20
-
-
-
- -

{node.comment.content}

-
    -
  • - reply -
  • -
  • - link -
  • -
-
- {node.showReply && } - {node.children && } -
+ )}
) } +} - handleReplyClick(i: CommentNodeI, event) { - i.showReply = true; + +interface CommentNodeState { + showReply: boolean; +} + +interface CommentNodeProps { + node: CommentNodeI; + noIndent?: boolean; +} + +export class CommentNode extends Component { + + private emptyState: CommentNodeState = { + showReply: false + } + + constructor(props, context) { + super(props, context); + + this.state = this.emptyState; + this.handleReplyCancel = this.handleReplyCancel.bind(this); + this.handleCommentLike = this.handleCommentLike.bind(this); + this.handleCommentDisLike = this.handleCommentDisLike.bind(this); + } + + render() { + let node = this.props.node; + return ( +
+
+
+
{node.comment.score}
+
+
+
+ +

{node.comment.content}

+
    +
  • + reply +
  • +
  • + link +
  • +
+
+ {this.state.showReply && } + {this.props.node.children && } +
+ ) + } + + private getScore(): number { + return (this.props.node.comment.upvotes - this.props.node.comment.downvotes) || 0; + } + + handleReplyClick(i: CommentNode, event) { + i.state.showReply = true; + i.setState(i.state); + } + + handleReplyCancel(): any { + this.state.showReply = false; this.setState(this.state); } - handleReplyCancel(i: CommentNodeI): any { - i.showReply = false; - this.setState(this.state); + handleCommentLike(i: CommentNodeI, event) { + + let form: CommentLikeForm = { + comment_id: i.comment.id, + post_id: i.comment.post_id, + score: (i.comment.my_vote == 1) ? 0 : 1 + }; + WebSocketService.Instance.likeComment(form); + } + + handleCommentDisLike(i: CommentNodeI, event) { + let form: CommentLikeForm = { + comment_id: i.comment.id, + post_id: i.comment.post_id, + score: (i.comment.my_vote == -1) ? 0 : -1 + }; + WebSocketService.Instance.likeComment(form); } } interface CommentFormProps { postId?: number; node?: CommentNodeI; - onReplyCancel?(node: CommentNodeI); + onReplyCancel?(); } interface CommentFormState { commentForm: CommentFormI; - topReply: boolean; } export class CommentForm extends Component { @@ -253,8 +314,7 @@ export class CommentForm extends Component { content: null, post_id: null, parent_id: null - }, - topReply: true + } } constructor(props, context) { @@ -262,16 +322,11 @@ export class CommentForm extends Component { this.state = this.emptyState; if (this.props.node) { - this.state.topReply = false; this.state.commentForm.post_id = this.props.node.comment.post_id; this.state.commentForm.parent_id = this.props.node.comment.id; } else { this.state.commentForm.post_id = this.props.postId; } - - console.log(this.state); - - this.handleReplyCancel = this.handleReplyCancel.bind(this); } render() { @@ -286,7 +341,7 @@ export class CommentForm extends Component {
- {!this.state.topReply && } + {this.props.node && }
@@ -299,6 +354,9 @@ export class CommentForm extends Component { i.state.commentForm.content = undefined; i.setState(i.state); event.target.reset(); + if (i.props.node) { + i.props.onReplyCancel(); + } } handleCommentContentChange(i: CommentForm, event) { @@ -306,7 +364,7 @@ export class CommentForm extends Component { i.state.commentForm.content = event.target.value; } - handleReplyCancel(event) { - this.props.onReplyCancel(this.props.node); + handleReplyCancel(i: CommentForm, event) { + i.props.onReplyCancel(); } } diff --git a/ui/src/interfaces.ts b/ui/src/interfaces.ts index 14c2843..d499eb0 100644 --- a/ui/src/interfaces.ts +++ b/ui/src/interfaces.ts @@ -1,5 +1,5 @@ export enum UserOperation { - Login, Register, CreateCommunity, CreatePost, ListCommunities, GetPost, GetCommunity, CreateComment + Login, Register, CreateCommunity, CreatePost, ListCommunities, GetPost, GetCommunity, CreateComment, CreateCommentLike } export interface User { @@ -63,6 +63,10 @@ export interface Comment { parent_id?: number; published: string; updated?: string; + score: number; + upvotes: number; + downvotes: number; + my_vote?: number; } export interface CommentForm { @@ -77,6 +81,18 @@ export interface CommentResponse { comment: Comment; } +export interface CommentLikeForm { + comment_id: number; + post_id: number; + score: number; + auth?: string; +} + +export interface CreateCommentLikeResponse { + op: string; + comment: Comment; +} + export interface LoginForm { username_or_email: string; password: string; diff --git a/ui/src/services/WebSocketService.ts b/ui/src/services/WebSocketService.ts index beefac8..ed08fa1 100644 --- a/ui/src/services/WebSocketService.ts +++ b/ui/src/services/WebSocketService.ts @@ -1,5 +1,5 @@ import { wsUri } from '../env'; -import { LoginForm, RegisterForm, UserOperation, CommunityForm, PostForm, CommentForm } from '../interfaces'; +import { LoginForm, RegisterForm, UserOperation, CommunityForm, PostForm, CommentForm, CommentLikeForm } from '../interfaces'; import { webSocket } from 'rxjs/webSocket'; import { Subject } from 'rxjs'; import { retryWhen, delay, take } from 'rxjs/operators'; @@ -47,7 +47,8 @@ export class WebSocketService { } public getPost(postId: number) { - this.subject.next(this.wsSendWrapper(UserOperation.GetPost, {id: postId})); + let data = {id: postId, auth: UserService.Instance.auth }; + this.subject.next(this.wsSendWrapper(UserOperation.GetPost, data)); } public getCommunity(communityId: number) { @@ -59,6 +60,11 @@ export class WebSocketService { this.subject.next(this.wsSendWrapper(UserOperation.CreateComment, commentForm)); } + public likeComment(form: CommentLikeForm) { + this.setAuth(form); + this.subject.next(this.wsSendWrapper(UserOperation.CreateCommentLike, form)); + } + private wsSendWrapper(op: UserOperation, data: any) { let send = { op: UserOperation[op], data: data }; console.log(send); @@ -72,4 +78,10 @@ export class WebSocketService { throw "Not logged in"; } } + } + +window.onbeforeunload = (e => { + WebSocketService.Instance.subject.unsubscribe(); +}); +