Ensure timeline menu buttons shows up when it should.
This commit is contained in:
@@ -3,7 +3,6 @@ import {
|
|||||||
ActionsConfig,
|
ActionsConfig,
|
||||||
ActionType,
|
ActionType,
|
||||||
FrigateCardCustomAction,
|
FrigateCardCustomAction,
|
||||||
FRIGATE_CARD_VIEW_DEFAULT,
|
|
||||||
} from '../config/types.js';
|
} from '../config/types.js';
|
||||||
import {
|
import {
|
||||||
convertActionToFrigateCardCustomAction,
|
convertActionToFrigateCardCustomAction,
|
||||||
@@ -177,14 +176,10 @@ export class ActionsManager {
|
|||||||
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
|
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
|
||||||
const targetViewName =
|
const targetViewName =
|
||||||
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
|
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
|
||||||
const verifiedViewName = this._api
|
|
||||||
.getViewManager()
|
|
||||||
.isViewSupportedByCamera(selectCameraID, targetViewName)
|
|
||||||
? targetViewName
|
|
||||||
: FRIGATE_CARD_VIEW_DEFAULT;
|
|
||||||
this._api.getViewManager().setViewByParameters({
|
this._api.getViewManager().setViewByParameters({
|
||||||
viewName: verifiedViewName,
|
viewName: targetViewName,
|
||||||
cameraID: selectCameraID,
|
cameraID: selectCameraID,
|
||||||
|
failSafe: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -190,7 +190,17 @@ export class ViewManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
|
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
|
||||||
const capabilities = this._api.getCameraManager().getCameraCapabilities(cameraID);
|
const dependentCamerasCapabilities = this._api
|
||||||
|
.getCameraManager()
|
||||||
|
.getAggregateCameraCapabilities(
|
||||||
|
this._api.getCameraManager().getStore().getAllDependentCameras(cameraID),
|
||||||
|
);
|
||||||
|
const allCamerasCapabilities = this._api
|
||||||
|
.getCameraManager()
|
||||||
|
.getAggregateCameraCapabilities(
|
||||||
|
this._api.getCameraManager().getStore().getCameraIDs(),
|
||||||
|
);
|
||||||
|
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case 'live':
|
case 'live':
|
||||||
case 'image':
|
case 'image':
|
||||||
@@ -198,20 +208,22 @@ export class ViewManager {
|
|||||||
return true;
|
return true;
|
||||||
case 'clip':
|
case 'clip':
|
||||||
case 'clips':
|
case 'clips':
|
||||||
return !!capabilities?.supportsClips;
|
return !!dependentCamerasCapabilities?.supportsClips;
|
||||||
case 'snapshot':
|
case 'snapshot':
|
||||||
case 'snapshots':
|
case 'snapshots':
|
||||||
return !!capabilities?.supportsSnapshots;
|
return !!dependentCamerasCapabilities?.supportsSnapshots;
|
||||||
case 'recording':
|
case 'recording':
|
||||||
case 'recordings':
|
case 'recordings':
|
||||||
return !!capabilities?.supportsRecordings;
|
return !!dependentCamerasCapabilities?.supportsRecordings;
|
||||||
case 'timeline':
|
case 'timeline':
|
||||||
return !!capabilities?.supportsTimeline;
|
// Show the timeline if any camera supports it, even cameras unrelated
|
||||||
|
// to the currently selected camera.
|
||||||
|
return !!allCamerasCapabilities?.supportsTimeline;
|
||||||
case 'media':
|
case 'media':
|
||||||
return (
|
return (
|
||||||
!!capabilities?.supportsClips ||
|
!!dependentCamerasCapabilities?.supportsClips ||
|
||||||
!!capabilities?.supportsSnapshots ||
|
!!dependentCamerasCapabilities?.supportsSnapshots ||
|
||||||
!!capabilities?.supportsRecordings
|
!!dependentCamerasCapabilities?.supportsRecordings
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ class FrigateCard extends LitElement {
|
|||||||
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
|
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
|
||||||
mediaPlayerController: this._controller.getMediaPlayerManager(),
|
mediaPlayerController: this._controller.getMediaPlayerManager(),
|
||||||
microphoneManager: this._controller.getMicrophoneManager(),
|
microphoneManager: this._controller.getMicrophoneManager(),
|
||||||
|
viewManager: this._controller.getViewManager(),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
.entityRegistryManager=${this._controller.getEntityRegistryManager()}
|
.entityRegistryManager=${this._controller.getEntityRegistryManager()}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { StyleInfo } from 'lit/directives/style-map';
|
|||||||
import { CameraManager } from '../camera-manager/manager';
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||||
|
import { ViewManager } from '../card-controller/view-manager';
|
||||||
import {
|
import {
|
||||||
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
FrigateCardCustomAction,
|
FrigateCardCustomAction,
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
} from '../config/types';
|
} from '../config/types';
|
||||||
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
||||||
@@ -31,6 +32,7 @@ export interface MenuButtonControllerOptions {
|
|||||||
inExpandedMode?: boolean;
|
inExpandedMode?: boolean;
|
||||||
microphoneManager?: MicrophoneManager | null;
|
microphoneManager?: MicrophoneManager | null;
|
||||||
mediaPlayerController?: MediaPlayerManager | null;
|
mediaPlayerController?: MediaPlayerManager | null;
|
||||||
|
viewManager?: ViewManager | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MenuButtonController {
|
export class MenuButtonController {
|
||||||
@@ -62,18 +64,19 @@ export class MenuButtonController {
|
|||||||
): MenuItem[] {
|
): MenuItem[] {
|
||||||
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
|
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
|
||||||
const selectedCameraID = view.camera;
|
const selectedCameraID = view.camera;
|
||||||
|
const substreamAwareCameraID =
|
||||||
|
view.context?.live?.overrides?.get(selectedCameraID) ?? selectedCameraID;
|
||||||
const selectedCameraConfig = cameraManager
|
const selectedCameraConfig = cameraManager
|
||||||
.getStore()
|
.getStore()
|
||||||
.getCameraConfig(selectedCameraID);
|
.getCameraConfig(selectedCameraID);
|
||||||
const allSelectedCameraIDs = cameraManager
|
const allSelectedCameraIDs = cameraManager
|
||||||
.getStore()
|
.getStore()
|
||||||
.getAllDependentCameras(selectedCameraID);
|
.getAllDependentCameras(selectedCameraID);
|
||||||
const selectedMedia = view.queryResults?.getSelectedResult();
|
|
||||||
|
|
||||||
const selectedCameraCapabilities =
|
const substreamAwareCameraCapabilities =
|
||||||
cameraManager.getCameraCapabilities(selectedCameraID);
|
cameraManager.getCameraCapabilities(substreamAwareCameraID);
|
||||||
const aggregateCapabilities =
|
|
||||||
cameraManager.getAggregateCameraCapabilities(allSelectedCameraIDs);
|
const selectedMedia = view.queryResults?.getSelectedResult();
|
||||||
const mediaCapabilities = selectedMedia
|
const mediaCapabilities = selectedMedia
|
||||||
? cameraManager?.getMediaCapabilities(selectedMedia)
|
? cameraManager?.getMediaCapabilities(selectedMedia)
|
||||||
: null;
|
: null;
|
||||||
@@ -125,7 +128,6 @@ export class MenuButtonController {
|
|||||||
|
|
||||||
if (selectedCameraID && allSelectedCameraIDs && view.is('live')) {
|
if (selectedCameraID && allSelectedCameraIDs && view.is('live')) {
|
||||||
const dependencies = [...allSelectedCameraIDs];
|
const dependencies = [...allSelectedCameraIDs];
|
||||||
const override = view.context?.live?.overrides?.get(selectedCameraID);
|
|
||||||
|
|
||||||
if (dependencies.length === 2) {
|
if (dependencies.length === 2) {
|
||||||
// If there are only two dependencies (the main camera, and 1 other)
|
// If there are only two dependencies (the main camera, and 1 other)
|
||||||
@@ -133,7 +135,9 @@ export class MenuButtonController {
|
|||||||
buttons.push({
|
buttons.push({
|
||||||
icon: 'mdi:video-input-component',
|
icon: 'mdi:video-input-component',
|
||||||
style:
|
style:
|
||||||
override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
|
substreamAwareCameraID !== selectedCameraID
|
||||||
|
? this._getEmphasizedStyle()
|
||||||
|
: {},
|
||||||
title: localize('config.menu.buttons.substreams'),
|
title: localize('config.menu.buttons.substreams'),
|
||||||
...config.menu.buttons.substreams,
|
...config.menu.buttons.substreams,
|
||||||
type: 'custom:frigate-card-menu-icon',
|
type: 'custom:frigate-card-menu-icon',
|
||||||
@@ -166,7 +170,9 @@ export class MenuButtonController {
|
|||||||
icon: 'mdi:video-input-component',
|
icon: 'mdi:video-input-component',
|
||||||
title: localize('config.menu.buttons.substreams'),
|
title: localize('config.menu.buttons.substreams'),
|
||||||
style:
|
style:
|
||||||
override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
|
substreamAwareCameraID !== selectedCameraID
|
||||||
|
? this._getEmphasizedStyle()
|
||||||
|
: {},
|
||||||
...config.menu.buttons.substreams,
|
...config.menu.buttons.substreams,
|
||||||
type: 'custom:frigate-card-menu-submenu',
|
type: 'custom:frigate-card-menu-submenu',
|
||||||
items: menuItems,
|
items: menuItems,
|
||||||
@@ -183,7 +189,7 @@ export class MenuButtonController {
|
|||||||
tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction,
|
tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (aggregateCapabilities?.supportsClips) {
|
if (options?.viewManager?.isViewSupportedByCamera(selectedCameraID, 'clips')) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
icon: 'mdi:filmstrip',
|
icon: 'mdi:filmstrip',
|
||||||
...config.menu.buttons.clips,
|
...config.menu.buttons.clips,
|
||||||
@@ -195,7 +201,7 @@ export class MenuButtonController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aggregateCapabilities?.supportsSnapshots) {
|
if (options?.viewManager?.isViewSupportedByCamera(selectedCameraID, 'snapshots')) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
icon: 'mdi:camera',
|
icon: 'mdi:camera',
|
||||||
...config.menu.buttons.snapshots,
|
...config.menu.buttons.snapshots,
|
||||||
@@ -211,7 +217,7 @@ export class MenuButtonController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aggregateCapabilities?.supportsRecordings) {
|
if (options?.viewManager?.isViewSupportedByCamera(selectedCameraID, 'recordings')) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
icon: 'mdi:album',
|
icon: 'mdi:album',
|
||||||
...config.menu.buttons.recordings,
|
...config.menu.buttons.recordings,
|
||||||
@@ -236,9 +242,7 @@ export class MenuButtonController {
|
|||||||
tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction,
|
tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't show the timeline button unless there's at least one non-birdseye
|
if (options?.viewManager?.isViewSupportedByCamera(selectedCameraID, 'timeline')) {
|
||||||
// camera with a Frigate camera name.
|
|
||||||
if (aggregateCapabilities?.supportsTimeline) {
|
|
||||||
buttons.push({
|
buttons.push({
|
||||||
icon: 'mdi:chart-gantt',
|
icon: 'mdi:chart-gantt',
|
||||||
...config.menu.buttons.timeline,
|
...config.menu.buttons.timeline,
|
||||||
@@ -419,7 +423,7 @@ export class MenuButtonController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUsablePTZ(selectedCameraCapabilities, config.live.controls.ptz)) {
|
if (hasUsablePTZ(substreamAwareCameraCapabilities, config.live.controls.ptz)) {
|
||||||
const isOn =
|
const isOn =
|
||||||
view.context?.live?.ptzVisible === false
|
view.context?.live?.ptzVisible === false
|
||||||
? false
|
? false
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import {
|
import {
|
||||||
@@ -382,6 +383,7 @@ describe('ActionsManager.executeAction', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
viewName: 'live',
|
viewName: 'live',
|
||||||
cameraID: 'camera',
|
cameraID: 'camera',
|
||||||
|
failSafe: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -409,6 +411,7 @@ describe('ActionsManager.executeAction', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
viewName: 'timeline',
|
viewName: 'timeline',
|
||||||
cameraID: 'camera',
|
cameraID: 'camera',
|
||||||
|
failSafe: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -442,6 +445,7 @@ describe('ActionsManager.executeAction', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
viewName: 'clips',
|
viewName: 'clips',
|
||||||
cameraID: 'camera',
|
cameraID: 'camera',
|
||||||
|
failSafe: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -467,6 +471,7 @@ describe('ActionsManager.executeAction', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
viewName: 'live',
|
viewName: 'live',
|
||||||
cameraID: 'camera',
|
cameraID: 'camera',
|
||||||
|
failSafe: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -503,32 +508,6 @@ describe('ActionsManager.executeAction', () => {
|
|||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with an unsupported view', async () => {
|
|
||||||
const api = createCardAPI();
|
|
||||||
const manager = new ActionsManager(api);
|
|
||||||
|
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
|
||||||
createView({
|
|
||||||
view: 'timeline',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await manager.executeFrigateAction(
|
|
||||||
createAction({
|
|
||||||
frigate_card_action: 'camera_select',
|
|
||||||
camera: 'camera',
|
|
||||||
})!,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
// Should have fallen back to the default view.
|
|
||||||
viewName: 'live',
|
|
||||||
cameraID: 'camera',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle live_substream_select action', async () => {
|
it('should handle live_substream_select action', async () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { EventMediaQueries } from '../../src/view/media-queries';
|
|||||||
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||||
import { View } from '../../src/view/view';
|
import { View } from '../../src/view/view';
|
||||||
import {
|
import {
|
||||||
|
createAggregateCameraCapabilities,
|
||||||
createCameraCapabilities,
|
createCameraCapabilities,
|
||||||
createCameraConfig,
|
createCameraConfig,
|
||||||
createCameraManager,
|
createCameraManager,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
createHASS,
|
createHASS,
|
||||||
createStore,
|
createStore,
|
||||||
createView,
|
createView,
|
||||||
generateViewMediaArray
|
generateViewMediaArray,
|
||||||
} from '../test-utils';
|
} from '../test-utils';
|
||||||
|
|
||||||
vi.mock('../../src/utils/media-to-view');
|
vi.mock('../../src/utils/media-to-view');
|
||||||
@@ -218,8 +219,8 @@ describe('ViewManager.setViewByParameters', () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue(
|
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||||
createCameraCapabilities({
|
createAggregateCameraCapabilities({
|
||||||
supportsClips: true,
|
supportsClips: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -247,8 +248,8 @@ describe('ViewManager.setViewByParameters', () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue(
|
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||||
createCameraCapabilities({
|
createAggregateCameraCapabilities({
|
||||||
supportsClips: true,
|
supportsClips: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -336,10 +337,12 @@ describe('ViewManager.setViewByParameters', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||||
vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue(createCameraCapabilities({
|
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||||
supportsSnapshots: false,
|
createAggregateCameraCapabilities({
|
||||||
}));
|
supportsSnapshots: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const manager = new ViewManager(api);
|
const manager = new ViewManager(api);
|
||||||
manager.setViewByParameters({
|
manager.setViewByParameters({
|
||||||
viewName: 'snapshots',
|
viewName: 'snapshots',
|
||||||
@@ -360,16 +363,18 @@ describe('ViewManager.setViewByParameters', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||||
vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue(createCameraCapabilities({
|
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||||
supportsSnapshots: false,
|
createAggregateCameraCapabilities({
|
||||||
}));
|
supportsSnapshots: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const manager = new ViewManager(api);
|
const manager = new ViewManager(api);
|
||||||
manager.setViewByParameters({
|
manager.setViewByParameters({
|
||||||
viewName: 'snapshots',
|
viewName: 'snapshots',
|
||||||
failSafe: true,
|
failSafe: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(manager.hasView()).toBeTruthy();
|
expect(manager.hasView()).toBeTruthy();
|
||||||
expect(manager.getView()?.view).toBe('live');
|
expect(manager.getView()?.view).toBe('live');
|
||||||
});
|
});
|
||||||
@@ -392,8 +397,8 @@ describe('ViewManager.setViewByParameters', () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue(
|
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||||
createCameraCapabilities({
|
createAggregateCameraCapabilities({
|
||||||
supportsClips: true,
|
supportsClips: true,
|
||||||
supportsRecordings: true,
|
supportsRecordings: true,
|
||||||
supportsSnapshots: true,
|
supportsSnapshots: true,
|
||||||
@@ -443,8 +448,8 @@ describe('ViewManager.setViewByParameters', () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue(
|
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||||
createCameraCapabilities({
|
createAggregateCameraCapabilities({
|
||||||
supportsClips: true,
|
supportsClips: true,
|
||||||
supportsRecordings: true,
|
supportsRecordings: true,
|
||||||
supportsSnapshots: true,
|
supportsSnapshots: true,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user