diff --git a/frontend/cypress/e2e/notifications/notification-filters.cy.ts b/frontend/cypress/e2e/notifications/notification-filters.cy.ts new file mode 100644 index 000000000..058ce369f --- /dev/null +++ b/frontend/cypress/e2e/notifications/notification-filters.cy.ts @@ -0,0 +1,96 @@ +import { resetData } from '../../support/seed' + +describe('Notification filters', () => { + let alpha: string + let beta: string + let alphaSpace: string + let betaSpace: string + + const mentionHtml = + '

Ping @Member

' + + beforeEach(() => { + resetData('two_communities').then((ids) => { + ;[alpha, beta] = (ids.communities as string[]).map(String) + ;[alphaSpace, betaSpace] = (ids.spaces as string[]).map(String) + }) + cy.then(() => { + for (const [space, title] of [ + [alphaSpace, 'Alpha thread'], + [betaSpace, 'Beta thread'], + [alphaSpace, 'Padding one'], + [alphaSpace, 'Padding two'], + [alphaSpace, 'Padding three'], + ]) { + cy.task('requestAsUser', { + user: 'member2@example.com', + method: 'POST', + path: '/api/v2/document/GP Discussion', + body: { title, project: space, content: mentionHtml }, + }) + } + }) + cy.loginAs('member') + cy.intercept('GET', '**/api/v2/document/GP%20Notification*').as('notificationList') + cy.visit('/g/notifications') + cy.wait('@notificationList') + }) + + it('lists both mentions under a day label', () => { + cy.contains('[data-slot="list-group-header"]', 'Today').should('be.visible') + cy.contains('Alpha thread').should('be.visible') + cy.contains('Beta thread').should('be.visible') + }) + + it('narrows to the picked community', () => { + cy.button('All communities').click() + cy.contains('[role="option"]', 'Alpha').click() + cy.get('body').type('{esc}') + cy.wait('@notificationList') + + cy.contains('Alpha thread').should('be.visible') + cy.contains('Beta thread').should('not.exist') + cy.button('Alpha').should('be.visible').click() + cy.contains('[role="option"]', 'Beta').click() + cy.get('body').type('{esc}') + cy.button('2 communities').should('be.visible') + + cy.button('Clear').click() + cy.wait('@notificationList') + cy.contains('Beta thread').should('be.visible') + }) + + it('narrows to the picked space', () => { + cy.button('All spaces').click() + cy.contains('[role="option"]', 'Beta Space').click() + cy.get('body').type('{esc}') + cy.wait('@notificationList') + + cy.contains('Beta thread').should('be.visible') + cy.contains('Alpha thread').should('not.exist') + }) + + it('keeps today’s rows under the Today filter and shows the day on an empty day', () => { + cy.button('All dates').click() + cy.button('Today').click() + cy.wait('@notificationList') + cy.button('Today').should('be.visible') + cy.contains('Alpha thread').should('be.visible') + cy.contains('Beta thread').should('be.visible') + + cy.window().then((win) => { + win.localStorage.setItem( + 'gameplan:notificationFilters', + JSON.stringify({ + communities: [], + spaces: [], + date: { kind: 'range', from: '2020-01-01', to: '2020-01-01' }, + }), + ) + }) + cy.reload() + cy.wait('@notificationList') + cy.contains('1 January 2020').should('be.visible') + cy.contains('Nothing here').should('be.visible') + }) +}) diff --git a/frontend/src/components/DiscussionNotificationBell.vue b/frontend/src/components/DiscussionNotificationBell.vue new file mode 100644 index 000000000..90ddb30f8 --- /dev/null +++ b/frontend/src/components/DiscussionNotificationBell.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/components/DiscussionView.vue b/frontend/src/components/DiscussionView.vue index 5e94fbf21..e2948b4b8 100644 --- a/frontend/src/components/DiscussionView.vue +++ b/frontend/src/components/DiscussionView.vue @@ -4,6 +4,24 @@ +
@@ -65,6 +99,7 @@ padding puts its contents back where they were. -->
+ canMoveOrPinContent(discussion.doc, space.value, useSessionUser()), ) +const postActionsRow = useTemplateRef('postActionsRow') +const postActionsVisible = ref(true) +useIntersectionObserver(postActionsRow, ([entry]) => { + postActionsVisible.value = entry?.isIntersecting ?? true +}) +const showHeaderActions = computed( + () => + !postActionsVisible.value && !props.readOnlyMode && Boolean(discussion.doc?.notification_state), +) +const HEADER_MENU_EXCLUDES = new Set(['Edit', 'Revisions']) +const headerActions = computed(() => + actions.value.filter((action) => !HEADER_MENU_EXCLUDES.has(action.label)), +) + const actions = computed(() => [ { label: 'Edit', @@ -977,6 +1034,15 @@ const actions = computed(() => [ }, ]) +async function setNotificationState(choice: DiscussionNotificationChoice) { + await discussion.setNotificationState.submit({ state: choice }) + const result = discussion.setNotificationState.data + if (discussion.doc && result) { + discussion.doc.notification_state = result.notification_state + discussion.doc.notification_state_is_explicit = result.notification_state_is_explicit + } +} + useCommandPaletteCommands( computed(() => { if (props.readOnlyMode || !discussion.doc) return [] diff --git a/frontend/src/components/NotificationFilterTrigger.vue b/frontend/src/components/NotificationFilterTrigger.vue new file mode 100644 index 000000000..2f24a6dd7 --- /dev/null +++ b/frontend/src/components/NotificationFilterTrigger.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/NotificationFilters.vue b/frontend/src/components/NotificationFilters.vue new file mode 100644 index 000000000..da9adb9d5 --- /dev/null +++ b/frontend/src/components/NotificationFilters.vue @@ -0,0 +1,143 @@ + + + diff --git a/frontend/src/components/NotificationListRow.vue b/frontend/src/components/NotificationListRow.vue new file mode 100644 index 000000000..0b44f9c16 --- /dev/null +++ b/frontend/src/components/NotificationListRow.vue @@ -0,0 +1,104 @@ + + + diff --git a/frontend/src/components/Settings/CommunitiesSettings.vue b/frontend/src/components/Settings/CommunitiesSettings.vue index 84b485e02..e8f663e30 100644 --- a/frontend/src/components/Settings/CommunitiesSettings.vue +++ b/frontend/src/components/Settings/CommunitiesSettings.vue @@ -54,13 +54,27 @@ > @@ -205,6 +219,8 @@ import CommunityMembersList from '@/pages/Configure/CommunityMembersList.vue' import CommunityMembersListControls from '@/pages/Configure/CommunityMembersListControls.vue' import CommunitySpacesList from '@/pages/Configure/CommunitySpacesList.vue' import CommunitySpacesListControls from '@/pages/Configure/CommunitySpacesListControls.vue' +import { useCommunitySpaceData } from '@/pages/Configure/useCommunitySpaceData' +import { isSpaceNotifying, setSpaceNotifications } from '@/data/spaceNotifications' import NewCommunityDialog from '@/pages/Configure/NewCommunityDialog.vue' type CommunityView = 'spaces' | 'members' @@ -281,6 +297,13 @@ const canCreateSpace = computed(() => !selectedCommunity.value.archived_at, ), ) +const { communitySpaces } = useCommunitySpaceData(selectedCommunityId) +const liveSpaces = computed(() => communitySpaces.value.filter((space) => !space.archived_at)) +const allSpacesNotifying = computed(() => + liveSpaces.value.every((space) => isSpaceNotifying(space.name)), +) +const notifyAllIcon = computed(() => (allSpacesNotifying.value ? 'lucide-bell-off' : 'lucide-bell')) + function openCommunitySpaces(communityId: string) { router.push({ name: 'SettingsCommunity', params: { communityId, view: 'spaces' } }) } diff --git a/frontend/src/components/Settings/NotificationsSettings.vue b/frontend/src/components/Settings/NotificationsSettings.vue index 0d82e919c..8645c74db 100644 --- a/frontend/src/components/Settings/NotificationsSettings.vue +++ b/frontend/src/components/Settings/NotificationsSettings.vue @@ -2,10 +2,105 @@ -
+
- +
+
+
Receive notifications
+
+ + +
+
+
+
+
+ + + + + + + + +
@@ -102,7 +122,16 @@ // fallthrough (this component renders a fragment); it simply isn't emitted here. defineEmits<{ (e: 'close-dialog'): void }>() import { computed, ref } from 'vue' -import { Button, SettingsRow, Select, Switch, toast, useDoctype } from 'frappe-ui' +import { + Button, + Combobox, + SettingsRow, + Select, + Switch, + toast, + useCall, + useDoctype, +} from 'frappe-ui' import PanelHeader from './PanelHeader.vue' import PanelBody from './PanelBody.vue' import CustomizeSidebarDialog from '@/components/AppRail/CustomizeSidebarDialog.vue' @@ -129,6 +158,29 @@ const { currentCursorStyle, setCursorStyle } = useCursorStyle() const userProfiles = useDoctype('GP User Profile') const showCustomizeSidebar = ref(false) + +const timezoneHost = ref(null) +const timezones = useCall<{ timezones: string[] }>({ + url: '/api/v2/method/frappe.core.doctype.user.user.get_timezones', + cacheKey: 'timezones', +}) +const timezoneOptions = computed(() => + (timezones.data?.timezones ?? []).map((zone) => ({ label: zone, value: zone })), +) +const users = useDoctype<{ name: string; time_zone?: string }>('User') +async function saveTimezone(value: unknown) { + const zone = typeof value === 'string' ? value : '' + if (zone === (sessionUser.time_zone || '')) return + const previous = sessionUser.time_zone + sessionUser.time_zone = zone + try { + await users.setValue.submit({ name: sessionUser.name, time_zone: zone }) + toast.success('Timezone saved') + } catch { + sessionUser.time_zone = previous + toast.error('Could not save timezone') + } +} const savingBadgeStyle = ref(false) const themeOptions: Array<{ label: string; value: Theme }> = [ diff --git a/frontend/src/data/discussions.ts b/frontend/src/data/discussions.ts index 1b2bfa43d..ff1bab949 100644 --- a/frontend/src/data/discussions.ts +++ b/frontend/src/data/discussions.ts @@ -3,6 +3,10 @@ import { useDoc, useList } from 'frappe-ui' import { UseListOptions } from 'frappe-ui' import { useDocumentVisibility } from '@vueuse/core' import { GPDiscussion } from '@/types/doctypes' +import type { + DiscussionNotificationChoice, + DiscussionNotificationState, +} from '@/data/notificationPreferences' // Reload the feed when the tab is re-activated after sitting in the background // for at least this long, so new posts show up without a manual refresh. @@ -78,6 +82,8 @@ export function useDiscussion(discussionId: MaybeRefOrGetter) { last_unread_poll: string is_bookmarked: boolean views: number + notification_state: DiscussionNotificationState + notification_state_is_explicit: boolean } interface DiscussionMethods { @@ -90,6 +96,10 @@ export function useDiscussion(discussionId: MaybeRefOrGetter) { addBookmark: () => void removeBookmark: () => void moveToProject: (data: { project: string }) => void + setNotificationState: (data: { state: DiscussionNotificationChoice }) => { + notification_state: DiscussionNotificationState + notification_state_is_explicit: boolean + } } let name = toValue(discussionId) @@ -107,6 +117,7 @@ export function useDiscussion(discussionId: MaybeRefOrGetter) { addBookmark: 'add_bookmark', removeBookmark: 'remove_bookmark', moveToProject: 'move_to_project', + setNotificationState: 'set_notification_state', }, }) } diff --git a/frontend/src/data/notificationFilters.ts b/frontend/src/data/notificationFilters.ts new file mode 100644 index 000000000..11d0745d8 --- /dev/null +++ b/frontend/src/data/notificationFilters.ts @@ -0,0 +1,108 @@ +import { computed } from 'vue' +import { useLocalStorage } from '@vueuse/core' +import { dayjs, dayjsLocal, getConfig } from 'frappe-ui' + +export type NotificationDateFilter = + | { kind: 'all' } + | { kind: 'today' } + | { kind: 'range'; from: string; to: string } + +export interface NotificationFilterState { + communities: string[] + spaces: string[] + date: NotificationDateFilter +} + +const defaultFilters: NotificationFilterState = { + communities: [], + spaces: [], + date: { kind: 'all' }, +} + +const stored = useLocalStorage('gameplan:notificationFilters', { + ...defaultFilters, +}) + +export const notificationFilters = computed({ + get: () => normalize(stored.value), + set: (value: NotificationFilterState) => { + stored.value = normalize(value) + }, +}) + +export function setNotificationCommunities(communities: string[]) { + notificationFilters.value = { ...notificationFilters.value, communities } +} + +export function setNotificationSpaces(spaces: string[]) { + notificationFilters.value = { ...notificationFilters.value, spaces } +} + +export function setNotificationDate(date: NotificationDateFilter) { + notificationFilters.value = { ...notificationFilters.value, date } +} + +export function clearNotificationFilters() { + notificationFilters.value = { ...defaultFilters } +} + +export const hasActiveNotificationFilters = computed(() => { + const { communities, spaces, date } = notificationFilters.value + return communities.length > 0 || spaces.length > 0 || date.kind !== 'all' +}) + +export const notificationDateBounds = computed<[string, string] | null>(() => { + const date = notificationFilters.value.date + if (date.kind === 'all') return null + if (date.kind === 'today') { + const today = dayjsLocal().format('YYYY-MM-DD') + return [today, today] + } + return [date.from, date.to] +}) + +export function notificationListFilters(): Record { + const { communities, spaces } = notificationFilters.value + const filters: Record = {} + if (communities.length) filters.team = ['in', communities] + if (spaces.length) filters.project = ['in', spaces] + const bounds = notificationDateBounds.value + if (bounds) { + const [from, to] = bounds + filters.last_event_at = ['between', [toSystem(`${from} 00:00:00`), toSystem(`${to} 23:59:59`)]] + } + return filters +} + +function toSystem(localDateTime: string) { + const systemTimezone = getConfig('systemTimezone') + const localTimezone = + getConfig('localTimezone') || Intl.DateTimeFormat().resolvedOptions().timeZone + const at = systemTimezone + ? dayjs.tz(localDateTime, localTimezone).tz(systemTimezone) + : dayjs(localDateTime) + return at.format('YYYY-MM-DD HH:mm:ss') +} + +function normalize(value: Partial | null | undefined) { + const communities = Array.isArray(value?.communities) ? value.communities.map(String) : [] + const spaces = Array.isArray(value?.spaces) ? value.spaces.map(String) : [] + const date = normalizeDate(value?.date) + return { communities, spaces, date } +} + +function normalizeDate(date: unknown): NotificationDateFilter { + if (!date || typeof date !== 'object') return { kind: 'all' } + const candidate = date as Partial<{ kind: string; from: string; to: string }> + if (candidate.kind === 'today') return { kind: 'today' } + if (candidate.kind === 'range' && isDate(candidate.from) && isDate(candidate.to)) { + return candidate.from <= candidate.to + ? { kind: 'range', from: candidate.from, to: candidate.to } + : { kind: 'range', from: candidate.to, to: candidate.from } + } + return { kind: 'all' } +} + +function isDate(value: unknown): value is string { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) +} diff --git a/frontend/src/data/notificationPreferences.ts b/frontend/src/data/notificationPreferences.ts new file mode 100644 index 000000000..244206c46 --- /dev/null +++ b/frontend/src/data/notificationPreferences.ts @@ -0,0 +1,203 @@ +import { computed, ref, type Ref } from 'vue' +import { toast, useDoctype } from 'frappe-ui' +import type { GPUserProfile } from '@/types/doctypes' + +export type NotificationLevel = 'Mentions only' | 'Mute' +export type NotificationChannel = 'In-app' | 'Push' | 'Email' + +export type DiscussionNotificationState = 'Mute' | 'Mentions only' | 'Watch' +export type DiscussionNotificationChoice = DiscussionNotificationState | 'Default' + +export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun' +export const allWeekdays: Weekday[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] +export const allDayStart = '00:00' +export const allDayEnd = '23:59' + +export const participationLevels = ['Watch', 'Mentions only'] as const +export type ParticipationLevel = (typeof participationLevels)[number] +export const defaultParticipationLevel: ParticipationLevel = 'Watch' + +const participation = ref(defaultParticipationLevel) +const receiveNotifications = ref(true) +const channel = ref('In-app') +const activeHoursStart = ref(allDayStart) +const activeHoursEnd = ref(allDayEnd) +const activeHoursDays = ref([...allWeekdays]) +const profileName = ref('') + +const userProfiles = useDoctype('GP User Profile') +const saveToastId = 'notification-preference-save' + +export const currentParticipationLevel = computed(() => participation.value) +export const currentReceiveNotifications = computed(() => receiveNotifications.value) +export const currentNotificationChannel = computed(() => channel.value) +export const currentActiveHoursStart = computed(() => activeHoursStart.value) +export const currentActiveHoursEnd = computed(() => activeHoursEnd.value) +export const currentActiveHoursDays = computed(() => activeHoursDays.value) + +export function loadNotificationPreferences( + user: { + participation_level?: unknown + notification_channel?: unknown + receive_notifications?: unknown + active_hours_enabled?: unknown + active_hours_start?: unknown + active_hours_end?: unknown + active_hours_days?: unknown + }, + currentProfileName = '', +) { + profileName.value = currentProfileName + participation.value = normalizeParticipation(user.participation_level) + receiveNotifications.value = toBoolean(user.receive_notifications, true) + channel.value = normalizeChannel(user.notification_channel) + const scheduled = toBoolean(user.active_hours_enabled, false) + activeHoursStart.value = (scheduled && toTime(user.active_hours_start)) || allDayStart + activeHoursEnd.value = (scheduled && toTime(user.active_hours_end)) || allDayEnd + activeHoursDays.value = scheduled ? normalizeDays(user.active_hours_days) : [...allWeekdays] +} + +function setPref( + target: Ref, + value: unknown, + normalize: (value: unknown) => T, + field: keyof GPUserProfile, + toStored: (value: T) => unknown = (v) => v, +) { + const next = normalize(value) + const previous = target.value + if (next === previous) return + target.value = next + void persist( + { [field]: toStored(next) } as Partial, + () => (target.value = previous), + ) +} + +export const setParticipationLevel = (value: unknown) => + setPref(participation, value, normalizeParticipation, 'participation_level') + +export const setNotificationChannel = (value: unknown) => + setPref(channel, value, normalizeChannel, 'notification_channel') + +export const setReceiveNotifications = (value: boolean) => + setPref(receiveNotifications, value, Boolean, 'receive_notifications', (on) => (on ? 1 : 0)) + +const ACTIVE_HOURS_QUIET_MS = 600 +let activeHoursTimer: ReturnType | undefined +let pending: { start: string; end: string; days: Weekday[] } | null = null + +export function setActiveHours(patch: { start?: string; end?: string; days?: Weekday[] }) { + const current = { + start: activeHoursStart.value, + end: activeHoursEnd.value, + days: activeHoursDays.value, + } + const previous = pending ?? current + const next = { + start: patch.start || current.start, + end: patch.end || current.end, + days: patch.days ?? current.days, + } + if (next.start === next.end) return + pending = previous + activeHoursStart.value = next.start + activeHoursEnd.value = next.end + activeHoursDays.value = next.days + const allDay = + next.start === allDayStart && next.end === allDayEnd && next.days.length === allWeekdays.length + clearTimeout(activeHoursTimer) + activeHoursTimer = setTimeout(() => { + pending = null + void persist( + { + active_hours_enabled: allDay ? 0 : 1, + active_hours_start: `${next.start}:00`, + active_hours_end: `${next.end}:00`, + active_hours_days: JSON.stringify(next.days), + }, + () => { + activeHoursStart.value = previous.start + activeHoursEnd.value = previous.end + activeHoursDays.value = previous.days + }, + ) + }, ACTIVE_HOURS_QUIET_MS) +} + +async function persist(patch: Partial, rollback: () => void) { + if (!profileName.value) return + try { + await userProfiles.setValue.submit({ name: profileName.value, ...patch }) + toast.success('Notification preference saved', { id: saveToastId }) + } catch { + rollback() + toast.error('Could not save notification preference', { id: saveToastId }) + } +} + +function normalizeParticipation(value: unknown): ParticipationLevel { + return participationLevels.includes(value as ParticipationLevel) + ? (value as ParticipationLevel) + : defaultParticipationLevel +} + +function normalizeChannel(value: unknown): NotificationChannel { + return value === 'Email' || value === 'Push' ? value : 'In-app' +} + +function toBoolean(value: unknown, fallback: boolean) { + if (value === undefined || value === null || value === '') return fallback + return Boolean(Number(value)) +} + +function toTime(value: unknown) { + if (typeof value !== 'string') return '' + const match = value.match(/^(\d{1,2}):(\d{2})/) + return match ? `${match[1].padStart(2, '0')}:${match[2]}` : '' +} + +function normalizeDays(value: unknown): Weekday[] { + let list: unknown = value + if (typeof value === 'string') { + try { + list = JSON.parse(value) + } catch { + list = null + } + } + if (!Array.isArray(list)) return [...allWeekdays] + const days = allWeekdays.filter((day) => list.includes(day)) + return days.length ? days : [...allWeekdays] +} + +export const discussionNotificationStates: DiscussionNotificationState[] = [ + 'Mute', + 'Mentions only', + 'Watch', +] + +export const discussionStateIcon: Record = { + Mute: 'lucide-bell-off', + 'Mentions only': 'lucide-bell', + Watch: 'lucide-bell-ring', +} + +export const discussionStateDescription: Record = { + Mute: 'Nothing from this discussion, not even mentions', + 'Mentions only': 'Only when someone @mentions you here', + Watch: 'Every new comment, plus mentions', +} + +export function discussionNotificationOptions( + state: DiscussionNotificationState, + onSelect: (choice: DiscussionNotificationChoice) => void, +) { + return discussionNotificationStates.map((candidate) => ({ + label: candidate, + description: discussionStateDescription[candidate], + icon: discussionStateIcon[candidate], + selected: state === candidate, + onClick: () => onSelect(candidate), + })) +} diff --git a/frontend/src/data/notifications.ts b/frontend/src/data/notifications.ts index 756660fae..a3a85fc19 100644 --- a/frontend/src/data/notifications.ts +++ b/frontend/src/data/notifications.ts @@ -1,6 +1,10 @@ import { useCall } from 'frappe-ui' import { useDebounceFn } from '@vueuse/core' -import { onSocketEvent } from '@/socket' +import type { RouteLocationRaw } from 'vue-router' +import { onSocketEvent, type NotificationChange } from '@/socket' +import type { GPNotification } from '@/types/doctypes' +import { getCommunity } from './communities' +import { getSpace } from './spaces' export let unreadNotifications = useCall({ cacheKey: 'Unread Notifications Count', @@ -11,25 +15,42 @@ export let unreadNotifications = useCall({ const listeners = new Set<() => void>() /** - * Run `handler` when this user's unread notifications changed into something this tab is - * not already showing — a new notification, or one read or cleared elsewhere. Returns an - * unsubscribe function. + * What this tab last saw of each notification row, keyed by name. Fed by the pages that + * render rows (`rememberNotificationRows`) and read by the socket handler below to tell a + * genuine change from the echo of this tab's own write. + */ +const lastSeen = new Map() + +export function rememberNotificationRows( + rows: Array<{ name: string; event_count?: number; read?: 0 | 1 | boolean }> | null | undefined, +) { + for (const row of rows ?? []) { + lastSeen.set(row.name, { + event_count: row.event_count ?? 1, + read: row.read ? 1 : 0, + }) + } +} + +/** + * Run `handler` when this user's notifications changed into something this tab is not + * already showing — a new row, a merge that bumped a row's count, or a row read or cleared + * elsewhere. Returns an unsubscribe function. * * The backend signals this user on every change, from any tab or device, this one * included. An event this tab caused is pure duplication: the tab has already reloaded * itself, so acting on the echo doubles the requests per click and aborts the reload still * in flight, which frappe-ui's fetch wrapper reports as an AbortError. * - * The echo is told apart by *what it reports*, not by when it arrives: the event carries - * the user's current unread count (see gameplan/realtime.py), and an event naming the - * count the badge already shows says nothing this tab does not know. A clock cannot decide - * this — clicking a notification marks it read here and, one navigation later, has - * `track_visit` clear the rest of that thread on the server, so a window wide enough to - * cover the echo swallowed that real change and left the badge too high. - * - * Losing an event whose count matches by coincidence — one notification read elsewhere - * while another arrives — is the whole cost, and it leaves the badge correct and only the - * open list stale until the next change. + * The echo is told apart by *what it reports*, not by when it arrives. The event carries the + * changed row (see gameplan/realtime.py); a row this tab already holds with the same + * `event_count` and `read` says nothing this tab does not know. Anything else is news: a + * row never seen here, a merge that moved its count, a read flag flipped elsewhere, or a + * bulk clear (`notification: null`). The unread total alone could not carry that — a repeat + * event merging into an already-unread row leaves the total exactly where it was — and a + * clock cannot decide it: clicking a notification marks it read here and, one navigation + * later, has `track_visit` clear the rest of that thread on the server, so a window wide + * enough to cover the echo swallowed that real change and left the badge too high. */ export function onRemoteNotificationChange(handler: () => void) { listeners.add(handler) @@ -38,15 +59,22 @@ export function onRemoteNotificationChange(handler: () => void) { } } +function rowIsNews(notification: NotificationChange | null) { + if (!notification) return false + const seen = lastSeen.get(notification.name) + return !seen || seen.event_count !== notification.event_count || seen.read !== notification.read +} + // One subscription, so every listener acts on the same set of events. Deciding per listener // would race: whichever ran first would reload the badge and change the answer for the rest. onSocketEvent( - 'gameplan:notification_count_changed', - useDebounceFn(({ count }) => { - if (count === unreadNotifications.data) return + 'gameplan:notification_changed', + useDebounceFn(({ count, notification }) => { + const countMoved = count !== unreadNotifications.data + if (!countMoved && !rowIsNews(notification)) return // Reloaded rather than taken from the event: `data` is a computed inside useCall, and a // single fetched count keeps the badge and the lists reading the same server state. - unreadNotifications.reload() + if (countMoved) unreadNotifications.reload() for (const handler of [...listeners]) { try { handler() @@ -57,3 +85,79 @@ onSocketEvent( } }, 500), ) + +export type NotificationRow = Pick< + GPNotification, + | 'name' + | 'type' + | 'message' + | 'from_user' + | 'discussion' + | 'comment' + | 'poll' + | 'task' + | 'project' + | 'team' + | 'read' + | 'event_count' +> & { last_event_at: string } + +export function notificationRoute( + notification: Omit, +): RouteLocationRaw | null { + if (notification.discussion) { + return { + name: 'Discussion', + params: { + communityId: notification.team, + spaceId: notification.project, + postId: notification.discussion, + }, + query: notification.poll + ? { poll: notification.poll } + : notification.comment + ? { comment: notification.comment } + : undefined, + } + } + if (notification.task) { + return { + name: 'SpaceTask', + params: { + communityId: notification.team, + spaceId: notification.project, + taskId: notification.task, + }, + query: notification.comment ? { comment: notification.comment } : undefined, + } + } + if (notification.project && notification.team) { + return { + name: 'Space', + params: { communityId: notification.team, spaceId: notification.project }, + } + } + if (notification.team) { + return { name: 'Discussions', params: { communityId: notification.team } } + } + return null +} + +export function notificationIcon(notification: { type?: string }) { + if (notification.type === 'Rich Quote') return 'lucide-text-quote' + if (notification.type === 'Comment') return 'lucide-message-circle' + if (notification.type === 'New Discussion') return 'lucide-message-square-plus' + if (notification.type === 'Added') return 'lucide-user-plus' + if (notification.type === 'Moved') return 'lucide-corner-up-right' + if (notification.type === 'Poll Vote') return 'lucide-bar-chart-2' + return 'lucide-at-sign' +} + +export function notificationLocation(notification: { + team?: string | null + project?: string | number | null +}) { + const community = notification.team ? getCommunity(notification.team)?.title : null + const space = notification.project ? getSpace(String(notification.project))?.title : null + return [community, space].filter(Boolean).join(' / ') +} diff --git a/frontend/src/data/spaceNotifications.ts b/frontend/src/data/spaceNotifications.ts new file mode 100644 index 000000000..ff1d36065 --- /dev/null +++ b/frontend/src/data/spaceNotifications.ts @@ -0,0 +1,86 @@ +import { computed, reactive, watch } from 'vue' +import { toast, useList } from 'frappe-ui' +import { session } from './session' +import type { GPSpaceSubscription } from '@/types/doctypes' + +export const spaceSubscriptions = useList({ + doctype: 'GP Space Subscription', + fields: ['name', 'project'], + limit: 1000, + cacheKey: ['Space Subscriptions', session.user ?? ''], + immediate: true, +}) + +const subscriptionByProject = computed( + () => new Map((spaceSubscriptions.data ?? []).map((row) => [String(row.project), row])), +) + +const requested = reactive(new Map()) + +export function isSpaceNotifying(project: string | number) { + const key = String(project) + return requested.has(key) ? requested.get(key)! : subscriptionByProject.value.has(key) +} + +const toggleToastId = 'space-notifications-toggle' + +const inFlight = new Set() + +function settle() { + for (const [project, on] of requested) { + if (!inFlight.has(project) && subscriptionByProject.value.has(project) === on) { + requested.delete(project) + } + } +} + +watch(subscriptionByProject, settle) + +async function apply(projects: (string | number)[], on: boolean, message: string) { + const changing = projects + .map(String) + .filter((project) => !inFlight.has(project) && isSpaceNotifying(project) !== on) + if (!changing.length) return + changing.forEach((project) => { + inFlight.add(project) + requested.set(project, on) + }) + const results = await Promise.allSettled( + changing.map((project) => { + const row = subscriptionByProject.value.get(project) + return on + ? spaceSubscriptions.insert.submit({ project }) + : row && spaceSubscriptions.delete.submit({ name: row.name }) + }), + ) + if (results.some((result) => result.status === 'rejected')) { + toast.error('Could not update space notifications', { id: toggleToastId }) + await spaceSubscriptions.reload().catch(() => {}) + changing.forEach((project) => requested.delete(project)) + } else { + toast.success(message, { id: toggleToastId }) + } + changing.forEach((project) => inFlight.delete(project)) + settle() +} + +export function toggleSpaceNotifications(project: string | number) { + const on = !isSpaceNotifying(project) + return apply( + [project], + on, + on + ? 'You will be notified about new discussions here' + : 'You will no longer be notified about new discussions here', + ) +} + +export function setSpaceNotifications(projects: (string | number)[], on: boolean) { + return apply( + projects, + on, + on + ? 'You will be notified about new discussions in these spaces' + : 'You will no longer be notified about new discussions in these spaces', + ) +} diff --git a/frontend/src/data/users.ts b/frontend/src/data/users.ts index fea9a249a..0b5aedb3d 100644 --- a/frontend/src/data/users.ts +++ b/frontend/src/data/users.ts @@ -2,6 +2,11 @@ import { computed, reactive, readonly, ref, watch } from 'vue' import { useCall } from 'frappe-ui' import router from '@/router' import { setCommunityOrder } from './communityOrder' +import { + loadNotificationPreferences, + type NotificationChannel, + type NotificationLevel, +} from './notificationPreferences' import { loadPinnedSpaces } from './pinnedSpaces' import { loadQuickReactionSlots } from './reactionPreferences' import { setSidebarBadgeStyle, type SidebarBadgeStyle } from './sidebarPreferences' @@ -27,6 +32,8 @@ export interface UserInfo { user_image: string full_name: string user_type: string + /** Frappe's own User.time_zone; the schedule in Settings → Notifications runs in it. */ + time_zone?: string | null creation: string user_profile: string image_background_color: string @@ -40,6 +47,14 @@ export interface UserInfo { email_digest_frequency?: EmailDigestFrequency email_digest_day_of_week?: EmailDigestDayOfWeek email_digest_last_sent_on?: string + notification_level?: NotificationLevel + participation_level?: 'Watch' | 'Mentions only' + notification_channel?: NotificationChannel + receive_notifications?: 0 | 1 + active_hours_enabled?: 0 | 1 + active_hours_start?: string + active_hours_end?: string + active_hours_days?: string bio: string role: 'Gameplan Admin' | 'Gameplan Member' | 'Gameplan Guest' isGuest?: boolean @@ -72,6 +87,7 @@ function mergeUserInfo(user: UserInfo) { loadPinnedSpaces(user.pinned_spaces, user.user_profile) loadQuickReactionSlots(user.quick_reaction_emojis, user.user_profile) setSidebarBadgeStyle(user.sidebar_badge_style) + loadNotificationPreferences(user, user.user_profile) } } diff --git a/frontend/src/index.css b/frontend/src/index.css index d5e913cdb..33780d418 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -34,6 +34,15 @@ html { } } +/* + Active hours (Settings → Notifications): native time inputs give hour / minute / am-pm + as steppable segments, which is the control wanted there; the browser's clock popup + behind the little icon is not, so the icon goes. +*/ +.active-hours-time input::-webkit-calendar-picker-indicator { + display: none; +} + /* Flash on the source passage when navigating from a rich quote back to it. Duration matches the 2.5s class-removal timeout in useRichQuotes. */ .highlighted-quote-target { diff --git a/frontend/src/pages/Configure/CommunitySpacesList.vue b/frontend/src/pages/Configure/CommunitySpacesList.vue index 57487a7a9..ea382414d 100644 --- a/frontend/src/pages/Configure/CommunitySpacesList.vue +++ b/frontend/src/pages/Configure/CommunitySpacesList.vue @@ -11,8 +11,8 @@ :columns="{ base: ['minmax(0,1fr)', 'auto'], md: hasGuests - ? ['minmax(8rem,1fr)', '15.25rem', '5rem', '1.5rem'] - : ['minmax(8rem,1fr)', '15.25rem', '1.5rem'], + ? ['minmax(12rem,1fr)', '10rem', '4rem', '4rem'] + : ['minmax(12rem,1fr)', '10rem', '4rem'], }" class="list-gap-12 max-md:list-gap-1" > diff --git a/frontend/src/pages/Configure/SpaceRow.vue b/frontend/src/pages/Configure/SpaceRow.vue index 054bfa9b8..cbe7e43a1 100644 --- a/frontend/src/pages/Configure/SpaceRow.vue +++ b/frontend/src/pages/Configure/SpaceRow.vue @@ -51,6 +51,13 @@ +
+ + +
+
+ +
+ +
+