Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.24 on 2026-01-10 18:13

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('db', '0115_auto_20260105_1406'),
]

operations = [
migrations.AddField(
model_name='profile',
name='notification_view_mode',
field=models.CharField(choices=[('full', 'Full'), ('compact', 'Compact')], default='full', max_length=255),
),
]
8 changes: 7 additions & 1 deletion apps/api/plane/db/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ class Profile(TimeAuditModel):
FRIDAY = 5
SATURDAY = 6

class NotificationViewMode(models.TextChoices):
FULL = "full", "Full"
COMPACT = "compact", "Compact"

START_OF_THE_WEEK_CHOICES = (
(SUNDAY, "Sunday"),
(MONDAY, "Monday"),
Expand Down Expand Up @@ -221,7 +225,9 @@ class Profile(TimeAuditModel):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)

notification_view_mode = models.CharField(
max_length=255, choices=NotificationViewMode.choices, default=NotificationViewMode.FULL
)
is_smooth_cursor_enabled = models.BooleanField(default=False)
# mobile
is_mobile_onboarded = models.BooleanField(default=False)
Expand Down
2 changes: 1 addition & 1 deletion apps/space/core/components/issues/peek-overview/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type Props = {

const PEEK_MODES: {
key: IPeekMode;
icon: any;
icon: React.FC<React.SVGProps<SVGSVGElement>>;
label: string;
}[] = [
{ key: "side", icon: SidePanelIcon, label: "Side Peek" },
Expand Down
3 changes: 2 additions & 1 deletion apps/space/core/store/profile.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { set } from "lodash-es";
import { action, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { UserService } from "@plane/services";
import { NOTIFICATION_VIEW_MODES, EStartOfTheWeek } from "@plane/types";
import type { TUserProfile } from "@plane/types";
import { EStartOfTheWeek } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";

Expand Down Expand Up @@ -44,6 +44,7 @@ export class ProfileStore implements IProfileStore {
},
is_onboarded: false,
is_tour_completed: false,
notification_view_mode: NOTIFICATION_VIEW_MODES[0].key,
use_case: undefined,
billing_address_country: undefined,
billing_address: undefined,
Expand Down
45 changes: 5 additions & 40 deletions apps/web/ce/components/navigations/top-navigation-root.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,24 @@
// components
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
import { Tooltip } from "@plane/propel/tooltip";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { InboxIcon } from "@plane/propel/icons";
import useSWR from "swr";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { cn } from "@plane/utils";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// local imports
import { StarUsOnGitHubLink } from "@/app/(all)/[workspaceSlug]/(projects)/star-us-link";
import { NotificationsPopoverRoot } from "@/components/notifications/popover/root";

export const TopNavigationRoot = observer(function TopNavigationRoot() {
// router
const { workspaceSlug } = useParams();
const pathname = usePathname();

// store hooks
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { preferences } = useAppRailPreferences();

const showLabel = preferences.displayMode === "icon_with_label";

// Fetch notification count
useSWR(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null
);

// Calculate notification count
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
const totalNotifications = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;

return (
<div
className={cn("flex items-center min-h-10 w-full px-3.5 bg-canvas z-[27] transition-all duration-300", {
Expand All @@ -54,23 +35,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
</div>
{/* Additional Actions */}
<div className="shrink-0 flex-1 flex gap-1 items-center justify-end">
<Tooltip tooltipContent="Inbox" position="bottom">
<AppSidebarItem
variant="link"
item={{
href: `/${workspaceSlug?.toString()}/notifications/`,
icon: (
<div className="relative">
<InboxIcon className="size-5" />
{totalNotifications > 0 && (
<span className="absolute top-0 right-0 size-2 rounded-full bg-danger-primary" />
)}
</div>
),
isActive: pathname?.includes("/notifications/"),
}}
/>
</Tooltip>
<NotificationsPopoverRoot workspaceSlug={workspaceSlug?.toString()} />
<HelpMenuRoot />
<StarUsOnGitHubLink />
<div className="flex items-center justify-center size-8 hover:bg-layer-1-hover rounded-md">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NotificationCardListRoot } from "./notification-card/root";
export type TNotificationListRoot = {
workspaceSlug: string;
workspaceId: string;
onNotificationClick?: () => void;
};

export function NotificationListRoot(props: TNotificationListRoot) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import { useWorkspaceNotifications } from "@/hooks/store/notifications";
type TNotificationCardListRoot = {
workspaceSlug: string;
workspaceId: string;
onNotificationClick?: () => void;
};

export const NotificationCardListRoot = observer(function NotificationCardListRoot(props: TNotificationCardListRoot) {
const { workspaceSlug, workspaceId } = props;
const { workspaceSlug, workspaceId, onNotificationClick } = props;
// hooks
const { loader, paginationInfo, getNotifications, notificationIdsByWorkspaceId } = useWorkspaceNotifications();
const notificationIds = notificationIdsByWorkspaceId(workspaceId);
Expand All @@ -32,7 +33,12 @@ export const NotificationCardListRoot = observer(function NotificationCardListRo
return (
<div>
{notificationIds.map((notificationId: string) => (
<NotificationItem key={notificationId} workspaceSlug={workspaceSlug} notificationId={notificationId} />
<NotificationItem
key={notificationId}
workspaceSlug={workspaceSlug}
notificationId={notificationId}
onNotificationClick={onNotificationClick}
/>
))}

{/* fetch next page notifications */}
Expand Down
89 changes: 89 additions & 0 deletions apps/web/core/components/notifications/popover/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/

import { useWorkspaceNotifications } from "@/hooks/store/notifications";

import { InboxIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";

import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { NotificationsSidebarRoot } from "@/components/workspace-notifications/sidebar";
import { Popover } from "@plane/propel/popover";

type NotificationsPopoverRootProps = {
workspaceSlug: string;
};

export function NotificationsPopoverRoot({ workspaceSlug }: NotificationsPopoverRootProps) {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const pathname = usePathname();
const { unreadNotificationsCount, viewMode } = useWorkspaceNotifications();

const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
const totalNotifications = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
const isNotificationsPath = pathname.includes(`/${workspaceSlug}/notifications/`);
const shouldPopoverBeOpen = viewMode === "compact" && !isNotificationsPath && isOpen;

const handleSidebarClick = () => {
if (viewMode === "full") {
setIsOpen(false);
router.push(`/${workspaceSlug}/notifications/`);
}
};

const handlePopoverChange = (open: boolean) => {
if (!isNotificationsPath) {
setIsOpen(open);
} else {
setIsOpen(false);
}
};

return (
<Popover open={shouldPopoverBeOpen} onOpenChange={handlePopoverChange}>
<Popover.Button>
<AppSidebarItem
variant={"button"}
item={{
icon: (
<div className="relative">
<InboxIcon className="size-5" />
{totalNotifications > 0 && (
<span className="absolute top-0 right-0 size-2 rounded-full bg-danger-primary" />
)}
</div>
),
isActive: isOpen,
onClick: handleSidebarClick,
}}
/>
</Popover.Button>
<Popover.Panel side="bottom" align="start" positionerClassName={"z-30"} className={"h-[477px] w-[530px]"}>
<NotificationsSidebarRoot
viewMode="compact"
onFullViewMode={() => setIsOpen(false)}
onNotificationClick={() => setIsOpen(false)}
onModeChange={(mode) => {
if (mode === "full" && isOpen) {
setIsOpen(false);
}
}}
/>
</Popover.Panel>
</Popover>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const NotificationFilter = observer(function NotificationFilter() {
data={translatedFilterTypeOptions}
button={
<Tooltip tooltipContent={t("notification.options.filters")} isMobile={isMobile} position="bottom">
<IconButton size="base" variant="ghost" icon={ListFilter} />
<IconButton size="base" variant="secondary" icon={ListFilter} />
</Tooltip>
}
keyExtractor={(item: { label: string; value: ENotificationFilterType }) => item.value}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const NotificationHeaderMenuOption = observer(function NotificationHeader
return (
<PopoverMenu
data={popoverMenuOptions}
button={<IconButton size="base" variant="ghost" icon={MoreVertical} />}
button={<IconButton size="base" variant="secondary" icon={MoreVertical} />}
keyExtractor={(item: TPopoverMenuOptions) => item.key}
panelClassName="p-0 py-2 rounded-md border border-subtle bg-surface-1 space-y-1"
render={(item: TPopoverMenuOptions) => <NotificationMenuOptionItem {...item} />}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import { observer } from "mobx-react";
import { CheckCheck, RefreshCw } from "lucide-react";
// plane imports
import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import { Spinner } from "@plane/ui";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { NotificationFilter } from "../../filters/menu";
import { NotificationHeaderMenuOption } from "./menu-option";
import { IconButton } from "@plane/propel/icon-button";

type TNotificationSidebarHeaderOptions = {
workspaceSlug: string;
Expand All @@ -20,56 +10,8 @@ type TNotificationSidebarHeaderOptions = {
export const NotificationSidebarHeaderOptions = observer(function NotificationSidebarHeaderOptions(
props: TNotificationSidebarHeaderOptions
) {
const { workspaceSlug } = props;
// hooks
const { isMobile } = usePlatformOS();
const { loader, getNotifications, markAllNotificationsAsRead } = useWorkspaceNotifications();
const { t } = useTranslation();

const refreshNotifications = async () => {
if (loader) return;
try {
await getNotifications(workspaceSlug, ENotificationLoader.MUTATION_LOADER, ENotificationQueryParamType.CURRENT);
} catch (error) {
console.error(error);
}
};

const handleMarkAllNotificationsAsRead = async () => {
// NOTE: We are using loader to prevent continues request when we are making all the notification to read
if (loader) return;
try {
await markAllNotificationsAsRead(workspaceSlug);
} catch (error) {
console.error(error);
}
};

return (
<div className="relative flex justify-center items-center gap-2 text-body-xs-medium">
{/* mark all notifications as read*/}
<Tooltip tooltipContent={t("notification.options.mark_all_as_read")} isMobile={isMobile} position="bottom">
<IconButton
size="base"
variant="ghost"
icon={loader === ENotificationLoader.MARK_ALL_AS_READY ? Spinner : CheckCheck}
onClick={() => {
handleMarkAllNotificationsAsRead();
}}
/>
</Tooltip>

{/* refetch current notifications */}
<Tooltip tooltipContent={t("notification.options.refresh")} isMobile={isMobile} position="bottom">
<IconButton
size="base"
variant="ghost"
icon={RefreshCw}
className={loader === ENotificationLoader.MUTATION_LOADER ? "animate-spin" : ""}
onClick={refreshNotifications}
/>
</Tooltip>

{/* notification filters */}
<NotificationFilter />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ import { NotificationOption } from "./options";
type TNotificationItem = {
workspaceSlug: string;
notificationId: string;
onNotificationClick?: () => void;
};

export const NotificationItem = observer(function NotificationItem(props: TNotificationItem) {
const { workspaceSlug, notificationId } = props;
const { workspaceSlug, notificationId, onNotificationClick } = props;
// hooks
const { currentSelectedNotificationId, setCurrentSelectedNotificationId } = useWorkspaceNotifications();
const { asJson: notification, markNotificationAsRead } = useNotification(notificationId);
Expand All @@ -39,6 +40,8 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif

const handleNotificationIssuePeekOverview = async () => {
if (workspaceSlug && projectId && issueId && !isSnoozeStateModalOpen && !customSnoozeModal) {
onNotificationClick?.();

setPeekIssue(undefined);
setCurrentSelectedNotificationId(notificationId);

Expand Down
Loading
Loading