fix: Fix notifications (#2132)

* add global service for unread notifications

* update inbox counter when marking a message as read

* adapt NotificationService for new auth parameter

* refactor unread counter service

* user service: refactor moderatesSomething

* use behavioursubjects for unreadcounterservice

* retry tests

---------

Co-authored-by: SleeplessOne1917 <abias1122@gmail.com>
This commit is contained in:
biosfood 2023-10-04 22:47:51 +02:00 committed by GitHub
parent 8a2cd127ee
commit de8255fc9e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 163 additions and 113 deletions

View file

@ -1,31 +1,30 @@
import { myAuth, showAvatars } from "@utils/app"; import { showAvatars } from "@utils/app";
import { isBrowser } from "@utils/browser"; import { isBrowser } from "@utils/browser";
import { numToSI, poll } from "@utils/helpers"; import { numToSI } from "@utils/helpers";
import { amAdmin, canCreateCommunity } from "@utils/roles"; import { amAdmin, canCreateCommunity } from "@utils/roles";
import { Component, createRef, linkEvent } from "inferno"; import { Component, createRef, linkEvent } from "inferno";
import { NavLink } from "inferno-router"; import { NavLink } from "inferno-router";
import { GetSiteResponse } from "lemmy-js-client";
import { donateLemmyUrl } from "../../config";
import { import {
GetReportCountResponse, I18NextService,
GetSiteResponse, UserService,
GetUnreadCountResponse, UnreadCounterService,
GetUnreadRegistrationApplicationCountResponse, } from "../../services";
} from "lemmy-js-client";
import { donateLemmyUrl, updateUnreadCountsInterval } from "../../config";
import { I18NextService, UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import { toast } from "../../toast"; import { toast } from "../../toast";
import { Icon } from "../common/icon"; import { Icon } from "../common/icon";
import { PictrsImage } from "../common/pictrs-image"; import { PictrsImage } from "../common/pictrs-image";
import { Subscription } from "rxjs";
interface NavbarProps { interface NavbarProps {
siteRes?: GetSiteResponse; siteRes?: GetSiteResponse;
} }
interface NavbarState { interface NavbarState {
unreadInboxCountRes: RequestState<GetUnreadCountResponse>;
unreadReportCountRes: RequestState<GetReportCountResponse>;
unreadApplicationCountRes: RequestState<GetUnreadRegistrationApplicationCountResponse>;
onSiteBanner?(url: string): any; onSiteBanner?(url: string): any;
unreadInboxCount: number;
unreadReportCount: number;
unreadApplicationCount: number;
} }
function handleCollapseClick(i: Navbar) { function handleCollapseClick(i: Navbar) {
@ -44,13 +43,17 @@ function handleLogOut(i: Navbar) {
} }
export class Navbar extends Component<NavbarProps, NavbarState> { export class Navbar extends Component<NavbarProps, NavbarState> {
state: NavbarState = {
unreadInboxCountRes: { state: "empty" },
unreadReportCountRes: { state: "empty" },
unreadApplicationCountRes: { state: "empty" },
};
collapseButtonRef = createRef<HTMLButtonElement>(); collapseButtonRef = createRef<HTMLButtonElement>();
mobileMenuRef = createRef<HTMLDivElement>(); mobileMenuRef = createRef<HTMLDivElement>();
unreadInboxCountSubscription: Subscription;
unreadReportCountSubscription: Subscription;
unreadApplicationCountSubscription: Subscription;
state: NavbarState = {
unreadInboxCount: 0,
unreadReportCount: 0,
unreadApplicationCount: 0,
};
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
@ -63,7 +66,18 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
if (isBrowser()) { if (isBrowser()) {
// On the first load, check the unreads // On the first load, check the unreads
this.requestNotificationPermission(); this.requestNotificationPermission();
this.fetchUnreads(); this.unreadInboxCountSubscription =
UnreadCounterService.Instance.unreadInboxCountSubject.subscribe(
unreadInboxCount => this.setState({ unreadInboxCount }),
);
this.unreadReportCountSubscription =
UnreadCounterService.Instance.unreadReportCountSubject.subscribe(
unreadReportCount => this.setState({ unreadReportCount }),
);
this.unreadApplicationCountSubscription =
UnreadCounterService.Instance.unreadApplicationCountSubject.subscribe(
unreadApplicationCount => this.setState({ unreadApplicationCount }),
);
this.requestNotificationPermission(); this.requestNotificationPermission();
document.addEventListener("mouseup", this.handleOutsideMenuClick); document.addEventListener("mouseup", this.handleOutsideMenuClick);
@ -72,6 +86,9 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener("mouseup", this.handleOutsideMenuClick); document.removeEventListener("mouseup", this.handleOutsideMenuClick);
this.unreadInboxCountSubscription.unsubscribe();
this.unreadReportCountSubscription.unsubscribe();
this.unreadApplicationCountSubscription.unsubscribe();
} }
// TODO class active corresponding to current pages // TODO class active corresponding to current pages
@ -103,34 +120,34 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
to="/inbox" to="/inbox"
className="p-1 nav-link border-0 nav-messages" className="p-1 nav-link border-0 nav-messages"
title={I18NextService.i18n.t("unread_messages", { title={I18NextService.i18n.t("unread_messages", {
count: Number(this.state.unreadApplicationCountRes.state), count: Number(this.state.unreadInboxCount),
formattedCount: numToSI(this.unreadInboxCount), formattedCount: numToSI(this.state.unreadInboxCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="bell" /> <Icon icon="bell" />
{this.unreadInboxCount > 0 && ( {this.state.unreadInboxCount > 0 && (
<span className="mx-1 badge text-bg-light"> <span className="mx-1 badge text-bg-light">
{numToSI(this.unreadInboxCount)} {numToSI(this.state.unreadInboxCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
</li> </li>
{this.moderatesSomething && ( {UserService.Instance.moderatesSomething && (
<li className="nav-item nav-item-icon"> <li className="nav-item nav-item-icon">
<NavLink <NavLink
to="/reports" to="/reports"
className="p-1 nav-link border-0" className="p-1 nav-link border-0"
title={I18NextService.i18n.t("unread_reports", { title={I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadReportCount), count: Number(this.state.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount), formattedCount: numToSI(this.state.unreadReportCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="shield" /> <Icon icon="shield" />
{this.unreadReportCount > 0 && ( {this.state.unreadReportCount > 0 && (
<span className="mx-1 badge text-bg-light"> <span className="mx-1 badge text-bg-light">
{numToSI(this.unreadReportCount)} {numToSI(this.state.unreadReportCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -144,16 +161,18 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
title={I18NextService.i18n.t( title={I18NextService.i18n.t(
"unread_registration_applications", "unread_registration_applications",
{ {
count: Number(this.unreadApplicationCount), count: Number(this.state.unreadApplicationCount),
formattedCount: numToSI(this.unreadApplicationCount), formattedCount: numToSI(
this.state.unreadApplicationCount,
),
}, },
)} )}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="clipboard" /> <Icon icon="clipboard" />
{this.unreadApplicationCount > 0 && ( {this.state.unreadApplicationCount > 0 && (
<span className="mx-1 badge text-bg-light"> <span className="mx-1 badge text-bg-light">
{numToSI(this.unreadApplicationCount)} {numToSI(this.state.unreadApplicationCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -268,46 +287,48 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
className="nav-link d-inline-flex align-items-center d-md-inline-block" className="nav-link d-inline-flex align-items-center d-md-inline-block"
to="/inbox" to="/inbox"
title={I18NextService.i18n.t("unread_messages", { title={I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadInboxCount), count: Number(this.state.unreadInboxCount),
formattedCount: numToSI(this.unreadInboxCount), formattedCount: numToSI(this.state.unreadInboxCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="bell" /> <Icon icon="bell" />
<span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0"> <span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("unread_messages", { {I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadInboxCount), count: Number(this.state.unreadInboxCount),
formattedCount: numToSI(this.unreadInboxCount), formattedCount: numToSI(this.state.unreadInboxCount),
})} })}
</span> </span>
{this.unreadInboxCount > 0 && ( {this.state.unreadInboxCount > 0 && (
<span className="mx-1 badge text-bg-light"> <span className="mx-1 badge text-bg-light">
{numToSI(this.unreadInboxCount)} {numToSI(this.state.unreadInboxCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
</li> </li>
{this.moderatesSomething && ( {UserService.Instance.moderatesSomething && (
<li id="navModeration" className="nav-item"> <li id="navModeration" className="nav-item">
<NavLink <NavLink
className="nav-link d-inline-flex align-items-center d-md-inline-block" className="nav-link d-inline-flex align-items-center d-md-inline-block"
to="/reports" to="/reports"
title={I18NextService.i18n.t("unread_reports", { title={I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadReportCount), count: Number(this.state.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount), formattedCount: numToSI(this.state.unreadReportCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="shield" /> <Icon icon="shield" />
<span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0"> <span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("unread_reports", { {I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadReportCount), count: Number(this.state.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount), formattedCount: numToSI(
this.state.unreadReportCount,
),
})} })}
</span> </span>
{this.unreadReportCount > 0 && ( {this.state.unreadReportCount > 0 && (
<span className="mx-1 badge text-bg-light"> <span className="mx-1 badge text-bg-light">
{numToSI(this.unreadReportCount)} {numToSI(this.state.unreadReportCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -321,9 +342,9 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
title={I18NextService.i18n.t( title={I18NextService.i18n.t(
"unread_registration_applications", "unread_registration_applications",
{ {
count: Number(this.unreadApplicationCount), count: Number(this.state.unreadApplicationCount),
formattedCount: numToSI( formattedCount: numToSI(
this.unreadApplicationCount, this.state.unreadApplicationCount,
), ),
}, },
)} )}
@ -334,16 +355,16 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
{I18NextService.i18n.t( {I18NextService.i18n.t(
"unread_registration_applications", "unread_registration_applications",
{ {
count: Number(this.unreadApplicationCount), count: Number(this.state.unreadApplicationCount),
formattedCount: numToSI( formattedCount: numToSI(
this.unreadApplicationCount, this.state.unreadApplicationCount,
), ),
}, },
)} )}
</span> </span>
{this.unreadApplicationCount > 0 && ( {this.state.unreadApplicationCount > 0 && (
<span className="mx-1 badge text-bg-light"> <span className="mx-1 badge text-bg-light">
{numToSI(this.unreadApplicationCount)} {numToSI(this.state.unreadApplicationCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -441,68 +462,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
} }
} }
get moderatesSomething(): boolean {
const mods = UserService.Instance.myUserInfo?.moderates;
const moderatesS = (mods && mods.length > 0) || false;
return amAdmin() || moderatesS;
}
fetchUnreads() {
poll(async () => {
if (window.document.visibilityState !== "hidden") {
if (myAuth()) {
this.setState({
unreadInboxCountRes: await HttpService.client.getUnreadCount(),
});
if (this.moderatesSomething) {
this.setState({
unreadReportCountRes: await HttpService.client.getReportCount({}),
});
}
if (amAdmin()) {
this.setState({
unreadApplicationCountRes:
await HttpService.client.getUnreadRegistrationApplicationCount(),
});
}
}
}
}, updateUnreadCountsInterval);
}
get unreadInboxCount(): number {
if (this.state.unreadInboxCountRes.state === "success") {
const data = this.state.unreadInboxCountRes.data;
return data.replies + data.mentions + data.private_messages;
} else {
return 0;
}
}
get unreadReportCount(): number {
if (this.state.unreadReportCountRes.state === "success") {
const data = this.state.unreadReportCountRes.data;
return (
data.post_reports +
data.comment_reports +
(data.private_message_reports ?? 0)
);
} else {
return 0;
}
}
get unreadApplicationCount(): number {
if (this.state.unreadApplicationCountRes.state === "success") {
const data = this.state.unreadApplicationCountRes.data;
return data.registration_applications;
} else {
return 0;
}
}
get currentLocation() { get currentLocation() {
return this.context.router.history.location.pathname; return this.context.router.history.location.pathname;
} }

View file

@ -62,6 +62,7 @@ import {
import { fetchLimit, relTags } from "../../config"; import { fetchLimit, relTags } from "../../config";
import { CommentViewType, InitialFetchRequest } from "../../interfaces"; import { CommentViewType, InitialFetchRequest } from "../../interfaces";
import { FirstLoadService, I18NextService, UserService } from "../../services"; import { FirstLoadService, I18NextService, UserService } from "../../services";
import { UnreadCounterService } from "../../services";
import { import {
EmptyRequestState, EmptyRequestState,
HttpService, HttpService,
@ -792,6 +793,7 @@ export class Inbox extends Component<any, InboxState> {
limit, limit,
}), }),
}); });
UnreadCounterService.Instance.update();
} }
async handleSortChange(val: CommentSortType) { async handleSortChange(val: CommentSortType) {

View file

@ -0,0 +1,83 @@
import { UserService, HttpService } from "../services";
import { updateUnreadCountsInterval } from "../config";
import { poll } from "@utils/helpers";
import { myAuth } from "@utils/app";
import { amAdmin } from "@utils/roles";
import { isBrowser } from "@utils/browser";
import { BehaviorSubject } from "rxjs";
/**
* Service to poll and keep track of unread messages / notifications.
*/
export class UnreadCounterService {
// fetched by HttpService.getUnreadCount, appear in inbox
unreadPrivateMessages = 0;
unreadReplies = 0;
unreadMentions = 0;
public unreadInboxCountSubject: BehaviorSubject<number> =
new BehaviorSubject<number>(0);
// fetched by HttpService.getReportCount, appear in report page
commentReportCount = 0;
postReportCount = 0;
messageReportCount = 0;
public unreadReportCountSubject: BehaviorSubject<number> =
new BehaviorSubject<number>(0);
// fetched by HttpService.getUnreadRegistrationApplicationCount, appear in registration application page
public unreadApplicationCountSubject: BehaviorSubject<number> =
new BehaviorSubject<number>(0);
static #instance: UnreadCounterService;
constructor() {
if (isBrowser()) {
poll(this.update, updateUnreadCountsInterval);
}
}
public update = async () => {
if (window.document.visibilityState === "hidden") {
return;
}
if (!myAuth()) {
return;
}
const unreadCountRes = await HttpService.client.getUnreadCount();
if (unreadCountRes.state === "success") {
this.unreadPrivateMessages = unreadCountRes.data.private_messages;
this.unreadReplies = unreadCountRes.data.replies;
this.unreadMentions = unreadCountRes.data.mentions;
this.unreadInboxCountSubject.next(
this.unreadPrivateMessages + this.unreadReplies + this.unreadMentions,
);
}
if (UserService.Instance.moderatesSomething) {
const reportCountRes = await HttpService.client.getReportCount({});
if (reportCountRes.state === "success") {
this.commentReportCount = reportCountRes.data.comment_reports ?? 0;
this.postReportCount = reportCountRes.data.post_reports ?? 0;
this.messageReportCount =
reportCountRes.data.private_message_reports ?? 0;
this.unreadReportCountSubject.next(
this.commentReportCount +
this.postReportCount +
this.messageReportCount,
);
}
}
if (amAdmin()) {
const unreadApplicationsRes =
await HttpService.client.getUnreadRegistrationApplicationCount();
if (unreadApplicationsRes.state === "success") {
this.unreadApplicationCountSubject.next(
unreadApplicationsRes.data.registration_applications,
);
}
}
};
static get Instance() {
return this.#instance ?? (this.#instance = new this());
}
}

View file

@ -5,6 +5,7 @@ import jwt_decode from "jwt-decode";
import { LoginResponse, MyUserInfo } from "lemmy-js-client"; import { LoginResponse, MyUserInfo } from "lemmy-js-client";
import { toast } from "../toast"; import { toast } from "../toast";
import { I18NextService } from "./I18NextService"; import { I18NextService } from "./I18NextService";
import { amAdmin } from "@utils/roles";
import { HttpService } from "."; import { HttpService } from ".";
interface Claims { interface Claims {
@ -88,6 +89,10 @@ export class UserService {
} }
} }
public get moderatesSomething(): boolean {
return amAdmin() || (this.myUserInfo?.moderates?.length ?? 0) > 0;
}
public static get Instance() { public static get Instance() {
return this.#instance || (this.#instance = new this()); return this.#instance || (this.#instance = new this());
} }

View file

@ -3,3 +3,4 @@ export { HomeCacheService } from "./HomeCacheService";
export { HttpService } from "./HttpService"; export { HttpService } from "./HttpService";
export { I18NextService } from "./I18NextService"; export { I18NextService } from "./I18NextService";
export { UserService } from "./UserService"; export { UserService } from "./UserService";
export { UnreadCounterService } from "./UnreadCounterService";