RED-5908: update viewed pages when a change occurs
This commit is contained in:
parent
341ec38841
commit
e69bb94f8f
@ -86,16 +86,12 @@
|
||||
<mat-icon svgIcon="red:nav-first"></mat-icon>
|
||||
</div>
|
||||
|
||||
<div class="pages" id="pages">
|
||||
<redaction-page-indicator
|
||||
(pageSelected)="pageSelectedByClick($event)"
|
||||
*ngFor="let pageNumber of displayedPages"
|
||||
[activeSelection]="pageHasSelection(pageNumber)"
|
||||
[active]="pageNumber === activeViewerPage"
|
||||
[number]="pageNumber"
|
||||
[showDottedIcon]="hasOnlyManualRedactionsAndIsExcluded(pageNumber)"
|
||||
></redaction-page-indicator>
|
||||
</div>
|
||||
<redaction-pages
|
||||
(click)="pagesPanelActive = true"
|
||||
[activePage]="activeViewerPage"
|
||||
[displayedAnnotations]="displayedAnnotations"
|
||||
[pages]="displayedPages"
|
||||
></redaction-pages>
|
||||
|
||||
<div
|
||||
(click)="scrollQuickNavLast()"
|
||||
|
||||
@ -77,12 +77,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pages {
|
||||
@include common-mixins.no-scroll-bar;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.annotations {
|
||||
|
||||
@ -185,15 +185,6 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnDestroy
|
||||
}
|
||||
}
|
||||
|
||||
hasOnlyManualRedactionsAndIsExcluded(pageNumber: number): boolean {
|
||||
const hasOnlyManualRedactions = this.displayedAnnotations.get(pageNumber)?.every(annotation => annotation.manual);
|
||||
return hasOnlyManualRedactions && this.file.excludedPages.includes(pageNumber);
|
||||
}
|
||||
|
||||
pageHasSelection(page: number) {
|
||||
return this.multiSelectService.isActive && !!this.listingService.selected.find(a => a.pageNumber === page);
|
||||
}
|
||||
|
||||
selectAllOnActivePage() {
|
||||
this.listingService.selectAnnotations(this.activeAnnotations);
|
||||
}
|
||||
@ -286,11 +277,6 @@ export class FileWorkloadComponent extends AutoUnsubscribe implements OnDestroy
|
||||
return firstValueFrom(this.state.file$).then(file => this.pdf.navigateTo(file.numberOfPages));
|
||||
}
|
||||
|
||||
pageSelectedByClick($event: number): void {
|
||||
this.pagesPanelActive = true;
|
||||
this.pdf.navigateTo($event);
|
||||
}
|
||||
|
||||
preventKeyDefault($event: KeyboardEvent): void {
|
||||
if (COMMAND_KEY_ARRAY.includes($event.key) && !(($event.target as any).localName === 'input')) {
|
||||
$event.preventDefault();
|
||||
|
||||
@ -1,59 +1,45 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
|
||||
import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
|
||||
import { PermissionsService } from '@services/permissions.service';
|
||||
import { ConfigService } from '@services/config.service';
|
||||
import { ViewedPagesService } from '@services/files/viewed-pages.service';
|
||||
import { FilePreviewStateService } from '../../services/file-preview-state.service';
|
||||
import { PageRotationService } from '../../../pdf-viewer/services/page-rotation.service';
|
||||
import { ContextComponent } from '@iqser/common-ui';
|
||||
import { ContextComponent, getConfig } from '@iqser/common-ui';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { FileDataService } from '../../services/file-data.service';
|
||||
import { AppConfig, ViewedPage } from '@red/domain';
|
||||
import { ViewedPagesMapService } from '@services/files/viewed-pages-map.service';
|
||||
|
||||
interface PageIndicatorContext {
|
||||
isRotated: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'redaction-page-indicator',
|
||||
selector: 'redaction-page-indicator [number] [read]',
|
||||
templateUrl: './page-indicator.component.html',
|
||||
styleUrls: ['./page-indicator.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PageIndicatorComponent extends ContextComponent<PageIndicatorContext> implements OnChanges, OnInit {
|
||||
@Input() active = false;
|
||||
@Input() showDottedIcon = false;
|
||||
@Input() number: number;
|
||||
@Input() activeSelection = false;
|
||||
|
||||
@Input() read = false;
|
||||
@Output() readonly pageSelected = new EventEmitter<number>();
|
||||
|
||||
pageReadTimeout: number = null;
|
||||
read = false;
|
||||
isRotated = false;
|
||||
readonly #config = getConfig<AppConfig>();
|
||||
|
||||
constructor(
|
||||
private readonly _viewedPagesService: ViewedPagesService,
|
||||
private readonly _configService: ConfigService,
|
||||
private readonly _viewedPagesMapService: ViewedPagesMapService,
|
||||
private readonly _changeDetectorRef: ChangeDetectorRef,
|
||||
private readonly _permissionService: PermissionsService,
|
||||
private readonly _stateService: FilePreviewStateService,
|
||||
private readonly _fileDataService: FileDataService,
|
||||
private readonly _state: FilePreviewStateService,
|
||||
readonly pageRotationService: PageRotationService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get activePage() {
|
||||
return this._fileDataService.viewedPages.find(p => p.page === this.number);
|
||||
}
|
||||
|
||||
get dossierId() {
|
||||
return this._stateService.dossierId;
|
||||
}
|
||||
|
||||
get fileId() {
|
||||
return this._stateService.fileId;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const isRotated$ = this.pageRotationService.isRotated$(this.number).pipe(
|
||||
tap(value => {
|
||||
@ -65,24 +51,21 @@ export class PageIndicatorComponent extends ContextComponent<PageIndicatorContex
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this._setReadState();
|
||||
this.handlePageRead();
|
||||
}
|
||||
|
||||
async toggleReadState() {
|
||||
const file = this._stateService.file;
|
||||
if (this._permissionService.canMarkPagesAsViewed(file)) {
|
||||
if (this._permissionService.canMarkPagesAsViewed(this._state.file)) {
|
||||
if (this.read) {
|
||||
await this._markPageUnread();
|
||||
await this.#markPageUnread();
|
||||
} else {
|
||||
await this._markPageRead();
|
||||
await this.#markPageRead();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePageRead(): void {
|
||||
const file = this._stateService.file;
|
||||
if (!this._permissionService.canMarkPagesAsViewed(file)) {
|
||||
if (!this._permissionService.canMarkPagesAsViewed(this._state.file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -93,36 +76,22 @@ export class PageIndicatorComponent extends ContextComponent<PageIndicatorContex
|
||||
if (this.active && !this.read) {
|
||||
this.pageReadTimeout = window.setTimeout(async () => {
|
||||
if (this.active && !this.read) {
|
||||
await this._markPageRead();
|
||||
await this.#markPageRead();
|
||||
}
|
||||
}, this._configService.values.AUTO_READ_TIME * 1000);
|
||||
}, this.#config.AUTO_READ_TIME * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private _setReadState() {
|
||||
const activePage = this.activePage;
|
||||
if (!activePage) {
|
||||
this.read = false;
|
||||
} else {
|
||||
this.read = !activePage.showAsUnseen;
|
||||
}
|
||||
this._changeDetectorRef.markForCheck();
|
||||
async #markPageRead() {
|
||||
const fileId = this._state.fileId;
|
||||
await this._viewedPagesService.add({ page: this.number }, this._state.dossierId, fileId);
|
||||
const viewedPage = new ViewedPage({ page: this.number, fileId });
|
||||
this._viewedPagesMapService.add(fileId, viewedPage);
|
||||
}
|
||||
|
||||
private async _markPageRead() {
|
||||
await this._viewedPagesService.addPage({ page: this.number }, this.dossierId, this.fileId);
|
||||
if (this.activePage) {
|
||||
this.activePage.showAsUnseen = false;
|
||||
} else {
|
||||
this._fileDataService.viewedPages.push({ page: this.number, fileId: this.fileId });
|
||||
}
|
||||
this._setReadState();
|
||||
}
|
||||
|
||||
private async _markPageUnread() {
|
||||
await this._viewedPagesService.removePage(this.dossierId, this.fileId, this.number);
|
||||
const pageToDelete = this._fileDataService.viewedPages.findIndex(p => p.page === this.number);
|
||||
this._fileDataService.viewedPages.splice(pageToDelete, 1);
|
||||
this._setReadState();
|
||||
async #markPageUnread() {
|
||||
const fileId = this._state.fileId;
|
||||
await this._viewedPagesService.remove(this._state.dossierId, fileId, this.number);
|
||||
this._viewedPagesMapService.delete(fileId, this.number);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
<div *ngIf="viewedPages$ | async as viewedPages" class="pages" id="pages">
|
||||
<redaction-page-indicator
|
||||
(pageSelected)="pageSelectedByClick($event)"
|
||||
*ngFor="let pageNumber of pages; trackBy: trackBy"
|
||||
[activeSelection]="pageHasSelection(pageNumber)"
|
||||
[active]="pageNumber === activePage"
|
||||
[number]="pageNumber"
|
||||
[read]="!!getViewedPage(viewedPages, pageNumber)"
|
||||
[showDottedIcon]="hasOnlyManualRedactionsAndIsExcluded(pageNumber)"
|
||||
></redaction-page-indicator>
|
||||
</div>
|
||||
@ -0,0 +1,11 @@
|
||||
@use 'common-mixins';
|
||||
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.pages {
|
||||
@include common-mixins.no-scroll-bar;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core';
|
||||
import { List } from '@iqser/common-ui';
|
||||
import { PdfViewer } from '../../../pdf-viewer/services/pdf-viewer.service';
|
||||
import { MultiSelectService } from '../../services/multi-select.service';
|
||||
import { AnnotationsListingService } from '../../services/annotations-listing.service';
|
||||
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
|
||||
import { FilePreviewStateService } from '../../services/file-preview-state.service';
|
||||
import { ViewedPagesMapService } from '@services/files/viewed-pages-map.service';
|
||||
import { ViewedPage } from '@red/domain';
|
||||
|
||||
@Component({
|
||||
selector: 'redaction-pages [pages] [activePage] [displayedAnnotations]',
|
||||
templateUrl: './pages.component.html',
|
||||
styleUrls: ['./pages.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PagesComponent {
|
||||
@Input() pages: List<number>;
|
||||
@Input() activePage: number;
|
||||
@Input() displayedAnnotations: Map<number, AnnotationWrapper[]>;
|
||||
|
||||
readonly #pdf = inject(PdfViewer);
|
||||
readonly #state = inject(FilePreviewStateService);
|
||||
readonly viewedPages$ = inject(ViewedPagesMapService).get$(this.#state.fileId);
|
||||
readonly #multiSelectService = inject(MultiSelectService);
|
||||
readonly #listingService = inject(AnnotationsListingService);
|
||||
|
||||
pageSelectedByClick($event: number): void {
|
||||
this.#pdf.navigateTo($event);
|
||||
}
|
||||
|
||||
readonly trackBy = (_index: number, item: number) => item;
|
||||
|
||||
pageHasSelection(page: number) {
|
||||
return this.#multiSelectService.isActive && !!this.#listingService.selected.find(a => a.pageNumber === page);
|
||||
}
|
||||
|
||||
hasOnlyManualRedactionsAndIsExcluded(pageNumber: number): boolean {
|
||||
const hasOnlyManualRedactions = this.displayedAnnotations.get(pageNumber)?.every(annotation => annotation.manual);
|
||||
return hasOnlyManualRedactions && this.#state.file.excludedPages.includes(pageNumber);
|
||||
}
|
||||
|
||||
getViewedPage(viewedPages: ViewedPage[], pageNumber: number) {
|
||||
return viewedPages.find(p => p.page === pageNumber);
|
||||
}
|
||||
}
|
||||
@ -54,6 +54,7 @@ import { FilePreviewRightContainerComponent } from './components/right-container
|
||||
import { RssDialogComponent } from './dialogs/rss-dialog/rss-dialog.component';
|
||||
import { ReadonlyBannerComponent } from './components/readonly-banner/readonly-banner.component';
|
||||
import { SuggestionsService } from './services/suggestions.service';
|
||||
import { PagesComponent } from './components/pages/pages.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@ -85,6 +86,7 @@ const components = [
|
||||
AnnotationWrapperComponent,
|
||||
AnnotationsListComponent,
|
||||
PageIndicatorComponent,
|
||||
PagesComponent,
|
||||
PageExclusionComponent,
|
||||
AnnotationActionsComponent,
|
||||
CommentsComponent,
|
||||
|
||||
@ -6,13 +6,15 @@ import { annotationTypesTranslations } from '@translations/annotation-types-tran
|
||||
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
|
||||
import { annotationDefaultColorConfig } from '@red/domain';
|
||||
import { FilePreviewStateService } from './file-preview-state.service';
|
||||
import { FileDataService } from './file-data.service';
|
||||
import { DefaultColorsService } from '@services/entity-services/default-colors.service';
|
||||
import { of } from 'rxjs';
|
||||
import { ViewedPagesMapService } from '@services/files/viewed-pages-map.service';
|
||||
import { FileDataService } from './file-data.service';
|
||||
|
||||
@Injectable()
|
||||
export class AnnotationProcessingService {
|
||||
constructor(
|
||||
private readonly _viewedPagesMapService: ViewedPagesMapService,
|
||||
private readonly _fileDataService: FileDataService,
|
||||
private readonly _state: FilePreviewStateService,
|
||||
private readonly _defaultColorsService: DefaultColorsService,
|
||||
@ -43,7 +45,7 @@ export class AnnotationProcessingService {
|
||||
checked: false,
|
||||
topLevelFilter: true,
|
||||
checker: (annotation: AnnotationWrapper) =>
|
||||
!this._fileDataService.viewedPages.some(page => page.page === annotation.pageNumber),
|
||||
!this._viewedPagesMapService.get(this._state.fileId).some(page => page.page === annotation.pageNumber),
|
||||
},
|
||||
{
|
||||
id: 'pages-without-annotations',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ChangeType, File, IRedactionLog, IRedactionLogEntry, IViewedPage, ManualRedactionType, ViewMode, ViewModes } from '@red/domain';
|
||||
import { ChangeType, File, IRedactionLog, IRedactionLogEntry, ManualRedactionType, ViewMode, ViewModes } from '@red/domain';
|
||||
import { AnnotationWrapper } from '@models/file/annotation.wrapper';
|
||||
import { BehaviorSubject, firstValueFrom, iif, Observable, Subject } from 'rxjs';
|
||||
import { RedactionLogEntry } from '@models/file/redaction-log.entry';
|
||||
@ -20,12 +20,12 @@ import { FilesService } from '@services/files/files.service';
|
||||
import { DefaultColorsService } from '@services/entity-services/default-colors.service';
|
||||
import { DictionaryService } from '@services/entity-services/dictionary.service';
|
||||
import { SuggestionsService } from './suggestions.service';
|
||||
import { ViewedPagesMapService } from '@services/files/viewed-pages-map.service';
|
||||
|
||||
const DELTA_VIEW_TIME = 10 * 60 * 1000; // 10 minutes;
|
||||
|
||||
@Injectable()
|
||||
export class FileDataService extends EntitiesService<AnnotationWrapper, AnnotationWrapper> {
|
||||
viewedPages: IViewedPage[] = [];
|
||||
missingTypes = new Set<string>();
|
||||
readonly hasChangeLog$ = new BehaviorSubject<boolean>(false);
|
||||
readonly annotations$: Observable<AnnotationWrapper[]>;
|
||||
@ -37,6 +37,7 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
|
||||
constructor(
|
||||
private readonly _state: FilePreviewStateService,
|
||||
private readonly _viewedPagesService: ViewedPagesService,
|
||||
private readonly _viewedPagesMapService: ViewedPagesMapService,
|
||||
private readonly _viewModeService: ViewModeService,
|
||||
private readonly _userPreferenceService: UserPreferenceService,
|
||||
private readonly _dictionaryService: DictionaryService,
|
||||
@ -148,11 +149,12 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
|
||||
|
||||
async #loadViewedPages(file: File) {
|
||||
if (!this._permissionsService.canMarkPagesAsViewed(file)) {
|
||||
this.viewedPages = [];
|
||||
this._viewedPagesMapService.set(file.fileId, []);
|
||||
return;
|
||||
}
|
||||
|
||||
this.viewedPages = await this._viewedPagesService.getViewedPages(file.dossierId, file.fileId);
|
||||
const viewedPages = await this._viewedPagesService.load(file.dossierId, file.fileId);
|
||||
this._viewedPagesMapService.set(file.fileId, viewedPages);
|
||||
}
|
||||
|
||||
#getVisibleAnnotations(annotations: AnnotationWrapper[], viewMode: ViewMode) {
|
||||
@ -268,8 +270,7 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
|
||||
|
||||
const lastChange = viableChanges.length >= 1 ? viableChanges[viableChanges.length - 1] : undefined;
|
||||
const page = redactionLogEntry.positions?.[0].page;
|
||||
|
||||
const viewedPage = this.viewedPages.filter(p => p.page === page).pop();
|
||||
const viewedPage = this._viewedPagesMapService.get(file.fileId, page);
|
||||
|
||||
// page has been seen -> let's see if it's a change
|
||||
if (viewedPage) {
|
||||
@ -279,7 +280,11 @@ export class FileDataService extends EntitiesService<AnnotationWrapper, Annotati
|
||||
// at least one unseen change
|
||||
if (relevantChanges.length > 0) {
|
||||
// at least 1 relevant change
|
||||
viewedPage.showAsUnseen = dayjs(viewedPage.viewedTime).valueOf() < dayjs(lastChange.dateTime).valueOf();
|
||||
const showAsUnseen = dayjs(viewedPage.viewedTime).valueOf() < dayjs(lastChange.dateTime).valueOf();
|
||||
if (showAsUnseen) {
|
||||
this._viewedPagesMapService.delete(this._state.fileId, viewedPage);
|
||||
}
|
||||
|
||||
this.hasChangeLog$.next(true);
|
||||
return {
|
||||
changeLogType: relevantChanges[relevantChanges.length - 1].type,
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { IViewedPage, ViewedPage } from '@red/domain';
|
||||
import { EntitiesMapService } from '@iqser/common-ui';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ViewedPagesMapService extends EntitiesMapService<IViewedPage, ViewedPage> {
|
||||
protected readonly _primaryKey = 'id';
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { GenericService, RequiredParam, Validate } from '@iqser/common-ui';
|
||||
import { GenericService, mapEach, RequiredParam, Validate } from '@iqser/common-ui';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { IViewedPage, IViewedPagesRequest } from '@red/domain';
|
||||
import { IViewedPage, IViewedPagesRequest, ViewedPage } from '@red/domain';
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
@ -11,22 +11,23 @@ export class ViewedPagesService extends GenericService<unknown> {
|
||||
protected readonly _defaultModelPath = 'viewedPages';
|
||||
|
||||
@Validate()
|
||||
addPage(@RequiredParam() body: IViewedPagesRequest, @RequiredParam() dossierId: string, @RequiredParam() fileId: string) {
|
||||
add(@RequiredParam() body: IViewedPagesRequest, @RequiredParam() dossierId: string, @RequiredParam() fileId: string) {
|
||||
const modelPath = `${this._defaultModelPath}/${dossierId}/${fileId}`;
|
||||
return firstValueFrom(this._post(body, modelPath));
|
||||
}
|
||||
|
||||
@Validate()
|
||||
removePage(@RequiredParam() dossierId: string, @RequiredParam() fileId: string, @RequiredParam() page: number) {
|
||||
remove(@RequiredParam() dossierId: string, @RequiredParam() fileId: string, @RequiredParam() page: number) {
|
||||
const modelPath = `${this._defaultModelPath}/${dossierId}/${fileId}/${page}`;
|
||||
return firstValueFrom(super.delete({}, modelPath));
|
||||
}
|
||||
|
||||
@Validate()
|
||||
getViewedPages(@RequiredParam() dossierId: string, @RequiredParam() fileId: string) {
|
||||
load(@RequiredParam() dossierId: string, @RequiredParam() fileId: string) {
|
||||
const request = this._getOne<{ pages?: IViewedPage[] }>([dossierId, fileId]).pipe(
|
||||
map(res => res.pages),
|
||||
catchError(() => of([] as IViewedPage[])),
|
||||
mapEach(page => new ViewedPage(page)),
|
||||
);
|
||||
|
||||
return firstValueFrom(request);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export * from './page-range';
|
||||
export * from './viewed-page';
|
||||
export * from './viewed-page.model';
|
||||
export * from './viewed-pages.request';
|
||||
export * from './page-exclusion.request';
|
||||
export * from './page-rotation.request';
|
||||
|
||||
24
libs/red-domain/src/lib/pages/viewed-page.model.ts
Normal file
24
libs/red-domain/src/lib/pages/viewed-page.model.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Entity } from '@iqser/common-ui';
|
||||
import { IViewedPage } from '.';
|
||||
|
||||
export class ViewedPage extends Entity<IViewedPage, number> {
|
||||
readonly fileId: string;
|
||||
readonly page: number;
|
||||
readonly userId?: string;
|
||||
readonly viewedTime?: string;
|
||||
|
||||
override readonly routerLink = undefined;
|
||||
override readonly searchKey = '';
|
||||
|
||||
constructor(viewedPage: IViewedPage) {
|
||||
super(viewedPage);
|
||||
this.fileId = viewedPage.fileId;
|
||||
this.page = viewedPage.page;
|
||||
this.userId = viewedPage.userId;
|
||||
this.viewedTime = viewedPage.viewedTime;
|
||||
}
|
||||
|
||||
override get id() {
|
||||
return this.page;
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
export interface IViewedPage {
|
||||
fileId?: string;
|
||||
page?: number;
|
||||
userId?: string;
|
||||
viewedTime?: string;
|
||||
showAsUnseen?: boolean;
|
||||
readonly fileId: string;
|
||||
readonly page: number;
|
||||
readonly userId?: string;
|
||||
readonly viewedTime?: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user