diff --git a/e2e/cypress/component/message_list.ts b/e2e/cypress/component/message_list.ts new file mode 100644 index 000000000..e6c7b80d1 --- /dev/null +++ b/e2e/cypress/component/message_list.ts @@ -0,0 +1,37 @@ +const pick = (functions) => { + const randomIndex = Math.floor(Math.random() * functions.length); + return functions[randomIndex]; // Returns a randomly selected function +}; + +export function rangeCheckMessages(from, to) { + checkMessage(from) + cy.get('body').type('{shift}', { release: false }); // Press Shift + checkMessage(to) + cy.get('body').type('{shift}'); // Release Shift +} + +function table() { + return cy.get('app-virtual-scroll-table'); +} + +export function firstMessage() { + return nthMessage(0) +} + +export function nthMessage(n) { + return table().find('tbody').eq(n); +} + +export function checkMessage(n) { + const checkboxClick = () => nthMessage(n).find('mat-checkbox').click() + const ctrlMessageClick = () => nthMessage(n).click({ ctrlKey: true }) + + return pick([ + checkboxClick, + ctrlMessageClick, + ])() +} + +export function checkedMessages() { + return cy.get('tbody .mat-checkbox-checked') +} diff --git a/e2e/cypress/integration/canvastable.ts b/e2e/cypress/integration/canvastable.ts index e69d91e30..10c3eed94 100644 --- a/e2e/cypress/integration/canvastable.ts +++ b/e2e/cypress/integration/canvastable.ts @@ -1,23 +1,21 @@ /// -describe('Selecting rows in canvastable', () => { - function canvas() { - return cy.get('canvastable canvas:first-of-type'); - } +import { checkMessage, checkedMessages, rangeCheckMessages } from '../component/message_list.ts'; - function moveButton() { - return cy.get('button[mattooltip*="Move"]'); - } +function moveButton() { + return cy.get('button[mattooltip*="Move"]'); +} - it('should select one row', () => { +describe('Selecting rows in canvastable', () => { + it('should select and deselect one row', () => { cy.viewport('iphone-6'); cy.visit('/'); - // select - canvas().click({ x: 15, y: 40 }); + moveButton().should('not.exist'); + checkMessage(0) moveButton().should('be.visible'); - // unselect - canvas().click({ x: 21, y: 41, force: true }); + checkedMessages().should('have.length', 1) + checkMessage(0) moveButton().should('not.exist'); }) @@ -25,14 +23,22 @@ describe('Selecting rows in canvastable', () => { cy.viewport('iphone-6'); cy.visit('/'); - canvas().trigger('mousedown', { x: 15, y: 10 }); - for (let ndx = 0; ndx <= 5; ndx++) { - canvas().trigger('mousemove', { x: 20, y: 36 * ndx + 11 }); - } + rangeCheckMessages(0, 5) + + + // Verify multiple checkboxes are checked + checkedMessages().should('have.length', 6); + moveButton().should('be.visible'); - // unselect by moving mouse back up - canvas().trigger('mousemove', { x: 21, y: 12 }); + checkMessage(0) + + // Verify count decreases + checkedMessages().should('have.length', 5) + + rangeCheckMessages(1, 5) + checkedMessages().should('have.length', 0) moveButton().should('not.exist'); - }) + }); + }) diff --git a/e2e/cypress/integration/compose.ts b/e2e/cypress/integration/compose.ts index bc87851a2..f6cf3114a 100644 --- a/e2e/cypress/integration/compose.ts +++ b/e2e/cypress/integration/compose.ts @@ -1,5 +1,7 @@ /// +import { firstMessage } from '../component/message_list.ts'; + describe('Composing emails', () => { beforeEach(async () => { localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); @@ -104,10 +106,7 @@ describe('Composing emails', () => { }); it('closing a new reply should return to inbox', () => { - cy.visit('/'); - cy.wait(1000); cy.visit('/#Inbox:1'); - cy.get('canvastable canvas:first-of-type').click({ x: 300, y: 10 }); cy.get('single-mail-viewer').should('exist'); cy.get('button[mattooltip="Reply"]').click(); cy.get('button[mattooltip="Close draft"').click(); diff --git a/e2e/cypress/integration/folder-switching.ts b/e2e/cypress/integration/folder-switching.ts index eb91b41ca..2b35da7b8 100644 --- a/e2e/cypress/integration/folder-switching.ts +++ b/e2e/cypress/integration/folder-switching.ts @@ -1,19 +1,18 @@ /// describe('Switching between folders (and not-folders)', () => { - function goToInbox() { cy.get('rmm-folderlist mat-tree-node:contains(Inbox)', {'timeout':10000}).click(); cy.url().should('match', /\/(#Inbox)?$/); cy.get('rmm-folderlist mat-tree-node:contains(Inbox)').should('have.class', 'selectedFolder'); } - it('can switch from welcome to inbox', () => { - localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); - localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); + it('can switch from welcome to inbox', () => { + localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); + localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); - // start of on /welcome, like a fresh new user - cy.visit('/welcome'); + // start of on /welcome, like a fresh new user + cy.visit('/welcome'); // should be able to switch to inbox... goToInbox(); diff --git a/e2e/cypress/integration/folders.ts b/e2e/cypress/integration/folders.ts index 269270e01..00c0bd231 100644 --- a/e2e/cypress/integration/folders.ts +++ b/e2e/cypress/integration/folders.ts @@ -1,13 +1,13 @@ /// -describe('Folder management', () => { - function canvas() { - return cy.get('canvastable canvas:first-of-type'); - } +import { firstMessage } from '../component/message_list.ts' +describe('Folder management', () => { it('should create folder at root level', () => { + cy.intercept('GET', '/rest/v1/email_folder/list').as('getEmailFolders'); cy.visit('/'); - + cy.wait('@getEmailFolders'); + cy.wait(5000); cy.get('#createFolderButton').click(); cy.get('.mat-dialog-title').should('contain', 'Add new folder'); cy.get('mat-dialog-container mat-dialog-content').should('contain', 'root level'); @@ -32,9 +32,9 @@ describe('Folder management', () => { }); it('should create new draft on templates folder message click', () => { - cy.visit('/') - cy.contains('mat-tree-node', 'Templates').click() - canvas().click({ x: 55, y: 40 }); + cy.visit('/') + cy.contains('mat-tree-node', 'Templates').click() + firstMessage().click(); cy.location().should((loc) => { expect(loc.pathname).to.eq('/compose'); }); diff --git a/e2e/cypress/integration/mailviewer.ts b/e2e/cypress/integration/mailviewer.ts index 7abe6d3fc..84732a919 100644 --- a/e2e/cypress/integration/mailviewer.ts +++ b/e2e/cypress/integration/mailviewer.ts @@ -1,10 +1,8 @@ /// -describe('Interacting with mailviewer', () => { - function canvas() { - return cy.get('canvastable canvas:first-of-type'); - } +import { nthMessage } from '../component/message_list.ts' +describe('Interacting with mailviewer', () => { beforeEach(async () => { localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); localStorage.setItem('221:Global:messageSubjectDragTipShown', JSON.stringify('true')); @@ -26,30 +24,20 @@ describe('Interacting with mailviewer', () => { // }); it('can open an email and go back and forth in browser history', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/#Inbox:11'); - - cy.wait('@get11', {'timeout':10000}); - // canvas().click(400, 300); - cy.hash().should('equal', '#Inbox:11'); - /* TODO: apparently forward broke at some point - * in headless mode. Works normally in a proper browser + nthMessage(1).click(); + cy.hash().should('equal', '#Inbox:2'); cy.go('back'); - cy.hash().should('not.contain', 'Inbox:11'); - cy.go('forward'); cy.hash().should('equal', '#Inbox:11'); + cy.go('forward'); + cy.hash().should('equal', '#Inbox:2'); cy.get('button[mattooltip="Close"]').click(); cy.hash().should('equal', '#Inbox'); - */ }); it('can reply to an email with no "To"', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/#Inbox:11') - cy.wait('@get11', {'timeout':10000}); - // cy.get('#messageContents'); - cy.get('button[mattooltip="Reply"]').click(); cy.location().should((loc) => { expect(loc.pathname).to.eq('/compose'); @@ -59,11 +47,8 @@ describe('Interacting with mailviewer', () => { }); it('can forward an email with no "To"', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); - // cy.get('#messageContents'); cy.get('button[mattooltip="Forward"]').click(); cy.location().should((loc) => { @@ -74,11 +59,8 @@ describe('Interacting with mailviewer', () => { }); it('can reply to an email with no "To" or "Subject"', () => { - cy.intercept('/rest/v1/email/download/*').as('get13'); cy.visit('/'); - cy.wait('@get13', {'timeout':10000}); cy.visit('/#Inbox:13'); - // cy.get('#messageContents'); cy.get('button[mattooltip="Reply"]').click(); cy.location().should((loc) => { @@ -89,11 +71,8 @@ describe('Interacting with mailviewer', () => { }); it('can forward an email with no "To" or "Subject"', () => { - cy.intercept('/rest/v1/email/download/*').as('get13'); cy.visit('/'); - cy.wait('@get13', {'timeout':10000}); cy.visit('/#Inbox:13'); - // cy.get('#messageContents'); cy.get('button[mattooltip="Forward"]').click(); cy.location().should((loc) => { @@ -104,9 +83,7 @@ describe('Interacting with mailviewer', () => { }); it('Vertical to horizontal mode exposes full height button', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); // Make sure we're in vertical mode @@ -116,9 +93,7 @@ describe('Interacting with mailviewer', () => { }); it('Changing viewpane height is stored', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); // cy.hash().should('equal', '#Inbox:11'); @@ -133,9 +108,7 @@ describe('Interacting with mailviewer', () => { }); it('Half height reduces stored pane height', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); // cy.hash().should('equal', '#Inbox:11'); @@ -161,9 +134,7 @@ describe('Interacting with mailviewer', () => { }); it('Revisit open email in horizontal mode loads it', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); // cy.hash().should('equal', '#Inbox:11'); @@ -177,9 +148,7 @@ describe('Interacting with mailviewer', () => { }); it('Can go out of mailviewer and back and still see our email', () => { - cy.intercept('/rest/v1/email/download/*').as('get12'); cy.visit('/'); - cy.wait('@get12',{'timeout':10000}); cy.visit('/#Inbox:12'); // cy.hash().should('equal', '#Inbox:12'); diff --git a/e2e/cypress/integration/message-caching.ts b/e2e/cypress/integration/message-caching.ts deleted file mode 100644 index 658217809..000000000 --- a/e2e/cypress/integration/message-caching.ts +++ /dev/null @@ -1,38 +0,0 @@ -/// - -describe('Message caching', () => { - beforeEach(async () => { - localStorage.setItem('221:localSearchPromptDisplayed', 'true'); - (await indexedDB.databases()) - .filter(db => db.name && /messageCache/.test(db.name)) - .forEach(db => indexedDB.deleteDatabase(db.name!)); - - }); - - it('should fetch all messages on first time page load', () => { - cy.intercept('/rest/v1/email/download/*').as('message12requested'); - - cy.visit('/'); - cy.wait('@message12requested', {'timeout':10000}); - cy.wait(1000); // hopefully this is enough time for all the iDB writes to actually finish - }); - - it('should not re-request messages after a page reload', () => { - cy.intercept('/rest/v1/email/download/*').as('message12requested'); - - cy.visit('/'); - cy.wait('@message12requested', {'timeout':10000}); - // This should have fetched/cached the message - - // Now don't fetch it again: - cy.visit('/#Inbox:12'); - let called = false; - cy.intercept('/rest/v1/email/download/*', (_req) => { - called = true; - }); - - cy.get('div#messageHeaderSubject').contains('Default from fix test').then(() => { - assert.equal(called, false); - }); - }); -}); diff --git a/src/app/app.component.html b/src/app/app.component.html index b7f173af2..1d7647071 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -6,7 +6,7 @@ fixedTopGap="0" id="sideMenu" appResizable> - + @@ -107,7 +107,7 @@ [folders]="displayedFolders" [folderMessageCounts]="messagelistservice.folderMessageCountSubject" [selectedFolder]="selectedFolder" - (folderSelected)="selectFolder($event)" + (folderSelected)="onFolderSelect($event)" (droppedToFolder)="dropToFolder($event)" (emptyTrash)="emptyTrash($event)" (emptySpam)="emptySpam($event)" @@ -167,7 +167,6 @@
No Message Selected - +

@@ -239,12 +238,12 @@

No Message Selected

- + - +
- +
- + No Message Selected Unread only - + No Message Selected
-
- -
+ +
+ + + + + + + + + + + Date + + + + + + {{ + selectedFolder !== messagelistservice.sentFolderName ? "From" : "To" + }} + + + + + + + Subject + + + + + + Count + + + + + + Size + + + + + + + Folder + + + + + Attachments + + + Answered + + + Flagged + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{item.messageDate}} + + + + {{item.from}} + + + + {{item.subject}} + + +
+ {{item.count}} +
+ + + {{item.size | humanBytes}} + + + {{item.folder}} + + + + + + + + + + + + + + + + + + + + + + + {{item.plaintext | async}} + + + + + + + + + +
+
+
@@ -449,7 +701,7 @@

No Message Selected

- +
+ + + + mail Moving {{rowsSelectionModel.selected.length}} + + +
+ + diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 1150ca33e..42f76a4d1 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -68,3 +68,180 @@ width: 150px; } +.resizable { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + +#messageTableContainerArea { + container-type: inline-size; // or use `container: inline-size;` + + .messages-table { + --cell-y-spacing-top: 0.3lh; + --cell-y-spacing-top-first: 0.15lh; + --cell-y-spacing-bottom: 0.2lh; + --row-y-spacing: 0.3lh; + + th { + text-align: left; + position: relative; + background-color: white; + z-index: 1; + } + + td { + padding-top: var(--cell-y-spacing-top); + padding-bottom: var(--cell-y-spacing-bottom); + + &.count { + text-align: center; + } + } + + tbody tr td:first-child { + padding-top: var(--cell-y-spacing-top-first); + } + + td, + th { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + td.count > div { + display: inline-block; + font-size: 0.75rem; + padding: 0.2em 0.4em; + line-height: 1.2; + } + + tbody { + cursor: pointer; + } + + .checkbox-cell { + width: 3ch; + text-align: center; + } + + + .time-cell { + width: 11%; + } + + .from-cell { + width: 22%; + } + + .subject-cell { + width: 33%; + } + + .conversations-cell { + width: 11%; + } + + .size-cell { + width: 6%; + } + + .folder-cell { + width: 11%; + } + + .attachments-cell { + width: 2%; + } + + .answered-cell { + width: 2%; + } + + .flagged-cell { + width: 2%; + } + + .preview { + display: block; + height: 1lh; + } + + // Container query replaces media query + @container (max-width: 25rem) { + display: block; + + thead { + display: none; + } + + tbody { + position: relative; + display: block; + padding-top: var(--row-y-spacing); + padding-bottom: var(--row-y-spacing); + } + + td, tbody tr:first-child td { + padding: 0; + display: inline-block; + margin-top: auto; + margin-bottom: auto; + margin-left: 2rem; + + &.sm-hidden { + display: none; + } + + &.count > div { + position: absolute; + top: var(--row-y-spacing); + right: 0; + margin-right: 8px; + } + + &.subject { + width: 100%; + } + + &.checkbox-cell { + position: absolute; + margin-left: 4px; + } + } + + tr { + padding-top: unset; + padding-bottom: unset; + margin-left: 8px; + display: flex; + flex-flow: row wrap; + } + } + } +} + +::ng-deep table { + font-family: "Avenir Next Pro Regular", "Helvetica Neue", sans-serif; + font-size: 14px; +} + +tbody mat-icon { + /* Prevent icon from increasing tbody height */ + max-height: 1.3em; + margin: -0.3em; +} + +@keyframes skeleton-loading { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 2634b24de..5ae971240 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -17,14 +17,9 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { AfterViewInit, Component, DoCheck, NgZone, OnInit, ViewChild, Renderer2, ChangeDetectorRef, ElementRef } from '@angular/core'; -import { - CanvasTableSelectListener, CanvasTableComponent, - CanvasTableContainerComponent -} from './canvastable/canvastable'; +import { AfterViewInit, Component, DoCheck, NgZone, OnInit, ViewChild, Renderer2, ChangeDetectorRef, ElementRef, HostListener } from '@angular/core'; import { SingleMailViewerComponent } from './mailviewer/singlemailviewer.component'; import { SearchService } from './xapian/searchservice'; -import { PostMessageAction } from './xapian/messageactions'; import { MatLegacyDialogRef as MatDialogRef, MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; import { MatIconRegistry } from '@angular/material/icon'; @@ -43,11 +38,11 @@ import { DraftDeskService } from './compose/draftdesk.service'; import { RMM7MessageActions } from './mailviewer/rmm7messageactions'; import { FolderListComponent, CreateFolderEvent, RenameFolderEvent, MoveFolderEvent } from './folder/folder.module'; import { SimpleInputDialog, SimpleInputDialogParams } from './dialog/dialog.module'; -import { map, take, skip, mergeMap, filter, tap, throttleTime, debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { map, mergeMap, filter, tap, debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { WebSocketSearchService } from './websocketsearch/websocketsearch.service'; import { WebSocketSearchMailList } from './websocketsearch/websocketsearchmaillist'; -import { from, Observable } from 'rxjs'; +import { from, Observable, BehaviorSubject, firstValueFrom } from 'rxjs'; import { xapianLoadedSubject } from './xapian/xapianwebloader'; import { SwPush } from '@angular/service-worker'; import { exportKeysFromJWK } from './webpush/vapid.tools'; @@ -66,6 +61,9 @@ import { UsageReportsService } from './common/usage-reports.service'; import { objectEqualWithKeys } from './common/util'; import { UpdateAlertService } from './updatealert/updatealert.service'; import { UpdateAlertComponent } from './updatealert/updatealert.component'; +import { FilterSelectionModel } from './models/filter-selection-model'; +import { BindableSelectionModel } from './models/bindable-selection-model'; +import { Direction } from './sort-button/sort-button.component'; const LOCAL_STORAGE_SETTING_MAILVIEWER_ON_RIGHT_SIDE_IF_MOBILE = 'mailViewerOnRightSideIfMobile'; const LOCAL_STORAGE_SETTING_MAILVIEWER_ON_RIGHT_SIDE = 'mailViewerOnRightSide'; @@ -81,12 +79,39 @@ const TOOLBAR_LIST_BUTTON_WIDTH = 30; // eslint-disable-next-line @angular-eslint/component-selector selector: 'app', styleUrls: ['app.component.scss'], - templateUrl: 'app.component.html' + templateUrl: 'app.component.html', }) -export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectListener, DoCheck { - showSelectOperations: boolean; +export class AppComponent implements OnInit, AfterViewInit, DoCheck { showSelectMarkOpMenu: boolean; + + rows = []; + + private rowsSubject= new BehaviorSubject(this.rows); + debouncedRows = this.rowsSubject.asObservable().pipe(debounceTime(300)); + + lastCheckedIndex = -1; + scrollToIndex = new BehaviorSubject(0); + rowSelectionModel = new FilterSelectionModel( + false, + [], + false, + messagesEqual, + hasId + ); + rowsSelectionModel = new FilterSelectionModel( + true, + [], + false, + messagesEqual, + hasId + ); + orderSelectionModel = new BindableSelectionModel( + false, + [], + true, + ) + lastSearchText = ''; searchText = ''; dataReady = false; @@ -103,6 +128,8 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis localSearchIndexPrompted = false; offerInitialLocalIndex = false; + dragEvent: DragEvent | null = null + indexDocCount = 0; entireHistoryInProgress = false; @@ -137,14 +164,12 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis @ViewChild(SingleMailViewerComponent) singlemailviewer: SingleMailViewerComponent; @ViewChild(FolderListComponent) folderListComponent: FolderListComponent; - @ViewChild(CanvasTableContainerComponent, { static: true }) canvastablecontainer: CanvasTableContainerComponent; @ViewChild(MatSidenav) sidemenu: MatSidenav; @ViewChild('toolbarListButtonContainer') toolbarListButtonContainer: ElementRef; sideMenuOpened = true; hasChildRouterOutlet = false; - canvastable: CanvasTableComponent; fragment: string; jumpToFragment = false; @@ -153,6 +178,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis messageActionsHandler: RMM7MessageActions = new RMM7MessageActions(); + dynamicSearchFieldPlaceHolder: string; numHistoryChunksProcessed = 0; @@ -164,6 +190,18 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis xapianLoaded = xapianLoadedSubject; morelistbuttonindex = 7; + renderedRange = {start: 0, end: 0}; // First ten messages. + + widths = {}; + sort = { + sortColumn: 2, + sortDescending: true + }; + messageTable = { + rows: null, + hasChanges: true, + showContentTextPreview: true, + }; constructor( public searchService: SearchService, @@ -193,21 +231,20 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis private usage: UsageReportsService, public updateService: UpdateAlertService, ) { - this.hotkeysService.add( - new Hotkey(['j', 'k'], - (event: KeyboardEvent, combo: string): ExtendedKeyboardEvent => { - if (combo === 'k') { - this.canvastable.scrollUp(); - combo = null; - } - if (combo === 'j') { - this.canvastable.scrollDown(); - } - const e: ExtendedKeyboardEvent = event; - e.returnValue = false; - return e; - }) - ); + this.orderSelectionModel.selectionModel.changed.subscribe(() => { + const {data: column, direction} = this.orderSelectionModel.selected + + if (direction === Direction.None) { + this.sort.sortColumn = 2; + this.sort.sortDescending = true; + } else { + this.sort.sortColumn = column; + this.sort.sortDescending = Direction.Descending === direction; + } + + this.updateSearch(true) + }) + this.hotkeysService.add( new Hotkey( 'up up down down left right left right b a', @@ -238,21 +275,19 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis if (evt.code === 'ArrowUp') { // slightly ugly as we need to call *this* rowSelected, not // the cvtable one - const newRowIndex = this.canvastable.rows.openedRowIndex - 1; + const newRowIndex = this.messageTable.rows.openedRowIndex - 1; if (newRowIndex >= 0) { this.rowSelected(newRowIndex, 3, false); - this.canvastable.scrollUp(); - this.canvastable.hasChanges = true; + this.messageTable.hasChanges = true; evt.preventDefault(); } } else if (evt.code === 'ArrowDown') { // slightly ugly as we need to call *this* rowSelected, not // the cvtable one - const newRowIndex = this.canvastable.rows.openedRowIndex + 1; - if (newRowIndex < this.canvastable.rows.rowCount()) { + const newRowIndex = this.messageTable.rows.openedRowIndex + 1; + if (newRowIndex < this.messageTable.rows.rowCount()) { this.rowSelected(newRowIndex, 3, false); - this.canvastable.scrollDown(); - this.canvastable.hasChanges = true; + this.messageTable.hasChanges = true; evt.preventDefault(); } } @@ -270,7 +305,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.setMessageDisplay('websocketlist', results); this.showingWebSocketSearchResults = true; } - this.resetColumns(); }); this.sideMenuOpened = (mobileQuery.screenSize === ScreenSize.Desktop ? true : false); @@ -291,15 +325,13 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.mailViewerRightSideWidth = '100%'; this.mailViewerOnRightSide = this.preferences.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SETTING_MAILVIEWER_ON_RIGHT_SIDE_IF_MOBILE}`); } - console.log(this.mailViewerOnRightSide); }); preferenceService.preferences.subscribe((prefs) => { // message list prefs - if (this.canvastable) { - this.canvastable.showContentTextPreview = prefs.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; - this.canvastable.columnWidths = prefs.get(`${this.preferenceService.prefGroup}:canvasNamedColumnWidthsBySet`) || {}; + if (this.messageTable) { + this.messageTable.showContentTextPreview = prefs.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; } this.keepMessagePaneOpen = prefs.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_KEEP_PANE}`) === 'true'; this.unreadMessagesOnlyCheckbox = prefs.get(`${DefaultPrefGroups.Global}:${LOCAL_STORAGE_SHOW_UNREAD_ONLY}`) === 'true'; @@ -344,6 +376,10 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis : 0; } + get showSelectOperations() { + return !this.rowsSelectionModel.isEmpty() + } + ngDoCheck(): void { if (!this.usewebsocketsearch && this.searchService.api && this.xapianDocCount) { this.dynamicSearchFieldPlaceHolder = 'Start typing to search ' + @@ -359,17 +395,16 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.calculateWidthDependentElements(); } - ngOnInit(): void { - this.canvastable = this.canvastablecontainer.canvastable; + async ngOnInit() { + await firstValueFrom(this.xapianLoaded); + if (this.preferences.has(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`)) { - this.canvastable.showContentTextPreview = this.preferences.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; + this.messageTable.showContentTextPreview = this.preferences.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; } - if (this.preferences.has(`${this.preferenceService.prefGroup}:canvasNamedColumnWidthsBySet`)) { - this.canvastable.columnWidths = this.preferences.get(`${this.preferenceService.prefGroup}:canvasNamedColumnWidthsBySet`) || {}; + this.orderSelectionModel.selected = { + data: 2, + direction: Direction.Descending } - this.canvastablecontainer.sortColumn = 2; - this.canvastablecontainer.sortDescending = true; - this.resetColumns(); this.messagelistservice.messagesInViewSubject.subscribe(res => { this.messagelist = res; @@ -381,10 +416,8 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.setMessageDisplay('messagelist', this.messagelist); if (this.jumpToFragment && res.length > 0) { this.selectMessageFromFragment(this.fragment); - this.canvastable.jumpToOpenMessage(); this.jumpToFragment = false; } - this.canvastable.hasChanges = true; } }); @@ -401,22 +434,9 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis .pipe(map((folders: FolderListEntry[]) => folders.filter(f => f.folderPath.indexOf('Drafts') !== 0)) ); - this.canvastable.scrollLimitHit.subscribe((limit) => - this.messagelistservice.requestMoreData(limit) - ); - - this.canvastable.canvasResizedSubject.pipe( - filter(widthChanged => widthChanged === true), - debounceTime(20) - ).subscribe(() => - this.autoAdjustColumnWidths() - ); - this.route.fragment.subscribe( fragment => { if (!fragment) { - // This also runs when we load '/compose' .. but doesnt need to - this.switchToFolder('Inbox'); if (this.singlemailviewer) { this.singlemailviewer.close(); } @@ -427,8 +447,8 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis if (fragment !== this.fragment) { this.fragment = fragment; this.selectMessageFromFragment(this.fragment); - if (this.canvastable.rows && this.canvastable.rows.rowCount() > 0) { - this.canvastable.jumpToOpenMessage(); + if (this.messageTable.rows && this.messageTable.rows.rowCount() > 0) { + return } else { this.jumpToFragment = true; } @@ -446,6 +466,10 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis }); } }); + + if (!this.selectedFolder && this.router.url === '/') { + this.switchToFolder('Inbox'); + } } ngAfterViewInit() { @@ -468,35 +492,12 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.overviewSelected = this.router.url === '/overview'; }); - // Download visible messages in the background - this.canvastable.repaintDoneSubject.pipe( - filter(() => !this.canvastable.isScrollInProgress()), - throttleTime(1000) - ).subscribe(() => { - const rowIndexes = this.canvastable.getVisibleRowIndexes(); - const messageIds = rowIndexes.filter( - idx => idx < this.canvastable.rows.rowCount() - ).map(idx => this.canvastable.rows.getRowMessageId(idx)); - // FIXME: promise errors? - this.rmmapi.downloadMessages(messageIds).then( - (messages) => { - const updateWorker = new Map(); - for (const msg of messages) { - this.searchService.updateMessageText(msg['mid']); - updateWorker.set(msg['mid'], msg.text.text); - } - // Send to the messageCache in the worker, so we can add the text to the index: - if(updateWorker.size > 0) { - this.searchService.indexWorker.postMessage({'action': PostMessageAction.messageCache, 'updates': updateWorker }); - this.canvastable.hasChanges = true; - } - }); - }); - if ('serviceWorker' in navigator) { try { Notification.requestPermission(); - } catch (e) {} + } catch (e) { + console.error(e) + } } this.subscribeToNotifications(); @@ -513,9 +514,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis const [folder, msgId] = fragmentTarget; this.switchToFolder(folder); if (msgId === null) { - if (this.singlemailviewer) { - this.singlemailviewer.close(); - } + this.singlemailviewer?.close(); } if (msgId != null && this.singlemailviewer && this.singlemailviewer.messageId !== msgId) { this.selectRowByMessageId(msgId); @@ -608,7 +607,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis async emptyTrash(trashFolder: FolderListEntry) { console.log('found trash folder with name', trashFolder.folderName); - this.messageActionsHandler.updateMessages({ + await this.updateMessages({ messageIds: [], updateLocal: (msgIds: number[]) => { this.messagelistservice.pretendEmptyTrash(); @@ -628,8 +627,8 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis true, spamFolderName ).toPromise(); - const messageIds = messageLists.map(msg => msg.id); - this.messageActionsHandler.updateMessages({ + const messageIds = messageLists.map(idValue); + await this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => this.messagelistservice.moveMessages(msgIds, this.messagelistservice.trashFolderName), updateRemote: (msgIds: number[]) => @@ -654,7 +653,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis await this.draftDeskService.newBugReport( this.searchService.localSearchActivated, this.keepMessagePaneOpen, - this.canvastable.showContentTextPreview, + this.messageTable.showContentTextPreview, this.mailViewerOnRightSide, this.unreadMessagesOnlyCheckbox, this.mobileQuery.matches @@ -679,7 +678,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } saveContentPreviewSetting(): void { - const setting = this.canvastable.showContentTextPreview ? 'true' : 'false'; + const setting = this.messageTable.showContentTextPreview ? 'true' : 'false'; this.preferenceService.set(this.preferenceService.prefGroup, LOCAL_STORAGE_SHOWCONTENTPREVIEW, setting); // localStorage.setItem(LOCAL_STORAGE_SHOWCONTENTPREVIEW, setting); } @@ -687,17 +686,17 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public trainSpam(params) { const msg = params.is_spam ? 'Reporting spam' : 'Reporting not spam'; this.snackBar.open( msg, 'Dismiss' ); - const unfilteredMessageIds = this.canvastable.rows.selectedMessageIds(); + const unfilteredMessageIds = this.selectedMessageIds; // ensure valid IDs const messageIds = unfilteredMessageIds.filter(id => Number.isInteger(id)); - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { // Move to spam folder (delete from index), set spam flag if (params.is_spam) { // remove from message display - this.canvastable.rows.removeMessages(messageIds); + this.messageTable.rows.removeMessages(messageIds); this.searchService.deleteMessages(msgIds); this.messagelistservice.moveMessages(msgIds, this.messagelistservice.spamFolderName, true); } else { @@ -757,9 +756,9 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public setReadStatus(status: boolean) { this.snackBar.open('Toggling read status...'); - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds; - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { msgIds.forEach( (id) => { @@ -768,7 +767,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis new MessageFlagChange(id, status, null) ); }); - this.clearSelection(); if (this.singlemailviewer && messageIds.find((id) => id === this.singlemailviewer.messageId)) { this.singlemailviewer.mailObj.seen_flag = status ? 1 : 0; } @@ -786,9 +784,9 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public setFlaggedStatus(status: boolean) { this.snackBar.open('Toggling flags...'); - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds; - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { msgIds.forEach( (id) => { @@ -797,7 +795,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis new MessageFlagChange(id, null, status) ); }); - this.clearSelection(); if (this.singlemailviewer && messageIds.find((id) => id === this.singlemailviewer.messageId)) { this.singlemailviewer.mailObj.flagged_flag = status ? 1 : 0; } @@ -816,13 +813,13 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis // Delete selected messages in current canvastable view // If looking at Trash, this will be "delete permanently" public deleteMessages() { - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds; - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { // remove from message display - this.canvastable.rows.removeMessages(messageIds); + this.messageTable.rows.removeMessages(messageIds); this.searchService.deleteMessages(msgIds); if (this.selectedFolder === this.messagelistservice.trashFolderName) { this.messagelistservice.deleteTrashMessages(msgIds); @@ -841,8 +838,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public deleteLocalIndex() { if (this.searchService.localSearchActivated || this.dataReady) { this.usewebsocketsearch = true; - this.canvastable.topindex = 0; - this.canvastable.rows = null; + this.messageTable.rows = null; this.viewmode = 'messages'; this.conversationGroupingCheckbox = this.viewmode === 'conversations'; this.preferenceService.set(this.preferenceService.prefGroup, LOCAL_STORAGE_VIEWMODE, this.viewmode); @@ -850,8 +846,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.showingSearchResults = false; this.searchText = ''; - this.resetColumns(); - this.usage.report('local-index-deleted'); this.searchService.deleteLocalIndex().subscribe(() => { @@ -865,122 +859,116 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } } + setMessageTableRows(newList) { + this.messageTable.rows.setRows(newList); + this.messageTable.hasChanges = true; + } + public setMessageDisplay(displayType: string, ...args) { if (displayType === 'search') { - if (this.canvastable.rows instanceof SearchMessageDisplay) { - this.canvastable.updateRows(args[1]); + if (this.messageTable.rows instanceof SearchMessageDisplay) { + this.setMessageTableRows(args[1]); } else { - this.canvastable.rows = new SearchMessageDisplay(...args); + this.messageTable.rows = new SearchMessageDisplay(...args); // messages updated, check if we need to select a message from the fragment this.selectMessageFromFragment(this.fragment); } } if (displayType === 'messagelist') { - if (this.canvastable.rows instanceof MessageList) { - this.canvastable.updateRows(args[0]); + if (this.messageTable.rows instanceof MessageList) { + this.setMessageTableRows(args[0]); } else { - this.canvastable.rows = new MessageList(...args); + this.messageTable.rows = new MessageList(...args); // messages updated, check if we need to select a message from the fragment this.selectMessageFromFragment(this.fragment); } } if (displayType === 'websocketlist') { - if (this.canvastable.rows instanceof WebSocketSearchMailList) { - this.canvastable.updateRows(args[0]); + if (this.messageTable.rows instanceof WebSocketSearchMailList) { + this.setMessageTableRows(args[0]); } else { - this.canvastable.rows = new WebSocketSearchMailList(...args); + this.messageTable.rows = new WebSocketSearchMailList(...args); // messages updated, check if we need to select a message from the fragment this.selectMessageFromFragment(this.fragment); } } - this.filterMessageDisplay(); - // FIXME: looks weird, should probably rename "rows" to "messagedisplay" - // in canvastable, and anyway get CV to just read the columns itself - // "this" so we can check selectedFolder (FIXME: improve!) - // parts like app.selectedFolder.indexOf('Sent') === 0 etc are - // why we have resetColumns scattered everywhere, if canvas just called getCTC whenever it did a paint, we wouldnt need to? - // would that slow things down? - // NB this triggers hasChanged for us and forces a redraw - this.canvastable.columns = this.canvastable.rows.getCanvasTableColumns(this); + this.filterMessageDisplay(); + this.updateRows() } public filterMessageDisplay() { - if (this.canvastable.rows && this.canvastable.rows.rowCount() > 0) { + if (this.messageTable.rows && this.messageTable.rows.rowCount() > 0) { const options = new Map(); options.set('unreadOnly', this.unreadMessagesOnlyCheckbox); options.set('searchText', this.searchText); - this.canvastable.rows.filterBy(options); - this.canvastable.hasChanges = true; + this.messageTable.rows.filterBy(options); + this.messageTable.hasChanges = true; } } public clearSelection() { - if (this.canvastable.rows) { - this.canvastable.rows.clearSelection(); - } - this.canvastable.hasChanges = true; - this.showSelectOperations = false; - this.showSelectMarkOpMenu = false; + this.rowsSelectionModel.clear() } public selectRowByMessageId(messageId: number) { - const matchingRowIndex = this.canvastable.rows.findRowByMessageId(messageId); + const matchingRowIndex = this.messageTable.rows.findRowByMessageId(messageId); if (matchingRowIndex > -1) { + this.rowSelectionModel.select({id: messageId}); this.rowSelected(matchingRowIndex, 1, false); - } else { - this.singlemailviewer.close(); - } + } } public rowSelected(rowIndex: number, columnIndex: number, multiSelect?: boolean) { const isSelect = (columnIndex === 0) || multiSelect + const shouldScroll = !this.singlemailviewer.messageId + + this.rowSelectionModel.select(this.rows[rowIndex]) + this.lastCheckedIndex = rowIndex + + if (shouldScroll) { + this.scrollToIndex.next(rowIndex - 1); + } if ((this.selectedFolder === this.messagelistservice.templateFolderName) && !isSelect) { this.draftDeskService.newTemplateDraft( - this.canvastable.rows.getRowMessageId(rowIndex) + this.messageTable.rows.getRowMessageId(rowIndex) ); this.drafts(); return; } - this.canvastable.rows.rowSelected(rowIndex, columnIndex, multiSelect); - this.showSelectOperations = this.canvastable.rows.anySelected(); + this.messageTable.rows.rowSelected(rowIndex, columnIndex, multiSelect); - if (this.canvastable.rows.hasChanges) { - this.updateUrlFragment(this.canvastable.rows.getRowMessageId(rowIndex)); - this.singlemailviewer.messageId = this.canvastable.rows.getRowMessageId(rowIndex); + if (this.messageTable.rows.hasChanges) { + this.updateUrlFragment(this.messageTable.rows.getRowMessageId(rowIndex)); + this.singlemailviewer.messageId = this.messageTable.rows.getRowMessageId(rowIndex); if (!this.mobileQuery.matches && !this.messageSubjectDragTipShown) { this.snackBar.open('Tip: Drag subject to a folder to move message(s)' , 'Got it'); this.preferenceService.set(DefaultPrefGroups.Global, 'messageSubjectDragTipShown', 'true'); } // FIXME: [2] is searchservice specific! - if (this.viewmode === 'conversations' && this.canvastable.rows.getCurrentRow()[2] !== '1') { + + if (this.viewmode === 'conversations' && this.messageTable.rows.getCurrentRow()[2] !== '1') { this.viewmode = 'singleconversation'; - this.resetColumns(); this.clearSelection(); // FIXME [0] is searchservice specific! const conversationId = - this.searchService.api.getStringValue(this.canvastable.rows.getCurrentRow()[0], 1) + this.searchService.api.getStringValue(this.messageTable.rows.getCurrentRow()[0], 1) .replace(/[^0-9A-Z]/g, '_'); this.conversationSearchText = 'conversation:' + conversationId + '..' + conversationId; this.updateSearch(true); } - this.canvastable.hasChanges = true; + this.messageTable.hasChanges = true; } } - // CanvasTableSelectListener, columnWidths changed: - saveColumnWidthsPreference(widths: any) { - this.preferenceService.set(this.preferenceService.prefGroup, 'canvasNamedColumnWidthsBySet', widths); - } - updateTime() { const time = new Date(); const hour = time.getHours(); @@ -1066,15 +1054,11 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.offerInitialLocalIndex = false; } - singleMailViewerClosed(action: string): void { - this.canvastable.rows.clearOpenedRow(); + singleMailViewerClosed(): void { + this.messageTable.rows.clearOpenedRow(); this.updateUrlFragment(); } - updateMessageListHeight() { - this.canvastable.jumpToOpenMessage(); - } - searchTextFieldFocus() { if (!this.usewebsocketsearch && !this.dataReady) { this.usewebsocketsearch = true; @@ -1095,10 +1079,23 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } } + onMessagesDragStart(event: DragEvent, row) { + + // If no messages are selected we'll select the current message + if (this.rowsSelectionModel.isEmpty()) { + this.rowsSelectionModel.select(row) + } + + // Remove the default image + event.dataTransfer?.setDragImage(new Image(), 0, 0); // Set an empty image + + this.dragEvent = event + } + dropToFolder(folderId): void { - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { const folders = this.messagelistservice.folderListSubject.value; @@ -1108,7 +1105,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis // moveMessagesToFolder cant see these cos not in index if (this.messagelistservice.unindexedFolders.includes(this.selectedFolder)) { // remove from current message display - this.canvastable.rows.removeMessages(messageIds); + this.messageTable.rows.removeMessages(messageIds); this.searchService.moveMessagesToFolder(msgIds, folderPath); } this.messagelistservice.moveMessages(msgIds, folderPath); @@ -1128,13 +1125,12 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public moveToFolder() { const dialogRef: MatDialogRef = this.dialog.open(MoveMessageDialogComponent); // dialogRef.componentInstance.messageActionsHandler = this.messageActionsHandler; - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds; - console.log('selected messages', messageIds); // dialogRef.componentInstance.selectedMessageIds = messageIds; dialogRef.afterClosed().subscribe(folder => { if (folder) { - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { const folders = this.messagelistservice.folderListSubject.value; @@ -1145,7 +1141,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis if (this.selectedFolder !== this.messagelistservice.spamFolderName && this.selectedFolder !== this.messagelistservice.trashFolderName) { // remove from current message display - this.canvastable.rows.removeMessages(messageIds); + this.messageTable.rows.removeMessages(messageIds); this.searchService.moveMessagesToFolder(msgIds, folderPath); } this.messagelistservice.moveMessages(msgIds, folderPath); @@ -1171,11 +1167,15 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis if (viewmode !== 'singleconversation') { this.conversationSearchText = null; } - this.resetColumns(); this.updateSearch(true); } } + onFolderSelect(folder: string) { + this.scrollToIndex.next(0); + this.selectFolder(folder) + } + selectFolder(folder: string): void { if (this.mobileQuery.matches && this.sidemenu.opened) { this.sidemenu.close(); @@ -1194,29 +1194,13 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis console.log('Change selectedFolder'); this.clearSelection(); - let doResetColumns = false; - if (folder.startsWith('Sent') || this.selectedFolder?.startsWith('Sent')) { - doResetColumns = true; - } - this.selectedFolder = folder; - // FIXME: fairly sure this is redundant, the messageDisplay setting - // in the subscribe in ngInit should do it for us - this.messagelistservice.messagesInViewSubject - .pipe( - skip(1), - take(1) - ).subscribe(() => - // Reset columns after folder list is updated - this.resetColumns() - ); this.messagelistservice.setCurrentFolder(folder); if (this.viewmode === 'singleconversation') { this.viewmode = 'conversations'; this.conversationSearchText = undefined; - doResetColumns = true; } if (this.hasChildRouterOutlet) { @@ -1224,23 +1208,10 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } setTimeout(() => { - if (doResetColumns) { - this.resetColumns(); - } this.updateSearch(true); - this.canvastable.scrollTop(); }, 0); } - resetColumns() { - if (this.canvastable && this.canvastable.rows) { - this.canvastable.columns = this.canvastable.rows.getCanvasTableColumns(this); - } - this.canvastable.rowWrapModeWrapColumn = 3; - this.canvastable.rowWrapModeDefaultSelectedColumn = 3; - this.autoAdjustColumnWidths(); - } - showSaveSearchDialog(): void { const dialog = this.dialog.open(SimpleInputDialog, { data: new SimpleInputDialogParams( @@ -1299,7 +1270,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis */ if (this.showingSearchResults) { this.showingSearchResults = false; - this.resetColumns(); } this.setMessageDisplay('messagelist', this.messagelist); @@ -1342,7 +1312,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis if (!this.showingSearchResults || this.displayFolderColumn !== previousDisplayFolderColumn) { this.showingSearchResults = true; - this.resetColumns(); } if (querytext.match(/date:/) && querytext.match(/\.\./)) { @@ -1355,8 +1324,8 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.ngZone.runOutsideAngular(() => { searchResults = this.searchService.api.sortedXapianQuery( querytext, - this.canvastablecontainer.sortColumn, - this.canvastablecontainer.sortDescending ? 1 : 0, 0, 50000, + this.sort.sortColumn, + this.sort.sortDescending ? 1 : 0, 0, 50000, this.viewmode === 'conversations' ? 1 : -1 ); }); @@ -1367,11 +1336,10 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.searchResultsCount = searchResults.length; if (searchResults) { this.setMessageDisplay('search', this.searchService, searchResults); - if (!noscroll) { - this.canvastable.scrollTop(); - } } - } catch (e) { } + } catch (e) { + console.error(e) + } } } @@ -1396,16 +1364,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis setTimeout(() => this.singlemailviewer.messageId = currentMessageId, 0); } - horizScroll(evt: any) { - this.canvastable.horizScroll = evt.target.scrollLeft; - } - - autoAdjustColumnWidths() { - setTimeout(() => - this.canvastable.autoAdjustColumnWidths(40, true), 0 - ); - } - promptLocalSearch() { console.log('promptLocalSearch'); this.rmmapi.me.pipe( @@ -1437,11 +1395,146 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis fragment += `:${messageId}`; } - // navigating to the same page does not fire off our fragment.subscribe - if (fragment !== this.fragment) { - this.fragment = fragment; - this.router.navigate(['/'], { fragment }); + this.router.navigate(['/'], { fragment }); + } + + get selectedMessageIds() { + return this.rowsSelectionModel.isEmpty() + ? this.rowSelectionModel.selected.map(idValue) + : this.rowsSelectionModel.selected.map(idValue) + } + + updateRows() { + this.rows = this.messageTable?.rows?.rows ? [...this.messageTable.rows.rows] : [] + + return this.enrichRows() + } + + async enrichRows() { + if (!this.messageTable.rows) return; + + const { start, end } = this.renderedRange; + + for (let index = start; index < end; index++) { + if (index >= this.rows.length) break + + this.rows[index] = this.messageTable.rows.getRowData(index, this) + this.rows[index].plaintext = this.searchService.messageText(this.rows[index].id) + this.rows[index].loaded = true + } + + this.rows = Object.create(this.rows) + + this.rowsSubject.next(this.rows) + } + + rangeSelectFrom(fromIndex: number, to: number, check: boolean) { + const left = Math.min(fromIndex, to) + const right = Math.max(fromIndex, to) + + for (let i = left; i <= right; i++) { + if (check) { + this.rowsSelectionModel.select(this.rows[i]) + } else { + this.rowsSelectionModel.deselect(this.rows[i]) + } + } + + this.lastCheckedIndex = to + + } + + onCheckboxClick(event, row, index) { + this.onRowClick(event, row, index, true) + event.stopPropagation() + event.preventDefault() + } + + rangeSelect(to: number, check: boolean) { + const fromIndex = this.lastCheckedIndex; + + // When nothing is selected yet. + if (fromIndex === -1) return this.oneSelect(to, check) + + return this.rangeSelectFrom(fromIndex, to, check) + } + + oneSelect(index, check) { + this.rangeSelectFrom(index, index, check) + } + + onRowClick(event, row, index, checkbox = false) { + const shiftKey = event.getModifierState('Shift') + const check = !this.rowsSelectionModel.isSelected(this.rows[index]) + + if (shiftKey) { + return this.rangeSelect(index, check) + } + + const ctrlKey = event.getModifierState('Control') + const metaKey = event.getModifierState('Meta') + + if (ctrlKey || metaKey) { + return this.oneSelect(index, check) } + + if (!checkbox) { + // Deselect an email when clicking on a selected email. + if (this.rowSelectionModel.isSelected(this.rows[index])) { + this.singlemailviewer.messageId = null; + this.rowSelectionModel.clear() + return this.singleMailViewerClosed() + } + + return this.rowSelected(index, 3, false); + } + + this.oneSelect(index, check) + } + + onRowKeydown(event, row, index) { + // Only work on Enter and space. + if (event.key !== 'Enter') return; + + return this.onRowClick(event, row, index) + } + + onAllCheckboxChange() { + if (this.rowsSelectionModel.isEmpty()) { + this.rowsSelectionModel.select(...this.rows); + } else { + this.rowsSelectionModel.deselect(...this.rows); + } + } + + get allItemsSelected() { + return this.rowsSelectionModel.selected.length === this.rows.length + } + + @HostListener('document:dragend', ['$event']) + onDragEnded() { + delete this.dragEvent; + } + + onTableResize() { + this.widths = {}; + } + + // TODO: The this.rows can change after a onRenderedRangeChange is called. + // This will drop the resolved values. + onRenderedRangeChange(event) { + this.renderedRange = event; + this.enrichRows() + } + + async updateMessages(args) { + await this.messageActionsHandler.updateMessages(args); + setTimeout(() => { + this.updateSearch(true); + }, 1000); } } +const idValue = (x: any) => x.id +const messagesEqual = (a: any, b: any) => a?.id === b?.id +const hasId = (x: any) => Boolean(x?.id) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bced66edb..ad9dc8049 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -34,6 +34,7 @@ import { ContactsService } from './contacts-app/contacts.service'; import { StorageService } from './storage.service'; import { RouterModule, Routes } from '@angular/router'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button'; +import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card'; import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox'; @@ -51,7 +52,7 @@ import { MatSidenavModule } from '@angular/material/sidenav'; import { MatLegacySnackBarModule as MatSnackBarModule } from '@angular/material/legacy-snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip'; -import { CanvasTableModule } from './canvastable/canvastable'; +import { VirtualScrollTableComponent } from './virtual-scroll-table/virtual-scroll-table.component' import { MoveMessageDialogComponent } from './actions/movemessage.action'; import { RunboxWebmailAPI } from './rmmapi/rbwebmail'; import { RMMOfflineService } from './rmmapi/rmmoffline.service'; @@ -88,7 +89,12 @@ import { SavedSearchesService } from './saved-searches/saved-searches.service'; import { HelpComponent } from './help/help.component'; import { HelpModule } from './help/help.module'; import { DomainRegisterRedirectComponent } from './domainregister/domreg-redirect.component'; - +import { HumanBytesPipe } from './human-bytes.pipe'; +import { FollowsMouseComponent } from './follows-mouse/follows-mouse.component'; +import { DatePipe } from '@angular/common'; +import { ResizableButtonComponent } from './resizable-button/resizable-button.component'; +import { SortButtonComponent } from './sort-button/sort-button.component'; +import { ResizeObserverDirective } from './directives/resize-observer.directive'; window.addEventListener('dragover', (event) => event.preventDefault()); window.addEventListener('drop', (event) => event.preventDefault()); @@ -140,10 +146,17 @@ const routes: Routes = [ ]; @NgModule({ - imports: [BrowserModule, FormsModule, + imports: [ + BrowserModule, + FormsModule, + ResizeObserverDirective, + DatePipe, + ResizableButtonComponent, + SortButtonComponent, + MatBadgeModule, HttpClientModule, + VirtualScrollTableComponent, HttpClientJsonpModule, - CanvasTableModule, ComposeModule, StartDeskModule, WelcomeDeskModule, @@ -180,10 +193,14 @@ const routes: Routes = [ RunboxCommonModule, RouterModule.forRoot(routes), ServiceWorkerModule.register('/app/ngsw-worker.js', { enabled: environment.production }), - HotkeyModule.forRoot() + HotkeyModule.forRoot(), + HumanBytesPipe, + FollowsMouseComponent ], exports: [], - declarations: [MainContainerComponent, AppComponent, + declarations: [ + MainContainerComponent, + AppComponent, MoveMessageDialogComponent, PopularRecipientsComponent, SavedSearchesComponent, diff --git a/src/app/canvastable/canvastable.component.html b/src/app/canvastable/canvastable.component.html deleted file mode 100644 index f6c53aa7e..000000000 --- a/src/app/canvastable/canvastable.component.html +++ /dev/null @@ -1,17 +0,0 @@ - -
-
diff --git a/src/app/canvastable/canvastable.spec.ts b/src/app/canvastable/canvastable.spec.ts deleted file mode 100644 index ef51405f1..000000000 --- a/src/app/canvastable/canvastable.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -// --------- BEGIN RUNBOX LICENSE --------- -// Copyright (C) 2016-2022 Runbox Solutions AS (runbox.com). -// -// This file is part of Runbox 7. -// -// Runbox 7 is free software: You can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// Runbox 7 is distributed in the hope that it will be useful, but -// WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Runbox 7. If not, see . -// ---------- END RUNBOX LICENSE ---------- - -import { TestBed } from '@angular/core/testing'; -import { CanvasTableModule, CanvasTableContainerComponent } from './canvastable'; -import { MessageList } from '../common/messagelist'; - -describe('canvastable', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - CanvasTableModule - ] - }); - }); - - it('should activate draggable column overlay on mouseover', async () => { - const fixture = TestBed.createComponent(CanvasTableContainerComponent); - fixture.componentInstance.canvastableselectlistener = { - rowSelected: (rowIndex: number, colIndex: number, rowContent: any, multiSelect?: boolean): void => { - - }, - saveColumnWidthsPreference: (widths: any): void => { - } - }; - fixture.componentInstance.canvastable.columns = [ - { - name: 'Column1', - cacheKey: 'col1', - sortColumn: null, - getValue: (row) => row.col1, - width: 200 - }, - { - name: 'Column2', - cacheKey: 'col2', - sortColumn: null, - getValue: (row) => row.col2, - width: 200, - draggable: true - }, - ]; - fixture.componentInstance.canvastable.rows = new MessageList([ - { col1: 'subject1', col2: 'fld' }, - { col1: 'test', col2: 'hello' } - ]); - fixture.componentInstance.canvastable.rowWrapMode = false; - fixture.detectChanges(); - - fixture.componentInstance.canvastable.canvRef.nativeElement.dispatchEvent(new MouseEvent('mousemove', { - clientX: 270, - clientY: 50 - })); - - await new Promise(resolve => setTimeout(resolve, 500)); - fixture.detectChanges(); - expect(fixture.componentInstance.canvastable.floatingTooltip).toBeTruthy(); - expect(fixture.componentInstance.canvastable.columnOverlay).toBeTruthy(); - }); -}); diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts deleted file mode 100644 index 30dbe0895..000000000 --- a/src/app/canvastable/canvastable.ts +++ /dev/null @@ -1,1548 +0,0 @@ -// --------- BEGIN RUNBOX LICENSE --------- -// Copyright (C) 2016-2022 Runbox Solutions AS (runbox.com). -// -// This file is part of Runbox 7. -// -// Runbox 7 is free software: You can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// Runbox 7 is distributed in the hope that it will be useful, but -// WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Runbox 7. If not, see . -// ---------- END RUNBOX LICENSE ---------- - -/* - * Copyright 2010-2018 FinTech Neo AS / Runbox ( fintechneo.com / runbox.com )- All rights reserved - */ - - -import { - NgModule, Component, AfterViewInit, - Input, Output, Renderer2, - ElementRef, - DoCheck, NgZone, EventEmitter, OnInit, ViewChild -} from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button'; -import { MatLegacyMenuModule as MatMenuModule, MatLegacyMenuTrigger as MatMenuTrigger } from '@angular/material/legacy-menu'; -import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio'; -import { FormsModule } from '@angular/forms'; -import { MatIconModule } from '@angular/material/icon'; -import { MatLegacyTooltipModule as MatTooltipModule, MatLegacyTooltip as MatTooltip } from '@angular/material/legacy-tooltip'; -import { BehaviorSubject , Subject } from 'rxjs'; -import { MessageDisplay } from '../common/messagedisplay'; -import { CanvasTableColumn } from './canvastablecolumn'; -import { PreferencesService } from '../common/preferences.service'; - -const MIN_COLUMN_WIDTH = 40; - -const getCSSClassProperty = (className, propertyName) => { - const elementId = '_classPropertyLookup_' + className; - let element: HTMLSpanElement = document.getElementById(elementId); - if (!element) { - element = document.createElement('span'); - element.id = elementId; - element.className = className; - element.style.display = 'none'; - document.documentElement.appendChild(element); - } - return window.getComputedStyle(element, null).getPropertyValue(propertyName); -}; - -export interface CanvasTableSelectListener { - rowSelected(rowIndex: number, colIndex: number, multiSelect?: boolean): void; - saveColumnWidthsPreference(widths); -} - -export class FloatingTooltip { - constructor(public top: number, - public left: number, - public width: number, - public height: number, - public tooltipText: string) { - - } -} - -export namespace CanvasTable { - export enum RowSelect { - Visible = 'visible', - All = 'all', - } -} - -@Component({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: 'canvastable', - templateUrl: 'canvastable.component.html' -}) -export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { - static incrementalId = 1; - public elementId: string; - private _topindex = 0.0; - public get topindex(): number { return this._topindex; } - public set topindex(topindex: number) { - if (this._topindex !== topindex) { - this._topindex = topindex; - this.hasChanges = true; - } - } - - @ViewChild('thecanvas') canvRef: ElementRef; - - @Input() columnWidths = {}; - - @Output() columnresize = new EventEmitter(); - @Output() columnresizeend = new EventEmitter(); - @Output() columnresizestart = new EventEmitter(); - - @ViewChild(MatTooltip) columnOverlay: MatTooltip; - - repaintDoneSubject: Subject = new Subject(); - canvasResizedSubject: Subject = new Subject(); - - private canv: HTMLCanvasElement; - - private ctx: CanvasRenderingContext2D; - private wantedCanvasWidth = 300; - private wantedCanvasHeight = 300; - - private _rowheight = 28; - private fontheight = 14; - private fontheightSmall = 13; - private fontheightSmaller = 12; - - private scrollbarwidth = 12; - - public fontFamily = '"Avenir Next Pro Regular", "Helvetica Neue", sans-serif'; - public fontFamilyBold = '"Avenir Next Pro Medium", "Helvetica Neue", sans-serif'; - - private maxVisibleRows: number; - - private scrollBarRect: any; - - private isTouchZoom = false; - private scrollbarDragInProgress = false; - columnResizeInProgress = false; - private scrollbarArea = false; - - private jumpToMessage = false; - - visibleColumnSeparatorAlpha = 0; - visibleColumnSeparatorIndex = 0; - lastClientY: number; - - public _horizScroll = 0; - public get horizScroll(): number { return this._horizScroll; } - public set horizScroll(horizScroll: number) { - - if (this._horizScroll !== horizScroll) { - this._horizScroll = horizScroll; - this.hasChanges = true; - } - } - - // public _rows: any[] = []; - public _rows: MessageDisplay; - - columnWidthsDefaults = { - '': 40, - 'Date': 110, - 'To': 300, - 'From': 300, - 'Subject': 300, - 'Size': 80, - 'Count': 80, - }; - - public hasSortColumns = false; - public _columns: CanvasTableColumn[] = []; - public get columns(): CanvasTableColumn[] { return this._columns; } - public set columns(columns: CanvasTableColumn[]) { - if (this._columns !== columns) { - this.calculateColumnWidths(columns); - this._columns = columns; - this.hasSortColumns = columns.filter(col => col.sortColumn !== null).length > 0; - this.hasChanges = true; } - } - - // Colors retrieved from css classes - textColorLink: string = getCSSClassProperty('themePalettePrimary', 'color'); - selectedRowColor: string = getCSSClassProperty('themePaletteAccentLighter', 'color'); - openedRowColor: string = getCSSClassProperty('themePaletteLighterGray', 'color'); - hoverRowColor: string = getCSSClassProperty('themePaletteLightGray', 'color'); - textColor: string = getCSSClassProperty('themePaletteBlack', 'color'); - - - public colpaddingleft = 10; - public colpaddingright = 10; - public seprectextraverticalpadding = 4; // Extra padding above/below for separator rectangles - - private lastMouseDownEvent: MouseEvent; - private _hoverRowIndex: number; - private get hoverRowIndex(): number { return this._hoverRowIndex; } - private set hoverRowIndex(hoverRowIndex: number) { - if (this._hoverRowIndex !== hoverRowIndex) { - this._hoverRowIndex = hoverRowIndex; - this.hasChanges = true; - } - } - - private dragSelectionDirectionIsDown: boolean = null; - - // Auto row wrap mode (width based on iphone 5) - set to 0 to disable row wrap mode - public autoRowWrapModeWidth = 540; - - public rowWrapMode = true; - public rowWrapModeWrapColumn = 2; - public rowWrapModeDefaultSelectedColumn = 2; - - public _showContentTextPreview = false; - - public hasChanges: boolean; - - private formattedValueCache: { [key: string]: string; } = {}; - - public scrollLimitHit: BehaviorSubject = new BehaviorSubject(0); - - public floatingTooltip: FloatingTooltip; - - @Input() selectListener: CanvasTableSelectListener; - @Output() touchscroll = new EventEmitter(); - - touchScrollSpeedY = 0; - - // Are we selecting all rows, or just the visible ones? - public selectWhichRows = CanvasTable.RowSelect.Visible; - - constructor(elementRef: ElementRef, private renderer: Renderer2, private _ngZone: NgZone) { - } - - ngDoCheck() { - if (this.canv) { - - const devicePixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1; - this.wantedCanvasWidth = this.canv.parentElement.parentElement.clientWidth * devicePixelRatio; - this.wantedCanvasHeight = this.canv.parentElement.parentElement.clientHeight * devicePixelRatio; - - if (this.canv.width !== this.wantedCanvasWidth || this.canv.height !== this.wantedCanvasHeight) { - this.hasChanges = true; - } - } - } - - private calculateColumnWidths(columns: CanvasTableColumn[]) { - const colWidthSet = columns.map((col) => col.name).filter((cname) => cname.length > 0).join(','); - for (const c of columns) { - // try the stored settings, then an existing value, then 100px just in case - c.width = this.columnWidths[colWidthSet] - ? this.columnWidths[colWidthSet][c.name] - : this.columnWidthsDefaults[c.name] || c.width || 100; - } - } - - ngOnInit() { - this.calculateColumnWidths(this.columns); - } - - ngAfterViewInit() { - this.canv = this.canvRef.nativeElement; - this.ctx = this.canv.getContext('2d'); - - this.canv.onwheel = (event: WheelEvent) => { - event.preventDefault(); - switch (event.deltaMode) { - case 0: - // pixels - this.topindex += (event.deltaY / this.rowheight); - break; - case 1: - // lines - this.topindex += event.deltaY; - break; - case 2: - // pages - this.topindex += (event.deltaY * (this.canv.scrollHeight / this.rowheight)); - break; - } - - this.enforceScrollLimit(); - }; - - /** - * Returns true if clientX/Y is inside the scrollbar area and if wholeScrollbar specified then not just the draggable slider - * @param clientX - * @param clientY - * @param wholeScrollbar include whole scrollbar area, not just the draggable slider - */ - const checkIfScrollbarArea = (clientX: number, clientY: number, wholeScrollbar?: boolean): boolean => { - if (!this.scrollBarRect) { - return false; - } - const canvrect = this.canv.getBoundingClientRect(); - const x = clientX - canvrect.left; - const y = clientY - canvrect.top; - return x > this.scrollBarRect.x && x < (this.scrollBarRect.x + this.scrollBarRect.width) && - (wholeScrollbar || y > this.scrollBarRect.y && y < this.scrollBarRect.y + this.scrollBarRect.height); - }; - - const checkScrollbarDrag = (clientX: number, clientY: number) => { - - if (!this.scrollBarRect) { - return; - } - - const canvrect = this.canv.getBoundingClientRect(); - if (checkIfScrollbarArea(clientX, clientY)) { - this.scrollbarDragInProgress = true; - this.scrollbarArea = true; - } else if (checkIfScrollbarArea(clientX, clientY, true)) { - // Check if click is above or below scrollbar slider - - const y = clientY - canvrect.top; - if (y < this.scrollBarRect.y) { - // above - this.topindex -= this.canv.scrollHeight / this.rowheight; - } else { - // below - this.topindex += this.canv.scrollHeight / this.rowheight; - } - this.scrollbarArea = true; - } else { - this.scrollbarArea = false; - } - }; - - this.canv.onmousedown = (event: MouseEvent) => { - event.preventDefault(); - checkScrollbarDrag(event.clientX, event.clientY); - this.lastMouseDownEvent = event; - - if (this.visibleColumnSeparatorIndex > 0) { - this.columnresizestart.emit({ colindex: this.visibleColumnSeparatorIndex, clientx: event.clientX }); - } - - // Reset drag select direction - this.dragSelectionDirectionIsDown = null; - }; - - let previousTouchY: number; - let previousTouchX: number; - let touchMoved = false; - - this.canv.addEventListener('touchstart', (event: TouchEvent) => { - this.isTouchZoom = false; - - previousTouchX = event.targetTouches[0].clientX; - previousTouchY = event.targetTouches[0].clientY; - checkScrollbarDrag(event.targetTouches[0].clientX, event.targetTouches[0].clientY); - if (this.scrollbarDragInProgress) { - event.preventDefault(); - } - - touchMoved = false; - }); - - - this.canv.addEventListener('touchmove', (event: TouchEvent) => { - if (event.targetTouches.length > 1) { - this.isTouchZoom = true; - return; - } - event.preventDefault(); - touchMoved = true; - - if (event.targetTouches.length === 1) { - const newTouchY = event.targetTouches[0].clientY; - const newTouchX = event.targetTouches[0].clientX; - if (this.scrollbarDragInProgress === true) { - this.doScrollBarDrag(newTouchY); - } else { - - this.touchScrollSpeedY = (newTouchY - previousTouchY); - if (Math.abs(this.touchScrollSpeedY) > 0) { - this.hasChanges = true; - } - - if (!this.rowWrapMode) { - this.horizScroll -= (newTouchX - previousTouchX); - } - - previousTouchY = newTouchY; - previousTouchX = newTouchX; - } - this.enforceScrollLimit(); - this.touchscroll.emit(this.horizScroll); - } - - }, false); - - this.canv.addEventListener('touchend', (event: TouchEvent) => { - if (this.isTouchZoom) { - return; - } - event.preventDefault(); - if (!this.scrollbarArea && !touchMoved) { - this.selectRow(event.changedTouches[0].clientX, event.changedTouches[0].clientY); - } - if (this.scrollbarDragInProgress) { - this.scrollbarDragInProgress = false; - this.hasChanges = true; - } - }); - - this.renderer.listen('window', 'mousemove', (event: MouseEvent) => { - if (this.scrollbarDragInProgress === true) { - event.preventDefault(); - this.doScrollBarDrag(event.clientY); - } - }); - - this.canv.onmousemove = (event: MouseEvent) => { - if (this.scrollbarDragInProgress === true || this.columnResizeInProgress === true) { - event.preventDefault(); - return; - } - - const canvrect = this.canv.getBoundingClientRect(); - const clientX = event.clientX - canvrect.left; - - let newHoverRowIndex = this.getRowIndexByClientY(event.clientY); - if (this.scrollbarDragInProgress || checkIfScrollbarArea(event.clientX, event.clientY, true)) { - newHoverRowIndex = null; - } - - if (this.hoverRowIndex !== newHoverRowIndex) { - // check if mouse is down - if (this.lastMouseDownEvent) { - // set drag select direction to true if down, or false if up - const newDragSelectionDirectionIsDown = newHoverRowIndex > this.hoverRowIndex ? true : false; - - if (this.dragSelectionDirectionIsDown !== newDragSelectionDirectionIsDown) { - // select previous row on drag select direction change - this.selectRowByIndex(this.lastMouseDownEvent.clientX, this.hoverRowIndex); - this.dragSelectionDirectionIsDown = newDragSelectionDirectionIsDown; - } - let rowIndex = this.hoverRowIndex; - // Select all rows between the previous and current hover row index - while ( - (newDragSelectionDirectionIsDown === true && rowIndex < newHoverRowIndex) || - (newDragSelectionDirectionIsDown === false && rowIndex > newHoverRowIndex) - ) { - if (newDragSelectionDirectionIsDown === true) { - rowIndex ++; - } else { - rowIndex --; - } - this.selectRowByIndex(this.lastMouseDownEvent.clientX, rowIndex); - } - } - this.hoverRowIndex = newHoverRowIndex; - } - - if (this.dragSelectionDirectionIsDown === null) { - // Check for column resize - if (this.lastMouseDownEvent && this.visibleColumnSeparatorIndex > 0) { - this.columnresize.emit(this.visibleColumnSeparatorIndex); - } else { - this.updateVisibleColumnSeparatorIndex(clientX); - } - - if (this.visibleColumnSeparatorIndex > 0) { - this.lastClientY = event.clientY - canvrect.top; - this.hasChanges = true; - return; - } - } - - if (this.dragSelectionDirectionIsDown === null && this.hoverRowIndex !== null) { - const colIndex = this.getColIndexByClientX(clientX); - let colStartX = this.columns.reduce((prev, curr, ndx) => ndx < colIndex ? prev + curr.width : prev, 0); - - let tooltipText: string | ((rowIndex: any) => string) = - this.columns[colIndex] && this.columns[colIndex].tooltipText; - - // FIXME: message display class - if (typeof tooltipText === 'function' && this.rows.rowExists(this.hoverRowIndex)) { - tooltipText = tooltipText(this.hoverRowIndex); - } - - if (!event.shiftKey && !this.lastMouseDownEvent && - (tooltipText || (this.columns[colIndex] && this.columns[colIndex].draggable)) - ) { - if (this.rowWrapMode && - colIndex >= this.rowWrapModeWrapColumn) { - // Subtract first row width if in row wrap mode - colStartX -= this.columns.reduce((prev, curr, ndx) => - ndx < this.rowWrapModeWrapColumn ? prev + curr.width : prev, 0); - } - - this.floatingTooltip = new FloatingTooltip( - (this.hoverRowIndex - this.topindex) * this.rowheight, - colStartX - this.horizScroll + this.colpaddingleft, - this.columns[colIndex].width - this.colpaddingright - this.colpaddingleft, - this.rowheight, tooltipText as string); - - if (this.rowWrapMode) { - this.floatingTooltip.top += - + (colIndex >= this.rowWrapModeWrapColumn ? this.rowheight / 2 : 0); - this.floatingTooltip.height = this.rowheight / 2; - } - - setTimeout(() => { - if (this.columnOverlay) { - this.columnOverlay.show(300); - } - }, 0); - } else { - this.floatingTooltip = null; - } - } else { - this.floatingTooltip = null; - } - }; - - this.canv.onmouseout = (event: MouseEvent) => { - const newHoverRowIndex = null; - if (this.hoverRowIndex !== newHoverRowIndex) { - this.hoverRowIndex = newHoverRowIndex; - } - }; - - this.renderer.listen('window', 'mouseup', (event: MouseEvent) => { - this.lastMouseDownEvent = undefined; - if (this.scrollbarDragInProgress) { - this.scrollbarDragInProgress = false; - this.hasChanges = true; - } - }); - - this.canv.onmouseup = (event: MouseEvent) => { - event.preventDefault(); - if (this.visibleColumnSeparatorIndex > 0) { - this.columnresizeend.emit(); - } else if (!this.scrollbarArea && this.lastMouseDownEvent) { - const lastcol = this.getColIndexByClientX(this.lastMouseDownEvent.clientX); - const thiscol = this.getColIndexByClientX(event.clientX); - const lastrow = this.getRowIndexByClientY(this.lastMouseDownEvent.clientY); - const thisrow = this.getRowIndexByClientY(event.clientY); - if (lastcol === thiscol && lastrow === thisrow) { - this.selectRow(event.clientX, event.clientY); - } - } - - this.lastMouseDownEvent = null; - this.dragSelectionDirectionIsDown = null; - }; - - - this.renderer.listen('window', 'resize', () => true); - - const paintLoop = () => { - if (this.hasChanges) { - if (Math.abs(this.touchScrollSpeedY) > 0) { - // Scroll if speed - this.topindex -= this.touchScrollSpeedY / this.rowheight; - - // ---- Enforce scroll limit - if (this.topindex < 0) { - this.topindex = 0; - } else if (this.rows.rowCount() < this.maxVisibleRows) { - this.topindex = 0; - } else if (this.topindex + this.maxVisibleRows > this.rows.rowCount()) { - this.topindex = this.rows.rowCount() - this.maxVisibleRows; - } - // --------- - - // Slow down - this.touchScrollSpeedY *= 0.9; - if (Math.abs(this.touchScrollSpeedY) < 0.4) { - this.touchScrollSpeedY = 0; - } - } - try { - this.dopaint(); - if (this.rows) { - this.repaintDoneSubject.next(undefined); - } - } catch (e) { - console.log(e); - } - - if (Math.abs(this.touchScrollSpeedY) > 0) { - // Continue scrolling while we have scroll speed - this.hasChanges = true; - } else { - this.hasChanges = false; - } - } - window.requestAnimationFrame(() => paintLoop()); - }; - - this._ngZone.runOutsideAngular(() => - window.requestAnimationFrame(() => paintLoop()) - ); - } - - private updateDragImage(selectedRowIndex: number) :HTMLCanvasElement { - const dragImageYCoords: number[][] = []; - let dragImageDestY = 0; - - // FIXME move to message_display?? - this.rows.rows - .forEach((row, ndx) => { - if ( - ndx >= this.topindex && (ndx - this.topindex) <= (this.canv.height / this.rowheight) - && - (this.rows.isSelectedRow(ndx) || ndx === selectedRowIndex) - ) { - const dragImageDataY = Math.floor((ndx - this.topindex) * this.rowheight * devicePixelRatio); - dragImageYCoords.push([dragImageDataY, dragImageDestY]); - - dragImageDestY += this.rowheight * devicePixelRatio; - } - }); - - const dragImageCanvas = document.createElement('canvas'); - dragImageCanvas.width = this.canv.width - 20; - dragImageCanvas.height = dragImageYCoords.length * this.rowheight * devicePixelRatio; - - const dragContext = dragImageCanvas.getContext('2d'); - dragContext.clearRect(0,0,dragImageCanvas.width,dragImageCanvas.height); - dragContext.fillStyle = 'red'; - dragContext.fillRect(0,0,dragImageCanvas.width,dragImageCanvas.height); - dragImageYCoords.forEach(ycoords => { - dragContext.drawImage(this.canv, - 0, ycoords[0], this.canv.width - 20, this.rowheight * devicePixelRatio, - 0, - ycoords[1], - this.canv.width - 20, this.rowheight * devicePixelRatio - ); - }); - - document.body.append(dragImageCanvas); - dragImageCanvas.setAttribute('id', 'thedragcanvas'); - dragImageCanvas.style.position = 'absolute'; dragImageCanvas.style.top = '0px'; dragImageCanvas.style.left = '-'+ dragImageCanvas.width + 'px'; - dragImageCanvas.style.width = Math.floor(((this.canv.width - 20) / devicePixelRatio)) + 'px'; - - return dragImageCanvas; - } - - public dragColumnOverlay(event: DragEvent) { - const canvrect = this.canv.getBoundingClientRect(); - const selectedColIndex = this.getColIndexByClientX(event.clientX - canvrect.left); - const selectedRowIndex = this.getRowIndexByClientY(event.clientY); - - if (!this.columns[selectedColIndex].checkbox) { - this.selectListener.rowSelected(selectedRowIndex, -1); - const dragCanvas = this.updateDragImage(selectedRowIndex); - event.dataTransfer.dropEffect = 'move'; - event.dataTransfer.setDragImage(dragCanvas, 0, 0); - event.dataTransfer.setData('text/plain', 'rowIndex:' + selectedRowIndex); - } else { - event.preventDefault(); - this.lastMouseDownEvent = event; - } - - this.hasChanges = true; - } - - public columnOverlayClicked(event: MouseEvent) { - this.lastMouseDownEvent = null; - this.selectRow(event.clientX, event.clientY); - } - - public doScrollBarDrag(clientY: number) { - const canvrect = this.canv.getBoundingClientRect(); - this.topindex = this.rows.rowCount() * ((clientY - canvrect.top) / this.canv.scrollHeight); - - this.enforceScrollLimit(); - } - - private getRowIndexByClientY(clientY: number) { - const canvrect = this.canv.getBoundingClientRect(); - return Math.floor(this.topindex + (clientY - canvrect.top) / this.rowheight); - } - - public getColIndexByClientX(clientX: number) { - if (this.rowWrapMode) { - return clientX > this.columns[0].width ? this.rowWrapModeDefaultSelectedColumn : 0; - } else { - let x = -this.horizScroll; - let selectedColIndex = 0; - for (; selectedColIndex < this.columns.length; selectedColIndex++) { - const col = this.columns[selectedColIndex]; - if (clientX >= x && clientX < x + col.width) { - break; - } - x += col.width; - } - return selectedColIndex; - } - } - - public updateVisibleColumnSeparatorIndex(clientX: number) { - let x = -this.horizScroll; - let selectedColIndex = 0; - for (; selectedColIndex < this.columns.length; selectedColIndex++) { - const col = this.columns[selectedColIndex]; - if (clientX >= x - 5 && clientX < x + 5) { - break; - } - x += col.width; - } - if (selectedColIndex === this.columns.length) { - selectedColIndex = -1; - } - - if (selectedColIndex !== this.visibleColumnSeparatorIndex && !this.rowWrapMode) { - if (selectedColIndex > 0) { - this.canv.style.cursor = 'col-resize'; - } else { - this.canv.style.cursor = 'pointer'; - } - this.visibleColumnSeparatorAlpha = 0; - this.visibleColumnSeparatorIndex = selectedColIndex; - this.hasChanges = true; - } - } - - public isScrollInProgress(): boolean { - return this.scrollbarDragInProgress || Math.abs(this.touchScrollSpeedY) > 0; - } - - public getVisibleRowIndexes(): number[] { - return new Array(Math.floor(this.maxVisibleRows)) - .fill(0).map((v, n) => Math.round(this.topindex + n)); - } - - public selectRows() { - if (this.selectWhichRows === CanvasTable.RowSelect.Visible) { - this.selectAllVisibleRows(); - } else { - this.selectAllRows(); - } - } - - public selectAllRows() { - const allSelected = this.rows.allSelected(); - - this.rows.rows.forEach((rowobj, rowIndex) => - this.selectListener.rowSelected( - rowIndex, - 0, - !allSelected - ) - ); - } - - public selectAllVisibleRows() { - const visibleRowIndexes = this.getVisibleRowIndexes(); - - const visibleRowsAlreadySelected = visibleRowIndexes.reduce((prev, next) => - prev && - (next >= this.rows.rowCount() || this.rows.isSelectedRow(next)) - , true); - - visibleRowIndexes.forEach(selectedRowIndex => - this.selectListener.rowSelected(selectedRowIndex, - 0, - !visibleRowsAlreadySelected) - ); - - this.hasChanges = true; - } - - public selectRow(clientX: number, clientY: number, multiSelect?: boolean) { - const selectedRowIndex = this.getRowIndexByClientY(clientY); - this.selectRowByIndex(clientX, selectedRowIndex, multiSelect); - } - - public selectRowByIndex(clientX: number, selectedRowIndex: number, multiSelect?: boolean) { - const canvrect = this.canv.getBoundingClientRect(); - clientX -= canvrect.left; - - this.selectListener.rowSelected(selectedRowIndex, - this.getColIndexByClientX(clientX), - multiSelect); - - this.hasChanges = true; - } - - public autoAdjustColumnWidths(minwidth: number, tryFitScreenWidth = false) { - if (!this.canv || this._columns.length === 0) { - return; - } - - const canvasWidth = Math.floor(this.wantedCanvasWidth / window.devicePixelRatio) - this.scrollbarwidth - 2; - - const columnsTotalWidth = () => this.columns.reduce((prev, curr) => prev + curr.width, 0); - - if (!this.rowWrapMode && tryFitScreenWidth) { - // Reduce the width of the widest column to fit screen - - const findWidestColumn = () => this.columns.reduce((prev, curr) => - prev.width < curr.width ? curr : prev, this.columns[0]); - - if (columnsTotalWidth() < canvasWidth) { - // Restore original column widths since we are using less space than the canvas width - this.columns - .filter(col => col.originalWidth ? true : false) - .forEach(col => { - col.width = col.originalWidth; - col.originalWidth = null; - }); - } - - let widestColumn = findWidestColumn(); - - // Reduce column widths - while (widestColumn.width > minwidth && columnsTotalWidth() > canvasWidth) { - if (!widestColumn.originalWidth) { - widestColumn.originalWidth = widestColumn.width; - } - widestColumn.width--; - if (widestColumn.width < minwidth) { - widestColumn.width = minwidth; - } - widestColumn = findWidestColumn(); - } - } - - this.hasChanges = true; - } - - public scrollTop() { - this.topindex = 0; - this.hasChanges = true; - } - - public scrollUp() { - this.topindex--; - this.enforceScrollLimit(); - this.hasChanges = true; - } - - public scrollDown() { - this.topindex++; - this.enforceScrollLimit(); - this.hasChanges = true; - } - - public get rows(): MessageDisplay { - return this._rows; - } - - public set rows(rows: MessageDisplay) { - if (this._rows !== rows) { - this._rows = rows; - - this.hasChanges = true; - } - } - - public updateRows(newList) { - this.rows.setRows(newList); - this.enforceScrollLimit(); - this.hasChanges = true; - } - - public get showContentTextPreview(): boolean { - return this._showContentTextPreview; - } - - public set showContentTextPreview(showContentTextPreview: boolean) { - this._showContentTextPreview = showContentTextPreview; - this.hasChanges = true; - } - - // When loading a url with a fragment containing a msg id - scroll to there - public jumpToOpenMessage() { - this.jumpToMessage = true; - } - - private enforceScrollLimit() { - if (this.topindex < 0) { - this.topindex = 0; - } else if (this.rows && this.rows.rowCount() < this.maxVisibleRows) { - this.topindex = 0; - } else if (this.rows && this.topindex + this.maxVisibleRows > this.rows.rowCount()) { - this.topindex = this.rows.rowCount() - this.maxVisibleRows; - // send max rows hit events (use to fetch more data) - this.scrollLimitHit.next(this.rows.rowCount()); - } - - - const columnsTotalWidth = this.columns.reduce((width, col) => - col.width + width, 0); - - if (this.horizScroll < 0) { - this.horizScroll = 0; - } else if ( - this.canv.scrollWidth < columnsTotalWidth && - this.horizScroll + this.canv.scrollWidth > columnsTotalWidth) { - this.horizScroll = columnsTotalWidth - this.canv.scrollWidth; - } - } - - /** - * Draws a rounded rectangle using the current state of the canvas. - * If you omit the last three params, it will draw a rectangle - * outline with a 5 pixel border radius - * @param {CanvasRenderingContext2D} ctx - * @param {Number} x The top left x coordinate - * @param {Number} y The top left y coordinate - * @param {Number} width The width of the rectangle - * @param {Number} height The height of the rectangle - * @param {Number} [radius = 5] The corner radius; It can also be an object - * to specify different radii for corners - * @param {Number} [radius.tl = 0] Top left - * @param {Number} [radius.tr = 0] Top right - * @param {Number} [radius.br = 0] Bottom right - * @param {Number} [radius.bl = 0] Bottom left - * @param {Boolean} [fill = false] Whether to fill the rectangle. - * @param {Boolean} [stroke = true] Whether to stroke the rectangle. - */ - private roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, - width: number, height: number, - radius?: any, fill?: boolean, stroke?: boolean) { - if (typeof stroke === 'undefined') { - stroke = true; - } - if (typeof radius === 'undefined') { - radius = 5; - } - if (typeof radius === 'number') { - radius = { tl: radius, tr: radius, br: radius, bl: radius }; - } else { - const defaultRadius = { tl: 0, tr: 0, br: 0, bl: 0 }; - Object.keys(defaultRadius).forEach(side => - radius[side] = radius[side] || defaultRadius[side]); - } - ctx.beginPath(); - ctx.moveTo(x + radius.tl, y); - ctx.lineTo(x + width - radius.tr, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr); - ctx.lineTo(x + width, y + height - radius.br); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height); - ctx.lineTo(x + radius.bl, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl); - ctx.lineTo(x, y + radius.tl); - ctx.quadraticCurveTo(x, y, x + radius.tl, y); - ctx.closePath(); - if (fill) { - ctx.fill(); - } - if (stroke) { - ctx.stroke(); - } - } - - // Height of message list rows - public get rowheight(): number { - return (this.rowWrapMode || this.showContentTextPreview ) ? - 1.75 * this._rowheight : this._rowheight; - } - - public set rowheight(rowheight: number) { - if (this._rowheight !== rowheight) { - this._rowheight = rowheight; - this.hasChanges = true; - } - } - - private dopaint() { - const devicePixelRatio = window.devicePixelRatio; - if (this.canv.width !== this.wantedCanvasWidth || - this.canv.height !== this.wantedCanvasHeight) { - - const widthChanged = this.canv.width !== this.wantedCanvasWidth; - /* Only resize on detection of width change - * otherwise reducing column widths so that the scrollbar - * disappears indicates a change of height and triggers resize - */ - - this.canv.style.width = (this.wantedCanvasWidth / devicePixelRatio) + 'px'; - this.canv.style.height = (this.wantedCanvasHeight / devicePixelRatio) + 'px'; - - this.canv.width = this.wantedCanvasWidth; - this.canv.height = this.wantedCanvasHeight; - - this.maxVisibleRows = this.canv.scrollHeight / this.rowheight; - if(this.jumpToMessage) { - // currently selected row in the centre: - if (this.rows.rowCount() > 0 && this.rows.openedRowIndex) { - this.topindex = this.rows.openedRowIndex - Math.round(this.maxVisibleRows / 2); - } - this.jumpToMessage = false; - } - this.enforceScrollLimit(); - this.hasChanges = true; - if (this.canv.clientWidth < this.autoRowWrapModeWidth) { - this.rowWrapMode = true; - } else { - this.rowWrapMode = false; - } - - this.canvasResizedSubject.next(widthChanged); - } - - if (devicePixelRatio !== 1) { - // This is not scale() as that would keep multiplying - // Moved out of above if() statement as something (!?) - // was resetting transform, still not sure what - this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); - } - - this.ctx.textBaseline = 'middle'; - this.ctx.font = this.fontheight + 'px ' + this.fontFamily; - - const canvwidth: number = this.canv.scrollWidth; - const canvheight: number = this.canv.scrollHeight; - - let colx = 0 - this.horizScroll; - // Columns - for (let colindex = 0; colindex < this.columns.length; colindex++) { - const col: CanvasTableColumn = this.columns[colindex]; - if (colx + col.width > 0 && colx < canvwidth) { - this.ctx.fillStyle = col.backgroundColor ? col.backgroundColor : '#fff'; - this.ctx.fillRect(colx, - 0, - colindex === this.columns.length - 1 ? - canvwidth - colx : - col.width, - canvheight - ); - } - colx += col.width; - } - - if (!this.rows || this.rows.rowCount() < 1) { - return; - } - - // Rows - for (let n = this.topindex; n < this.rows.rowCount(); n += 1.0) { - const rowIndex = Math.floor(n); - - if (rowIndex > this.rows.rowCount()) { - break; - } - -// const rowobj = this.rows[rowIndex]; - - const halfrowheight = (this.rowheight / 2); - const rowy = (rowIndex - this.topindex) * this.rowheight; - if (this.rows.rowExists(rowIndex)) { - // Clear row area - // Alternating row colors: - // let rowBgColor : string = (rowIndex%2===0 ? "#e8e8e8" : "rgba(255,255,255,0.7)"); - // Single row color: - let rowBgColor = '#fff'; - - const isBoldRow = this.rows.isBoldRow(rowIndex); - const isSelectedRow = this.rows.isSelectedRow(rowIndex); - const isOpenedRow = this.rows.isOpenedRow(rowIndex); - if (this.hoverRowIndex === rowIndex) { - rowBgColor = this.hoverRowColor; - } - if (isSelectedRow) { - rowBgColor = this.selectedRowColor; - } - if (isOpenedRow) { - rowBgColor = this.openedRowColor; - } - - this.ctx.fillStyle = rowBgColor; - this.ctx.fillRect(0, rowy, canvwidth, this.rowheight); - - // Row borders separating each row - this.ctx.strokeStyle = '#eee'; - this.ctx.beginPath(); - this.ctx.moveTo(0, rowy); - this.ctx.lineTo(canvwidth, rowy); - this.ctx.stroke(); - - let x = 0; - for (let colindex = 0; colindex < this.columns.length; colindex++) { - const col: CanvasTableColumn = this.columns[colindex]; - let val: any = col.getValue(rowIndex); - if (val === 'RETRY') { - // retry later if value is null - setTimeout(() => this.hasChanges = true, 2); - val = ''; - } - let formattedVal: string; - const formattedValueCacheKey: string = col.cacheKey + ':' + val; - if (this.formattedValueCache[formattedValueCacheKey]) { - formattedVal = this.formattedValueCache[formattedValueCacheKey]; - } else if (('' + val).length > 0 && col.getFormattedValue) { - formattedVal = col.getFormattedValue(val); - this.formattedValueCache[formattedValueCacheKey] = formattedVal; - } else { - formattedVal = '' + val; - this.formattedValueCache[formattedValueCacheKey] = formattedVal; - } - if (this.rowWrapMode && col.rowWrapModeHidden) { - continue; - } else if (this.rowWrapMode && col.rowWrapModeChipCounter && parseInt(val, 10) > 1) { - this.ctx.save(); - - this.ctx.strokeStyle = ''; - - this.roundRect(this.ctx, - canvwidth - 50, - rowy + 9, - 28, - 15, 10, false); - this.ctx.font = '10px ' + this.fontFamily; - - this.ctx.strokeStyle = '#000'; - if (isSelectedRow) { - this.ctx.fillStyle = this.textColor; - } else { - this.ctx.fillStyle = this.textColor; - } - this.ctx.textAlign = 'center'; - this.ctx.fillText(formattedVal + '', canvwidth - 36, rowy + halfrowheight - 15); - - this.ctx.restore(); - - continue; - } else if (this.rowWrapMode && col.rowWrapModeChipCounter) { - continue; - } - if (this.rowWrapMode && colindex === this.rowWrapModeWrapColumn) { - x = 0; - } - - x += this.colpaddingleft; - - if ((x - this.horizScroll + col.width) >= 0 && formattedVal.length > 0) { - this.ctx.fillStyle = this.textColor; // Text color of unselected row - if (isSelectedRow) { - this.ctx.fillStyle = this.textColor; // Text color of selected row - } - - if (this.rowWrapMode) { - // Wrap rows if in row wrap mode (for e.g. mobile portrait view) - - // Check box - const texty: number = rowy + halfrowheight; - const textx: number = x - this.horizScroll; - - const width = col.width - this.colpaddingright - this.colpaddingleft; - - this.ctx.save(); - this.ctx.beginPath(); - this.ctx.moveTo(textx, rowy); - this.ctx.lineTo(textx + width, rowy); - this.ctx.lineTo(textx + width, rowy + this.rowheight); - this.ctx.lineTo(textx, rowy + this.rowheight); - this.ctx.closePath(); - - if (col.checkbox) { - const checkboxWidthHeight = 12; - const checkboxCheckedPadding = 3; - const checkboxLeftPadding = 4; - this.ctx.strokeStyle = this.textColor; - this.ctx.beginPath(); - this.ctx.rect(checkboxLeftPadding + textx, texty - checkboxWidthHeight / 2, checkboxWidthHeight, checkboxWidthHeight); - this.ctx.stroke(); - if (val) { - this.ctx.beginPath(); - this.ctx.rect(checkboxLeftPadding + textx + checkboxCheckedPadding, - checkboxCheckedPadding + texty - checkboxWidthHeight / 2, - checkboxWidthHeight - checkboxCheckedPadding * 2, - checkboxWidthHeight - checkboxCheckedPadding * 2); - this.ctx.fill(); - } - } else { - - // Other columns - if (colindex >= this.rowWrapModeWrapColumn) { - // Subject - x += 30; // Increase padding before Subject - this.ctx.save(); - if (isBoldRow) { - this.ctx.save(); - this.ctx.font = 'bold ' + this.fontheight + 'px ' + this.fontFamilyBold; - this.ctx.fillStyle = this.textColorLink; - } else { - this.ctx.save(); - this.ctx.font = this.fontheight + 'px ' + this.fontFamily; - this.ctx.fillStyle = this.textColorLink; - } - this.ctx.fillText(formattedVal, x, rowy + halfrowheight + 12 - - (this.showContentTextPreview ? 12 : 0) - ); - this.ctx.restore(); - } else if (col.rowWrapModeMuted) { - // Date/time - x = 42; // sufficiently away from the checkbox - this.ctx.save(); - this.ctx.font = this.fontheightSmaller + 'px ' + this.fontFamily; - this.ctx.fillStyle = this.textColor; - this.ctx.fillText(formattedVal, x, rowy + halfrowheight - 10 - - (this.showContentTextPreview ? 8 : 0) - ); - this.ctx.restore(); - } else { - x = 128; // far enough to make the date above fit nicely - this.ctx.font = this.fontheightSmall + 'px ' + this.fontFamily; - this.ctx.fillText(formattedVal, x, rowy + halfrowheight - 10 - - (this.showContentTextPreview ? 8 : 0)); - this.ctx.fillStyle = this.textColorLink; - } - } - this.ctx.restore(); - } else if (x - this.horizScroll < canvwidth) { - // Normal no-wrap mode - - // Check box - const texty: number = rowy + halfrowheight - (this.showContentTextPreview ? 10 : 0); - let textx: number = x - this.horizScroll; - - const width = col.width - this.colpaddingright - this.colpaddingleft; - - this.ctx.save(); - this.ctx.beginPath(); - this.ctx.moveTo(textx, rowy); - this.ctx.lineTo(textx + width, rowy); - this.ctx.lineTo(textx + width, rowy + this.rowheight); - this.ctx.lineTo(textx, rowy + this.rowheight); - this.ctx.closePath(); - - this.ctx.clip(); - - if (col.checkbox) { - const checkboxWidthHeight = 12; - const checkboxCheckedPadding = 3; - const checkboxLeftPadding = 4; - this.ctx.strokeStyle = this.textColor; - this.ctx.beginPath(); - this.ctx.rect(checkboxLeftPadding + textx, texty - checkboxWidthHeight / 2, checkboxWidthHeight, checkboxWidthHeight); - this.ctx.stroke(); - if (val) { - this.ctx.beginPath(); - this.ctx.rect(checkboxLeftPadding + textx + checkboxCheckedPadding, - checkboxCheckedPadding + texty - checkboxWidthHeight / 2, - checkboxWidthHeight - checkboxCheckedPadding * 2, - checkboxWidthHeight - checkboxCheckedPadding * 2); - this.ctx.fill(); - } - } else { - // Other columns - if (col.textAlign === 1) { - textx += width; - this.ctx.textAlign = 'end'; - } - - if (col.font) { - this.ctx.font = col.font; - } - if (colindex === 2 || colindex === 3) { - // Column 2 is From, 3 is Subject - this.ctx.fillStyle = this.textColorLink; - if (isBoldRow) { - this.ctx.font = 'bold ' + this.fontheight + 'px ' + this.fontFamilyBold; - } - } - this.ctx.fillText(formattedVal, textx, texty); - } - this.ctx.restore(); - } - } - - x += (Math.round(col.width * (this.rowWrapMode && col.rowWrapModeMuted ? - (10 / this.fontheight) : 1)) - this.colpaddingleft); // We've already added colpaddingleft above - } - } else { - // skipping rows we've removed while canvas was updating.... - console.log('Skipped repainting a row as its data is missing, continuing anyway'); - } - if (this.showContentTextPreview) { - const contentTextPreviewColumn = this.columns - .find(col => col.getContentPreviewText ? true : false); - if (contentTextPreviewColumn) { - const contentPreviewText = contentTextPreviewColumn.getContentPreviewText(rowIndex); - if (contentPreviewText) { - this.ctx.save(); - this.ctx.fillStyle = this.textColor; - this.ctx.font = this.fontheightSmaller + 'px ' + this.fontFamily; - const contentTextPreviewColumnPadding = this.rowWrapMode ? 2 : 10; // Increase left padding of content preview - this.ctx.fillText(contentPreviewText, this.columns[0]. width + contentTextPreviewColumnPadding, - rowy + halfrowheight + (this.rowWrapMode ? 18 : 15)); - this.ctx.restore(); - } - } - } - - if (rowy > canvheight) { - break; - } - this.ctx.fillStyle = this.textColor; - - } - - // Column separators - - if (!this.rowWrapMode) { - // No column separators in row wrap mode - this.ctx.fillStyle = `rgba(166,166,166,${this.visibleColumnSeparatorAlpha})`; - this.ctx.strokeStyle = `rgba(176,176,176,${this.visibleColumnSeparatorAlpha})`; - - if (this.visibleColumnSeparatorAlpha < 1) { - this.visibleColumnSeparatorAlpha += 0.01; - setTimeout(() => this.hasChanges = true, 0); - } - - let x = 0; - for (let colindex = 0; colindex < this.columns.length; colindex++) { - if (colindex > 0 && this.visibleColumnSeparatorIndex === colindex) { - // Only draw column separator near the mouse pointer - this.ctx.beginPath(); - this.ctx.moveTo(x - this.horizScroll, 0); - this.ctx.lineTo(x - this.horizScroll, canvheight); - this.ctx.stroke(); - - this.ctx.fillRect(x - this.horizScroll - 5, this.lastClientY - 10, 10, 20); - } - x += this.columns[colindex].width; - } - } - - // Scrollbar - let scrollbarheight = (this.maxVisibleRows / this.rows.rowCount()) * canvheight; - if (scrollbarheight < 20) { - scrollbarheight = 20; - } - const scrollbarpos = - (this.topindex / (this.rows.rowCount() - this.maxVisibleRows)) * (canvheight - scrollbarheight); - - if (scrollbarheight < canvheight) { - const scrollbarverticalpadding = 4; - - const scrollbarx = canvwidth - this.scrollbarwidth; - this.ctx.fillStyle = '#aaa'; - this.ctx.fillRect(scrollbarx, 0, this.scrollbarwidth, canvheight); - this.ctx.fillStyle = '#fff'; - this.scrollBarRect = { - x: scrollbarx + 1, - y: scrollbarpos + scrollbarverticalpadding / 2, - width: this.scrollbarwidth - 2, - height: scrollbarheight - scrollbarverticalpadding - }; - - if (this.scrollbarDragInProgress) { - this.ctx.fillStyle = 'rgba(200,200,255,0.5)'; - this.roundRect(this.ctx, - this.scrollBarRect.x - 4, - this.scrollBarRect.y - 4, - this.scrollBarRect.width + 8, - this.scrollBarRect.height + 8, 5, true); - - this.ctx.fillStyle = '#fff'; - this.ctx.fillRect(this.scrollBarRect.x, - this.scrollBarRect.y, - this.scrollBarRect.width, - this.scrollBarRect.height); - } else { - this.ctx.fillStyle = '#fff'; - this.ctx.fillRect(this.scrollBarRect.x, this.scrollBarRect.y, this.scrollBarRect.width, this.scrollBarRect.height); - } - - } - - } -} - -@Component({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: 'canvastablecontainer', - templateUrl: 'canvastablecontainer.component.html', - styleUrls: ['canvastablecontainer.component.scss'] -}) -export class CanvasTableContainerComponent implements OnInit { - colResizeInitialClientX: number; - colResizeColumnIndex: number; - colResizePreviousWidth: number; - - columnResized: boolean; - sortColumn = 0; - sortDescending = false; - - columnWidths = {}; - - preferenceService: PreferencesService; - - @Input() configname = 'default'; - @Input() canvastableselectlistener: CanvasTableSelectListener; - - @Output() sortToggled: EventEmitter = new EventEmitter(); - - @ViewChild(CanvasTableComponent, { static: true }) canvastable: CanvasTableComponent; - @ViewChild('tablecontainer') tablecontainer: ElementRef; - @ViewChild('tablebodycontainer') tablebodycontainer: ElementRef; - @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger; - - RowSelect = CanvasTable.RowSelect; - private selectAllTimeout; - - constructor(private renderer: Renderer2) { - // const oldSavedColumnWidths = localStorage.getItem('canvasNamedColumnWidths'); - // if (oldSavedColumnWidths) { - // const colWidthSet = Object.keys(JSON.parse(oldSavedColumnWidths)).filter((col) => col.length > 0).join(','); - // const newColWidths = {}; - // newColWidths[colWidthSet] = JSON.parse(oldSavedColumnWidths); - // localStorage.setItem('canvasNamedColumnWidthsBySet', JSON.stringify(newColWidths)); - // localStorage.removeItem('canvasNamedColumnWidths'); - // } - - // const savedColumnWidths = localStorage.getItem('canvasNamedColumnWidthsBySet'); - // if (savedColumnWidths) { - // this.columnWidths = JSON.parse(savedColumnWidths); - // } - } - - saveColumnWidths() { - const newColWidths = {}; - const colWidthSet = this.canvastable.columns.map((col) => col.name).filter((cname) => cname.length > 0).join(','); - for (const c of this.canvastable.columns) { - newColWidths[c.name] = c.width; - } - this.columnWidths[colWidthSet] = newColWidths; - this.canvastableselectlistener.saveColumnWidthsPreference(this.columnWidths); - // localStorage.setItem('canvasNamedColumnWidthsBySet', JSON.stringify(this.columnWidths)); - } - - ngOnInit() { - this.renderer.listen('window', 'mousemove', (event: MouseEvent) => { - if (this.colResizeInitialClientX) { - event.preventDefault(); - event.stopPropagation(); - this.colresize(event.clientX); - } - }); - - this.renderer.listen('window', 'mouseup', (event: MouseEvent) => { - if (this.colResizeInitialClientX) { - event.preventDefault(); - event.stopPropagation(); - this.colresizeend(); - } - }); - } - - colresizestart(clientX: number, colIndex: number) { - if (colIndex > 0) { - this.colResizeInitialClientX = clientX; - // We're always resizing the column before - this.colResizeColumnIndex = colIndex - 1; - this.colResizePreviousWidth = this.canvastable.columns[this.colResizeColumnIndex].width; - this.canvastable.columnResizeInProgress = true; - } - } - - colresize(clientX: number) { - if (this.colResizeInitialClientX) { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - - const column: CanvasTableColumn = this.canvastable.columns[this.colResizeColumnIndex]; - if (column && column.width) { - column.width = this.colResizePreviousWidth + (clientX - this.colResizeInitialClientX); - if (column.width < MIN_COLUMN_WIDTH) { - column.width = MIN_COLUMN_WIDTH; - } - this.canvastable.hasChanges = true; - this.columnResized = true; - - this.saveColumnWidths(); - } - } - } - - public sumWidthsBefore(colIndex: number) { - let ret = 0; - for (let n = 0; n < colIndex; n++) { - ret += this.canvastable.columns[n].width; - } - return ret; - } - - colresizeend() { - this.colResizeInitialClientX = null; - this.colResizeColumnIndex = null; - this.canvastable.columnResizeInProgress = false; - } - - horizScroll(evt: Event) { - this.canvastable.horizScroll = evt.target['scrollLeft']; - } - - handleTouchScroll(scrollValue: number) { - if (this.tablecontainer.nativeElement.scrollWidth > - this.tablecontainer.nativeElement.clientWidth) { - this.tablecontainer.nativeElement.scrollLeft = scrollValue; - } else { - this.canvastable.horizScroll = 0; - } - } - - public toggleSort(column: number) { - if (column === null) { - return; - } - - if (this.columnResized) { - this.columnResized = false; - return; - } - - if (column === this.sortColumn) { - this.sortDescending = !this.sortDescending; - } else { - this.sortColumn = column; - } - this.sortToggled.emit({ sortColumn: this.sortColumn, sortDescending: this.sortDescending }); - } - - public mouseOverSelectAll() { - this.selectAllTimeout = setTimeout(() => { - this.trigger.openMenu(); - }, 200); - } - - public mouseLeftSelectAll() { - if (this.selectAllTimeout) { - clearTimeout(this.selectAllTimeout); - this.trigger.closeMenu(); - this.selectAllTimeout = null; - } - } - -} - - -@NgModule({ - imports: [ - CommonModule, - MatTooltipModule, - MatButtonModule, - MatMenuModule, - MatRadioModule, - FormsModule, - MatIconModule - ], - declarations: [CanvasTableComponent, CanvasTableContainerComponent], - exports: [CanvasTableComponent, CanvasTableContainerComponent] -}) -export class CanvasTableModule { - -} diff --git a/src/app/canvastable/canvastablecolumn.ts b/src/app/canvastable/canvastablecolumn.ts deleted file mode 100644 index 50d75b4ad..000000000 --- a/src/app/canvastable/canvastablecolumn.ts +++ /dev/null @@ -1,48 +0,0 @@ -// --------- BEGIN RUNBOX LICENSE --------- -// Copyright (C) 2016-2022 Runbox Solutions AS (runbox.com). -// -// This file is part of Runbox 7. -// -// Runbox 7 is free software: You can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// Runbox 7 is distributed in the hope that it will be useful, but -// WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Runbox 7. If not, see . -// ---------- END RUNBOX LICENSE ---------- - -/* - * Copyright 2010-2020 FinTech Neo AS / Runbox ( fintechneo.com / runbox.com )- All rights reserved - */ - -export interface CanvasTableColumn { - name: string; - cacheKey: string; - - footerText?: string; - - width?: number; - originalWidth?: number; - font?: string; - backgroundColor?: string; - tooltipText?: string | ((rowobj: any) => string); - draggable?: boolean; - sortColumn: number; - rowWrapModeHidden?: boolean; - rowWrapModeMuted?: boolean; - rowWrapModeChipCounter?: boolean; // E.g. for displaying number of messages in conversation in a "chip"/"badge" - checkbox?: boolean; // checkbox for selecting rows - textAlign?: number; // default = left, 1 = right, 2 = center - getContentPreviewText?: (rowobj: any) => string; - - getValue(rowobj: any): any; - - getFormattedValue?(val: any): string; -} - diff --git a/src/app/canvastable/canvastablecontainer.component.html b/src/app/canvastable/canvastablecontainer.component.html deleted file mode 100644 index 0c43569ff..000000000 --- a/src/app/canvastable/canvastablecontainer.component.html +++ /dev/null @@ -1,138 +0,0 @@ - -
- - - - - - - - Visible only - All rows - - - -
- {{col.name}} - - -
-
-
-
- -
- {{col.name}} -
-
-
- -
- - -
-
- {{col.footerText}} -
-
diff --git a/src/app/canvastable/canvastablecontainer.component.scss b/src/app/canvastable/canvastablecontainer.component.scss deleted file mode 100644 index 30f1578a6..000000000 --- a/src/app/canvastable/canvastablecontainer.component.scss +++ /dev/null @@ -1,4 +0,0 @@ -.sortIcon { - position: relative; - bottom: 3px; -} diff --git a/src/app/common/human-bytes.ts b/src/app/common/human-bytes.ts new file mode 100644 index 000000000..21f4f2d20 --- /dev/null +++ b/src/app/common/human-bytes.ts @@ -0,0 +1,31 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +export default function humanBytes(value: number, decimalPlaces = 0): string { + if (value === 0) { + return '0 B'; + } + + const base = 1000; + const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const exponent = Math.floor(Math.log(value) / Math.log(base)); + + const result = (value / Math.pow(base, exponent)).toFixed(decimalPlaces); + return `${parseFloat(result)} ${suffixes[exponent]}`; +} diff --git a/src/app/common/messagedisplay.ts b/src/app/common/messagedisplay.ts index 57b7947f6..65b9c9923 100644 --- a/src/app/common/messagedisplay.ts +++ b/src/app/common/messagedisplay.ts @@ -16,7 +16,6 @@ // You should have received a copy of the GNU General Public License // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { CanvasTableColumn } from '../canvastable/canvastablecolumn'; export abstract class MessageDisplay { public openedRowIndex: number; @@ -202,5 +201,5 @@ export abstract class MessageDisplay { abstract filterBy(options: Map); // columns - abstract getCanvasTableColumns(app: any): CanvasTableColumn[]; + abstract getRowData(index: number, app: any): any; } diff --git a/src/app/common/messagelist.ts b/src/app/common/messagelist.ts index dd7e50249..cb329f66e 100644 --- a/src/app/common/messagelist.ts +++ b/src/app/common/messagelist.ts @@ -20,7 +20,6 @@ import { MessageDisplay } from '../common/messagedisplay'; import { MessageInfo } from './messageinfo'; import { MessageTableRowTool} from '../messagetable/messagetablerow'; -import { CanvasTableColumn } from '../canvastable/canvastablecolumn'; export class MessageList extends MessageDisplay { @@ -70,91 +69,22 @@ export class MessageList extends MessageDisplay { } } - public getCanvasTableColumns(app: any): CanvasTableColumn[] { - const columns: CanvasTableColumn[] = [ - { - sortColumn: null, - name: '', - cacheKey: 'selectbox', - rowWrapModeHidden: false, - getValue: (rowIndex: number): any => this.isSelectedRow(rowIndex), - checkbox: true, - draggable: true - }, - { - name: 'Date', - cacheKey: 'date', - sortColumn: null, - rowWrapModeMuted: true, - getValue: (rowIndex: number): string => this.getRow(rowIndex).messageDate.toJSON(), - getFormattedValue: (datestring) => MessageTableRowTool.formatTimestamp(datestring), - draggable: true - }, - { - name: app.selectedFolder === 'Sent' ? 'To' : 'From', - cacheKey: 'from', - sortColumn: null, - getValue: (rowIndex: number): any => app.selectedFolder === 'Sent' - ? this.getToColumnValueForRow(rowIndex) - : this.getFromColumnValueForRow(rowIndex), - draggable: true - }, - { - name: 'Subject', - cacheKey: 'subject', - sortColumn: null, - getValue: (rowIndex: number): string => this.getRow(rowIndex).subject, - draggable: true, - getContentPreviewText: (rowIndex): string => { - const ret = this.getRow(rowIndex).plaintext; - return ret ? ret.trim() : ''; - }, - // tooltipText: 'Tip: Drag subject to a folder to move message(s)' - }, - { - sortColumn: null, - name: 'Size', - cacheKey: 'size', - rowWrapModeHidden: true, - getValue: (rowIndex: number): number => this.getRow(rowIndex).size, - getFormattedValue: MessageTableRowTool.formatBytes, - draggable: true - }, - { - sortColumn: null, - name: '', - cacheKey: 'attachment', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex: number): boolean => this.getRow(rowIndex).attachment, - getFormattedValue: (val) => val ? '\uE226' : '', - tooltipText: 'Attachment' - }, - { - sortColumn: null, - name: '', - cacheKey: 'answered', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex: number): boolean => this.getRow(rowIndex).answeredFlag, - getFormattedValue: (val) => val ? '\uE15E' : '', - tooltipText: 'Answered' - }, - { - sortColumn: null, - name: '', - cacheKey: 'flagged', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex: number): boolean => this.getRow(rowIndex).flaggedFlag, - getFormattedValue: (val) => val ? '\uE153' : '', - tooltipText: 'Flagged' - } - ]; + getRowData(rowIndex, app) { + const row = this.rows[rowIndex] - return columns; + return { + id: row.id, + seen: row.seenFlag, + messageDate: MessageTableRowTool.formatTimestamp(row.messageDate.toJSON()), + from: app.selectedFolder === 'Sent' + ? this.getToColumnValueForRow(rowIndex) + : this.getFromColumnValueForRow(rowIndex), + subject: row.subject, + size: row.size, + attachment: row.attachment , + answered: row.answeredFlag , + flagged: row.flaggedFlag , + plaintext: row.plaintext?.trim(), + }; } } diff --git a/src/app/directives/resize-observer.directive.ts b/src/app/directives/resize-observer.directive.ts new file mode 100644 index 000000000..446f9a491 --- /dev/null +++ b/src/app/directives/resize-observer.directive.ts @@ -0,0 +1,62 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Directive, ElementRef, EventEmitter, OnDestroy, Output } from '@angular/core'; + +@Directive({ + selector: '[appResizeObserver]', + standalone: true, +}) +export class ResizeObserverDirective implements OnDestroy { + @Output() resize = new EventEmitter(); + @Output() horizontalResize = new EventEmitter(); + @Output() verticalResize = new EventEmitter(); + + private observer: ResizeObserver; + private lastSize: { width: number; height: number } | null = null; + + constructor(private elementRef: ElementRef) { + this.observer = new ResizeObserver((entries) => { + const entry = entries[0]; + + if (!entry) return; + + const { width, height } = entry.contentRect; + + if (this.lastSize) { + if (this.lastSize.width !== width) { + this.horizontalResize.emit(entry); + } + + if (this.lastSize.height !== height) { + this.verticalResize.emit(entry); + } + } + + this.resize.emit(entry); + this.lastSize = { width, height }; + }); + + this.observer.observe(this.elementRef.nativeElement); + } + + ngOnDestroy(): void { + this.observer.disconnect(); + } +} diff --git a/src/app/follows-mouse/follows-mouse.component.html b/src/app/follows-mouse/follows-mouse.component.html new file mode 100644 index 000000000..e85571510 --- /dev/null +++ b/src/app/follows-mouse/follows-mouse.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/follows-mouse/follows-mouse.component.scss b/src/app/follows-mouse/follows-mouse.component.scss new file mode 100644 index 000000000..77645beaf --- /dev/null +++ b/src/app/follows-mouse/follows-mouse.component.scss @@ -0,0 +1,8 @@ +.follows-mouse { + /* Ensure the mouse can interact with underlying elements */ + pointer-events: none; + transition: transform 0.1s ease; + z-index: 10000; + display: inline-block; + white-space: nowrap; +} diff --git a/src/app/follows-mouse/follows-mouse.component.ts b/src/app/follows-mouse/follows-mouse.component.ts new file mode 100644 index 000000000..1a83b4507 --- /dev/null +++ b/src/app/follows-mouse/follows-mouse.component.ts @@ -0,0 +1,42 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Component, ElementRef, HostListener } from '@angular/core'; + +@Component({ + selector: 'app-follows-mouse', + standalone: true, + templateUrl: './follows-mouse.component.html', + styleUrls: ['./follows-mouse.component.scss'], +}) +export class FollowsMouseComponent { + + constructor(private el: ElementRef) { + this.el.nativeElement.style.display = 'inline-block'; + this.el.nativeElement.style.position = 'fixed'; + this.el.nativeElement.style['z-index'] = '1000'; + } + + @HostListener('document:mousemove', ['$event']) + @HostListener('document:drag', ['$event']) + onMouseMove(event: MouseEvent) { + this.el.nativeElement.style.left = `${event.clientX + 4}px`; + this.el.nativeElement.style.top = `${event.clientY + 4}px`; + } +} diff --git a/src/app/human-bytes.pipe.ts b/src/app/human-bytes.pipe.ts new file mode 100644 index 000000000..0f8c9b853 --- /dev/null +++ b/src/app/human-bytes.pipe.ts @@ -0,0 +1,29 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Pipe, PipeTransform } from '@angular/core'; +import humanBytes from './common/human-bytes' + +@Pipe({ + name: 'humanBytes', + standalone: true +}) +export class HumanBytesPipe implements PipeTransform { + public transform = humanBytes +} diff --git a/src/app/mailviewer/singlemailviewer.component.ts b/src/app/mailviewer/singlemailviewer.component.ts index b52f888d5..e3f8d3ed6 100644 --- a/src/app/mailviewer/singlemailviewer.component.ts +++ b/src/app/mailviewer/singlemailviewer.component.ts @@ -70,7 +70,7 @@ type Mail = any; templateUrl: 'singlemailviewer.component.html', styleUrls: ['singlemailviewer.component.scss'] }) -export class SingleMailViewerComponent implements OnInit, DoCheck, AfterViewInit { +export class SingleMailViewerComponent implements OnInit, AfterViewInit, DoCheck { _messageId = null; // Message id or filename diff --git a/src/app/messagetable/messagetablerow.ts b/src/app/messagetable/messagetablerow.ts index cb238da2a..9161f21c0 100644 --- a/src/app/messagetable/messagetablerow.ts +++ b/src/app/messagetable/messagetablerow.ts @@ -18,6 +18,7 @@ // ---------- END RUNBOX LICENSE ---------- const datelen: number = 'yyyy-MM-dd'.length; +import humanBytes from '../common/human-bytes' export class MessageTableRowTool { @@ -75,18 +76,7 @@ export class MessageTableRowTool { )); } - public static formatBytes(a, b?): string { - if (0 === a) { - return'0 B'; - } - - const c = 1e3, - d = b || 0, - e = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], - f = Math.floor(Math.log(a) / Math.log(c)); - - return parseFloat((a / Math.pow(c, f)).toFixed(d)) + ' ' + e[f]; - } + public static formatBytes = humanBytes } export interface MessageTableRow { diff --git a/src/app/models/bindable-selection-model.ts b/src/app/models/bindable-selection-model.ts new file mode 100644 index 000000000..637b6b22f --- /dev/null +++ b/src/app/models/bindable-selection-model.ts @@ -0,0 +1,44 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { SelectionModel } from '@angular/cdk/collections'; + +export class BindableSelectionModel { + selectionModel: SelectionModel; + + constructor( + multiple: boolean, + initialValues: T[] = [], + emitChanges = true, + compareWith: (a: T, b: T) => boolean = (a, b) => a === b, + ) { + this.selectionModel = new SelectionModel(multiple, initialValues, emitChanges, compareWith); + } + + // Getter for `selected` + get selected(): T | T[] { + return this.selectionModel.isMultipleSelection() ? this.selectionModel.selected : this.selectionModel.selected[0]; + } + + // Setter for `selected` + set selected(items: T | T[]) { + const selection = (this.selectionModel.isMultipleSelection() ? items : [items]) as T[]; + this.selectionModel.setSelection(...selection) + } +} diff --git a/src/app/help/help.component.spec.ts b/src/app/models/filter-selection-model.ts similarity index 53% rename from src/app/help/help.component.spec.ts rename to src/app/models/filter-selection-model.ts index 78805b537..f771112b6 100644 --- a/src/app/help/help.component.spec.ts +++ b/src/app/models/filter-selection-model.ts @@ -1,5 +1,5 @@ // --------- BEGIN RUNBOX LICENSE --------- -// Copyright (C) 2016-2021 Runbox Solutions AS (runbox.com). +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). // // This file is part of Runbox 7. // @@ -17,28 +17,22 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SelectionModel } from '@angular/cdk/collections'; -import { HelpComponent } from './help.component'; +export class FilterSelectionModel extends SelectionModel { + constructor(multiple: boolean, initialValues: T[], emitChanges: boolean, compareWith: (a: T, b: T) => boolean, predicate: (a) => boolean) { + super(multiple, initialValues, emitChanges, compareWith); -describe('HelpComponent', () => { - let component: HelpComponent; - let fixture: ComponentFixture; + return new Proxy(this, { + get(target, prop) { + if (prop === 'select') { + return (...items: T[]) => { + return target.select(...items.filter(predicate)); + }; + } - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ HelpComponent ] - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(HelpComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); + return target[prop]; + } + }); + } +} diff --git a/src/app/resizable-button/resizable-button.component.html b/src/app/resizable-button/resizable-button.component.html new file mode 100644 index 000000000..74a099e92 --- /dev/null +++ b/src/app/resizable-button/resizable-button.component.html @@ -0,0 +1,11 @@ + diff --git a/src/app/resizable-button/resizable-button.component.scss b/src/app/resizable-button/resizable-button.component.scss new file mode 100644 index 000000000..c88828c67 --- /dev/null +++ b/src/app/resizable-button/resizable-button.component.scss @@ -0,0 +1,25 @@ +button { + --fg: #333; + border-left: 1px solid var(--fg); + border-right: 1px solid var(--fg); + border-top: none; + border-bottom: none; + position: absolute; + top: 0; + bottom: 0; + cursor: col-resize; + right: 0; + width: 0.5rem; + border-radius: unset; + padding: 0; + margin: 0; + display: block; + transition: opacity 0.1s ease; + opacity: 0; + background: none; +} + +button:hover, button:focus, button.resizing { + opacity: 1; +} + diff --git a/src/app/resizable-button/resizable-button.component.ts b/src/app/resizable-button/resizable-button.component.ts new file mode 100644 index 000000000..6b8ae84dc --- /dev/null +++ b/src/app/resizable-button/resizable-button.component.ts @@ -0,0 +1,148 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Component, ElementRef, EventEmitter, Output, Input, HostListener, OnChanges } from '@angular/core'; +import { Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; + +const userResize = new Subject() + +@Component({ + selector: 'app-resizable-button', + templateUrl: './resizable-button.component.html', + styleUrls: ['./resizable-button.component.scss'], + standalone: true, +}) +export class ResizableButtonComponent implements OnChanges { + + @Input() width: number; + @Output() widthChange = new EventEmitter(); + + isResizing = false; + private startX = 0; + private startWidth = 0; + + // Hold the reference to the event listeners + private onMouseMoveListener: (event: MouseEvent) => void; + private onMouseUpListener: () => void; + + constructor(private elementRef: ElementRef) { + // Only set absolute value when the user does a resize. + userResize.pipe(take(1)).subscribe(() => { + this.setAbsoluteWidth(); + }); + } + + ngOnChanges(changes) { + if (changes.width?.currentValue == null) { + this.resetWidth(); + } + } + + get parentElement() { + return this.elementRef.nativeElement.parentElement; + } + + setAbsoluteWidth() { + setTimeout(() => { + if (!this.parentElement) return + + this.changeWidth(this.parentElement.offsetWidth); + }, 0) + } + + resetWidth() { + this.parentElement.style.removeProperty('width'); + this.setAbsoluteWidth() + } + + onMouseDown(event: MouseEvent): void { + this.isResizing = true; + this.startX = event.clientX; + const parentElement = this.parentElement; + if (parentElement) { + this.startWidth = parentElement.offsetWidth; + } + + // Define the mouse move and up handlers + this.onMouseMoveListener = this.onMouseMove.bind(this); + this.onMouseUpListener = this.onMouseUp.bind(this); + + // Add the mousemove and mouseup event listeners to the document + document.addEventListener('mousemove', this.onMouseMoveListener); + document.addEventListener('mouseup', this.onMouseUpListener); + + // Prevent text selection during resizing + event.preventDefault(); + } + + private onMouseMove(event: MouseEvent): void { + if (!this.isResizing) return; + + const parentElement = this.parentElement; + if (parentElement) { + const diff = event.clientX - this.startX; + const newWidth = this.startWidth + diff; + this.changeWidth(newWidth); + } + } + + private onMouseUp(): void { + this.isResizing = false; + this.removeMouseListeners(); + } + + @HostListener('document:keydown', ['$event']) + onKeyDown(event: KeyboardEvent): void { + if (!this.elementRef.nativeElement.contains(document.activeElement)) { + return; + } + + const parentElement = this.elementRef.nativeElement.parentElement; + if (!parentElement) return; + + const step = 10; // Resize step for each key press + const currentWidth = parentElement.offsetWidth; + + if (event.key === 'ArrowRight') { + this.changeWidth(currentWidth + step); + } else if (event.key === 'ArrowLeft') { + this.changeWidth(currentWidth - step); + } + } + + changeWidth(pixels: number) { + this.widthChange.emit(pixels) + userResize.next(pixels) + } + + @HostListener('window:blur') + @HostListener('window:focus') + onWindowFocus(): void { + if (this.isResizing) { + this.isResizing = false; // Stop resizing immediately + this.removeMouseListeners(); // Remove listeners if resizing was interrupted + } + } + + private removeMouseListeners(): void { + document.removeEventListener('mousemove', this.onMouseMoveListener); + document.removeEventListener('mouseup', this.onMouseUpListener); + } +} diff --git a/src/app/rmmapi/messagelist.service.ts b/src/app/rmmapi/messagelist.service.ts index ce8d08da4..a76eae003 100644 --- a/src/app/rmmapi/messagelist.service.ts +++ b/src/app/rmmapi/messagelist.service.ts @@ -1,18 +1,18 @@ // --------- BEGIN RUNBOX LICENSE --------- // Copyright (C) 2016-2018 Runbox Solutions AS (runbox.com). -// +// // This file is part of Runbox 7. -// +// // Runbox 7 is free software: You can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the // Free Software Foundation, either version 3 of the License, or (at your // option) any later version. -// +// // Runbox 7 is distributed in the hope that it will be useful, but // WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU // General Public License for more details. -// +// // You should have received a copy of the GNU General Public License // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- @@ -61,6 +61,7 @@ export class MessageListService { staleFolders: { [name: string]: boolean } = {}; trashFolderName = 'Trash'; + sentFolderName = 'Sent'; spamFolderName = 'Spam'; unindexedFolders = ['Trash', 'Spam', 'Templates']; templateFolderName = 'Templates'; diff --git a/src/app/sort-button/sort-button.component.ts b/src/app/sort-button/sort-button.component.ts new file mode 100644 index 000000000..63478a547 --- /dev/null +++ b/src/app/sort-button/sort-button.component.ts @@ -0,0 +1,135 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; + +export enum Direction { + Ascending = 'ASC', + Descending = 'DESC', + None = 'NONE' +} + +export interface OrderEvent { + data: any; + direction: Direction; +} + +@Component({ + standalone: true, + imports: [CommonModule, MatIconModule], + selector: 'app-sort-button', + template: ` + + `, + styles: [ + ` + .sort-button { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + background: none; + border: none; + font-weight: inherit; + padding-left: 0; + } + + .sort-button[disabled] { + color: black; + } + + .sort-button:hover { + text-decoration: underline; + } + + .sort-button[disabled]:hover { + cursor: not-allowed; + text-decoration: none; + } + `, + ], +}) +export class SortButtonComponent { + @Input() order: OrderEvent = { data: Symbol('init'), direction: Direction.None }; + @Input() data: any; + @Input() disabled?:any; + + @Output() orderChange = new EventEmitter(); + + readonly Direction = Direction; + + private readonly directionCycle = new Map([ + [Direction.Ascending, Direction.Descending], + [Direction.Descending, Direction.Ascending], + ]); + + private readonly hrDirectionTr = new Map([ + [Direction.Ascending, 'ascending'], + [Direction.Descending, 'descending'], + [Direction.None, 'no particular'], + ]) + + private readonly directionIconMap = new Map([ + [Direction.Ascending, 'arrow_downward'], + [Direction.Descending, 'arrow_upward'], + [Direction.None, 'empty'], + ]); + + // Optional helper getter if you want cleaner template usage + get isDisabled(): boolean { + return this.disabled !== undefined && this.disabled !== false; + } + + get directionIcon() { + return (this.data === this.order?.data) + ? this.directionIconMap.get(this.order?.direction) + : this.directionIconMap.get(Direction.None); + } + + get hrDirection() { + return this.hrDirectionTr.get(this.order?.direction) + } + + onClick(): void { + // Set direction to Ascending when switching columns. + const direction = (this.order?.data !== this.data) + ? Direction.Descending + : this.directionCycle.get(this.order?.direction) ?? Direction.Ascending + + this.orderChange.emit({ + data: this.data, + direction, + }); + } +} diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.html b/src/app/virtual-scroll-table/virtual-scroll-table.component.html new file mode 100644 index 000000000..3a1e3b5bc --- /dev/null +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.html @@ -0,0 +1,14 @@ + + +
+ + diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.scss b/src/app/virtual-scroll-table/virtual-scroll-table.component.scss new file mode 100644 index 000000000..c58eaed75 --- /dev/null +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.scss @@ -0,0 +1,22 @@ +table { + border-collapse: collapse; /* Removes space between table cells */ + border-spacing: 0; /* Ensures no extra spacing between cells */ + max-width: 100%; + table-layout: fixed; + user-select: none; + width: 100%; + box-shadow: unset; +} + +cdk-virtual-scroll-viewport { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + ::ng-deep & table thead { + visibility: hidden; + opacity: 0; + } +} diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts new file mode 100644 index 000000000..8ff36b735 --- /dev/null +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts @@ -0,0 +1,145 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { + OnDestroy, + OnInit, + ChangeDetectionStrategy, + AfterViewInit, + Component, + ContentChild, + ElementRef, + EventEmitter, + Input, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { CommonModule } from '@angular/common'; +import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { ListRange } from '@angular/cdk/collections'; +import { Subject, Subscription, BehaviorSubject } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; + +@Component({ + selector: 'app-virtual-scroll-table', + standalone: true, + imports: [ScrollingModule, CommonModule, MatCheckboxModule], + templateUrl: './virtual-scroll-table.component.html', + styleUrls: ['./virtual-scroll-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class VirtualScrollTableComponent implements OnInit, OnDestroy, AfterViewInit { + @ContentChild('tbody', { read: TemplateRef }) tbodyTemplate!: TemplateRef | null; + @ContentChild('thead', { read: TemplateRef }) theadTemplate!: TemplateRef | null; + + @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; + + @Output() renderedRangeChange = new EventEmitter(); + @Input() items: any[] = []; + + @Input() scrollToIndex!: BehaviorSubject; + + firstRowHeight = 24; + maxBufferPx: number; + + private renderedRangeSub!: Subscription; + private inputChangesSub!: Subscription; + private scrollToIndexSub!: Subscription; + private inputChanges$ = new Subject(); + + private mutationObserver?: MutationObserver; + private pendingScrollToIndex: number | null = null; + + constructor(private elementRef: ElementRef) {} + + ngOnInit() { + this.scrollToIndexSub = this.scrollToIndex.subscribe(index => { + this.pendingScrollToIndex = index; + this.inputChanges$.next() + }); + } + + ngAfterViewInit() { + this.renderedRangeSub = this.viewport.renderedRangeStream + .pipe(debounceTime(50)) + .subscribe(range => { + this.renderedRangeChange.emit(range); + }); + + this.inputChangesSub = this.inputChanges$ + .pipe(debounceTime(50)) + .subscribe(() => { + this.updateFirstRowHeight(); + + this.doScrollToIndex(this.pendingScrollToIndex); + }); + + const elem = this.elementRef.nativeElement; + + this.mutationObserver = new MutationObserver(() => { + this.inputChanges$.next(); + }); + + this.mutationObserver.observe(elem, { + childList: true, + subtree: true, + attributes: false + }); + } + + ngOnDestroy(): void { + this.renderedRangeSub.unsubscribe(); + this.scrollToIndexSub.unsubscribe(); + this.inputChangesSub.unsubscribe(); + this.mutationObserver.disconnect(); + } + + trackBy(index: number) { + return index; + } + + doScrollToIndex(index: number, retries = 5, delayMs = 500): void { + if (!this.viewport || index == null) return; + if (this.pendingScrollToIndex == null) return + + const scrollPosBefore = this.viewport.measureScrollOffset(); + + this.viewport.scrollToIndex(index, 'smooth'); + + setTimeout(() => { + const scrollPosAfter = this.viewport.measureScrollOffset(); + + const isStable = Math.abs(scrollPosAfter - scrollPosBefore) < 1; + + if (!isStable && retries > 0) { + this.doScrollToIndex(index, retries - 1, delayMs); + } else { + this.pendingScrollToIndex = null; + } + }, delayMs); + } + + private updateFirstRowHeight(): void { + const elem = this.elementRef.nativeElement.querySelector('tbody'); + + this.firstRowHeight = elem?.offsetHeight || this.firstRowHeight; + } +} diff --git a/src/app/websocketsearch/websocketsearchmaillist.ts b/src/app/websocketsearch/websocketsearchmaillist.ts index 61ae6e6d5..20d65abeb 100644 --- a/src/app/websocketsearch/websocketsearchmaillist.ts +++ b/src/app/websocketsearch/websocketsearchmaillist.ts @@ -19,8 +19,6 @@ import { MessageDisplay } from '../common/messagedisplay'; import { WebSocketSearchMailRow } from '../websocketsearch/websocketsearchmailrow.class'; -import { MessageTableRowTool} from '../messagetable/messagetablerow'; -import { CanvasTableColumn } from '../canvastable/canvastablecolumn'; export class WebSocketSearchMailList extends MessageDisplay { @@ -51,51 +49,16 @@ export class WebSocketSearchMailList extends MessageDisplay { } } - public getCanvasTableColumns(app: any): CanvasTableColumn[] { - const columns: CanvasTableColumn[] = [ - { - sortColumn: null, - name: '', - cacheKey: 'selectbox', - rowWrapModeHidden: true, - getValue: (rowIndex: number): any => this.isSelectedRow(rowIndex), - checkbox: true, - }, - { - name: 'Date', - draggable: true, - cacheKey: 'date', - sortColumn: null, - rowWrapModeMuted: true, - getValue: (rowIndex: number): string => this.getRow(rowIndex).dateTime, - }, - { - name: 'From', - draggable: true, - cacheKey: 'from', - sortColumn: null, - getValue: (rowIndex: number): string => this.getRow(rowIndex).fromName, - }, - { - name: 'Subject', - cacheKey: 'subject', - sortColumn: null, - getValue: (rowIndex: number): string => this.getRow(rowIndex).subject, - draggable: true - // tooltipText: "Tip: Drag subject to a folder to move message(s)" - }, - { - sortColumn: null, - draggable: true, - name: 'Size', - cacheKey: 'size', - rowWrapModeHidden: true, - getValue: (rowIndex: number): number => this.getRow(rowIndex).size, - getFormattedValue: MessageTableRowTool.formatBytes, - } - ]; - - return columns; - } + getRowData(rowIndex, app) { + return { + id: this.getRowMessageId(rowIndex), + selectbox: this.isSelectedRow(rowIndex), + messageDate: this.getRow(rowIndex).dateTime, + from: this.getRow(rowIndex).fromName, + subject: this.getRow(rowIndex).subject, + size: this.getRow(rowIndex).size, + seen: this.getRowSeen(rowIndex) + }; + } } diff --git a/src/app/xapian/searchmessagedisplay.ts b/src/app/xapian/searchmessagedisplay.ts index 8bd686cb7..8463697f2 100644 --- a/src/app/xapian/searchmessagedisplay.ts +++ b/src/app/xapian/searchmessagedisplay.ts @@ -20,7 +20,6 @@ import { MessageDisplay } from '../common/messagedisplay'; import { SearchService } from './searchservice'; import { MessageTableRowTool} from '../messagetable/messagetablerow'; -import { CanvasTableColumn } from '../canvastable/canvastablecolumn'; export class SearchMessageDisplay extends MessageDisplay { private searchService: SearchService; @@ -42,11 +41,11 @@ export class SearchMessageDisplay extends MessageDisplay { getRowMessageId(index: number): number { let msgId = 0; try { - msgId = this.searchService.getMessageIdFromDocId(this.rows[index][0]); + msgId = this.searchService.getMessageIdFromDocId(this.getRowId(index)); } catch (e) { // This shouldnt happen, it means something changed the stored // data without updating the messagedisplay rows. - console.log('Tried to lookup ' + index + ' in searchIndex, isnt there! ' + e); + console.error('Tried to lookup ' + index + ' in searchIndex, isnt there! ', e); } return msgId; } @@ -54,165 +53,43 @@ export class SearchMessageDisplay extends MessageDisplay { filterBy(options: Map) { } - // columns - // app is a Component (currently) - public getCanvasTableColumns(app: any): CanvasTableColumn[] { - const columns: CanvasTableColumn[] = [ - { - sortColumn: null, - name: '', - cacheKey: 'selectbox', - rowWrapModeHidden: false, - getValue: (rowIndex): any => this.isSelectedRow(rowIndex), - checkbox: true - }, - { - name: 'Date', - draggable: true, - cacheKey: 'date', - sortColumn: 2, - rowWrapModeMuted : true, - getValue: (rowIndex): string => this.searchService.api.getStringValue(this.getRowId(rowIndex), 2), - getFormattedValue: (datestring) => MessageTableRowTool.formatTimestampFromStringWithoutSeparators(datestring) - }, - (app.selectedFolder.indexOf('Sent') === 0 && !app.displayFolderColumn) ? { - name: 'To', - draggable: true, - cacheKey: 'from', - sortColumn: null, - getValue: (rowIndex): string => this.searchService.getDocData(this.getRowId(rowIndex)).recipients.join(', '), - } : - { - name: 'From', - draggable: true, - cacheKey: 'from', - sortColumn: 0, - getValue: (rowIndex): string => { - return this.searchService.getDocData(this.getRowId(rowIndex)).from; - }, - }, - { - name: 'Subject', - cacheKey: 'subject', - sortColumn: 1, - getValue: (rowIndex): string => { - return this.searchService.getDocData(this.getRowId(rowIndex)).subject; - }, - draggable: true, - getContentPreviewText: (rowIndex): string => { - const ret = this.searchService.getDocData(this.getRowId(rowIndex)).textcontent; - return ret ? ret.trim() : ''; - }, - // tooltipText: 'Tip: Drag subject to a folder to move message(s)' - } - ]; + public getRowData(index: number, app: any) { + const rowData: any = { + id: this.getRowMessageId(index), + messageDate: MessageTableRowTool.formatTimestampFromStringWithoutSeparators(this.searchService.api.getStringValue(this.getRowId(index), 2)), + from: app.selectedFolder.indexOf('Sent') === 0 && !app.displayFolderColumn + ? this.searchService.getDocData(this.getRowId(index)).recipients.join(', ') + : this.searchService.getDocData(this.getRowId(index)).from, + subject: this.searchService.getDocData(this.getRowId(index)).subject, + plaintext: this.searchService.getDocData(this.getRowId(index)).textcontent?.trim(), + size: this.searchService.api.getNumericValue(this.getRowId(index), 3), + attachment: this.searchService.getDocData(this.getRowId(index)).attachment ? true : false, + answered: this.searchService.getDocData(this.getRowId(index)).answered ? true : false, + flagged: this.searchService.getDocData(this.getRowId(index)).flagged ? true : false, + folder: this.searchService.getDocData(this.getRowId(index)).folder, + seen: this.searchService.getDocData(this.getRowId(index)).seen, + }; if (app.viewmode === 'conversations') { - // Array containing row (conversation) objects waiting to be counted - let currentCountObject = null; - - const processCurrentCountObject = () => { - // Function for counting messages in a conversation - const rowObj = currentCountObject; - const conversationId = this.searchService.api.getStringValue(rowObj[0], 1); - this.searchService.api.setStringValueRange(1, 'conversation:'); - const conversationSearchText = `conversation:${conversationId}..${conversationId}`; - const results = this.searchService.api.sortedXapianQuery( - conversationSearchText, - 1, 0, 0, 1000, 1 - ); - this.searchService.api.clearValueRange(); + const rowObj = this.getRow(index); + + const conversationId = this.searchService.api.getStringValue(rowObj[0], 1); + this.searchService.api.setStringValueRange(1, 'conversation:'); + const conversationSearchText = `conversation:${conversationId}..${conversationId}`; + const results = this.searchService.api.sortedXapianQuery( + conversationSearchText, + 1, 0, 0, 1000, 1 + ); + this.searchService.api.clearValueRange(); + + if (results[0]?.[1]) { rowObj[2] = `${results[0][1] + 1}`; - - currentCountObject = null; - }; - - columns.push( - { - name: 'Count', - draggable: true, - cacheKey: 'count', - sortColumn: null, - rowWrapModeChipCounter: true, - getValue: (rowIndex): string => { - if (!this.getRow(rowIndex)[2]) { - if (currentCountObject === null) { - currentCountObject = this.getRow(rowIndex); - setTimeout(() => processCurrentCountObject(), 0); - } - return 'RETRY'; - } else { - return this.getRow(rowIndex)[2]; - } - }, - textAlign: 1, - }); - } else { - columns.push( - { - sortColumn: 3, - draggable: true, - name: 'Size', - cacheKey: 'size', - rowWrapModeHidden: true, - getValue: (rowIndex): string => { - return `${this.searchService.api.getNumericValue(this.getRowId(rowIndex), 3)}`; - }, - getFormattedValue: (val) => val === '-1' ? '\u267B' : MessageTableRowTool.formatBytes(val), - tooltipText: (rowIndex) => this.searchService.api.getNumericValue(this.getRowId(rowIndex), 3) === -1 ? - 'This message is marked for deletion by an IMAP client' : null - }); - - if (app.displayFolderColumn) { - columns.push({ - sortColumn: null, - name: 'Folder', - cacheKey: 'folder', - rowWrapModeHidden: true, - getValue: (rowIndex): string => this.searchService.getDocData(this.getRowId(rowIndex)).folder, - width: 200 - }); - } - - // Attachment flag column - columns.push({ - sortColumn: null, - name: '', - cacheKey: 'attachment', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex): boolean => this.searchService.getDocData(this.getRowId(rowIndex)).attachment ? true : false, - width: 35, - getFormattedValue: (val) => val ? '\uE226' : '' - }); - - // Answered flag column - columns.push({ - sortColumn: null, - name: '', - cacheKey: 'answered', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex): boolean => this.searchService.getDocData(this.getRowId(rowIndex)).answered ? true : false, - width: 35, - getFormattedValue: (val) => val ? '\uE15E' : '' - }); - - // Flagged flag column - columns.push({ - sortColumn: null, - name: '', - cacheKey: 'flagged', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex): boolean => this.searchService.getDocData(this.getRowId(rowIndex)).flagged ? true : false, - width: 35, - getFormattedValue: (val) => val ? '\uE153' : '' - }); + rowData.count = rowObj[2]; + } else { + rowData.count = 1 + } } - return columns; + + return rowData; } } diff --git a/src/app/xapian/searchservice.ts b/src/app/xapian/searchservice.ts index efe5c9420..c49cf77c7 100644 --- a/src/app/xapian/searchservice.ts +++ b/src/app/xapian/searchservice.ts @@ -22,7 +22,7 @@ import { HttpClient, HttpRequest, HttpResponse, HttpEventType } from '@angular/c import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; import { MatLegacySnackBar as MatSnackBar, MatLegacySnackBarRef as MatSnackBarRef } from '@angular/material/legacy-snack-bar'; -import { Observable, AsyncSubject, Subject, of, from } from 'rxjs'; +import { Observable, AsyncSubject, Subject, of, from, firstValueFrom } from 'rxjs'; import { mergeMap, map, filter, catchError, tap, take, bufferCount, distinctUntilChanged } from 'rxjs/operators'; import { XapianAPI } from '@runboxcom/runbox-searchindex'; @@ -258,10 +258,10 @@ export class SearchService { FS.syncfs(true, () => { // console.log('Main: Syncd files:'); // console.log(FS.stat(XAPIAN_GLASS_WR)); - // FS.readdir(this.partitionsdir).forEach((f) => { + FS.readdir(this.partitionsdir).forEach((f) => { // console.log(`${f}`); // console.log(FS.stat(`${this.partitionsdir}/${f}`)); - // }); + }); this.api.reloadXapianDatabase(); this.indexReloadedSubject.next(undefined); }); @@ -862,7 +862,6 @@ export class SearchService { if (this.messageTextCache.has(rmmMessageId)) { this.currentDocData.textcontent = this.messageTextCache.get(rmmMessageId); } - this.updateMessageText(rmmMessageId); try { this.api.documentXTermList(docid); @@ -911,31 +910,28 @@ export class SearchService { // fetch message contents, we actually only want the "text.text" part here // then we can use it for previews and search, both with/without local index // skip haschanges/updates if we already saw this one .. - public updateMessageText(messageId: number): boolean { - if (!this.messageTextCache.has(messageId)) { - this.rmmapi.getMessageContents(messageId).subscribe((content) => { - if (content['status'] === 'success') { - this.messageTextCache.set(messageId, content.text.text); - if (this.messagelistservice.messagesById[messageId]) { - this.messagelistservice.messagesById[messageId].plaintext = content.text.text; - } - } else { - if (content.hasOwnProperty('errors')) { - // this is an error restapi generated - console.error(`DataError in updateMessageText ${messageId}`, content['errors']); - } - // even if we dont know where it came from, still dont retry - // it this session - this.messageTextCache.set(messageId, ''); - } - }, - (err) => { - console.error(`HTTPError in updateMessageText ${messageId}`, err); - // stop repeatedly looking up broken ones - this.messageTextCache.set(messageId, ''); - }); - return true; + async messageText(messageId: number): Promise { + const cached = this.messageTextCache.get(messageId); + if (cached !== undefined) return cached; + + let text = ''; + + try { + const content = await firstValueFrom(this.rmmapi.getMessageContents(messageId)); + if (content['status'] === 'success') { + text = content.text.text; + } else if ('errors' in content) { + console.error(`DataError in messageText ${messageId}`, content.errors); + } + } catch (err) { + console.error(`HTTPError in messageText ${messageId}`, err); } - return false; + + this.messageTextCache.set(messageId, text); + + const message = this.messagelistservice.messagesById[messageId]; + if (message) message.plaintext = text; + + return text; } } diff --git a/src/assets/Avenir-Next-LT-Pro-Demi.otf b/src/assets/Avenir-Next-LT-Pro-Demi.otf new file mode 100644 index 000000000..0edab0f57 Binary files /dev/null and b/src/assets/Avenir-Next-LT-Pro-Demi.otf differ diff --git a/src/styles.scss b/src/styles.scss index 6e6d63eb2..0d7093269 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -32,7 +32,7 @@ $rmm-darker-background: #01001c; $rmm-gray: #dddddd; $rmm-gray-light: #eeeeee; $rmm-gray-lighter: #f3f3f3; - + $rmm-default-theme: mat.define-light-theme($rmm-default-primary, $rmm-default-accent, $rmm-default-warn); $rmm-default-lighter-gray: #eeeeee; @@ -60,12 +60,22 @@ $rmm-default-black: #444444; font-weight: normal; } +@font-face { + font-family: "Avenir Next Pro Demi"; + src: url("assets/Avenir-Next-LT-Pro-Demi.otf"); + font-style: normal; + font-weight: normal; +} + // GTA 13.06.2018: Override default fonts as per https://material.angular.io/guide/typography $custom-typography: mat.define-legacy-typography-config( $font-family: '"Avenir Next Pro Regular", "Helvetica Neue", sans-serif' ); +$font-family: "Avenir Next Pro Regular", "Helvetica Neue", sans-serif; +$font-family-bold: "Avenir Next Pro Demi", "Helvetica Neue", sans-serif; + // TODO(v15): As of v15 mat.legacy-core no longer includes default typography styles. // The following line adds: // 1. Default typography styles for all components @@ -87,7 +97,7 @@ html { body { margin: 0; height: 100%; - font-family: "Avenir Next Pro Regular", "Helvetica Neue", sans-serif; + font-family: $font-family; overscroll-behavior: contain; } @@ -159,7 +169,7 @@ a[mat-list-item] .mat-list-item-content { min-height: 24px !important; } -.mat-list[dense] .mat-list-item .mat-list-text, +.mat-list[dense] .mat-list-item .mat-list-text, .mat-nav-list[dense] .mat-list-item .mat-list-text>*, mat-list-item .mat-list-text, a[mat-list-item] .mat-list-text { @@ -313,21 +323,21 @@ mat-grid-tile.tableTitle { height: 16px; width: 42px; } - + .mat-slide-toggle.mat-checked .mat-slide-toggle-thumb-container { top: -5px; transform: translate3d(20px, 0, 0); } - + .mat-slide-toggle.mat-checked .mat-slide-toggle-thumb { height: 24px; width: 24px; } - + .mat-slide-toggle-label { font-size: 16px; } - + .mat-slide-toggle-content { margin-left: 2px; } @@ -337,6 +347,10 @@ mat-grid-tile.tableTitle { font-size: 12px; } +.text-primary { + color: mat.get-color-from-palette($rmm-default-primary); +} + .warning { color: mat.get-color-from-palette($rmm-default-warn); font-weight: bold; @@ -384,7 +398,7 @@ mat-grid-tile.tableTitle { color: #949494 !important; } .themePaletteBlack { - color: #444444 !important; + color: $rmm-default-black !important; } /*** App-specific styles ***/ @@ -394,12 +408,12 @@ mat-grid-tile.tableTitle { /*** Main ***/ #main { - position: fixed; - top: 0px; - bottom: 0px; - left: 0px; - right: 0px; - width: 100%; + position: fixed; + top: 0px; + bottom: 0px; + left: 0px; + right: 0px; + width: 100%; height: 100%; min-height: 100%; display: flex; @@ -439,7 +453,7 @@ mat-grid-tile.tableTitle { display: none; margin: 0; } - + #logo { margin: 0; width: 300px; @@ -518,7 +532,7 @@ div.loginScreen { mat-form-field { width: 200px; } - } + } #loginOptions { display: flex; margin: 0.5em; @@ -724,8 +738,8 @@ rmm-headertoolbar { /* Sidenav pane */ mat-sidenav-container { - position: absolute !important; - bottom: 0px !important; + position: absolute !important; + bottom: 0px !important; left: 0px !important; right: 0px !important; width: 100% !important; @@ -782,7 +796,7 @@ mat-sidenav-container { .mat-mini-fab .mat-button-wrapper { line-height: 18px; } - + a { width: 30%; } @@ -914,12 +928,12 @@ rmm-folderlist { } .folderName { - color: #444; + color: $rmm-default-black; } .folderNameUnread { - font-family: "Avenir Next Pro Medium"; - color: #444; + font-family: "Avenir Next Pro Demi", "Helvetica Neue", sans-serif; + color: $rmm-default-black; font-weight: bold !important; } @@ -936,7 +950,7 @@ rmm-folderlist { } .foldersidebarcount { - font-size: 10px; + font-size: 10px; } .draftsFolder { @@ -982,7 +996,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { flex-grow: 1; overflow: hidden; } - + .messageListActionButtonsRight button { width: 30px; // Remember to also update TOOLBAR_LIST_BUTTON_WIDTH in app.component.ts in order to show the correct number of menu items } @@ -1008,7 +1022,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { button { margin-right: 10px; - + @media(max-width: 540px) { margin-right: 2px; height: 30px; @@ -1022,7 +1036,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { #offerLocalIndex .mat-list-item-content { padding: 0 5px; -} +} #searchField { flex-grow: 10; @@ -1097,7 +1111,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { .mat-icon, .mat-icon-button { color: mat.get-color-from-palette($rmm-default-primary); } - + @media (max-width: 540px) { #threadedCheckbox { display: none; @@ -1112,19 +1126,37 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { margin-top: 20px; flex-grow: 1; text-align: center; - font-family: "Avenir Next Pro Regular ", "Helvetica Neue", sans-serif; + font-family: $font-family; font-weight: 400; font-size: 13px; color: #949494; } -#canvasTableContainerArea { +#messageTableContainerArea { position: absolute; top: 55px; left: 0px; right: 0px; } +#messageTableContainerArea { + tbody { + border-bottom: 1px solid $rmm-gray-light; + } + tbody:hover { + background-color: $rmm-gray; + } + tbody.selected { + background-color: $rmm-gray-light; + } + tbody.checked { + background-color: mat.get-color-from-palette($rmm-default-highlight); + } + .mat-checkbox-checked.mat-accent .mat-checkbox-background { + background-color: mat.get-color-from-palette($rmm-default-primary); + } +} + .mat-fab.mat-accent { color: mat.get-color-from-palette($rmm-default-primary); } @@ -1172,7 +1204,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { .mat-radio-label-content { padding: 0 !important; } - + button, .mat-radio-button, .mat-checkbox { margin-left: 5px; } @@ -1195,7 +1227,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { .mat-icon { margin: 0 3px !important; } - + mat-flat-button, .mat-flat-button, mat-raised-button, .mat-raised-button { min-width: 30px !important; width: 30px !important; @@ -1307,7 +1339,7 @@ compose #fieldFrom .mat-form-field-wrapper, compose .fieldRecipient .mat-form-fi .recipientSuggestionContainer { max-height: 90px; overflow: auto; - + span { font-size: 12.5px; } @@ -1347,7 +1379,7 @@ compose #fieldFrom .mat-form-field-wrapper, compose .fieldRecipient .mat-form-fi .mat-nav-list[dense], .mat-list-item, .mat-list-text, .mat-form-field { height: 48px !important; } -} +} .contactList .mat-form-field-infix { font-size: 16px; @@ -1370,7 +1402,7 @@ compose #fieldFrom .mat-form-field-wrapper, compose .fieldRecipient .mat-form-fi .mat-form-field-appearance-legacy .mat-form-field-infix { padding-top: 0 !important; } - + .mat-form-field-appearance-legacy .mat-form-field-label { top: 0.75em; } @@ -1541,7 +1573,7 @@ app-calendar-event-editor-dialog p { .productGrid mat-card.recommended { border: 1px solid mat.get-color-from-palette($rmm-default-primary); -} +} #pricePlans td { /* border-right: 1px solid $rmm-dark-background !important; */ @@ -1591,7 +1623,7 @@ app-calendar-event-editor-dialog p { color: #0F0; } -.dev.runbox-components .nice_green_timer .timeunit { +.dev.runbox-components .nice_green_timer .timeunit { border: 1px solid #0F0; width: 40px; height: 40px; @@ -1606,22 +1638,22 @@ app-calendar-event-editor-dialog p { .dev.runbox-components .nice_green_timer .timeunit.hours { color: #0f0; } -.dev.runbox-components .nice_green_timer .timeunit.years::after { +.dev.runbox-components .nice_green_timer .timeunit.years::after { content: "y"; } -.dev.runbox-components .nice_green_timer .timeunit.months::after { +.dev.runbox-components .nice_green_timer .timeunit.months::after { content: "m"; } -.dev.runbox-components .nice_green_timer .timeunit.days::after { +.dev.runbox-components .nice_green_timer .timeunit.days::after { content: "d"; } -.dev.runbox-components .nice_green_timer .timeunit.hours::after { +.dev.runbox-components .nice_green_timer .timeunit.hours::after { content: "h"; } -.dev.runbox-components .nice_green_timer .timeunit.minutes::after { +.dev.runbox-components .nice_green_timer .timeunit.minutes::after { content: "m"; } -.dev.runbox-components .nice_green_timer .timeunit.seconds::after { +.dev.runbox-components .nice_green_timer .timeunit.seconds::after { content: "s"; } @@ -1637,22 +1669,22 @@ app-calendar-event-editor-dialog p { align-items: center; } -.dev.runbox-components .nice_blue_timer .timeunit.years::after { +.dev.runbox-components .nice_blue_timer .timeunit.years::after { content: " years"; } -.dev.runbox-components .nice_blue_timer .timeunit.months::after { +.dev.runbox-components .nice_blue_timer .timeunit.months::after { content: " months"; } -.dev.runbox-components .nice_blue_timer .timeunit.days::after { +.dev.runbox-components .nice_blue_timer .timeunit.days::after { content: " days"; } -.dev.runbox-components .nice_blue_timer .timeunit.hours::after { +.dev.runbox-components .nice_blue_timer .timeunit.hours::after { content: " hours"; } -.dev.runbox-components .nice_blue_timer .timeunit.minutes::after { +.dev.runbox-components .nice_blue_timer .timeunit.minutes::after { content: " mins"; } -.dev.runbox-components .nice_blue_timer .timeunit.seconds::after { +.dev.runbox-components .nice_blue_timer .timeunit.seconds::after { content: " secs"; } @@ -1686,12 +1718,6 @@ runbox-section-header mat-slide-toggle { display: inline; } -canvastable { - [draggable] { - cursor: pointer; - } -} - // helpers for multi-row mobile-friendly mat-tables tr.detailsRow { height: 0 !important; @@ -1740,7 +1766,7 @@ td.mat-cell.cdk-column-renewal_name.mat-column-renewal_name { table.renewalsTable td, table.paymentsTable td { padding: 5px 10px 0px 10px !important; -} +} table.detailsTable { width: 100%; @@ -1750,3 +1776,42 @@ table.detailsTable tr td:nth-of-type(2) { display: flex; justify-content: flex-end; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + clip: rect(0, 0, 0, 0); + overflow: hidden; +} + +.bold { + font-family: $font-family-bold; + font-weight: bold; +} + +.text-center { + text-align: center; +} + +/* Transition causes column width calculations to glitch. */ +.mat-drawer-transition .mat-drawer-content { + transition: none !important; +} + +.skeleton-bone { + content: ' '; + display: inline-block; + width: 100%; + /* Adjust based on the required placeholder size */ + height: 1em; + /* Adjust for height */ + background: linear-gradient(90deg, #e0e0e0 25%, #f8f8f8 50%, #e0e0e0 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; + border-radius: 4px; + /* Optional for rounded edges */ +}