diff --git a/app/internal_packages/main-calendar/lib/main.tsx b/app/internal_packages/main-calendar/lib/main.tsx index 119b48ebce..5c4fceed85 100644 --- a/app/internal_packages/main-calendar/lib/main.tsx +++ b/app/internal_packages/main-calendar/lib/main.tsx @@ -17,18 +17,20 @@ const Notice = () => Notice.displayName = 'Notice'; export function activate() { - ComponentRegistry.register(MailspringCalendar, { - location: WorkspaceStore.Location.Center, - }); - ComponentRegistry.register(Notice, { - location: WorkspaceStore.Sheet.Main.Header, - }); - ComponentRegistry.register(QuickEventButton, { - location: WorkspaceStore.Location.Center.Toolbar, - }); - ComponentRegistry.register(EventSearchBar, { - location: WorkspaceStore.Location.Center.Toolbar, - }); + if (AppEnv.getWindowType() === 'calendar') { + ComponentRegistry.register(MailspringCalendar, { + location: WorkspaceStore.Location.Center, + }); + ComponentRegistry.register(Notice, { + location: WorkspaceStore.Sheet.Main.Header, + }); + ComponentRegistry.register(QuickEventButton, { + location: WorkspaceStore.Location.Center.Toolbar, + }); + ComponentRegistry.register(EventSearchBar, { + location: WorkspaceStore.Location.Center.Toolbar, + }); + } } export function deactivate() { diff --git a/app/internal_packages/main-calendar/package.json b/app/internal_packages/main-calendar/package.json index 4f39fdcff6..2deb9f7a77 100644 --- a/app/internal_packages/main-calendar/package.json +++ b/app/internal_packages/main-calendar/package.json @@ -11,6 +11,7 @@ "mailspring": "*" }, "windowTypes": { + "default": true, "calendar": true } } diff --git a/app/internal_packages/message-list/lib/main.tsx b/app/internal_packages/message-list/lib/main.tsx index 534c1678d7..5ad748995c 100644 --- a/app/internal_packages/message-list/lib/main.tsx +++ b/app/internal_packages/message-list/lib/main.tsx @@ -9,8 +9,7 @@ import { import { MessageListHiddenMessagesToggle } from './message-list-hidden-messages-toggle'; import MessageList from './message-list'; -import { SidebarPluginContainer } from './sidebar-plugin-container'; -import { SidebarParticipantPicker } from './sidebar-participant-picker'; +import { SidebarViewSwitcher } from './sidebar-view-switcher'; export function activate() { if (AppEnv.isMainWindow()) { @@ -18,10 +17,7 @@ export function activate() { ComponentRegistry.register(MessageList, { location: WorkspaceStore.Location.MessageList, }); - ComponentRegistry.register(SidebarParticipantPicker, { - location: WorkspaceStore.Location.MessageListSidebar, - }); - ComponentRegistry.register(SidebarPluginContainer, { + ComponentRegistry.register(SidebarViewSwitcher, { location: WorkspaceStore.Location.MessageListSidebar, }); ComponentRegistry.register(MessageListHiddenMessagesToggle, { @@ -33,7 +29,7 @@ export function activate() { ComponentRegistry.register(MessageList, { location: WorkspaceStore.Location.Center }); // We need to locate the thread and focus it so that the MessageList displays it - DatabaseStore.find(Thread, threadId).then(thread => + DatabaseStore.find(Thread, threadId).then((thread) => Actions.setFocus({ collection: 'thread', item: thread }) ); @@ -48,6 +44,5 @@ export function activate() { export function deactivate() { ComponentRegistry.unregister(MessageList); - ComponentRegistry.unregister(SidebarPluginContainer); - ComponentRegistry.unregister(SidebarParticipantPicker); + ComponentRegistry.unregister(SidebarViewSwitcher); } diff --git a/app/internal_packages/message-list/lib/sidebar-agenda-view.tsx b/app/internal_packages/message-list/lib/sidebar-agenda-view.tsx new file mode 100644 index 0000000000..d6c33f6f6b --- /dev/null +++ b/app/internal_packages/message-list/lib/sidebar-agenda-view.tsx @@ -0,0 +1,125 @@ +import moment, { Moment } from 'moment'; +import React from 'react'; +import { Rx, DatabaseStore, Calendar, Actions, localized } from 'mailspring-exports'; +import { + CalendarDataSource, + EventOccurrence, + FocusedEventInfo, +} from '../../main-calendar/lib/core/calendar-data-source'; +import { AgendaView } from '../../main-calendar/lib/core/agenda-view'; +import { CalendarView } from '../../main-calendar/lib/core/calendar-constants'; +import { CalendarEventPopover } from '../../main-calendar/lib/core/calendar-event-popover'; +import { Disposable } from 'rx-core'; + +const DISABLED_CALENDARS = 'mailspring.disabledCalendars'; + +interface SidebarAgendaViewState { + focusedMoment: Moment; + selectedEvents: EventOccurrence[]; + focusedEvent: FocusedEventInfo | null; + disabledCalendars: string[]; + calendars: Calendar[]; +} + +export class SidebarAgendaView extends React.Component<{}, SidebarAgendaViewState> { + static displayName = 'SidebarAgendaView'; + + _dataSource = new CalendarDataSource(); + _configDisposable?: Disposable; + _calDisposable?: Disposable; + + constructor(props: {}) { + super(props); + this.state = { + focusedMoment: moment(), + selectedEvents: [], + focusedEvent: null, + disabledCalendars: AppEnv.config.get(DISABLED_CALENDARS) || [], + calendars: [], + }; + } + + componentDidMount() { + this._configDisposable = Rx.Observable.fromConfig( + DISABLED_CALENDARS + ).subscribe((disabledCalendars) => { + this.setState({ disabledCalendars: disabledCalendars || [] }); + }); + + const calQuery = DatabaseStore.findAll(Calendar); + this._calDisposable = Rx.Observable.fromQuery(calQuery).subscribe((calendars) => { + this.setState({ calendars }); + }); + } + + componentWillUnmount() { + this._configDisposable?.dispose(); + this._calDisposable?.dispose(); + } + + _getReadOnlyCalendarIds(): Set { + const ids = new Set(); + for (const cal of this.state.calendars) { + if (cal.readOnly) { + ids.add(cal.id); + } + } + return ids; + } + + _onChangeFocusedMoment = (m: Moment) => { + this.setState({ focusedMoment: m, focusedEvent: null }); + }; + + _onChangeView = () => { + // No-op in the sidebar — we always show the agenda view + }; + + _onEventClick = (e: React.MouseEvent, event: EventOccurrence) => { + this.setState({ selectedEvents: [event], focusedEvent: null }); + }; + + _onEventDoubleClick = (event: EventOccurrence) => { + const el = document.getElementById(event.id); + if (!el) return; + Actions.openPopover(, { + originRect: el.getBoundingClientRect(), + direction: 'left', + fallbackDirection: 'right', + closeOnAppBlur: false, + }); + }; + + _onEventFocused = (event: EventOccurrence) => { + this._onEventDoubleClick(event); + }; + + // No-ops for drag and mouse handlers — not needed in sidebar + _noopMouseHandler = () => {}; + _noopDragStart = () => {}; + + render() { + return ( +
+ +
+ ); + } +} diff --git a/app/internal_packages/message-list/lib/sidebar-view-switcher.tsx b/app/internal_packages/message-list/lib/sidebar-view-switcher.tsx new file mode 100644 index 0000000000..0cafb40b52 --- /dev/null +++ b/app/internal_packages/message-list/lib/sidebar-view-switcher.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { PropTypes, FocusedContactsStore, Contact } from 'mailspring-exports'; +import { InjectedComponentSet } from 'mailspring-component-kit'; +import { SidebarViewToggle, SidebarView } from './sidebar-view-toggle'; +import { SidebarParticipantPicker } from './sidebar-participant-picker'; +import { SidebarAgendaView } from './sidebar-agenda-view'; + +class FocusedContactStorePropsContainer extends React.Component< + { children: React.ReactElement }, + { sortedContacts: Contact[]; focusedContact: Contact } +> { + static displayName = 'FocusedContactStorePropsContainer'; + + unsubscribe: () => void; + + constructor(props) { + super(props); + this.state = this._getStateFromStores(); + } + + componentDidMount() { + this.unsubscribe = FocusedContactsStore.listen(this._onChange); + } + + componentWillUnmount() { + this.unsubscribe(); + } + + _onChange = () => { + this.setState(this._getStateFromStores()); + }; + + _getStateFromStores() { + return { + sortedContacts: FocusedContactsStore.sortedContacts(), + focusedContact: FocusedContactsStore.focusedContact(), + }; + } + + render() { + let classname = 'sidebar-section'; + let inner = null; + if (this.state.focusedContact) { + classname += ' visible'; + inner = React.cloneElement(this.props.children, this.state); + } + return
{inner}
; + } +} + +const SidebarPluginContainerInner = (props) => { + return ( + + ); +}; + +SidebarPluginContainerInner.propTypes = { + focusedContact: PropTypes.object, +}; + +export class SidebarViewSwitcher extends React.Component<{}, { selectedView: SidebarView }> { + static displayName = 'SidebarViewSwitcher'; + + static containerStyles = { + order: 0, + flexShrink: 0, + minWidth: 150, + maxWidth: 300, + }; + + constructor(props: {}) { + super(props); + this.state = { + selectedView: 'participants', + }; + } + + _onSelectView = (view: SidebarView) => { + this.setState({ selectedView: view }); + }; + + render() { + const { selectedView } = this.state; + + return ( +
+ + {selectedView === 'participants' ? ( +
+ + + + +
+ ) : ( + + )} +
+ ); + } +} diff --git a/app/internal_packages/message-list/lib/sidebar-view-toggle.tsx b/app/internal_packages/message-list/lib/sidebar-view-toggle.tsx new file mode 100644 index 0000000000..f5c9f25edb --- /dev/null +++ b/app/internal_packages/message-list/lib/sidebar-view-toggle.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { localized } from 'mailspring-exports'; + +export type SidebarView = 'participants' | 'calendar'; + +interface SidebarViewToggleProps { + selectedView: SidebarView; + onSelectView: (view: SidebarView) => void; +} + +export class SidebarViewToggle extends React.Component { + static displayName = 'SidebarViewToggle'; + + render() { + const { selectedView, onSelectView } = this.props; + return ( +
+ + +
+ ); + } +} diff --git a/app/internal_packages/message-list/styles/message-list.less b/app/internal_packages/message-list/styles/message-list.less index 33ad158d5b..ae1551d1c1 100644 --- a/app/internal_packages/message-list/styles/message-list.less +++ b/app/internal_packages/message-list/styles/message-list.less @@ -282,7 +282,9 @@ body.platform-win32 { .message-item-white-wrap { background: @background-primary; border: 0; - box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.28), 0 1px 1.5px rgba(0, 0, 0, 0.08); + box-shadow: + 0 0 0.5px rgba(0, 0, 0, 0.28), + 0 1px 1.5px rgba(0, 0, 0, 0.08); border-radius: 4px; } @@ -390,8 +392,11 @@ body.platform-win32 { position: relative; border-top: 1px solid @border-color-divider; background-color: darken(@background-primary, 2%); - box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.1), 0 -0.5px 0 rgba(0, 0, 0, 0.1), - 0.5px 0 0 rgba(0, 0, 0, 0.1), -0.5px 0 0 rgba(0, 0, 0, 0.1); + box-shadow: + 0 0.5px 0 rgba(0, 0, 0, 0.1), + 0 -0.5px 0 rgba(0, 0, 0, 0.1), + 0.5px 0 0 rgba(0, 0, 0, 0.1), + -0.5px 0 0 rgba(0, 0, 0, 0.1); } } @@ -433,7 +438,9 @@ body.platform-win32 { } .message-actions-wrap { - transition: opacity 100ms, width 150ms; + transition: + opacity 100ms, + width 150ms; transition-delay: 50ms, 0ms; transition-timing-function: ease-in-out; opacity: 1; @@ -554,7 +561,9 @@ body.platform-win32 { z-index: 2; border: 0; - box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.28), 0 1px 1.5px rgba(0, 0, 0, 0.08); + box-shadow: + 0 0 0.5px rgba(0, 0, 0, 0.28), + 0 1px 1.5px rgba(0, 0, 0, 0.08); border-top: 1px dashed @border-color-divider; border-radius: 0 0 4px 4px; background: @background-primary; @@ -814,3 +823,149 @@ body.platform-win32 { width: 1px; } } + +/////////////////////////// +// sidebar-view-switcher // +/////////////////////////// +.sidebar-view-switcher { + display: flex; + flex-direction: column; + height: 100%; +} + +.sidebar-view-toggle { + display: flex; + padding: 8px 8px 4px 8px; + gap: 0; + flex-shrink: 0; + + .toggle-btn { + flex: 1; + padding: 4px 8px; + font-size: 11px; + font-weight: @font-weight-semi-bold; + text-align: center; + border: 1px solid @border-color-primary; + background: transparent; + color: @text-color-subtle; + cursor: pointer; + transition: + background 0.1s ease, + color 0.1s ease; + + &:first-child { + border-radius: @border-radius-base 0 0 @border-radius-base; + border-right: none; + } + &:last-child { + border-radius: 0 @border-radius-base @border-radius-base 0; + } + &.active { + background: @background-primary; + color: @text-color; + border-color: @border-color-primary; + } + &:hover:not(.active) { + background: fade(@background-primary, 50%); + } + &:focus { + outline: none; + } + } +} + +.sidebar-participants-view { + flex: 1; + overflow: auto; +} + +/////////////////////////// +// sidebar-agenda-view // +/////////////////////////// +.sidebar-agenda-view { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + // The AgendaView is designed for full-width calendar window — adapt it for + // the narrow sidebar panel (150-300px). + .calendar-view.agenda-view { + display: flex; + flex-direction: column; + height: 100%; + + // Hide the top-banner (injected component area) in the sidebar + .top-banner { + display: none; + } + + // Adapt the header controls for the narrow sidebar + .header-controls { + padding: 6px 8px; + position: relative; + + // Hide the Day/Week/Month/Agenda view picker — not useful in the sidebar + .view-controls { + display: none; + } + + // Hide the absolutely-positioned "Today" button to avoid overlap + > .btn { + display: none; + } + + .center-controls { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + .title { + width: auto; + flex: 1; + font-size: 13px; + text-align: center; + } + } + } + + // Ensure the scroll region fills available space + .agenda-scroll-region { + flex: 1; + } + + // Constrain the event list for narrow width + .agenda-event-list { + padding: 0 8px 12px; + } + + // Stack event content vertically: color-bar on the left, then + // time above title/location on the right, with no gap. + .agenda-event { + // Keep the default row flex — color-bar and content side by side. + // The content after the bar becomes a vertical column. + .agenda-event-time { + width: auto; + flex: none; + } + + .agenda-event-details { + flex-basis: 100%; + } + + // Wrap time + details in a column so they stack tightly + // after the color-bar. We achieve this by making the event + // itself a CSS grid: [bar] [stacked-content]. + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + column-gap: 0; + + .agenda-event-color-bar { + grid-row: 1 / -1; + margin-right: 8px; + } + } + } +}